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/steps.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""The lazy plan node.
|
|
2
|
+
|
|
3
|
+
A `Step` holds references to its **inputs only** (`upstream`). There is no registry,
|
|
4
|
+
no parent pointer, and no downstream list. "The DAG" is whatever is reachable
|
|
5
|
+
upstream from the node you hold, and lineage operations are pure functions of a root
|
|
6
|
+
node (`matchlab.lineage`).
|
|
7
|
+
|
|
8
|
+
Nothing is computed until `collect()`. Collection walks the plan upstream-first and
|
|
9
|
+
runs only the steps whose artifact is not already stored.
|
|
10
|
+
|
|
11
|
+
**Fingerprints identify artifacts.** A step's fingerprint combines its kind, its
|
|
12
|
+
spec, and its inputs' fingerprints. Everything downstream of a source can therefore
|
|
13
|
+
derive it from the *plan alone*, before any work happens, and `collect`
|
|
14
|
+
can skip a cached step without running it. Sources are the exception. Raw data enters
|
|
15
|
+
there, so a source's spec key includes a content hash of the data it read.
|
|
16
|
+
Constructing a fresh `Source` therefore re-reads the warehouse (the documented way to
|
|
17
|
+
refresh), while an existing `Source` object memoises its read.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from abc import ABC, abstractmethod
|
|
22
|
+
from typing import ClassVar, Self
|
|
23
|
+
|
|
24
|
+
from platformdirs import user_cache_path
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from matchlab import lineage
|
|
28
|
+
from matchlab.adapters import Adapter, DuckDBAdapter, Fingerprint
|
|
29
|
+
from matchlab.core.hash import HASH_FUNC
|
|
30
|
+
from matchlab.core.kinds import StepKind
|
|
31
|
+
from matchlab.lineage import StepStatus
|
|
32
|
+
from matchlab.progress import report
|
|
33
|
+
|
|
34
|
+
CACHE_DIR = user_cache_path("matchlab")
|
|
35
|
+
|
|
36
|
+
_DEFAULT_ADAPTER: Adapter | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_default_adapter(adapter: Adapter | None) -> None:
|
|
40
|
+
"""Set the adapter used by `collect()` when none is passed. `None` resets it."""
|
|
41
|
+
global _DEFAULT_ADAPTER # module-level default, by design
|
|
42
|
+
_DEFAULT_ADAPTER = adapter
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_adapter() -> Adapter:
|
|
46
|
+
"""Return the default adapter, creating a DuckDB store in the cache dir if unset."""
|
|
47
|
+
global _DEFAULT_ADAPTER # module-level default, by design
|
|
48
|
+
if _DEFAULT_ADAPTER is None:
|
|
49
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
_DEFAULT_ADAPTER = DuckDBAdapter(CACHE_DIR / "store.duckdb")
|
|
51
|
+
return _DEFAULT_ADAPTER
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Step(ABC):
|
|
55
|
+
"""A node in a lazy plan."""
|
|
56
|
+
|
|
57
|
+
kind: ClassVar[StepKind]
|
|
58
|
+
|
|
59
|
+
def __init__(self, upstream: "tuple[Step, ...]" = ()) -> None:
|
|
60
|
+
"""Initialise a plan node with its direct inputs.
|
|
61
|
+
|
|
62
|
+
Steps have no names. They are identified by **position**, where they fall in
|
|
63
|
+
`lineage.walk`, which is the order `collect` runs them in and the order
|
|
64
|
+
`PlanDocument` lists them in. `step 7` in a log, `[7]` in `draw()`, and
|
|
65
|
+
`steps[7]` in a document are therefore the same node.
|
|
66
|
+
|
|
67
|
+
A position is not stored here, because it is not a property of the step. It
|
|
68
|
+
belongs to the walk it came from, and the same step numbers differently in
|
|
69
|
+
`walk(deduped)` and `walk(companies)`. Whoever does the walking passes it to
|
|
70
|
+
whoever needs it, `collect` to its reporter, `draw` to its own renderer.
|
|
71
|
+
|
|
72
|
+
Finding a result later is a separate matter, and a separate act.
|
|
73
|
+
`Resolver.publish` points a **label** at a resolver's output. `Source` is the
|
|
74
|
+
one step with a name, and it means something else again. A source's name is
|
|
75
|
+
part of its output, prefixing every column it contributes and tagging its rows.
|
|
76
|
+
"""
|
|
77
|
+
self.upstream = tuple(upstream)
|
|
78
|
+
self._fp: Fingerprint | None = None
|
|
79
|
+
self._adapter: Adapter | None = None
|
|
80
|
+
|
|
81
|
+
def __repr__(self) -> str:
|
|
82
|
+
"""Return a short representation showing kind and collection state."""
|
|
83
|
+
return f"<{type(self).__name__} {'collected' if self.is_collected else 'lazy'}>"
|
|
84
|
+
|
|
85
|
+
def __str__(self) -> str:
|
|
86
|
+
"""How this step appears in a drawing."""
|
|
87
|
+
return self.kind
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def is_collected(self) -> bool:
|
|
91
|
+
"""Whether this step has been materialised."""
|
|
92
|
+
return self._fp is not None
|
|
93
|
+
|
|
94
|
+
# -- plan identity ----------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def spec(self) -> BaseModel:
|
|
99
|
+
"""This step's settings, as a serialisable model.
|
|
100
|
+
|
|
101
|
+
One model per step kind, in `matchlab.specs`. It must carry everything this
|
|
102
|
+
step's output depends on and nothing else. That is the invariant `_spec_key`
|
|
103
|
+
rests on, and the one to check when adding a setting. Omit something that
|
|
104
|
+
changes the output and collect will hand back a stale artifact without
|
|
105
|
+
re-running (see `_fingerprint`).
|
|
106
|
+
|
|
107
|
+
Specs describe a step's own settings, not its inputs'. Edges live on
|
|
108
|
+
`upstream`, and `_fingerprint` already folds in the parents' fingerprints.
|
|
109
|
+
"""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
def _spec_key(self) -> bytes:
|
|
113
|
+
"""Bytes identifying this step's spec.
|
|
114
|
+
|
|
115
|
+
`Source` extends this with a content hash of the data it read, so that the
|
|
116
|
+
fingerprint tracks the warehouse. For every other kind the spec is the
|
|
117
|
+
whole story.
|
|
118
|
+
"""
|
|
119
|
+
return json.dumps(self.spec.model_dump(mode="json"), sort_keys=True).encode()
|
|
120
|
+
|
|
121
|
+
def _fingerprint(self) -> Fingerprint:
|
|
122
|
+
"""Address this step by kind, spec and inputs.
|
|
123
|
+
|
|
124
|
+
The key is derived from the *plan*, not from the step's output. That is what
|
|
125
|
+
makes it computable before the step runs, which is what lets `_ensure` skip
|
|
126
|
+
work. An output digest would only be knowable once the work was already done.
|
|
127
|
+
Sources are the exception. They fold a hash of the data they read into
|
|
128
|
+
`_spec_key`, because no spec reveals that the warehouse moved.
|
|
129
|
+
|
|
130
|
+
The trade-off is that the key can disagree with the bytes in both directions.
|
|
131
|
+
|
|
132
|
+
* the spec omits something that changes the output, a **stale hit**, because
|
|
133
|
+
`_ensure` never runs the step and reads the old artifact. `SplinkLinker`
|
|
134
|
+
without an explicit seed is a live example. The only defence is that every
|
|
135
|
+
`_spec_key` covers everything its step's output depends on. Treat that as
|
|
136
|
+
the invariant to protect when adding a step or a setting.
|
|
137
|
+
* the spec includes something that doesn't change the output, a **spurious
|
|
138
|
+
miss**, re-running the step and everything below it. No spec does this
|
|
139
|
+
today, and the way to keep it that way is to record settings only. A spec
|
|
140
|
+
that described an *input* would, since identity already arrives via the
|
|
141
|
+
parent fingerprint. A setting that must point at an input points at its
|
|
142
|
+
position, which is not redundant. It decides which input the setting
|
|
143
|
+
applies to.
|
|
144
|
+
|
|
145
|
+
There is also no early cutoff. A `Transform` whose SQL is reformatted but
|
|
146
|
+
semantically unchanged invalidates the whole subtree beneath it.
|
|
147
|
+
|
|
148
|
+
TODO(fingerprints): split this into an action key (plan-derived, as now)
|
|
149
|
+
mapping to an output digest (content-derived, recorded by the adapter on
|
|
150
|
+
write), and build a child's key from its parents' output digests rather than
|
|
151
|
+
their action keys. That buys early cutoff, storage dedup and rename tolerance.
|
|
152
|
+
It does **not** fix stale hits. An output digest governs propagation, never
|
|
153
|
+
admission, so a step whose action key is a hit still never runs and its digest
|
|
154
|
+
is never consulted. Costs an indirection table in the adapter contract, a hash
|
|
155
|
+
of every artifact on write, and the loss of a work-set knowable before running.
|
|
156
|
+
See PLAN.md, "Known limitations", for the full ledger.
|
|
157
|
+
"""
|
|
158
|
+
parts: list[bytes] = [self.kind.encode(), self._spec_key()]
|
|
159
|
+
for parent in self.upstream:
|
|
160
|
+
if parent._fp is None: # collect orders upstream first
|
|
161
|
+
raise RuntimeError(
|
|
162
|
+
f"A {parent.kind} input of this {self.kind} has no fingerprint yet."
|
|
163
|
+
)
|
|
164
|
+
parts.append(parent._fp)
|
|
165
|
+
return HASH_FUNC(b"|".join(parts)).digest()
|
|
166
|
+
|
|
167
|
+
# -- execution --------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
@abstractmethod
|
|
170
|
+
def _execute(self, adapter: Adapter, fp: Fingerprint) -> None:
|
|
171
|
+
"""Compute this step and persist its artifact under `fp`."""
|
|
172
|
+
...
|
|
173
|
+
|
|
174
|
+
def _ensure(self, adapter: Adapter) -> StepStatus:
|
|
175
|
+
"""Materialise this step unless its artifact is already stored.
|
|
176
|
+
|
|
177
|
+
This classifies the outcome but reports none of it. `collect` holds the walk,
|
|
178
|
+
and therefore each step's position, the thing a record has to quote to be
|
|
179
|
+
findable in the plan. Reporting belongs there, with the single
|
|
180
|
+
`matchlab.progress.Progress` that owns what is drawn and what is logged. That is
|
|
181
|
+
also what keeps the two from ever disagreeing. This keeps to the work.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
What it took. `DONE` if this call computed the step, `CACHED` if the
|
|
185
|
+
artifact was already stored.
|
|
186
|
+
"""
|
|
187
|
+
self._adapter = adapter
|
|
188
|
+
|
|
189
|
+
if self._fp is not None and adapter.has(self._fp):
|
|
190
|
+
return StepStatus.CACHED
|
|
191
|
+
|
|
192
|
+
fp = self._fingerprint()
|
|
193
|
+
|
|
194
|
+
if adapter.has(fp): # cache hit, skip the work entirely
|
|
195
|
+
self._fp = fp
|
|
196
|
+
return StepStatus.CACHED
|
|
197
|
+
|
|
198
|
+
self._execute(adapter, fp)
|
|
199
|
+
self._fp = fp
|
|
200
|
+
return StepStatus.DONE
|
|
201
|
+
|
|
202
|
+
# -- public API -------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def collect(
|
|
205
|
+
self, adapter: Adapter | None = None, interactive: bool | None = None
|
|
206
|
+
) -> Self:
|
|
207
|
+
"""Materialise this step and everything it depends on.
|
|
208
|
+
|
|
209
|
+
Steps whose artifact is already stored are skipped without being run, so
|
|
210
|
+
re-collecting after adding a downstream step only does the new work.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
adapter: Where to read and write artifacts. Defaults to the module-level
|
|
214
|
+
adapter (a DuckDB store in the user cache directory).
|
|
215
|
+
interactive: Whether someone is watching. `None`, the default, takes a
|
|
216
|
+
terminal or a notebook as a yes. When they are, the plan is drawn as a
|
|
217
|
+
live tree redrawn in place, and not logged. The tree on screen is the
|
|
218
|
+
key those `[step N]` records need, and it stays there. When they are
|
|
219
|
+
not, the plan is logged instead. See `matchlab.progress`.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
This step, now collected.
|
|
223
|
+
"""
|
|
224
|
+
adapter = adapter or default_adapter()
|
|
225
|
+
# One walk, used for both. It fixes each step's position, so a `step 7` the
|
|
226
|
+
# reporter logs is the node it drew as `[7]`.
|
|
227
|
+
steps = lineage.walk(self)
|
|
228
|
+
with report(self, steps, interactive, adapter) as reporter:
|
|
229
|
+
for step in steps:
|
|
230
|
+
reporter.begin(step)
|
|
231
|
+
try:
|
|
232
|
+
status = step._ensure(adapter)
|
|
233
|
+
except Exception:
|
|
234
|
+
reporter.end(step, StepStatus.FAILED)
|
|
235
|
+
raise
|
|
236
|
+
reporter.end(step, status)
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def lineage(self) -> list["Step"]:
|
|
240
|
+
"""Return this step and all its inputs, upstream-first."""
|
|
241
|
+
return lineage.walk(self)
|
|
242
|
+
|
|
243
|
+
def draw(self) -> str:
|
|
244
|
+
"""Render this step's sub-plan as a tree."""
|
|
245
|
+
return lineage.draw(self)
|
|
246
|
+
|
|
247
|
+
def fingerprints(self) -> set[Fingerprint]:
|
|
248
|
+
"""Address every artifact this plan is made of, its own and its inputs'.
|
|
249
|
+
|
|
250
|
+
Which artifacts belong to a plan is the plan's own business, so this is where
|
|
251
|
+
a store gets told: `adapter.prune(keep=plan.fingerprints())` hands storage a set
|
|
252
|
+
of addresses it already understands, rather than a graph it would have to learn
|
|
253
|
+
to walk.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
One fingerprint per step in `lineage()`. A set, because two steps in one
|
|
257
|
+
plan can address the same artifact. Identical specs over identical
|
|
258
|
+
inputs is the same bytes, and it is stored once.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
RuntimeError: If any step has not been collected. An uncollected plan names
|
|
262
|
+
no artifacts at all, so answering with a smaller set would quietly tell
|
|
263
|
+
a caller that less is worth keeping than they think.
|
|
264
|
+
"""
|
|
265
|
+
return {step._collected()[1] for step in self.lineage()}
|
|
266
|
+
|
|
267
|
+
# -- helpers for subclasses -------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def _collected(self) -> tuple[Adapter, Fingerprint]:
|
|
270
|
+
"""Return the adapter this step was collected into, and its fingerprint.
|
|
271
|
+
|
|
272
|
+
Both together, because everything that reads a stored artifact needs both and
|
|
273
|
+
neither exists before collection. Returning the pair is what lets a caller
|
|
274
|
+
use the fingerprint without re-checking that it is there.
|
|
275
|
+
"""
|
|
276
|
+
if self._adapter is None or self._fp is None:
|
|
277
|
+
raise RuntimeError(
|
|
278
|
+
f"This {self.kind} has not been collected. Call collect() first."
|
|
279
|
+
)
|
|
280
|
+
return self._adapter, self._fp
|
|
281
|
+
|
|
282
|
+
def _require_adapter(self) -> Adapter:
|
|
283
|
+
"""Return the adapter this step was collected into."""
|
|
284
|
+
return self._collected()[0]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Generate data whose answer you already know, then score matching against it.
|
|
2
|
+
|
|
3
|
+
This package makes entity resolution *measurable*. It plants entities you already know
|
|
4
|
+
the answer for, so a test can assert precision and recall against truth rather than
|
|
5
|
+
eyeball a fixture.
|
|
6
|
+
|
|
7
|
+
The package has one job, done in four steps: **describe** the data (`features`),
|
|
8
|
+
**generate** it and plant the answer (`sources`, `linked`), **match** it (`matchers`),
|
|
9
|
+
then **score** the result (`compare`). `LinkedSources` is the handle for all of it. It
|
|
10
|
+
planted the entities, so nothing downstream has to be told the answer.
|
|
11
|
+
|
|
12
|
+
## Which do I use?
|
|
13
|
+
|
|
14
|
+
| I want… | Use |
|
|
15
|
+
|---|---|
|
|
16
|
+
| data whose answer I know | `linked_sources_factory()` → `LinkedSources` |
|
|
17
|
+
| one source only | `source_factory()` → `GeneratedSource` |
|
|
18
|
+
| rows I wrote by hand | `source_from_tuple()` |
|
|
19
|
+
| a plan to test | `linked.dedupe(...)` / `linked.link(..., through=...)` |
|
|
20
|
+
| to score a collected plan | `linked.diff_resolver_output(resolver_output, *sources)` |
|
|
21
|
+
| to score a methodology's edges | `linked.diff_model_edges(edges, left=...)` |
|
|
22
|
+
| different columns | `FeatureConfig` + `SuffixRule` / `ReplaceRule` |
|
|
23
|
+
| a matcher that already knows the answer | `PerfectDeduper` / `PerfectLinker` |
|
|
24
|
+
| to build or bend the answer it matches on | `AnswerKey.from_sources(...)` |
|
|
25
|
+
| to compose a comparison by hand | `resolver_output_to_clusters` + `diff_entities` |
|
|
26
|
+
|
|
27
|
+
`dedupe()` and `link()` wire a perfect matcher up for you, so the last two rows are
|
|
28
|
+
only for when you want a matcher that is deliberately wrong in a particular way.
|
|
29
|
+
|
|
30
|
+
A testkit exposes what it knows about the *fixture*. Anything you do to the *plan* goes
|
|
31
|
+
through the node it wraps: `source.source.dedupe(...)`, `model.model.resolve()`. There
|
|
32
|
+
are no forwarding shortcuts, so it is always clear which of the two you are holding.
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from matchlab.testkit import linked_sources_factory
|
|
36
|
+
|
|
37
|
+
linked = linked_sources_factory(
|
|
38
|
+
n_true_entities=10, engine=warehouse
|
|
39
|
+
).write_to_location()
|
|
40
|
+
resolver_output = linked.link("crn", "cdms").model.resolve().collect().entities()
|
|
41
|
+
|
|
42
|
+
identical, report = linked.diff_resolver_output(resolver_output, "crn", "cdms")
|
|
43
|
+
assert identical, report
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Two layers, two questions
|
|
47
|
+
|
|
48
|
+
Ground truth is expressed twice, in different ID spaces. Neither subsumes the other, and
|
|
49
|
+
picking the wrong one is the easiest mistake to make here:
|
|
50
|
+
|
|
51
|
+
| | Synthetic-ID layer | Value-keyed layer |
|
|
52
|
+
|---|---|---|
|
|
53
|
+
| Asks | does this *methodology* work? | does the *plan* carry grouping forward? |
|
|
54
|
+
| Built from | `GeneratedModel.predicted_clusters` | `AnswerKey` + `Perfect*` |
|
|
55
|
+
| Scored with | `diff_model_edges` | `diff_resolver_output` |
|
|
56
|
+
| Needs a warehouse | no, the record step read is mocked | yes, and a real `collect()` |
|
|
57
|
+
| Example | `test/methodologies/` | `test/plan/test_ground_truth.py` |
|
|
58
|
+
|
|
59
|
+
The split exists because matchlab derives record identity by content-hashing rows at
|
|
60
|
+
collect time. IDs are unknowable when a fixture is built, so anything asserted against a
|
|
61
|
+
*collected* plan has to be keyed by row values instead. See `matchers` for the full
|
|
62
|
+
reasoning. `dedupe()`/`link()` build both, so either is available from one call.
|
|
63
|
+
|
|
64
|
+
Names not re-exported below are still importable from their module, but they are
|
|
65
|
+
plumbing rather than entry points.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
from matchlab.testkit.compare import diff_entities, resolver_output_to_clusters
|
|
69
|
+
from matchlab.testkit.entities import Cluster, TrueEntity
|
|
70
|
+
from matchlab.testkit.features import (
|
|
71
|
+
FeatureConfig,
|
|
72
|
+
ReplaceRule,
|
|
73
|
+
SourceParameters,
|
|
74
|
+
SuffixRule,
|
|
75
|
+
)
|
|
76
|
+
from matchlab.testkit.linked import LinkedSources, linked_sources_factory
|
|
77
|
+
from matchlab.testkit.matchers import AnswerKey, PerfectDeduper, PerfectLinker
|
|
78
|
+
from matchlab.testkit.models import GeneratedModel
|
|
79
|
+
from matchlab.testkit.sources import GeneratedSource, source_factory, source_from_tuple
|
|
80
|
+
|
|
81
|
+
__all__ = (
|
|
82
|
+
# generate data with a known answer
|
|
83
|
+
"linked_sources_factory",
|
|
84
|
+
"LinkedSources",
|
|
85
|
+
"source_factory",
|
|
86
|
+
"source_from_tuple",
|
|
87
|
+
"GeneratedSource",
|
|
88
|
+
"GeneratedModel",
|
|
89
|
+
# match, when you want something other than a perfect matcher
|
|
90
|
+
"PerfectDeduper",
|
|
91
|
+
"PerfectLinker",
|
|
92
|
+
"AnswerKey",
|
|
93
|
+
# describe what to generate
|
|
94
|
+
"FeatureConfig",
|
|
95
|
+
"SuffixRule",
|
|
96
|
+
"ReplaceRule",
|
|
97
|
+
"SourceParameters",
|
|
98
|
+
# score results against the answer
|
|
99
|
+
"diff_entities",
|
|
100
|
+
"resolver_output_to_clusters",
|
|
101
|
+
# the vocabulary you get back
|
|
102
|
+
"TrueEntity",
|
|
103
|
+
"Cluster",
|
|
104
|
+
)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Row and entity generation.
|
|
2
|
+
|
|
3
|
+
Internal. Reach for `source_factory` or `linked_sources_factory` instead. These are the
|
|
4
|
+
pieces they are built from, and they take arguments that only make sense once the
|
|
5
|
+
callers have worked them out.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from functools import cache
|
|
9
|
+
from itertools import product
|
|
10
|
+
from math import prod
|
|
11
|
+
|
|
12
|
+
import polars as pl
|
|
13
|
+
import pyarrow as pa
|
|
14
|
+
from faker import Faker
|
|
15
|
+
|
|
16
|
+
from matchlab.testkit.entities import EntityReference, TrueEntity
|
|
17
|
+
from matchlab.testkit.features import FeatureConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@cache
|
|
21
|
+
def generate_entities(
|
|
22
|
+
generator: Faker,
|
|
23
|
+
features: tuple[FeatureConfig, ...],
|
|
24
|
+
n: int,
|
|
25
|
+
) -> tuple[TrueEntity]:
|
|
26
|
+
"""Generate base entities with their ground truth values from the generator."""
|
|
27
|
+
entities = []
|
|
28
|
+
for _ in range(n):
|
|
29
|
+
base_values = {}
|
|
30
|
+
for feature in features:
|
|
31
|
+
generator_func = generator.unique if feature.unique else generator
|
|
32
|
+
value_generator = getattr(generator_func, feature.base_generator)
|
|
33
|
+
parameters = {} if not feature.parameters else dict(feature.parameters)
|
|
34
|
+
|
|
35
|
+
value = value_generator(**parameters)
|
|
36
|
+
# Explicitly cast lists to tuples to ensure they are hashable
|
|
37
|
+
if isinstance(value, list):
|
|
38
|
+
value = tuple(value)
|
|
39
|
+
base_values[feature.name] = value
|
|
40
|
+
|
|
41
|
+
entities.append(TrueEntity(base_values=base_values, keys=EntityReference()))
|
|
42
|
+
return tuple(entities)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def generate_rows(
|
|
46
|
+
generator: Faker,
|
|
47
|
+
selected_entities: tuple[TrueEntity, ...],
|
|
48
|
+
features: tuple[FeatureConfig, ...],
|
|
49
|
+
repetition: int,
|
|
50
|
+
) -> tuple[dict[str, list], dict[int, list[str]], dict[int, list[str]]]:
|
|
51
|
+
"""Generate raw data rows, plus the maps that connect three different IDs.
|
|
52
|
+
|
|
53
|
+
Every row carries three IDs, each answering a different question. Confusing them is
|
|
54
|
+
the mistake this docstring exists to prevent.
|
|
55
|
+
|
|
56
|
+
* `key`: the row's own identifier. Like a source's primary key, unique within the
|
|
57
|
+
source but not across entities.
|
|
58
|
+
* `id`: matchlab's identifier for the row's *content*. Rows with identical feature
|
|
59
|
+
values share one `id`, whichever entity produced them.
|
|
60
|
+
* `entity`: the ID of the `TrueEntity` that generated the row. This is the planted
|
|
61
|
+
answer, and the one truth downstream tests check their result against.
|
|
62
|
+
|
|
63
|
+
`entity` groups rows by who generated them, and `id` groups rows by what content
|
|
64
|
+
they hold. The two groupings diverge whenever an entity produces more than one
|
|
65
|
+
variation: rows from the same entity can carry different `id`s, and (via
|
|
66
|
+
`repetition`) the same `id` can appear on more than one row.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
- raw_data: column arrays, ready to build a DataFrame from
|
|
70
|
+
- entity_keys: `TrueEntity` ID -> the keys it produced
|
|
71
|
+
- id_keys: content `id` -> the keys sharing that content
|
|
72
|
+
|
|
73
|
+
Worked example, two entities, each producing two variations twice over:
|
|
74
|
+
|
|
75
|
+
| id | key | company_name |
|
|
76
|
+
|----|-----|--------------|
|
|
77
|
+
| 1 | a | alpha co |
|
|
78
|
+
| 2 | b | alpha ltd |
|
|
79
|
+
| 1 | c | alpha co | # same content as row a
|
|
80
|
+
| 2 | d | alpha ltd | # same content as row b
|
|
81
|
+
| 3 | e | beta co |
|
|
82
|
+
| 4 | f | beta ltd |
|
|
83
|
+
| 3 | g | beta co | # same content as row e
|
|
84
|
+
| 4 | h | beta ltd | # same content as row f
|
|
85
|
+
|
|
86
|
+
As raw data:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
raw_data = {
|
|
90
|
+
"id": [1, 2, 1, 2, 3, 4, 3, 4],
|
|
91
|
+
"key": ["a", "b", "c", "d", "e", "f", "g", "h"],
|
|
92
|
+
"company_name": [
|
|
93
|
+
"alpha co",
|
|
94
|
+
"alpha ltd",
|
|
95
|
+
"alpha co",
|
|
96
|
+
"alpha ltd",
|
|
97
|
+
"beta co",
|
|
98
|
+
"beta ltd",
|
|
99
|
+
"beta co",
|
|
100
|
+
"beta ltd",
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Grouped by entity, the two variations sit together:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
entity_keys = {
|
|
109
|
+
1: ["a", "b", "c", "d"], # every key entity 1 produced
|
|
110
|
+
2: ["e", "f", "g", "h"], # every key entity 2 produced
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Grouped by content, they split apart again:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
id_keys = {
|
|
118
|
+
1: ["a", "c"], # both rows read "alpha co"
|
|
119
|
+
2: ["b", "d"], # both rows read "alpha ltd"
|
|
120
|
+
3: ["e", "g"], # both rows read "beta co"
|
|
121
|
+
4: ["f", "h"], # both rows read "beta ltd"
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
"""
|
|
125
|
+
raw_data = {"key": [], "id": []}
|
|
126
|
+
for feature in features:
|
|
127
|
+
raw_data[feature.name] = []
|
|
128
|
+
|
|
129
|
+
# Track entity locations and row identities
|
|
130
|
+
entity_keys = {entity.id: [] for entity in selected_entities}
|
|
131
|
+
id_keys = {}
|
|
132
|
+
value_to_id = {}
|
|
133
|
+
|
|
134
|
+
def add_row(entity_id: int, values: tuple) -> None:
|
|
135
|
+
"""Add a row of data, handling IDs and keys."""
|
|
136
|
+
key = str(generator.uuid4())
|
|
137
|
+
entity_keys[entity_id].append(key)
|
|
138
|
+
|
|
139
|
+
if values not in value_to_id:
|
|
140
|
+
mb_id = generator.random_number(digits=16)
|
|
141
|
+
value_to_id[values] = mb_id
|
|
142
|
+
id_keys[mb_id] = []
|
|
143
|
+
|
|
144
|
+
row_id = value_to_id[values]
|
|
145
|
+
id_keys[row_id].append(key)
|
|
146
|
+
|
|
147
|
+
raw_data["key"].append(key)
|
|
148
|
+
raw_data["id"].append(row_id)
|
|
149
|
+
for feature, value in zip(features, values, strict=True):
|
|
150
|
+
raw_data[feature.name].append(value)
|
|
151
|
+
|
|
152
|
+
for entity in selected_entities:
|
|
153
|
+
# Collect all possible values, per feature.
|
|
154
|
+
possible_values = []
|
|
155
|
+
for feature in features:
|
|
156
|
+
base = entity.base_values[feature.name]
|
|
157
|
+
|
|
158
|
+
for rule in feature.variations:
|
|
159
|
+
if not isinstance(base, rule.type):
|
|
160
|
+
raise TypeError(
|
|
161
|
+
f"{rule.__class__.__name__} requires {rule.type} "
|
|
162
|
+
f"but {feature.name} generates {type(base)}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
variations = []
|
|
166
|
+
# Apply all variations as long as they change the value
|
|
167
|
+
for v in (rule.apply(base) for rule in feature.variations):
|
|
168
|
+
if v != base:
|
|
169
|
+
variations.append(v)
|
|
170
|
+
|
|
171
|
+
values = variations if feature.drop_base else variations + [base]
|
|
172
|
+
possible_values.append(values or [base])
|
|
173
|
+
|
|
174
|
+
if (num_variations := prod(len(values) for values in possible_values)) > 100:
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
f"Entity {entity.id} would generate {num_variations:,} variations, "
|
|
177
|
+
"which exceeds the maximum of 100. This would cause a Cartesian "
|
|
178
|
+
"explosion."
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
for values in product(*possible_values):
|
|
182
|
+
for _ in range(repetition + 1):
|
|
183
|
+
add_row(entity.id, values)
|
|
184
|
+
|
|
185
|
+
return raw_data, entity_keys, id_keys
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@cache
|
|
189
|
+
def generate_source(
|
|
190
|
+
generator: Faker,
|
|
191
|
+
n_true_entities: int,
|
|
192
|
+
features: tuple[FeatureConfig, ...],
|
|
193
|
+
repetition: int,
|
|
194
|
+
seed_entities: tuple[TrueEntity, ...] | None = None,
|
|
195
|
+
) -> tuple[pa.Table, dict[int, set[str]], dict[int, set[str]]]:
|
|
196
|
+
"""Generate raw data as PyArrow tables with entity tracking.
|
|
197
|
+
|
|
198
|
+
Note: this is `@cache`d but also *mutates* `total_unique_variations` on the shared
|
|
199
|
+
`TrueEntity` objects it is given, so a cache hit skips that mutation. Harmless
|
|
200
|
+
today because the first call for a given key does the writing, but it means the
|
|
201
|
+
function is not free of side effects and the cache cannot simply be dropped.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
- data: PyArrow table with generated data
|
|
205
|
+
- entity_keys: TrueEntity ID -> list of keys mapping
|
|
206
|
+
- id_keys: Unique row ID -> list of keys mapping for identical rows
|
|
207
|
+
"""
|
|
208
|
+
# Select or generate entities
|
|
209
|
+
if seed_entities is None:
|
|
210
|
+
selected_entities = generate_entities(generator, features, n_true_entities)
|
|
211
|
+
else:
|
|
212
|
+
selected_entities = generator.random_elements(
|
|
213
|
+
elements=seed_entities,
|
|
214
|
+
unique=True,
|
|
215
|
+
length=min(n_true_entities, len(seed_entities)),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Generate initial data
|
|
219
|
+
raw_data, entity_keys, id_keys = generate_rows(
|
|
220
|
+
generator=generator,
|
|
221
|
+
selected_entities=selected_entities,
|
|
222
|
+
features=features,
|
|
223
|
+
repetition=repetition,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# Create DataFrame
|
|
227
|
+
df = pl.DataFrame(raw_data)
|
|
228
|
+
|
|
229
|
+
# Update variation counts
|
|
230
|
+
for entity in selected_entities:
|
|
231
|
+
if entity.id in entity_keys:
|
|
232
|
+
# Count unique row IDs this entity appears in
|
|
233
|
+
entity_rows = df.filter(pl.col("key").is_in(list(entity_keys[entity.id])))
|
|
234
|
+
entity.total_unique_variations = entity_rows["id"].n_unique()
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
df.to_arrow(),
|
|
238
|
+
entity_keys,
|
|
239
|
+
id_keys,
|
|
240
|
+
)
|