Files
lda-wf/examples/agent_challenges/evaluation.py
T

329 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import hashlib
import json
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .names import short_challenge_name, short_model_name
FIGURE_STEMS = (
"agent-challenge-audited-outcomes-by-cell",
"agent-challenge-automatic-vs-manual-outcomes",
"agent-challenge-longitudinal-outcomes",
"agent-challenge-duration",
"agent-challenge-token-volume",
)
_MANUAL_OUTCOMES = frozenset({"pass", "invalid", "fail"})
_TASK_OUTCOMES = frozenset({"success", "failed", "timeout", "runner_error"})
_EVALUATION_PROFILES = frozenset({"none", "skills", "all"})
_CHALLENGE_ORDER = {"browser": 0, "report": 1}
_MODEL_ORDER = {"deepseek": 0, "mimo": 1}
_PROFILE_ORDER = {"none": 0, "skills": 1, "all": 2}
@dataclass(frozen=True, slots=True)
class EvaluationTrial:
"""One manually audited trial selected into an evaluation cohort."""
report_path: Path
wave: int
challenge: str
model: str
profile: str
trial_index: int
repository_commit: str
base_prompt_hash: str
manual_outcome: str
task_outcome: str
duration_seconds: float
tokens_total: int
audit_notes: str
@dataclass(frozen=True, slots=True)
class EvaluationCohort:
"""Immutable cohort metadata and its validated report projections."""
cohort_id: str
title: str
selection_rule: str
limitations: tuple[str, ...]
trials: tuple[EvaluationTrial, ...]
def _object(value: object, *, field: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValueError(f"{field} must be an object")
return value
def _string(value: object, *, field: str) -> str:
if not isinstance(value, str) or not value:
raise ValueError(f"{field} must be a non-empty string")
return value
def _integer(value: object, *, field: str) -> int:
if not isinstance(value, int):
raise ValueError(f"{field} must be an integer")
return value
def _report_path(repository_root: Path, value: object) -> Path:
relative = Path(_string(value, field="runs[].report"))
if relative.is_absolute():
raise ValueError("runs[].report must be relative to the repository root")
resolved = (repository_root / relative).resolve()
if not resolved.is_relative_to(repository_root.resolve()):
raise ValueError(f"report path escapes repository root: {relative}")
return resolved
def _report_sha256(value: object, *, report_path: Path) -> str:
"""Validate snapshot provenance and, when available, its local report."""
expected = _string(value, field="runs[].report_sha256")
if len(expected) != 64 or any(
character not in "0123456789abcdef" for character in expected
):
raise ValueError(f"invalid report SHA-256 for {report_path}")
if report_path.is_file():
actual = hashlib.sha256(report_path.read_bytes()).hexdigest()
if actual != expected:
raise ValueError(
f"local report does not match cohort snapshot: {report_path}"
)
return expected
def _load_trial(
run: dict[str, Any], report_path: Path, *, wave: int
) -> EvaluationTrial:
_report_sha256(run.get("report_sha256"), report_path=report_path)
manual_outcome = _string(run.get("manual_outcome"), field="runs[].manual_outcome")
if manual_outcome not in _MANUAL_OUTCOMES:
raise ValueError(
f"unsupported manual outcome {manual_outcome!r}: {report_path}"
)
profile = _string(run.get("profile"), field="runs[].profile")
if profile not in _EVALUATION_PROFILES:
raise ValueError(f"unsupported evaluation profile {profile!r}: {report_path}")
task_outcome = _string(run.get("task_outcome"), field="runs[].task_outcome")
if task_outcome not in _TASK_OUTCOMES:
raise ValueError(f"unsupported task outcome {task_outcome!r}: {report_path}")
raw_audit_notes = run.get("audit_notes")
if raw_audit_notes is None:
audit_notes = ""
elif isinstance(raw_audit_notes, str):
audit_notes = raw_audit_notes
else:
raise ValueError(f"runs[].audit_notes must be a string: {report_path}")
duration = run.get("duration_seconds")
if not isinstance(duration, int | float):
raise ValueError(f"runs[].duration_seconds must be numeric: {report_path}")
return EvaluationTrial(
report_path=report_path,
wave=wave,
challenge=short_challenge_name(
_string(run.get("challenge"), field="runs[].challenge")
),
model=short_model_name(_string(run.get("model"), field="runs[].model")),
profile=profile,
trial_index=_integer(run.get("trial_index"), field="runs[].trial_index"),
repository_commit=_string(
run.get("repository_commit"), field="runs[].repository_commit"
),
base_prompt_hash=_string(
run.get("base_prompt_hash"), field="runs[].base_prompt_hash"
),
manual_outcome=manual_outcome,
task_outcome=task_outcome,
duration_seconds=float(duration),
tokens_total=_integer(run.get("tokens_total"), field="runs[].tokens_total"),
audit_notes=audit_notes,
)
def load_evaluation_cohort(
manifest_path: Path, *, repository_root: Path
) -> EvaluationCohort:
"""Load an explicit cohort manifest and validate every report projection."""
manifest = _object(
json.loads(manifest_path.read_text(encoding="utf-8")), field=str(manifest_path)
)
if manifest.get("schema_version") != 1:
raise ValueError("agent challenge cohort schema_version must be 1")
raw_runs = manifest.get("runs")
if not isinstance(raw_runs, list) or not raw_runs:
raise ValueError("agent challenge cohort runs must be a non-empty list")
trials: list[EvaluationTrial] = []
seen: set[Path] = set()
for index, raw_run in enumerate(raw_runs):
run = _object(raw_run, field=f"runs[{index}]")
wave = _integer(run.get("wave"), field=f"runs[{index}].wave")
if wave < 1:
raise ValueError(f"runs[{index}].wave must be positive")
report_path = _report_path(repository_root, run.get("report"))
if report_path in seen:
raise ValueError(f"duplicate report in cohort: {report_path}")
seen.add(report_path)
trials.append(_load_trial(run, report_path, wave=wave))
limitations = manifest.get("limitations")
if not isinstance(limitations, list) or not all(
isinstance(item, str) and item for item in limitations
):
raise ValueError("limitations must be a list of non-empty strings")
return EvaluationCohort(
cohort_id=_string(manifest.get("cohort_id"), field="cohort_id"),
title=_string(manifest.get("title"), field="title"),
selection_rule=_string(manifest.get("selection_rule"), field="selection_rule"),
limitations=tuple(limitations),
trials=tuple(trials),
)
def render_evaluation_figures(
cohort: EvaluationCohort, output_dir: Path
) -> tuple[Path, ...]:
"""Render the cohort through the optional Matplotlib figure layer."""
# Keeping plotting imports out of this data module lets summary tooling run
# in minimal environments that do not install thesis build dependencies.
from .evaluation_figures import render_evaluation_figures as render
return render(cohort, output_dir)
def _cell_rows(cohort: EvaluationCohort) -> list[tuple[str, Counter[str]]]:
grouped: dict[tuple[str, str, str], Counter[str]] = {}
for trial in cohort.trials:
key = (trial.challenge, trial.model, trial.profile)
grouped.setdefault(key, Counter())[trial.manual_outcome] += 1
keys = sorted(
grouped,
key=lambda key: (
_CHALLENGE_ORDER.get(key[0], 99),
_MODEL_ORDER.get(key[1], 99),
_PROFILE_ORDER.get(key[2], 99),
),
)
return [
(f"{challenge} / {model} / {profile}", grouped[(challenge, model, profile)])
for challenge, model, profile in keys
]
def render_evaluation_markdown(cohort: EvaluationCohort) -> str:
"""Render the checked cohort as a compact, auditable Markdown appendix."""
outcomes = Counter(trial.manual_outcome for trial in cohort.trials)
lines = [
"## Audited Agent Challenge Campaign",
"",
(
f"The primary campaign contains {len(cohort.trials)} manually audited "
f"trials: {outcomes['pass']} clean product-path passes under the "
f"campaign rules, {outcomes['invalid']} invalid evaluation samples, "
f"and {outcomes['fail']} failure. These counts are not a "
f"model-success-rate estimate."
),
"",
(
"The campaign crosses two challenges × two hosted models × three "
"instruction profiles (`none`, `skills`, and `all`) = 12 cells, with "
"three repetitions per cell (n=3). The checked cohort snapshot records "
"report hashes, prompt hashes, the repository commit, automatic metrics, "
"and manual-audit outcomes; local raw report files are verified against "
"those hashes when present."
),
"",
(
"Because repository snapshots and one prompt rule changed between waves, "
"this is longitudinal engineering evidence, not a controlled model comparison."
),
"",
"> **Campaign validity note.** This campaign is a bounded longitudinal audit, "
"not a controlled comparison. Each cell has n=3; waves changed product and "
"prompt snapshots; all audits were performed by the author.",
"",
f"Selection rule: {cohort.selection_rule}",
"",
"| Challenge / model / profile | Pass | Invalid | Fail |",
"| --- | ---: | ---: | ---: |",
]
for label, counts in _cell_rows(cohort):
lines.append(
f"| {label} | {counts['pass']} | {counts['invalid']} | {counts['fail']} |"
)
lines.extend(
[
"",
": Audited outcomes by challenge, model, and instruction profile. {#tbl:agent-challenge-outcomes}",
"",
(
"A manual `pass` requires both successful product-path evidence and an "
"acceptable audit trail. It does not imply the agent avoided every "
"exploratory read, only that no disqualifying read or bypass was found. "
"`Invalid` means the sample cannot support the clean benchmark claim, "
"commonly because the agent read repository or example material outside "
"its supplied workspace. `Fail` means the challenge contract itself was "
"not established."
),
"",
"![Audited outcomes by evaluation cell.](figures/agent-challenge-audited-outcomes-by-cell.svg){#fig:agent-challenge-audited-outcomes-by-cell width=95%}",
"",
(
"[@fig:agent-challenge-audited-outcomes-by-cell] reports all three "
"repetitions rather than hiding invalid samples. The profile labels are "
"descriptive; this campaign does not isolate instruction-profile effects. "
"Profile × wave is confounded because the base prompt changed before "
"wave 3, so apparent differences may reflect prompt changes, model "
"updates, or repository drift rather than instruction-layer effects."
),
"",
"![Automatic task outcomes compared with manual outcomes.](figures/agent-challenge-automatic-vs-manual-outcomes.svg){#fig:agent-challenge-automatic-vs-manual-outcomes width=75%}",
"",
(
"[@fig:agent-challenge-automatic-vs-manual-outcomes] shows why the "
"manual layer matters. Seven automatically successful trials were invalid "
"as clean evidence, while three automatically failed reports were accepted "
"after their saved run evidence and report artifacts were manually audited."
),
"",
"![Audited outcomes across the three longitudinal waves.](figures/agent-challenge-longitudinal-outcomes.svg){#fig:agent-challenge-longitudinal-outcomes width=75%}",
"",
(
"The waves in [@fig:agent-challenge-longitudinal-outcomes] are not "
"an improvement curve: product commits, prompt wording, and enforcement "
"changed. They preserve the chronology needed to study those changes."
),
"",
"![Wall-clock duration grouped by challenge, instruction profile, model, and wave.](figures/agent-challenge-duration.svg){#fig:agent-challenge-duration width=78%}",
"",
(
"[@fig:agent-challenge-duration] separates the two challenges. Circle "
"and square markers redundantly identify the models without relying on "
"color. Wall-clock duration includes hosted-service latency and is not a "
"normalized model-efficiency metric."
),
"",
"![Recorded token totals grouped by challenge, instruction profile, model, and wave.](figures/agent-challenge-token-volume.svg){#fig:agent-challenge-token-volume width=78%}",
"",
(
"[@fig:agent-challenge-token-volume] reports OpenCode token totals, which "
"include cache-read accounting. The figure records observed workload volume; "
"it is not an efficiency comparison."
),
"",
"### Campaign Limitations",
"",
]
)
lines.extend(f"- {limitation}" for limitation in cohort.limitations)
return "\n".join(lines) + "\n"