matchlab 0.1.0.dev1__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 (72) hide show
  1. matchlab/__init__.py +55 -0
  2. matchlab/adapters/__init__.py +24 -0
  3. matchlab/adapters/base.py +446 -0
  4. matchlab/adapters/duckdb.py +659 -0
  5. matchlab/cli.py +177 -0
  6. matchlab/core/__init__.py +1 -0
  7. matchlab/core/dataframes.py +254 -0
  8. matchlab/core/dsu.py +62 -0
  9. matchlab/core/exceptions.py +40 -0
  10. matchlab/core/hash.py +141 -0
  11. matchlab/core/kinds.py +12 -0
  12. matchlab/core/logging.py +190 -0
  13. matchlab/core/resolver_output.py +173 -0
  14. matchlab/core/schemas.py +82 -0
  15. matchlab/core/sql.py +12 -0
  16. matchlab/document.py +329 -0
  17. matchlab/eval/__init__.py +11 -0
  18. matchlab/eval/judgements.py +41 -0
  19. matchlab/eval/metrics.py +244 -0
  20. matchlab/eval/samples.py +415 -0
  21. matchlab/eval/tui/__init__.py +66 -0
  22. matchlab/eval/tui/app.py +418 -0
  23. matchlab/eval/tui/modals.py +84 -0
  24. matchlab/eval/tui/styles.tcss +184 -0
  25. matchlab/eval/tui/widgets/__init__.py +1 -0
  26. matchlab/eval/tui/widgets/assignment.py +72 -0
  27. matchlab/eval/tui/widgets/styling.py +75 -0
  28. matchlab/eval/tui/widgets/table.py +285 -0
  29. matchlab/lineage.py +239 -0
  30. matchlab/locations.py +347 -0
  31. matchlab/models/__init__.py +10 -0
  32. matchlab/models/comparison.py +67 -0
  33. matchlab/models/dedupers/__init__.py +5 -0
  34. matchlab/models/dedupers/base.py +38 -0
  35. matchlab/models/dedupers/naive.py +77 -0
  36. matchlab/models/linkers/__init__.py +9 -0
  37. matchlab/models/linkers/base.py +41 -0
  38. matchlab/models/linkers/deterministic.py +229 -0
  39. matchlab/models/linkers/splinklinker.py +262 -0
  40. matchlab/models/linkers/weighteddeterministic.py +143 -0
  41. matchlab/models/models.py +198 -0
  42. matchlab/progress.py +455 -0
  43. matchlab/recordstep.py +181 -0
  44. matchlab/resolvers/__init__.py +10 -0
  45. matchlab/resolvers/base.py +63 -0
  46. matchlab/resolvers/components.py +66 -0
  47. matchlab/resolvers/resolvers.py +409 -0
  48. matchlab/sources.py +291 -0
  49. matchlab/specs.py +129 -0
  50. matchlab/steps.py +284 -0
  51. matchlab/testkit/__init__.py +104 -0
  52. matchlab/testkit/_generate.py +240 -0
  53. matchlab/testkit/compare.py +178 -0
  54. matchlab/testkit/entities.py +290 -0
  55. matchlab/testkit/features.py +146 -0
  56. matchlab/testkit/linked.py +527 -0
  57. matchlab/testkit/matchers.py +233 -0
  58. matchlab/testkit/models.py +165 -0
  59. matchlab/testkit/sources.py +327 -0
  60. matchlab/transformers/__init__.py +24 -0
  61. matchlab/transformers/base.py +41 -0
  62. matchlab/transformers/clean.py +48 -0
  63. matchlab/transformers/explode.py +67 -0
  64. matchlab/transformers/group.py +56 -0
  65. matchlab/transformers/select.py +31 -0
  66. matchlab/transformers/transform.py +101 -0
  67. matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
  68. matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
  69. matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
  70. matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  71. matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
  72. matchlab-0.1.0.dev1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,659 @@
1
+ """DuckDB storage adapter — the reference local backend for matchlab.
2
+
3
+ A single DuckDB database (a file, or `:memory:`) holds every collected artifact,
4
+ keyed by step fingerprint. There is no engine here that resolves on demand. Resolvers
5
+ arrive already materialised (merge-forward), so reads are plain table scans. Analysts
6
+ can point their own SQL at the `resolver_output` table. That is the whole point.
7
+ """
8
+
9
+ import os
10
+ from collections.abc import Iterable, Mapping
11
+ from pathlib import Path
12
+
13
+ import duckdb
14
+ import polars as pl
15
+
16
+ from matchlab.adapters.base import Adapter, Fingerprint, PruneResult, StoreStats
17
+ from matchlab.core.kinds import StepKind
18
+ from matchlab.core.resolver_output import root_id_of
19
+ from matchlab.core.schemas import (
20
+ SCHEMA_CLUSTER_EXPANSION,
21
+ SCHEMA_JUDGEMENTS,
22
+ SCHEMA_MODEL_EDGES,
23
+ SCHEMA_RESOLVER_OUTPUT,
24
+ check_schema_subset,
25
+ )
26
+ from matchlab.eval.judgements import Judgement
27
+
28
+ # Bumped whenever the stored shape changes, or stored IDs stop meaning what they did.
29
+ # A store written by an older matchlab is recreated rather than half-read. That's the
30
+ # honest failure mode for a cache.
31
+ _SCHEMA_VERSION = 6
32
+
33
+ _SCHEMA_DDL = """
34
+ CREATE TABLE IF NOT EXISTS meta (
35
+ schema_version INTEGER
36
+ );
37
+ -- `fp` is BLOB in every table below. A fingerprint is a 32-byte SHA-256 digest, and
38
+ -- DuckDB's widest integer holds only 16 bytes, so an integer key would mean truncating
39
+ -- the hash.
40
+ --
41
+ -- Truncating would buy nothing. Every `fp` predicate here is scalar equality. A store
42
+ -- holds few fingerprints against many rows, so the column dictionary-compresses to a
43
+ -- code per row. The zonemaps prune whole row groups regardless (see `source_leaves`
44
+ -- below). Measured against a UBIGINT key over 20M rows, the BLOB is no slower laid out
45
+ -- that way, and it is faster interleaved, where random 64-bit keys do not compress.
46
+ --
47
+ -- Truncation is a bad trade too. A leaf ID collision shows up as a wrong merge in the
48
+ -- data. A fingerprint collision is worse: it is silent. It makes `_ensure` skip the
49
+ -- step and read back a different artifact.
50
+ CREATE TABLE IF NOT EXISTS artifacts (
51
+ fp BLOB PRIMARY KEY, kind VARCHAR
52
+ );
53
+ -- A label: a pointer from a string someone chose to the resolver output they want to
54
+ -- find again. It is kept separate from `artifacts` because labelling is an act, not a
55
+ -- property. Most artifacts carry no label, and moving a label to a newer fingerprint
56
+ -- never disturbs the artifact it used to point at.
57
+ CREATE TABLE IF NOT EXISTS labels (
58
+ label VARCHAR PRIMARY KEY, fp BLOB, published_at TIMESTAMP
59
+ );
60
+ -- A stored source knows its own key column, so its extract can be read back and
61
+ -- joined to a resolver output without the plan that built it.
62
+ CREATE TABLE IF NOT EXISTS source_meta (
63
+ fp BLOB PRIMARY KEY, key_field VARCHAR
64
+ );
65
+ -- Which source artifacts a resolver's output was built from. A resolver's output
66
+ -- records source *names*, and one store can hold several generations of the same name.
67
+ CREATE TABLE IF NOT EXISTS resolver_output_sources (
68
+ fp BLOB, source_name VARCHAR, source_fp BLOB
69
+ );
70
+ -- The three tables below hold every artifact of their kind side by side. They are the
71
+ -- only ones here that grow with the data rather than with the plan.
72
+ --
73
+ -- Every read of them is `WHERE fp = ?` for one artifact, and none carries an index.
74
+ -- That works because the write path already sorts them: each artifact arrives as
75
+ -- exactly one INSERT, so its rows are appended as one contiguous run, and no row group
76
+ -- (~122k rows) straddles two artifacts of any size. DuckDB keeps min/max statistics
77
+ -- per row group, so a scan for one fingerprint skips the others' groups without
78
+ -- decompressing them. On a digest, whose leading bytes are already random, the 8-byte
79
+ -- prefix those statistics truncate to discriminates as well as the whole value would.
80
+ --
81
+ -- This is a property of how we write, not one the storage layer enforces. Writing an
82
+ -- artifact in several statements, or interleaving two artifacts' writes, would scatter
83
+ -- each across row groups whose min/max then span both fingerprints. That prunes
84
+ -- nothing. Nothing else disturbs the layout: `_purge` marks rows deleted in place, a
85
+ -- re-collect appends afresh, and `_rewrite` copies in scan order. Artifacts small
86
+ -- enough to share a row group prune poorly, which costs no more than scanning them
87
+ -- would anyway.
88
+ CREATE TABLE IF NOT EXISTS source_leaves (
89
+ fp BLOB, key VARCHAR, leaf UBIGINT
90
+ );
91
+ CREATE TABLE IF NOT EXISTS model_edges (
92
+ fp BLOB, left_id UBIGINT, right_id UBIGINT, score FLOAT
93
+ );
94
+ CREATE TABLE IF NOT EXISTS resolver_output (
95
+ fp BLOB, root UBIGINT, leaf UBIGINT, key VARCHAR, source VARCHAR
96
+ );
97
+ CREATE TABLE IF NOT EXISTS judgements (
98
+ tag VARCHAR, user_name VARCHAR, endorsed UBIGINT, shown UBIGINT
99
+ );
100
+ CREATE TABLE IF NOT EXISTS expansion (
101
+ root UBIGINT PRIMARY KEY, leaves UBIGINT[]
102
+ );
103
+ """
104
+
105
+
106
+ class DuckDBStoreStats(StoreStats):
107
+ """What a DuckDB store holds, plus what only a file of blocks can report.
108
+
109
+ Attributes:
110
+ path: The database file, or `None` for `:memory:`. `location` already names the
111
+ store. This is the file itself, for code that wants to `stat` or delete it.
112
+ free_bytes: Space already freed inside the file. DuckDB reuses those blocks for
113
+ later writes, but never returns them to the OS. This is the gap between
114
+ what the store weighs and what it holds. It is the only figure that says
115
+ what a reclaim could recover, without first deciding what to delete. It has
116
+ no meaning for a backend that is not a file of reusable blocks. That is
117
+ why it lives here rather than on `StoreStats`.
118
+ """
119
+
120
+ path: Path | None = None
121
+ free_bytes: int = 0
122
+
123
+ @property
124
+ def size(self) -> str:
125
+ """Say when the bytes are resident rather than written.
126
+
127
+ `4.6 MB` reads as a disk size, but for `:memory:` it isn't one. The store
128
+ vanishes with the process. The distinction matters most exactly when a user is
129
+ least likely to be thinking about it.
130
+ """
131
+ return super().size if self.path is not None else f"{super().size} in memory"
132
+
133
+
134
+ def _mint_cluster_id(leaves: list[int]) -> int:
135
+ """Deterministic, content-addressed cluster ID for a group of leaves.
136
+
137
+ A singleton maps to the leaf itself, so evaluation's singleton-expansion fallback
138
+ (`process_judgements`) works without an explicit expansion row.
139
+
140
+ A group uses `root_id`, the same function a resolver mints its roots with. That
141
+ match is load-bearing, not just tidy. Scoring compares a judged group against the
142
+ resolver output's clusters by ID. If the two ever disagree, every comparison
143
+ misses, and precision and recall get computed over an empty set.
144
+ """
145
+ if len(leaves) == 1:
146
+ return int(leaves[0])
147
+ return root_id_of(leaves)
148
+
149
+
150
+ class DuckDBAdapter(Adapter):
151
+ """Store collected artifacts in a DuckDB database, keyed by fingerprint."""
152
+
153
+ def __init__(self, path: str | Path = ":memory:") -> None:
154
+ """Open (or create) the store at `path`. Use `:memory:` for ephemeral stores."""
155
+ self.path = str(path)
156
+ if self.path != ":memory:":
157
+ Path(self.path).parent.mkdir(parents=True, exist_ok=True)
158
+ self.conn = duckdb.connect(self.path)
159
+ self._open_schema()
160
+
161
+ def _open_schema(self) -> None:
162
+ """Create the schema, recreating the store if it predates this version."""
163
+ existing = self.conn.execute(
164
+ "SELECT table_name FROM information_schema.tables WHERE table_name = 'meta'"
165
+ ).fetchone()
166
+ if existing:
167
+ row = self.conn.execute("SELECT schema_version FROM meta").fetchone()
168
+ if row and row[0] == _SCHEMA_VERSION:
169
+ return
170
+
171
+ # Either a pre-versioned store or an older version, so start clean. Artifacts
172
+ # are a cache, and everything in here can be recomputed from the plan that
173
+ # made it.
174
+ for (table,) in self.conn.execute(
175
+ "SELECT table_name FROM information_schema.tables "
176
+ "WHERE table_schema = 'main'"
177
+ ).fetchall():
178
+ self.conn.execute(f'DROP TABLE IF EXISTS "{table}" CASCADE')
179
+
180
+ self.conn.execute(_SCHEMA_DDL)
181
+ self.conn.execute("INSERT INTO meta VALUES (?)", [_SCHEMA_VERSION])
182
+
183
+ # -- helpers ----------------------------------------------------------------------
184
+
185
+ @staticmethod
186
+ def _extract_table(fp: Fingerprint) -> str:
187
+ """Name of the per-source extract table (arbitrary-schema, so its own table)."""
188
+ return f"extract_{fp.hex()}"
189
+
190
+ @staticmethod
191
+ def _transform_table(fp: Fingerprint) -> str:
192
+ """Name of a per-transform materialised table (arbitrary-schema, so its own)."""
193
+ return f"transform_{fp.hex()}"
194
+
195
+ def _register(self, name: str, df: pl.DataFrame) -> None:
196
+ self.conn.register(name, df.to_arrow())
197
+
198
+ def _kind(self, fp: Fingerprint) -> StepKind | None:
199
+ row = self.conn.execute(
200
+ "SELECT kind FROM artifacts WHERE fp = ?", [fp]
201
+ ).fetchone()
202
+ return StepKind(row[0]) if row else None
203
+
204
+ def _register_artifact(self, fp: Fingerprint, kind: StepKind) -> None:
205
+ self.conn.execute(
206
+ "INSERT OR REPLACE INTO artifacts VALUES (?, ?)", [fp, kind.value]
207
+ )
208
+
209
+ def _purge(self, fp: Fingerprint) -> None:
210
+ """Remove any existing artifact for `fp`, so stores are idempotent.
211
+
212
+ Only ever called immediately before storing that same fingerprint again. A
213
+ fingerprint addresses content, so the replacement is the same data by
214
+ construction. That is why any **label** pointing at `fp` is left alone. It
215
+ still resolves, to bytes indistinguishable from the ones it resolved to before.
216
+ A re-collect should not quietly revoke a publication.
217
+ """
218
+ kind = self._kind(fp)
219
+ if kind is None:
220
+ return
221
+ if kind is StepKind.SOURCE:
222
+ self.conn.execute(f'DROP TABLE IF EXISTS "{self._extract_table(fp)}"')
223
+ self.conn.execute("DELETE FROM source_leaves WHERE fp = ?", [fp])
224
+ self.conn.execute("DELETE FROM source_meta WHERE fp = ?", [fp])
225
+ elif kind is StepKind.TRANSFORM:
226
+ self.conn.execute(f'DROP TABLE IF EXISTS "{self._transform_table(fp)}"')
227
+ elif kind is StepKind.MODEL:
228
+ self.conn.execute("DELETE FROM model_edges WHERE fp = ?", [fp])
229
+ elif kind is StepKind.RESOLVER:
230
+ self.conn.execute("DELETE FROM resolver_output WHERE fp = ?", [fp])
231
+ self.conn.execute("DELETE FROM resolver_output_sources WHERE fp = ?", [fp])
232
+ self.conn.execute("DELETE FROM artifacts WHERE fp = ?", [fp])
233
+
234
+ # -- existence --------------------------------------------------------------------
235
+
236
+ def has(self, fp: Fingerprint) -> bool:
237
+ return self._kind(fp) is not None
238
+
239
+ # -- introspection ----------------------------------------------------------------
240
+
241
+ def stats(self) -> DuckDBStoreStats:
242
+ """Report the store's size and contents.
243
+
244
+ **Checkpoints a file store before measuring it.** That makes this the one
245
+ method here that writes without being asked to. Without it, the figure is not
246
+ just imprecise. It is the wrong order of magnitude. Recent writes sit in the
247
+ write-ahead log as a compact journal, and settling them into 256 KB blocks can
248
+ turn 21 KB of log into 5.5 MB of file. Measured on `examples/companies`: 33 KB
249
+ reported against a store that became 5.5 MB the moment it was closed. Reporting
250
+ a size that a user's next `du` contradicts by 170x is worse than reporting none.
251
+
252
+ DuckDB's own block count is no help before that point. It reads zero until a
253
+ checkpoint has happened. Afterwards the two agree to within the file header, so
254
+ `stat` is used instead. It counts the `.wal` sibling too, and it is the number
255
+ a user can actually check.
256
+
257
+ The checkpoint is cheap, because DuckDB has usually already done most of it:
258
+ 1.4 ms after writing 10M rows, 0.08 ms when there is nothing pending.
259
+
260
+ `free_blocks` still comes from DuckDB. Nothing outside the file can see how
261
+ much of it is reusable, and an in-memory store's size is no different: it
262
+ allocates no blocks and has no file to measure.
263
+ """
264
+ if self.path == ":memory:":
265
+ # `sum` over an empty table is NULL, so an untouched store needs the `or 0`
266
+ # as much as a missing row does.
267
+ resident = self.conn.execute(
268
+ "SELECT sum(memory_usage_bytes) FROM duckdb_memory()"
269
+ ).fetchone()
270
+ size = (resident[0] if resident else 0) or 0
271
+ free, path = 0, None
272
+ else:
273
+ self.conn.execute("CHECKPOINT")
274
+ blocks = self.conn.execute(
275
+ "SELECT block_size, free_blocks FROM pragma_database_size()"
276
+ ).fetchone()
277
+ free = blocks[0] * blocks[1] if blocks else 0
278
+ # `self.path` is the constructor's argument verbatim, so it's resolved
279
+ # here. Otherwise a store opened as "./run.duckdb" would report itself
280
+ # against a working directory that has since moved on.
281
+ path = Path(self.path).resolve()
282
+ size = sum(
283
+ candidate.stat().st_size
284
+ for candidate in (path, path.with_name(f"{path.name}.wal"))
285
+ if candidate.exists()
286
+ )
287
+
288
+ labelled = self.conn.execute("SELECT count(*) FROM labels").fetchone()
289
+ return DuckDBStoreStats(
290
+ location=self.path,
291
+ path=path,
292
+ bytes=int(size),
293
+ artifacts=dict(
294
+ self.conn.execute(
295
+ "SELECT kind, count(*) FROM artifacts GROUP BY kind"
296
+ ).fetchall()
297
+ ),
298
+ labels=labelled[0] if labelled else 0,
299
+ free_bytes=free,
300
+ )
301
+
302
+ # -- sources ----------------------------------------------------------------------
303
+
304
+ def store_source(
305
+ self,
306
+ fp: Fingerprint,
307
+ key_field: str,
308
+ extract: pl.DataFrame,
309
+ leaves: pl.DataFrame,
310
+ ) -> None:
311
+ missing = {"key", "leaf"} - set(leaves.columns)
312
+ if missing:
313
+ raise ValueError(f"leaves is missing columns: {sorted(missing)}")
314
+
315
+ self._purge(fp)
316
+
317
+ self._register("_reg_extract", extract)
318
+ self.conn.execute(
319
+ f'CREATE OR REPLACE TABLE "{self._extract_table(fp)}" '
320
+ "AS SELECT * FROM _reg_extract"
321
+ )
322
+ self.conn.unregister("_reg_extract")
323
+
324
+ leaves = leaves.select(
325
+ pl.col("key").cast(pl.Utf8), pl.col("leaf").cast(pl.UInt64)
326
+ )
327
+ self._register("_reg_leaves", leaves)
328
+ self.conn.execute(
329
+ "INSERT INTO source_leaves SELECT ? AS fp, key, leaf FROM _reg_leaves", [fp]
330
+ )
331
+ self.conn.unregister("_reg_leaves")
332
+
333
+ self.conn.execute("INSERT INTO source_meta VALUES (?, ?)", [fp, key_field])
334
+ self._register_artifact(fp, StepKind.SOURCE)
335
+
336
+ def read_source_extract(self, fp: Fingerprint) -> pl.DataFrame:
337
+ if self._kind(fp) is not StepKind.SOURCE:
338
+ raise KeyError(f"No stored source for fingerprint {fp.hex()}")
339
+ return self.conn.execute(f'SELECT * FROM "{self._extract_table(fp)}"').pl()
340
+
341
+ def read_source_leaves(self, fp: Fingerprint) -> pl.DataFrame:
342
+ if self._kind(fp) is not StepKind.SOURCE:
343
+ raise KeyError(f"No stored source for fingerprint {fp.hex()}")
344
+ return self.conn.execute(
345
+ "SELECT key, leaf FROM source_leaves WHERE fp = ?", [fp]
346
+ ).pl()
347
+
348
+ # -- identifiers ------------------------------------------------------------------
349
+
350
+ def read_identifiers(
351
+ self,
352
+ source_fp: Fingerprint,
353
+ source_name: str,
354
+ resolver_fp: Fingerprint | None = None,
355
+ ) -> pl.DataFrame:
356
+ if self._kind(source_fp) is not StepKind.SOURCE:
357
+ raise KeyError(f"No stored source for fingerprint {source_fp.hex()}")
358
+
359
+ if resolver_fp is None:
360
+ return self.conn.execute(
361
+ "SELECT leaf AS id, CAST(? AS VARCHAR) AS source, key, leaf "
362
+ "FROM source_leaves WHERE fp = ?",
363
+ [source_name, source_fp],
364
+ ).pl()
365
+
366
+ if self._kind(resolver_fp) is not StepKind.RESOLVER:
367
+ raise KeyError(f"No stored resolver for fingerprint {resolver_fp.hex()}")
368
+ # Both predicates matter. `source` keeps this from scanning every source's
369
+ # rows, and `resolver_output` holds one generation per collect of the plan.
370
+ return self.conn.execute(
371
+ "SELECT root AS id, source, key, leaf "
372
+ "FROM resolver_output WHERE fp = ? AND source = ?",
373
+ [resolver_fp, source_name],
374
+ ).pl()
375
+
376
+ # -- maintenance ------------------------------------------------------------------
377
+
378
+ def _keep_set(self, keep: Iterable[Fingerprint | str]) -> set[Fingerprint]:
379
+ """Every fingerprint that must survive a prune.
380
+
381
+ Labels go in whether or not they were named. A label also drags in the sources
382
+ its resolver output needs. Reading a published resolver output without a plan
383
+ goes through `resolver_output_sources` to each source's extract. Without them,
384
+ a kept label resolves to a fingerprint whose data is gone.
385
+ """
386
+ kept: set[Fingerprint] = set()
387
+ for item in keep:
388
+ kept |= self._label_closure(item) if isinstance(item, str) else {item}
389
+
390
+ for label in self.labels():
391
+ kept |= self._label_closure(label)
392
+
393
+ return kept
394
+
395
+ def _label_closure(self, label: str) -> set[Fingerprint]:
396
+ """A label's resolver-output fingerprint, plus the sources it reads through."""
397
+ fp = self.find(label)
398
+ if fp is None:
399
+ raise ValueError(
400
+ f"No resolver output is labelled '{label}' in this store. "
401
+ f"Known labels: {', '.join(self.labels()) or 'none'}."
402
+ )
403
+ return {fp, *self.resolver_output_sources(fp).values()}
404
+
405
+ def prune(self, keep: Iterable[Fingerprint | str] = ()) -> PruneResult:
406
+ """Delete every artifact except the ones named, and reclaim what that frees.
407
+
408
+ Deleting is only half of it. DuckDB marks freed blocks for reuse but never
409
+ hands them back to the OS, so purging alone does not shrink the file at all.
410
+ Measured on a real 575 MB store, deleting 77% of its artifacts freed 0 bytes.
411
+ The space comes back only by rewriting the database, copying what is left into a
412
+ fresh file and swapping it in. Purge and rewrite together recovered 437 MB of
413
+ that store, in half a second.
414
+
415
+ The swap is a rename over the original. A failure anywhere leaves the store
416
+ exactly as it was, with the half-written copy orphaned beside it.
417
+
418
+ **This reopens the connection.** That is the one internal detail anything
419
+ outside this class needs to know about. Session settings applied through
420
+ `adapter.conn` (`memory_limit` and `temp_directory`, as the guide suggests) do
421
+ not survive. The adapter itself stays valid, so anything holding *it*, rather
422
+ than its connection, is unaffected.
423
+
424
+ An in-memory store is purged but not rewritten. It has no file, and reopening
425
+ one would hand back an empty database rather than a smaller one. Its freed
426
+ blocks return to the allocator anyway, so the reclaim is real regardless.
427
+ """
428
+ kept = self._keep_set(keep)
429
+ stored = {
430
+ fp for (fp,) in self.conn.execute("SELECT fp FROM artifacts").fetchall()
431
+ }
432
+ doomed = stored - kept
433
+
434
+ if not kept and stored:
435
+ raise ValueError(
436
+ "Pruning with nothing to keep would empty the store. Name a plan or a "
437
+ "label, or delete the file instead if that is what you meant."
438
+ )
439
+
440
+ before = self.stats().bytes
441
+ for fp in doomed:
442
+ self._purge(fp)
443
+
444
+ if self.path != ":memory:":
445
+ self._rewrite()
446
+
447
+ return PruneResult(
448
+ removed=len(doomed),
449
+ kept=len(stored) - len(doomed),
450
+ reclaimed=max(before - self.stats().bytes, 0),
451
+ )
452
+
453
+ def _rewrite(self) -> None:
454
+ """Copy the store into a fresh file and swap it in, returning freed space.
455
+
456
+ The temp file sits beside the store rather than in a temp directory, so the
457
+ swap is a rename within one filesystem and therefore atomic.
458
+ """
459
+ path = Path(self.path).resolve()
460
+ temporary = path.with_name(f"{path.name}.prune-tmp")
461
+ temporary.unlink(missing_ok=True)
462
+
463
+ database = self.conn.execute(
464
+ "SELECT database_name FROM duckdb_databases() WHERE NOT internal LIMIT 1"
465
+ ).fetchone()
466
+ if database is None: # a connected store always has one
467
+ return
468
+
469
+ self.conn.execute("CHECKPOINT")
470
+ self.conn.execute(f"ATTACH '{temporary}' AS pruned")
471
+ self.conn.execute(f'COPY FROM DATABASE "{database[0]}" TO pruned')
472
+ self.conn.execute("DETACH pruned")
473
+ self.conn.close()
474
+
475
+ os.replace(temporary, path)
476
+ self.conn = duckdb.connect(self.path)
477
+ self._open_schema()
478
+
479
+ # -- models -----------------------------------------------------------------------
480
+
481
+ def store_model(self, fp: Fingerprint, edges: pl.DataFrame) -> None:
482
+ check_schema_subset(SCHEMA_MODEL_EDGES, edges.to_arrow().schema)
483
+ self._purge(fp)
484
+ edges = edges.select(
485
+ pl.col("left_id").cast(pl.UInt64),
486
+ pl.col("right_id").cast(pl.UInt64),
487
+ pl.col("score").cast(pl.Float32),
488
+ )
489
+ self._register("_reg_edges", edges)
490
+ self.conn.execute(
491
+ "INSERT INTO model_edges "
492
+ "SELECT ? AS fp, left_id, right_id, score FROM _reg_edges",
493
+ [fp],
494
+ )
495
+ self.conn.unregister("_reg_edges")
496
+ self._register_artifact(fp, StepKind.MODEL)
497
+
498
+ def read_model(self, fp: Fingerprint) -> pl.DataFrame:
499
+ if self._kind(fp) is not StepKind.MODEL:
500
+ raise KeyError(f"No stored model for fingerprint {fp.hex()}")
501
+ return self.conn.execute(
502
+ "SELECT left_id, right_id, score FROM model_edges WHERE fp = ?", [fp]
503
+ ).pl()
504
+
505
+ # -- transforms -------------------------------------------------------------------
506
+
507
+ def store_transform(self, fp: Fingerprint, table: pl.DataFrame) -> None:
508
+ self._purge(fp)
509
+ self._register("_reg_transform", table)
510
+ self.conn.execute(
511
+ f'CREATE OR REPLACE TABLE "{self._transform_table(fp)}" '
512
+ "AS SELECT * FROM _reg_transform"
513
+ )
514
+ self.conn.unregister("_reg_transform")
515
+ self._register_artifact(fp, StepKind.TRANSFORM)
516
+
517
+ def read_transform(self, fp: Fingerprint) -> pl.DataFrame:
518
+ if self._kind(fp) is not StepKind.TRANSFORM:
519
+ raise KeyError(f"No stored transform for fingerprint {fp.hex()}")
520
+ return self.conn.execute(f'SELECT * FROM "{self._transform_table(fp)}"').pl()
521
+
522
+ # -- resolvers --------------------------------------------------------------------
523
+
524
+ def store_resolver(
525
+ self,
526
+ fp: Fingerprint,
527
+ resolver_output: pl.DataFrame,
528
+ sources: Mapping[str, Fingerprint] | None = None,
529
+ ) -> None:
530
+ check_schema_subset(SCHEMA_RESOLVER_OUTPUT, resolver_output.to_arrow().schema)
531
+ self._purge(fp)
532
+ resolver_output = resolver_output.select(
533
+ pl.col("root").cast(pl.UInt64),
534
+ pl.col("leaf").cast(pl.UInt64),
535
+ pl.col("key").cast(pl.Utf8),
536
+ pl.col("source").cast(pl.Utf8),
537
+ )
538
+ self._register("_reg_res", resolver_output)
539
+ self.conn.execute(
540
+ "INSERT INTO resolver_output "
541
+ "SELECT ? AS fp, root, leaf, key, source FROM _reg_res",
542
+ [fp],
543
+ )
544
+ self.conn.unregister("_reg_res")
545
+ for source_name, source_fp in (sources or {}).items():
546
+ self.conn.execute(
547
+ "INSERT INTO resolver_output_sources VALUES (?, ?, ?)",
548
+ [fp, source_name, source_fp],
549
+ )
550
+ self._register_artifact(fp, StepKind.RESOLVER)
551
+
552
+ def read_resolver(self, fp: Fingerprint) -> pl.DataFrame:
553
+ if self._kind(fp) is not StepKind.RESOLVER:
554
+ raise KeyError(f"No stored resolver for fingerprint {fp.hex()}")
555
+ return self.conn.execute(
556
+ "SELECT root, leaf, key, source FROM resolver_output WHERE fp = ?", [fp]
557
+ ).pl()
558
+
559
+ # -- evaluation -------------------------------------------------------------------
560
+
561
+ def store_judgement(self, judgement: Judgement, user_name: str = "local") -> None:
562
+ shown_id = _mint_cluster_id(judgement.shown)
563
+ self._upsert_expansion(shown_id, judgement.shown)
564
+
565
+ for group in judgement.endorsed:
566
+ endorsed_id = _mint_cluster_id(group)
567
+ self._upsert_expansion(endorsed_id, group)
568
+ self.conn.execute(
569
+ "INSERT INTO judgements VALUES (?, ?, ?, ?)",
570
+ [judgement.tag, user_name, endorsed_id, shown_id],
571
+ )
572
+
573
+ def _upsert_expansion(self, root: int, leaves: list[int]) -> None:
574
+ self.conn.execute(
575
+ "INSERT INTO expansion (root, leaves) "
576
+ "SELECT ?, CAST(? AS UBIGINT[]) ON CONFLICT (root) DO NOTHING",
577
+ [root, sorted(int(leaf) for leaf in leaves)],
578
+ )
579
+
580
+ def read_eval_data(
581
+ self, tag: str | None = None
582
+ ) -> tuple[pl.DataFrame, pl.DataFrame]:
583
+ if tag is None:
584
+ judgements = self.conn.execute(
585
+ "SELECT user_name, endorsed, shown FROM judgements"
586
+ ).pl()
587
+ else:
588
+ judgements = self.conn.execute(
589
+ "SELECT user_name, endorsed, shown FROM judgements WHERE tag = ?",
590
+ [tag],
591
+ ).pl()
592
+ expansion = self.conn.execute("SELECT root, leaves FROM expansion").pl()
593
+
594
+ # Present empty results with the right columns/dtypes. We deliberately do not
595
+ # re-validate against the arrow transport schemas here. Those pin `leaves` to a
596
+ # small `list`, while polars naturally emits `large_list`. That's a
597
+ # serialisation detail that is meaningless locally. Types are guaranteed by the
598
+ # table DDL, and inputs are validated on write.
599
+ if judgements.height == 0:
600
+ judgements = pl.DataFrame(schema=pl.Schema(SCHEMA_JUDGEMENTS))
601
+ if expansion.height == 0:
602
+ expansion = pl.DataFrame(schema=pl.Schema(SCHEMA_CLUSTER_EXPANSION))
603
+
604
+ return judgements, expansion
605
+
606
+ def sample(
607
+ self, resolver_fp: Fingerprint, n: int, seed: int | None = None
608
+ ) -> pl.DataFrame:
609
+ resolver_output = self.read_resolver(resolver_fp)
610
+ roots = resolver_output["root"].unique()
611
+ if n < roots.len():
612
+ roots = roots.sample(n=n, seed=seed, shuffle=True)
613
+ return resolver_output.filter(pl.col("root").is_in(roots.to_list())).select(
614
+ "root", "leaf", "key", "source"
615
+ )
616
+
617
+ # -- lookups ----------------------------------------------------------------------
618
+
619
+ def publish(self, label: str, fp: Fingerprint) -> None:
620
+ self.conn.execute(
621
+ "INSERT OR REPLACE INTO labels VALUES (?, ?, now())", [label, fp]
622
+ )
623
+
624
+ def find(self, label: str) -> Fingerprint | None:
625
+ row = self.conn.execute(
626
+ "SELECT fp FROM labels WHERE label = ?", [label]
627
+ ).fetchone()
628
+ return row[0] if row else None
629
+
630
+ def labels(self) -> list[str]:
631
+ return [
632
+ row[0]
633
+ for row in self.conn.execute(
634
+ "SELECT label FROM labels ORDER BY label"
635
+ ).fetchall()
636
+ ]
637
+
638
+ def source_key_field(self, fp: Fingerprint) -> str:
639
+ row = self.conn.execute(
640
+ "SELECT key_field FROM source_meta WHERE fp = ?", [fp]
641
+ ).fetchone()
642
+ if row is None:
643
+ raise KeyError(f"No stored source for fingerprint {fp.hex()}")
644
+ return row[0]
645
+
646
+ def resolver_output_sources(self, fp: Fingerprint) -> dict[str, Fingerprint]:
647
+ return {
648
+ name: source_fp
649
+ for name, source_fp in self.conn.execute(
650
+ "SELECT source_name, source_fp FROM resolver_output_sources "
651
+ "WHERE fp = ?",
652
+ [fp],
653
+ ).fetchall()
654
+ }
655
+
656
+ # -- lifecycle --------------------------------------------------------------------
657
+
658
+ def close(self) -> None:
659
+ self.conn.close()