hsds-entity-resolution 2.0.0__tar.gz → 2.2.0__tar.gz
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.
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/PKG-INFO +42 -4
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/README.pypi.md +41 -3
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/pyproject.toml +1 -1
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/config/entity_resolution_run_config.py +55 -74
- hsds_entity_resolution-2.2.0/src/hsds_entity_resolution/core/admission.py +224 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/domain_utils.py +0 -17
- hsds_entity_resolution-2.2.0/src/hsds_entity_resolution/core/generate_candidates.py +987 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/generate_candidates_sharded.py +21 -4
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/pipeline.py +33 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/source_policy.py +6 -69
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/taxonomy_utils.py +0 -15
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/state.py +21 -9
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/__init__.py +2 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/contracts.py +12 -2
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/frames.py +12 -0
- hsds_entity_resolution-2.0.0/src/hsds_entity_resolution/core/generate_candidates.py +0 -1354
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/.gitignore +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/config/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/apply_mitigation.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/clean_entities.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/cluster_pairs.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/dataframe_utils.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/evidence_policy.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/materialize_review_queue.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/algorithms.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/safeguards.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/scoring.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/types.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/pair_tiering.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/prepare_persistence_artifacts.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/score_candidates.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/score_candidates_sharded.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/dagster/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/dagster/components/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/dagster/components/entity_resolution_component.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/dagster/definitions.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/defs/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/answers.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/protocol.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/reference.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/stage.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/normalize.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/observability/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/observability/progress.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/observability/tracer.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/py.typed +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/artifact_rows.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/domain.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/rows/__init__.py +0 -0
- {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/rows/clean_entity_rows.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: hsds-entity-resolution
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: HSDS entity resolution: candidate generation, scoring, a Pair Judge contract and clustering, with a Dagster component.
|
|
5
5
|
Project-URL: Homepage, https://github.com/211-Connect/hsds-entity-resolution
|
|
6
6
|
Project-URL: Repository, https://github.com/211-Connect/hsds-entity-resolution
|
|
@@ -48,7 +48,7 @@ runs an incremental seven-stage entity-resolution pipeline on your HSDS data:
|
|
|
48
48
|
| Stage | What happens |
|
|
49
49
|
|---|---|
|
|
50
50
|
| **Clean entities** | Normalize contact fields, compute content hashes, detect adds/changes/removals since the last run |
|
|
51
|
-
| **Generate candidates** |
|
|
51
|
+
| **Generate candidates** | Admit a pair when it shares an Informative Key (name, phone, email, website, address) or its embedding similarity reaches one floor, unless a caller's Structural Exclusion vetoes it |
|
|
52
52
|
| **Score candidates** | Weighted combination of deterministic overlap signals and NLP fuzzy name/description matching |
|
|
53
53
|
| **Judge pairs** *(optional)* | A caller-supplied `PairJudge` answers Same Site, Same Offering, physical delivery and Organization Relation as probabilities for every scored pair |
|
|
54
54
|
| **Apply mitigation** | Carry forward stable pairs, retire pairs for removed entities, detect pair identity continuity |
|
|
@@ -211,8 +211,8 @@ Key thresholds:
|
|
|
211
211
|
| `scoring.deterministic_section_weight` | `0.5625` | `0.50` | Weight of overlap-signal section |
|
|
212
212
|
| `scoring.nlp_section_weight` | `0.4375` | `0.50` | Weight of NLP fuzzy-match section; the two must sum to 1 |
|
|
213
213
|
| `scoring.nlp.fuzzy_threshold` | `0.88` | `0.86` | Minimum name similarity to count as NLP match |
|
|
214
|
-
| `blocking.similarity_threshold` | `0.75` | `0.75` |
|
|
215
|
-
| `blocking.max_candidates_per_entity` | `50` | `125` | Maximum
|
|
214
|
+
| `blocking.similarity_threshold` | `0.75` | `0.75` | The embedding floor: a pair at or above it is a candidate on similarity alone |
|
|
215
|
+
| `blocking.max_candidates_per_entity` | `50` | `125` | Maximum embedding-floor candidates per anchor (shared-key candidates are not capped by it) |
|
|
216
216
|
|
|
217
217
|
---
|
|
218
218
|
|
|
@@ -276,6 +276,44 @@ result.judge_answers # one row per scored pair, JUDGE_ANSWERS_SCHEMA
|
|
|
276
276
|
`judge_scored_pairs(...)` runs the same stage on its own, for hosts that run the judge
|
|
277
277
|
as a separate step.
|
|
278
278
|
|
|
279
|
+
## Candidate admission
|
|
280
|
+
|
|
281
|
+
One generic rule decides which pairs are scored (2.1.0):
|
|
282
|
+
|
|
283
|
+
- **Informative Keys.** Two records that share a value of `name`, `phone`, `email`,
|
|
284
|
+
`website` or `address` (street line required) are a candidate, whatever their
|
|
285
|
+
embedding similarity, provided the field is informative in both records' source
|
|
286
|
+
schemas. A field is informative where its values mostly identify a record; a value
|
|
287
|
+
repeated across hundreds of rows (a category label used as a name) is not.
|
|
288
|
+
- **Embedding floor.** Records at or above `blocking.similarity_threshold` are a
|
|
289
|
+
candidate, up to `blocking.max_candidates_per_entity` per anchor.
|
|
290
|
+
- **Structural Exclusions.** A caller-supplied callable can veto any pair. Excluded
|
|
291
|
+
pairs are returned as `excluded_pairs` with the reason, never dropped silently.
|
|
292
|
+
|
|
293
|
+
Pass both to `run_incremental` (or `run_incremental_until_candidates`):
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
from hsds_entity_resolution.core.admission import build_informative_key_table
|
|
297
|
+
|
|
298
|
+
informative_keys = build_informative_key_table(
|
|
299
|
+
ratios={"SOURCE_A": {"name": 0.12, "phone": 0.74}}, # distinct-value ratios you measure
|
|
300
|
+
cutoff=0.3,
|
|
301
|
+
overrides={"SOURCE_B": {"name": True}},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def exclude(entity_a, entity_b):
|
|
305
|
+
return "different_by_construction" if ... else None
|
|
306
|
+
|
|
307
|
+
result = run_incremental(..., informative_keys=informative_keys, structural_exclusion=exclude)
|
|
308
|
+
result.excluded_pairs # pair_key, entity ids, schemas, similarity, exclusion_reason
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Without them every key field is informative and nothing is excluded. Scoring does not
|
|
312
|
+
depend on how a pair was admitted: `pair_rules` still pick scoring overrides from the
|
|
313
|
+
entity type and the two source schemas. `source_policy.admission_rules` and
|
|
314
|
+
`blocking.overlap_prefilter_channels` were removed; passing them fails validation with a
|
|
315
|
+
migration message.
|
|
316
|
+
|
|
279
317
|
## Normalisers
|
|
280
318
|
|
|
281
319
|
`hsds_entity_resolution.normalize` holds pure, idempotent functions on plain strings —
|
|
@@ -24,7 +24,7 @@ runs an incremental seven-stage entity-resolution pipeline on your HSDS data:
|
|
|
24
24
|
| Stage | What happens |
|
|
25
25
|
|---|---|
|
|
26
26
|
| **Clean entities** | Normalize contact fields, compute content hashes, detect adds/changes/removals since the last run |
|
|
27
|
-
| **Generate candidates** |
|
|
27
|
+
| **Generate candidates** | Admit a pair when it shares an Informative Key (name, phone, email, website, address) or its embedding similarity reaches one floor, unless a caller's Structural Exclusion vetoes it |
|
|
28
28
|
| **Score candidates** | Weighted combination of deterministic overlap signals and NLP fuzzy name/description matching |
|
|
29
29
|
| **Judge pairs** *(optional)* | A caller-supplied `PairJudge` answers Same Site, Same Offering, physical delivery and Organization Relation as probabilities for every scored pair |
|
|
30
30
|
| **Apply mitigation** | Carry forward stable pairs, retire pairs for removed entities, detect pair identity continuity |
|
|
@@ -187,8 +187,8 @@ Key thresholds:
|
|
|
187
187
|
| `scoring.deterministic_section_weight` | `0.5625` | `0.50` | Weight of overlap-signal section |
|
|
188
188
|
| `scoring.nlp_section_weight` | `0.4375` | `0.50` | Weight of NLP fuzzy-match section; the two must sum to 1 |
|
|
189
189
|
| `scoring.nlp.fuzzy_threshold` | `0.88` | `0.86` | Minimum name similarity to count as NLP match |
|
|
190
|
-
| `blocking.similarity_threshold` | `0.75` | `0.75` |
|
|
191
|
-
| `blocking.max_candidates_per_entity` | `50` | `125` | Maximum
|
|
190
|
+
| `blocking.similarity_threshold` | `0.75` | `0.75` | The embedding floor: a pair at or above it is a candidate on similarity alone |
|
|
191
|
+
| `blocking.max_candidates_per_entity` | `50` | `125` | Maximum embedding-floor candidates per anchor (shared-key candidates are not capped by it) |
|
|
192
192
|
|
|
193
193
|
---
|
|
194
194
|
|
|
@@ -252,6 +252,44 @@ result.judge_answers # one row per scored pair, JUDGE_ANSWERS_SCHEMA
|
|
|
252
252
|
`judge_scored_pairs(...)` runs the same stage on its own, for hosts that run the judge
|
|
253
253
|
as a separate step.
|
|
254
254
|
|
|
255
|
+
## Candidate admission
|
|
256
|
+
|
|
257
|
+
One generic rule decides which pairs are scored (2.1.0):
|
|
258
|
+
|
|
259
|
+
- **Informative Keys.** Two records that share a value of `name`, `phone`, `email`,
|
|
260
|
+
`website` or `address` (street line required) are a candidate, whatever their
|
|
261
|
+
embedding similarity, provided the field is informative in both records' source
|
|
262
|
+
schemas. A field is informative where its values mostly identify a record; a value
|
|
263
|
+
repeated across hundreds of rows (a category label used as a name) is not.
|
|
264
|
+
- **Embedding floor.** Records at or above `blocking.similarity_threshold` are a
|
|
265
|
+
candidate, up to `blocking.max_candidates_per_entity` per anchor.
|
|
266
|
+
- **Structural Exclusions.** A caller-supplied callable can veto any pair. Excluded
|
|
267
|
+
pairs are returned as `excluded_pairs` with the reason, never dropped silently.
|
|
268
|
+
|
|
269
|
+
Pass both to `run_incremental` (or `run_incremental_until_candidates`):
|
|
270
|
+
|
|
271
|
+
```python
|
|
272
|
+
from hsds_entity_resolution.core.admission import build_informative_key_table
|
|
273
|
+
|
|
274
|
+
informative_keys = build_informative_key_table(
|
|
275
|
+
ratios={"SOURCE_A": {"name": 0.12, "phone": 0.74}}, # distinct-value ratios you measure
|
|
276
|
+
cutoff=0.3,
|
|
277
|
+
overrides={"SOURCE_B": {"name": True}},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def exclude(entity_a, entity_b):
|
|
281
|
+
return "different_by_construction" if ... else None
|
|
282
|
+
|
|
283
|
+
result = run_incremental(..., informative_keys=informative_keys, structural_exclusion=exclude)
|
|
284
|
+
result.excluded_pairs # pair_key, entity ids, schemas, similarity, exclusion_reason
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Without them every key field is informative and nothing is excluded. Scoring does not
|
|
288
|
+
depend on how a pair was admitted: `pair_rules` still pick scoring overrides from the
|
|
289
|
+
entity type and the two source schemas. `source_policy.admission_rules` and
|
|
290
|
+
`blocking.overlap_prefilter_channels` were removed; passing them fails validation with a
|
|
291
|
+
migration message.
|
|
292
|
+
|
|
255
293
|
## Normalisers
|
|
256
294
|
|
|
257
295
|
`hsds_entity_resolution.normalize` holds pure, idempotent functions on plain strings —
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "hsds-entity-resolution"
|
|
3
3
|
requires-python = ">=3.10,<3.15"
|
|
4
|
-
version = "2.
|
|
4
|
+
version = "2.2.0"
|
|
5
5
|
description = "HSDS entity resolution: candidate generation, scoring, a Pair Judge contract and clustering, with a Dagster component."
|
|
6
6
|
readme = "README.pypi.md"
|
|
7
7
|
keywords = ["dagster", "entity-resolution", "hsds", "deduplication"]
|
|
@@ -8,15 +8,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
|
|
8
8
|
|
|
9
9
|
from hsds_entity_resolution.types.domain import EntityType
|
|
10
10
|
|
|
11
|
-
_SUPPORTED_BLOCKING_OVERLAP_CHANNELS = {
|
|
12
|
-
"email",
|
|
13
|
-
"phone",
|
|
14
|
-
"website",
|
|
15
|
-
"taxonomy",
|
|
16
|
-
"location",
|
|
17
|
-
"address_exact",
|
|
18
|
-
}
|
|
19
|
-
|
|
20
11
|
_ML_SECTION_REMOVED_MESSAGE = (
|
|
21
12
|
"The ML scoring section was removed in hsds-entity-resolution 1.2.0: the score is "
|
|
22
13
|
"deterministic plus NLP only, and the shadow/legacy calibration scores are gone. "
|
|
@@ -25,6 +16,33 @@ _ML_SECTION_REMOVED_MESSAGE = (
|
|
|
25
16
|
)
|
|
26
17
|
|
|
27
18
|
|
|
19
|
+
_ADMISSION_REPLACED_MESSAGE = (
|
|
20
|
+
"Per-lane candidate admission was replaced in hsds-entity-resolution 2.1.0 (ISS-2177) "
|
|
21
|
+
"by one rule: a pair is a candidate when it shares an Informative Key value or its "
|
|
22
|
+
"embedding similarity is at or above blocking.similarity_threshold, unless a "
|
|
23
|
+
"Structural Exclusion vetoes it. Remove {keys} from this config and pass "
|
|
24
|
+
"informative_keys / structural_exclusion to the pipeline instead."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _reject_replaced_admission_keys(data: Any, *, removed: tuple[str, ...]) -> Any:
|
|
29
|
+
"""Raise a migration error when a config still carries replaced admission keys.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
data: Raw mapping handed to a pydantic ``mode="before"`` validator.
|
|
33
|
+
removed: Key names that no longer exist in this model.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
``data`` unchanged when none of the removed keys are present.
|
|
37
|
+
"""
|
|
38
|
+
if not isinstance(data, dict):
|
|
39
|
+
return data
|
|
40
|
+
present = sorted(key for key in removed if key in data)
|
|
41
|
+
if present:
|
|
42
|
+
raise ValueError(_ADMISSION_REPLACED_MESSAGE.format(keys=", ".join(present)))
|
|
43
|
+
return data
|
|
44
|
+
|
|
45
|
+
|
|
28
46
|
def _reject_removed_ml_keys(data: Any, *, removed: tuple[str, ...]) -> Any:
|
|
29
47
|
"""Raise a migration error when a config still carries removed ML keys.
|
|
30
48
|
|
|
@@ -64,36 +82,23 @@ class BaseStrictModel(BaseModel):
|
|
|
64
82
|
|
|
65
83
|
|
|
66
84
|
class BlockingConfig(BaseStrictModel):
|
|
67
|
-
"""Candidate
|
|
85
|
+
"""Candidate admission and fanout controls.
|
|
86
|
+
|
|
87
|
+
``similarity_threshold`` is the single embedding floor: a pair at or above it is a
|
|
88
|
+
candidate on similarity alone. ``max_candidates_per_entity`` caps how many such
|
|
89
|
+
pairs one anchor keeps; pairs admitted by a shared Informative Key are not capped by
|
|
90
|
+
it (see ``ChunkingConfig`` for the optional key caps).
|
|
91
|
+
"""
|
|
68
92
|
|
|
69
93
|
similarity_threshold: float = Field(default=0.75, ge=0.0, le=1.0)
|
|
70
94
|
max_candidates_per_entity: int = Field(default=50, ge=1, le=500)
|
|
71
95
|
blocking_batch_size: int = Field(default=5000, ge=1, le=50000)
|
|
72
|
-
overlap_prefilter_channels: list[str] = Field(
|
|
73
|
-
default_factory=lambda: ["email", "phone", "website", "taxonomy", "location"]
|
|
74
|
-
)
|
|
75
96
|
|
|
76
|
-
@
|
|
97
|
+
@model_validator(mode="before")
|
|
77
98
|
@classmethod
|
|
78
|
-
def
|
|
79
|
-
"""
|
|
80
|
-
|
|
81
|
-
for value in values:
|
|
82
|
-
if not isinstance(value, str):
|
|
83
|
-
message = "overlap_prefilter_channels entries must be strings"
|
|
84
|
-
raise ValueError(message)
|
|
85
|
-
normalized_value = value.strip().lower()
|
|
86
|
-
if normalized_value:
|
|
87
|
-
normalized.append(normalized_value)
|
|
88
|
-
unique_values = list(dict.fromkeys(normalized))
|
|
89
|
-
if not unique_values:
|
|
90
|
-
message = "overlap_prefilter_channels must contain at least one channel"
|
|
91
|
-
raise ValueError(message)
|
|
92
|
-
unsupported = sorted(set(unique_values).difference(_SUPPORTED_BLOCKING_OVERLAP_CHANNELS))
|
|
93
|
-
if unsupported:
|
|
94
|
-
message = f"Unsupported overlap prefilter channels: {unsupported!r}"
|
|
95
|
-
raise ValueError(message)
|
|
96
|
-
return unique_values
|
|
99
|
+
def reject_replaced_admission_keys(cls, data: Any) -> Any:
|
|
100
|
+
"""Fail with a migration message when the removed overlap prefilter is passed."""
|
|
101
|
+
return _reject_replaced_admission_keys(data, removed=("overlap_prefilter_channels",))
|
|
97
102
|
|
|
98
103
|
|
|
99
104
|
class ChunkingConfig(BaseStrictModel):
|
|
@@ -127,8 +132,8 @@ class ChunkingConfig(BaseStrictModel):
|
|
|
127
132
|
ge=1,
|
|
128
133
|
le=100_000,
|
|
129
134
|
description=(
|
|
130
|
-
"When set, cap
|
|
131
|
-
"Unset
|
|
135
|
+
"When set, cap the Informative Key pairs considered per anchor. "
|
|
136
|
+
"Unset leaves Informative Key admission uncapped."
|
|
132
137
|
),
|
|
133
138
|
)
|
|
134
139
|
max_contact_index_fanout: int | None = Field(
|
|
@@ -136,45 +141,12 @@ class ChunkingConfig(BaseStrictModel):
|
|
|
136
141
|
ge=1,
|
|
137
142
|
le=100_000,
|
|
138
143
|
description=(
|
|
139
|
-
"When set, truncate
|
|
140
|
-
"entities (deterministic sorted keep). Unset
|
|
144
|
+
"When set, truncate Informative Key index values that map to more than this "
|
|
145
|
+
"many entities (deterministic sorted keep). Unset leaves fanout uncapped."
|
|
141
146
|
),
|
|
142
147
|
)
|
|
143
148
|
|
|
144
149
|
|
|
145
|
-
class AdmissionRuleConfig(BaseStrictModel):
|
|
146
|
-
"""Generic candidate-admission rule evaluated after embedding threshold."""
|
|
147
|
-
|
|
148
|
-
rule_id: str
|
|
149
|
-
entity_types: list[EntityType] = Field(default_factory=lambda: ["organization", "service"])
|
|
150
|
-
source_relation: Literal[
|
|
151
|
-
"any",
|
|
152
|
-
"same_source",
|
|
153
|
-
"cross_source",
|
|
154
|
-
"same_profile",
|
|
155
|
-
"cross_source_same_profile",
|
|
156
|
-
"cross_profile",
|
|
157
|
-
] = "any"
|
|
158
|
-
source_profiles: list[str] = Field(default_factory=list)
|
|
159
|
-
min_embedding_similarity: float | None = Field(default=None, ge=0.0, le=1.0)
|
|
160
|
-
all_of: list[str] = Field(default_factory=list)
|
|
161
|
-
any_of: list[str] = Field(default_factory=list)
|
|
162
|
-
none_of: list[str] = Field(default_factory=list)
|
|
163
|
-
|
|
164
|
-
@model_validator(mode="after")
|
|
165
|
-
def validate_admission_rule(self) -> AdmissionRuleConfig:
|
|
166
|
-
"""Validate supported channel names and source-profile references."""
|
|
167
|
-
referenced = [*self.all_of, *self.any_of, *self.none_of]
|
|
168
|
-
unsupported = sorted(set(referenced).difference(_SUPPORTED_BLOCKING_OVERLAP_CHANNELS))
|
|
169
|
-
if unsupported:
|
|
170
|
-
message = f"Unsupported admission rule channels: {unsupported!r}"
|
|
171
|
-
raise ValueError(message)
|
|
172
|
-
if not self.all_of and not self.any_of:
|
|
173
|
-
message = "Admission rule must define at least one all_of or any_of channel"
|
|
174
|
-
raise ValueError(message)
|
|
175
|
-
return self
|
|
176
|
-
|
|
177
|
-
|
|
178
150
|
class DeterministicSignalConfig(BaseStrictModel):
|
|
179
151
|
"""Configuration for one deterministic overlap signal."""
|
|
180
152
|
|
|
@@ -350,18 +322,27 @@ class SourceProfileConfig(BaseStrictModel):
|
|
|
350
322
|
|
|
351
323
|
|
|
352
324
|
class SourcePolicyConfig(BaseStrictModel):
|
|
353
|
-
"""
|
|
325
|
+
"""Source-aware scoring overrides supplied by host applications.
|
|
326
|
+
|
|
327
|
+
Only scoring reads it: ``pair_rules`` pick per-pair overrides by entity type and the
|
|
328
|
+
two records' source profiles. Candidate admission is generic (ISS-2177).
|
|
329
|
+
"""
|
|
354
330
|
|
|
355
331
|
source_profiles: dict[str, SourceProfileConfig] = Field(default_factory=dict)
|
|
356
|
-
admission_rules: list[AdmissionRuleConfig] = Field(default_factory=list)
|
|
357
332
|
pair_rules: list[PairRuleConfig] = Field(default_factory=list)
|
|
358
333
|
|
|
334
|
+
@model_validator(mode="before")
|
|
335
|
+
@classmethod
|
|
336
|
+
def reject_replaced_admission_rules(cls, data: Any) -> Any:
|
|
337
|
+
"""Fail with a migration message when per-lane admission rules are passed."""
|
|
338
|
+
return _reject_replaced_admission_keys(data, removed=("admission_rules",))
|
|
339
|
+
|
|
359
340
|
@model_validator(mode="after")
|
|
360
341
|
def validate_source_policy(self) -> SourcePolicyConfig:
|
|
361
|
-
"""Validate rule references against configured source profiles."""
|
|
342
|
+
"""Validate pair-rule references against configured source profiles."""
|
|
362
343
|
profile_ids = set(self.source_profiles)
|
|
363
344
|
referenced: set[str] = set()
|
|
364
|
-
for rule in
|
|
345
|
+
for rule in self.pair_rules:
|
|
365
346
|
referenced.update(rule.source_profiles)
|
|
366
347
|
unknown = sorted(referenced.difference(profile_ids))
|
|
367
348
|
if unknown:
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Candidate admission: one generic rule on Informative Keys plus a Structural Exclusion hook.
|
|
2
|
+
|
|
3
|
+
A pair becomes a candidate when
|
|
4
|
+
|
|
5
|
+
* its two records share at least one value of an **Informative Key** field, or
|
|
6
|
+
* its embedding similarity is at or above one floor (``blocking.similarity_threshold``),
|
|
7
|
+
|
|
8
|
+
and the caller's **Structural Exclusion**, if any, does not exclude it.
|
|
9
|
+
|
|
10
|
+
Whether a field is informative is decided per source schema: a field shared by many
|
|
11
|
+
records of one schema (a service name that is really a category label repeated across
|
|
12
|
+
hundreds of rows) proves nothing when two records share it. The caller measures that,
|
|
13
|
+
usually as a distinct-value ratio, and hands the engine an :data:`InformativeKeyTable`;
|
|
14
|
+
:func:`build_informative_key_table` builds one from ratios, a cutoff and overrides. A key
|
|
15
|
+
admits a pair only when the field is informative in both records' schemas.
|
|
16
|
+
|
|
17
|
+
Structural Exclusions are pairs that are different by construction (for example two
|
|
18
|
+
records of one site that a source system always splits by category). The engine does
|
|
19
|
+
not know any source system: the caller passes a callable, and every pair it excludes is
|
|
20
|
+
returned with its reason instead of being dropped silently.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Callable, Mapping
|
|
26
|
+
from typing import Any, Literal, TypeAlias, get_args
|
|
27
|
+
|
|
28
|
+
from hsds_entity_resolution.core.dataframe_utils import clean_string_list, clean_text_scalar
|
|
29
|
+
from hsds_entity_resolution.core.score_candidates import (
|
|
30
|
+
_first_present, # pyright: ignore[reportPrivateUsage]
|
|
31
|
+
_normalize_address_component, # pyright: ignore[reportPrivateUsage]
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
KeyField: TypeAlias = Literal["name", "phone", "email", "website", "address"]
|
|
35
|
+
KEY_FIELDS: tuple[KeyField, ...] = get_args(KeyField)
|
|
36
|
+
|
|
37
|
+
InformativeKeyTable: TypeAlias = Mapping[str, Mapping[KeyField, bool]]
|
|
38
|
+
"""Source schema (any case) → key field → whether the field is informative there.
|
|
39
|
+
|
|
40
|
+
A schema missing from the table, or a field missing for a schema, counts as informative.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
StructuralExclusion: TypeAlias = Callable[[Mapping[str, Any], Mapping[str, Any]], str | None]
|
|
44
|
+
"""``(entity_a, entity_b) -> reason`` when the pair is different by construction, else ``None``."""
|
|
45
|
+
|
|
46
|
+
KeyValueFilter: TypeAlias = Callable[[Mapping[str, Any], KeyField, set[str]], set[str]]
|
|
47
|
+
"""``(entity, field, values) -> values`` narrowing one record's key values before matching.
|
|
48
|
+
|
|
49
|
+
For values that never identify a record in a given source (a service name that is the
|
|
50
|
+
record's own category label, an address of a placeholder location). It can only remove
|
|
51
|
+
values: anything it returns that ``key_values`` did not produce is ignored.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
KeyCorroboration: TypeAlias = Callable[[Mapping[str, Any], Mapping[str, Any], KeyField], bool]
|
|
55
|
+
"""``(entity_a, entity_b, field) -> admits`` deciding whether a shared key admits a pair.
|
|
56
|
+
|
|
57
|
+
For a key that identifies a record only in context (a program name that repeats across
|
|
58
|
+
agencies and identifies a service only near the other one). Consulted once per shared
|
|
59
|
+
field of a key-sharing pair; a field it rejects does not admit the pair. It never affects
|
|
60
|
+
the embedding floor, and a pair can still be admitted by another shared field.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
EMBEDDING_FLOOR_RULE_ID = "embedding_floor"
|
|
64
|
+
EMBEDDING_FLOOR_REASON_CODE = "embedding_floor"
|
|
65
|
+
INFORMATIVE_KEY_RULE_PREFIX = "informative_key:"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def informative_key_reason_code(field: KeyField) -> str:
|
|
69
|
+
"""Return the candidate reason code naming the Informative Key that admitted a pair.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
field: The key field whose shared value admitted the pair.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
For example ``"informative_key_phone"``.
|
|
76
|
+
"""
|
|
77
|
+
return f"informative_key_{field}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def informative_key_rule_id(fields: set[KeyField]) -> str:
|
|
81
|
+
"""Return the ``blocking_rule_id`` for a pair admitted by shared Informative Keys.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
fields: Every key field the pair shares a value of.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
For example ``"informative_key:email+phone"``.
|
|
88
|
+
"""
|
|
89
|
+
return INFORMATIVE_KEY_RULE_PREFIX + "+".join(sorted(fields))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_informative_key_table(
|
|
93
|
+
*,
|
|
94
|
+
ratios: Mapping[str, Mapping[KeyField, float | None]],
|
|
95
|
+
cutoff: float,
|
|
96
|
+
overrides: Mapping[str, Mapping[KeyField, bool]],
|
|
97
|
+
) -> dict[str, dict[KeyField, bool]]:
|
|
98
|
+
"""Decide per schema which key fields are informative.
|
|
99
|
+
|
|
100
|
+
A field is informative when its distinct-value ratio in the schema is strictly above
|
|
101
|
+
``cutoff``; a field with no measured ratio (no values) is not. An override for the
|
|
102
|
+
schema and field wins over the ratio.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
ratios: Schema → field → distinct-value ratio in ``[0, 1]`` or ``None``.
|
|
106
|
+
cutoff: The global cutoff.
|
|
107
|
+
overrides: Schema → field → forced informative yes/no.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A table keyed by upper-cased schema with every :data:`KEY_FIELDS` entry set.
|
|
111
|
+
"""
|
|
112
|
+
upper_overrides = {schema.strip().upper(): fields for schema, fields in overrides.items()}
|
|
113
|
+
table: dict[str, dict[KeyField, bool]] = {}
|
|
114
|
+
for schema, schema_ratios in ratios.items():
|
|
115
|
+
key = schema.strip().upper()
|
|
116
|
+
schema_overrides = upper_overrides.get(key, {})
|
|
117
|
+
decided: dict[KeyField, bool] = {}
|
|
118
|
+
for field in KEY_FIELDS:
|
|
119
|
+
if field in schema_overrides:
|
|
120
|
+
decided[field] = schema_overrides[field]
|
|
121
|
+
continue
|
|
122
|
+
ratio = schema_ratios.get(field)
|
|
123
|
+
decided[field] = ratio is not None and ratio > cutoff
|
|
124
|
+
table[key] = decided
|
|
125
|
+
return table
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def is_informative(table: InformativeKeyTable | None, *, schema: str, field: KeyField) -> bool:
|
|
129
|
+
"""Return whether ``field`` is an Informative Key in ``schema``.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
table: The run's table, or ``None`` when the caller supplied none.
|
|
133
|
+
schema: Source schema of the record.
|
|
134
|
+
field: Key field.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
``True`` unless the table says the field is not informative in that schema.
|
|
138
|
+
"""
|
|
139
|
+
if table is None:
|
|
140
|
+
return True
|
|
141
|
+
schema_table = _schema_entry(table, schema)
|
|
142
|
+
if schema_table is None or field not in schema_table:
|
|
143
|
+
return True
|
|
144
|
+
return bool(schema_table[field])
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _schema_entry(table: InformativeKeyTable, schema: str) -> Mapping[KeyField, bool] | None:
|
|
148
|
+
"""Look up a schema case-insensitively."""
|
|
149
|
+
if schema in table:
|
|
150
|
+
return table[schema]
|
|
151
|
+
upper = schema.strip().upper()
|
|
152
|
+
for candidate, entry in table.items():
|
|
153
|
+
if candidate.strip().upper() == upper:
|
|
154
|
+
return entry
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def key_values(entity: Mapping[str, Any], field: KeyField) -> set[str]:
|
|
159
|
+
"""Return the comparable values of one key field for one record.
|
|
160
|
+
|
|
161
|
+
Values are compared exactly after light cleaning (lowercase, trimmed, whitespace
|
|
162
|
+
collapsed); an address needs a street line and becomes ``street|city|state|zip``.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
entity: A cleaned entity row.
|
|
166
|
+
field: Key field.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
The record's values for the field; empty when it has none.
|
|
170
|
+
"""
|
|
171
|
+
if field == "name":
|
|
172
|
+
name = clean_text_scalar(entity.get("name"))
|
|
173
|
+
return {name} if name else set()
|
|
174
|
+
if field == "phone":
|
|
175
|
+
return set(clean_string_list(entity.get("phones")))
|
|
176
|
+
if field == "email":
|
|
177
|
+
return set(clean_string_list(entity.get("emails")))
|
|
178
|
+
if field == "website":
|
|
179
|
+
return set(clean_string_list(entity.get("websites")))
|
|
180
|
+
return set(_address_values(entity.get("locations")))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def filtered_key_values(
|
|
184
|
+
entity: Mapping[str, Any], field: KeyField, key_value_filter: KeyValueFilter | None
|
|
185
|
+
) -> set[str]:
|
|
186
|
+
"""Return a record's key values after the caller's optional filter.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
entity: A cleaned entity row.
|
|
190
|
+
field: Key field.
|
|
191
|
+
key_value_filter: The caller's filter, or ``None``.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The values that may admit a pair; never more than :func:`key_values` returns.
|
|
195
|
+
"""
|
|
196
|
+
values = key_values(entity, field)
|
|
197
|
+
if key_value_filter is None or not values:
|
|
198
|
+
return values
|
|
199
|
+
return values & key_value_filter(entity, field, set(values))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _address_values(locations_value: Any) -> list[str]:
|
|
203
|
+
"""Build exact-address tokens, requiring a street line."""
|
|
204
|
+
if not isinstance(locations_value, list):
|
|
205
|
+
return []
|
|
206
|
+
output: list[str] = []
|
|
207
|
+
for location in locations_value:
|
|
208
|
+
if not isinstance(location, dict):
|
|
209
|
+
continue
|
|
210
|
+
street = _normalize_address_component(
|
|
211
|
+
_first_present(location, ("address_1", "address1", "line1", "street", "address"))
|
|
212
|
+
)
|
|
213
|
+
if not street:
|
|
214
|
+
continue
|
|
215
|
+
parts = [
|
|
216
|
+
street,
|
|
217
|
+
_normalize_address_component(_first_present(location, ("city",))),
|
|
218
|
+
_normalize_address_component(_first_present(location, ("state",))),
|
|
219
|
+
_normalize_address_component(
|
|
220
|
+
_first_present(location, ("postal_code", "postal", "zip", "zipcode"))
|
|
221
|
+
),
|
|
222
|
+
]
|
|
223
|
+
output.append("|".join(part for part in parts if part))
|
|
224
|
+
return clean_string_list(output)
|
|
@@ -61,23 +61,6 @@ def extract_contact_domains(*, emails_value: object, websites_value: object) ->
|
|
|
61
61
|
return domains
|
|
62
62
|
|
|
63
63
|
|
|
64
|
-
def extract_gov_website_registered_domains(*, websites_value: object) -> set[str]:
|
|
65
|
-
"""Extract registrable ``.gov`` domains from website values only.
|
|
66
|
-
|
|
67
|
-
This intentionally ignores emails. Candidate generation uses it as a
|
|
68
|
-
conservative URL-only bypass for public-sector services, while ordinary
|
|
69
|
-
email/website domain scoring continues to use ``extract_contact_domains``.
|
|
70
|
-
"""
|
|
71
|
-
domains: set[str] = set()
|
|
72
|
-
for website in clean_string_list(websites_value):
|
|
73
|
-
if "@" in website or website.startswith("mailto:"):
|
|
74
|
-
continue
|
|
75
|
-
token = _parse_domain_token(website, is_email=False)
|
|
76
|
-
if token is not None and token.registered_domain.endswith(".gov"):
|
|
77
|
-
domains.add(token.registered_domain)
|
|
78
|
-
return domains
|
|
79
|
-
|
|
80
|
-
|
|
81
64
|
def domain_overlap_score(
|
|
82
65
|
*,
|
|
83
66
|
left_emails: object,
|