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,409 @@
1
+ """Resolver collapses model edges into clusters and materialises the resolver output."""
2
+
3
+ from typing import Any, ClassVar, Self
4
+
5
+ import polars as pl
6
+
7
+ from matchlab.adapters import Adapter, Fingerprint
8
+ from matchlab.core.dataframes import qualify
9
+ from matchlab.core.exceptions import StepNotFound
10
+ from matchlab.core.kinds import StepKind
11
+ from matchlab.core.resolver_output import materialise_resolver_output
12
+ from matchlab.models import Model
13
+ from matchlab.recordstep import IdentifierRead, RecordStep, build_record_step
14
+ from matchlab.resolvers.base import ResolverMethod, ResolverSettings
15
+ from matchlab.resolvers.components import Components
16
+ from matchlab.sources import Source
17
+ from matchlab.specs import ResolverSpec
18
+
19
+ _RESOLVER_CLASSES: dict[str, type[ResolverMethod]] = {}
20
+
21
+
22
+ def add_resolver_class(resolver_class: type[ResolverMethod]) -> None:
23
+ """Register a resolver methodology so it can be named in a plan."""
24
+ if not issubclass(resolver_class, ResolverMethod):
25
+ raise ValueError("The argument is not a subclass of ResolverMethod.")
26
+ _RESOLVER_CLASSES[resolver_class.__name__] = resolver_class
27
+
28
+
29
+ add_resolver_class(Components)
30
+
31
+
32
+ class Resolver(RecordStep):
33
+ """Clusters computed over one or more models' edges.
34
+
35
+ A resolver is also a `RecordStep`. Read as records, its rows are the sources it
36
+ covers, with `id` set to the entity root, so matching on top of a resolver is how a
37
+ plan layers (`deduped.link(dh, …)`). The resolver output it stores is a separate
38
+ artifact, and the record step itself is derived on read.
39
+ """
40
+
41
+ kind: ClassVar[StepKind] = StepKind.RESOLVER
42
+
43
+ def __init__(
44
+ self,
45
+ *models: Model,
46
+ resolver_class: type[ResolverMethod] | str = "Components",
47
+ resolver_settings: ResolverSettings | dict[str, Any] | None = None,
48
+ ) -> None:
49
+ """Define a resolver.
50
+
51
+ Args:
52
+ *models: The models whose edges to resolve. At least one.
53
+ resolver_class: A `ResolverMethod` subclass or its registered name.
54
+ Defaults to connected components.
55
+ resolver_settings: Settings for that methodology.
56
+ """
57
+ deduped: list[Model] = []
58
+ for model in models:
59
+ if not isinstance(model, Model):
60
+ raise ValueError(f"Resolver inputs must be models, got {type(model)}")
61
+ if not any(model is seen for seen in deduped):
62
+ deduped.append(model)
63
+ if not deduped:
64
+ raise ValueError("A resolver needs at least one model")
65
+
66
+ self.inputs = tuple(deduped)
67
+
68
+ self.resolver_class = (
69
+ _RESOLVER_CLASSES[resolver_class]
70
+ if isinstance(resolver_class, str)
71
+ else resolver_class
72
+ )
73
+ settings = resolver_settings if resolver_settings is not None else {}
74
+ if isinstance(settings, dict):
75
+ settings_class = self.resolver_class.model_fields["settings"].annotation
76
+ self.resolver_settings = settings_class(
77
+ **{
78
+ field: self._positions(field, value)
79
+ for field, value in settings.items()
80
+ }
81
+ )
82
+ else:
83
+ self.resolver_settings = settings
84
+ self.resolver_instance = self.resolver_class(settings=self.resolver_settings)
85
+
86
+ super().__init__(upstream=self.inputs)
87
+
88
+ def _positions(self, field: str, value: Any) -> Any: # noqa: ANN401 - any setting
89
+ """Replace `Model` keys in a setting with the position of that input.
90
+
91
+ A setting that points at one of this resolver's inputs, such as a per-model
92
+ threshold, is written as `{model: 0.9}`. That holds the object you already
93
+ have, rather than retyping its name. Here is the only place that can be
94
+ translated, because here is where both the models and the order they were
95
+ given in are known.
96
+
97
+ Positions rather than names, because a name is not identity. Renaming a model
98
+ would move this resolver's fingerprint without changing a byte of its output.
99
+ Reordering the inputs *does* reassign thresholds, but that is not inconsistent.
100
+ Reordering already changes the fingerprint anyway, since parents fold in in
101
+ order, making this a genuinely different resolver.
102
+
103
+ Raises:
104
+ ValueError: If a setting names a model that is not an input.
105
+ """
106
+ if not isinstance(value, dict):
107
+ return value
108
+
109
+ position = {id(model): index for index, model in enumerate(self.inputs)}
110
+ translated: dict[Any, Any] = {}
111
+ for key, setting in value.items():
112
+ if not isinstance(key, Model):
113
+ translated[key] = setting
114
+ continue
115
+ if id(key) not in position:
116
+ raise ValueError(
117
+ f"'{field}' has an entry for a model this resolver does not read. "
118
+ f"It resolves {len(self.inputs)} model(s), and a setting may only "
119
+ "point at one of those."
120
+ )
121
+ translated[position[id(key)]] = setting
122
+ return translated
123
+
124
+ # -- Step contract ----------------------------------------------------------------
125
+
126
+ def __str__(self) -> str:
127
+ """A resolver is drawn with the methodology implementing it."""
128
+ return f"{self.kind}({self.resolver_class.__name__})"
129
+
130
+ @property
131
+ def spec(self) -> ResolverSpec:
132
+ """The serialisable spec for this resolver."""
133
+ return ResolverSpec(
134
+ resolver_class=self.resolver_class.__name__,
135
+ resolver_settings=self.resolver_settings.model_dump(mode="json"),
136
+ )
137
+
138
+ # -- publishing -------------------------------------------------------------------
139
+
140
+ def publish(self, label: str, overwrite: bool = False) -> Self:
141
+ """Point a label at this resolver's output, so it can be found without the plan.
142
+
143
+ Publishing is an act, not a property of the plan. A label changes nothing
144
+ about what gets computed, and there is nothing to point at until the resolver
145
+ output exists. Publish after collection, for example
146
+ `resolver.collect().publish("x")`. A plan that is never published is still
147
+ perfectly runnable, just unlabelled.
148
+
149
+ A *label* rather than a name, because a name is something else here. A
150
+ source's name is part of its output, prefixing every column it contributes. A
151
+ label belongs to the store, and points at whichever resolver output you last
152
+ aimed it at.
153
+
154
+ Re-publishing the same label for the same resolver output is a no-op, so
155
+ re-running an unchanged pipeline is safe. Aiming an existing label at a
156
+ *different* resolver output needs `overwrite=True`, because that is how you
157
+ lose track of what a label used to mean.
158
+
159
+ Args:
160
+ label: The label to publish under.
161
+ overwrite: Move the label if it already points somewhere else.
162
+
163
+ Returns:
164
+ This resolver, so it chains off `collect()`.
165
+
166
+ Raises:
167
+ RuntimeError: If this resolver has not been collected.
168
+ ValueError: If `label` already points at a different resolver output and
169
+ `overwrite` is not set.
170
+ """
171
+ adapter, fp = self._collected()
172
+ existing = adapter.find(label)
173
+ if existing is not None and existing != fp and not overwrite:
174
+ raise ValueError(
175
+ f"The label '{label}' already points at a different resolver output "
176
+ f"({existing.hex()[:8]}). Pass overwrite=True to move it, or publish "
177
+ "under another label."
178
+ )
179
+ adapter.publish(label, fp)
180
+ return self
181
+
182
+ # -- Step contract ----------------------------------------------------------------
183
+
184
+ def _execute(self, adapter: Adapter, fp: Fingerprint) -> None:
185
+ edges = {
186
+ position: adapter.read_model(model._fp)
187
+ for position, model in enumerate(self.inputs)
188
+ }
189
+ clusters = self.resolver_instance.compute_clusters(model_edges=edges)
190
+
191
+ # materialise_resolver_output carries forward every leaf reachable through
192
+ # this resolver's inputs, including records no model formed an edge over
193
+ # (the merge-forward / fall-through requirement).
194
+
195
+ # Deduplicate what is read. Linking every pair of n sources gives n(n-1)
196
+ # (model, record_step) pairs, but only a handful of distinct readings between
197
+ # them, since they share an upstream resolver and cover the same sources.
198
+ # Asking per pair would otherwise repeat the same query a quadratic number of
199
+ # times. `dict.fromkeys` dedupes while keeping lineage order, so the record
200
+ # step is built the same way every run.
201
+ reads = dict.fromkeys(
202
+ read
203
+ for model in self.inputs
204
+ for record_step in model.inputs
205
+ for read in record_step._identifier_reads
206
+ )
207
+ upstream = pl.concat(
208
+ [adapter.read_identifiers(*read) for read in reads],
209
+ how="vertical",
210
+ ).unique()
211
+
212
+ # Record which source artifacts this resolver output covers. A resolver's
213
+ # output names its sources, but a store can hold several generations of a
214
+ # name. That is what lets it be read back without the plan that built it.
215
+ # Those names are data, tagging which source each row came from. They are
216
+ # nothing to do with publishing, which is `publish()` and happens after this.
217
+ adapter.store_resolver(
218
+ fp=fp,
219
+ resolver_output=materialise_resolver_output(clusters, upstream),
220
+ sources={source.name: source._fp for source in self.sources},
221
+ )
222
+
223
+ # -- data -------------------------------------------------------------------------
224
+
225
+ @property
226
+ def sources(self) -> tuple[Source, ...]:
227
+ """The sources reachable through this resolver, in lineage order."""
228
+ return tuple(step for step in self.lineage() if isinstance(step, Source))
229
+
230
+ def entities(self, sources: list[str] | None = None) -> pl.DataFrame:
231
+ """Return `(root, leaf, key, source)`. Collects the plan first if needed.
232
+
233
+ One row per source record. `root` is the entity it resolved to, `leaf` its
234
+ content-addressed record identity, and `key` its key in the original source.
235
+
236
+ Args:
237
+ sources: Restrict to these source names. Defaults to all of them.
238
+
239
+ Raises:
240
+ StepNotFound: If `sources` names something this resolver does not read.
241
+ """
242
+ if not self.is_collected:
243
+ self.collect()
244
+ adapter, fp = self._collected()
245
+ resolver_output = adapter.read_resolver(fp)
246
+
247
+ if sources is None:
248
+ return resolver_output
249
+
250
+ return resolver_output.filter(pl.col("source").is_in(self._named(sources)))
251
+
252
+ def _named(self, sources: list[str]) -> list[str]:
253
+ """Narrow this resolver's sources to those named, in lineage order.
254
+
255
+ A name that isn't one of them is an error, not an empty result. Asking
256
+ for a source this resolver never read is a mistake in the caller, and silently
257
+ returning nothing is how it stays one.
258
+ """
259
+ available = [source.name for source in self.sources]
260
+ unknown = [name for name in sources if name not in available]
261
+ if unknown:
262
+ raise StepNotFound(
263
+ f"This resolver does not read {', '.join(repr(n) for n in unknown)}. "
264
+ f"It covers: {', '.join(available)}."
265
+ )
266
+ if not sources:
267
+ raise StepNotFound("No compatible source was found")
268
+ return [name for name in available if name in sources]
269
+
270
+ def get_lookup(self, sources: list[str] | None = None) -> pl.DataFrame:
271
+ """Return `root` plus one qualified-key column per source.
272
+
273
+ The wide form of `entities()` is a row per entity, joined across sources, with
274
+ nulls where a source has no record in it. This is the table you hand to
275
+ someone who just wants their identifiers lined up.
276
+
277
+ Args:
278
+ sources: Restrict to these source names. Defaults to all of them.
279
+ """
280
+ resolver_output = self.entities(sources)
281
+ names = self._named(sources) if sources is not None else None
282
+
283
+ # This is never empty. A resolver always has a source, and `_named` raises
284
+ # rather than narrow to none, so there is always a table to start the join
285
+ # from.
286
+ columns = [
287
+ resolver_output.filter(pl.col("source") == source.name).select(
288
+ "root", pl.col("key").alias(source.qualified_key)
289
+ )
290
+ for source in self.sources
291
+ if names is None or source.name in names
292
+ ]
293
+
294
+ lookup = columns[0]
295
+ for keys in columns[1:]:
296
+ lookup = lookup.join(keys, on="root", how="full", coalesce=True)
297
+ return lookup
298
+
299
+ def leaf_sets(self, sources: list[str] | None = None) -> list[list[int]]:
300
+ """Return each entity as a sorted list of record identities.
301
+
302
+ Cluster IDs are dropped, so two resolver outputs over the same records can be
303
+ compared by structure alone. Leaves are deduplicated, so each one appears once
304
+ per key it holds.
305
+
306
+ Args:
307
+ sources: Restrict to these source names. Defaults to all of them.
308
+ """
309
+ groups = self.entities(sources).group_by("root").agg("leaf")["leaf"].to_list()
310
+ return [sorted(set(group)) for group in groups]
311
+
312
+ def view_entity(self, root: int, merge_fields: bool = False) -> pl.DataFrame:
313
+ """Return the stored rows for every record in one entity.
314
+
315
+ Values come from the extract cached when each source was collected, not from a
316
+ fresh warehouse read. That is the data the matching actually saw, the same
317
+ rows the reviewer puts on screen, and it means looking at an entity needs no
318
+ warehouse connection.
319
+
320
+ Args:
321
+ root: The entity to look at.
322
+ merge_fields: Collapse the source qualifier on index fields, so a field two
323
+ sources share lands in one column. Key fields stay qualified, since
324
+ they are what tells you which source a row came from.
325
+
326
+ Raises:
327
+ KeyError: If no source has a record in that entity.
328
+ """
329
+ adapter, fp = self._collected()
330
+ resolver_output = self.entities().filter(pl.col("root") == root)
331
+ stored = adapter.resolver_output_sources(fp)
332
+
333
+ rows: list[pl.DataFrame] = []
334
+ key_columns: list[str] = []
335
+ for source in self.sources:
336
+ keys = resolver_output.filter(pl.col("source") == source.name)["key"]
337
+ if keys.len() == 0:
338
+ continue
339
+
340
+ records, qualified_key = adapter.read_source_records(
341
+ stored[source.name], source.name, keys
342
+ )
343
+ if merge_fields:
344
+ prefix = qualify(source.name)
345
+ records = records.rename(
346
+ {
347
+ column: column.removeprefix(prefix)
348
+ for column in records.columns
349
+ if column != qualified_key
350
+ }
351
+ )
352
+
353
+ key_columns.append(qualified_key)
354
+ rows.append(records)
355
+
356
+ if not rows:
357
+ raise KeyError(f"Entity {root} not available")
358
+
359
+ # Coerce fields to their common super-type, and lead with the keys.
360
+ joined = pl.concat(rows, how="diagonal_relaxed")
361
+ remaining = [column for column in joined.columns if column not in key_columns]
362
+ return joined.select(*key_columns, *remaining)
363
+
364
+ def lookup_key(
365
+ self, from_source: str, to_sources: list[str], key: str
366
+ ) -> dict[str, list[str]]:
367
+ """Find the keys in `to_sources` that resolve to the same entity as `key`.
368
+
369
+ Args:
370
+ from_source: The source `key` belongs to.
371
+ to_sources: The sources to find matching keys in.
372
+ key: The key to look up.
373
+
374
+ Returns:
375
+ Source name → matching keys, including `from_source` itself.
376
+ """
377
+ resolver_output = self.entities()
378
+ origin = resolver_output.filter(
379
+ (pl.col("source") == from_source) & (pl.col("key") == key)
380
+ )
381
+ if origin.height == 0:
382
+ raise StepNotFound(f"Key '{key}' not found in source '{from_source}'")
383
+
384
+ cluster = resolver_output.filter(pl.col("root") == origin["root"][0])
385
+
386
+ def keys_in(source_name: str) -> list[str]:
387
+ return cluster.filter(pl.col("source") == source_name)["key"].to_list()
388
+
389
+ return {from_source: keys_in(from_source)} | {
390
+ target: keys_in(target) for target in to_sources
391
+ }
392
+
393
+ # -- RecordStep contract -------------------------------------------------------
394
+
395
+ def _read_cache(self, adapter: Adapter) -> pl.DataFrame:
396
+ """Derive the table, this resolver's sources with `id` set to the entity root.
397
+
398
+ A re-derivable join over artifacts already stored (the source extracts and this
399
+ resolver's output), so nothing is materialised for it.
400
+ """
401
+ if self._fp is None: # collect orders upstream first
402
+ raise RuntimeError(
403
+ "This resolver has not been collected. Call collect() first."
404
+ )
405
+ return build_record_step(adapter, self.sources, self)
406
+
407
+ @property
408
+ def _identifier_reads(self) -> tuple[IdentifierRead, ...]:
409
+ return tuple((source._fp, source.name, self._fp) for source in self.sources)