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
matchlab/steps.py ADDED
@@ -0,0 +1,284 @@
1
+ """The lazy plan node.
2
+
3
+ A `Step` holds references to its **inputs only** (`upstream`). There is no registry,
4
+ no parent pointer, and no downstream list. "The DAG" is whatever is reachable
5
+ upstream from the node you hold, and lineage operations are pure functions of a root
6
+ node (`matchlab.lineage`).
7
+
8
+ Nothing is computed until `collect()`. Collection walks the plan upstream-first and
9
+ runs only the steps whose artifact is not already stored.
10
+
11
+ **Fingerprints identify artifacts.** A step's fingerprint combines its kind, its
12
+ spec, and its inputs' fingerprints. Everything downstream of a source can therefore
13
+ derive it from the *plan alone*, before any work happens, and `collect`
14
+ can skip a cached step without running it. Sources are the exception. Raw data enters
15
+ there, so a source's spec key includes a content hash of the data it read.
16
+ Constructing a fresh `Source` therefore re-reads the warehouse (the documented way to
17
+ refresh), while an existing `Source` object memoises its read.
18
+ """
19
+
20
+ import json
21
+ from abc import ABC, abstractmethod
22
+ from typing import ClassVar, Self
23
+
24
+ from platformdirs import user_cache_path
25
+ from pydantic import BaseModel
26
+
27
+ from matchlab import lineage
28
+ from matchlab.adapters import Adapter, DuckDBAdapter, Fingerprint
29
+ from matchlab.core.hash import HASH_FUNC
30
+ from matchlab.core.kinds import StepKind
31
+ from matchlab.lineage import StepStatus
32
+ from matchlab.progress import report
33
+
34
+ CACHE_DIR = user_cache_path("matchlab")
35
+
36
+ _DEFAULT_ADAPTER: Adapter | None = None
37
+
38
+
39
+ def set_default_adapter(adapter: Adapter | None) -> None:
40
+ """Set the adapter used by `collect()` when none is passed. `None` resets it."""
41
+ global _DEFAULT_ADAPTER # module-level default, by design
42
+ _DEFAULT_ADAPTER = adapter
43
+
44
+
45
+ def default_adapter() -> Adapter:
46
+ """Return the default adapter, creating a DuckDB store in the cache dir if unset."""
47
+ global _DEFAULT_ADAPTER # module-level default, by design
48
+ if _DEFAULT_ADAPTER is None:
49
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
50
+ _DEFAULT_ADAPTER = DuckDBAdapter(CACHE_DIR / "store.duckdb")
51
+ return _DEFAULT_ADAPTER
52
+
53
+
54
+ class Step(ABC):
55
+ """A node in a lazy plan."""
56
+
57
+ kind: ClassVar[StepKind]
58
+
59
+ def __init__(self, upstream: "tuple[Step, ...]" = ()) -> None:
60
+ """Initialise a plan node with its direct inputs.
61
+
62
+ Steps have no names. They are identified by **position**, where they fall in
63
+ `lineage.walk`, which is the order `collect` runs them in and the order
64
+ `PlanDocument` lists them in. `step 7` in a log, `[7]` in `draw()`, and
65
+ `steps[7]` in a document are therefore the same node.
66
+
67
+ A position is not stored here, because it is not a property of the step. It
68
+ belongs to the walk it came from, and the same step numbers differently in
69
+ `walk(deduped)` and `walk(companies)`. Whoever does the walking passes it to
70
+ whoever needs it, `collect` to its reporter, `draw` to its own renderer.
71
+
72
+ Finding a result later is a separate matter, and a separate act.
73
+ `Resolver.publish` points a **label** at a resolver's output. `Source` is the
74
+ one step with a name, and it means something else again. A source's name is
75
+ part of its output, prefixing every column it contributes and tagging its rows.
76
+ """
77
+ self.upstream = tuple(upstream)
78
+ self._fp: Fingerprint | None = None
79
+ self._adapter: Adapter | None = None
80
+
81
+ def __repr__(self) -> str:
82
+ """Return a short representation showing kind and collection state."""
83
+ return f"<{type(self).__name__} {'collected' if self.is_collected else 'lazy'}>"
84
+
85
+ def __str__(self) -> str:
86
+ """How this step appears in a drawing."""
87
+ return self.kind
88
+
89
+ @property
90
+ def is_collected(self) -> bool:
91
+ """Whether this step has been materialised."""
92
+ return self._fp is not None
93
+
94
+ # -- plan identity ----------------------------------------------------------------
95
+
96
+ @property
97
+ @abstractmethod
98
+ def spec(self) -> BaseModel:
99
+ """This step's settings, as a serialisable model.
100
+
101
+ One model per step kind, in `matchlab.specs`. It must carry everything this
102
+ step's output depends on and nothing else. That is the invariant `_spec_key`
103
+ rests on, and the one to check when adding a setting. Omit something that
104
+ changes the output and collect will hand back a stale artifact without
105
+ re-running (see `_fingerprint`).
106
+
107
+ Specs describe a step's own settings, not its inputs'. Edges live on
108
+ `upstream`, and `_fingerprint` already folds in the parents' fingerprints.
109
+ """
110
+ ...
111
+
112
+ def _spec_key(self) -> bytes:
113
+ """Bytes identifying this step's spec.
114
+
115
+ `Source` extends this with a content hash of the data it read, so that the
116
+ fingerprint tracks the warehouse. For every other kind the spec is the
117
+ whole story.
118
+ """
119
+ return json.dumps(self.spec.model_dump(mode="json"), sort_keys=True).encode()
120
+
121
+ def _fingerprint(self) -> Fingerprint:
122
+ """Address this step by kind, spec and inputs.
123
+
124
+ The key is derived from the *plan*, not from the step's output. That is what
125
+ makes it computable before the step runs, which is what lets `_ensure` skip
126
+ work. An output digest would only be knowable once the work was already done.
127
+ Sources are the exception. They fold a hash of the data they read into
128
+ `_spec_key`, because no spec reveals that the warehouse moved.
129
+
130
+ The trade-off is that the key can disagree with the bytes in both directions.
131
+
132
+ * the spec omits something that changes the output, a **stale hit**, because
133
+ `_ensure` never runs the step and reads the old artifact. `SplinkLinker`
134
+ without an explicit seed is a live example. The only defence is that every
135
+ `_spec_key` covers everything its step's output depends on. Treat that as
136
+ the invariant to protect when adding a step or a setting.
137
+ * the spec includes something that doesn't change the output, a **spurious
138
+ miss**, re-running the step and everything below it. No spec does this
139
+ today, and the way to keep it that way is to record settings only. A spec
140
+ that described an *input* would, since identity already arrives via the
141
+ parent fingerprint. A setting that must point at an input points at its
142
+ position, which is not redundant. It decides which input the setting
143
+ applies to.
144
+
145
+ There is also no early cutoff. A `Transform` whose SQL is reformatted but
146
+ semantically unchanged invalidates the whole subtree beneath it.
147
+
148
+ TODO(fingerprints): split this into an action key (plan-derived, as now)
149
+ mapping to an output digest (content-derived, recorded by the adapter on
150
+ write), and build a child's key from its parents' output digests rather than
151
+ their action keys. That buys early cutoff, storage dedup and rename tolerance.
152
+ It does **not** fix stale hits. An output digest governs propagation, never
153
+ admission, so a step whose action key is a hit still never runs and its digest
154
+ is never consulted. Costs an indirection table in the adapter contract, a hash
155
+ of every artifact on write, and the loss of a work-set knowable before running.
156
+ See PLAN.md, "Known limitations", for the full ledger.
157
+ """
158
+ parts: list[bytes] = [self.kind.encode(), self._spec_key()]
159
+ for parent in self.upstream:
160
+ if parent._fp is None: # collect orders upstream first
161
+ raise RuntimeError(
162
+ f"A {parent.kind} input of this {self.kind} has no fingerprint yet."
163
+ )
164
+ parts.append(parent._fp)
165
+ return HASH_FUNC(b"|".join(parts)).digest()
166
+
167
+ # -- execution --------------------------------------------------------------------
168
+
169
+ @abstractmethod
170
+ def _execute(self, adapter: Adapter, fp: Fingerprint) -> None:
171
+ """Compute this step and persist its artifact under `fp`."""
172
+ ...
173
+
174
+ def _ensure(self, adapter: Adapter) -> StepStatus:
175
+ """Materialise this step unless its artifact is already stored.
176
+
177
+ This classifies the outcome but reports none of it. `collect` holds the walk,
178
+ and therefore each step's position, the thing a record has to quote to be
179
+ findable in the plan. Reporting belongs there, with the single
180
+ `matchlab.progress.Progress` that owns what is drawn and what is logged. That is
181
+ also what keeps the two from ever disagreeing. This keeps to the work.
182
+
183
+ Returns:
184
+ What it took. `DONE` if this call computed the step, `CACHED` if the
185
+ artifact was already stored.
186
+ """
187
+ self._adapter = adapter
188
+
189
+ if self._fp is not None and adapter.has(self._fp):
190
+ return StepStatus.CACHED
191
+
192
+ fp = self._fingerprint()
193
+
194
+ if adapter.has(fp): # cache hit, skip the work entirely
195
+ self._fp = fp
196
+ return StepStatus.CACHED
197
+
198
+ self._execute(adapter, fp)
199
+ self._fp = fp
200
+ return StepStatus.DONE
201
+
202
+ # -- public API -------------------------------------------------------------------
203
+
204
+ def collect(
205
+ self, adapter: Adapter | None = None, interactive: bool | None = None
206
+ ) -> Self:
207
+ """Materialise this step and everything it depends on.
208
+
209
+ Steps whose artifact is already stored are skipped without being run, so
210
+ re-collecting after adding a downstream step only does the new work.
211
+
212
+ Args:
213
+ adapter: Where to read and write artifacts. Defaults to the module-level
214
+ adapter (a DuckDB store in the user cache directory).
215
+ interactive: Whether someone is watching. `None`, the default, takes a
216
+ terminal or a notebook as a yes. When they are, the plan is drawn as a
217
+ live tree redrawn in place, and not logged. The tree on screen is the
218
+ key those `[step N]` records need, and it stays there. When they are
219
+ not, the plan is logged instead. See `matchlab.progress`.
220
+
221
+ Returns:
222
+ This step, now collected.
223
+ """
224
+ adapter = adapter or default_adapter()
225
+ # One walk, used for both. It fixes each step's position, so a `step 7` the
226
+ # reporter logs is the node it drew as `[7]`.
227
+ steps = lineage.walk(self)
228
+ with report(self, steps, interactive, adapter) as reporter:
229
+ for step in steps:
230
+ reporter.begin(step)
231
+ try:
232
+ status = step._ensure(adapter)
233
+ except Exception:
234
+ reporter.end(step, StepStatus.FAILED)
235
+ raise
236
+ reporter.end(step, status)
237
+ return self
238
+
239
+ def lineage(self) -> list["Step"]:
240
+ """Return this step and all its inputs, upstream-first."""
241
+ return lineage.walk(self)
242
+
243
+ def draw(self) -> str:
244
+ """Render this step's sub-plan as a tree."""
245
+ return lineage.draw(self)
246
+
247
+ def fingerprints(self) -> set[Fingerprint]:
248
+ """Address every artifact this plan is made of, its own and its inputs'.
249
+
250
+ Which artifacts belong to a plan is the plan's own business, so this is where
251
+ a store gets told: `adapter.prune(keep=plan.fingerprints())` hands storage a set
252
+ of addresses it already understands, rather than a graph it would have to learn
253
+ to walk.
254
+
255
+ Returns:
256
+ One fingerprint per step in `lineage()`. A set, because two steps in one
257
+ plan can address the same artifact. Identical specs over identical
258
+ inputs is the same bytes, and it is stored once.
259
+
260
+ Raises:
261
+ RuntimeError: If any step has not been collected. An uncollected plan names
262
+ no artifacts at all, so answering with a smaller set would quietly tell
263
+ a caller that less is worth keeping than they think.
264
+ """
265
+ return {step._collected()[1] for step in self.lineage()}
266
+
267
+ # -- helpers for subclasses -------------------------------------------------------
268
+
269
+ def _collected(self) -> tuple[Adapter, Fingerprint]:
270
+ """Return the adapter this step was collected into, and its fingerprint.
271
+
272
+ Both together, because everything that reads a stored artifact needs both and
273
+ neither exists before collection. Returning the pair is what lets a caller
274
+ use the fingerprint without re-checking that it is there.
275
+ """
276
+ if self._adapter is None or self._fp is None:
277
+ raise RuntimeError(
278
+ f"This {self.kind} has not been collected. Call collect() first."
279
+ )
280
+ return self._adapter, self._fp
281
+
282
+ def _require_adapter(self) -> Adapter:
283
+ """Return the adapter this step was collected into."""
284
+ return self._collected()[0]
@@ -0,0 +1,104 @@
1
+ """Generate data whose answer you already know, then score matching against it.
2
+
3
+ This package makes entity resolution *measurable*. It plants entities you already know
4
+ the answer for, so a test can assert precision and recall against truth rather than
5
+ eyeball a fixture.
6
+
7
+ The package has one job, done in four steps: **describe** the data (`features`),
8
+ **generate** it and plant the answer (`sources`, `linked`), **match** it (`matchers`),
9
+ then **score** the result (`compare`). `LinkedSources` is the handle for all of it. It
10
+ planted the entities, so nothing downstream has to be told the answer.
11
+
12
+ ## Which do I use?
13
+
14
+ | I want… | Use |
15
+ |---|---|
16
+ | data whose answer I know | `linked_sources_factory()` → `LinkedSources` |
17
+ | one source only | `source_factory()` → `GeneratedSource` |
18
+ | rows I wrote by hand | `source_from_tuple()` |
19
+ | a plan to test | `linked.dedupe(...)` / `linked.link(..., through=...)` |
20
+ | to score a collected plan | `linked.diff_resolver_output(resolver_output, *sources)` |
21
+ | to score a methodology's edges | `linked.diff_model_edges(edges, left=...)` |
22
+ | different columns | `FeatureConfig` + `SuffixRule` / `ReplaceRule` |
23
+ | a matcher that already knows the answer | `PerfectDeduper` / `PerfectLinker` |
24
+ | to build or bend the answer it matches on | `AnswerKey.from_sources(...)` |
25
+ | to compose a comparison by hand | `resolver_output_to_clusters` + `diff_entities` |
26
+
27
+ `dedupe()` and `link()` wire a perfect matcher up for you, so the last two rows are
28
+ only for when you want a matcher that is deliberately wrong in a particular way.
29
+
30
+ A testkit exposes what it knows about the *fixture*. Anything you do to the *plan* goes
31
+ through the node it wraps: `source.source.dedupe(...)`, `model.model.resolve()`. There
32
+ are no forwarding shortcuts, so it is always clear which of the two you are holding.
33
+
34
+ ```python
35
+ from matchlab.testkit import linked_sources_factory
36
+
37
+ linked = linked_sources_factory(
38
+ n_true_entities=10, engine=warehouse
39
+ ).write_to_location()
40
+ resolver_output = linked.link("crn", "cdms").model.resolve().collect().entities()
41
+
42
+ identical, report = linked.diff_resolver_output(resolver_output, "crn", "cdms")
43
+ assert identical, report
44
+ ```
45
+
46
+ ## Two layers, two questions
47
+
48
+ Ground truth is expressed twice, in different ID spaces. Neither subsumes the other, and
49
+ picking the wrong one is the easiest mistake to make here:
50
+
51
+ | | Synthetic-ID layer | Value-keyed layer |
52
+ |---|---|---|
53
+ | Asks | does this *methodology* work? | does the *plan* carry grouping forward? |
54
+ | Built from | `GeneratedModel.predicted_clusters` | `AnswerKey` + `Perfect*` |
55
+ | Scored with | `diff_model_edges` | `diff_resolver_output` |
56
+ | Needs a warehouse | no, the record step read is mocked | yes, and a real `collect()` |
57
+ | Example | `test/methodologies/` | `test/plan/test_ground_truth.py` |
58
+
59
+ The split exists because matchlab derives record identity by content-hashing rows at
60
+ collect time. IDs are unknowable when a fixture is built, so anything asserted against a
61
+ *collected* plan has to be keyed by row values instead. See `matchers` for the full
62
+ reasoning. `dedupe()`/`link()` build both, so either is available from one call.
63
+
64
+ Names not re-exported below are still importable from their module, but they are
65
+ plumbing rather than entry points.
66
+ """
67
+
68
+ from matchlab.testkit.compare import diff_entities, resolver_output_to_clusters
69
+ from matchlab.testkit.entities import Cluster, TrueEntity
70
+ from matchlab.testkit.features import (
71
+ FeatureConfig,
72
+ ReplaceRule,
73
+ SourceParameters,
74
+ SuffixRule,
75
+ )
76
+ from matchlab.testkit.linked import LinkedSources, linked_sources_factory
77
+ from matchlab.testkit.matchers import AnswerKey, PerfectDeduper, PerfectLinker
78
+ from matchlab.testkit.models import GeneratedModel
79
+ from matchlab.testkit.sources import GeneratedSource, source_factory, source_from_tuple
80
+
81
+ __all__ = (
82
+ # generate data with a known answer
83
+ "linked_sources_factory",
84
+ "LinkedSources",
85
+ "source_factory",
86
+ "source_from_tuple",
87
+ "GeneratedSource",
88
+ "GeneratedModel",
89
+ # match, when you want something other than a perfect matcher
90
+ "PerfectDeduper",
91
+ "PerfectLinker",
92
+ "AnswerKey",
93
+ # describe what to generate
94
+ "FeatureConfig",
95
+ "SuffixRule",
96
+ "ReplaceRule",
97
+ "SourceParameters",
98
+ # score results against the answer
99
+ "diff_entities",
100
+ "resolver_output_to_clusters",
101
+ # the vocabulary you get back
102
+ "TrueEntity",
103
+ "Cluster",
104
+ )
@@ -0,0 +1,240 @@
1
+ """Row and entity generation.
2
+
3
+ Internal. Reach for `source_factory` or `linked_sources_factory` instead. These are the
4
+ pieces they are built from, and they take arguments that only make sense once the
5
+ callers have worked them out.
6
+ """
7
+
8
+ from functools import cache
9
+ from itertools import product
10
+ from math import prod
11
+
12
+ import polars as pl
13
+ import pyarrow as pa
14
+ from faker import Faker
15
+
16
+ from matchlab.testkit.entities import EntityReference, TrueEntity
17
+ from matchlab.testkit.features import FeatureConfig
18
+
19
+
20
+ @cache
21
+ def generate_entities(
22
+ generator: Faker,
23
+ features: tuple[FeatureConfig, ...],
24
+ n: int,
25
+ ) -> tuple[TrueEntity]:
26
+ """Generate base entities with their ground truth values from the generator."""
27
+ entities = []
28
+ for _ in range(n):
29
+ base_values = {}
30
+ for feature in features:
31
+ generator_func = generator.unique if feature.unique else generator
32
+ value_generator = getattr(generator_func, feature.base_generator)
33
+ parameters = {} if not feature.parameters else dict(feature.parameters)
34
+
35
+ value = value_generator(**parameters)
36
+ # Explicitly cast lists to tuples to ensure they are hashable
37
+ if isinstance(value, list):
38
+ value = tuple(value)
39
+ base_values[feature.name] = value
40
+
41
+ entities.append(TrueEntity(base_values=base_values, keys=EntityReference()))
42
+ return tuple(entities)
43
+
44
+
45
+ def generate_rows(
46
+ generator: Faker,
47
+ selected_entities: tuple[TrueEntity, ...],
48
+ features: tuple[FeatureConfig, ...],
49
+ repetition: int,
50
+ ) -> tuple[dict[str, list], dict[int, list[str]], dict[int, list[str]]]:
51
+ """Generate raw data rows, plus the maps that connect three different IDs.
52
+
53
+ Every row carries three IDs, each answering a different question. Confusing them is
54
+ the mistake this docstring exists to prevent.
55
+
56
+ * `key`: the row's own identifier. Like a source's primary key, unique within the
57
+ source but not across entities.
58
+ * `id`: matchlab's identifier for the row's *content*. Rows with identical feature
59
+ values share one `id`, whichever entity produced them.
60
+ * `entity`: the ID of the `TrueEntity` that generated the row. This is the planted
61
+ answer, and the one truth downstream tests check their result against.
62
+
63
+ `entity` groups rows by who generated them, and `id` groups rows by what content
64
+ they hold. The two groupings diverge whenever an entity produces more than one
65
+ variation: rows from the same entity can carry different `id`s, and (via
66
+ `repetition`) the same `id` can appear on more than one row.
67
+
68
+ Returns:
69
+ - raw_data: column arrays, ready to build a DataFrame from
70
+ - entity_keys: `TrueEntity` ID -> the keys it produced
71
+ - id_keys: content `id` -> the keys sharing that content
72
+
73
+ Worked example, two entities, each producing two variations twice over:
74
+
75
+ | id | key | company_name |
76
+ |----|-----|--------------|
77
+ | 1 | a | alpha co |
78
+ | 2 | b | alpha ltd |
79
+ | 1 | c | alpha co | # same content as row a
80
+ | 2 | d | alpha ltd | # same content as row b
81
+ | 3 | e | beta co |
82
+ | 4 | f | beta ltd |
83
+ | 3 | g | beta co | # same content as row e
84
+ | 4 | h | beta ltd | # same content as row f
85
+
86
+ As raw data:
87
+
88
+ ```python
89
+ raw_data = {
90
+ "id": [1, 2, 1, 2, 3, 4, 3, 4],
91
+ "key": ["a", "b", "c", "d", "e", "f", "g", "h"],
92
+ "company_name": [
93
+ "alpha co",
94
+ "alpha ltd",
95
+ "alpha co",
96
+ "alpha ltd",
97
+ "beta co",
98
+ "beta ltd",
99
+ "beta co",
100
+ "beta ltd",
101
+ ],
102
+ }
103
+ ```
104
+
105
+ Grouped by entity, the two variations sit together:
106
+
107
+ ```python
108
+ entity_keys = {
109
+ 1: ["a", "b", "c", "d"], # every key entity 1 produced
110
+ 2: ["e", "f", "g", "h"], # every key entity 2 produced
111
+ }
112
+ ```
113
+
114
+ Grouped by content, they split apart again:
115
+
116
+ ```python
117
+ id_keys = {
118
+ 1: ["a", "c"], # both rows read "alpha co"
119
+ 2: ["b", "d"], # both rows read "alpha ltd"
120
+ 3: ["e", "g"], # both rows read "beta co"
121
+ 4: ["f", "h"], # both rows read "beta ltd"
122
+ }
123
+ ```
124
+ """
125
+ raw_data = {"key": [], "id": []}
126
+ for feature in features:
127
+ raw_data[feature.name] = []
128
+
129
+ # Track entity locations and row identities
130
+ entity_keys = {entity.id: [] for entity in selected_entities}
131
+ id_keys = {}
132
+ value_to_id = {}
133
+
134
+ def add_row(entity_id: int, values: tuple) -> None:
135
+ """Add a row of data, handling IDs and keys."""
136
+ key = str(generator.uuid4())
137
+ entity_keys[entity_id].append(key)
138
+
139
+ if values not in value_to_id:
140
+ mb_id = generator.random_number(digits=16)
141
+ value_to_id[values] = mb_id
142
+ id_keys[mb_id] = []
143
+
144
+ row_id = value_to_id[values]
145
+ id_keys[row_id].append(key)
146
+
147
+ raw_data["key"].append(key)
148
+ raw_data["id"].append(row_id)
149
+ for feature, value in zip(features, values, strict=True):
150
+ raw_data[feature.name].append(value)
151
+
152
+ for entity in selected_entities:
153
+ # Collect all possible values, per feature.
154
+ possible_values = []
155
+ for feature in features:
156
+ base = entity.base_values[feature.name]
157
+
158
+ for rule in feature.variations:
159
+ if not isinstance(base, rule.type):
160
+ raise TypeError(
161
+ f"{rule.__class__.__name__} requires {rule.type} "
162
+ f"but {feature.name} generates {type(base)}"
163
+ )
164
+
165
+ variations = []
166
+ # Apply all variations as long as they change the value
167
+ for v in (rule.apply(base) for rule in feature.variations):
168
+ if v != base:
169
+ variations.append(v)
170
+
171
+ values = variations if feature.drop_base else variations + [base]
172
+ possible_values.append(values or [base])
173
+
174
+ if (num_variations := prod(len(values) for values in possible_values)) > 100:
175
+ raise RuntimeError(
176
+ f"Entity {entity.id} would generate {num_variations:,} variations, "
177
+ "which exceeds the maximum of 100. This would cause a Cartesian "
178
+ "explosion."
179
+ )
180
+
181
+ for values in product(*possible_values):
182
+ for _ in range(repetition + 1):
183
+ add_row(entity.id, values)
184
+
185
+ return raw_data, entity_keys, id_keys
186
+
187
+
188
+ @cache
189
+ def generate_source(
190
+ generator: Faker,
191
+ n_true_entities: int,
192
+ features: tuple[FeatureConfig, ...],
193
+ repetition: int,
194
+ seed_entities: tuple[TrueEntity, ...] | None = None,
195
+ ) -> tuple[pa.Table, dict[int, set[str]], dict[int, set[str]]]:
196
+ """Generate raw data as PyArrow tables with entity tracking.
197
+
198
+ Note: this is `@cache`d but also *mutates* `total_unique_variations` on the shared
199
+ `TrueEntity` objects it is given, so a cache hit skips that mutation. Harmless
200
+ today because the first call for a given key does the writing, but it means the
201
+ function is not free of side effects and the cache cannot simply be dropped.
202
+
203
+ Returns:
204
+ - data: PyArrow table with generated data
205
+ - entity_keys: TrueEntity ID -> list of keys mapping
206
+ - id_keys: Unique row ID -> list of keys mapping for identical rows
207
+ """
208
+ # Select or generate entities
209
+ if seed_entities is None:
210
+ selected_entities = generate_entities(generator, features, n_true_entities)
211
+ else:
212
+ selected_entities = generator.random_elements(
213
+ elements=seed_entities,
214
+ unique=True,
215
+ length=min(n_true_entities, len(seed_entities)),
216
+ )
217
+
218
+ # Generate initial data
219
+ raw_data, entity_keys, id_keys = generate_rows(
220
+ generator=generator,
221
+ selected_entities=selected_entities,
222
+ features=features,
223
+ repetition=repetition,
224
+ )
225
+
226
+ # Create DataFrame
227
+ df = pl.DataFrame(raw_data)
228
+
229
+ # Update variation counts
230
+ for entity in selected_entities:
231
+ if entity.id in entity_keys:
232
+ # Count unique row IDs this entity appears in
233
+ entity_rows = df.filter(pl.col("key").is_in(list(entity_keys[entity.id])))
234
+ entity.total_unique_variations = entity_rows["id"].n_unique()
235
+
236
+ return (
237
+ df.to_arrow(),
238
+ entity_keys,
239
+ id_keys,
240
+ )