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.
Files changed (94) hide show
  1. slmeval/__init__.py +71 -0
  2. slmeval/__main__.py +14 -0
  3. slmeval/adapters/__init__.py +24 -0
  4. slmeval/adapters/instrument.py +135 -0
  5. slmeval/adapters/langgraph.py +237 -0
  6. slmeval/cli/__init__.py +15 -0
  7. slmeval/cli/commands/__init__.py +35 -0
  8. slmeval/cli/commands/baseline_cmd.py +201 -0
  9. slmeval/cli/commands/cache_cmd.py +249 -0
  10. slmeval/cli/commands/dataset_cmd.py +52 -0
  11. slmeval/cli/commands/doctor_cmd.py +68 -0
  12. slmeval/cli/commands/gate_cmd.py +602 -0
  13. slmeval/cli/commands/init_cmd.py +144 -0
  14. slmeval/cli/commands/judge_cmd.py +284 -0
  15. slmeval/cli/commands/record_cmd.py +154 -0
  16. slmeval/cli/commands/report_cmd.py +98 -0
  17. slmeval/cli/commands/run_cmd.py +247 -0
  18. slmeval/cli/commands/show_cmd.py +482 -0
  19. slmeval/cli/commands/stubs.py +57 -0
  20. slmeval/cli/commands/validate_cmd.py +296 -0
  21. slmeval/cli/loading.py +149 -0
  22. slmeval/cli/main.py +91 -0
  23. slmeval/cli/render.py +251 -0
  24. slmeval/cli/wrap.py +31 -0
  25. slmeval/config/__init__.py +47 -0
  26. slmeval/config/loader.py +363 -0
  27. slmeval/config/locate.py +97 -0
  28. slmeval/config/models.py +430 -0
  29. slmeval/config/templates.py +246 -0
  30. slmeval/core/__init__.py +78 -0
  31. slmeval/core/adapter.py +253 -0
  32. slmeval/core/context.py +418 -0
  33. slmeval/core/outcome.py +260 -0
  34. slmeval/core/router.py +319 -0
  35. slmeval/core/runner.py +1209 -0
  36. slmeval/core/trace.py +188 -0
  37. slmeval/core/verdict.py +127 -0
  38. slmeval/dataset/__init__.py +8 -0
  39. slmeval/dataset/loader.py +130 -0
  40. slmeval/dataset/models.py +108 -0
  41. slmeval/doctor/__init__.py +7 -0
  42. slmeval/doctor/checks.py +380 -0
  43. slmeval/errors.py +76 -0
  44. slmeval/eval/__init__.py +64 -0
  45. slmeval/eval/assertions.py +356 -0
  46. slmeval/eval/calibration.py +364 -0
  47. slmeval/eval/cost.py +291 -0
  48. slmeval/eval/pipeline.py +273 -0
  49. slmeval/eval/stats.py +376 -0
  50. slmeval/eval/tier1.py +510 -0
  51. slmeval/eval/tier2.py +582 -0
  52. slmeval/eval/tier3.py +415 -0
  53. slmeval/exit_codes.py +28 -0
  54. slmeval/gate.py +722 -0
  55. slmeval/providers/__init__.py +69 -0
  56. slmeval/providers/base.py +426 -0
  57. slmeval/providers/fake.py +760 -0
  58. slmeval/providers/jsonx.py +239 -0
  59. slmeval/providers/ladder.py +339 -0
  60. slmeval/providers/openai_compat.py +392 -0
  61. slmeval/providers/registry.py +185 -0
  62. slmeval/providers/retry.py +81 -0
  63. slmeval/providers/schema.py +136 -0
  64. slmeval/providers/tokens.py +150 -0
  65. slmeval/py.typed +0 -0
  66. slmeval/report/__init__.py +115 -0
  67. slmeval/report/attribution.py +197 -0
  68. slmeval/report/baseline.py +353 -0
  69. slmeval/report/drilldown.py +1395 -0
  70. slmeval/report/export.py +450 -0
  71. slmeval/report/model.py +562 -0
  72. slmeval/report/progress.py +257 -0
  73. slmeval/report/scorecard.py +367 -0
  74. slmeval/sandbox/__init__.py +145 -0
  75. slmeval/sandbox/diagnosis.py +690 -0
  76. slmeval/sandbox/envelope.py +486 -0
  77. slmeval/sandbox/faults.py +794 -0
  78. slmeval/sandbox/keys.py +458 -0
  79. slmeval/sandbox/normalizers.py +333 -0
  80. slmeval/sandbox/store.py +525 -0
  81. slmeval/sandbox/tool.py +685 -0
  82. slmeval/storage/__init__.py +73 -0
  83. slmeval/storage/blobs.py +308 -0
  84. slmeval/storage/codec.py +93 -0
  85. slmeval/storage/db.py +1334 -0
  86. slmeval/storage/ingest.py +346 -0
  87. slmeval/storage/migrations.py +358 -0
  88. slmeval/storage/models.py +554 -0
  89. slmeval/storage/provenance.py +201 -0
  90. slmeval_cli-0.1.0.dist-info/METADATA +484 -0
  91. slmeval_cli-0.1.0.dist-info/RECORD +94 -0
  92. slmeval_cli-0.1.0.dist-info/WHEEL +4 -0
  93. slmeval_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. slmeval_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -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
+ ]