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/core/logging.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""The logger and console everything in matchlab reports through.
|
|
2
|
+
|
|
3
|
+
Two objects, one each for the two ways output leaves the library:
|
|
4
|
+
|
|
5
|
+
* `logger` — the `matchlab` logger, wrapped so any record can carry a prefix saying
|
|
6
|
+
which part of a plan produced it. Like any library logger it has no handler of its
|
|
7
|
+
own, and is silent until the application configures logging.
|
|
8
|
+
* `console` — the Rich console. `matchlab.progress` draws the collection tree on it,
|
|
9
|
+
and tests swap it for a quiet one.
|
|
10
|
+
|
|
11
|
+
Both are module attributes rather than values passed around, so `matchlab.progress`
|
|
12
|
+
reads them through the module (`mlog.console`) instead of importing them directly.
|
|
13
|
+
That indirection is what lets a test patch one and have the library pick it up.
|
|
14
|
+
|
|
15
|
+
**A prefix is usually ambient, not passed.** The thing worth quoting is a step's
|
|
16
|
+
position in the plan being collected, and the code that knows a message worth logging
|
|
17
|
+
is rarely the code that knows the position — a `Linker` counting matches is three
|
|
18
|
+
layers below the walk that numbered its step. `collect` therefore publishes the
|
|
19
|
+
position in `prefix`, a `ContextVar`, and every record emitted while that step runs
|
|
20
|
+
picks it up. Call sites stay ignorant, which is what keeps positions a property of the
|
|
21
|
+
walk rather than of the step (see `matchlab.steps.Step.__init__`).
|
|
22
|
+
|
|
23
|
+
The `prefix=` keyword remains for callers with something to say outside a collection,
|
|
24
|
+
where there is no position to quote. It is a fallback, not an override: an ambient
|
|
25
|
+
position wins, so records read consistently within the run that printed the tree.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import sys
|
|
30
|
+
from collections.abc import Generator
|
|
31
|
+
from contextlib import contextmanager
|
|
32
|
+
from contextvars import ContextVar
|
|
33
|
+
from typing import IO, Any, Final
|
|
34
|
+
|
|
35
|
+
from rich.console import Console
|
|
36
|
+
from rich.file_proxy import FileProxy
|
|
37
|
+
|
|
38
|
+
prefix: Final[ContextVar[str | None]] = ContextVar("matchlab_prefix", default=None)
|
|
39
|
+
"""The prefix records carry right now, or `None` outside any collection.
|
|
40
|
+
|
|
41
|
+
Set by `matchlab.progress.Progress` for the duration of each step, so that anything
|
|
42
|
+
logged while that step runs is tied to the plan the collection drew. Being a
|
|
43
|
+
`ContextVar` is what makes it restore correctly when a collection runs inside another:
|
|
44
|
+
the inner one holds the outer one's token and gives it back on the way out.
|
|
45
|
+
|
|
46
|
+
A handler or filter that wants to know which step is running can read it directly.
|
|
47
|
+
Setting it is `Progress`'s business — a record of your own takes `prefix=` instead.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PrefixedLoggerAdapter(logging.LoggerAdapter):
|
|
52
|
+
"""A logger adapter that tags records with the plan position they came from.
|
|
53
|
+
|
|
54
|
+
The prefix is normally ambient, read from `prefix`. A `prefix=` keyword on the call
|
|
55
|
+
supplies one for records emitted outside a collection.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def process(
|
|
59
|
+
self,
|
|
60
|
+
msg: Any, # noqa: ANN401
|
|
61
|
+
kwargs: dict[str, Any],
|
|
62
|
+
) -> tuple[Any, dict[str, Any]]:
|
|
63
|
+
"""Add the ambient or explicit prefix to `msg`, if either is set."""
|
|
64
|
+
# Popped either way: `prefix` is ours, and `Logger.log` would reject it.
|
|
65
|
+
explicit = kwargs.pop("prefix", None)
|
|
66
|
+
# Ambient first: inside a collection the position is the prefix that matches
|
|
67
|
+
# the tree, and it is the one a reader can look up. A caller's own idea of
|
|
68
|
+
# what it is only applies where there is no position to quote.
|
|
69
|
+
tag = prefix.get() or explicit
|
|
70
|
+
|
|
71
|
+
if tag:
|
|
72
|
+
msg = f"[{tag}] {msg}"
|
|
73
|
+
|
|
74
|
+
return msg, kwargs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
logger: Final[PrefixedLoggerAdapter] = PrefixedLoggerAdapter(
|
|
78
|
+
logging.getLogger("matchlab"), {}
|
|
79
|
+
)
|
|
80
|
+
"""Logger for matchlab.
|
|
81
|
+
|
|
82
|
+
Used for all logging in the matchlab library.
|
|
83
|
+
|
|
84
|
+
Records logged while a collection is running are prefixed with the position of the step
|
|
85
|
+
that produced them, which is what ties a record to the plan `collect` drew. Nothing at
|
|
86
|
+
the call site arranges that; see `prefix`.
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
```python
|
|
90
|
+
logger.info(f"Round {round_num}: Found {len(matches):,} matches")
|
|
91
|
+
```
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
console: Final[Console] = Console()
|
|
95
|
+
"""Console for matchlab.
|
|
96
|
+
|
|
97
|
+
Where `matchlab.progress` draws a collection's plan, and where any CLI utility writes.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@contextmanager
|
|
102
|
+
def through_console() -> Generator[None]:
|
|
103
|
+
"""Route handlers already writing to this terminal through the console instead.
|
|
104
|
+
|
|
105
|
+
Rich redirects `sys.stdout` and `sys.stderr` while a live display is running, so a
|
|
106
|
+
bare `print` lands above the frame rather than through it. Logging misses out:
|
|
107
|
+
`logging.StreamHandler` reads `sys.stderr` **once**, when it is constructed, and
|
|
108
|
+
`logging.basicConfig()` constructs one at import time — long before there is a
|
|
109
|
+
frame to avoid. That handler then writes straight past Rich for the rest of the
|
|
110
|
+
process.
|
|
111
|
+
|
|
112
|
+
That is the ordinary case, not an exotic one. Rather than asking every caller to
|
|
113
|
+
work around it, `matchlab.progress` closes the gap itself: for as long as it owns
|
|
114
|
+
the terminal, it points any handler bound to that terminal at the same proxy Rich
|
|
115
|
+
installs, and puts it back afterwards. Handlers writing anywhere else — a file, a
|
|
116
|
+
socket, another stream — are left alone, having nothing to collide with.
|
|
117
|
+
|
|
118
|
+
It restores the original streams on the way out, however the block ends. It's
|
|
119
|
+
reentrant only in the sense that a handler already proxied is skipped, so a nested
|
|
120
|
+
display can't double-wrap one.
|
|
121
|
+
|
|
122
|
+
It does nothing in a notebook. The problem it solves is a cursor collision, and a
|
|
123
|
+
notebook has no cursor: Rich draws a live display into an `ipywidgets.Output` that
|
|
124
|
+
redraws itself in isolation, so nothing can smear it. Routing records through the
|
|
125
|
+
console there would replace each one with its own `display()` call — a separate
|
|
126
|
+
HTML block, boxed and spaced apart. Left alone, the stream they already write to
|
|
127
|
+
renders them as ordinary contiguous text instead.
|
|
128
|
+
"""
|
|
129
|
+
if console.is_jupyter:
|
|
130
|
+
yield
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
# Read before Rich swaps them: afterwards `sys.stdout` is a proxy, and a handler
|
|
134
|
+
# holding the real stream would no longer look like it points at the terminal.
|
|
135
|
+
terminal = {sys.stdout, sys.stderr, console.file}
|
|
136
|
+
patched: list[tuple[logging.StreamHandler, IO[str]]] = []
|
|
137
|
+
|
|
138
|
+
target: logging.Logger | None = logging.getLogger("matchlab")
|
|
139
|
+
while target is not None:
|
|
140
|
+
for handler in target.handlers:
|
|
141
|
+
stream = getattr(handler, "stream", None)
|
|
142
|
+
if (
|
|
143
|
+
isinstance(handler, logging.StreamHandler)
|
|
144
|
+
and not isinstance(stream, FileProxy)
|
|
145
|
+
and any(stream is candidate for candidate in terminal)
|
|
146
|
+
):
|
|
147
|
+
patched.append((handler, stream))
|
|
148
|
+
handler.setStream(FileProxy(console, stream))
|
|
149
|
+
target = target.parent if target.propagate else None
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
yield
|
|
153
|
+
finally:
|
|
154
|
+
for handler, stream in patched:
|
|
155
|
+
handler.setStream(stream)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ConsoleHandler(logging.Handler):
|
|
159
|
+
"""A logging handler that writes through matchlab's own console.
|
|
160
|
+
|
|
161
|
+
Sharing the console is what lets logging coexist with the live tree
|
|
162
|
+
`matchlab.progress` draws: Rich prints through the live region, so records land
|
|
163
|
+
above the frame instead of over it. A handler holding a raw stream — which is what
|
|
164
|
+
`logging.basicConfig()` gives you, bound at the moment it was called — writes
|
|
165
|
+
straight past Rich and smears whatever is being redrawn.
|
|
166
|
+
|
|
167
|
+
Deliberately plainer than `rich.logging.RichHandler`, which lays records out in a
|
|
168
|
+
table and would wrap a logged plan inside its narrow message column. What matchlab
|
|
169
|
+
logs is mostly trees, and a tree that wraps is not a tree. Records are written at
|
|
170
|
+
the console's full width, unstyled, and formatted however the application asked.
|
|
171
|
+
|
|
172
|
+
You do not need this to run a collection at a terminal: `through_console` already
|
|
173
|
+
borrows a handler bound to one for as long as a live tree is up. This is for saying
|
|
174
|
+
so up front, and for the handler that outlives any one collection.
|
|
175
|
+
|
|
176
|
+
Examples:
|
|
177
|
+
```python
|
|
178
|
+
logging.basicConfig(level=logging.INFO, handlers=[ConsoleHandler()])
|
|
179
|
+
```
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
183
|
+
"""Write `record` to the console."""
|
|
184
|
+
try:
|
|
185
|
+
# `markup=False` matters: a drawn tree is full of `[2]`, which Rich would
|
|
186
|
+
# otherwise try to parse as a style tag. `highlight=False` likewise — its
|
|
187
|
+
# guesses at numbers and brackets colour the tree into confetti.
|
|
188
|
+
console.print(self.format(record), markup=False, highlight=False)
|
|
189
|
+
except Exception: # noqa: BLE001 - a handler must never raise into the caller
|
|
190
|
+
self.handleError(record)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Each resolver materialises its complete, merge-forward output once.
|
|
2
|
+
|
|
3
|
+
Every resolver, when it runs, computes that output and stores it. Everything
|
|
4
|
+
downstream (`entities`, `lookup_key`, models querying through it) just reads the
|
|
5
|
+
stored table.
|
|
6
|
+
|
|
7
|
+
This module holds the pure functions that produce that output:
|
|
8
|
+
|
|
9
|
+
* `leaf_id` / `root_id` — content-addressed cluster IDs. A leaf is a hash of a
|
|
10
|
+
record's content. That is the correct anchor for evaluation, not merely a
|
|
11
|
+
reproducibility trick — see `leaf_id` for the full reasoning.
|
|
12
|
+
* `materialise_resolver_output` — turns a resolver's clusters and upstream output
|
|
13
|
+
into the complete result table. See its own docstring for the shape of each.
|
|
14
|
+
|
|
15
|
+
**Merge-forward** means a leaf grouped upstream but untouched by this resolver
|
|
16
|
+
inherits its upstream cluster rather than collapsing to a singleton.
|
|
17
|
+
`materialise_resolver_output` implements this by giving every *untouched* reachable ID
|
|
18
|
+
its own component ("fall-through"), so the upstream grouping survives.
|
|
19
|
+
|
|
20
|
+
This module assumes a resolver's inputs share a consistent lineage, so each
|
|
21
|
+
reachable leaf maps to exactly one upstream ID. DAG construction enforces that, so
|
|
22
|
+
nothing here needs to reconcile a leaf that maps to more than one.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
|
|
27
|
+
import polars as pl
|
|
28
|
+
import polars_hash # noqa: F401 - registers the `nchash` expression namespace
|
|
29
|
+
|
|
30
|
+
from matchlab.core.schemas import SCHEMA_RESOLVER_OUTPUT
|
|
31
|
+
|
|
32
|
+
# Every reachable query-space ID, mapped to a source row and its leaf. This is the
|
|
33
|
+
# shape `materialise_resolver_output` expects for its `upstream` argument.
|
|
34
|
+
UPSTREAM_COLUMNS = ("id", "source", "key", "leaf")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def leaf_id(row_hash: pl.Expr) -> pl.Expr:
|
|
38
|
+
"""Map a column of row content hashes to stable 64-bit leaf cluster IDs.
|
|
39
|
+
|
|
40
|
+
A record's identity is a hash of its **content**, not of its key. This is the
|
|
41
|
+
load-bearing decision, and it is deliberate, not an accident of implementation.
|
|
42
|
+
The reason is evaluation.
|
|
43
|
+
|
|
44
|
+
A judgement is a person or a model saying "looking at *this*, these records are the
|
|
45
|
+
same entity." The only input to that decision is the content shown — a name, a
|
|
46
|
+
postcode. The key plays no part. The judge never sees it. A judgement must
|
|
47
|
+
therefore be anchored to the content it was actually made against:
|
|
48
|
+
|
|
49
|
+
* If you anchor to the **key**, a judgement outlives a content change. Say a
|
|
50
|
+
judgement was made when the evidence read "acme / london = acme / london". If
|
|
51
|
+
that evidence later becomes "acme / manchester", the judgement still stands —
|
|
52
|
+
attributed to evidence the judge never saw. It looks inexplicable later, or
|
|
53
|
+
quietly validates a match nobody made.
|
|
54
|
+
* If you anchor to the **content** instead (this module's choice), the leaf ID
|
|
55
|
+
changes whenever the content does, so the old judgement stops applying. That is
|
|
56
|
+
correct. Nobody has judged the new content, so there should be no judgement
|
|
57
|
+
about it. Judgements decay exactly when their evidence does.
|
|
58
|
+
|
|
59
|
+
The same logic explains why identical rows share a leaf. To any judge they are
|
|
60
|
+
indistinguishable, so there is no decision to make. Differing keys don't separate
|
|
61
|
+
them, because a key is not evidence.
|
|
62
|
+
|
|
63
|
+
**What gets hashed must match what gets shown.** If a column feeds the hash but
|
|
64
|
+
the sampler doesn't display it, a judgement decays for a reason the judge never
|
|
65
|
+
saw. If a column appears in the sampler but doesn't feed the hash, a judgement
|
|
66
|
+
survives a change the judge did see, which is wrong.
|
|
67
|
+
|
|
68
|
+
The two stay in sync today because both read the same definition. `leaf_id`
|
|
69
|
+
hashes every non-key column (see `Source._read_warehouse`), and `get_samples`
|
|
70
|
+
displays every non-key column. Selecting a column in the extract is what makes it
|
|
71
|
+
both hashed and shown — there is no second list to keep in step by hand (see
|
|
72
|
+
`Source`'s "the extract is the whole declaration").
|
|
73
|
+
|
|
74
|
+
Content-addressing also makes runs reproducible and IDs stable across re-collect,
|
|
75
|
+
but that is a consequence, not the reason.
|
|
76
|
+
|
|
77
|
+
This is vectorised deliberately. Calling a Python function once per row, as
|
|
78
|
+
`map_elements` would, dominates collection time.
|
|
79
|
+
"""
|
|
80
|
+
return row_hash.bin.slice(0, 8).bin.reinterpret(dtype=pl.UInt64, endianness="big")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def root_id(leaves: pl.Expr) -> pl.Expr:
|
|
84
|
+
"""Deterministic 64-bit root cluster ID for a column of leaf lists.
|
|
85
|
+
|
|
86
|
+
This is invariant to leaf order (callers sort) and to the arbitrary component
|
|
87
|
+
label, so two runs that produce the same clustering produce the same root IDs.
|
|
88
|
+
|
|
89
|
+
This is vectorised for the same reason as `leaf_id`. Calling this once per
|
|
90
|
+
cluster, in Python, would mean as many calls as there are entities.
|
|
91
|
+
"""
|
|
92
|
+
# `polars_hash` registers the `nchash` namespace on polars expressions at import.
|
|
93
|
+
joined = leaves.cast(pl.List(pl.Utf8)).list.join(",")
|
|
94
|
+
return joined.nchash.xxh3_64().cast(pl.UInt64)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def root_id_of(leaves: Iterable[int]) -> int:
|
|
98
|
+
"""`root_id` for a single leaf set. For tests and one-off checks, not for loops."""
|
|
99
|
+
leaves = sorted(leaves)
|
|
100
|
+
if not leaves:
|
|
101
|
+
raise ValueError("A cluster must contain at least one leaf")
|
|
102
|
+
frame = pl.DataFrame({"leaves": [leaves]}).with_columns(
|
|
103
|
+
pl.col("leaves").list.eval(pl.element().cast(pl.UInt64))
|
|
104
|
+
)
|
|
105
|
+
return frame.select(root_id(pl.col("leaves")).alias("root"))["root"][0]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def materialise_resolver_output(
|
|
109
|
+
clusters: pl.DataFrame,
|
|
110
|
+
upstream: pl.DataFrame,
|
|
111
|
+
) -> pl.DataFrame:
|
|
112
|
+
"""Build a resolver's complete, merge-forward output.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
clusters: `SCHEMA_CLUSTERS` `(parent_id, child_id)` from the resolver's
|
|
116
|
+
methodology. `child_id`s are query-space IDs the models formed edges over.
|
|
117
|
+
IDs not present here are untouched by this resolver.
|
|
118
|
+
upstream: The union of the resolver's input queries as `(id, source, key,
|
|
119
|
+
leaf)` rows, covering *every* leaf reachable by the resolver, whether or
|
|
120
|
+
not an edge formed over it. `id` is the query-space ID the model
|
|
121
|
+
referenced.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
One row per reachable source record, shaped as `SCHEMA_RESOLVER_OUTPUT`
|
|
125
|
+
`(root, leaf, key, source)`, with `root` the cluster it resolves to. Complete
|
|
126
|
+
and merge-forward.
|
|
127
|
+
"""
|
|
128
|
+
missing = set(UPSTREAM_COLUMNS) - set(upstream.columns)
|
|
129
|
+
if missing:
|
|
130
|
+
raise ValueError(f"upstream is missing columns: {sorted(missing)}")
|
|
131
|
+
|
|
132
|
+
upstream = upstream.select(
|
|
133
|
+
pl.col("id").cast(pl.UInt64),
|
|
134
|
+
pl.col("source").cast(pl.Utf8),
|
|
135
|
+
pl.col("key").cast(pl.Utf8),
|
|
136
|
+
pl.col("leaf").cast(pl.UInt64),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if upstream.height == 0:
|
|
140
|
+
return pl.from_arrow(SCHEMA_RESOLVER_OUTPUT.empty_table())
|
|
141
|
+
|
|
142
|
+
child_to_parent = clusters.select(
|
|
143
|
+
pl.col("child_id").cast(pl.UInt64),
|
|
144
|
+
pl.col("parent_id").cast(pl.UInt64),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Component label per reachable row:
|
|
148
|
+
# * touched IDs (in a cluster) -> "c{parent_id}" (this resolver's new cluster)
|
|
149
|
+
# * untouched IDs -> "s{id}" (fall-through: keep upstream)
|
|
150
|
+
# Prefixes keep the two integer spaces from colliding.
|
|
151
|
+
labelled = upstream.join(
|
|
152
|
+
child_to_parent, left_on="id", right_on="child_id", how="left"
|
|
153
|
+
).with_columns(
|
|
154
|
+
pl.when(pl.col("parent_id").is_not_null())
|
|
155
|
+
.then("c" + pl.col("parent_id").cast(pl.Utf8))
|
|
156
|
+
.otherwise("s" + pl.col("id").cast(pl.Utf8))
|
|
157
|
+
.alias("_component")
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# Content-addressed root per component, derived from its full leaf set.
|
|
161
|
+
component_roots = (
|
|
162
|
+
labelled.group_by("_component")
|
|
163
|
+
.agg(pl.col("leaf").unique().sort().alias("_leaves"))
|
|
164
|
+
.with_columns(root_id(pl.col("_leaves")).alias("root"))
|
|
165
|
+
.select("_component", "root")
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
labelled.join(component_roots, on="_component")
|
|
170
|
+
.select("root", "leaf", "key", "source")
|
|
171
|
+
.unique()
|
|
172
|
+
.sort("root", "leaf", "key")
|
|
173
|
+
)
|
matchlab/core/schemas.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Arrow schemas for the artifacts an adapter persists.
|
|
2
|
+
|
|
3
|
+
These are the shapes that cross the boundary between a step and its storage: what a
|
|
4
|
+
`Model` or `Resolver` hands the adapter, and what the adapter hands back. They live here
|
|
5
|
+
rather than with their producers because both sides need them, and `core` is the only
|
|
6
|
+
layer both a producer and an adapter can depend on without inverting.
|
|
7
|
+
|
|
8
|
+
The membership rule is exactly that boundary — **the adapter persists it or returns
|
|
9
|
+
it**. A shape that never leaves the process belongs with the code that produces it:
|
|
10
|
+
`SCHEMA_CLUSTERS` is a resolver methodology's output, read by the `Resolver` that called
|
|
11
|
+
it and by nothing else, so it lives in `matchlab.resolvers.base`. Keeping those out is
|
|
12
|
+
what stops this module from becoming a catch-all for anything that happens to be an
|
|
13
|
+
Arrow schema.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Final
|
|
17
|
+
|
|
18
|
+
import pyarrow as pa
|
|
19
|
+
from pyarrow import Schema
|
|
20
|
+
|
|
21
|
+
from matchlab.core.exceptions import SchemaMismatch
|
|
22
|
+
|
|
23
|
+
SCHEMA_MODEL_EDGES: Final[pa.Schema] = pa.schema(
|
|
24
|
+
[
|
|
25
|
+
("left_id", pa.uint64()),
|
|
26
|
+
("right_id", pa.uint64()),
|
|
27
|
+
("score", pa.float32()),
|
|
28
|
+
]
|
|
29
|
+
)
|
|
30
|
+
"""A deduper's or linker's output: a scored edge list."""
|
|
31
|
+
|
|
32
|
+
SCHEMA_RESOLVER_OUTPUT: Final[pa.Schema] = pa.schema(
|
|
33
|
+
[
|
|
34
|
+
("root", pa.uint64()),
|
|
35
|
+
("leaf", pa.uint64()),
|
|
36
|
+
("key", pa.large_string()),
|
|
37
|
+
("source", pa.large_string()),
|
|
38
|
+
]
|
|
39
|
+
)
|
|
40
|
+
"""A resolver's complete flat output: one row per reachable source record.
|
|
41
|
+
|
|
42
|
+
Evaluation sampling returns a row-subset of this, which is why it is named for the
|
|
43
|
+
resolver's output rather than for the sample.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
SCHEMA_JUDGEMENTS: Final[pa.Schema] = pa.schema(
|
|
47
|
+
[
|
|
48
|
+
("user_name", pa.large_string()),
|
|
49
|
+
("endorsed", pa.uint64()),
|
|
50
|
+
("shown", pa.uint64()),
|
|
51
|
+
]
|
|
52
|
+
)
|
|
53
|
+
"""Stored user judgements, as returned by `Adapter.read_eval_data`."""
|
|
54
|
+
|
|
55
|
+
SCHEMA_CLUSTER_EXPANSION: Final[pa.Schema] = pa.schema(
|
|
56
|
+
[
|
|
57
|
+
("root", pa.uint64()),
|
|
58
|
+
("leaves", pa.list_(pa.uint64())),
|
|
59
|
+
]
|
|
60
|
+
)
|
|
61
|
+
"""A cluster ID mapped to all its source cluster IDs.
|
|
62
|
+
|
|
63
|
+
Used alongside `SCHEMA_JUDGEMENTS` in evaluation scoring."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def check_schema_subset(expected: Schema, actual: Schema) -> None:
|
|
67
|
+
"""Check presence of Arrow fields, ignoring field order, extras and metadata.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
expected: Schema with fields that must be present
|
|
71
|
+
actual: Schema to check
|
|
72
|
+
"""
|
|
73
|
+
actual = pa.schema(
|
|
74
|
+
[
|
|
75
|
+
(name, actual.field(name).type)
|
|
76
|
+
for name in expected.names
|
|
77
|
+
if name in actual.names
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not actual.equals(expected):
|
|
82
|
+
raise SchemaMismatch(expected=expected, actual=actual)
|
matchlab/core/sql.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""SQL fragment string types shared across matchlab."""
|
|
2
|
+
|
|
3
|
+
from typing import TypeAlias
|
|
4
|
+
|
|
5
|
+
SQLExpression: TypeAlias = str
|
|
6
|
+
"""Any SQL text `sqlglot` can parse, with no shape guaranteed beyond that."""
|
|
7
|
+
|
|
8
|
+
SQLCondition: TypeAlias = SQLExpression
|
|
9
|
+
"""A boolean WHERE-clause predicate, qualified with `l`/`r` per `models.comparison`."""
|
|
10
|
+
|
|
11
|
+
SQLQuery: TypeAlias = SQLExpression
|
|
12
|
+
"""A full SQL statement, such as a `SELECT`, not just a predicate."""
|