iop/scripts/agent_benchmark/reporting.py
toki 029ff0d2c8 feat(benchmark): 비교 파이프라인을 완성한다
동일한 IOP 경유 과업을 caller와 model 설정만 바꿔 재현하고, 실패를 포함한 실행·검증·채점 근거를 보존할 수 있어야 한다.
2026-08-12 01:44:26 +09:00

541 lines
23 KiB
Python

"""Deterministic, fail-closed Markdown reporting for benchmark evidence.
This module is intentionally a reader of the immutable run tree. It does not
retry execution or scoring, derive values that producers did not record, or
write anything except the idempotent ``report.md`` artifact after every input
has passed its owning strict loader.
"""
from __future__ import annotations
import json
import os
import stat
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from scripts.agent_benchmark import scoring as _scoring
from scripts.agent_benchmark.attempts import (
Attempt,
AttemptStateError,
RunIdentity,
RunStore,
_connectivity_result_from_payload,
)
from scripts.agent_benchmark.manifest import Manifest, MatrixCell
from scripts.agent_benchmark.measurement import (
METRIC_NAMES,
TIMELINE_NAMES,
AttemptMeasurement,
MeasurementError,
Observation,
load_measurement,
)
from scripts.agent_benchmark.web_validation import (
WebValidation,
WebValidationError,
load_web_validation,
)
from scripts.agent_benchmark.rubric import validate_worksheet
REPORT_FILENAME = "report.md"
_MAX_REPORT_BYTES = 2 * 1024 * 1024
class ReportError(Exception):
"""The immutable evidence cannot safely be projected into a report."""
@dataclass(frozen=True)
class CategoryProjection:
id: str
score: int
max_score: int
@dataclass(frozen=True)
class ScoreProjection:
status: str
categories: tuple[CategoryProjection, ...]
total: int | None
rank: int | None
reasons: tuple[str, ...]
score_id: str | None
evaluator: tuple[str, str, str, str] | None
raw_paths: tuple[str, ...]
@dataclass(frozen=True)
class AttemptProjection:
cell: MatrixCell
attempt: Attempt
measurement: AttemptMeasurement | None
web: WebValidation | None
score: ScoreProjection
raw_paths: tuple[str, ...]
@dataclass(frozen=True)
class ReportProjection:
run: RunIdentity
manifest: Manifest
preflights: tuple[dict[str, Any], ...]
attempts: tuple[AttemptProjection, ...]
def _markdown(value: object) -> str:
"""Escape one table cell without allowing a value to alter Markdown shape."""
return str(value).replace("\\", "\\\\").replace("|", "\\|").replace(
"\r", " ").replace("\n", " ")
def _relative_path(value: str) -> str:
if not isinstance(value, str) or not value or "\\" in value or ":" in value:
raise ReportError("report evidence path is invalid")
path = PurePosixPath(value)
if path.is_absolute() or str(path) != value or any(
part in ("", ".", "..") for part in path.parts
):
raise ReportError("report evidence path is invalid")
return value
def _regular_under(root: Path, relative: str) -> None:
"""Require a contained regular file and reject every symlink component."""
relative = _relative_path(relative)
current = root
try:
root_info = os.lstat(root)
except OSError as exc:
raise ReportError("report run root is unavailable") from exc
if not stat.S_ISDIR(root_info.st_mode) or stat.S_ISLNK(root_info.st_mode):
raise ReportError("report run root is invalid")
for component in PurePosixPath(relative).parts:
current = current / component
try:
info = os.lstat(current)
except OSError as exc:
raise ReportError("report evidence is unavailable") from exc
if stat.S_ISLNK(info.st_mode):
raise ReportError("report evidence path is invalid")
if not stat.S_ISREG(info.st_mode):
raise ReportError("report evidence must be a regular file")
try:
current.resolve().relative_to(root.resolve())
except ValueError as exc:
raise ReportError("report evidence escapes run root") from exc
def _raw_link(path: str) -> str:
path = _relative_path(path)
return f"[raw](<{path.replace('>', '%3E')}>)"
def _attempt_label(item: AttemptProjection) -> str:
"""Render the complete durable identity used by detailed report rows."""
identity = item.attempt.identity
return f"{item.cell.id}/r{identity.repetition}/a{identity.attempt}"
def _observation(value: Observation) -> str:
if value.status == "observed":
return (
f"{value.value} {value.unit}; clock={value.clock}; "
f"source={value.source}"
)
return f"unavailable; reason={value.reason}; source={value.source}"
def _observation_group(values: dict[str, Observation]) -> str:
"""Keep every named value while coalescing identical unavailable causes."""
groups: dict[tuple[str, str], list[str]] = {}
rendered: list[str] = []
for name, value in values.items():
if value.status == "observed":
rendered.append(f"{name}={_observation(value)}")
else:
groups.setdefault((value.reason, value.source), []).append(name)
for (reason, source), names in groups.items():
rendered.append(
f"{','.join(names)}=unavailable; reason={reason}; source={source}"
)
return "; ".join(rendered)
def _read_canonical_json(path: Path, label: str) -> dict[str, Any]:
"""Use scoring's no-follow reader and reject non-canonical JSON records."""
try:
value = _scoring._load_canonical(path, label)
except Exception as exc:
raise ReportError("report scoring evidence is unavailable") from exc
if not isinstance(value, dict):
raise ReportError("report scoring evidence is invalid")
return value
def _score_projection(
run: RunIdentity, manifest: Manifest, attempt: Attempt, *, blocked: bool
) -> ScoreProjection:
"""Strictly load the terminal score state without allocating or recovering."""
root = Path(attempt.root)
raw_paths: list[str] = []
try:
score_root = _scoring._score_root(attempt, create=False)
score_dirs = _scoring._score_dirs(score_root)
is_unscored = _scoring._validate_unscored(run, manifest, attempt)
if is_unscored:
if score_dirs:
raise ReportError("report scoring state is invalid")
raw_paths.append(
f"cells/{attempt.identity.cell_id}/repetition-"
f"{attempt.identity.repetition:04d}/attempt-"
f"{attempt.identity.attempt:06d}/scoring/unscored.json"
)
record = _read_canonical_json(root / "scoring" / "unscored.json", "unscored evidence")
reasons = record.get("reasons")
if not isinstance(reasons, list) or not all(isinstance(item, str) for item in reasons):
raise ReportError("report unscored evidence is invalid")
return ScoreProjection(
"unscored", (), None, None, tuple(reasons), None, None,
tuple(raw_paths),
)
if not score_dirs:
return ScoreProjection(
"blocked" if blocked else "unavailable", (), None, None,
("evaluator_preflight_blocked",) if blocked else ("not_recorded",),
None, None, (),
)
statuses: list[str] = []
allocations: list[dict[str, Any]] = []
for score_root in score_dirs:
status = _scoring._result_status(score_root, run, manifest, attempt)
if status is None:
raise ReportError("report scoring result is incomplete")
statuses.append(status)
allocations.append(
_scoring._validate_allocation(
score_root / "allocation.json", run, manifest, attempt, score_root.name
)
)
prefix = (
f"cells/{attempt.identity.cell_id}/repetition-"
f"{attempt.identity.repetition:04d}/attempt-"
f"{attempt.identity.attempt:06d}/scoring/{score_root.name}"
)
raw_paths.extend((f"{prefix}/allocation.json", f"{prefix}/result.json"))
if "scored" in statuses:
if statuses[-1] != "scored" or statuses.count("scored") != 1:
raise ReportError("report scoring state is invalid")
score_root = score_dirs[-1]
result = _read_canonical_json(score_root / "result.json", "scoring result")
try:
worksheet = validate_worksheet(result.get("worksheet"))
except Exception as exc:
raise ReportError("report worksheet is invalid") from exc
categories = tuple(
CategoryProjection(item.id, item.score, item.max_score)
for item in worksheet.categories
)
if not categories:
raise ReportError("report worksheet is invalid")
evaluator = allocations[-1]["evaluator"]
binding = (
evaluator["caller"], evaluator["route_id"],
evaluator["request_model"], evaluator["requested_effort"],
)
return ScoreProjection(
"scored", categories, worksheet.total, None, (), score_root.name,
binding, tuple(raw_paths),
)
if any(status != "scoring_failed" for status in statuses):
raise ReportError("report scoring state is invalid")
result = _read_canonical_json(score_dirs[-1] / "result.json", "scoring result")
reason = result.get("reason")
if not isinstance(reason, str) or not reason:
raise ReportError("report scoring failure is invalid")
evaluator = allocations[-1]["evaluator"]
binding = (
evaluator["caller"], evaluator["route_id"],
evaluator["request_model"], evaluator["requested_effort"],
)
return ScoreProjection(
"scoring_failed", (), None, None, (reason,), score_dirs[-1].name,
binding, tuple(raw_paths),
)
except ReportError:
raise
except Exception as exc:
raise ReportError("report scoring evidence is invalid") from exc
def _scoring_preflight_blocked(run: RunIdentity, manifest: Manifest) -> bool:
"""Validate evaluator preflights and report whether the latest one blocked."""
root = Path(run.root) / "scoring-preflight"
if not root.exists() and not root.is_symlink():
return False
try:
if root.is_symlink() or not root.is_dir():
raise ReportError("report scoring preflight is invalid")
evaluator = _scoring._evaluator_cell(manifest)
statuses: list[str] = []
for expected, path in enumerate(sorted(root.iterdir()), start=1):
if path.name != f"preflight-{expected:06d}.json":
raise ReportError("report scoring preflight sequence is invalid")
payload = _read_canonical_json(path, "evaluator preflight")
result, _, _ = _connectivity_result_from_payload(payload, evaluator)
statuses.append(result.status)
return bool(statuses and statuses[-1] != "ready")
except ReportError:
raise
except Exception as exc:
raise ReportError("report scoring preflight is invalid") from exc
def _rank(attempts: list[AttemptProjection]) -> tuple[AttemptProjection, ...]:
"""Assign competition ranks without using display order as a tie-breaker."""
scored = sorted(
(item for item in attempts if item.score.status == "scored"),
key=lambda item: -int(item.score.total),
)
ranks: dict[tuple[str, int, int], int] = {}
previous: int | None = None
for index, item in enumerate(scored, start=1):
total = int(item.score.total)
if total != previous:
rank = index
previous = total
ranks[(item.attempt.identity.cell_id, item.attempt.identity.repetition, item.attempt.identity.attempt)] = rank
result: list[AttemptProjection] = []
for item in attempts:
key = (item.attempt.identity.cell_id, item.attempt.identity.repetition, item.attempt.identity.attempt)
score = item.score
result.append(AttemptProjection(
item.cell, item.attempt, item.measurement, item.web,
ScoreProjection(
score.status, score.categories, score.total, ranks.get(key),
score.reasons, score.score_id, score.evaluator, score.raw_paths,
),
item.raw_paths,
))
return tuple(result)
def project_report(store: RunStore, run: RunIdentity, manifest: Manifest) -> ReportProjection:
"""Read every report input in stable manifest/slot/attempt order."""
try:
bound = store.open(manifest, run.run_id)
if bound != run:
raise ReportError("report run identity is invalid")
preflights = store.preflights(bound, manifest)
blocked = _scoring_preflight_blocked(bound, manifest)
cells = {cell.id: cell for cell in manifest.matrix}
rows: list[AttemptProjection] = []
for attempt in store.execution_attempts(bound, manifest):
cell = cells.get(attempt.identity.cell_id)
if cell is None:
raise ReportError("report attempt cell is invalid")
measurement = None
web = None
raw_paths = [
f"cells/{attempt.identity.cell_id}/repetition-"
f"{attempt.identity.repetition:04d}/attempt-"
f"{attempt.identity.attempt:06d}/attempt.json",
]
if attempt.state != "running":
measurement = load_measurement(attempt.root)
if (
measurement.run_id != bound.run_id
or measurement.cell_id != attempt.identity.cell_id
or measurement.repetition != attempt.identity.repetition
or measurement.attempt != attempt.identity.attempt
or measurement.caller != cell.caller
):
raise ReportError("report measurement identity is invalid")
web = load_web_validation(attempt.root, manifest=manifest)
if web.record["attempt"] != {
"run_id": bound.run_id, "cell_id": attempt.identity.cell_id,
"repetition": attempt.identity.repetition, "attempt": attempt.identity.attempt,
}:
raise ReportError("report web evidence identity is invalid")
raw_paths.extend((
f"cells/{attempt.identity.cell_id}/repetition-{attempt.identity.repetition:04d}/attempt-{attempt.identity.attempt:06d}/attempt-measurement.json",
f"cells/{attempt.identity.cell_id}/repetition-{attempt.identity.repetition:04d}/attempt-{attempt.identity.attempt:06d}/web-validation.json",
))
score = _score_projection(bound, manifest, attempt, blocked=blocked)
rows.append(AttemptProjection(cell, attempt, measurement, web, score, tuple(raw_paths)))
return ReportProjection(bound, manifest, preflights, _rank(rows))
except ReportError:
raise
except (AttemptStateError, MeasurementError, WebValidationError) as exc:
raise ReportError("report evidence is invalid") from exc
except Exception as exc:
raise ReportError("report evidence is unavailable") from exc
def render_report(projection: ReportProjection) -> bytes:
"""Render one fixed-order UTF-8/LF Markdown projection."""
root = Path(projection.run.root)
raw_paths = {"manifest.json", "run.json"}
scoring_preflight = root / "scoring-preflight"
if scoring_preflight.exists() or scoring_preflight.is_symlink():
# project_report already performed the schema validation; retain the
# exact immutable preflight bytes as the blocked/ready score pointer.
for expected, path in enumerate(sorted(scoring_preflight.iterdir()), start=1):
if path.name != f"preflight-{expected:06d}.json":
raise ReportError("report scoring preflight sequence is invalid")
raw_paths.add(f"scoring-preflight/{path.name}")
lines = [
"# Agent comparison benchmark report",
"",
"## Run identity",
"",
"| field | value |",
"|---|---|",
f"| run_id | {_markdown(projection.run.run_id)} |",
f"| manifest_digest | {_markdown(projection.run.manifest_digest)} |",
f"| pipeline_version | {_markdown(projection.manifest.pipeline_version)} |",
"",
"## Immutable conditions",
"",
"| field | value |",
"|---|---|",
f"| environment | {_markdown(projection.manifest.environment)} |",
f"| fixture | {_markdown(projection.manifest.fixture.version)} ({_markdown(projection.manifest.fixture.checksum)}) |",
f"| rubric | {_markdown(projection.manifest.rubric_version)} |",
f"| session_policy | {_markdown(projection.manifest.session_policy)} |",
f"| setup_cache_policy | {_markdown(projection.manifest.setup_cache_policy)} |",
f"| evaluator | {_markdown(projection.manifest.evaluator.caller)}/{_markdown(projection.manifest.evaluator.iop.request_model)}/{_markdown(projection.manifest.evaluator.iop.requested_effort)} |",
"",
"## Execution preflight",
"",
"| sequence | status | results |",
"|---:|---|---:|",
]
if projection.preflights:
for item in projection.preflights:
path = f"preflight/preflight-{item['sequence']:06d}.json"
raw_paths.add(path)
lines.append(f"| {item['sequence']} | {_markdown(item['status'])} | {len(item['results'])} |")
else:
lines.append("| — | unavailable | 0 |")
lines.extend(("", "## Attempt outcomes", "", "| cell | repetition | attempt | execution | terminal | web | scoring | total | rank |", "|---|---:|---:|---|---|---|---|---:|---:|"))
if not projection.attempts:
lines.append("| — | — | — | blocked | unavailable | unavailable | unavailable | — | — |")
for item in projection.attempts:
terminal = "unavailable" if item.measurement is None else item.measurement.terminal_reason
web = "unavailable" if item.web is None else item.web.status
total = "" if item.score.total is None else str(item.score.total)
rank = "" if item.score.rank is None else str(item.score.rank)
lines.append(
f"| {_markdown(item.cell.id)} | {item.attempt.identity.repetition} | {item.attempt.identity.attempt} | "
f"{_markdown(item.attempt.state)} | {_markdown(terminal)} | {_markdown(web)} | "
f"{_markdown(item.score.status)} | {total} | {rank} |"
)
raw_paths.update(item.raw_paths)
raw_paths.update(item.score.raw_paths)
lines.extend((
"", "## Quality score breakdown", "",
"| cell/repetition/attempt | category | score | max |",
"|---|---|---:|---:|",
))
wrote_categories = False
for item in projection.attempts:
label = _attempt_label(item)
for category in item.score.categories:
lines.append(
f"| {_markdown(label)} | {_markdown(category.id)} | "
f"{category.score} | {category.max_score} |"
)
wrote_categories = True
if not wrote_categories:
lines.append("| — | unavailable | — | — |")
lines.extend(("", "## Timing and token evidence", "", "| cell/repetition/attempt | time observations | token observations |", "|---|---|---|"))
wrote_metrics = False
for item in projection.attempts:
if item.measurement is None:
continue
label = _attempt_label(item)
timeline = _observation_group(item.measurement.timeline)
usage = _observation_group(item.measurement.usage)
lines.append(f"| {_markdown(label)} | {_markdown(timeline)} | {_markdown(usage)} |")
wrote_metrics = True
if not wrote_metrics:
lines.append("| — | unavailable; reason=not_recorded | unavailable; reason=not_recorded |")
lines.extend(("", "## Web validation and scoring provenance", "", "| cell/repetition/attempt | web gates | screenshots | score_id | evaluator | scoring condition |", "|---|---|---|---|---|---|"))
if not projection.attempts:
lines.append("| — | unavailable | unavailable | — | — | blocked |")
for item in projection.attempts:
gates, shots = "unavailable", "unavailable"
if item.web is not None:
gates = ", ".join(
f"{gate['id']}={'pass' if gate['passed'] else 'fail'}"
for gate in item.web.record["gates"]
)
shots = ", ".join(
str(view["screenshot"]["file"])
for view in item.web.record["viewports"]
) or "unavailable"
evaluator = "unavailable" if item.score.evaluator is None else "/".join(item.score.evaluator)
condition = ", ".join(item.score.reasons) or "recorded"
lines.append(
f"| {_markdown(_attempt_label(item))} | {_markdown(gates)} | {_markdown(shots)} | "
f"{_markdown(item.score.score_id or '')} | {_markdown(evaluator)} | {_markdown(condition)} |"
)
lines.extend(("", "## Limitations", "", "- Values marked `unavailable` retain the producing source and reason; they are not inferred as zero.", "- Automatic web gates establish eligibility only and contribute no quality points.", "- Equal scored totals share a competition rank; unscored and scoring-failed attempts do not receive a rank.", "", "## Raw evidence index", "", "| contained pointer |", "|---|"))
for path in sorted(raw_paths):
_regular_under(root, path)
lines.append(f"| {_raw_link(path)} |")
lines.append("")
return "\n".join(lines).encode("utf-8")
def publish_report(store: RunStore, run: RunIdentity, manifest: Manifest) -> Path:
"""Render and idempotently publish the sole run-owned report artifact."""
data = render_report(project_report(store, run, manifest))
if len(data) > _MAX_REPORT_BYTES:
raise ReportError("report exceeds the bounded artifact size")
root = Path(run.root)
path = root / REPORT_FILENAME
try:
info = os.lstat(path)
except FileNotFoundError:
info = None
except OSError as exc:
raise ReportError("report publication is unavailable") from exc
if info is not None:
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
raise ReportError("report publication target is invalid")
try:
existing = path.read_bytes()
except OSError as exc:
raise ReportError("report publication is unavailable") from exc
if existing != data:
raise ReportError("report publication refused an existing target")
return path
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(path, flags, 0o600)
with os.fdopen(descriptor, "wb") as handle:
handle.write(data)
handle.flush()
os.fsync(handle.fileno())
directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
try:
os.fsync(directory)
finally:
os.close(directory)
except OSError as exc:
raise ReportError("report publication failed") from exc
return path