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.
- analyticsreceipts/__init__.py +3 -0
- analyticsreceipts/broken.py +25 -0
- analyticsreceipts/compare.py +67 -0
- analyticsreceipts/compiler.py +216 -0
- analyticsreceipts/contract.py +50 -0
- analyticsreceipts/control_server.py +26 -0
- analyticsreceipts/data/OSSIE-NOTICE.md +7 -0
- analyticsreceipts/data/online_retail.ossie.yaml +128 -0
- analyticsreceipts/data/ossie-schema.json +374 -0
- analyticsreceipts/execution.py +185 -0
- analyticsreceipts/fixtures.py +181 -0
- analyticsreceipts/grader.py +348 -0
- analyticsreceipts/live.py +423 -0
- analyticsreceipts/load.py +74 -0
- analyticsreceipts/materialise.py +86 -0
- analyticsreceipts/model.py +122 -0
- analyticsreceipts/numbers.py +93 -0
- analyticsreceipts/paths.py +31 -0
- analyticsreceipts/rawtools.py +105 -0
- analyticsreceipts/receipt.schema.json +56 -0
- analyticsreceipts/receipts.py +17 -0
- analyticsreceipts/report.py +162 -0
- analyticsreceipts/server.py +165 -0
- analyticsreceipts-0.0.2.dist-info/METADATA +313 -0
- analyticsreceipts-0.0.2.dist-info/RECORD +29 -0
- analyticsreceipts-0.0.2.dist-info/WHEEL +4 -0
- analyticsreceipts-0.0.2.dist-info/licenses/LICENSE +21 -0
- analyticsreceipts-0.0.2.dist-info/licenses/vendor/ossie/LICENSE +201 -0
- analyticsreceipts-0.0.2.dist-info/licenses/vendor/ossie/NOTICE +5 -0
|
@@ -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
|