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.
Files changed (72) hide show
  1. matchlab/__init__.py +55 -0
  2. matchlab/adapters/__init__.py +24 -0
  3. matchlab/adapters/base.py +446 -0
  4. matchlab/adapters/duckdb.py +659 -0
  5. matchlab/cli.py +177 -0
  6. matchlab/core/__init__.py +1 -0
  7. matchlab/core/dataframes.py +254 -0
  8. matchlab/core/dsu.py +62 -0
  9. matchlab/core/exceptions.py +40 -0
  10. matchlab/core/hash.py +141 -0
  11. matchlab/core/kinds.py +12 -0
  12. matchlab/core/logging.py +190 -0
  13. matchlab/core/resolver_output.py +173 -0
  14. matchlab/core/schemas.py +82 -0
  15. matchlab/core/sql.py +12 -0
  16. matchlab/document.py +329 -0
  17. matchlab/eval/__init__.py +11 -0
  18. matchlab/eval/judgements.py +41 -0
  19. matchlab/eval/metrics.py +244 -0
  20. matchlab/eval/samples.py +415 -0
  21. matchlab/eval/tui/__init__.py +66 -0
  22. matchlab/eval/tui/app.py +418 -0
  23. matchlab/eval/tui/modals.py +84 -0
  24. matchlab/eval/tui/styles.tcss +184 -0
  25. matchlab/eval/tui/widgets/__init__.py +1 -0
  26. matchlab/eval/tui/widgets/assignment.py +72 -0
  27. matchlab/eval/tui/widgets/styling.py +75 -0
  28. matchlab/eval/tui/widgets/table.py +285 -0
  29. matchlab/lineage.py +239 -0
  30. matchlab/locations.py +347 -0
  31. matchlab/models/__init__.py +10 -0
  32. matchlab/models/comparison.py +67 -0
  33. matchlab/models/dedupers/__init__.py +5 -0
  34. matchlab/models/dedupers/base.py +38 -0
  35. matchlab/models/dedupers/naive.py +77 -0
  36. matchlab/models/linkers/__init__.py +9 -0
  37. matchlab/models/linkers/base.py +41 -0
  38. matchlab/models/linkers/deterministic.py +229 -0
  39. matchlab/models/linkers/splinklinker.py +262 -0
  40. matchlab/models/linkers/weighteddeterministic.py +143 -0
  41. matchlab/models/models.py +198 -0
  42. matchlab/progress.py +455 -0
  43. matchlab/recordstep.py +181 -0
  44. matchlab/resolvers/__init__.py +10 -0
  45. matchlab/resolvers/base.py +63 -0
  46. matchlab/resolvers/components.py +66 -0
  47. matchlab/resolvers/resolvers.py +409 -0
  48. matchlab/sources.py +291 -0
  49. matchlab/specs.py +129 -0
  50. matchlab/steps.py +284 -0
  51. matchlab/testkit/__init__.py +104 -0
  52. matchlab/testkit/_generate.py +240 -0
  53. matchlab/testkit/compare.py +178 -0
  54. matchlab/testkit/entities.py +290 -0
  55. matchlab/testkit/features.py +146 -0
  56. matchlab/testkit/linked.py +527 -0
  57. matchlab/testkit/matchers.py +233 -0
  58. matchlab/testkit/models.py +165 -0
  59. matchlab/testkit/sources.py +327 -0
  60. matchlab/transformers/__init__.py +24 -0
  61. matchlab/transformers/base.py +41 -0
  62. matchlab/transformers/clean.py +48 -0
  63. matchlab/transformers/explode.py +67 -0
  64. matchlab/transformers/group.py +56 -0
  65. matchlab/transformers/select.py +31 -0
  66. matchlab/transformers/transform.py +101 -0
  67. matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
  68. matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
  69. matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
  70. matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
  71. matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
  72. 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