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
matchlab/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The matchlab plan API.
|
|
2
|
+
|
|
3
|
+
Build a plan from a `Source`, chain verbs, then `collect()`:
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from matchlab import Source
|
|
7
|
+
|
|
8
|
+
deduped = source.clean(...).dedupe(...).resolve()
|
|
9
|
+
lookup = deduped.collect().get_lookup()
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Nothing runs until `collect()`, and collecting again only does the work whose plan
|
|
13
|
+
or inputs changed.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from importlib.metadata import version
|
|
17
|
+
|
|
18
|
+
from matchlab.document import PlanDocument, dump, load
|
|
19
|
+
from matchlab.locations import RelationalDBLocation
|
|
20
|
+
from matchlab.models import Model
|
|
21
|
+
from matchlab.recordstep import RecordStep
|
|
22
|
+
from matchlab.resolvers import Resolver
|
|
23
|
+
from matchlab.sources import Source
|
|
24
|
+
from matchlab.steps import Step, default_adapter, set_default_adapter
|
|
25
|
+
from matchlab.transformers import (
|
|
26
|
+
Clean,
|
|
27
|
+
Group,
|
|
28
|
+
Select,
|
|
29
|
+
Transform,
|
|
30
|
+
Transformer,
|
|
31
|
+
add_transformer_class,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
__version__ = version("matchlab")
|
|
35
|
+
|
|
36
|
+
__all__ = (
|
|
37
|
+
"Clean",
|
|
38
|
+
"Group",
|
|
39
|
+
"Model",
|
|
40
|
+
"PlanDocument",
|
|
41
|
+
"RecordStep",
|
|
42
|
+
"RelationalDBLocation",
|
|
43
|
+
"Resolver",
|
|
44
|
+
"Select",
|
|
45
|
+
"Source",
|
|
46
|
+
"Step",
|
|
47
|
+
"Transform",
|
|
48
|
+
"Transformer",
|
|
49
|
+
"__version__",
|
|
50
|
+
"add_transformer_class",
|
|
51
|
+
"default_adapter",
|
|
52
|
+
"dump",
|
|
53
|
+
"load",
|
|
54
|
+
"set_default_adapter",
|
|
55
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Storage adapters for matchlab.
|
|
2
|
+
|
|
3
|
+
An adapter persists collected DAG-step artifacts keyed by fingerprint. It is storage,
|
|
4
|
+
not an engine that resolves on demand. `DuckDBAdapter` is the reference implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from matchlab.adapters.base import (
|
|
8
|
+
Adapter,
|
|
9
|
+
Fingerprint,
|
|
10
|
+
PruneResult,
|
|
11
|
+
StoreStats,
|
|
12
|
+
format_bytes,
|
|
13
|
+
)
|
|
14
|
+
from matchlab.adapters.duckdb import DuckDBAdapter, DuckDBStoreStats
|
|
15
|
+
|
|
16
|
+
__all__ = (
|
|
17
|
+
"Adapter",
|
|
18
|
+
"DuckDBAdapter",
|
|
19
|
+
"DuckDBStoreStats",
|
|
20
|
+
"Fingerprint",
|
|
21
|
+
"PruneResult",
|
|
22
|
+
"StoreStats",
|
|
23
|
+
"format_bytes",
|
|
24
|
+
)
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""The storage adapter contract for matchlab.
|
|
2
|
+
|
|
3
|
+
An adapter is **storage, not an engine**. It persists the artifacts each collected DAG
|
|
4
|
+
step produces, keyed by that step's content fingerprint, and reads them back. It does
|
|
5
|
+
*not* resolve anything on demand. Resolvers materialise their complete, merge-forward
|
|
6
|
+
output at collect time and hand the adapter a finished table.
|
|
7
|
+
|
|
8
|
+
Artifacts, by step kind (schemas in `matchlab.core.schemas`, which holds exactly the
|
|
9
|
+
shapes that cross this boundary):
|
|
10
|
+
|
|
11
|
+
* Source → warehouse extract (arbitrary schema) + leaf assignment `(key, leaf)`.
|
|
12
|
+
* Transform → the reshaped record step it materialises (arbitrary schema).
|
|
13
|
+
* Model → edge list, `SCHEMA_MODEL_EDGES` `(left_id, right_id, score)`.
|
|
14
|
+
* Resolver → complete flat output, `SCHEMA_RESOLVER_OUTPUT` `(root, leaf, key, src)`.
|
|
15
|
+
This is the merge-forward guarantee. It computes `merge(upstream complete
|
|
16
|
+
output, own clusters)`, not just the resolver's own clusters.
|
|
17
|
+
|
|
18
|
+
A `Source` and a `Resolver` produce a record step too, but neither stores one. A source
|
|
19
|
+
is fully materialised as its *extract* and derives its record step on read. A resolver
|
|
20
|
+
read is a re-derivable join over the source's extract and the resolver's own output.
|
|
21
|
+
Only a `Transform` stores its materialisation.
|
|
22
|
+
|
|
23
|
+
Plus evaluation storage (judgements + cluster expansion), publication (`publish` points
|
|
24
|
+
a label at a resolver's output) and `close`.
|
|
25
|
+
|
|
26
|
+
**Nothing here deletes an artifact on the store's own initiative.** A store keeps what
|
|
27
|
+
it is given until the owner disposes of it. `prune` is that disposal. It deletes only
|
|
28
|
+
what the caller has said it may, and never a published resolver output. See the guide's
|
|
29
|
+
"Reclaiming storage".
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from abc import ABC, abstractmethod
|
|
33
|
+
from collections.abc import Iterable, Mapping
|
|
34
|
+
|
|
35
|
+
import polars as pl
|
|
36
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
37
|
+
|
|
38
|
+
from matchlab.core.dataframes import qualify
|
|
39
|
+
from matchlab.core.kinds import StepKind
|
|
40
|
+
from matchlab.eval.judgements import Judgement
|
|
41
|
+
|
|
42
|
+
Fingerprint = bytes
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class StoreStats(BaseModel):
|
|
46
|
+
"""What a store holds, and what it costs.
|
|
47
|
+
|
|
48
|
+
A store keeps everything collected into it, so it grows with every plan and every
|
|
49
|
+
edit to one. Adapters subclass this to report metrics that are relevant to them.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
location: Where the store is, as a reader would name it, such as a path, a
|
|
53
|
+
URI, or `":memory:"`. Always present, because a store you cannot point at
|
|
54
|
+
is one you cannot go and delete.
|
|
55
|
+
bytes: The store's size. For anything file-backed, this is a **high-water
|
|
56
|
+
mark**. A store that briefly held 4 GB reports 4 GB after the rows are
|
|
57
|
+
gone, because deleting inside a database file does not return space to the
|
|
58
|
+
OS. That is the right number for a disk filling up, and the wrong one for
|
|
59
|
+
"how much data do I have".
|
|
60
|
+
artifacts: How many artifacts of each step kind are stored.
|
|
61
|
+
labels: How many published labels point into the store.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(frozen=True)
|
|
65
|
+
|
|
66
|
+
location: str
|
|
67
|
+
bytes: int
|
|
68
|
+
artifacts: dict[StepKind, int] = Field(default_factory=dict)
|
|
69
|
+
labels: int = 0
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def size(self) -> str:
|
|
73
|
+
"""This store's size, as a phrase.
|
|
74
|
+
|
|
75
|
+
A hook, because a bare figure can mislead. Bytes held in memory and bytes
|
|
76
|
+
written to disk are not the same claim, and only the backend knows which one
|
|
77
|
+
it just reported.
|
|
78
|
+
"""
|
|
79
|
+
return format_bytes(self.bytes)
|
|
80
|
+
|
|
81
|
+
def describe(self, since: "StoreStats | None" = None) -> str:
|
|
82
|
+
"""One clause saying what the store costs, for a collect to print.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
since: An earlier reading, so the growth between them can be attributed to
|
|
86
|
+
whatever happened in between. Without it the figure is the store's size
|
|
87
|
+
and nothing more, which names no cause.
|
|
88
|
+
"""
|
|
89
|
+
count = sum(self.artifacts.values())
|
|
90
|
+
growth = (
|
|
91
|
+
f" ({format_bytes(self.bytes - since.bytes, signed=True)})"
|
|
92
|
+
if since is not None
|
|
93
|
+
else ""
|
|
94
|
+
)
|
|
95
|
+
return f"Store {self.size}{growth}, {count} artifact{'' if count == 1 else 's'}"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PruneResult(BaseModel):
|
|
99
|
+
"""What a prune removed, and what that actually recovered.
|
|
100
|
+
|
|
101
|
+
Adapters subclass this where they have more to say.
|
|
102
|
+
|
|
103
|
+
Attributes:
|
|
104
|
+
removed: How many artifacts were deleted.
|
|
105
|
+
kept: How many survived. This includes the ones named, the ones their lineage
|
|
106
|
+
needed, and everything published.
|
|
107
|
+
reclaimed: Bytes genuinely returned. **Measured** as the store's size before
|
|
108
|
+
minus after, not totted up from what was deleted. The two are not the same
|
|
109
|
+
number. Deleting inside a database file usually frees nothing at all until
|
|
110
|
+
the file is rewritten, and a reclaim that reported the bytes it *deleted*
|
|
111
|
+
would claim to have freed space while the disk sat unchanged.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
model_config = ConfigDict(frozen=True)
|
|
115
|
+
|
|
116
|
+
removed: int
|
|
117
|
+
kept: int
|
|
118
|
+
reclaimed: int
|
|
119
|
+
|
|
120
|
+
def describe(self) -> str:
|
|
121
|
+
"""One line saying what happened, for a caller to print."""
|
|
122
|
+
return (
|
|
123
|
+
f"Removed {self.removed} artifact{'' if self.removed == 1 else 's'}, "
|
|
124
|
+
f"kept {self.kept}, reclaimed {format_bytes(self.reclaimed)}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def format_bytes(count: int, *, signed: bool = False) -> str:
|
|
129
|
+
"""Render a byte count the way someone reading a disk would.
|
|
130
|
+
|
|
131
|
+
Binary units, because that is what a file browser and `du -h` report, and a size
|
|
132
|
+
that disagrees with the one the user can check is worse than no size at all.
|
|
133
|
+
"""
|
|
134
|
+
sign = "+" if signed and count >= 0 else "-" if count < 0 else ""
|
|
135
|
+
size = float(abs(count))
|
|
136
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
137
|
+
if size < 1024 or unit == "TB":
|
|
138
|
+
# Whole bytes never want a decimal point. Larger units always do, so a
|
|
139
|
+
# store that grew stays visibly distinct from one that didn't.
|
|
140
|
+
precision = 0 if unit == "B" else 1
|
|
141
|
+
return f"{sign}{size:.{precision}f} {unit}"
|
|
142
|
+
size /= 1024
|
|
143
|
+
raise AssertionError("unreachable: the loop returns at TB")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Adapter(ABC):
|
|
147
|
+
"""Fingerprint-keyed storage for collected DAG-step artifacts.
|
|
148
|
+
|
|
149
|
+
Implementations are single-user and local. `DuckDBAdapter` is the reference.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
# -- existence / caching ----------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
@abstractmethod
|
|
155
|
+
def has(self, fp: Fingerprint) -> bool:
|
|
156
|
+
"""Return whether an artifact for this fingerprint is already stored.
|
|
157
|
+
|
|
158
|
+
The client uses this to skip re-running a step whose plan is unchanged.
|
|
159
|
+
"""
|
|
160
|
+
...
|
|
161
|
+
|
|
162
|
+
# -- sources ----------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
@abstractmethod
|
|
165
|
+
def store_source(
|
|
166
|
+
self,
|
|
167
|
+
fp: Fingerprint,
|
|
168
|
+
key_field: str,
|
|
169
|
+
extract: pl.DataFrame,
|
|
170
|
+
leaves: pl.DataFrame,
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Store a collected source.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
fp: The source step's fingerprint.
|
|
176
|
+
key_field: Which column of `extract` holds the key. Stored so the extract
|
|
177
|
+
can be read back and joined to a resolver's output without the plan.
|
|
178
|
+
extract: The warehouse extract (arbitrary schema) to cache.
|
|
179
|
+
leaves: The leaf assignment, columns `(key: str, leaf: uint64)`.
|
|
180
|
+
"""
|
|
181
|
+
...
|
|
182
|
+
|
|
183
|
+
@abstractmethod
|
|
184
|
+
def read_source_extract(self, fp: Fingerprint) -> pl.DataFrame:
|
|
185
|
+
"""Return the cached warehouse extract for a stored source."""
|
|
186
|
+
...
|
|
187
|
+
|
|
188
|
+
@abstractmethod
|
|
189
|
+
def read_source_leaves(self, fp: Fingerprint) -> pl.DataFrame:
|
|
190
|
+
"""Return the `(key, leaf)` assignment for a stored source."""
|
|
191
|
+
...
|
|
192
|
+
|
|
193
|
+
# -- identifiers ------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
@abstractmethod
|
|
196
|
+
def read_identifiers(
|
|
197
|
+
self,
|
|
198
|
+
source_fp: Fingerprint,
|
|
199
|
+
source_name: str,
|
|
200
|
+
resolver_fp: Fingerprint | None = None,
|
|
201
|
+
) -> pl.DataFrame:
|
|
202
|
+
"""Return `(id, source, key, leaf)` for one source's records.
|
|
203
|
+
|
|
204
|
+
`id` is `resolver_fp`'s root cluster when reading through a resolver, otherwise
|
|
205
|
+
the source's own leaf. This is the *upstream output* a downstream resolver
|
|
206
|
+
needs in order to carry every reachable leaf forward, including records no model
|
|
207
|
+
matched.
|
|
208
|
+
|
|
209
|
+
A query rather than an artifact. Nothing here is computed. Both readings are
|
|
210
|
+
projections of tables this store already holds, `source_leaves` and
|
|
211
|
+
`resolver_output`. There is nothing to cache, and caching it under a record
|
|
212
|
+
step's fingerprint would be wrong anyway. The result depends only on the source
|
|
213
|
+
and resolver read, not on how a record step reshapes the data.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
source_fp: Fingerprint of the stored source whose records are wanted.
|
|
217
|
+
source_name: The source's name, which is returned in the `source` column
|
|
218
|
+
and tags each row for the resolver output below.
|
|
219
|
+
resolver_fp: Fingerprint of the resolver to read through, or `None` to read
|
|
220
|
+
the source's own leaves.
|
|
221
|
+
"""
|
|
222
|
+
...
|
|
223
|
+
|
|
224
|
+
# -- models -----------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
@abstractmethod
|
|
227
|
+
def store_model(self, fp: Fingerprint, edges: pl.DataFrame) -> None:
|
|
228
|
+
"""Store a model's edge list (`SCHEMA_MODEL_EDGES`)."""
|
|
229
|
+
...
|
|
230
|
+
|
|
231
|
+
@abstractmethod
|
|
232
|
+
def read_model(self, fp: Fingerprint) -> pl.DataFrame:
|
|
233
|
+
"""Return a stored model's edge list."""
|
|
234
|
+
...
|
|
235
|
+
|
|
236
|
+
# -- transforms -------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
@abstractmethod
|
|
239
|
+
def store_transform(self, fp: Fingerprint, table: pl.DataFrame) -> None:
|
|
240
|
+
"""Store a transform's materialised record step (arbitrary schema).
|
|
241
|
+
|
|
242
|
+
Called on every collect, so a transform feeding several models is computed
|
|
243
|
+
once and read back by each.
|
|
244
|
+
"""
|
|
245
|
+
...
|
|
246
|
+
|
|
247
|
+
@abstractmethod
|
|
248
|
+
def read_transform(self, fp: Fingerprint) -> pl.DataFrame:
|
|
249
|
+
"""Return a stored transform's record step."""
|
|
250
|
+
...
|
|
251
|
+
|
|
252
|
+
# -- resolvers --------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
@abstractmethod
|
|
255
|
+
def store_resolver(
|
|
256
|
+
self,
|
|
257
|
+
fp: Fingerprint,
|
|
258
|
+
resolver_output: pl.DataFrame,
|
|
259
|
+
sources: Mapping[str, Fingerprint] | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Store a resolver's complete flat output.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
fp: The resolver step's fingerprint.
|
|
265
|
+
resolver_output: `SCHEMA_RESOLVER_OUTPUT` columns `(root, leaf, key,
|
|
266
|
+
source)`, already merged forward over all upstream leaves. The adapter
|
|
267
|
+
does not verify the merge. That is the client's contract. The adapter
|
|
268
|
+
does validate the schema.
|
|
269
|
+
sources: Source name to fingerprint, for every source this output covers. A
|
|
270
|
+
resolver's output names its sources but one store can hold several
|
|
271
|
+
generations of a name, so this records which were actually used.
|
|
272
|
+
"""
|
|
273
|
+
...
|
|
274
|
+
|
|
275
|
+
# -- labels -----------------------------------------------------------------------
|
|
276
|
+
#
|
|
277
|
+
# A **label** is a pointer, kept here, from a string you chose to a resolver output
|
|
278
|
+
# you want to find again. It is deliberately not called a name. A *name* belongs to
|
|
279
|
+
# a source and is part of its output. A label belongs to the store and is part of
|
|
280
|
+
# finding things in it. Storing an artifact and labelling one are separate acts.
|
|
281
|
+
# `Resolver.publish` does the second, after the first.
|
|
282
|
+
#
|
|
283
|
+
# This is what lets evaluation run against a store alone, with no plan.
|
|
284
|
+
|
|
285
|
+
@abstractmethod
|
|
286
|
+
def publish(self, label: str, fp: Fingerprint) -> None:
|
|
287
|
+
"""Point `label` at `fp`, replacing whatever it pointed at before.
|
|
288
|
+
|
|
289
|
+
The adapter moves the pointer without arguing. The caller decides whether
|
|
290
|
+
overwriting is allowed, since it knows what the user asked for.
|
|
291
|
+
"""
|
|
292
|
+
...
|
|
293
|
+
|
|
294
|
+
@abstractmethod
|
|
295
|
+
def find(self, label: str) -> Fingerprint | None:
|
|
296
|
+
"""Return the fingerprint a label points at, if any."""
|
|
297
|
+
...
|
|
298
|
+
|
|
299
|
+
@abstractmethod
|
|
300
|
+
def labels(self) -> list[str]:
|
|
301
|
+
"""Return every label in this store, sorted."""
|
|
302
|
+
...
|
|
303
|
+
|
|
304
|
+
@abstractmethod
|
|
305
|
+
def source_key_field(self, fp: Fingerprint) -> str:
|
|
306
|
+
"""Return which column of a stored source's extract holds the key."""
|
|
307
|
+
...
|
|
308
|
+
|
|
309
|
+
def read_source_records(
|
|
310
|
+
self, source_fp: Fingerprint, source_name: str, keys: pl.Series
|
|
311
|
+
) -> tuple[pl.DataFrame, str]:
|
|
312
|
+
"""Return a source's stored rows for `keys`, every column qualified by name.
|
|
313
|
+
|
|
314
|
+
A query rather than an artifact, like `read_identifiers`, and concrete rather
|
|
315
|
+
than abstract because it is nothing but `source_key_field` and
|
|
316
|
+
`read_source_extract` composed.
|
|
317
|
+
|
|
318
|
+
The extract cached when the source was collected *is* the data the matching saw.
|
|
319
|
+
That is what you want in front of you when judging a resolver's output, and it
|
|
320
|
+
means both reading an entity and reviewing one work with no warehouse
|
|
321
|
+
connection.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
source_fp: Fingerprint of the stored source whose rows are wanted.
|
|
325
|
+
source_name: The source's name, which every column is prefixed with.
|
|
326
|
+
keys: The keys to return rows for.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
`(rows, qualified key column)`.
|
|
330
|
+
"""
|
|
331
|
+
key_field = self.source_key_field(source_fp)
|
|
332
|
+
extract = self.read_source_extract(source_fp)
|
|
333
|
+
qualified = extract.select(pl.all().name.prefix(qualify(source_name)))
|
|
334
|
+
qualified_key = qualify(source_name, key_field)
|
|
335
|
+
rows = qualified.with_columns(pl.col(qualified_key).cast(pl.Utf8)).filter(
|
|
336
|
+
pl.col(qualified_key).is_in(keys.implode())
|
|
337
|
+
)
|
|
338
|
+
return rows, qualified_key
|
|
339
|
+
|
|
340
|
+
@abstractmethod
|
|
341
|
+
def resolver_output_sources(self, fp: Fingerprint) -> dict[str, Fingerprint]:
|
|
342
|
+
"""Return source name to fingerprint for a stored resolver output."""
|
|
343
|
+
...
|
|
344
|
+
|
|
345
|
+
@abstractmethod
|
|
346
|
+
def read_resolver(self, fp: Fingerprint) -> pl.DataFrame:
|
|
347
|
+
"""Return a resolver's stored output `(root, leaf, key, source)`."""
|
|
348
|
+
...
|
|
349
|
+
|
|
350
|
+
# -- evaluation -------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
@abstractmethod
|
|
353
|
+
def store_judgement(self, judgement: Judgement, user_name: str = "local") -> None:
|
|
354
|
+
"""Persist a user judgement, expanding its clusters to leaves for scoring."""
|
|
355
|
+
...
|
|
356
|
+
|
|
357
|
+
@abstractmethod
|
|
358
|
+
def read_eval_data(
|
|
359
|
+
self, tag: str | None = None
|
|
360
|
+
) -> tuple[pl.DataFrame, pl.DataFrame]:
|
|
361
|
+
"""Return `(judgements, expansion)` tables for `matchlab.core.eval`.
|
|
362
|
+
|
|
363
|
+
`judgements` follows `SCHEMA_JUDGEMENTS`. `expansion` follows
|
|
364
|
+
`SCHEMA_CLUSTER_EXPANSION`. Both filter to `tag` when it is given.
|
|
365
|
+
"""
|
|
366
|
+
...
|
|
367
|
+
|
|
368
|
+
@abstractmethod
|
|
369
|
+
def sample(
|
|
370
|
+
self, resolver_fp: Fingerprint, n: int, seed: int | None = None
|
|
371
|
+
) -> pl.DataFrame:
|
|
372
|
+
"""Sample up to `n` clusters from a stored resolver output for evaluation.
|
|
373
|
+
|
|
374
|
+
Returns `SCHEMA_RESOLVER_OUTPUT` rows `(root, leaf, key, source)` for the
|
|
375
|
+
sampled roots.
|
|
376
|
+
"""
|
|
377
|
+
...
|
|
378
|
+
|
|
379
|
+
# -- introspection ----------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
@abstractmethod
|
|
382
|
+
def stats(self) -> StoreStats:
|
|
383
|
+
"""Report the store's size and contents.
|
|
384
|
+
|
|
385
|
+
Abstract rather than an optional hook like `close`, because there is no honest
|
|
386
|
+
default. A store that reported zero would be wrong in exactly the place a user
|
|
387
|
+
is being asked to trust a number, and a store that cannot say how much room it
|
|
388
|
+
is taking is not one to hand a growing cache to.
|
|
389
|
+
|
|
390
|
+
Called on every collect, so it must be cheap. It may also settle pending writes
|
|
391
|
+
to report a size that has stopped moving, since `DuckDBAdapter` checkpoints.
|
|
392
|
+
This is not guaranteed to be a pure read.
|
|
393
|
+
"""
|
|
394
|
+
...
|
|
395
|
+
|
|
396
|
+
# -- maintenance ------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
@abstractmethod
|
|
399
|
+
def prune(self, keep: Iterable[Fingerprint | str] = ()) -> PruneResult:
|
|
400
|
+
"""Delete every artifact except the ones named, and reclaim what that frees.
|
|
401
|
+
|
|
402
|
+
**You say what to keep. Nothing is inferred.** This is deliberately not the
|
|
403
|
+
`gc()` that used to live here. That one worked out what was still alive from
|
|
404
|
+
which Python objects happened to be reachable. A fresh interpreter has nothing
|
|
405
|
+
reachable, so it considered the whole store garbage and duly emptied one
|
|
406
|
+
that had a published resolver output in it. An artifact's worth has nothing to
|
|
407
|
+
do with whether some process is holding the variable that produced it. The root
|
|
408
|
+
set therefore arrives as an argument, from a caller who has just named it.
|
|
409
|
+
|
|
410
|
+
Both kinds of name are ones a store already understands. A plan is different.
|
|
411
|
+
Which artifacts belong to one is the plan's business, and `Step.fingerprints()`
|
|
412
|
+
answers that question. Storage should not have to learn to walk a graph in
|
|
413
|
+
order to know what it may delete.
|
|
414
|
+
|
|
415
|
+
**Published labels are kept whether or not they are named**, along with the
|
|
416
|
+
sources their resolver output needs in order to stay readable. Publishing is the
|
|
417
|
+
strongest "keep this" the system has, and losing one because a caller forgot to
|
|
418
|
+
list it would be the old mistake in new clothes.
|
|
419
|
+
|
|
420
|
+
Implementations must:
|
|
421
|
+
|
|
422
|
+
* never touch stored judgements, which are human work and cannot be recomputed
|
|
423
|
+
from anything
|
|
424
|
+
* report `reclaimed` as space genuinely returned, measured rather than assumed.
|
|
425
|
+
Deleting is not always the same as reclaiming, and a store that says it freed
|
|
426
|
+
something it did not is worse than one that frees nothing.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
keep: What to preserve. Either a `Fingerprint`, or a `str` naming a
|
|
430
|
+
published label, which keeps that resolver output and the sources it
|
|
431
|
+
reads through.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
What was removed and what that recovered.
|
|
435
|
+
|
|
436
|
+
Raises:
|
|
437
|
+
ValueError: If nothing would survive the prune, because `keep` was empty
|
|
438
|
+
and nothing is published. Deleting everything is the same as deleting
|
|
439
|
+
the file, and should not be what an accidentally-empty list does.
|
|
440
|
+
"""
|
|
441
|
+
...
|
|
442
|
+
|
|
443
|
+
# -- lifecycle --------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
def close(self) -> None: # noqa: B027 - optional concrete hook, not abstract
|
|
446
|
+
"""Release any underlying resources. Override if needed."""
|