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,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
|