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,178 @@
1
+ """Scoring: turn results into the vocabulary, then compare them with the answer.
2
+
3
+ The functions here convert a resolver's or model's output into `Cluster` objects, and
4
+ diff two `Cluster` lists. None of that needs the planted answer, which is why they stay
5
+ as plain functions rather than methods. `LinkedSources.diff_resolver_output()` and
6
+ `.diff_model_edges()` do need the planted answer, so those are methods on the one
7
+ object that holds it.
8
+
9
+ Every comparison returns `(identical, report)`, where the report classifies each cluster
10
+ as perfect, subset, superset, wrong or invalid. That breakdown is the point. "Eight
11
+ clusters were subsets" tells you the matcher is too strict, in a way a number cannot.
12
+ """
13
+
14
+ from collections import Counter
15
+
16
+ import polars as pl
17
+
18
+ from matchlab.core.dsu import DisjointSet
19
+ from matchlab.testkit.entities import Cluster, EntityReference
20
+
21
+
22
+ def resolver_output_to_clusters(resolver_output: pl.DataFrame) -> set[Cluster]:
23
+ """Convert a collected resolver output into entities comparable with truth.
24
+
25
+ This is the other half of measuring a plan against generated data. The testkit
26
+ plants known entities, the plan resolves records into clusters, and this turns
27
+ those clusters back into the same currency the truth is expressed in. Pair it with
28
+ [`LinkedSources.true_entity_subset()`][matchlab.testkit.linked.LinkedSources.true_entity_subset]
29
+ and [`diff_entities()`][matchlab.testkit.compare.diff_entities]:
30
+
31
+ identical, report = diff_entities(
32
+ expected=linked.true_entity_subset("crn", "cdms"),
33
+ actual=list(resolver_output_to_clusters(resolver_output)),
34
+ )
35
+
36
+ A `Cluster` compares by its keys and never by its ID (`Cluster.__eq__`), which is
37
+ what lets this work at all. The resolver output's `root` is a content-derived hash
38
+ minted at collect time, and has no counterpart in the testkit's synthetic ID space.
39
+ Only the `(source, key)` membership is comparable, and that is exactly what a
40
+ cluster asserts.
41
+
42
+ Args:
43
+ resolver_output: A table conforming to `SCHEMA_RESOLVER_OUTPUT`, the table
44
+ `Resolver.entities()` returns, with `root`, `key` and `source` columns.
45
+
46
+ Returns:
47
+ One Cluster per distinct `root`.
48
+
49
+ Raises:
50
+ ValueError: If the required columns are absent.
51
+ """
52
+ required = {"root", "key", "source"}
53
+ missing = required - set(resolver_output.columns)
54
+ if missing:
55
+ raise ValueError(
56
+ f"Fields {sorted(missing)} must be included in the resolver output and "
57
+ f"are missing. Available: {sorted(resolver_output.columns)}"
58
+ )
59
+
60
+ clusters = resolver_output.group_by("root").agg("source", "key")
61
+
62
+ entities: set[Cluster] = set()
63
+ for cluster in clusters.iter_rows(named=True):
64
+ keys: dict[str, set[str]] = {}
65
+ for source, key in zip(cluster["source"], cluster["key"], strict=True):
66
+ keys.setdefault(source, set()).add(str(key))
67
+ entities.add(
68
+ Cluster(
69
+ keys=EntityReference(
70
+ {source: frozenset(group) for source, group in keys.items()}
71
+ )
72
+ )
73
+ )
74
+
75
+ return entities
76
+
77
+
78
+ def scores_to_clusters(
79
+ scores: pl.DataFrame,
80
+ left_clusters: tuple[Cluster, ...],
81
+ right_clusters: tuple[Cluster, ...] | None = None,
82
+ threshold: float = 0.0,
83
+ ) -> tuple[Cluster, ...]:
84
+ """Merge clusters connected by a score at or above threshold.
85
+
86
+ Left and right clusters that share an edge scoring at least `threshold` merge into
87
+ one `Cluster`. With no `right_clusters`, this merges within `left_clusters` alone
88
+ (deduplication).
89
+
90
+ Args:
91
+ scores: A `left_id`/`right_id`/`score` edge table.
92
+ left_clusters: Clusters the model read as its left input.
93
+ right_clusters: Clusters read as the right input, for a linker. `None` for a
94
+ dedupe.
95
+ threshold: Score at or above which an edge counts as a match.
96
+
97
+ Returns:
98
+ One merged `Cluster` per connected component.
99
+ """
100
+ left_lookup = {entity.id: entity for entity in left_clusters}
101
+ if right_clusters is not None:
102
+ right_lookup = {entity.id: entity for entity in right_clusters}
103
+ else:
104
+ right_lookup = left_lookup
105
+
106
+ djs = DisjointSet[Cluster]()
107
+
108
+ # Add ALL entities to the disjoint set
109
+ for entity in left_clusters:
110
+ djs.add(entity)
111
+ if right_clusters is not None:
112
+ for entity in right_clusters:
113
+ djs.add(entity)
114
+
115
+ # Add edges to the disjoint set
116
+ for record in scores.to_dicts():
117
+ if record["score"] >= threshold:
118
+ djs.union(
119
+ left_lookup[record["left_id"]],
120
+ right_lookup[record["right_id"]],
121
+ )
122
+
123
+ components: set[set[Cluster]] = djs.get_components()
124
+
125
+ entities: list[Cluster] = []
126
+ for component in components:
127
+ merged: Cluster = sum(component)
128
+ entities.append(merged)
129
+
130
+ return tuple(entities)
131
+
132
+
133
+ def diff_entities(expected: list[Cluster], actual: list[Cluster]) -> tuple[bool, dict]:
134
+ """Compare two lists of Cluster against each other, with a diff report.
135
+
136
+ Args:
137
+ expected: The expected Cluster list.
138
+ actual: The actual Cluster list.
139
+
140
+ Returns:
141
+ `(identical, report)`. `identical` is `True` if the two lists match exactly.
142
+ `report` counts how each actual cluster relates to the expected ones:
143
+
144
+ - `perfect`: matches an expected cluster exactly.
145
+ - `subset`: is a subset of an expected cluster.
146
+ - `superset`: is a superset of an expected cluster.
147
+ - `wrong`: does not overlap any expected cluster.
148
+ - `invalid`: contains keys absent from every expected cluster.
149
+ """
150
+ expected_set, actual_set = set(expected), set(actual)
151
+ if expected_set == actual_set:
152
+ return True, {}
153
+
154
+ all_expected = sum(expected_set)
155
+ perfect_matches = expected_set & actual_set
156
+ remaining_actual = actual_set - perfect_matches
157
+
158
+ counter = Counter(
159
+ {
160
+ "perfect": len(perfect_matches),
161
+ "subset": 0,
162
+ "superset": 0,
163
+ "wrong": 0,
164
+ "invalid": 0,
165
+ }
166
+ )
167
+
168
+ for a in remaining_actual:
169
+ if any(a in e for e in expected_set):
170
+ counter["subset"] += 1
171
+ elif a not in all_expected:
172
+ counter["invalid"] += 1
173
+ elif any(e in a for e in expected_set):
174
+ counter["superset"] += 1
175
+ else:
176
+ counter["wrong"] += 1
177
+
178
+ return False, dict(counter)
@@ -0,0 +1,290 @@
1
+ """The vocabulary: what a planted answer is, and what a claimed answer is.
2
+
3
+ Two types exist, and the difference between them matters:
4
+
5
+ * `TrueEntity` is the *answer*: one planted real-world thing, identified by the values
6
+ it was generated from, spanning every source it landed in.
7
+ * `Cluster` is *an answer*: a set of records claimed to be one entity, identified by
8
+ that membership alone.
9
+
10
+ `TrueEntity.cluster()` projects the first onto the second, so the two become
11
+ comparable. Only membership can be compared this way. A resolver output's IDs are
12
+ minted at collect time and have no counterpart in generated data.
13
+ """
14
+
15
+ from random import getrandbits
16
+ from typing import Any, Self
17
+
18
+ from frozendict import frozendict
19
+ from pydantic import BaseModel, ConfigDict, Field
20
+
21
+
22
+ class EntityReference(frozendict):
23
+ """Reference to an entity's presence in specific sources.
24
+
25
+ Maps source names to sets of primary keys.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ mapping: dict[str, frozenset[str]] | None = None,
31
+ ) -> None:
32
+ """Initialise the EntityReference."""
33
+ super().__init__({} if mapping is None else mapping)
34
+
35
+ def __add__(self, other: "EntityReference") -> "EntityReference":
36
+ """Merge two EntityReferences by unioning keys for each source."""
37
+ if not isinstance(other, EntityReference):
38
+ return NotImplemented
39
+
40
+ return EntityReference(
41
+ {
42
+ k: self.get(k, frozenset()) | other.get(k, frozenset())
43
+ for k in self.keys() | other.keys()
44
+ }
45
+ )
46
+
47
+ def __le__(self, other: Self) -> bool:
48
+ """Test if self is a subset of other."""
49
+ if not isinstance(other, EntityReference):
50
+ return NotImplemented
51
+
52
+ return all(name in other and self[name] <= other[name] for name in self)
53
+
54
+
55
+ class EntityIDMixin:
56
+ """Shared ID behaviour for entity classes.
57
+
58
+ Provides integer conversion and comparison operators, so entities sort by `id`.
59
+ """
60
+
61
+ id: int
62
+
63
+ def __int__(self) -> int:
64
+ """Allow converting an entity to an integer by returning its ID."""
65
+ return self.id
66
+
67
+ def __lt__(self, other: Self | int) -> bool:
68
+ """Compare based on ID for sorting operations."""
69
+ if hasattr(other, "id"):
70
+ return self.id < other.id
71
+ if isinstance(other, int):
72
+ return self.id < other
73
+ return NotImplemented
74
+
75
+ def __gt__(self, other: Self | int) -> bool:
76
+ """Compare based on ID for sorting operations."""
77
+ if hasattr(other, "id"):
78
+ return self.id > other.id
79
+ if isinstance(other, int):
80
+ return self.id > other
81
+ return NotImplemented
82
+
83
+ def __le__(self, other: Self | int) -> bool:
84
+ """Compare based on ID for sorting operations."""
85
+ if hasattr(other, "id"):
86
+ return self.id <= other.id
87
+ if isinstance(other, int):
88
+ return self.id <= other
89
+ return NotImplemented
90
+
91
+ def __ge__(self, other: Self | int) -> bool:
92
+ """Compare based on ID for sorting operations."""
93
+ if hasattr(other, "id"):
94
+ return self.id >= other.id
95
+ if isinstance(other, int):
96
+ return self.id >= other
97
+ return NotImplemented
98
+
99
+
100
+ class SourceKeyMixin:
101
+ """Shared source-key behaviour for entity classes.
102
+
103
+ Provides `get_keys()`, for reading back the keys held for one source.
104
+ """
105
+
106
+ keys: EntityReference
107
+
108
+ def get_keys(self, name: str) -> set[str]:
109
+ """Get keys for a specific source.
110
+
111
+ Args:
112
+ name: Name of the source.
113
+
114
+ Returns:
115
+ Set of keys, empty if the source is not found.
116
+ """
117
+ return set(self.keys.get(name, frozenset()))
118
+
119
+
120
+ class Cluster(BaseModel, EntityIDMixin, SourceKeyMixin):
121
+ """A set of records claimed to be one entity, an *answer*.
122
+
123
+ This is the unit of comparison. Both sides of `diff_entities` are clusters: the
124
+ expected side projected from `TrueEntity.cluster()`, and the actual side read back
125
+ off a model or a resolver's output. It carries membership and nothing else, because
126
+ membership is the only thing the two sides can agree on. A resolver output's IDs
127
+ are minted at collect time and have no counterpart in generated data.
128
+
129
+ Equality and hashing are over `keys` alone. `id` is carried for bookkeeping and is
130
+ deliberately ignored when comparing. Contrast `TrueEntity`, which is the answer key
131
+ rather than an answer.
132
+ """
133
+
134
+ model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
135
+
136
+ id: int = Field(default_factory=lambda: getrandbits(63)) # 64 gives OverflowError
137
+ keys: EntityReference
138
+
139
+ def __add__(self, other: "Cluster") -> "Cluster":
140
+ """Combine two Cluster objects by combining the keys."""
141
+ if other is None:
142
+ return self
143
+ if not isinstance(other, Cluster):
144
+ return NotImplemented
145
+ return Cluster(keys=self.keys + other.keys)
146
+
147
+ def __radd__(self, other: object) -> "Cluster":
148
+ """Handle sum() by treating 0 as an empty Cluster."""
149
+ if other == 0: # sum() starts with 0
150
+ return self
151
+ return NotImplemented
152
+
153
+ def __sub__(self, other: "Cluster") -> dict[str, frozenset[str]]:
154
+ """Return keys in self that aren't in other, by source.
155
+
156
+ Used to diff two Cluster objects.
157
+ """
158
+ if not isinstance(other, Cluster):
159
+ return NotImplemented
160
+
161
+ diff = {}
162
+ for name, our_keys in self.keys.items():
163
+ their_keys = other.keys.get(name, frozenset())
164
+ if remaining := our_keys - their_keys:
165
+ diff[name] = remaining
166
+
167
+ return diff
168
+
169
+ def __rsub__(self, other: "Cluster") -> dict[str, frozenset[str]]:
170
+ """Support reverse subtraction."""
171
+ if not isinstance(other, Cluster):
172
+ return NotImplemented
173
+ return other - self
174
+
175
+ def __eq__(self, other: object) -> bool:
176
+ """Compare based on keys."""
177
+ if not isinstance(other, Cluster):
178
+ return NotImplemented
179
+ return self.keys == other.keys
180
+
181
+ def __contains__(self, other: "Cluster") -> bool:
182
+ """Check if this entity contains all keys from other entity."""
183
+ return other.keys <= self.keys
184
+
185
+ def __hash__(self) -> int:
186
+ """Hash based on EntityReference which is itself hashable."""
187
+ return hash(self.keys)
188
+
189
+ def is_subset_of_true_entity(self, source_entity: "TrueEntity") -> bool:
190
+ """Check if this Cluster's references are a subset of a TrueEntity's."""
191
+ return self.keys <= source_entity.keys
192
+
193
+ def similarity_ratio(self, other: "Cluster") -> float:
194
+ """Return ratio of shared keys to total keys across all sources."""
195
+ total_keys = 0
196
+ shared_keys = 0
197
+
198
+ # Get all source names
199
+ all_sources = set(self.keys.keys()) | set(other.keys.keys())
200
+
201
+ for name in all_sources:
202
+ our_keys = self.keys.get(name, frozenset())
203
+ their_keys = other.keys.get(name, frozenset())
204
+
205
+ total_keys += len(our_keys | their_keys)
206
+ shared_keys += len(our_keys & their_keys)
207
+
208
+ return shared_keys / total_keys if total_keys > 0 else 0.0
209
+
210
+
211
+ class TrueEntity(BaseModel, EntityIDMixin, SourceKeyMixin):
212
+ """One planted real-world thing, the *answer key*.
213
+
214
+ This is what the generator started from. It holds `base_values`, the feature values
215
+ its rows were derived from, and accumulates the keys it landed under in every source
216
+ it appears in. Equality is over `base_values`. Two entities are the same thing if
217
+ they were generated from the same values.
218
+
219
+ It spans every source, so it is not directly comparable with a result. Project it
220
+ onto the sources under test with `cluster()` to get something that is.
221
+ """
222
+
223
+ model_config = ConfigDict(arbitrary_types_allowed=True)
224
+
225
+ id: int = Field(default_factory=lambda: getrandbits(63)) # 64 gives OverflowError
226
+ base_values: dict[str, Any] = Field(description="Feature name -> base value")
227
+ keys: EntityReference = Field(
228
+ description="Source to keys mapping",
229
+ default=EntityReference(mapping=frozenset()),
230
+ )
231
+ total_unique_variations: int = Field(default=0)
232
+
233
+ def __eq__(self, other: object) -> bool:
234
+ """Equal if base values are shared, or integer ID matches."""
235
+ if isinstance(other, TrueEntity):
236
+ return self.base_values == other.base_values
237
+ if isinstance(other, int):
238
+ return self.id == other
239
+ return NotImplemented
240
+
241
+ def __hash__(self) -> int:
242
+ """Hash based on sorted base values."""
243
+ return hash(tuple(sorted(self.base_values.items())))
244
+
245
+ def add_source_reference(self, name: str, keys: list[str]) -> None:
246
+ """Add or update a source reference.
247
+
248
+ Args:
249
+ name: Source name.
250
+ keys: List of primary keys for this source.
251
+ """
252
+ mapping = dict(self.keys)
253
+ mapping[name] = frozenset(keys)
254
+ self.keys = EntityReference(mapping)
255
+
256
+ def cluster(self, *names: str) -> Cluster | None:
257
+ """Project this true entity onto the given sources, making it comparable.
258
+
259
+ Comparing equality of `Cluster` sets is a simpler, more reliable test than
260
+ checking whether `Cluster` objects are subsets of `TrueEntity` objects. This
261
+ method is what makes that comparison possible:
262
+
263
+ ```python
264
+ actual: set[Cluster] = ...
265
+ expected: set[Cluster] = {
266
+ s.cluster("source1", "source2") for s in true_entities
267
+ }
268
+
269
+ is_identical = expected == actual
270
+ missing = expected - actual
271
+ extra = actual - expected
272
+ ```
273
+
274
+ Args:
275
+ *names: Names of sources to include in the Cluster.
276
+
277
+ Returns:
278
+ A `Cluster` with only the named sources' keys, or `None` if this entity has
279
+ no keys in any of them.
280
+ """
281
+ filtered = {
282
+ name: self.keys.get(name)
283
+ for name in names
284
+ if self.keys.get(name) is not None
285
+ }
286
+
287
+ if len(filtered) == 0:
288
+ return None
289
+
290
+ return Cluster(keys=EntityReference(filtered))
@@ -0,0 +1,146 @@
1
+ """What to generate: feature declarations and the variations applied to them.
2
+
3
+ Pure declaration. Nothing here generates or compares anything. `FeatureConfig`
4
+ describes one column's values. Its `variations` are what let one true entity produce
5
+ more than one form of the same value, which `_generate.generate_rows` turns into rows.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import Generic, Self, TypeVar
10
+
11
+ import polars as pl
12
+ from adbc_driver_manager.dbapi import Connection as AdbcConnection
13
+ from faker import Faker
14
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
15
+ from sqlalchemy import Engine
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ class VariationRule(BaseModel, Generic[T], ABC):
21
+ """Abstract base class for variation rules."""
22
+
23
+ model_config = ConfigDict(frozen=True)
24
+
25
+ @property
26
+ @abstractmethod
27
+ def type(self) -> type[T]:
28
+ """Python type this rule can be applied to."""
29
+ ...
30
+
31
+ @abstractmethod
32
+ def apply(self, value: T) -> T:
33
+ """Apply the variation to a value."""
34
+ ...
35
+
36
+
37
+ class SuffixRule(VariationRule[str]):
38
+ """Add a suffix to a value."""
39
+
40
+ suffix: str
41
+
42
+ @property
43
+ def type(self) -> type[str]: # noqa: D102
44
+ return str
45
+
46
+ def apply(self, value: str) -> str: # noqa: D102
47
+ return f"{value}{self.suffix}"
48
+
49
+
50
+ class ReplaceRule(VariationRule[str]):
51
+ """Replace occurrences of a string with another."""
52
+
53
+ old: str
54
+ new: str
55
+
56
+ @property
57
+ def type(self) -> type[str]: # noqa: D102
58
+ return str
59
+
60
+ def apply(self, value: str) -> str: # noqa: D102
61
+ return value.replace(self.old, self.new)
62
+
63
+
64
+ def infer_data_type(base: str, parameters: tuple | None) -> pl.DataType:
65
+ """Infer the Polars data type a Faker configuration produces.
66
+
67
+ Args:
68
+ base: Faker generator type.
69
+ parameters: Parameters for the generator.
70
+
71
+ Returns:
72
+ The Polars data type of the generated values.
73
+ """
74
+ generator = Faker()
75
+ value_generator = getattr(generator, base)
76
+ parameters = {} if not parameters else dict(parameters)
77
+ examples = [value_generator(**dict(parameters)) for _ in range(5)]
78
+ series = pl.Series(examples)
79
+ return series.dtype
80
+
81
+
82
+ class FeatureConfig(BaseModel):
83
+ """Configuration for generating a feature with variations."""
84
+
85
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
86
+
87
+ name: str
88
+ base_generator: str
89
+ parameters: tuple | None = Field(
90
+ default=None,
91
+ description=(
92
+ "Parameters for the generator. A tuple of tuples passed to the generator."
93
+ ),
94
+ )
95
+ unique: bool = Field(
96
+ default=True,
97
+ description=(
98
+ "Whether the generator enforces uniqueness in the generated data. "
99
+ "For example, using unique=True with the 'boolean' generator will error "
100
+ "if more than two values are generated."
101
+ ),
102
+ )
103
+ drop_base: bool = Field(
104
+ default=False, description="Whether the base case is dropped."
105
+ )
106
+ variations: tuple[VariationRule, ...] = Field(default_factory=tuple)
107
+ datatype: pl.DataType = Field(
108
+ default_factory=lambda data: infer_data_type(
109
+ data["base_generator"], data["parameters"]
110
+ )
111
+ )
112
+
113
+ def add_variations(self, *rule: VariationRule) -> "FeatureConfig":
114
+ """Add a variation rule to the feature."""
115
+ return FeatureConfig(
116
+ name=self.name,
117
+ base_generator=self.base_generator,
118
+ parameters=self.parameters,
119
+ unique=self.unique,
120
+ drop_base=self.drop_base,
121
+ variations=self.variations + tuple(rule),
122
+ )
123
+
124
+ @field_validator("name", mode="after")
125
+ @classmethod
126
+ def protected_names(cls: type[Self], value: str) -> str:
127
+ """Ensure name is not a reserved keyword."""
128
+ if value in {"id", "key"}:
129
+ raise ValueError("Feature name cannot be 'id' or 'key'.")
130
+ return value
131
+
132
+
133
+ class SourceParameters(BaseModel):
134
+ """Configuration for generating a source."""
135
+
136
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True, extra="forbid")
137
+
138
+ features: tuple[FeatureConfig, ...] = Field(default_factory=tuple)
139
+ name: str
140
+ # Defaults to None rather than an engine, so that every caller ends up with its own
141
+ # warehouse. A `Field(default=create_engine(...))` is evaluated once at class
142
+ # definition, which silently shared one in-memory SQLite database across the whole
143
+ # process, so two sources built without an explicit engine could not be joined.
144
+ engine: Engine | AdbcConnection | None = Field(default=None)
145
+ n_true_entities: int | None = Field(default=None)
146
+ repetition: int = Field(default=0)