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,527 @@
1
+ """Sources that share entities, and everything you do with them.
2
+
3
+ This is the entry point. `linked_sources_factory` generates several sources from one
4
+ pool of planted entities, so the same real-world thing appears in more than one of them
5
+ under different keys. That is what makes linking testable at all.
6
+
7
+ The object it returns is the only one that knows the answer, so everything needing the
8
+ answer hangs off it. Build a plan with `dedupe()`/`link()` and score one with
9
+ `diff_resolver_output()`/`diff_model_edges()`, none of which need to be told the truth.
10
+ """
11
+
12
+ import warnings
13
+ from collections.abc import Callable, Iterable
14
+ from functools import cache
15
+ from typing import Any, Self
16
+
17
+ import polars as pl
18
+ from faker import Faker
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+ from sqlalchemy import Engine, create_engine
21
+ from sqlglot import cast, select
22
+ from sqlglot.expressions import column
23
+
24
+ from matchlab.locations import RelationalDBLocation
25
+ from matchlab.models.models import Model
26
+ from matchlab.recordstep import RecordStep
27
+ from matchlab.resolvers import Resolver
28
+ from matchlab.sources import Source
29
+ from matchlab.testkit._generate import generate_entities, generate_source
30
+ from matchlab.testkit.compare import (
31
+ diff_entities,
32
+ resolver_output_to_clusters,
33
+ scores_to_clusters,
34
+ )
35
+ from matchlab.testkit.entities import Cluster, EntityReference, TrueEntity
36
+ from matchlab.testkit.features import FeatureConfig, SourceParameters, SuffixRule
37
+ from matchlab.testkit.matchers import (
38
+ AnswerKey,
39
+ PerfectDeduper,
40
+ PerfectDeduperSettings,
41
+ PerfectLinker,
42
+ PerfectLinkerSettings,
43
+ )
44
+ from matchlab.testkit.models import GeneratedModel, generate_entity_scores
45
+ from matchlab.testkit.sources import GeneratedSource
46
+
47
+
48
+ class LinkedSources(BaseModel):
49
+ """A set of generated sources, plus the true entities planted across all of them.
50
+
51
+ This is the object the module docstring describes. Build a plan from it with
52
+ `dedupe()`/`link()`, then score one with
53
+ `diff_resolver_output()`/`diff_model_edges()`.
54
+ """
55
+
56
+ model_config = ConfigDict(arbitrary_types_allowed=True)
57
+
58
+ true_entities: set[TrueEntity] = Field(default_factory=set)
59
+ sources: dict[str, GeneratedSource]
60
+
61
+ def find_entities(
62
+ self,
63
+ min_appearances: dict[str, int] | None = None,
64
+ max_appearances: dict[str, int] | None = None,
65
+ ) -> list[TrueEntity]:
66
+ """Find entities matching appearance criteria.
67
+
68
+ Args:
69
+ min_appearances: For each named source, the minimum number of keys an
70
+ entity must have there.
71
+ max_appearances: For each named source, the maximum number of keys an
72
+ entity may have there.
73
+ """
74
+ result = list(self.true_entities)
75
+
76
+ def _meets_criteria(
77
+ entity: TrueEntity, criteria: dict[str, int], compare: Callable
78
+ ) -> bool:
79
+ return all(
80
+ compare(len(entity.get_keys(src)), count)
81
+ for src, count in criteria.items()
82
+ )
83
+
84
+ if min_appearances:
85
+ result = [
86
+ entity
87
+ for entity in result
88
+ if _meets_criteria(entity, min_appearances, lambda x, y: x >= y)
89
+ ]
90
+
91
+ if max_appearances:
92
+ result = [
93
+ entity
94
+ for entity in result
95
+ if _meets_criteria(entity, max_appearances, lambda x, y: x <= y)
96
+ ]
97
+
98
+ return result
99
+
100
+ def true_entity_subset(self, *sources: str) -> list[Cluster]:
101
+ """Return a subset of true entities that appear in the given sources."""
102
+ cluster_entities = [entity.cluster(*sources) for entity in self.true_entities]
103
+ return [entity for entity in cluster_entities if entity is not None]
104
+
105
+ def diff_model_edges(
106
+ self,
107
+ edges: pl.DataFrame,
108
+ left: GeneratedSource,
109
+ right: GeneratedSource | None = None,
110
+ threshold: float = 0.0,
111
+ ) -> tuple[bool, dict]:
112
+ """Diff a model's edges against the planted true entities.
113
+
114
+ This takes the sources rather than their innards. The clusters the model
115
+ started from, and the names to compare over, are both properties of the
116
+ sources you handed it, so re-supplying them separately was only a chance to
117
+ pass the wrong ones.
118
+
119
+ Args:
120
+ edges: The model edge table to score.
121
+ left: The generated source the model read as its left input.
122
+ right: Its right input, for a linker. `None` for a deduper.
123
+ threshold: Score at or above which an edge counts as a match.
124
+
125
+ Returns:
126
+ `(identical, report)`. See
127
+ [`diff_entities()`][matchlab.testkit.compare.diff_entities]
128
+ for the report format.
129
+ """
130
+ names = [left.source.name] + ([right.source.name] if right is not None else [])
131
+ return diff_entities(
132
+ expected=self.true_entity_subset(*names),
133
+ actual=scores_to_clusters(
134
+ scores=edges,
135
+ left_clusters=left.input_clusters,
136
+ right_clusters=right.input_clusters if right is not None else None,
137
+ threshold=threshold,
138
+ ),
139
+ )
140
+
141
+ def diff_resolver_output(
142
+ self, resolver_output: pl.DataFrame, *sources: str
143
+ ) -> tuple[bool, dict]:
144
+ """Diff a collected resolver output against the planted true entities.
145
+
146
+ The counterpart to `diff_model_edges` for a plan that has run. It scores the
147
+ `(root, key, source)` table `Resolver.entities()` returns, whose IDs are
148
+ content-derived at collect time and so have no counterpart here. Only the
149
+ record membership is comparable, which is exactly what a cluster asserts.
150
+
151
+ Args:
152
+ resolver_output: The table returned by `Resolver.entities()`.
153
+ *sources: The source names to compare over, e.g. `"crn", "cdms"`.
154
+
155
+ Returns:
156
+ `(identical, report)`. See
157
+ [`diff_entities()`][matchlab.testkit.compare.diff_entities]
158
+ for the report format.
159
+ """
160
+ return diff_entities(
161
+ expected=self.true_entity_subset(*sources),
162
+ actual=list(resolver_output_to_clusters(resolver_output)),
163
+ )
164
+
165
+ def write_to_location(self, client: Any | None = None) -> Self: # noqa: ANN401
166
+ """Write every source's data to its location.
167
+
168
+ Args:
169
+ client: Write through this client instead of each source's own, repointing
170
+ the whole linked set at one warehouse. Mirrors
171
+ `GeneratedSource.write_to_location`, so callers don't need to loop over
172
+ `sources` by hand.
173
+ """
174
+ for source_testkit in self.sources.values():
175
+ source_testkit.write_to_location(client=client)
176
+
177
+ return self
178
+
179
+ def dedupe(
180
+ self,
181
+ source: str,
182
+ *,
183
+ true_entities: Iterable[TrueEntity] | None = None,
184
+ score_range: tuple[float, float] = (0.8, 1.0),
185
+ seed: int = 42,
186
+ ) -> "GeneratedModel":
187
+ """Build a perfect deduper over one of these sources.
188
+
189
+ The truth is implied. It is what this testkit planted, so there is nothing to
190
+ thread through by hand.
191
+
192
+ Args:
193
+ source: Name of the source to deduplicate.
194
+ true_entities: Restrict the truth to these entities. Defaults to all of
195
+ them, which is what you want unless you are deliberately building a
196
+ model that knows only part of the answer.
197
+ score_range: Range the emitted scores fall in.
198
+ seed: Random seed for the generated scores.
199
+ """
200
+ left = self.sources[source]
201
+ return _build_model(
202
+ left_testkit=left,
203
+ right_testkit=None,
204
+ true_entities=(
205
+ self.true_entities if true_entities is None else true_entities
206
+ ),
207
+ left=left.source,
208
+ right=None,
209
+ score_range=score_range,
210
+ seed=seed,
211
+ )
212
+
213
+ def link(
214
+ self,
215
+ left: str,
216
+ right: str,
217
+ *,
218
+ through: "Resolver | None" = None,
219
+ true_entities: Iterable[TrueEntity] | None = None,
220
+ score_range: tuple[float, float] = (0.8, 1.0),
221
+ seed: int = 42,
222
+ ) -> "GeneratedModel":
223
+ """Build a perfect linker between two of these sources.
224
+
225
+ Args:
226
+ left: Name of the left source.
227
+ right: Name of the right source.
228
+ through: Read the *left* source through this resolver rather than raw, so
229
+ the link sits on top of an upstream dedupe. That is the layered shape
230
+ worth exercising: the apex must carry the upstream grouping forward as
231
+ well as its own.
232
+ true_entities: Restrict the truth to these entities. Defaults to all.
233
+ score_range: Range the emitted scores fall in.
234
+ seed: Random seed for the generated scores.
235
+ """
236
+ left_testkit, right_testkit = self.sources[left], self.sources[right]
237
+ return _build_model(
238
+ left_testkit=left_testkit,
239
+ right_testkit=right_testkit,
240
+ true_entities=(
241
+ self.true_entities if true_entities is None else true_entities
242
+ ),
243
+ left=(through if through is not None else left_testkit.source),
244
+ right=right_testkit.source,
245
+ score_range=score_range,
246
+ seed=seed,
247
+ )
248
+
249
+
250
+ def _build_model(
251
+ *,
252
+ left_testkit: GeneratedSource,
253
+ right_testkit: GeneratedSource | None,
254
+ true_entities: Iterable[TrueEntity],
255
+ left: RecordStep,
256
+ right: RecordStep | None,
257
+ score_range: tuple[float, float] = (0.8, 1.0),
258
+ seed: int = 42,
259
+ ) -> GeneratedModel:
260
+ """Wire a scripted model onto generated sources, building both expectations.
261
+
262
+ You usually want `LinkedSources.dedupe` or `.link` instead. They know the truth and
263
+ the record steps, so they take a source name rather than seven arguments. This is
264
+ the shared implementation behind them, and is private because building the record
265
+ steps correctly is the part that needs the testkit.
266
+
267
+ Args:
268
+ left_testkit: Generated testkit for the left source. Value-keyed truth is
269
+ derived from it, so it cannot be a bare `Source`.
270
+ right_testkit: The right source's testkit, for a linker. `None` deduplicates.
271
+ true_entities: The planted entities the model should recover.
272
+ left: The record step the model reads as its left input.
273
+ right: The record step the model reads as its right input, for a linker.
274
+ score_range: Range the emitted scores fall in.
275
+ seed: Random seed for the generated scores.
276
+
277
+ Raises:
278
+ ValueError: If `score_range` is not increasing within [0, 1].
279
+ """
280
+ if not (0 <= score_range[0] <= score_range[1] <= 1):
281
+ raise ValueError("Scores must be increasing values between 0 and 1")
282
+
283
+ entities = tuple(true_entities)
284
+ left_entities = left_testkit.input_clusters
285
+ right_entities = right_testkit.input_clusters if right_testkit is not None else None
286
+
287
+ # The synthetic-ID expectation, in the testkit's own entity space. This is what
288
+ # `diff_model_edges` asserts a methodology against without running a plan.
289
+ scores = generate_entity_scores(
290
+ left_entities=frozenset(left_entities),
291
+ right_entities=frozenset(right_entities) if right_entities else None,
292
+ true_entities=frozenset(entities),
293
+ score_range=score_range,
294
+ seed=seed,
295
+ )
296
+
297
+ # The value-keyed truth. The model matches on row values, so a collected plan
298
+ # emits the same truth over whatever content-derived IDs the record step actually
299
+ # carries.
300
+ truth_id = AnswerKey.from_sources(
301
+ left=left_testkit,
302
+ true_entities=entities,
303
+ right=right_testkit,
304
+ score=score_range[1],
305
+ ).register()
306
+
307
+ if right_testkit is not None:
308
+ model_class: type[PerfectLinker] | type[PerfectDeduper] = PerfectLinker
309
+ model_settings: PerfectLinkerSettings | PerfectDeduperSettings = (
310
+ PerfectLinkerSettings(truth_id=truth_id)
311
+ )
312
+ else:
313
+ model_class = PerfectDeduper
314
+ model_settings = PerfectDeduperSettings(truth_id=truth_id)
315
+
316
+ return GeneratedModel(
317
+ model=Model(
318
+ model_class=model_class,
319
+ model_settings=model_settings,
320
+ left=left,
321
+ right=right,
322
+ ),
323
+ left_source=left_testkit,
324
+ right_source=right_testkit,
325
+ scores=scores,
326
+ )
327
+
328
+
329
+ @cache
330
+ def linked_sources_factory(
331
+ source_parameters: tuple[SourceParameters, ...] | None = None,
332
+ n_true_entities: int | None = None,
333
+ engine: Engine | None = None,
334
+ seed: int = 42,
335
+ ) -> LinkedSources:
336
+ """Generate a set of linked sources with tracked entities.
337
+
338
+ Args:
339
+ source_parameters: Optional tuple of source testkit parameters
340
+ n_true_entities: Optional number of true entities to generate. If provided,
341
+ overrides any n_true_entities in source configs. If not provided, each
342
+ SourceParameters must specify its own n_true_entities.
343
+ engine: Optional SQLAlchemy engine to use for all sources. If provided,
344
+ overrides any engine in source configs.
345
+ seed: Random seed for reproducibility
346
+ """
347
+ generator = Faker()
348
+ generator.seed_instance(seed)
349
+
350
+ default_engine = create_engine("sqlite:///:memory:")
351
+
352
+ if source_parameters is None:
353
+ # Use factory parameter or default for default configs
354
+ n_true_entities = n_true_entities if n_true_entities is not None else 10
355
+
356
+ features = {
357
+ "company_name": FeatureConfig(
358
+ name="company_name",
359
+ base_generator="company",
360
+ ),
361
+ "crn": FeatureConfig(
362
+ name="crn",
363
+ base_generator="bothify",
364
+ parameters=(("text", "???-###-???-###"),),
365
+ ),
366
+ "dh": FeatureConfig(
367
+ name="dh",
368
+ base_generator="numerify",
369
+ parameters=(("text", "########"),),
370
+ ),
371
+ "cdms": FeatureConfig(
372
+ name="cdms",
373
+ base_generator="numerify",
374
+ parameters=(("text", "ORG-########"),),
375
+ ),
376
+ "address": FeatureConfig(
377
+ name="address",
378
+ base_generator="address",
379
+ ),
380
+ }
381
+
382
+ source_parameters = (
383
+ SourceParameters(
384
+ name="crn",
385
+ engine=engine or default_engine,
386
+ features=(
387
+ features["company_name"].add_variations(
388
+ SuffixRule(suffix=" Limited"),
389
+ SuffixRule(suffix=" UK"),
390
+ SuffixRule(suffix=" Company"),
391
+ ),
392
+ features["crn"],
393
+ ),
394
+ n_true_entities=n_true_entities,
395
+ repetition=0,
396
+ ),
397
+ SourceParameters(
398
+ name="dh",
399
+ engine=engine or default_engine,
400
+ features=(
401
+ features["company_name"],
402
+ features["dh"],
403
+ ),
404
+ n_true_entities=n_true_entities // 2,
405
+ repetition=0,
406
+ ),
407
+ SourceParameters(
408
+ name="cdms",
409
+ engine=engine or default_engine,
410
+ features=(
411
+ features["crn"],
412
+ features["cdms"],
413
+ ),
414
+ n_true_entities=n_true_entities,
415
+ repetition=1,
416
+ ),
417
+ )
418
+ else:
419
+ if n_true_entities is not None:
420
+ # Factory parameter provided, warn if configs have values set
421
+ config_entities = [
422
+ parameters.n_true_entities for parameters in source_parameters
423
+ ]
424
+ if any(n is not None for n in config_entities):
425
+ warnings.warn(
426
+ "Both source testkit configs and linked_sources_factory specify "
427
+ "n_true_entities. The factory parameter will be used.",
428
+ UserWarning,
429
+ stacklevel=2,
430
+ )
431
+ # Override all configs with factory parameter
432
+ source_parameters = tuple(
433
+ parameters.model_copy(
434
+ update={
435
+ "engine": engine or parameters.engine,
436
+ "n_true_entities": n_true_entities,
437
+ }
438
+ )
439
+ for parameters in source_parameters
440
+ )
441
+ else:
442
+ # No factory parameter, check all configs have n_true_entities set
443
+ missing_counts = [
444
+ parameters.name
445
+ for parameters in source_parameters
446
+ if parameters.n_true_entities is None
447
+ ]
448
+ if missing_counts:
449
+ raise ValueError(
450
+ "n_true_entities not set for sources: "
451
+ f"{', '.join(missing_counts)}. When factory n_true_entities is "
452
+ "not provided, all configs must specify it."
453
+ )
454
+
455
+ # Collect all unique features
456
+ all_features = set()
457
+ for parameters in source_parameters:
458
+ all_features.update(parameters.features)
459
+ all_features = tuple(sorted(all_features, key=lambda f: f.name))
460
+
461
+ # Find maximum number of entities needed across all sources
462
+ max_entities = max(parameters.n_true_entities for parameters in source_parameters)
463
+
464
+ # Generate all possible entities
465
+ all_entities = generate_entities(
466
+ generator=generator, features=all_features, n=max_entities
467
+ )
468
+
469
+ # Initialize LinkedSources
470
+ true_entity_lookup = {entity.id: entity for entity in all_entities}
471
+ linked = LinkedSources(
472
+ true_entities=all_entities,
473
+ sources={},
474
+ )
475
+
476
+ # Generate sources
477
+ for parameters in source_parameters:
478
+ # Generate source data using seed entities
479
+ data, entity_keys, row_keys = generate_source(
480
+ generator=generator,
481
+ features=tuple(parameters.features),
482
+ n_true_entities=parameters.n_true_entities,
483
+ repetition=parameters.repetition,
484
+ seed_entities=all_entities,
485
+ )
486
+
487
+ # Create Cluster objects from row_keys
488
+ clusters = [
489
+ Cluster(
490
+ id=row_id,
491
+ keys=EntityReference({parameters.name: frozenset(keys)}),
492
+ )
493
+ for row_id, keys in row_keys.items()
494
+ ]
495
+
496
+ # Create source config. Resolve the engine here rather than relying on a field
497
+ # default, so sources that were given none still share this call's warehouse
498
+ # and can therefore be linked to each other.
499
+ source = Source(
500
+ location=RelationalDBLocation(
501
+ name=str(parameters.name),
502
+ client=parameters.engine or engine or default_engine,
503
+ ),
504
+ name=parameters.name,
505
+ extract_transform=select(
506
+ cast(column("key"), "string").as_("key"),
507
+ *[column(feature.name) for feature in parameters.features],
508
+ )
509
+ .from_(parameters.name)
510
+ .sql(),
511
+ key_field="key",
512
+ )
513
+
514
+ # Add source to linked.sources
515
+ linked.sources[parameters.name] = GeneratedSource(
516
+ source=source,
517
+ features=tuple(parameters.features),
518
+ data=data,
519
+ input_clusters=tuple(sorted(clusters)),
520
+ )
521
+
522
+ # Update entities with source references
523
+ for entity_id, keys in entity_keys.items():
524
+ entity = true_entity_lookup[entity_id]
525
+ entity.add_source_reference(parameters.name, keys)
526
+
527
+ return linked