| """Report-readiness eval runner. |
| |
| Feeds each golden case in `readiness_dataset.json` to the deterministic |
| `is_report_ready` signal (`src/agents/report/readiness.py`) via injectable FAKE |
| stores — no LLM, no DB — then scores both the boolean `ready` and the `missing` |
| gaps. Prints a per-case detail table + aggregate summary and writes a timestamped |
| JSON report under `results/` (never overwritten — one file per run, diffable). |
| |
| Two metrics matter: |
| - FLOOR correctness (ready + missing exact) — should be ~100%; this is the |
| regression guard as the criteria evolve. |
| - ALIGNMENT GAP — cases the floor calls ready=true but whose analyses are NOT |
| aligned to the problem statement (`aligned=false`). The floor can't see this; |
| the gap count is the evidence for/against adding the deferred LLM-judge. |
| |
| Invoke as a module so `src` imports resolve: |
| |
| uv run python -m eval.readiness.run_eval |
| uv run python -m eval.readiness.run_eval --limit 5 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import asyncio |
| import json |
| import statistics |
| import time |
| from dataclasses import asdict, dataclass, field |
| from datetime import UTC, datetime, timedelta |
| from pathlib import Path |
| from typing import Any |
|
|
| from src.agents.gate import stub_analysis_state |
| from src.agents.report.readiness import ( |
| _MISSING_ANALYSIS, |
| _MISSING_DELTA, |
| is_report_ready, |
| ) |
|
|
| _HERE = Path(__file__).resolve().parent |
| DATASET = _HERE / "readiness_dataset.json" |
| RESULTS_DIR = _HERE / "results" |
| GROUPS = ["floor", "delta", "edge", "alignment"] |
|
|
| |
| |
| |
| _CODE_TO_MISSING = { |
| "analysis": _MISSING_ANALYSIS, |
| "delta": _MISSING_DELTA, |
| } |
|
|
|
|
| @dataclass |
| class _FakeTask: |
| """Mirrors slow_path.schemas.TaskSummary (the bits is_report_ready reads).""" |
|
|
| status: str |
| tools_used: list[str] |
|
|
|
|
| @dataclass |
| class _FakeRecord: |
| findings: list[Any] |
| created_at: datetime |
| tasks_run: list[_FakeTask] |
|
|
|
|
| @dataclass |
| class _FakeReport: |
| generated_at: datetime |
|
|
|
|
| class _FakeStore: |
| """Stand-in for the Postgres record/report store — returns canned rows.""" |
|
|
| def __init__(self, rows: list[Any]) -> None: |
| self._rows = rows |
|
|
| async def list_for_analysis(self, _analysis_id: str) -> list[Any]: |
| return self._rows |
|
|
|
|
| @dataclass |
| class CaseResult: |
| id: str |
| group: str |
| expected_ready: bool |
| got_ready: bool |
| expected_missing: list[str] |
| got_missing: list[str] |
| correct: bool |
| aligned: bool |
| gap: bool |
| latency_ms: float |
|
|
|
|
| def load_cases(path: Path) -> list[dict[str, Any]]: |
| data = json.loads(path.read_text(encoding="utf-8")) |
| return list(data["cases"]) |
|
|
|
|
| def _build_tasks(analysis: str) -> list[_FakeTask]: |
| """Realistic tasks_run: data-access always succeeds; the analyze_* task varies. |
| |
| analysis = 'success' (analyze_* succeeded) | 'failure' (analyze_* failed) | |
| 'none' (no analyze task at all — only check/retrieve succeeded). |
| """ |
| tasks = [ |
| _FakeTask(status="success", tools_used=["check_data"]), |
| _FakeTask(status="success", tools_used=["retrieve_data"]), |
| ] |
| if analysis == "success": |
| tasks.append(_FakeTask(status="success", tools_used=["analyze_aggregate"])) |
| elif analysis == "failure": |
| tasks.append(_FakeTask(status="failure", tools_used=["analyze_aggregate"])) |
| return tasks |
|
|
|
|
| def _build_records(specs: list[dict[str, Any]], now: datetime) -> list[_FakeRecord]: |
| return [ |
| _FakeRecord( |
| findings=["f"] * int(spec.get("findings", 0)), |
| created_at=now - timedelta(minutes=int(spec["age_min"])), |
| tasks_run=_build_tasks(str(spec.get("analysis", "success"))), |
| ) |
| for spec in specs |
| ] |
|
|
|
|
| def _build_reports(specs: list[dict[str, Any]], now: datetime) -> list[_FakeReport]: |
| return [ |
| _FakeReport(generated_at=now - timedelta(minutes=int(spec["age_min"]))) |
| for spec in specs |
| ] |
|
|
|
|
| async def run_case(case: dict[str, Any]) -> CaseResult: |
| now = datetime.now(UTC) |
| |
| |
| state = stub_analysis_state() |
| if case.get("report_id"): |
| state = state.model_copy(update={"report_id": case["report_id"]}) |
|
|
| record_store = _FakeStore(_build_records(case.get("records", []), now)) |
| report_store = _FakeStore(_build_reports(case.get("reports", []), now)) |
| expected_missing = sorted(_CODE_TO_MISSING[c] for c in case["expected_missing"]) |
|
|
| start = time.perf_counter() |
| rr = await is_report_ready( |
| case["id"], state, record_store=record_store, report_store=report_store |
| ) |
| latency_ms = round((time.perf_counter() - start) * 1000, 1) |
|
|
| got_missing = sorted(rr.missing) |
| ready_ok = rr.ready == bool(case["expected_ready"]) |
| missing_ok = got_missing == expected_missing |
| return CaseResult( |
| id=case["id"], |
| group=case["group"], |
| expected_ready=bool(case["expected_ready"]), |
| got_ready=rr.ready, |
| expected_missing=expected_missing, |
| got_missing=got_missing, |
| correct=ready_ok and missing_ok, |
| aligned=bool(case["aligned"]), |
| gap=rr.ready and not bool(case["aligned"]), |
| latency_ms=latency_ms, |
| ) |
|
|
|
|
| def _group_accuracy(results: list[CaseResult]) -> dict[str, dict[str, Any]]: |
| out: dict[str, dict[str, Any]] = {} |
| for g in GROUPS: |
| sub = [r for r in results if r.group == g] |
| if not sub: |
| continue |
| passed = sum(r.correct for r in sub) |
| out[g] = {"n": len(sub), "passed": passed, "accuracy": round(passed / len(sub), 3)} |
| return out |
|
|
|
|
| def summarize(results: list[CaseResult]) -> dict[str, Any]: |
| n = len(results) |
| passed = sum(r.correct for r in results) |
| gaps = [r for r in results if r.gap] |
| latencies = [r.latency_ms for r in results] |
| return { |
| "total": n, |
| "passed": passed, |
| "accuracy": round(passed / n, 3) if n else 0.0, |
| "runtime_avg_ms": round(statistics.mean(latencies), 2) if latencies else 0, |
| "alignment_gap": {"count": len(gaps), "ids": [r.id for r in gaps]}, |
| "by_group": _group_accuracy(results), |
| } |
|
|
|
|
| def _fmt_bool(value: bool) -> str: |
| return "T" if value else "F" |
|
|
|
|
| def _truncate(text: str, width: int) -> str: |
| return text if len(text) <= width else text[: width - 3] + "..." |
|
|
|
|
| def format_table(results: list[CaseResult]) -> str: |
| header = ( |
| f"{'ID':<12} {'GROUP':<10} {'RDY e/g':<8} " |
| f"{'MISSING (got)':<40} {'OK':<3} {'GAP':<4}" |
| ) |
| rule = "-" * len(header) |
| lines = [rule, header, rule] |
| for r in results: |
| rdy = f"{_fmt_bool(r.expected_ready)}/{_fmt_bool(r.got_ready)}" |
| missing = ", ".join(r.got_missing) or "-" |
| ok = "ok" if r.correct else "X" |
| gap = "GAP" if r.gap else "" |
| lines.append( |
| f"{r.id:<12} {r.group:<10} {rdy:<8} " |
| f"{_truncate(missing, 40):<40} {ok:<3} {gap:<4}" |
| ) |
| lines.append(rule) |
| return "\n".join(lines) |
|
|
|
|
| def format_summary(summary: dict[str, Any], results: list[CaseResult]) -> str: |
| lines = ["SUMMARY"] |
| lines.append( |
| f" Floor {summary['passed']}/{summary['total']} correct" |
| f" ({summary['accuracy'] * 100:.1f}%) avg {summary['runtime_avg_ms']} ms" |
| ) |
| gap = summary["alignment_gap"] |
| lines.append( |
| f" Align gap {gap['count']} case(s) ready-but-misaligned" |
| + (f" -> {', '.join(gap['ids'])}" if gap["ids"] else "") |
| ) |
| lines.append(" (floor can't catch these; this count is the LLM-judge justification)") |
| lines.append("") |
| lines.append(" By group") |
| for g, m in summary["by_group"].items(): |
| lines.append(f" {g:<12} {m['passed']}/{m['n']} {m['accuracy'] * 100:.0f}%") |
| failures = [r for r in results if not r.correct] |
| lines.append("") |
| lines.append(f" FAILURES ({len(failures)})") |
| for r in failures: |
| lines.append( |
| f" {r.id:<12} ready {_fmt_bool(r.expected_ready)}->{_fmt_bool(r.got_ready)}" |
| f" missing {r.expected_missing} -> {r.got_missing}" |
| ) |
| return "\n".join(lines) |
|
|
|
|
| def build_report( |
| results: list[CaseResult], summary: dict[str, Any], meta: dict[str, Any] |
| ) -> dict[str, Any]: |
| run = {**meta, **{k: summary[k] for k in ("total", "passed", "accuracy", "runtime_avg_ms")}} |
| return { |
| "run": run, |
| "alignment_gap": summary["alignment_gap"], |
| "by_group": summary["by_group"], |
| "cases": [asdict(r) for r in results], |
| } |
|
|
|
|
| @dataclass |
| class _Args: |
| dataset: Path = DATASET |
| limit: int = 0 |
| no_table: bool = False |
| extra: dict[str, Any] = field(default_factory=dict) |
|
|
|
|
| async def main() -> None: |
| parser = argparse.ArgumentParser(description="Report-readiness eval") |
| parser.add_argument("--dataset", type=Path, default=DATASET) |
| parser.add_argument("--limit", type=int, default=0, help="run first N cases only") |
| parser.add_argument("--no-table", action="store_true", help="skip the detail table") |
| args = parser.parse_args() |
|
|
| cases = load_cases(args.dataset) |
| if args.limit: |
| cases = cases[: args.limit] |
|
|
| started = datetime.now() |
| print(f"Report-Readiness Eval -- {started:%Y-%m-%d %H:%M:%S}") |
| print(f"dataset: {args.dataset.name} ({len(cases)} cases) target: is_report_ready") |
|
|
| results = [await run_case(case) for case in cases] |
|
|
| summary = summarize(results) |
| if not args.no_table: |
| print(format_table(results)) |
| print(format_summary(summary, results)) |
|
|
| meta = { |
| "timestamp": started.isoformat(timespec="seconds"), |
| "dataset": args.dataset.name, |
| "target": "src/agents/report/readiness.is_report_ready", |
| } |
| report = build_report(results, summary, meta) |
| RESULTS_DIR.mkdir(parents=True, exist_ok=True) |
| out_path = RESULTS_DIR / f"readiness_result_{started:%Y-%m-%d_%H%M%S}.json" |
| out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") |
| print(f"\n-> saved: {out_path.relative_to(_HERE.parent.parent)}") |
|
|
|
|
| if __name__ == "__main__": |
| asyncio.run(main()) |
|
|