analyticsreceipts 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ """FIXTURES harness: replay recorded transcripts through the real server and raw-SQL tools.
2
+
3
+ Phase 1 (execute, needs the warehouse): every step is executed for real (MCP arm over stdio against
4
+ `python -m analyticsreceipts.server`, raw arm through RawTools), so every receipt is real. The final
5
+ answer's {{rIcJ}} templates are filled from the last successful result. The executed transcripts are
6
+ written to a replay file, together with the canary result.
7
+ Phase 2 (grade, offline): the replay is graded against golden + expected tables and compared with the
8
+ frozen expected grades. `report.py` regenerates everything from the replay with no warehouse and no network.
9
+
10
+ Usage:
11
+ python -m analyticsreceipts.fixtures --db retail.duckdb --out replays/fixtures-v1.json [--planted-bug drop_c1]
12
+ """
13
+
14
+ import argparse
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import re
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import anyio
23
+ from mcp.client.session import ClientSession
24
+ from mcp.client.stdio import StdioServerParameters, stdio_client
25
+
26
+ from .compiler import compile_request
27
+ from .execution import Executor
28
+ from .model import default_model_path, load
29
+ from .paths import data_root, find_root
30
+ from .rawtools import RawTools
31
+ from .report import EXPECTED_TABLES
32
+
33
+ ROOT = find_root() or Path.cwd() # eval data: see paths.py (a clone's root, or $ANALYTICSRECEIPTS_ROOT)
34
+ TEMPLATE = re.compile(r"\{\{r(\d+)c(\d+)(\*[0-9.]+)?(%)?\}\}")
35
+
36
+
37
+ def sha(path):
38
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
39
+
40
+
41
+ def _numeric_cells(row):
42
+ return [v for v in row if isinstance(v, (int, float)) and not isinstance(v, bool)]
43
+
44
+
45
+ def _fmt(v, pct):
46
+ if pct:
47
+ return f"{v * 100:.2f}%"
48
+ if abs(v - round(v)) < 1e-9:
49
+ return f"{int(round(v)):,}"
50
+ return f"{v:,.2f}"
51
+
52
+
53
+ def fill(text, last):
54
+ if text is None:
55
+ return None
56
+
57
+ def sub(m):
58
+ if last is None:
59
+ return "[no result]"
60
+ rows = last["rows"]
61
+ i, j = int(m.group(1)), int(m.group(2))
62
+ if i >= len(rows) or j >= len(_numeric_cells(rows[i])):
63
+ return "[missing cell]"
64
+ v = _numeric_cells(rows[i])[j]
65
+ if m.group(3):
66
+ v = v * float(m.group(3)[1:])
67
+ return _fmt(v, bool(m.group(4)))
68
+
69
+ return TEMPLATE.sub(sub, text)
70
+
71
+
72
+ def final_text_of(fx, last):
73
+ if "final_text" in fx:
74
+ return fx["final_text"]
75
+ final = dict(fx["final"])
76
+ final["answer"] = fill(final.get("answer"), last)
77
+ return json.dumps(final)
78
+
79
+
80
+ async def execute_mcp(fixtures, db, planted_bug):
81
+ env = {"ANALYTICSRECEIPTS_DB": str(Path(db).resolve())}
82
+ args = (["-I", "-m", "analyticsreceipts.control_server", "--bug", planted_bug] if planted_bug
83
+ else ["-I", "-m", "analyticsreceipts.server"])
84
+ p = StdioServerParameters(command=sys.executable, args=args, env=env)
85
+ out = []
86
+ async with stdio_client(p) as (r, w):
87
+ async with ClientSession(r, w) as s:
88
+ await s.initialize()
89
+ for fx in fixtures:
90
+ steps, receipts, last = [], [], None
91
+ for st in fx["steps"]:
92
+ res = await s.call_tool(st["tool"], st["args"])
93
+ text = res.content[0].text if res.content else ""
94
+ rec = {"tool": st["tool"], "args": st["args"], "tool_error": bool(res.is_error)}
95
+ if not res.is_error:
96
+ body = json.loads(text)
97
+ rc = body.get("receipt") if isinstance(body, dict) else None
98
+ rec["result_type"] = body.get("result_type") if isinstance(body, dict) else None
99
+ if rc:
100
+ receipts.append(rc)
101
+ if not rc.get("error"):
102
+ last = rc["result"]
103
+ else:
104
+ rec["error_text"] = text[:300]
105
+ steps.append(rec)
106
+ out.append({"id": fx["id"], "arm": "mcp", "item": fx["item"], "planted": fx["planted"],
107
+ "steps": steps, "receipts": receipts, "final_text": final_text_of(fx, last)})
108
+ return out
109
+
110
+
111
+ def execute_raw(fixtures, db):
112
+ tools = RawTools(Executor(db))
113
+ out = []
114
+ for fx in fixtures:
115
+ steps, receipts, last = [], [], None
116
+ for st in fx["steps"]:
117
+ body = tools.call(st["tool"], st["args"])
118
+ rc = body.get("receipt")
119
+ steps.append({"tool": st["tool"], "args": st["args"], "tool_error": False,
120
+ "result_type": body.get("result_type"), "error_text": body.get("error")})
121
+ if rc:
122
+ receipts.append(rc)
123
+ if not rc.get("error"):
124
+ last = rc["result"]
125
+ out.append({"id": fx["id"], "arm": "raw", "item": fx["item"], "planted": fx["planted"],
126
+ "steps": steps, "receipts": receipts, "final_text": final_text_of(fx, last)})
127
+ return out
128
+
129
+
130
+ def run_canary(db, controls, planted_bug):
131
+ model = load(default_model_path())
132
+ fn = compile_request
133
+ if planted_bug:
134
+ from .broken import compile_request_broken
135
+ fn = compile_request_broken
136
+ q = fn(model, controls["canary"]["request"])
137
+ _, rows, _, err = Executor(db).run(q.sql, q.params)
138
+ got = None if err else rows[0][0]
139
+ ok = got is not None and abs(got - controls["canary"]["known_answer"]) <= controls["canary"]["tolerance"]
140
+ return {"result": got, "known_answer": controls["canary"]["known_answer"], "pass": ok, "error": err}
141
+
142
+
143
+ def execute(db, out_path, planted_bug=None):
144
+ tpath = ROOT / "fixtures" / "transcripts-v1.json"
145
+ gpath = ROOT / "fixtures" / "expected-grades-v1.json"
146
+ transcripts = json.loads(tpath.read_text())
147
+ controls = json.loads(gpath.read_text())["controls"]
148
+ fixtures = transcripts["fixtures"]
149
+ mcp = anyio.run(execute_mcp, [f for f in fixtures if f["arm"] == "mcp"], db, planted_bug)
150
+ raw = execute_raw([f for f in fixtures if f["arm"] == "raw"], db)
151
+ order = {f["id"]: i for i, f in enumerate(fixtures)}
152
+ replay = {
153
+ "label": "FIXTURES" + (f" — PLANTED BROKEN COMPILER ({planted_bug})" if planted_bug else ""),
154
+ "planted_bug": planted_bug,
155
+ "inputs": {"transcripts_sha256": sha(tpath), "expected_grades_sha256": sha(gpath),
156
+ "golden_sha256": sha(ROOT / "golden" / "golden-v1.json"),
157
+ "expected_tables_sha256": sha(EXPECTED_TABLES),
158
+ "semantic_model_sha256": load(default_model_path()).sha256},
159
+ "canary": run_canary(db, controls, planted_bug),
160
+ "runs": sorted(mcp + raw, key=lambda r: order[r["id"]]),
161
+ }
162
+ Path(out_path).parent.mkdir(parents=True, exist_ok=True)
163
+ Path(out_path).write_text(json.dumps(replay, indent=1, default=str) + "\n")
164
+ return replay
165
+
166
+
167
+ def main():
168
+ ap = argparse.ArgumentParser()
169
+ ap.add_argument("--db", default=os.environ.get("ANALYTICSRECEIPTS_DB"), required=False)
170
+ ap.add_argument("--out", required=True)
171
+ ap.add_argument("--planted-bug", default=None)
172
+ a = ap.parse_args()
173
+ if not a.db:
174
+ raise SystemExit("--db or ANALYTICSRECEIPTS_DB is required")
175
+ data_root() # a clear error when the eval data is not reachable
176
+ r = execute(a.db, a.out, a.planted_bug)
177
+ print(f"{r['label']}: {len(r['runs'])} fixtures executed; canary pass={r['canary']['pass']} -> {a.out}")
178
+
179
+
180
+ if __name__ == "__main__":
181
+ main()
@@ -0,0 +1,348 @@
1
+ """Four-axis grader. Deterministic: no model is used to grade.
2
+
3
+ Axes, graded per answer:
4
+ a metric the governed receipt's metric_id and version equal gold (MCP arm). "N/A" for the
5
+ raw-SQL arm, which has no metric id; N/A is never counted as a failure or a pass.
6
+ b query the last successful result table equals the expected table: same row count, every
7
+ expected cell matched (order- and column-name-invariant; extra columns allowed;
8
+ numbers within tolerance; dates normalised).
9
+ c number every number the answer asserts (dates and numbers from the question excluded)
10
+ equals an expected cell within its rounding; at least one number is asserted.
11
+ d interpretation the agent's resolved_spec equals gold field by field; on ambiguous or unanswerable
12
+ items, a clarification or an abstention passes.
13
+ Cells: correct · valid_wrong_question · valid_misreported · valid_misstated_spec · valid_metric_mismatch ·
14
+ invalid_right_intent · invalid_wrong · no_answer · correct_abstain · false_abstain · unwarranted_answer.
15
+ The headline count is valid_wrong_question: the query ran, and the EXECUTED query answered a different
16
+ question (axis b fails, or a trap is sprung). Rule ER-1, valid runs in order:
17
+ not b, or trap_sprung valid_wrong_question
18
+ a ok, b, c, d correct
19
+ a ok, b, d, not c valid_misreported
20
+ a ok, b, not d valid_misstated_spec (right table; the stated reading differs from gold)
21
+ not a ok, b valid_metric_mismatch (right table; receipt metric id or version is not gold's)
22
+ Spec equivalences (axis d, ER-1): E1 a grain equals no grain when every period lies inside one bucket of
23
+ that grain; E2 `in [x]` equals `eq x`. Nothing data-dependent is treated as equivalent.
24
+ Trap items: trap_sprung is True when the result equals the trap's naive reading, or (interpretation trap)
25
+ when the incomplete flag is not raised, a missing resolved_spec included. It is None, with a reason, when
26
+ there is no answer to evaluate.
27
+ """
28
+
29
+ import datetime as dt
30
+ import re
31
+
32
+ from .numbers import asserted, matches
33
+
34
+ ATOL, RTOL = 0.005, 1e-9 # a cell matches within min(ATOL, ATOL x |expected|): half a penny for money,
35
+ # 0.5 % relative for values under 1 (ratios); review finding 12
36
+ ANSWER_BEHAVIORS = ("answer", "answer_with_caveat")
37
+
38
+
39
+ # ---------------- table matching (axis b) ----------------
40
+ _MONTHS = ("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec")
41
+
42
+ def _norm_cell(v):
43
+ if isinstance(v, bool):
44
+ return ("s", str(v).lower())
45
+ if isinstance(v, (int, float)):
46
+ return ("n", float(v))
47
+ s = str(v).strip()
48
+ try:
49
+ return ("n", float(s.replace(",", "")))
50
+ except ValueError:
51
+ pass
52
+ m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", s)
53
+ if m:
54
+ return ("d", m.group(0))
55
+ m = re.match(r"^(\d{4})-(\d{2})$", s)
56
+ if m:
57
+ return ("d", f"{m.group(1)}-{m.group(2)}-01")
58
+ m = re.match(r"^([A-Za-z]{3,9})\.?\s+(\d{4})$", s)
59
+ if m and m.group(1).lower()[:3] in _MONTHS:
60
+ return ("d", f"{m.group(2)}-{_MONTHS.index(m.group(1).lower()[:3]) + 1:02d}-01")
61
+ m = re.match(r"^(\d{4})\s*-?\s*Q([1-4])$", s, re.I) or re.match(r"^Q([1-4])\s+(\d{4})$", s, re.I)
62
+ if m:
63
+ y, q = (m.group(1), m.group(2)) if len(m.group(1)) == 4 else (m.group(2), m.group(1))
64
+ return ("d", f"{y}-{(int(q) - 1) * 3 + 1:02d}-01")
65
+ return ("s", s.lower())
66
+
67
+
68
+ def _cell_eq(e, a):
69
+ if e[0] != a[0]:
70
+ return False
71
+ if e[0] == "n":
72
+ return abs(e[1] - a[1]) <= max(min(ATOL, ATOL * abs(e[1])), RTOL * abs(e[1]))
73
+ return e[1] == a[1]
74
+
75
+
76
+ def _row_covers(exp_row, agent_row):
77
+ pool = list(agent_row)
78
+ for e in exp_row:
79
+ for i, a in enumerate(pool):
80
+ if _cell_eq(e, a):
81
+ pool.pop(i)
82
+ break
83
+ else:
84
+ return False
85
+ return True
86
+
87
+
88
+ def table_match(expected, columns, rows):
89
+ """expected: {"columns", "rows"}. Rows match one-to-one, cells by value (extra agent columns allowed).
90
+
91
+ Comparison tables carry a 'period' label ('current' / 'comparison'). If the agent's table also has a
92
+ 'period' column, the labels are compared like any other cell, so a table with the periods swapped
93
+ fails. If it has no such column (e.g. raw SQL), rows are matched IN ORDER, current period first.
94
+ """
95
+ labelled = "period" in expected["columns"]
96
+ agent_labelled = any(str(c).lower() == "period" for c in (columns or []))
97
+ keep = [i for i, c in enumerate(expected["columns"]) if c != "period" or agent_labelled]
98
+ exp = [[_norm_cell(r[i]) for i in keep] for r in expected["rows"]]
99
+ got = [[_norm_cell(v) for v in r] for r in rows]
100
+ if len(exp) != len(got):
101
+ return False
102
+ if labelled and not agent_labelled:
103
+ return all(_row_covers(e, g) for e, g in zip(exp, got))
104
+
105
+ return _perfect_matching([[j for j, g in enumerate(got) if _row_covers(e, g)] for e in exp], len(got))
106
+
107
+
108
+ def _perfect_matching(adj, n_right):
109
+ """True iff every left node can be matched to a distinct right node (Kuhn's augmenting paths, written
110
+ with an explicit stack: polynomial time and no recursion, so 1,000-row tables are fine)."""
111
+ match_right = [-1] * n_right # right node -> left node
112
+ match_left = [-1] * len(adj) # left node -> right node
113
+ for start in range(len(adj)):
114
+ seen = [False] * n_right
115
+ parent = {} # right node -> left node it was reached from
116
+ stack = [(start, 0)] # (left node, index of its next candidate)
117
+ found = None
118
+ while stack:
119
+ u, k = stack.pop()
120
+ if k >= len(adj[u]):
121
+ continue
122
+ stack.append((u, k + 1))
123
+ v = adj[u][k]
124
+ if seen[v]:
125
+ continue
126
+ seen[v] = True
127
+ parent[v] = u
128
+ if match_right[v] == -1:
129
+ found = v
130
+ break
131
+ stack.append((match_right[v], 0))
132
+ if found is None:
133
+ return False
134
+ v = found # flip the augmenting path back to `start`
135
+ while v != -1:
136
+ u = parent[v]
137
+ prev = match_left[u]
138
+ match_left[u], match_right[v] = v, u
139
+ v = prev
140
+ return True
141
+
142
+
143
+ # ---------------- spec comparison (axis d) ----------------
144
+ def _norm_filters(fs):
145
+ out = []
146
+ for f in fs or []:
147
+ op = f.get("op")
148
+ v = f.get("value")
149
+ if op == "in" and isinstance(v, list) and len(v) == 1: # ER-1 E2
150
+ op, v = "eq", v[0]
151
+ if op in ("is_null", "not_null"):
152
+ v = None
153
+ elif op == "in" and isinstance(v, list):
154
+ v = sorted(str(x) for x in v)
155
+ elif v is not None:
156
+ v = str(v)
157
+ out.append((f.get("dim"), op, v if not isinstance(v, list) else tuple(v)))
158
+ return sorted(out, key=repr)
159
+
160
+
161
+ def _norm_period(p):
162
+ if not p:
163
+ return None
164
+ return (str(p.get("start")), str(p.get("end")))
165
+
166
+
167
+ def _flag(v):
168
+ if isinstance(v, str):
169
+ return v.strip().lower() in ("true", "yes", "1")
170
+ return bool(v)
171
+
172
+
173
+ def norm_spec(s):
174
+ if not isinstance(s, dict):
175
+ return None
176
+ comp = s.get("comparison")
177
+ rk = s.get("ranking")
178
+ return {
179
+ "metric": s.get("metric"),
180
+ "filters": _norm_filters(s.get("filters")),
181
+ "period": _norm_period(s.get("period")),
182
+ "incomplete": _flag((s.get("period") or {}).get("incomplete", False)),
183
+ "comparison": _norm_period(comp.get("period")) if isinstance(comp, dict) else None,
184
+ "grain": s.get("grain"),
185
+ "dimensions": sorted(s.get("dimensions") or []),
186
+ "ranking": (rk.get("by", s.get("metric")), rk.get("direction", "desc"), rk.get("limit")) if isinstance(rk, dict) else None,
187
+ }
188
+
189
+
190
+ def _bucket_key(day, grain):
191
+ d = dt.date.fromisoformat(str(day)[:10])
192
+ return {"day": (d.year, d.month, d.day), "month": (d.year, d.month),
193
+ "quarter": (d.year, (d.month - 1) // 3), "year": (d.year,)}[grain]
194
+
195
+
196
+ def _one_bucket(period, grain):
197
+ """True when the period [start, end] lies inside one bucket of `grain` (ER-1 E1)."""
198
+ try:
199
+ return _bucket_key(period[0], grain) == _bucket_key(period[1], grain)
200
+ except (KeyError, TypeError, ValueError):
201
+ return False
202
+
203
+
204
+ def _grain_equivalent(a, g):
205
+ if a["grain"] == g["grain"]:
206
+ return True
207
+ if None not in (a["grain"], g["grain"]) or not g["period"]:
208
+ return False
209
+ grain = a["grain"] or g["grain"]
210
+ return all(_one_bucket(p, grain) for p in (g["period"], g["comparison"]) if p)
211
+
212
+
213
+ def spec_mismatches(agent_spec, gold_spec):
214
+ a, g = norm_spec(agent_spec), norm_spec(gold_spec)
215
+ if a is None:
216
+ return ["resolved_spec missing"]
217
+ return [k for k in g if a[k] != g[k] and not (k == "grain" and _grain_equivalent(a, g))]
218
+
219
+
220
+ # ---------------- grading ----------------
221
+ UP_WORDS = re.compile(r"\b(rose|rise|rises|risen|increase[sd]?|grew|grow[sn]?|growth|higher|gain(?:ed|s)?|up(?! to\b))\b", re.I)
222
+ DOWN_WORDS = re.compile(r"\b(fell|fall(?:s|en)?|decrease[sd]?|declin(?:e|ed|es)|drop(?:ped|s)?|lower|down|shr[au]nk)\b", re.I)
223
+
224
+
225
+ def _comparison_values(expected):
226
+ """(current, comparison) for a two-row comparison table, else None. Rows are current first."""
227
+ cells = [float(v) for r in expected["rows"] for v in r if isinstance(v, (int, float)) and not isinstance(v, bool)]
228
+ return tuple(cells) if len(cells) == 2 else None
229
+
230
+
231
+ def _allowed_numbers(expected, comparison):
232
+ cells = [float(v) for r in expected["rows"] for v in r if isinstance(v, (int, float)) and not isinstance(v, bool)]
233
+ pair = _comparison_values(expected) if comparison else None
234
+ if pair:
235
+ cur, prev = pair
236
+ # magnitudes of the change are allowed; its direction is checked separately (direction_error)
237
+ cells += [cur - prev, abs(cur - prev)]
238
+ if prev:
239
+ cells += [(cur - prev) / prev, abs(cur - prev) / abs(prev), cur / prev]
240
+ return cells
241
+
242
+
243
+ def direction_error(answer, expected, comparison):
244
+ """A reason string if the answer's direction word contradicts the actual change, else None."""
245
+ pair = _comparison_values(expected) if comparison else None
246
+ if not pair or pair[0] == pair[1]:
247
+ return None
248
+ up, down = bool(UP_WORDS.search(answer or "")), bool(DOWN_WORDS.search(answer or ""))
249
+ actual = "up" if pair[0] > pair[1] else "down"
250
+ if (up and not down and actual == "down") or (down and not up and actual == "up"):
251
+ return f"answer says {'up' if up else 'down'} but the change is {actual}"
252
+ return None
253
+
254
+
255
+ def grade(item, expected, transcript):
256
+ """item: golden item. expected: materialised entry or None. transcript: {arm, final_text, receipts[]}.
257
+ Returns the grade dict (axes, cell, trap_sprung, reasons)."""
258
+ from .contract import parse_final
259
+
260
+ arm = transcript["arm"]
261
+ final, why = parse_final(transcript.get("final_text"))
262
+ ok = [r for r in transcript.get("receipts", []) if r and not r.get("error")]
263
+ last = ok[-1] if ok else None
264
+ want_answer = item["expected_behavior"] in ANSWER_BEHAVIORS
265
+ g = {"item": item["id"], "arm": arm, "a": None, "b": None, "c": None, "d": None, "cell": None,
266
+ "trap_sprung": None, "reasons": [], "contract_error": why, "valid": bool(ok)}
267
+
268
+ behavior = final.get("behavior") if final else None
269
+ if final is None:
270
+ g["cell"] = "no_answer"
271
+ g["reasons"].append(why)
272
+ for k in "abcd":
273
+ g[k] = "N/A" if (k == "a" and arm == "raw") else False
274
+ return _trap_not_evaluated(item, g)
275
+
276
+ if not want_answer:
277
+ if behavior in ("clarify", "abstain"):
278
+ g.update(a="N/A", b="N/A", c="N/A", d=True, cell="correct_abstain")
279
+ else:
280
+ g.update(a="N/A" if arm == "raw" else False, b=False, c=False, d=False, cell="unwarranted_answer")
281
+ g["reasons"].append(f"answered a question that expects {item['expected_behavior']}")
282
+ return _trap_not_evaluated(item, g)
283
+
284
+ gold = item["resolved_spec"]
285
+ if behavior in ("clarify", "abstain"):
286
+ g.update(a="N/A" if arm == "raw" else False, b=False, c=False, d=False, cell="false_abstain")
287
+ g["reasons"].append(f"{behavior} on an answerable item")
288
+ return _trap_not_evaluated(item, g)
289
+
290
+ # axis a
291
+ if arm == "raw":
292
+ g["a"] = "N/A"
293
+ else:
294
+ gov = [r for r in ok if r.get("kind") in ("governed_metric", "control_metric")] # metric identity is read the same way
295
+ r = gov[-1] if gov else None
296
+ g["a"] = bool(r and r["metric_id"] == gold["metric"] and r["metric_def_version"] == expected["metric_def_version"])
297
+ if not g["a"]:
298
+ if r and r["metric_id"] == gold["metric"]:
299
+ g["reasons"].append(f"metric version {r['metric_def_version']} != {expected['metric_def_version']}")
300
+ else:
301
+ g["reasons"].append(f"metric {r['metric_id'] if r else None} != {gold['metric']}")
302
+ # axis b
303
+ g["b"] = bool(last and table_match(expected, last["result"]["columns"], last["result"]["rows"]))
304
+ if not g["b"]:
305
+ g["reasons"].append("result table does not match the expected table")
306
+ # axis c
307
+ nums = asserted(final.get("answer") or "", item["question"])
308
+ allowed = _allowed_numbers(expected, gold.get("comparison"))
309
+ g["c"] = bool(nums) and all(matches(n, allowed) for n in nums)
310
+ if not g["c"]:
311
+ g["reasons"].append("no number asserted" if not nums else "an asserted number matches no expected cell")
312
+ derr = direction_error(final.get("answer") or "", expected, gold.get("comparison"))
313
+ if derr:
314
+ g["c"] = False
315
+ g["reasons"].append(derr)
316
+ # axis d
317
+ mm = spec_mismatches(final.get("resolved_spec"), gold)
318
+ g["d"] = not mm
319
+ if mm:
320
+ g["reasons"].append("spec fields differ: " + ", ".join(mm))
321
+ # trap first
322
+ if item.get("trap"):
323
+ naive = expected.get("naive")
324
+ if naive is not None:
325
+ g["trap_sprung"] = bool(last and table_match(naive, last["result"]["columns"], last["result"]["rows"]))
326
+ else: # interpretation trap (incomplete period): sprung if the flag was not raised
327
+ g["trap_sprung"] = "incomplete" in mm or "resolved_spec missing" in mm
328
+ a_ok = g["a"] in (True, "N/A")
329
+ if not g["valid"]:
330
+ g["cell"] = "invalid_right_intent" if g["d"] else "invalid_wrong"
331
+ g["reasons"].append("no query succeeded")
332
+ elif not g["b"] or g["trap_sprung"]:
333
+ g["cell"] = "valid_wrong_question"
334
+ elif a_ok and g["c"] and g["d"]:
335
+ g["cell"] = "correct"
336
+ elif a_ok and g["d"]:
337
+ g["cell"] = "valid_misreported"
338
+ elif a_ok:
339
+ g["cell"] = "valid_misstated_spec"
340
+ else:
341
+ g["cell"] = "valid_metric_mismatch"
342
+ return g
343
+
344
+
345
+ def _trap_not_evaluated(item, g):
346
+ if item.get("trap"):
347
+ g["reasons"].append("trap not evaluated: no answer to evaluate")
348
+ return g