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.
- matchlab/__init__.py +55 -0
- matchlab/adapters/__init__.py +24 -0
- matchlab/adapters/base.py +446 -0
- matchlab/adapters/duckdb.py +659 -0
- matchlab/cli.py +177 -0
- matchlab/core/__init__.py +1 -0
- matchlab/core/dataframes.py +254 -0
- matchlab/core/dsu.py +62 -0
- matchlab/core/exceptions.py +40 -0
- matchlab/core/hash.py +141 -0
- matchlab/core/kinds.py +12 -0
- matchlab/core/logging.py +190 -0
- matchlab/core/resolver_output.py +173 -0
- matchlab/core/schemas.py +82 -0
- matchlab/core/sql.py +12 -0
- matchlab/document.py +329 -0
- matchlab/eval/__init__.py +11 -0
- matchlab/eval/judgements.py +41 -0
- matchlab/eval/metrics.py +244 -0
- matchlab/eval/samples.py +415 -0
- matchlab/eval/tui/__init__.py +66 -0
- matchlab/eval/tui/app.py +418 -0
- matchlab/eval/tui/modals.py +84 -0
- matchlab/eval/tui/styles.tcss +184 -0
- matchlab/eval/tui/widgets/__init__.py +1 -0
- matchlab/eval/tui/widgets/assignment.py +72 -0
- matchlab/eval/tui/widgets/styling.py +75 -0
- matchlab/eval/tui/widgets/table.py +285 -0
- matchlab/lineage.py +239 -0
- matchlab/locations.py +347 -0
- matchlab/models/__init__.py +10 -0
- matchlab/models/comparison.py +67 -0
- matchlab/models/dedupers/__init__.py +5 -0
- matchlab/models/dedupers/base.py +38 -0
- matchlab/models/dedupers/naive.py +77 -0
- matchlab/models/linkers/__init__.py +9 -0
- matchlab/models/linkers/base.py +41 -0
- matchlab/models/linkers/deterministic.py +229 -0
- matchlab/models/linkers/splinklinker.py +262 -0
- matchlab/models/linkers/weighteddeterministic.py +143 -0
- matchlab/models/models.py +198 -0
- matchlab/progress.py +455 -0
- matchlab/recordstep.py +181 -0
- matchlab/resolvers/__init__.py +10 -0
- matchlab/resolvers/base.py +63 -0
- matchlab/resolvers/components.py +66 -0
- matchlab/resolvers/resolvers.py +409 -0
- matchlab/sources.py +291 -0
- matchlab/specs.py +129 -0
- matchlab/steps.py +284 -0
- matchlab/testkit/__init__.py +104 -0
- matchlab/testkit/_generate.py +240 -0
- matchlab/testkit/compare.py +178 -0
- matchlab/testkit/entities.py +290 -0
- matchlab/testkit/features.py +146 -0
- matchlab/testkit/linked.py +527 -0
- matchlab/testkit/matchers.py +233 -0
- matchlab/testkit/models.py +165 -0
- matchlab/testkit/sources.py +327 -0
- matchlab/transformers/__init__.py +24 -0
- matchlab/transformers/base.py +41 -0
- matchlab/transformers/clean.py +48 -0
- matchlab/transformers/explode.py +67 -0
- matchlab/transformers/group.py +56 -0
- matchlab/transformers/select.py +31 -0
- matchlab/transformers/transform.py +101 -0
- matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
- matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
- matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
- matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
- 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()
|