slmeval-cli 0.1.0__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.
- slmeval/__init__.py +71 -0
- slmeval/__main__.py +14 -0
- slmeval/adapters/__init__.py +24 -0
- slmeval/adapters/instrument.py +135 -0
- slmeval/adapters/langgraph.py +237 -0
- slmeval/cli/__init__.py +15 -0
- slmeval/cli/commands/__init__.py +35 -0
- slmeval/cli/commands/baseline_cmd.py +201 -0
- slmeval/cli/commands/cache_cmd.py +249 -0
- slmeval/cli/commands/dataset_cmd.py +52 -0
- slmeval/cli/commands/doctor_cmd.py +68 -0
- slmeval/cli/commands/gate_cmd.py +602 -0
- slmeval/cli/commands/init_cmd.py +144 -0
- slmeval/cli/commands/judge_cmd.py +284 -0
- slmeval/cli/commands/record_cmd.py +154 -0
- slmeval/cli/commands/report_cmd.py +98 -0
- slmeval/cli/commands/run_cmd.py +247 -0
- slmeval/cli/commands/show_cmd.py +482 -0
- slmeval/cli/commands/stubs.py +57 -0
- slmeval/cli/commands/validate_cmd.py +296 -0
- slmeval/cli/loading.py +149 -0
- slmeval/cli/main.py +91 -0
- slmeval/cli/render.py +251 -0
- slmeval/cli/wrap.py +31 -0
- slmeval/config/__init__.py +47 -0
- slmeval/config/loader.py +363 -0
- slmeval/config/locate.py +97 -0
- slmeval/config/models.py +430 -0
- slmeval/config/templates.py +246 -0
- slmeval/core/__init__.py +78 -0
- slmeval/core/adapter.py +253 -0
- slmeval/core/context.py +418 -0
- slmeval/core/outcome.py +260 -0
- slmeval/core/router.py +319 -0
- slmeval/core/runner.py +1209 -0
- slmeval/core/trace.py +188 -0
- slmeval/core/verdict.py +127 -0
- slmeval/dataset/__init__.py +8 -0
- slmeval/dataset/loader.py +130 -0
- slmeval/dataset/models.py +108 -0
- slmeval/doctor/__init__.py +7 -0
- slmeval/doctor/checks.py +380 -0
- slmeval/errors.py +76 -0
- slmeval/eval/__init__.py +64 -0
- slmeval/eval/assertions.py +356 -0
- slmeval/eval/calibration.py +364 -0
- slmeval/eval/cost.py +291 -0
- slmeval/eval/pipeline.py +273 -0
- slmeval/eval/stats.py +376 -0
- slmeval/eval/tier1.py +510 -0
- slmeval/eval/tier2.py +582 -0
- slmeval/eval/tier3.py +415 -0
- slmeval/exit_codes.py +28 -0
- slmeval/gate.py +722 -0
- slmeval/providers/__init__.py +69 -0
- slmeval/providers/base.py +426 -0
- slmeval/providers/fake.py +760 -0
- slmeval/providers/jsonx.py +239 -0
- slmeval/providers/ladder.py +339 -0
- slmeval/providers/openai_compat.py +392 -0
- slmeval/providers/registry.py +185 -0
- slmeval/providers/retry.py +81 -0
- slmeval/providers/schema.py +136 -0
- slmeval/providers/tokens.py +150 -0
- slmeval/py.typed +0 -0
- slmeval/report/__init__.py +115 -0
- slmeval/report/attribution.py +197 -0
- slmeval/report/baseline.py +353 -0
- slmeval/report/drilldown.py +1395 -0
- slmeval/report/export.py +450 -0
- slmeval/report/model.py +562 -0
- slmeval/report/progress.py +257 -0
- slmeval/report/scorecard.py +367 -0
- slmeval/sandbox/__init__.py +145 -0
- slmeval/sandbox/diagnosis.py +690 -0
- slmeval/sandbox/envelope.py +486 -0
- slmeval/sandbox/faults.py +794 -0
- slmeval/sandbox/keys.py +458 -0
- slmeval/sandbox/normalizers.py +333 -0
- slmeval/sandbox/store.py +525 -0
- slmeval/sandbox/tool.py +685 -0
- slmeval/storage/__init__.py +73 -0
- slmeval/storage/blobs.py +308 -0
- slmeval/storage/codec.py +93 -0
- slmeval/storage/db.py +1334 -0
- slmeval/storage/ingest.py +346 -0
- slmeval/storage/migrations.py +358 -0
- slmeval/storage/models.py +554 -0
- slmeval/storage/provenance.py +201 -0
- slmeval_cli-0.1.0.dist-info/METADATA +484 -0
- slmeval_cli-0.1.0.dist-info/RECORD +94 -0
- slmeval_cli-0.1.0.dist-info/WHEEL +4 -0
- slmeval_cli-0.1.0.dist-info/entry_points.txt +2 -0
- slmeval_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
slmeval/sandbox/store.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""The cassette store: SQLite on disk, an LRU in front of it.
|
|
2
|
+
|
|
3
|
+
Replay is the feature this table makes possible - 200 trajectories against four
|
|
4
|
+
models with no GPU, no API keys and no network - so its cost per tool call is
|
|
5
|
+
not an implementation detail. At 10 tool calls per execution and 90 executions,
|
|
6
|
+
a millisecond per lookup is a second of wall clock spent on bookkeeping, and the
|
|
7
|
+
headline claim is that replay is free.
|
|
8
|
+
|
|
9
|
+
Two things that look fine and are not:
|
|
10
|
+
|
|
11
|
+
**``with sqlite3.connect(...) as conn:`` commits but does not close.** It is a
|
|
12
|
+
transaction context manager, not a connection one. Opening per tool call leaks a
|
|
13
|
+
handle per call, and on Windows that becomes ``OSError: too many open files`` or
|
|
14
|
+
a locked database partway through a long run - after the expensive part, which
|
|
15
|
+
is the worst possible time. So: one connection per thread, opened once, closed
|
|
16
|
+
explicitly, with every pragma set at open.
|
|
17
|
+
|
|
18
|
+
**Opening a connection per ``get()`` is itself the dominant cost of a "0ms"
|
|
19
|
+
replay.** Connection setup dwarfs an indexed primary-key lookup. The LRU in
|
|
20
|
+
front means the second and later replays of a case never touch SQLite at all,
|
|
21
|
+
which is what turns 200 replays into something that finishes while you watch.
|
|
22
|
+
|
|
23
|
+
There is deliberately **no negative cache**. Caching misses would save a couple
|
|
24
|
+
of microseconds and introduce an invalidation bug whose symptom is a phantom
|
|
25
|
+
``CASSETTE_MISS`` - the harness reporting a hallucination it invented, which is
|
|
26
|
+
the one failure that would cost us a user's trust outright. Not a trade worth
|
|
27
|
+
making for 2µs.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import contextlib
|
|
33
|
+
import json
|
|
34
|
+
import sqlite3
|
|
35
|
+
import threading
|
|
36
|
+
from collections import OrderedDict
|
|
37
|
+
from collections.abc import Iterable, Mapping
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from datetime import UTC, datetime
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Final
|
|
42
|
+
|
|
43
|
+
from slmeval.errors import EnvironmentError_
|
|
44
|
+
from slmeval.sandbox import envelope
|
|
45
|
+
from slmeval.sandbox.keys import CassetteKeys, MatchTier
|
|
46
|
+
|
|
47
|
+
SCHEMA_VERSION: Final = 1
|
|
48
|
+
|
|
49
|
+
DEFAULT_LRU_SIZE: Final = 4096
|
|
50
|
+
"""Roughly a full matrix's worth of distinct tool calls. A cassette payload is
|
|
51
|
+
small; holding a few thousand costs a few megabytes and removes SQLite from the
|
|
52
|
+
hot path entirely."""
|
|
53
|
+
|
|
54
|
+
_DDL: Final = (
|
|
55
|
+
"""
|
|
56
|
+
CREATE TABLE IF NOT EXISTS cassettes (
|
|
57
|
+
key TEXT PRIMARY KEY,
|
|
58
|
+
case_id TEXT NOT NULL,
|
|
59
|
+
tool_name TEXT NOT NULL,
|
|
60
|
+
match_tier TEXT NOT NULL,
|
|
61
|
+
canonical_args TEXT NOT NULL,
|
|
62
|
+
args_json TEXT NOT NULL,
|
|
63
|
+
response_envelope TEXT NOT NULL,
|
|
64
|
+
recorded_at TEXT NOT NULL,
|
|
65
|
+
dataset_hash TEXT NOT NULL DEFAULT '',
|
|
66
|
+
tool_version TEXT NOT NULL DEFAULT ''
|
|
67
|
+
)
|
|
68
|
+
""",
|
|
69
|
+
# The miss diagnosis asks "what did we record for this tool, in this case?"
|
|
70
|
+
# so it can show the nearest recorded arguments. Without this index that
|
|
71
|
+
# question is a table scan, and it runs on every failure.
|
|
72
|
+
"CREATE INDEX IF NOT EXISTS cassettes_by_call ON cassettes (case_id, tool_name)",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
_COLUMNS: Final = (
|
|
76
|
+
"key",
|
|
77
|
+
"case_id",
|
|
78
|
+
"tool_name",
|
|
79
|
+
"match_tier",
|
|
80
|
+
"canonical_args",
|
|
81
|
+
"args_json",
|
|
82
|
+
"response_envelope",
|
|
83
|
+
"recorded_at",
|
|
84
|
+
"dataset_hash",
|
|
85
|
+
"tool_version",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class Cassette:
|
|
91
|
+
"""One recorded tool response, as stored.
|
|
92
|
+
|
|
93
|
+
``match_tier`` is the tier this row answers, and it is authoritative rather
|
|
94
|
+
than inferred: the tier is part of the key's hash, so a row found by a
|
|
95
|
+
subset-tier key can never be a row that was filed at the exact tier.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
key: str
|
|
99
|
+
case_id: str
|
|
100
|
+
tool_name: str
|
|
101
|
+
match_tier: MatchTier
|
|
102
|
+
canonical_args: str
|
|
103
|
+
args_json: str
|
|
104
|
+
response_envelope: str
|
|
105
|
+
recorded_at: str
|
|
106
|
+
dataset_hash: str = ""
|
|
107
|
+
tool_version: str = ""
|
|
108
|
+
|
|
109
|
+
def arguments(self) -> Any:
|
|
110
|
+
"""The arguments as originally recorded - what a diff is built against."""
|
|
111
|
+
return envelope.decode_json(self.args_json)
|
|
112
|
+
|
|
113
|
+
def response(self) -> Any:
|
|
114
|
+
"""The recorded return value, rebuilt with its original types."""
|
|
115
|
+
return envelope.decode_json(self.response_envelope)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass(frozen=True, slots=True)
|
|
119
|
+
class Hit:
|
|
120
|
+
"""A successful lookup, and the tier that produced it."""
|
|
121
|
+
|
|
122
|
+
cassette: Cassette
|
|
123
|
+
response: Any
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def tier(self) -> MatchTier:
|
|
127
|
+
return self.cassette.match_tier
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def degraded(self) -> bool:
|
|
131
|
+
"""True when the call did not match exactly.
|
|
132
|
+
|
|
133
|
+
A normalized hit means our normalizers covered a formatting difference;
|
|
134
|
+
a subset hit means the model reached the right call with something else
|
|
135
|
+
wrong. Both are reportable, so neither is allowed to look like a clean
|
|
136
|
+
pass.
|
|
137
|
+
"""
|
|
138
|
+
return self.cassette.match_tier is not MatchTier.EXACT
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class StoreStats:
|
|
143
|
+
cassettes: int
|
|
144
|
+
cases: int
|
|
145
|
+
tools: int
|
|
146
|
+
size_bytes: int
|
|
147
|
+
by_tier: Mapping[str, int]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class _Cached:
|
|
152
|
+
"""An LRU entry: the row, plus its envelope parsed once.
|
|
153
|
+
|
|
154
|
+
The *parsed* envelope is cached, never the decoded response. Handing the
|
|
155
|
+
same object to 200 replays would let the first execution mutate what the
|
|
156
|
+
other 199 see - a cross-contamination that looks exactly like
|
|
157
|
+
nondeterminism in the model. Decoding per call rebuilds fresh objects and
|
|
158
|
+
costs microseconds.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
cassette: Cassette
|
|
162
|
+
parsed: Any
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class CassetteStore:
|
|
166
|
+
"""Recorded tool responses for one project.
|
|
167
|
+
|
|
168
|
+
Thread-safe and usable from several event-loop threads: each thread gets its
|
|
169
|
+
own SQLite connection, and the LRU is guarded by a lock.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, path: Path | str, *, lru_size: int = DEFAULT_LRU_SIZE) -> None:
|
|
173
|
+
self.path = Path(path)
|
|
174
|
+
self._lru_size = max(0, lru_size)
|
|
175
|
+
self._cache: OrderedDict[str, _Cached] = OrderedDict()
|
|
176
|
+
self._cache_lock = threading.Lock()
|
|
177
|
+
self._local = threading.local()
|
|
178
|
+
self._connections: list[sqlite3.Connection] = []
|
|
179
|
+
self._conn_lock = threading.Lock()
|
|
180
|
+
self._closed = False
|
|
181
|
+
self.hits = 0
|
|
182
|
+
self.misses = 0
|
|
183
|
+
self.cache_hits = 0
|
|
184
|
+
|
|
185
|
+
# ------------------------------------------------------------- lifecycle
|
|
186
|
+
|
|
187
|
+
@classmethod
|
|
188
|
+
def for_project(cls, root: Path | str, **kwargs: Any) -> CassetteStore:
|
|
189
|
+
"""The store under a project's ``.slmeval/`` cache directory."""
|
|
190
|
+
cache = Path(root) / ".slmeval"
|
|
191
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
return cls(cache / "cassettes.sqlite", **kwargs)
|
|
193
|
+
|
|
194
|
+
def _open(self) -> sqlite3.Connection:
|
|
195
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
try:
|
|
197
|
+
conn = sqlite3.connect(
|
|
198
|
+
self.path,
|
|
199
|
+
timeout=5.0,
|
|
200
|
+
# The store is closed at the end of a run, when the workers that
|
|
201
|
+
# opened these connections may already be gone. Without this,
|
|
202
|
+
# closing another thread's connection raises ProgrammingError
|
|
203
|
+
# and the handle leaks anyway.
|
|
204
|
+
check_same_thread=False,
|
|
205
|
+
# Autocommit. Every pragma and every multi-row write below is
|
|
206
|
+
# explicit about its transaction, which beats a driver deciding
|
|
207
|
+
# to hold a write lock on our behalf.
|
|
208
|
+
isolation_level=None,
|
|
209
|
+
)
|
|
210
|
+
except sqlite3.Error as exc:
|
|
211
|
+
raise EnvironmentError_(
|
|
212
|
+
f"cannot open the cassette store at {self.path}: {exc}",
|
|
213
|
+
hint="run `slmeval doctor` to check the cache directory is writable",
|
|
214
|
+
) from exc
|
|
215
|
+
|
|
216
|
+
conn.row_factory = sqlite3.Row
|
|
217
|
+
# busy_timeout FIRST, and the order is load-bearing. Switching
|
|
218
|
+
# journal_mode needs a brief exclusive lock, and `CREATE TABLE` below
|
|
219
|
+
# needs a write lock; with the default timeout of 0 a second worker
|
|
220
|
+
# opening the store at the same moment gets `database is locked`
|
|
221
|
+
# immediately instead of waiting. Setting the timeout after those
|
|
222
|
+
# statements protects everything except the two that actually contend.
|
|
223
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
224
|
+
# WAL so readers never block the recorder. NORMAL because losing a
|
|
225
|
+
# cassette to a power cut costs one re-record, not correctness.
|
|
226
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
227
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
228
|
+
self._ensure_schema(conn)
|
|
229
|
+
return conn
|
|
230
|
+
|
|
231
|
+
def _ensure_schema(self, conn: sqlite3.Connection) -> None:
|
|
232
|
+
version = int(conn.execute("PRAGMA user_version").fetchone()[0])
|
|
233
|
+
if version > SCHEMA_VERSION:
|
|
234
|
+
conn.close()
|
|
235
|
+
raise EnvironmentError_(
|
|
236
|
+
f"{self.path} was written by a newer slmeval "
|
|
237
|
+
f"(store schema v{version}, this build reads v{SCHEMA_VERSION})",
|
|
238
|
+
hint="upgrade slmeval, or delete the .slmeval cache to re-record",
|
|
239
|
+
)
|
|
240
|
+
for statement in _DDL:
|
|
241
|
+
conn.execute(statement)
|
|
242
|
+
if version != SCHEMA_VERSION:
|
|
243
|
+
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
|
244
|
+
|
|
245
|
+
def _conn(self) -> sqlite3.Connection:
|
|
246
|
+
if self._closed:
|
|
247
|
+
raise RuntimeError("this CassetteStore is closed")
|
|
248
|
+
conn: sqlite3.Connection | None = getattr(self._local, "conn", None)
|
|
249
|
+
if conn is None:
|
|
250
|
+
# Opening is serialized, and that is not belt-and-braces. Switching
|
|
251
|
+
# journal_mode takes a brief exclusive lock, and SQLite does *not*
|
|
252
|
+
# run the busy handler for it - so `busy_timeout` cannot save two
|
|
253
|
+
# workers that open the store at the same instant, and one of them
|
|
254
|
+
# gets `database is locked` straight away. Every later statement is
|
|
255
|
+
# covered by the timeout; this one has to be covered by us.
|
|
256
|
+
# Opening happens once per thread, so the lock costs nothing.
|
|
257
|
+
with self._conn_lock:
|
|
258
|
+
conn = self._open()
|
|
259
|
+
self._connections.append(conn)
|
|
260
|
+
self._local.conn = conn
|
|
261
|
+
return conn
|
|
262
|
+
|
|
263
|
+
def close(self) -> None:
|
|
264
|
+
"""Close every connection this store opened, from any thread.
|
|
265
|
+
|
|
266
|
+
Explicit, and not optional: on Windows an unclosed handle keeps the
|
|
267
|
+
database file locked, so a test's temporary directory cannot be removed
|
|
268
|
+
and a user cannot delete their own cache.
|
|
269
|
+
"""
|
|
270
|
+
self._closed = True
|
|
271
|
+
with self._conn_lock:
|
|
272
|
+
connections, self._connections = self._connections, []
|
|
273
|
+
for conn in connections:
|
|
274
|
+
# A connection that is already gone is not a reason to leave the
|
|
275
|
+
# rest of them open.
|
|
276
|
+
with contextlib.suppress(sqlite3.Error):
|
|
277
|
+
conn.close()
|
|
278
|
+
self._local = threading.local()
|
|
279
|
+
with self._cache_lock:
|
|
280
|
+
self._cache.clear()
|
|
281
|
+
|
|
282
|
+
def __enter__(self) -> CassetteStore:
|
|
283
|
+
return self
|
|
284
|
+
|
|
285
|
+
def __exit__(self, *exc: object) -> None:
|
|
286
|
+
self.close()
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------- reading
|
|
289
|
+
|
|
290
|
+
def _cache_get(self, key: str) -> _Cached | None:
|
|
291
|
+
if not self._lru_size:
|
|
292
|
+
return None
|
|
293
|
+
with self._cache_lock:
|
|
294
|
+
entry = self._cache.get(key)
|
|
295
|
+
if entry is not None:
|
|
296
|
+
self._cache.move_to_end(key)
|
|
297
|
+
return entry
|
|
298
|
+
|
|
299
|
+
def _cache_put(self, key: str, entry: _Cached) -> None:
|
|
300
|
+
if not self._lru_size:
|
|
301
|
+
return
|
|
302
|
+
with self._cache_lock:
|
|
303
|
+
self._cache[key] = entry
|
|
304
|
+
self._cache.move_to_end(key)
|
|
305
|
+
while len(self._cache) > self._lru_size:
|
|
306
|
+
self._cache.popitem(last=False)
|
|
307
|
+
|
|
308
|
+
def _fetch(self, key: str) -> _Cached | None:
|
|
309
|
+
cached = self._cache_get(key)
|
|
310
|
+
if cached is not None:
|
|
311
|
+
self.cache_hits += 1
|
|
312
|
+
return cached
|
|
313
|
+
row = self._conn().execute(
|
|
314
|
+
f"SELECT {', '.join(_COLUMNS)} FROM cassettes WHERE key = ?", (key,)
|
|
315
|
+
).fetchone()
|
|
316
|
+
if row is None:
|
|
317
|
+
return None
|
|
318
|
+
cassette = _from_row(row)
|
|
319
|
+
entry = _Cached(cassette=cassette, parsed=json.loads(cassette.response_envelope))
|
|
320
|
+
self._cache_put(key, entry)
|
|
321
|
+
return entry
|
|
322
|
+
|
|
323
|
+
def get(self, key: str) -> Cassette | None:
|
|
324
|
+
entry = self._fetch(key)
|
|
325
|
+
return entry.cassette if entry else None
|
|
326
|
+
|
|
327
|
+
def lookup(self, keys: CassetteKeys) -> Hit | None:
|
|
328
|
+
"""Walk the cascade: exact, then normalized, then subset.
|
|
329
|
+
|
|
330
|
+
The first hit wins, and its tier is the finding. Trying them in order -
|
|
331
|
+
rather than matching fuzzily and reporting a confidence - is what makes
|
|
332
|
+
"the model got an extra argument wrong" a distinct, reportable outcome
|
|
333
|
+
instead of a shrug.
|
|
334
|
+
"""
|
|
335
|
+
for entry in keys:
|
|
336
|
+
found = self._fetch(entry.key)
|
|
337
|
+
if found is not None:
|
|
338
|
+
self.hits += 1
|
|
339
|
+
return Hit(cassette=found.cassette, response=envelope.decode(found.parsed))
|
|
340
|
+
self.misses += 1
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
def neighbours(self, case_id: str, tool_name: str, *, limit: int = 50) -> list[Cassette]:
|
|
344
|
+
"""Every exact-tier cassette for one (case, tool).
|
|
345
|
+
|
|
346
|
+
What the miss diagnosis searches for the nearest recorded arguments.
|
|
347
|
+
Exact-tier only: the normalized and subset rows of the same call hold
|
|
348
|
+
reduced argument forms, and showing a user a normalized rendering of
|
|
349
|
+
their own recording as "nearest recorded" would be confusing.
|
|
350
|
+
"""
|
|
351
|
+
rows = self._conn().execute(
|
|
352
|
+
f"SELECT {', '.join(_COLUMNS)} FROM cassettes "
|
|
353
|
+
"WHERE case_id = ? AND tool_name = ? AND match_tier = ? "
|
|
354
|
+
"ORDER BY recorded_at LIMIT ?",
|
|
355
|
+
(case_id, tool_name, MatchTier.EXACT.value, limit),
|
|
356
|
+
).fetchall()
|
|
357
|
+
return [_from_row(row) for row in rows]
|
|
358
|
+
|
|
359
|
+
def tool_names(self, case_id: str | None = None) -> list[str]:
|
|
360
|
+
"""Tools with at least one recording. Day 7 uses this to tell a
|
|
361
|
+
hallucinated tool name from an unrecorded path."""
|
|
362
|
+
if case_id is None:
|
|
363
|
+
rows = self._conn().execute(
|
|
364
|
+
"SELECT DISTINCT tool_name FROM cassettes ORDER BY tool_name"
|
|
365
|
+
).fetchall()
|
|
366
|
+
else:
|
|
367
|
+
rows = self._conn().execute(
|
|
368
|
+
"SELECT DISTINCT tool_name FROM cassettes WHERE case_id = ? ORDER BY tool_name",
|
|
369
|
+
(case_id,),
|
|
370
|
+
).fetchall()
|
|
371
|
+
return [str(row[0]) for row in rows]
|
|
372
|
+
|
|
373
|
+
# ---------------------------------------------------------------- writing
|
|
374
|
+
|
|
375
|
+
def record(
|
|
376
|
+
self,
|
|
377
|
+
keys: CassetteKeys,
|
|
378
|
+
*,
|
|
379
|
+
arguments: Mapping[str, Any],
|
|
380
|
+
response: Any,
|
|
381
|
+
tool_version: str = "",
|
|
382
|
+
dataset_hash: str = "",
|
|
383
|
+
) -> Hit:
|
|
384
|
+
"""Store one real tool call under every key it can be found by.
|
|
385
|
+
|
|
386
|
+
The response is encoded once and shared by all three rows, and the
|
|
387
|
+
arguments are stored as the caller passed them so a later diff shows
|
|
388
|
+
real values rather than a canonical rendering.
|
|
389
|
+
|
|
390
|
+
Two calls that differ only outside ``match_on`` collapse to the same
|
|
391
|
+
subset key, so the last recording wins *at that tier*. That is correct
|
|
392
|
+
rather than unfortunate: the subset tier answers "did the model reach
|
|
393
|
+
the right call", and its payload is an acknowledged stand-in.
|
|
394
|
+
"""
|
|
395
|
+
what = f"tool {keys.tool_name!r} return value"
|
|
396
|
+
response_envelope = envelope.encode_json(response, what=what)
|
|
397
|
+
args_json = envelope.encode_json(
|
|
398
|
+
dict(arguments), what=f"arguments to tool {keys.tool_name!r}"
|
|
399
|
+
)
|
|
400
|
+
recorded_at = datetime.now(UTC).isoformat()
|
|
401
|
+
|
|
402
|
+
cassettes = [
|
|
403
|
+
Cassette(
|
|
404
|
+
key=entry.key,
|
|
405
|
+
case_id=keys.case_id,
|
|
406
|
+
tool_name=keys.tool_name,
|
|
407
|
+
match_tier=entry.tier,
|
|
408
|
+
canonical_args=entry.canonical_args,
|
|
409
|
+
args_json=args_json,
|
|
410
|
+
response_envelope=response_envelope,
|
|
411
|
+
recorded_at=recorded_at,
|
|
412
|
+
dataset_hash=dataset_hash,
|
|
413
|
+
tool_version=tool_version,
|
|
414
|
+
)
|
|
415
|
+
for entry in keys
|
|
416
|
+
]
|
|
417
|
+
self.put_many(cassettes)
|
|
418
|
+
return Hit(cassette=cassettes[0], response=response)
|
|
419
|
+
|
|
420
|
+
def put(self, cassette: Cassette) -> None:
|
|
421
|
+
self.put_many([cassette])
|
|
422
|
+
|
|
423
|
+
def put_many(self, cassettes: Iterable[Cassette]) -> None:
|
|
424
|
+
"""Write rows in one transaction, and drop their cache entries.
|
|
425
|
+
|
|
426
|
+
Invalidating rather than updating the cache: a re-record whose payload
|
|
427
|
+
differs must not be shadowed by what an earlier execution read.
|
|
428
|
+
"""
|
|
429
|
+
rows = [
|
|
430
|
+
(
|
|
431
|
+
c.key,
|
|
432
|
+
c.case_id,
|
|
433
|
+
c.tool_name,
|
|
434
|
+
c.match_tier.value,
|
|
435
|
+
c.canonical_args,
|
|
436
|
+
c.args_json,
|
|
437
|
+
c.response_envelope,
|
|
438
|
+
c.recorded_at,
|
|
439
|
+
c.dataset_hash,
|
|
440
|
+
c.tool_version,
|
|
441
|
+
)
|
|
442
|
+
for c in cassettes
|
|
443
|
+
]
|
|
444
|
+
if not rows:
|
|
445
|
+
return
|
|
446
|
+
placeholders = ", ".join("?" * len(_COLUMNS))
|
|
447
|
+
conn = self._conn()
|
|
448
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
449
|
+
try:
|
|
450
|
+
conn.executemany(
|
|
451
|
+
f"INSERT OR REPLACE INTO cassettes ({', '.join(_COLUMNS)}) "
|
|
452
|
+
f"VALUES ({placeholders})",
|
|
453
|
+
rows,
|
|
454
|
+
)
|
|
455
|
+
except BaseException:
|
|
456
|
+
conn.execute("ROLLBACK")
|
|
457
|
+
raise
|
|
458
|
+
conn.execute("COMMIT")
|
|
459
|
+
with self._cache_lock:
|
|
460
|
+
for row in rows:
|
|
461
|
+
self._cache.pop(str(row[0]), None)
|
|
462
|
+
|
|
463
|
+
def delete_case(self, case_id: str) -> int:
|
|
464
|
+
"""Drop every cassette for one case - what ``record --force`` needs."""
|
|
465
|
+
conn = self._conn()
|
|
466
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
467
|
+
try:
|
|
468
|
+
cursor = conn.execute("DELETE FROM cassettes WHERE case_id = ?", (case_id,))
|
|
469
|
+
except BaseException:
|
|
470
|
+
conn.execute("ROLLBACK")
|
|
471
|
+
raise
|
|
472
|
+
conn.execute("COMMIT")
|
|
473
|
+
with self._cache_lock:
|
|
474
|
+
self._cache.clear()
|
|
475
|
+
return int(cursor.rowcount)
|
|
476
|
+
|
|
477
|
+
# ------------------------------------------------------------------ stats
|
|
478
|
+
|
|
479
|
+
def stats(self) -> StoreStats:
|
|
480
|
+
conn = self._conn()
|
|
481
|
+
total, cases, tools = conn.execute(
|
|
482
|
+
"SELECT COUNT(*), COUNT(DISTINCT case_id), COUNT(DISTINCT tool_name) FROM cassettes"
|
|
483
|
+
).fetchone()
|
|
484
|
+
by_tier = {
|
|
485
|
+
str(row[0]): int(row[1])
|
|
486
|
+
for row in conn.execute(
|
|
487
|
+
"SELECT match_tier, COUNT(*) FROM cassettes GROUP BY match_tier"
|
|
488
|
+
).fetchall()
|
|
489
|
+
}
|
|
490
|
+
size = self.path.stat().st_size if self.path.exists() else 0
|
|
491
|
+
return StoreStats(
|
|
492
|
+
cassettes=int(total),
|
|
493
|
+
cases=int(cases),
|
|
494
|
+
tools=int(tools),
|
|
495
|
+
size_bytes=size,
|
|
496
|
+
by_tier=by_tier,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
def __len__(self) -> int:
|
|
500
|
+
return int(self._conn().execute("SELECT COUNT(*) FROM cassettes").fetchone()[0])
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _from_row(row: sqlite3.Row) -> Cassette:
|
|
504
|
+
return Cassette(
|
|
505
|
+
key=row["key"],
|
|
506
|
+
case_id=row["case_id"],
|
|
507
|
+
tool_name=row["tool_name"],
|
|
508
|
+
match_tier=MatchTier(row["match_tier"]),
|
|
509
|
+
canonical_args=row["canonical_args"],
|
|
510
|
+
args_json=row["args_json"],
|
|
511
|
+
response_envelope=row["response_envelope"],
|
|
512
|
+
recorded_at=row["recorded_at"],
|
|
513
|
+
dataset_hash=row["dataset_hash"],
|
|
514
|
+
tool_version=row["tool_version"],
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
__all__ = [
|
|
519
|
+
"DEFAULT_LRU_SIZE",
|
|
520
|
+
"SCHEMA_VERSION",
|
|
521
|
+
"Cassette",
|
|
522
|
+
"CassetteStore",
|
|
523
|
+
"Hit",
|
|
524
|
+
"StoreStats",
|
|
525
|
+
]
|