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,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"])
|