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.
Files changed (54) hide show
  1. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/PKG-INFO +42 -4
  2. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/README.pypi.md +41 -3
  3. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/pyproject.toml +1 -1
  4. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/config/entity_resolution_run_config.py +55 -74
  5. hsds_entity_resolution-2.2.0/src/hsds_entity_resolution/core/admission.py +224 -0
  6. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/domain_utils.py +0 -17
  7. hsds_entity_resolution-2.2.0/src/hsds_entity_resolution/core/generate_candidates.py +987 -0
  8. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/generate_candidates_sharded.py +21 -4
  9. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/pipeline.py +33 -0
  10. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/source_policy.py +6 -69
  11. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/taxonomy_utils.py +0 -15
  12. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/state.py +21 -9
  13. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/__init__.py +2 -0
  14. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/contracts.py +12 -2
  15. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/frames.py +12 -0
  16. hsds_entity_resolution-2.0.0/src/hsds_entity_resolution/core/generate_candidates.py +0 -1354
  17. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/.gitignore +0 -0
  18. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/__init__.py +0 -0
  19. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/config/__init__.py +0 -0
  20. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/__init__.py +0 -0
  21. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/apply_mitigation.py +0 -0
  22. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/clean_entities.py +0 -0
  23. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/cluster_pairs.py +0 -0
  24. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/dataframe_utils.py +0 -0
  25. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/evidence_policy.py +0 -0
  26. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/materialize_review_queue.py +0 -0
  27. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/__init__.py +0 -0
  28. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/algorithms.py +0 -0
  29. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/safeguards.py +0 -0
  30. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/scoring.py +0 -0
  31. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/nlp/types.py +0 -0
  32. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/pair_tiering.py +0 -0
  33. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/prepare_persistence_artifacts.py +0 -0
  34. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/score_candidates.py +0 -0
  35. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/core/score_candidates_sharded.py +0 -0
  36. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/dagster/__init__.py +0 -0
  37. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/dagster/components/__init__.py +0 -0
  38. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/dagster/components/entity_resolution_component.py +0 -0
  39. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/dagster/definitions.py +0 -0
  40. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/defs/__init__.py +0 -0
  41. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/__init__.py +0 -0
  42. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/answers.py +0 -0
  43. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/protocol.py +0 -0
  44. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/reference.py +0 -0
  45. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/judge/stage.py +0 -0
  46. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/normalize.py +0 -0
  47. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/observability/__init__.py +0 -0
  48. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/observability/progress.py +0 -0
  49. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/observability/tracer.py +0 -0
  50. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/py.typed +0 -0
  51. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/artifact_rows.py +0 -0
  52. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/domain.py +0 -0
  53. {hsds_entity_resolution-2.0.0 → hsds_entity_resolution-2.2.0}/src/hsds_entity_resolution/types/rows/__init__.py +0 -0
  54. {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.0.0
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** | Block on overlap signals (email, phone, domain, taxonomy, location) to produce candidate pairs |
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` | Minimum embedding cosine similarity for blocking |
215
- | `blocking.max_candidates_per_entity` | `50` | `125` | Maximum candidate pairs per entity |
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** | Block on overlap signals (email, phone, domain, taxonomy, location) to produce candidate pairs |
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` | Minimum embedding cosine similarity for blocking |
191
- | `blocking.max_candidates_per_entity` | `50` | `125` | Maximum candidate pairs per entity |
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.0.0"
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 blocking and fanout controls."""
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
- @field_validator("overlap_prefilter_channels")
97
+ @model_validator(mode="before")
77
98
  @classmethod
78
- def validate_overlap_prefilter_channels(cls, values: list[str]) -> list[str]:
79
- """Validate overlap prefilter channel selections."""
80
- normalized: list[str] = []
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 default contact-overlap pairs retained per anchor. "
131
- "Unset preserves prior uncapped contact-overlap expansion."
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 inverted-index values that map to more than this many "
140
- "entities (deterministic sorted keep). Unset preserves prior uncapped fanout."
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
- """Generic source-aware policy extension supplied by host applications."""
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 [*self.admission_rules, *self.pair_rules]:
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,