Commit ·
990f264
1
Parent(s): 53d197f
[NOTICKET] feat(eval): report-readiness eval (dataset + runner + baseline)
Browse filesDeterministic eval for is_report_ready (no LLM, no DB — fake stores). 16 golden
cases across floor / delta / edge / alignment; scores both the ready boolean and the
missing gaps, and counts the alignment gap (floor says ready but analyses unaligned to
the problem statement). Baseline run: floor 16/16 (100%), alignment gap = 2
(align_01, align_02) — the evidence for/against the deferred LLM-judge.
Run: uv run python -m eval.readiness.run_eval
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
eval/readiness/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Report-readiness eval
|
| 2 |
+
|
| 3 |
+
Scores the deterministic `is_report_ready` signal (`src/agents/report/readiness.py`)
|
| 4 |
+
that the Help skill consumes to decide whether to nudge the user toward generating a
|
| 5 |
+
report. No LLM, no DB — each golden case declares an analysis state + a set of
|
| 6 |
+
persisted records/reports, and the runner feeds them through `is_report_ready` via
|
| 7 |
+
injectable fake stores.
|
| 8 |
+
|
| 9 |
+
## Run
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
uv run python -m eval.readiness.run_eval
|
| 13 |
+
uv run python -m eval.readiness.run_eval --limit 5 # smoke test
|
| 14 |
+
uv run python -m eval.readiness.run_eval --no-table # summary only
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
Each run writes a timestamped `results/readiness_result_<ts>.json` (never
|
| 18 |
+
overwritten, diffable across runs).
|
| 19 |
+
|
| 20 |
+
## What it measures
|
| 21 |
+
|
| 22 |
+
- **Floor correctness** — exact `ready` + `missing` for the deterministic floor
|
| 23 |
+
(validated goal · ≥1 substantive record · delta-since-report). Should sit at ~100%;
|
| 24 |
+
this is the regression guard as criteria evolve.
|
| 25 |
+
- **Alignment gap** — `alignment` cases have substantive records (floor says
|
| 26 |
+
`ready=true`) but `aligned=false`: the analyses don't address the problem statement.
|
| 27 |
+
The floor can't see this. The gap count is the evidence for/against adding the
|
| 28 |
+
deferred LLM-judge — "ship the floor, earn the judge."
|
| 29 |
+
|
| 30 |
+
## Dataset
|
| 31 |
+
|
| 32 |
+
`readiness_dataset.json` — groups: `floor`, `delta`, `edge` (doc-only product
|
| 33 |
+
question), `alignment`. See the `_about` / `_alignment` doc keys in the file. The
|
| 34 |
+
`aligned` label is a semantic judgment; owner: Rifqi (report semantics) + Sofhia.
|
eval/readiness/__init__.py
ADDED
|
File without changes
|
eval/readiness/readiness_dataset.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_about": "Golden dataset for the report-readiness signal (`src/agents/report/readiness.is_report_ready`). Unlike the intent eval, readiness is DETERMINISTIC (no LLM): each case declares an analysis state + a set of persisted AnalysisRecords/reports, and the runner feeds them through is_report_ready via injectable fake stores, scoring the boolean `ready` AND the `missing` gaps. Floor cases should score ~100% (that's the regression value). The point of interest is the `alignment` group — see _alignment.",
|
| 3 |
+
"_floor": "is_report_ready's deterministic floor: (1) problem_validated, (2) >=1 SUBSTANTIVE record (findings non-empty; a failed/empty run still persists a record and must NOT count), (3) delta-since-report (if report_id set, ready only when a substantive record is newer than the latest report).",
|
| 4 |
+
"_alignment": "ALIGNMENT cases probe the deferred LLM-judge: records are substantive so the floor says ready=true, but `aligned=false` means the analyses don't actually address the problem statement — a human would say NOT ready. These are scored as floor-correct (expected_ready follows the floor) but counted separately as the 'alignment gap'. The gap count is the evidence that earns (or doesn't) the LLM-judge. Owner of the alignment label: Rifqi (report semantics) + Sofhia.",
|
| 5 |
+
"_fields": "records[].age_min / reports[].age_min = minutes ago (smaller = more recent); records[].findings = count of findings (0 = failed/empty run). missing codes map to is_report_ready's constants in the runner: problem|analysis|delta.",
|
| 6 |
+
"schema": {
|
| 7 |
+
"id": "stable per-case handle, <group>_<NN>",
|
| 8 |
+
"group": "floor | delta | edge | alignment",
|
| 9 |
+
"problem_validated": "bool",
|
| 10 |
+
"report_id": "null = never generated; a string = a report exists",
|
| 11 |
+
"records": "[{ findings: int, age_min: int }]",
|
| 12 |
+
"reports": "[{ age_min: int }] (only meaningful when report_id set)",
|
| 13 |
+
"aligned": "bool — do the analyses address the problem statement? (floor ignores this)",
|
| 14 |
+
"expected_ready": "what the deterministic floor SHOULD return",
|
| 15 |
+
"expected_missing": "subset of [problem, analysis, delta]",
|
| 16 |
+
"note": "human-readable description"
|
| 17 |
+
},
|
| 18 |
+
"cases": [
|
| 19 |
+
{ "id": "floor_01", "group": "floor", "problem_validated": false, "report_id": null, "records": [], "reports": [], "aligned": false, "expected_ready": false, "expected_missing": ["problem", "analysis"], "note": "new analysis: no validated goal and no records yet" },
|
| 20 |
+
{ "id": "floor_02", "group": "floor", "problem_validated": false, "report_id": null, "records": [{ "findings": 2, "age_min": 30 }], "reports": [], "aligned": true, "expected_ready": false, "expected_missing": ["problem"], "note": "has a substantive record but goal not validated (isolates the problem gap)" },
|
| 21 |
+
{ "id": "floor_03", "group": "floor", "problem_validated": true, "report_id": null, "records": [], "reports": [], "aligned": false, "expected_ready": false, "expected_missing": ["analysis"], "note": "validated goal but no analysis run yet" },
|
| 22 |
+
{ "id": "floor_04", "group": "floor", "problem_validated": true, "report_id": null, "records": [{ "findings": 0, "age_min": 20 }], "reports": [], "aligned": false, "expected_ready": false, "expected_missing": ["analysis"], "note": "only a failed/empty run (no findings) — must not count as substantive" },
|
| 23 |
+
{ "id": "floor_05", "group": "floor", "problem_validated": true, "report_id": null, "records": [{ "findings": 0, "age_min": 40 }, { "findings": 0, "age_min": 10 }], "reports": [], "aligned": false, "expected_ready": false, "expected_missing": ["analysis"], "note": "multiple runs, all empty — still not ready" },
|
| 24 |
+
{ "id": "floor_06", "group": "floor", "problem_validated": true, "report_id": null, "records": [{ "findings": 2, "age_min": 15 }], "reports": [], "aligned": true, "expected_ready": true, "expected_missing": [], "note": "validated + one substantive record, no prior report → ready" },
|
| 25 |
+
{ "id": "floor_07", "group": "floor", "problem_validated": true, "report_id": null, "records": [{ "findings": 3, "age_min": 40 }, { "findings": 1, "age_min": 10 }], "reports": [], "aligned": true, "expected_ready": true, "expected_missing": [], "note": "multiple substantive records → ready" },
|
| 26 |
+
{ "id": "floor_08", "group": "floor", "problem_validated": true, "report_id": null, "records": [{ "findings": 0, "age_min": 30 }, { "findings": 2, "age_min": 10 }], "reports": [], "aligned": true, "expected_ready": true, "expected_missing": [], "note": "mix of failed + substantive → the substantive one is enough" },
|
| 27 |
+
|
| 28 |
+
{ "id": "delta_01", "group": "delta", "problem_validated": true, "report_id": "rep-1", "records": [{ "findings": 2, "age_min": 120 }], "reports": [{ "age_min": 5 }], "aligned": true, "expected_ready": false, "expected_missing": ["delta"], "note": "report exists, all analysis is older than it → nothing new to report" },
|
| 29 |
+
{ "id": "delta_02", "group": "delta", "problem_validated": true, "report_id": "rep-1", "records": [{ "findings": 2, "age_min": 5 }], "reports": [{ "age_min": 120 }], "aligned": true, "expected_ready": true, "expected_missing": [], "note": "report exists but a newer substantive analysis arrived → ready to regenerate" },
|
| 30 |
+
{ "id": "delta_03", "group": "delta", "problem_validated": true, "report_id": "rep-1", "records": [{ "findings": 1, "age_min": 90 }, { "findings": 2, "age_min": 10 }], "reports": [{ "age_min": 60 }], "aligned": true, "expected_ready": true, "expected_missing": [], "note": "one old + one newer-than-report analysis → ready" },
|
| 31 |
+
{ "id": "delta_04", "group": "delta", "problem_validated": true, "report_id": "rep-2", "records": [{ "findings": 2, "age_min": 90 }], "reports": [{ "age_min": 200 }, { "age_min": 30 }], "aligned": true, "expected_ready": false, "expected_missing": ["delta"], "note": "multiple reports — newest wins; analysis older than the newest report → not ready" },
|
| 32 |
+
|
| 33 |
+
{ "id": "edge_01", "group": "edge", "problem_validated": true, "report_id": null, "records": [], "reports": [], "aligned": false, "expected_ready": false, "expected_missing": ["analysis"], "note": "doc-only analysis (RAG, no structured run) produces no AnalysisRecord → never report-able under the floor. PRODUCT QUESTION: should doc-only be report-able? if yes, readiness needs a 2nd source." },
|
| 34 |
+
|
| 35 |
+
{ "id": "align_01", "group": "alignment", "problem_validated": true, "report_id": null, "records": [{ "findings": 2, "age_min": 15 }], "reports": [], "aligned": false, "expected_ready": true, "expected_missing": [], "note": "GAP: substantive records but they don't address the problem statement. Floor says ready; a human would say not-ready. The judge would catch this." },
|
| 36 |
+
{ "id": "align_02", "group": "alignment", "problem_validated": true, "report_id": null, "records": [{ "findings": 3, "age_min": 25 }, { "findings": 1, "age_min": 5 }], "reports": [], "aligned": false, "expected_ready": true, "expected_missing": [], "note": "GAP: lots of analysis, none aligned to the goal — floor over-reports ready" },
|
| 37 |
+
{ "id": "align_03", "group": "alignment", "problem_validated": true, "report_id": null, "records": [{ "findings": 2, "age_min": 15 }], "reports": [], "aligned": true, "expected_ready": true, "expected_missing": [], "note": "control: substantive AND aligned → genuinely ready, no gap" }
|
| 38 |
+
]
|
| 39 |
+
}
|
eval/readiness/results/.gitkeep
ADDED
|
File without changes
|
eval/readiness/results/readiness_result_2026-06-22_101645.json
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"run": {
|
| 3 |
+
"timestamp": "2026-06-22T10:16:45",
|
| 4 |
+
"dataset": "readiness_dataset.json",
|
| 5 |
+
"target": "src/agents/report/readiness.is_report_ready",
|
| 6 |
+
"total": 16,
|
| 7 |
+
"passed": 16,
|
| 8 |
+
"accuracy": 1.0,
|
| 9 |
+
"runtime_avg_ms": 0.0
|
| 10 |
+
},
|
| 11 |
+
"alignment_gap": {
|
| 12 |
+
"count": 2,
|
| 13 |
+
"ids": [
|
| 14 |
+
"align_01",
|
| 15 |
+
"align_02"
|
| 16 |
+
]
|
| 17 |
+
},
|
| 18 |
+
"by_group": {
|
| 19 |
+
"floor": {
|
| 20 |
+
"n": 8,
|
| 21 |
+
"passed": 8,
|
| 22 |
+
"accuracy": 1.0
|
| 23 |
+
},
|
| 24 |
+
"delta": {
|
| 25 |
+
"n": 4,
|
| 26 |
+
"passed": 4,
|
| 27 |
+
"accuracy": 1.0
|
| 28 |
+
},
|
| 29 |
+
"edge": {
|
| 30 |
+
"n": 1,
|
| 31 |
+
"passed": 1,
|
| 32 |
+
"accuracy": 1.0
|
| 33 |
+
},
|
| 34 |
+
"alignment": {
|
| 35 |
+
"n": 3,
|
| 36 |
+
"passed": 3,
|
| 37 |
+
"accuracy": 1.0
|
| 38 |
+
}
|
| 39 |
+
},
|
| 40 |
+
"cases": [
|
| 41 |
+
{
|
| 42 |
+
"id": "floor_01",
|
| 43 |
+
"group": "floor",
|
| 44 |
+
"expected_ready": false,
|
| 45 |
+
"got_ready": false,
|
| 46 |
+
"expected_missing": [
|
| 47 |
+
"a validated problem statement",
|
| 48 |
+
"at least one completed analysis"
|
| 49 |
+
],
|
| 50 |
+
"got_missing": [
|
| 51 |
+
"a validated problem statement",
|
| 52 |
+
"at least one completed analysis"
|
| 53 |
+
],
|
| 54 |
+
"correct": true,
|
| 55 |
+
"aligned": false,
|
| 56 |
+
"gap": false,
|
| 57 |
+
"latency_ms": 0.0
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"id": "floor_02",
|
| 61 |
+
"group": "floor",
|
| 62 |
+
"expected_ready": false,
|
| 63 |
+
"got_ready": false,
|
| 64 |
+
"expected_missing": [
|
| 65 |
+
"a validated problem statement"
|
| 66 |
+
],
|
| 67 |
+
"got_missing": [
|
| 68 |
+
"a validated problem statement"
|
| 69 |
+
],
|
| 70 |
+
"correct": true,
|
| 71 |
+
"aligned": true,
|
| 72 |
+
"gap": false,
|
| 73 |
+
"latency_ms": 0.0
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"id": "floor_03",
|
| 77 |
+
"group": "floor",
|
| 78 |
+
"expected_ready": false,
|
| 79 |
+
"got_ready": false,
|
| 80 |
+
"expected_missing": [
|
| 81 |
+
"at least one completed analysis"
|
| 82 |
+
],
|
| 83 |
+
"got_missing": [
|
| 84 |
+
"at least one completed analysis"
|
| 85 |
+
],
|
| 86 |
+
"correct": true,
|
| 87 |
+
"aligned": false,
|
| 88 |
+
"gap": false,
|
| 89 |
+
"latency_ms": 0.0
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"id": "floor_04",
|
| 93 |
+
"group": "floor",
|
| 94 |
+
"expected_ready": false,
|
| 95 |
+
"got_ready": false,
|
| 96 |
+
"expected_missing": [
|
| 97 |
+
"at least one completed analysis"
|
| 98 |
+
],
|
| 99 |
+
"got_missing": [
|
| 100 |
+
"at least one completed analysis"
|
| 101 |
+
],
|
| 102 |
+
"correct": true,
|
| 103 |
+
"aligned": false,
|
| 104 |
+
"gap": false,
|
| 105 |
+
"latency_ms": 0.0
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"id": "floor_05",
|
| 109 |
+
"group": "floor",
|
| 110 |
+
"expected_ready": false,
|
| 111 |
+
"got_ready": false,
|
| 112 |
+
"expected_missing": [
|
| 113 |
+
"at least one completed analysis"
|
| 114 |
+
],
|
| 115 |
+
"got_missing": [
|
| 116 |
+
"at least one completed analysis"
|
| 117 |
+
],
|
| 118 |
+
"correct": true,
|
| 119 |
+
"aligned": false,
|
| 120 |
+
"gap": false,
|
| 121 |
+
"latency_ms": 0.0
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"id": "floor_06",
|
| 125 |
+
"group": "floor",
|
| 126 |
+
"expected_ready": true,
|
| 127 |
+
"got_ready": true,
|
| 128 |
+
"expected_missing": [],
|
| 129 |
+
"got_missing": [],
|
| 130 |
+
"correct": true,
|
| 131 |
+
"aligned": true,
|
| 132 |
+
"gap": false,
|
| 133 |
+
"latency_ms": 0.0
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"id": "floor_07",
|
| 137 |
+
"group": "floor",
|
| 138 |
+
"expected_ready": true,
|
| 139 |
+
"got_ready": true,
|
| 140 |
+
"expected_missing": [],
|
| 141 |
+
"got_missing": [],
|
| 142 |
+
"correct": true,
|
| 143 |
+
"aligned": true,
|
| 144 |
+
"gap": false,
|
| 145 |
+
"latency_ms": 0.0
|
| 146 |
+
},
|
| 147 |
+
{
|
| 148 |
+
"id": "floor_08",
|
| 149 |
+
"group": "floor",
|
| 150 |
+
"expected_ready": true,
|
| 151 |
+
"got_ready": true,
|
| 152 |
+
"expected_missing": [],
|
| 153 |
+
"got_missing": [],
|
| 154 |
+
"correct": true,
|
| 155 |
+
"aligned": true,
|
| 156 |
+
"gap": false,
|
| 157 |
+
"latency_ms": 0.0
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"id": "delta_01",
|
| 161 |
+
"group": "delta",
|
| 162 |
+
"expected_ready": false,
|
| 163 |
+
"got_ready": false,
|
| 164 |
+
"expected_missing": [
|
| 165 |
+
"a new analysis since the last report"
|
| 166 |
+
],
|
| 167 |
+
"got_missing": [
|
| 168 |
+
"a new analysis since the last report"
|
| 169 |
+
],
|
| 170 |
+
"correct": true,
|
| 171 |
+
"aligned": true,
|
| 172 |
+
"gap": false,
|
| 173 |
+
"latency_ms": 0.0
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"id": "delta_02",
|
| 177 |
+
"group": "delta",
|
| 178 |
+
"expected_ready": true,
|
| 179 |
+
"got_ready": true,
|
| 180 |
+
"expected_missing": [],
|
| 181 |
+
"got_missing": [],
|
| 182 |
+
"correct": true,
|
| 183 |
+
"aligned": true,
|
| 184 |
+
"gap": false,
|
| 185 |
+
"latency_ms": 0.0
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"id": "delta_03",
|
| 189 |
+
"group": "delta",
|
| 190 |
+
"expected_ready": true,
|
| 191 |
+
"got_ready": true,
|
| 192 |
+
"expected_missing": [],
|
| 193 |
+
"got_missing": [],
|
| 194 |
+
"correct": true,
|
| 195 |
+
"aligned": true,
|
| 196 |
+
"gap": false,
|
| 197 |
+
"latency_ms": 0.0
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"id": "delta_04",
|
| 201 |
+
"group": "delta",
|
| 202 |
+
"expected_ready": false,
|
| 203 |
+
"got_ready": false,
|
| 204 |
+
"expected_missing": [
|
| 205 |
+
"a new analysis since the last report"
|
| 206 |
+
],
|
| 207 |
+
"got_missing": [
|
| 208 |
+
"a new analysis since the last report"
|
| 209 |
+
],
|
| 210 |
+
"correct": true,
|
| 211 |
+
"aligned": true,
|
| 212 |
+
"gap": false,
|
| 213 |
+
"latency_ms": 0.0
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"id": "edge_01",
|
| 217 |
+
"group": "edge",
|
| 218 |
+
"expected_ready": false,
|
| 219 |
+
"got_ready": false,
|
| 220 |
+
"expected_missing": [
|
| 221 |
+
"at least one completed analysis"
|
| 222 |
+
],
|
| 223 |
+
"got_missing": [
|
| 224 |
+
"at least one completed analysis"
|
| 225 |
+
],
|
| 226 |
+
"correct": true,
|
| 227 |
+
"aligned": false,
|
| 228 |
+
"gap": false,
|
| 229 |
+
"latency_ms": 0.0
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"id": "align_01",
|
| 233 |
+
"group": "alignment",
|
| 234 |
+
"expected_ready": true,
|
| 235 |
+
"got_ready": true,
|
| 236 |
+
"expected_missing": [],
|
| 237 |
+
"got_missing": [],
|
| 238 |
+
"correct": true,
|
| 239 |
+
"aligned": false,
|
| 240 |
+
"gap": true,
|
| 241 |
+
"latency_ms": 0.0
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"id": "align_02",
|
| 245 |
+
"group": "alignment",
|
| 246 |
+
"expected_ready": true,
|
| 247 |
+
"got_ready": true,
|
| 248 |
+
"expected_missing": [],
|
| 249 |
+
"got_missing": [],
|
| 250 |
+
"correct": true,
|
| 251 |
+
"aligned": false,
|
| 252 |
+
"gap": true,
|
| 253 |
+
"latency_ms": 0.0
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"id": "align_03",
|
| 257 |
+
"group": "alignment",
|
| 258 |
+
"expected_ready": true,
|
| 259 |
+
"got_ready": true,
|
| 260 |
+
"expected_missing": [],
|
| 261 |
+
"got_missing": [],
|
| 262 |
+
"correct": true,
|
| 263 |
+
"aligned": true,
|
| 264 |
+
"gap": false,
|
| 265 |
+
"latency_ms": 0.0
|
| 266 |
+
}
|
| 267 |
+
]
|
| 268 |
+
}
|
eval/readiness/run_eval.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Report-readiness eval runner.
|
| 2 |
+
|
| 3 |
+
Feeds each golden case in `readiness_dataset.json` to the deterministic
|
| 4 |
+
`is_report_ready` signal (`src/agents/report/readiness.py`) via injectable FAKE
|
| 5 |
+
stores — no LLM, no DB — then scores both the boolean `ready` and the `missing`
|
| 6 |
+
gaps. Prints a per-case detail table + aggregate summary and writes a timestamped
|
| 7 |
+
JSON report under `results/` (never overwritten — one file per run, diffable).
|
| 8 |
+
|
| 9 |
+
Two metrics matter:
|
| 10 |
+
- FLOOR correctness (ready + missing exact) — should be ~100%; this is the
|
| 11 |
+
regression guard as the criteria evolve.
|
| 12 |
+
- ALIGNMENT GAP — cases the floor calls ready=true but whose analyses are NOT
|
| 13 |
+
aligned to the problem statement (`aligned=false`). The floor can't see this;
|
| 14 |
+
the gap count is the evidence for/against adding the deferred LLM-judge.
|
| 15 |
+
|
| 16 |
+
Invoke as a module so `src` imports resolve:
|
| 17 |
+
|
| 18 |
+
uv run python -m eval.readiness.run_eval
|
| 19 |
+
uv run python -m eval.readiness.run_eval --limit 5
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import asyncio
|
| 26 |
+
import json
|
| 27 |
+
import statistics
|
| 28 |
+
import time
|
| 29 |
+
from dataclasses import asdict, dataclass, field
|
| 30 |
+
from datetime import UTC, datetime, timedelta
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Any
|
| 33 |
+
|
| 34 |
+
from src.agents.gate import stub_analysis_state
|
| 35 |
+
from src.agents.report.readiness import (
|
| 36 |
+
_MISSING_ANALYSIS,
|
| 37 |
+
_MISSING_DELTA,
|
| 38 |
+
_MISSING_PROBLEM,
|
| 39 |
+
is_report_ready,
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
_HERE = Path(__file__).resolve().parent
|
| 43 |
+
DATASET = _HERE / "readiness_dataset.json"
|
| 44 |
+
RESULTS_DIR = _HERE / "results"
|
| 45 |
+
GROUPS = ["floor", "delta", "edge", "alignment"]
|
| 46 |
+
|
| 47 |
+
# Dataset short codes -> the exact `missing` strings is_report_ready emits. Imported
|
| 48 |
+
# from the module so the dataset stays readable and survives wording changes.
|
| 49 |
+
_CODE_TO_MISSING = {
|
| 50 |
+
"problem": _MISSING_PROBLEM,
|
| 51 |
+
"analysis": _MISSING_ANALYSIS,
|
| 52 |
+
"delta": _MISSING_DELTA,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass
|
| 57 |
+
class _FakeRecord:
|
| 58 |
+
findings: list[Any]
|
| 59 |
+
created_at: datetime
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@dataclass
|
| 63 |
+
class _FakeReport:
|
| 64 |
+
generated_at: datetime
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class _FakeStore:
|
| 68 |
+
"""Stand-in for the Postgres record/report store — returns canned rows."""
|
| 69 |
+
|
| 70 |
+
def __init__(self, rows: list[Any]) -> None:
|
| 71 |
+
self._rows = rows
|
| 72 |
+
|
| 73 |
+
async def list_for_analysis(self, _analysis_id: str) -> list[Any]:
|
| 74 |
+
return self._rows
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class CaseResult:
|
| 79 |
+
id: str
|
| 80 |
+
group: str
|
| 81 |
+
expected_ready: bool
|
| 82 |
+
got_ready: bool
|
| 83 |
+
expected_missing: list[str]
|
| 84 |
+
got_missing: list[str]
|
| 85 |
+
correct: bool
|
| 86 |
+
aligned: bool
|
| 87 |
+
gap: bool # floor said ready but analyses not aligned to the problem statement
|
| 88 |
+
latency_ms: float
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def load_cases(path: Path) -> list[dict[str, Any]]:
|
| 92 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 93 |
+
return list(data["cases"])
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _build_records(specs: list[dict[str, Any]], now: datetime) -> list[_FakeRecord]:
|
| 97 |
+
return [
|
| 98 |
+
_FakeRecord(
|
| 99 |
+
findings=["f"] * int(spec["findings"]),
|
| 100 |
+
created_at=now - timedelta(minutes=int(spec["age_min"])),
|
| 101 |
+
)
|
| 102 |
+
for spec in specs
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _build_reports(specs: list[dict[str, Any]], now: datetime) -> list[_FakeReport]:
|
| 107 |
+
return [
|
| 108 |
+
_FakeReport(generated_at=now - timedelta(minutes=int(spec["age_min"])))
|
| 109 |
+
for spec in specs
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
async def run_case(case: dict[str, Any]) -> CaseResult:
|
| 114 |
+
now = datetime.now(UTC)
|
| 115 |
+
state = stub_analysis_state(problem_validated=bool(case["problem_validated"]))
|
| 116 |
+
if case.get("report_id"):
|
| 117 |
+
state = state.model_copy(update={"report_id": case["report_id"]})
|
| 118 |
+
|
| 119 |
+
record_store = _FakeStore(_build_records(case.get("records", []), now))
|
| 120 |
+
report_store = _FakeStore(_build_reports(case.get("reports", []), now))
|
| 121 |
+
expected_missing = sorted(_CODE_TO_MISSING[c] for c in case["expected_missing"])
|
| 122 |
+
|
| 123 |
+
start = time.perf_counter()
|
| 124 |
+
rr = await is_report_ready(
|
| 125 |
+
case["id"], state, record_store=record_store, report_store=report_store
|
| 126 |
+
)
|
| 127 |
+
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
| 128 |
+
|
| 129 |
+
got_missing = sorted(rr.missing)
|
| 130 |
+
ready_ok = rr.ready == bool(case["expected_ready"])
|
| 131 |
+
missing_ok = got_missing == expected_missing
|
| 132 |
+
return CaseResult(
|
| 133 |
+
id=case["id"],
|
| 134 |
+
group=case["group"],
|
| 135 |
+
expected_ready=bool(case["expected_ready"]),
|
| 136 |
+
got_ready=rr.ready,
|
| 137 |
+
expected_missing=expected_missing,
|
| 138 |
+
got_missing=got_missing,
|
| 139 |
+
correct=ready_ok and missing_ok,
|
| 140 |
+
aligned=bool(case["aligned"]),
|
| 141 |
+
gap=rr.ready and not bool(case["aligned"]),
|
| 142 |
+
latency_ms=latency_ms,
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _group_accuracy(results: list[CaseResult]) -> dict[str, dict[str, Any]]:
|
| 147 |
+
out: dict[str, dict[str, Any]] = {}
|
| 148 |
+
for g in GROUPS:
|
| 149 |
+
sub = [r for r in results if r.group == g]
|
| 150 |
+
if not sub:
|
| 151 |
+
continue
|
| 152 |
+
passed = sum(r.correct for r in sub)
|
| 153 |
+
out[g] = {"n": len(sub), "passed": passed, "accuracy": round(passed / len(sub), 3)}
|
| 154 |
+
return out
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def summarize(results: list[CaseResult]) -> dict[str, Any]:
|
| 158 |
+
n = len(results)
|
| 159 |
+
passed = sum(r.correct for r in results)
|
| 160 |
+
gaps = [r for r in results if r.gap]
|
| 161 |
+
latencies = [r.latency_ms for r in results]
|
| 162 |
+
return {
|
| 163 |
+
"total": n,
|
| 164 |
+
"passed": passed,
|
| 165 |
+
"accuracy": round(passed / n, 3) if n else 0.0,
|
| 166 |
+
"runtime_avg_ms": round(statistics.mean(latencies), 2) if latencies else 0,
|
| 167 |
+
"alignment_gap": {"count": len(gaps), "ids": [r.id for r in gaps]},
|
| 168 |
+
"by_group": _group_accuracy(results),
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _fmt_bool(value: bool) -> str:
|
| 173 |
+
return "T" if value else "F"
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _truncate(text: str, width: int) -> str:
|
| 177 |
+
return text if len(text) <= width else text[: width - 3] + "..."
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def format_table(results: list[CaseResult]) -> str:
|
| 181 |
+
header = (
|
| 182 |
+
f"{'ID':<12} {'GROUP':<10} {'RDY e/g':<8} "
|
| 183 |
+
f"{'MISSING (got)':<40} {'OK':<3} {'GAP':<4}"
|
| 184 |
+
)
|
| 185 |
+
rule = "-" * len(header)
|
| 186 |
+
lines = [rule, header, rule]
|
| 187 |
+
for r in results:
|
| 188 |
+
rdy = f"{_fmt_bool(r.expected_ready)}/{_fmt_bool(r.got_ready)}"
|
| 189 |
+
missing = ", ".join(r.got_missing) or "-"
|
| 190 |
+
ok = "ok" if r.correct else "X"
|
| 191 |
+
gap = "GAP" if r.gap else ""
|
| 192 |
+
lines.append(
|
| 193 |
+
f"{r.id:<12} {r.group:<10} {rdy:<8} "
|
| 194 |
+
f"{_truncate(missing, 40):<40} {ok:<3} {gap:<4}"
|
| 195 |
+
)
|
| 196 |
+
lines.append(rule)
|
| 197 |
+
return "\n".join(lines)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def format_summary(summary: dict[str, Any], results: list[CaseResult]) -> str:
|
| 201 |
+
lines = ["SUMMARY"]
|
| 202 |
+
lines.append(
|
| 203 |
+
f" Floor {summary['passed']}/{summary['total']} correct"
|
| 204 |
+
f" ({summary['accuracy'] * 100:.1f}%) avg {summary['runtime_avg_ms']} ms"
|
| 205 |
+
)
|
| 206 |
+
gap = summary["alignment_gap"]
|
| 207 |
+
lines.append(
|
| 208 |
+
f" Align gap {gap['count']} case(s) ready-but-misaligned"
|
| 209 |
+
+ (f" -> {', '.join(gap['ids'])}" if gap["ids"] else "")
|
| 210 |
+
)
|
| 211 |
+
lines.append(" (floor can't catch these; this count is the LLM-judge justification)")
|
| 212 |
+
lines.append("")
|
| 213 |
+
lines.append(" By group")
|
| 214 |
+
for g, m in summary["by_group"].items():
|
| 215 |
+
lines.append(f" {g:<12} {m['passed']}/{m['n']} {m['accuracy'] * 100:.0f}%")
|
| 216 |
+
failures = [r for r in results if not r.correct]
|
| 217 |
+
lines.append("")
|
| 218 |
+
lines.append(f" FAILURES ({len(failures)})")
|
| 219 |
+
for r in failures:
|
| 220 |
+
lines.append(
|
| 221 |
+
f" {r.id:<12} ready {_fmt_bool(r.expected_ready)}->{_fmt_bool(r.got_ready)}"
|
| 222 |
+
f" missing {r.expected_missing} -> {r.got_missing}"
|
| 223 |
+
)
|
| 224 |
+
return "\n".join(lines)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def build_report(
|
| 228 |
+
results: list[CaseResult], summary: dict[str, Any], meta: dict[str, Any]
|
| 229 |
+
) -> dict[str, Any]:
|
| 230 |
+
run = {**meta, **{k: summary[k] for k in ("total", "passed", "accuracy", "runtime_avg_ms")}}
|
| 231 |
+
return {
|
| 232 |
+
"run": run,
|
| 233 |
+
"alignment_gap": summary["alignment_gap"],
|
| 234 |
+
"by_group": summary["by_group"],
|
| 235 |
+
"cases": [asdict(r) for r in results],
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@dataclass
|
| 240 |
+
class _Args:
|
| 241 |
+
dataset: Path = DATASET
|
| 242 |
+
limit: int = 0
|
| 243 |
+
no_table: bool = False
|
| 244 |
+
extra: dict[str, Any] = field(default_factory=dict)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
async def main() -> None:
|
| 248 |
+
parser = argparse.ArgumentParser(description="Report-readiness eval")
|
| 249 |
+
parser.add_argument("--dataset", type=Path, default=DATASET)
|
| 250 |
+
parser.add_argument("--limit", type=int, default=0, help="run first N cases only")
|
| 251 |
+
parser.add_argument("--no-table", action="store_true", help="skip the detail table")
|
| 252 |
+
args = parser.parse_args()
|
| 253 |
+
|
| 254 |
+
cases = load_cases(args.dataset)
|
| 255 |
+
if args.limit:
|
| 256 |
+
cases = cases[: args.limit]
|
| 257 |
+
|
| 258 |
+
started = datetime.now()
|
| 259 |
+
print(f"Report-Readiness Eval -- {started:%Y-%m-%d %H:%M:%S}")
|
| 260 |
+
print(f"dataset: {args.dataset.name} ({len(cases)} cases) target: is_report_ready")
|
| 261 |
+
|
| 262 |
+
results = [await run_case(case) for case in cases]
|
| 263 |
+
|
| 264 |
+
summary = summarize(results)
|
| 265 |
+
if not args.no_table:
|
| 266 |
+
print(format_table(results))
|
| 267 |
+
print(format_summary(summary, results))
|
| 268 |
+
|
| 269 |
+
meta = {
|
| 270 |
+
"timestamp": started.isoformat(timespec="seconds"),
|
| 271 |
+
"dataset": args.dataset.name,
|
| 272 |
+
"target": "src/agents/report/readiness.is_report_ready",
|
| 273 |
+
}
|
| 274 |
+
report = build_report(results, summary, meta)
|
| 275 |
+
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 276 |
+
out_path = RESULTS_DIR / f"readiness_result_{started:%Y-%m-%d_%H%M%S}.json"
|
| 277 |
+
out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 278 |
+
print(f"\n-> saved: {out_path.relative_to(_HERE.parent.parent)}")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
if __name__ == "__main__":
|
| 282 |
+
asyncio.run(main())
|