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,233 @@
|
|
|
1
|
+
"""Matchers that already know the answer.
|
|
2
|
+
|
|
3
|
+
A perfect matcher makes no mistakes, so pointing one at generated data leaves the *plan*
|
|
4
|
+
as the only variable. That is what makes an end-to-end assertion mean something. Build
|
|
5
|
+
them through `LinkedSources.dedupe()` / `.link()`. They are public here only so the
|
|
6
|
+
registry can name them.
|
|
7
|
+
|
|
8
|
+
The subtlety is how a matcher identifies a record. Pre-generating edges between record
|
|
9
|
+
IDs does not work, because matchlab derives cluster IDs by content-hashing rows at
|
|
10
|
+
collect time. The IDs a model actually receives are unknowable when a fixture is built,
|
|
11
|
+
which is why an `AnswerKey` is keyed by the row's *values* instead. At match time the
|
|
12
|
+
matcher reads those columns out of the record step it was handed, looks up each row's
|
|
13
|
+
true entity, and emits edges between whatever IDs are actually present. That makes it
|
|
14
|
+
independent of how identity is assigned, and lets it survive any future change to ID
|
|
15
|
+
minting.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
from itertools import combinations
|
|
20
|
+
|
|
21
|
+
import polars as pl
|
|
22
|
+
from pydantic import BaseModel, ConfigDict
|
|
23
|
+
|
|
24
|
+
from matchlab.core.hash import HASH_FUNC
|
|
25
|
+
from matchlab.core.schemas import SCHEMA_MODEL_EDGES
|
|
26
|
+
from matchlab.models import add_model_class
|
|
27
|
+
from matchlab.models.dedupers.base import Deduper, DeduperSettings
|
|
28
|
+
from matchlab.models.linkers.base import Linker, LinkerSettings
|
|
29
|
+
from matchlab.testkit.entities import TrueEntity
|
|
30
|
+
from matchlab.testkit.sources import GeneratedSource
|
|
31
|
+
|
|
32
|
+
# Registered answer keys, by content hash. Only ever added to. A testkit process is
|
|
33
|
+
# short-lived and the keys are small.
|
|
34
|
+
_ANSWERS: dict[str, "AnswerKey"] = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AnswerKey(BaseModel):
|
|
38
|
+
"""The sheet a perfect matcher looks answers up in: row values → true entity.
|
|
39
|
+
|
|
40
|
+
Three things happen to one of these, in order:
|
|
41
|
+
|
|
42
|
+
* **build**: `AnswerKey.from_sources()` derives it from generated data. Pass a
|
|
43
|
+
subset of the true entities to make a matcher that knows only part of the answer.
|
|
44
|
+
* **store**: `.register()` puts it in a process-local registry and returns a
|
|
45
|
+
content-addressed ID. A matcher's settings carry that ID rather than the table
|
|
46
|
+
itself, because settings are JSON-serialised into a step's fingerprint and a
|
|
47
|
+
lookup table is not. Hashing the content keeps the fingerprint honest. A different
|
|
48
|
+
answer produces a different ID, and so a different model artifact.
|
|
49
|
+
* **use**: `.dedupe_edges()` / `.link_edges()` are what `PerfectDeduper` and
|
|
50
|
+
`PerfectLinker` call at match time, against whatever record step they were
|
|
51
|
+
handed.
|
|
52
|
+
|
|
53
|
+
`groups` maps a tuple of column values to a true-entity ID. `columns` names the
|
|
54
|
+
columns to read, in the same order they appear in the record step the model is
|
|
55
|
+
given (source-qualified). Linkers carry a second set for the right-hand record
|
|
56
|
+
step. Both sides map into the same entity-ID space, which is what lets them be
|
|
57
|
+
joined.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
|
|
61
|
+
|
|
62
|
+
left_columns: tuple[str, ...]
|
|
63
|
+
left_groups: dict[tuple, int]
|
|
64
|
+
right_columns: tuple[str, ...] | None = None
|
|
65
|
+
right_groups: dict[tuple, int] | None = None
|
|
66
|
+
score: float = 1.0
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_sources(
|
|
70
|
+
cls,
|
|
71
|
+
left: GeneratedSource,
|
|
72
|
+
true_entities: Iterable[TrueEntity],
|
|
73
|
+
right: GeneratedSource | None = None,
|
|
74
|
+
score: float = 1.0,
|
|
75
|
+
) -> "AnswerKey":
|
|
76
|
+
"""Derive the lookup from generated sources.
|
|
77
|
+
|
|
78
|
+
Each generated row is mapped to the true entity that owns its key, then keyed by
|
|
79
|
+
its feature values under the names the model will see them by, source-qualified,
|
|
80
|
+
because that is how they arrive in the record step a matcher is handed.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
left: The generated source the matcher reads as its left input.
|
|
84
|
+
right: Its right input, for a linker. `None` for a deduper.
|
|
85
|
+
true_entities: The planted entities to answer for. Pass a subset to build a
|
|
86
|
+
matcher that knows only part of the answer.
|
|
87
|
+
score: The score to emit on every edge.
|
|
88
|
+
"""
|
|
89
|
+
entities = tuple(true_entities)
|
|
90
|
+
|
|
91
|
+
def side(source: GeneratedSource) -> tuple[tuple[str, ...], dict[tuple, int]]:
|
|
92
|
+
key_to_entity: dict[str, int] = {
|
|
93
|
+
str(key): entity.id
|
|
94
|
+
for entity in entities
|
|
95
|
+
for key in entity.get_keys(source.source.name)
|
|
96
|
+
}
|
|
97
|
+
# From the testkit, not the plan node. The node would have to read the
|
|
98
|
+
# warehouse to list its columns, and this runs before any collect.
|
|
99
|
+
# `field_names` also covers a source set by hand, where `features` is None.
|
|
100
|
+
features = source.field_names
|
|
101
|
+
groups: dict[tuple, int] = {}
|
|
102
|
+
for row in pl.DataFrame(source.data).iter_rows(named=True):
|
|
103
|
+
entity_id = key_to_entity.get(str(row["key"]))
|
|
104
|
+
if entity_id is not None:
|
|
105
|
+
groups[tuple(row[feature] for feature in features)] = entity_id
|
|
106
|
+
columns = tuple(f"{source.source.name}_{feature}" for feature in features)
|
|
107
|
+
return columns, groups
|
|
108
|
+
|
|
109
|
+
left_columns, left_groups = side(left)
|
|
110
|
+
right_columns, right_groups = side(right) if right is not None else (None, None)
|
|
111
|
+
return cls(
|
|
112
|
+
left_columns=left_columns,
|
|
113
|
+
left_groups=left_groups,
|
|
114
|
+
right_columns=right_columns,
|
|
115
|
+
right_groups=right_groups,
|
|
116
|
+
score=score,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _assign(
|
|
120
|
+
self, data: pl.DataFrame, columns: tuple[str, ...], groups: dict[tuple, int]
|
|
121
|
+
) -> dict[int, list[int]]:
|
|
122
|
+
"""Bucket the row IDs by the true entity their values belong to."""
|
|
123
|
+
missing = [column for column in columns if column not in data.columns]
|
|
124
|
+
if missing:
|
|
125
|
+
raise KeyError(
|
|
126
|
+
f"Ground-truth columns absent from the model input: {missing}. "
|
|
127
|
+
f"Available: {sorted(data.columns)}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
buckets: dict[int, list[int]] = {}
|
|
131
|
+
for row in data.select("id", *columns).iter_rows(named=True):
|
|
132
|
+
entity = groups.get(tuple(row[column] for column in columns))
|
|
133
|
+
if entity is not None:
|
|
134
|
+
buckets.setdefault(entity, []).append(row["id"])
|
|
135
|
+
return buckets
|
|
136
|
+
|
|
137
|
+
def _edges(self, pairs: list[tuple[int, int]]) -> pl.DataFrame:
|
|
138
|
+
if not pairs:
|
|
139
|
+
return pl.DataFrame(schema=pl.Schema(SCHEMA_MODEL_EDGES))
|
|
140
|
+
left, right = zip(*pairs, strict=True)
|
|
141
|
+
return pl.DataFrame(
|
|
142
|
+
{
|
|
143
|
+
"left_id": list(left),
|
|
144
|
+
"right_id": list(right),
|
|
145
|
+
"score": [self.score] * len(pairs),
|
|
146
|
+
},
|
|
147
|
+
schema=pl.Schema(SCHEMA_MODEL_EDGES),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def dedupe_edges(self, data: pl.DataFrame) -> pl.DataFrame:
|
|
151
|
+
"""All within-entity pairs among the record step's records."""
|
|
152
|
+
pairs: list[tuple[int, int]] = []
|
|
153
|
+
for ids in self._assign(data, self.left_columns, self.left_groups).values():
|
|
154
|
+
unique = sorted(set(ids))
|
|
155
|
+
pairs.extend(combinations(unique, 2))
|
|
156
|
+
return self._edges(pairs)
|
|
157
|
+
|
|
158
|
+
def link_edges(self, left: pl.DataFrame, right: pl.DataFrame) -> pl.DataFrame:
|
|
159
|
+
"""All cross-side pairs whose records share a true entity."""
|
|
160
|
+
left_buckets = self._assign(left, self.left_columns, self.left_groups)
|
|
161
|
+
right_buckets = self._assign(
|
|
162
|
+
right, self.right_columns or (), self.right_groups or {}
|
|
163
|
+
)
|
|
164
|
+
pairs = [
|
|
165
|
+
(left_id, right_id)
|
|
166
|
+
for entity, left_ids in left_buckets.items()
|
|
167
|
+
for left_id in sorted(set(left_ids))
|
|
168
|
+
for right_id in sorted(set(right_buckets.get(entity, [])))
|
|
169
|
+
]
|
|
170
|
+
return self._edges(pairs)
|
|
171
|
+
|
|
172
|
+
def register(self) -> str:
|
|
173
|
+
"""Store this key, returning a content-addressed ID for it.
|
|
174
|
+
|
|
175
|
+
Settings are JSON-serialised into a step's fingerprint and a lookup table is
|
|
176
|
+
not, so the key itself cannot live there. Hashing its content keeps the
|
|
177
|
+
fingerprint honest. A different answer key produces a different ID, and
|
|
178
|
+
therefore a different model artifact.
|
|
179
|
+
"""
|
|
180
|
+
payload = repr(
|
|
181
|
+
(
|
|
182
|
+
self.left_columns,
|
|
183
|
+
sorted(map(repr, self.left_groups.items())),
|
|
184
|
+
self.right_columns,
|
|
185
|
+
sorted(map(repr, (self.right_groups or {}).items())),
|
|
186
|
+
self.score,
|
|
187
|
+
)
|
|
188
|
+
).encode()
|
|
189
|
+
key_id = HASH_FUNC(payload).hexdigest()
|
|
190
|
+
_ANSWERS[key_id] = self
|
|
191
|
+
return key_id
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class PerfectDeduperSettings(DeduperSettings):
|
|
195
|
+
"""Settings for a deduper that matches from registered ground truth."""
|
|
196
|
+
|
|
197
|
+
truth_id: str
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class PerfectDeduper(Deduper):
|
|
201
|
+
"""A perfect deduper. Emits every within-entity pair it is given."""
|
|
202
|
+
|
|
203
|
+
settings: PerfectDeduperSettings
|
|
204
|
+
|
|
205
|
+
def prepare(self, data: pl.DataFrame) -> None:
|
|
206
|
+
"""No preparation required."""
|
|
207
|
+
|
|
208
|
+
def dedupe(self, data: pl.DataFrame) -> pl.DataFrame:
|
|
209
|
+
"""Emit edges between records sharing a true entity."""
|
|
210
|
+
return _ANSWERS[self.settings.truth_id].dedupe_edges(data)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class PerfectLinkerSettings(LinkerSettings):
|
|
214
|
+
"""Settings for a linker that matches from registered ground truth."""
|
|
215
|
+
|
|
216
|
+
truth_id: str
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class PerfectLinker(Linker):
|
|
220
|
+
"""A perfect linker. Emits every cross-side pair sharing a true entity."""
|
|
221
|
+
|
|
222
|
+
settings: PerfectLinkerSettings
|
|
223
|
+
|
|
224
|
+
def prepare(self, left: pl.DataFrame, right: pl.DataFrame) -> None:
|
|
225
|
+
"""No preparation required."""
|
|
226
|
+
|
|
227
|
+
def link(self, left: pl.DataFrame, right: pl.DataFrame) -> pl.DataFrame:
|
|
228
|
+
"""Emit edges between records sharing a true entity."""
|
|
229
|
+
return _ANSWERS[self.settings.truth_id].link_edges(left, right)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
add_model_class(PerfectDeduper)
|
|
233
|
+
add_model_class(PerfectLinker)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""The generated model, and the expectation it is asserted against.
|
|
2
|
+
|
|
3
|
+
`GeneratedModel.scores` and `.predicted_clusters` express the expected result in the
|
|
4
|
+
testkit's *own* entity ID space, which is what `LinkedSources.diff_model_edges` compares
|
|
5
|
+
a methodology against without running a plan. The model itself matches on row values
|
|
6
|
+
(see `matchers`), so a collected plan reproduces the same answer over the
|
|
7
|
+
content-derived IDs the record step actually carries.
|
|
8
|
+
|
|
9
|
+
Build one with `LinkedSources.dedupe()` or `.link()`. They know the answer, so nothing
|
|
10
|
+
has to be threaded through by hand.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import polars as pl
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
16
|
+
|
|
17
|
+
from matchlab.core.schemas import SCHEMA_MODEL_EDGES
|
|
18
|
+
from matchlab.models.models import Model
|
|
19
|
+
from matchlab.testkit.compare import scores_to_clusters
|
|
20
|
+
from matchlab.testkit.entities import Cluster, TrueEntity
|
|
21
|
+
from matchlab.testkit.sources import GeneratedSource
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_entity_scores(
|
|
25
|
+
left_entities: frozenset[Cluster],
|
|
26
|
+
right_entities: frozenset[Cluster] | None,
|
|
27
|
+
true_entities: frozenset[TrueEntity],
|
|
28
|
+
score_range: tuple[float, float] = (0.8, 1.0),
|
|
29
|
+
seed: int = 42,
|
|
30
|
+
) -> pl.DataFrame:
|
|
31
|
+
"""Generate scores that recover the planted entity relationships.
|
|
32
|
+
|
|
33
|
+
Maps each `Cluster` to the `TrueEntity` it is a subset of, then emits a score for
|
|
34
|
+
every same-entity pair. The result is a fully connected, perfectly correct score
|
|
35
|
+
graph. This function does not generate partial, wrong, or missing matches.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
left_entities: Cluster objects from the left input.
|
|
39
|
+
right_entities: Cluster objects from the right input. `None` to deduplicate
|
|
40
|
+
`left_entities` instead.
|
|
41
|
+
true_entities: The planted true entities to score against.
|
|
42
|
+
score_range: Assign each matching pair a random score in this range.
|
|
43
|
+
seed: Random seed for reproducibility.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A Polars DataFrame with `left_id`, `right_id`, and `score` columns.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If `score_range` is not increasing within [0, 1], or a `Cluster` is
|
|
50
|
+
a subset of more than one true entity.
|
|
51
|
+
"""
|
|
52
|
+
# Validate inputs
|
|
53
|
+
if not (0 <= score_range[0] <= score_range[1] <= 1):
|
|
54
|
+
raise ValueError("Scores must be increasing values between 0 and 1")
|
|
55
|
+
|
|
56
|
+
# Handle deduplication case
|
|
57
|
+
if right_entities is None:
|
|
58
|
+
right_entities = left_entities
|
|
59
|
+
|
|
60
|
+
# Create mapping of Cluster -> TrueEntity
|
|
61
|
+
entity_mapping: dict[Cluster, TrueEntity] = {}
|
|
62
|
+
|
|
63
|
+
def _map_entity(entity: Cluster) -> None:
|
|
64
|
+
matching_sources = [
|
|
65
|
+
source
|
|
66
|
+
for source in true_entities
|
|
67
|
+
if entity.is_subset_of_true_entity(source)
|
|
68
|
+
]
|
|
69
|
+
if len(matching_sources) > 1:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"Cluster with ID {entity.id} is a subset of multiple "
|
|
72
|
+
f"true entities. This violates the uniqueness constraint."
|
|
73
|
+
)
|
|
74
|
+
if matching_sources:
|
|
75
|
+
entity_mapping[entity] = matching_sources[0]
|
|
76
|
+
|
|
77
|
+
# Map all entities, checking constraints
|
|
78
|
+
for entity in left_entities:
|
|
79
|
+
_map_entity(entity)
|
|
80
|
+
if right_entities is not left_entities:
|
|
81
|
+
for entity in right_entities:
|
|
82
|
+
_map_entity(entity)
|
|
83
|
+
|
|
84
|
+
# Group by TrueEntity
|
|
85
|
+
source_groups: dict[TrueEntity, tuple[set[Cluster], set[Cluster]]] = {}
|
|
86
|
+
for entity, source in entity_mapping.items():
|
|
87
|
+
if source not in source_groups:
|
|
88
|
+
source_groups[source] = (set(), set())
|
|
89
|
+
# Add to left or right group based on which input set it came from
|
|
90
|
+
if entity in left_entities:
|
|
91
|
+
source_groups[source][0].add(entity)
|
|
92
|
+
if entity in right_entities: # Note: could be in both for deduplication
|
|
93
|
+
source_groups[source][1].add(entity)
|
|
94
|
+
|
|
95
|
+
# Generate score edges for each group
|
|
96
|
+
edges = []
|
|
97
|
+
rng = np.random.default_rng(seed=seed)
|
|
98
|
+
|
|
99
|
+
for left_group, right_group in source_groups.values():
|
|
100
|
+
# Skip empty groups
|
|
101
|
+
if not left_group or not right_group:
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
# Generate all pairs within this group
|
|
105
|
+
for left_entity in left_group:
|
|
106
|
+
for right_entity in right_group:
|
|
107
|
+
# Deduplication counts each pair once, so keep left_id < right_id.
|
|
108
|
+
if (
|
|
109
|
+
right_entities == left_entities
|
|
110
|
+
and left_entity.id >= right_entity.id
|
|
111
|
+
):
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
# Generate random score in range
|
|
115
|
+
score = np.float32(rng.uniform(score_range[0], score_range[1]))
|
|
116
|
+
edges.append((left_entity.id, right_entity.id, score))
|
|
117
|
+
|
|
118
|
+
# If no edges were generated, return empty table with correct schema
|
|
119
|
+
if not edges:
|
|
120
|
+
return pl.DataFrame(schema=pl.Schema(SCHEMA_MODEL_EDGES))
|
|
121
|
+
|
|
122
|
+
return pl.DataFrame(edges, orient="row", schema=pl.Schema(SCHEMA_MODEL_EDGES))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class GeneratedModel(BaseModel):
|
|
126
|
+
"""A generated model: the plan node, its inputs, and the answer expected of it.
|
|
127
|
+
|
|
128
|
+
As with `GeneratedSource`, this exposes what it knows about the fixture and nothing
|
|
129
|
+
else. To run the plan, go through `.model`, e.g. `model.model.resolve()`.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)
|
|
133
|
+
|
|
134
|
+
# The plan node. Run it with `model.resolve()`.
|
|
135
|
+
model: Model
|
|
136
|
+
# The generated source it reads as its left input. Its rows and starting partition
|
|
137
|
+
# are reachable from here rather than copied onto this object.
|
|
138
|
+
left_source: GeneratedSource
|
|
139
|
+
# Its right input, for a linker. `None` for a deduper.
|
|
140
|
+
right_source: GeneratedSource | None = None
|
|
141
|
+
# The edges a perfect matcher should emit, in the sources' own entity ID space.
|
|
142
|
+
scores: pl.DataFrame = Field(frozen=True)
|
|
143
|
+
|
|
144
|
+
_predicted_clusters: tuple[Cluster, ...]
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def predicted_clusters(self) -> tuple[Cluster, ...]:
|
|
148
|
+
"""What the model's scores imply, to compare against the planted answer."""
|
|
149
|
+
return self._predicted_clusters
|
|
150
|
+
|
|
151
|
+
@model_validator(mode="after")
|
|
152
|
+
def _derive_predicted_clusters(self) -> "GeneratedModel":
|
|
153
|
+
"""Resolve the expected scores into the clusters they imply."""
|
|
154
|
+
self._predicted_clusters = scores_to_clusters(
|
|
155
|
+
scores=self.scores,
|
|
156
|
+
left_clusters=self.left_source.input_clusters,
|
|
157
|
+
right_clusters=(
|
|
158
|
+
self.right_source.input_clusters
|
|
159
|
+
if self.right_source is not None
|
|
160
|
+
else None
|
|
161
|
+
),
|
|
162
|
+
threshold=0,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return self
|