sofhiaazzhr Claude Opus 4.8 commited on
Commit
990f264
·
1 Parent(s): 53d197f

[NOTICKET] feat(eval): report-readiness eval (dataset + runner + baseline)

Browse files

Deterministic 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())