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.
- matchlab/__init__.py +55 -0
- matchlab/adapters/__init__.py +24 -0
- matchlab/adapters/base.py +446 -0
- matchlab/adapters/duckdb.py +659 -0
- matchlab/cli.py +177 -0
- matchlab/core/__init__.py +1 -0
- matchlab/core/dataframes.py +254 -0
- matchlab/core/dsu.py +62 -0
- matchlab/core/exceptions.py +40 -0
- matchlab/core/hash.py +141 -0
- matchlab/core/kinds.py +12 -0
- matchlab/core/logging.py +190 -0
- matchlab/core/resolver_output.py +173 -0
- matchlab/core/schemas.py +82 -0
- matchlab/core/sql.py +12 -0
- matchlab/document.py +329 -0
- matchlab/eval/__init__.py +11 -0
- matchlab/eval/judgements.py +41 -0
- matchlab/eval/metrics.py +244 -0
- matchlab/eval/samples.py +415 -0
- matchlab/eval/tui/__init__.py +66 -0
- matchlab/eval/tui/app.py +418 -0
- matchlab/eval/tui/modals.py +84 -0
- matchlab/eval/tui/styles.tcss +184 -0
- matchlab/eval/tui/widgets/__init__.py +1 -0
- matchlab/eval/tui/widgets/assignment.py +72 -0
- matchlab/eval/tui/widgets/styling.py +75 -0
- matchlab/eval/tui/widgets/table.py +285 -0
- matchlab/lineage.py +239 -0
- matchlab/locations.py +347 -0
- matchlab/models/__init__.py +10 -0
- matchlab/models/comparison.py +67 -0
- matchlab/models/dedupers/__init__.py +5 -0
- matchlab/models/dedupers/base.py +38 -0
- matchlab/models/dedupers/naive.py +77 -0
- matchlab/models/linkers/__init__.py +9 -0
- matchlab/models/linkers/base.py +41 -0
- matchlab/models/linkers/deterministic.py +229 -0
- matchlab/models/linkers/splinklinker.py +262 -0
- matchlab/models/linkers/weighteddeterministic.py +143 -0
- matchlab/models/models.py +198 -0
- matchlab/progress.py +455 -0
- matchlab/recordstep.py +181 -0
- matchlab/resolvers/__init__.py +10 -0
- matchlab/resolvers/base.py +63 -0
- matchlab/resolvers/components.py +66 -0
- matchlab/resolvers/resolvers.py +409 -0
- matchlab/sources.py +291 -0
- matchlab/specs.py +129 -0
- matchlab/steps.py +284 -0
- matchlab/testkit/__init__.py +104 -0
- matchlab/testkit/_generate.py +240 -0
- matchlab/testkit/compare.py +178 -0
- matchlab/testkit/entities.py +290 -0
- matchlab/testkit/features.py +146 -0
- matchlab/testkit/linked.py +527 -0
- matchlab/testkit/matchers.py +233 -0
- matchlab/testkit/models.py +165 -0
- matchlab/testkit/sources.py +327 -0
- matchlab/transformers/__init__.py +24 -0
- matchlab/transformers/base.py +41 -0
- matchlab/transformers/clean.py +48 -0
- matchlab/transformers/explode.py +67 -0
- matchlab/transformers/group.py +56 -0
- matchlab/transformers/select.py +31 -0
- matchlab/transformers/transform.py +101 -0
- matchlab-0.1.0.dev1.dist-info/METADATA +88 -0
- matchlab-0.1.0.dev1.dist-info/RECORD +72 -0
- matchlab-0.1.0.dev1.dist-info/WHEEL +5 -0
- matchlab-0.1.0.dev1.dist-info/entry_points.txt +2 -0
- matchlab-0.1.0.dev1.dist-info/licenses/LICENSE +21 -0
- 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)
|