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,423 @@
1
+ """LIVE-LITE harness: a local model (Ollama) answers golden questions in two arms, as a fixed-priority
2
+ queue under hard time caps. Whatever completes is the result; the order is fixed before the run.
3
+
4
+ mcp the semantic-layer MCP server's tools (no raw SQL)
5
+ raw the harness's describe_tables + run_sql
6
+ Both arms get the same system prompt: the same business conventions text (the model's ai_context) and
7
+ the same output contract. Only the tools differ. The model sees compact tool results; the harness keeps
8
+ every full receipt. Thinking is recorded as a size only and is not fed back.
9
+
10
+ Caps and floors (each one has a test in tests/test_live_caps.py, with a stub model):
11
+ * per-loop cap: a loop that runs past `loop_cap_s` is aborted and recorded as a loop-timeout;
12
+ * global soft cap: no new loop starts once `soft_cap_s` has elapsed;
13
+ * global hard cap: at `hard_cap_s` the in-flight record is saved (incomplete) and the process stops;
14
+ * disk floor: below 1 GiB free, the same clean stop;
15
+ * battery floor: at or below `battery_floor_pct`, the same clean stop, then the model is unloaded.
16
+ Every run record is appended to the JSONL replay as soon as it ends (flush + fsync). A record saved by
17
+ a stop is marked incomplete. A resumed run (--resume) continues from the first (item, arm) that has no
18
+ complete record, appending to the same replay; incomplete records are re-run.
19
+
20
+ Usage:
21
+ python -m analyticsreceipts.live --config live-lite-config.json --db retail.duckdb --out replay.jsonl [--resume]
22
+ """
23
+
24
+ import argparse
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import re
29
+ import subprocess
30
+ import sys
31
+ import threading
32
+ import time
33
+ import urllib.request
34
+ from pathlib import Path
35
+
36
+ import anyio
37
+
38
+ from .contract import CONTRACT_TEXT
39
+ from .model import default_model_path, load
40
+ from .paths import find_root
41
+ from .rawtools import TOOL_SPECS, compact
42
+
43
+ ROOT = find_root() or Path.cwd() # eval data: see paths.py (a clone's root, or $ANALYTICSRECEIPTS_ROOT)
44
+ OLLAMA = "http://127.0.0.1:11434/api/chat"
45
+
46
+
47
+ # ---------------------------------------------------------------- prompts and IO
48
+ def system_prompt(model, arm, addition=None):
49
+ """The system prompt for one arm. `addition` (from the run config, per arm) is inserted right after the
50
+ tool sentence; without it the prompt is exactly the LIVE-LITE v1 prompt."""
51
+ tools = ("Use the tools list_metrics, get_metric, list_dimensions, get_dimension_values and query_metric."
52
+ if arm == "mcp" else
53
+ "Use the tools describe_tables and run_sql (DuckDB SQL over the table raw_lines).")
54
+ if addition:
55
+ tools = tools + " " + addition
56
+ return ("You answer business questions about a UK online retailer's sales data. " + tools + "\n\n"
57
+ "Business conventions:\n" + model.instructions + "\n\n" + CONTRACT_TEXT)
58
+
59
+
60
+ def append_jsonl(path, obj):
61
+ with open(path, "a") as f:
62
+ f.write(json.dumps(obj, default=str) + "\n")
63
+ f.flush()
64
+ os.fsync(f.fileno())
65
+
66
+
67
+ def read_jsonl(path):
68
+ p = Path(path)
69
+ if not p.exists():
70
+ return []
71
+ return [json.loads(l) for l in p.read_text().splitlines() if l.strip()]
72
+
73
+
74
+ def ollama_chat(messages, tools, opts, timeout_s):
75
+ body = {"model": opts["model"], "messages": messages, "stream": False, "think": opts["think"],
76
+ "tools": tools, "options": {"num_ctx": opts["num_ctx"], "seed": opts["seed"]}}
77
+ req = urllib.request.Request(OLLAMA, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
78
+ with urllib.request.urlopen(req, timeout=timeout_s) as r:
79
+ return json.load(r)
80
+
81
+
82
+ def read_battery():
83
+ out = subprocess.run(["pmset", "-g", "batt"], capture_output=True, text=True).stdout
84
+ m = re.search(r"(\d+)%", out)
85
+ src = re.search(r"'([^']+)'", out)
86
+ return (int(m.group(1)) if m else None), (src.group(1) if src else None)
87
+
88
+
89
+ def read_free_kib():
90
+ df = subprocess.run(["df", "-k", os.path.expanduser("~")], capture_output=True, text=True).stdout.strip().splitlines()[-1]
91
+ return int(df.split()[3]), df
92
+
93
+
94
+ def read_swap():
95
+ return subprocess.run(["sysctl", "vm.swapusage"], capture_output=True, text=True).stdout.strip()
96
+
97
+
98
+ def read_lowpowermode():
99
+ out = subprocess.run(["pmset", "-g"], capture_output=True, text=True).stdout
100
+ m = re.search(r"lowpowermode\s+(\d)", out)
101
+ return int(m.group(1)) if m else None
102
+
103
+
104
+ def ollama_unload(model):
105
+ exe = Path.home() / ".local/ollama-0.40.0/ollama"
106
+ cmd = [str(exe) if exe.exists() else "ollama", "stop", model]
107
+ subprocess.run(cmd, capture_output=True, text=True, timeout=60)
108
+
109
+
110
+ def conditions():
111
+ pct, src = read_battery()
112
+ free, _ = read_free_kib()
113
+ return {"t": time.strftime("%Y-%m-%d %H:%M:%S"), "power_source": src, "battery_pct": pct,
114
+ "lowpowermode": read_lowpowermode(), "free_kib": free, "swapusage": read_swap()}
115
+
116
+
117
+ # ---------------------------------------------------------------- queue
118
+ def queue_from_config(cfg):
119
+ return [(item, arm) for item in cfg["items"] for arm in cfg["arms"] for _ in range(cfg.get("k", 1))]
120
+
121
+
122
+ def remaining_pairs(queue, replay_records):
123
+ """The fixed queue minus pairs that already have a COMPLETE record (incomplete ones are re-run)."""
124
+ done = {}
125
+ for r in replay_records:
126
+ if r.get("kind") == "run" and not r.get("incomplete"):
127
+ key = (r["item"], r["arm"])
128
+ done[key] = done.get(key, 0) + 1
129
+ out = []
130
+ for pair in queue:
131
+ if done.get(pair, 0) > 0:
132
+ done[pair] -= 1
133
+ else:
134
+ out.append(pair)
135
+ return out
136
+
137
+
138
+ class Stop(Exception):
139
+ def __init__(self, reason):
140
+ super().__init__(reason)
141
+ self.reason = reason
142
+
143
+
144
+ # ---------------------------------------------------------------- runner
145
+ class Runner:
146
+ """All side effects are injectable so the caps can be tested with a stub model and a fake clock."""
147
+
148
+ def __init__(self, cfg, out_path, *, chat=ollama_chat, clock=time.monotonic, battery=read_battery,
149
+ free_kib=read_free_kib, swap=read_swap, unload=ollama_unload, exit_fn=os._exit,
150
+ sample_every_s=60.0, start_sampler=True, prior_elapsed_s=0.0):
151
+ self.cfg, self.out = cfg, str(out_path)
152
+ self.chat, self.clock, self.battery, self.free_kib, self.swap = chat, clock, battery, free_kib, swap
153
+ self.unload, self.exit_fn = unload, exit_fn
154
+ self.t0 = clock()
155
+ self.prior_elapsed_s = float(prior_elapsed_s) # run time of earlier sessions of the same queue
156
+ self.lock = threading.Lock()
157
+ self.inflight = None # the record of the loop in progress
158
+ self.stopped = None
159
+ self.sample_every_s = sample_every_s
160
+ self._stop_evt = threading.Event()
161
+ if start_sampler:
162
+ threading.Thread(target=self._sampler, daemon=True).start()
163
+
164
+ # --- guards ---------------------------------------------------------------
165
+ def elapsed(self):
166
+ """Run time across sessions: earlier sessions' run time plus this session's (a break is not counted)."""
167
+ return self.prior_elapsed_s + self.clock() - self.t0
168
+
169
+ def guard_reason(self):
170
+ """Reason to stop NOW (hard), or None. Checked by the sampler and between steps."""
171
+ if self.elapsed() >= self.cfg["hard_cap_s"]:
172
+ return "hard_cap"
173
+ free, _ = self.free_kib()
174
+ if free < self.cfg["disk_floor_kib"]:
175
+ return "disk_floor"
176
+ pct, _ = self.battery()
177
+ if pct is not None and pct <= self.cfg["battery_floor_pct"]:
178
+ return "battery_floor"
179
+ return None
180
+
181
+ def hard_stop(self, reason):
182
+ """Save the in-flight record as incomplete, unload the model, record the stop, exit."""
183
+ with self.lock:
184
+ if self.stopped:
185
+ return
186
+ self.stopped = reason
187
+ if self.inflight is not None and not self.inflight.get("_saved"):
188
+ rec = {k: v for k, v in self.inflight.items() if not k.startswith("_")}
189
+ rec.update(incomplete=True, stop_reason=reason, seconds=round(self.clock() - self.inflight["_t0"], 2))
190
+ append_jsonl(self.out, rec)
191
+ self.inflight["_saved"] = True
192
+ try:
193
+ self.unload(self.cfg["model"])
194
+ finally:
195
+ append_jsonl(self.out, {"kind": "event", "event": "stop", "reason": reason,
196
+ "elapsed_s": round(self.elapsed(), 1), "conditions": self._cond()})
197
+ self.exit_fn(3)
198
+
199
+ def _cond(self):
200
+ pct, src = self.battery()
201
+ free, _ = self.free_kib()
202
+ return {"t": time.strftime("%Y-%m-%d %H:%M:%S"), "battery_pct": pct, "power_source": src,
203
+ "free_kib": free, "swapusage": self.swap()}
204
+
205
+ def sample_once(self):
206
+ append_jsonl(self.out + ".resources.log", self._cond())
207
+ reason = self.guard_reason()
208
+ if reason:
209
+ self.hard_stop(reason)
210
+ return reason
211
+
212
+ def _sampler(self):
213
+ while not self._stop_evt.is_set():
214
+ self.sample_once()
215
+ self._stop_evt.wait(self.sample_every_s)
216
+
217
+ # --- one loop --------------------------------------------------------------
218
+ async def one_run(self, arm, item, call_tool, tools, semantic_model, seed):
219
+ cfg = self.cfg
220
+ addition = (cfg.get("system_prompt_addition") or {}).get(arm)
221
+ msgs = [{"role": "system", "content": system_prompt(semantic_model, arm, addition)},
222
+ {"role": "user", "content": item["question"]}]
223
+ t0 = self.clock()
224
+ rec = {"kind": "run", "label": "LIVE-LITE", "arm": arm, "item": item["id"], "seed": seed,
225
+ "model": cfg["model"], "think": cfg["think"], "num_ctx": cfg["num_ctx"], "turns": [],
226
+ "tool_calls": [], "receipts": [], "final_text": None, "error": None, "loop_timeout": False,
227
+ "_t0": t0}
228
+ with self.lock:
229
+ self.inflight = rec
230
+ try:
231
+ for _ in range(cfg["max_turns"]):
232
+ remaining = cfg["loop_cap_s"] - (self.clock() - t0)
233
+ if remaining <= 0:
234
+ rec["loop_timeout"] = True
235
+ break
236
+ try:
237
+ resp = self.chat(msgs, tools, dict(cfg, seed=seed), min(cfg["request_timeout_s"], remaining))
238
+ except Exception as e: # a request timeout inside the loop cap is a loop-timeout too
239
+ if self.clock() - t0 >= cfg["loop_cap_s"] - 1:
240
+ rec["loop_timeout"] = True
241
+ rec["error"] = f"{type(e).__name__}: {e}"
242
+ break
243
+ m = resp["message"]
244
+ rec["turns"].append({k: resp.get(k) for k in ("prompt_eval_count", "prompt_eval_duration",
245
+ "eval_count", "eval_duration", "total_duration")}
246
+ | {"n_tool_calls": len(m.get("tool_calls") or []),
247
+ "thinking_chars": len(m.get("thinking") or ""),
248
+ "content_chars": len(m.get("content") or "")})
249
+ msgs.append({k: v for k, v in m.items() if k in ("role", "content", "tool_calls")})
250
+ if not m.get("tool_calls"):
251
+ rec["final_text"] = m.get("content", "")
252
+ break
253
+ for tc in m["tool_calls"]:
254
+ name, args = tc["function"]["name"], tc["function"].get("arguments") or {}
255
+ body, is_error = await call_tool(name, args)
256
+ rc = body.get("receipt") if isinstance(body, dict) else None
257
+ if rc:
258
+ rec["receipts"].append(rc)
259
+ rec["tool_calls"].append({"name": name, "arguments": args, "tool_error": is_error,
260
+ "result_type": body.get("result_type") if isinstance(body, dict) else None})
261
+ msgs.append({"role": "tool", "tool_name": name,
262
+ "content": compact(body) if isinstance(body, dict) else str(body)})
263
+ if self.clock() - t0 >= cfg["loop_cap_s"]:
264
+ rec["loop_timeout"] = True
265
+ break
266
+ except Exception as e:
267
+ rec["error"] = f"{type(e).__name__}: {e}"
268
+ rec["seconds"] = round(self.clock() - t0, 2)
269
+ if rec["loop_timeout"] and not rec["error"]:
270
+ rec["error"] = f"loop_timeout: exceeded {cfg['loop_cap_s']} s"
271
+ with self.lock:
272
+ if not rec.get("_saved"):
273
+ append_jsonl(self.out, {k: v for k, v in rec.items() if not k.startswith("_")})
274
+ rec["_saved"] = True
275
+ self.inflight = None
276
+ return rec
277
+
278
+ # --- the queue -------------------------------------------------------------
279
+ async def run_queue(self, pairs, golden, semantic_model, tool_router, tool_lists):
280
+ """Runs pairs in order. Returns the stop reason (soft cap or queue complete); hard stops exit."""
281
+ for idx, (item_id, arm) in enumerate(pairs):
282
+ if self.elapsed() >= self.cfg["soft_cap_s"]:
283
+ append_jsonl(self.out, {"kind": "event", "event": "stop", "reason": "soft_cap",
284
+ "elapsed_s": round(self.elapsed(), 1), "not_started": [list(p) for p in pairs[idx:]]})
285
+ return "soft_cap"
286
+ reason = self.guard_reason()
287
+ if reason:
288
+ self.hard_stop(reason)
289
+ return reason
290
+ rec = await self.one_run(arm, golden[item_id], tool_router[arm], tool_lists[arm], semantic_model,
291
+ self.cfg["seed"])
292
+ print(item_id, arm, rec["seconds"], (rec["error"] or "")[:60], flush=True)
293
+ append_jsonl(self.out, {"kind": "event", "event": "stop", "reason": "queue_complete",
294
+ "elapsed_s": round(self.elapsed(), 1)})
295
+ return "queue_complete"
296
+
297
+ def close(self):
298
+ self._stop_evt.set()
299
+
300
+
301
+ # ---------------------------------------------------------------- live wiring
302
+ async def run_live(cfg, db, out, resume, prior_elapsed_s=0.0, prior_elapsed_note=None):
303
+ from mcp.client.session import ClientSession
304
+ from mcp.client.stdio import StdioServerParameters, stdio_client
305
+
306
+ from .execution import Executor
307
+ from .rawtools import RawTools
308
+
309
+ model = load(default_model_path())
310
+ golden = {i["id"]: i for i in json.loads((ROOT / "golden" / "golden-v1.json").read_text())["items"]}
311
+ queue = queue_from_config(cfg)
312
+ prior = read_jsonl(out)
313
+ pairs = remaining_pairs(queue, prior) if resume else queue
314
+ runner = Runner(cfg, out, prior_elapsed_s=prior_elapsed_s)
315
+ append_jsonl(out, {"kind": "event", "event": "start", "resume": resume, "config_sha256": cfg["_sha256"],
316
+ "prior_elapsed_s": prior_elapsed_s, "prior_elapsed_note": prior_elapsed_note,
317
+ "pairs_remaining": [list(p) for p in pairs], "conditions": conditions(),
318
+ "semantic_model_sha256": model.sha256})
319
+ raw = RawTools(Executor(db))
320
+ raw_tools = [{"type": "function", "function": {"name": t["name"], "description": t["description"],
321
+ "parameters": t["input_schema"]}} for t in TOOL_SPECS]
322
+ p = StdioServerParameters(command=sys.executable, args=["-I", "-m", "analyticsreceipts.server"],
323
+ env={"ANALYTICSRECEIPTS_DB": str(Path(db).resolve())})
324
+ async with stdio_client(p) as (r, w):
325
+ async with ClientSession(r, w) as s:
326
+ await s.initialize()
327
+ tl = await s.list_tools()
328
+ mcp_tools = [{"type": "function", "function": {"name": t.name, "description": t.description or "",
329
+ "parameters": t.input_schema}} for t in tl.tools]
330
+
331
+ async def call_mcp(name, args):
332
+ res = await s.call_tool(name, args)
333
+ text = res.content[0].text if res.content else ""
334
+ if res.is_error:
335
+ return {"result_type": "tool_error", "error": text[:500]}, True
336
+ try:
337
+ return json.loads(text), False
338
+ except json.JSONDecodeError:
339
+ return {"result_type": "ok", "text": text[:2000]}, False
340
+
341
+ async def call_raw(name, args):
342
+ return raw.call(name, args), False
343
+
344
+ reason = await runner.run_queue(pairs, golden, model, {"mcp": call_mcp, "raw": call_raw},
345
+ {"mcp": mcp_tools, "raw": raw_tools})
346
+ runner.close()
347
+ ollama_unload(cfg["model"])
348
+ append_jsonl(out, {"kind": "event", "event": "end", "reason": reason, "conditions": conditions()})
349
+ return reason
350
+
351
+
352
+ def load_config(path):
353
+ raw = Path(path).read_bytes()
354
+ cfg = json.loads(raw)
355
+ cfg["_sha256"] = hashlib.sha256(raw).hexdigest()
356
+ return cfg
357
+
358
+
359
+ def live_report(path, label=None):
360
+ from .report import axis_table, cell_table, grade_runs, wilson
361
+
362
+ recs = read_jsonl(path)
363
+ runs = [r for r in recs if r.get("kind", "run") == "run" and not r.get("incomplete")]
364
+ incomplete = [r for r in recs if r.get("kind") == "run" and r.get("incomplete")]
365
+ events = [r for r in recs if r.get("kind") == "event"]
366
+ for r in runs:
367
+ r["id"] = f"{r['item']}/{r['arm']}"
368
+ grades = grade_runs(runs)
369
+ lines = ["# M1 LIVE-LITE report", ""] + ([f"**{label}**", ""] if label else []) + [
370
+ "**Label: LIVE-LITE.** One local model, one run per (item, arm), a fixed-priority queue under hard "
371
+ "time caps: whatever completed is the result. N is tiny: every number is a count, intervals are "
372
+ "Wilson 95 %, and no comparison between arms is claimed.", ""]
373
+ lines += ["## Events", ""] + [f"- {e['event']}: " + json.dumps({k: v for k, v in e.items() if k not in ('kind', 'event')}, default=str) for e in events] + [""]
374
+ lines += ["## Per-axis results", "", axis_table(grades), "", "## Cells", "", cell_table(grades), ""]
375
+ for arm in sorted({g["arm"] for g in grades}):
376
+ gs = [g for g in grades if g["arm"] == arm]
377
+ k = sum(g["cell"] == "valid_wrong_question" for g in gs)
378
+ lines.append(f"- {arm}: valid SQL, wrong question = {k}/{len(gs)}, Wilson 95 % {wilson(k, len(gs))}")
379
+ lines += ["", "## Per run", "",
380
+ "| item | arm | s | turns | tool calls (failed) | loop-timeout | tok/s | a | b | c | d | cell | trap | receipts |",
381
+ "|---|---|---|---|---|---|---|---|---|---|---|---|---|---|"]
382
+ gmap = {g["run_id"]: g for g in grades}
383
+ for r in runs:
384
+ g = gmap[r["id"]]
385
+ ev = sum((t.get("eval_count") or 0) for t in r["turns"])
386
+ ed = sum((t.get("eval_duration") or 0) for t in r["turns"]) / 1e9
387
+ fails = sum(1 for c in r["tool_calls"] if c["tool_error"] or c["result_type"] not in ("ok", None))
388
+ rc = ", ".join((x.get("compiled_sql_sha256") or "")[:10] for x in r["receipts"]) or "none"
389
+ lines.append(f"| {r['item']} | {r['arm']} | {r['seconds']} | {len(r['turns'])} | {len(r['tool_calls'])} ({fails}) | "
390
+ f"{r['loop_timeout']} | {round(ev / ed, 2) if ed else None} | {g['a']} | {g['b']} | {g['c']} | {g['d']} | "
391
+ f"{g['cell']} | {g['trap_sprung']} | {rc} |")
392
+ if incomplete:
393
+ lines += ["", "Incomplete (saved by a stop, not graded): " + ", ".join(f"{r['item']}/{r['arm']} ({r.get('stop_reason')})" for r in incomplete)]
394
+ return "\n".join(lines) + "\n"
395
+
396
+
397
+ def main():
398
+ ap = argparse.ArgumentParser()
399
+ ap.add_argument("--config", required=True)
400
+ ap.add_argument("--db", default=os.environ.get("ANALYTICSRECEIPTS_DB"))
401
+ ap.add_argument("--out", required=True)
402
+ ap.add_argument("--resume", action="store_true")
403
+ ap.add_argument("--prior-elapsed-s", type=float, default=0.0,
404
+ help="run time already used by earlier sessions of this queue (counts toward the 6 h / 7 h caps)")
405
+ ap.add_argument("--prior-elapsed-note", default=None, help="how --prior-elapsed-s was derived (recorded)")
406
+ a = ap.parse_args()
407
+ if not a.db:
408
+ raise SystemExit("--db or ANALYTICSRECEIPTS_DB is required")
409
+ out = Path(a.out)
410
+ if out.exists() and not a.resume:
411
+ raise SystemExit(f"{out} already exists; pass --resume to continue it")
412
+ out.parent.mkdir(parents=True, exist_ok=True)
413
+ cfg = load_config(a.config)
414
+ if a.resume:
415
+ starts = [r for r in read_jsonl(out) if r.get("event") == "start"]
416
+ if starts and starts[0]["config_sha256"] != cfg["_sha256"]:
417
+ raise SystemExit("config changed since the first run; refusing to resume")
418
+ reason = anyio.run(run_live, cfg, a.db, str(out), a.resume, a.prior_elapsed_s, a.prior_elapsed_note)
419
+ print("stop reason:", reason)
420
+
421
+
422
+ if __name__ == "__main__":
423
+ main()
@@ -0,0 +1,74 @@
1
+ """Build the read-only DuckDB warehouse from the UCI Online Retail II workbook.
2
+
3
+ Both yearly sheets are loaded exactly as delivered into one table, `raw_lines`, with the sheet name
4
+ and row number kept. Nothing is cleaned or de-duplicated here: the two sheets share an identical
5
+ window (2010-12-01 to 2010-12-09), and removing it is a rule of the semantic model, not of the load.
6
+ That keeps the raw-SQL arm of the eval honest: it sees the data a warehouse would really hold.
7
+
8
+ Usage: python -m analyticsreceipts.load <online_retail_II.xlsx> <out.duckdb>
9
+ """
10
+
11
+ import csv
12
+ import hashlib
13
+ import os
14
+ import sys
15
+ import tempfile
16
+
17
+ import duckdb
18
+
19
+ EXPECTED_XLSX_SHA256 = "bcbe73b35f5b7babf197fb0cb983a11f5d9ff929078d4aa53d171b1f2df2e980"
20
+
21
+
22
+ def sha256_file(path):
23
+ h = hashlib.sha256()
24
+ with open(path, "rb") as f:
25
+ for chunk in iter(lambda: f.read(1 << 20), b""):
26
+ h.update(chunk)
27
+ return h.hexdigest()
28
+
29
+
30
+ def _xlsx_to_csv(xlsx, out_csv):
31
+ import openpyxl # only needed to build the warehouse
32
+
33
+ wb = openpyxl.load_workbook(xlsx, read_only=True, data_only=True)
34
+ with open(out_csv, "w", newline="") as f:
35
+ w = csv.writer(f)
36
+ for ws in wb.worksheets:
37
+ rows = ws.iter_rows(values_only=True)
38
+ next(rows) # header
39
+ n = 0
40
+ for r in rows:
41
+ if all(v is None for v in r):
42
+ continue
43
+ n += 1
44
+ inv, code, desc, qty, ts, price, cust, country = r[:8]
45
+ w.writerow([ws.title, n, inv, code, desc, qty,
46
+ ts.isoformat(sep=" ") if ts is not None else None,
47
+ price, "" if cust is None else int(cust), country])
48
+
49
+
50
+ def build(xlsx, out_db, check_sha=True):
51
+ digest = sha256_file(xlsx)
52
+ if check_sha and digest != EXPECTED_XLSX_SHA256:
53
+ raise SystemExit(f"workbook sha256 {digest} != pinned {EXPECTED_XLSX_SHA256}")
54
+ if os.path.exists(out_db):
55
+ os.remove(out_db)
56
+ with tempfile.TemporaryDirectory() as tmp:
57
+ csv_path = os.path.join(tmp, "lines.csv")
58
+ _xlsx_to_csv(xlsx, csv_path)
59
+ con = duckdb.connect(out_db)
60
+ con.execute(
61
+ "CREATE TABLE raw_lines AS SELECT * FROM read_csv(?, header=false, columns={"
62
+ "'source_sheet':'VARCHAR','row_in_sheet':'BIGINT','invoice':'VARCHAR','stock_code':'VARCHAR',"
63
+ "'description':'VARCHAR','quantity':'BIGINT','invoice_ts':'TIMESTAMP','price':'DECIMAL(12,3)',"
64
+ "'customer_id':'VARCHAR','country':'VARCHAR'}, nullstr='')",
65
+ [csv_path],
66
+ )
67
+ con.execute("CREATE TABLE snapshot_meta AS SELECT ? AS xlsx_sha256, max(invoice_ts) AS max_invoice_ts, "
68
+ "count(*) AS raw_line_count FROM raw_lines", [digest])
69
+ con.close()
70
+ return digest
71
+
72
+
73
+ if __name__ == "__main__":
74
+ print(build(sys.argv[1], sys.argv[2]))
@@ -0,0 +1,86 @@
1
+ """Materialise expected result tables for the golden set from the pinned snapshot.
2
+
3
+ Expected tables are produced by compiling each frozen gold resolved_spec, never typed by hand.
4
+ Trap items also get the table their NAIVE reading produces, so the grader can tell when an agent
5
+ fell into the trap, and so the trap's strength (naive vs gold) is measured rather than assumed.
6
+ The compiler itself is checked against independent hand-written SQL in tests/test_reference.py.
7
+
8
+ Usage: python -m analyticsreceipts.materialise <golden.json> <retail.duckdb> <out.json>
9
+ """
10
+
11
+ import hashlib
12
+ import json
13
+ import sys
14
+
15
+ from .compiler import compile_request
16
+ from .execution import Executor, result_sha256
17
+ from .model import default_model_path, load
18
+
19
+ DEDUP = ("SELECT * FROM raw_lines WHERE NOT (source_sheet = 'Year 2010-2011' "
20
+ "AND invoice_ts < TIMESTAMP '2010-12-10 00:00:00')")
21
+ PRODUCT = "trim(stock_code) SIMILAR TO '[0-9]{5}[A-Za-z]{0,2}'"
22
+
23
+ # Each naive reading introduces ONLY the error its trap names (golden-v1 trap.naive_reading).
24
+ NAIVE_SQL = {
25
+ "G11": (f"SELECT SUM(CASE WHEN {PRODUCT} THEN quantity * price END) AS v FROM raw_lines "
26
+ "WHERE CAST(invoice_ts AS DATE) BETWEEN DATE '2010-12-01' AND DATE '2010-12-31'"),
27
+ "G12": (f"SELECT SUM(quantity * price) AS v FROM ({DEDUP}) "
28
+ "WHERE CAST(invoice_ts AS DATE) BETWEEN DATE '2011-01-01' AND DATE '2011-11-30'"),
29
+ "G13": (f"SELECT COUNT(DISTINCT invoice) AS v FROM ({DEDUP}) "
30
+ "WHERE CAST(invoice_ts AS DATE) BETWEEN DATE '2011-01-01' AND DATE '2011-11-30'"),
31
+ "G14": (f"SELECT SUM(CASE WHEN {PRODUCT} THEN quantity * price END) / COUNT(DISTINCT CASE WHEN "
32
+ f"invoice NOT LIKE 'C%' AND invoice NOT LIKE 'A%' AND {PRODUCT} AND customer_id IS NOT NULL "
33
+ f"THEN customer_id END) AS v FROM ({DEDUP}) WHERE country = 'United Kingdom' AND "
34
+ "CAST(invoice_ts AS DATE) BETWEEN DATE '2011-01-01' AND DATE '2011-11-30'"),
35
+ }
36
+
37
+
38
+ def spec_to_request(spec):
39
+ req = {"metric": spec["metric"],
40
+ "time_range": {"start": spec["period"]["start"], "end": spec["period"]["end"]},
41
+ "filters": [{"dim": f["dim"], "op": f["op"], "value": f.get("value")} for f in spec.get("filters") or []],
42
+ "grain": spec.get("grain"), "dimensions": list(spec.get("dimensions") or [])}
43
+ if spec.get("comparison"):
44
+ req["comparison_range"] = dict(spec["comparison"]["period"])
45
+ if spec.get("ranking"):
46
+ req["ranking"] = {"direction": spec["ranking"]["direction"], "limit": spec["ranking"]["limit"]}
47
+ return req
48
+
49
+
50
+ def materialise(golden_path, db_path, model_path=None, compile_fn=compile_request):
51
+ golden_bytes = open(golden_path, "rb").read()
52
+ golden = json.loads(golden_bytes)
53
+ model = load(model_path or default_model_path())
54
+ ex = Executor(db_path)
55
+ out = {"golden_sha256": hashlib.sha256(golden_bytes).hexdigest(), "semantic_model_sha256": model.sha256,
56
+ "snapshot": ex.snapshot, "items": {}}
57
+ for it in golden["items"]:
58
+ spec = it.get("resolved_spec")
59
+ if not spec:
60
+ continue
61
+ q = compile_fn(model, spec_to_request(spec))
62
+ cols, rows, trunc, err = ex.run(q.sql, q.params)
63
+ if err or trunc:
64
+ raise RuntimeError(f"{it['id']}: {err or 'truncated'}")
65
+ e = {"columns": cols, "rows": rows, "result_sha256": result_sha256(cols, rows),
66
+ "compiled_sql_sha256": q.sha256, "metric_def_version": model.metrics[spec["metric"]].meta.get("version"),
67
+ "naive": None, "trap_strength": None}
68
+ if it["id"] in NAIVE_SQL:
69
+ ncols, nrows, _, nerr = ex.run(NAIVE_SQL[it["id"]])
70
+ if nerr:
71
+ raise RuntimeError(f"{it['id']} naive: {nerr}")
72
+ e["naive"] = {"columns": ncols, "rows": nrows}
73
+ g, n = float(rows[0][-1]), float(nrows[0][-1])
74
+ e["trap_strength"] = {"gold": g, "naive": n, "naive_minus_gold": n - g,
75
+ "relative": (n - g) / abs(g) if g else None}
76
+ out["items"][it["id"]] = e
77
+ return out
78
+
79
+
80
+ if __name__ == "__main__":
81
+ res = materialise(sys.argv[1], sys.argv[2])
82
+ with open(sys.argv[3], "w") as f:
83
+ json.dump(res, f, indent=1, sort_keys=True)
84
+ f.write("\n")
85
+ for k, v in res["items"].items():
86
+ print(k, v["rows"][:3], v["trap_strength"])