hsds-entity-resolution 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. hsds_entity_resolution/__init__.py +5 -0
  2. hsds_entity_resolution/config/__init__.py +31 -0
  3. hsds_entity_resolution/config/entity_resolution_run_config.py +552 -0
  4. hsds_entity_resolution/core/__init__.py +5 -0
  5. hsds_entity_resolution/core/apply_mitigation.py +647 -0
  6. hsds_entity_resolution/core/clean_entities.py +431 -0
  7. hsds_entity_resolution/core/cluster_pairs.py +384 -0
  8. hsds_entity_resolution/core/dataframe_utils.py +88 -0
  9. hsds_entity_resolution/core/domain_utils.py +248 -0
  10. hsds_entity_resolution/core/evidence_policy.py +36 -0
  11. hsds_entity_resolution/core/generate_candidates.py +1354 -0
  12. hsds_entity_resolution/core/generate_candidates_sharded.py +142 -0
  13. hsds_entity_resolution/core/materialize_review_queue.py +125 -0
  14. hsds_entity_resolution/core/nlp/__init__.py +6 -0
  15. hsds_entity_resolution/core/nlp/algorithms.py +83 -0
  16. hsds_entity_resolution/core/nlp/safeguards.py +41 -0
  17. hsds_entity_resolution/core/nlp/scoring.py +43 -0
  18. hsds_entity_resolution/core/nlp/types.py +24 -0
  19. hsds_entity_resolution/core/pair_tiering.py +54 -0
  20. hsds_entity_resolution/core/pipeline.py +659 -0
  21. hsds_entity_resolution/core/prepare_persistence_artifacts.py +106 -0
  22. hsds_entity_resolution/core/score_candidates.py +1238 -0
  23. hsds_entity_resolution/core/score_candidates_sharded.py +182 -0
  24. hsds_entity_resolution/core/source_policy.py +257 -0
  25. hsds_entity_resolution/core/taxonomy_utils.py +249 -0
  26. hsds_entity_resolution/dagster/__init__.py +5 -0
  27. hsds_entity_resolution/dagster/components/__init__.py +7 -0
  28. hsds_entity_resolution/dagster/components/entity_resolution_component.py +212 -0
  29. hsds_entity_resolution/dagster/definitions.py +11 -0
  30. hsds_entity_resolution/defs/__init__.py +1 -0
  31. hsds_entity_resolution/judge/__init__.py +52 -0
  32. hsds_entity_resolution/judge/answers.py +137 -0
  33. hsds_entity_resolution/judge/protocol.py +44 -0
  34. hsds_entity_resolution/judge/reference.py +50 -0
  35. hsds_entity_resolution/judge/stage.py +130 -0
  36. hsds_entity_resolution/judge/state.py +281 -0
  37. hsds_entity_resolution/normalize.py +153 -0
  38. hsds_entity_resolution/observability/__init__.py +6 -0
  39. hsds_entity_resolution/observability/progress.py +133 -0
  40. hsds_entity_resolution/observability/tracer.py +152 -0
  41. hsds_entity_resolution/py.typed +0 -0
  42. hsds_entity_resolution/types/__init__.py +69 -0
  43. hsds_entity_resolution/types/artifact_rows.py +64 -0
  44. hsds_entity_resolution/types/contracts.py +105 -0
  45. hsds_entity_resolution/types/domain.py +15 -0
  46. hsds_entity_resolution/types/frames.py +251 -0
  47. hsds_entity_resolution/types/rows/__init__.py +17 -0
  48. hsds_entity_resolution/types/rows/clean_entity_rows.py +85 -0
  49. hsds_entity_resolution-2.0.0.dist-info/METADATA +306 -0
  50. hsds_entity_resolution-2.0.0.dist-info/RECORD +53 -0
  51. hsds_entity_resolution-2.0.0.dist-info/WHEEL +4 -0
  52. hsds_entity_resolution-2.0.0.dist-info/entry_points.txt +2 -0
  53. pyproject.toml +92 -0
@@ -0,0 +1,5 @@
1
+ """Top-level package exports for hsds_entity_resolution."""
2
+
3
+ from hsds_entity_resolution.dagster.components import EntityResolutionComponent
4
+
5
+ __all__ = ["EntityResolutionComponent"]
@@ -0,0 +1,31 @@
1
+ """Configuration models and schema for entity resolution components."""
2
+
3
+ from hsds_entity_resolution.config.entity_resolution_run_config import (
4
+ BlockingConfig,
5
+ ChunkingConfig,
6
+ ClusteringConfig,
7
+ EntityResolutionRunConfig,
8
+ ExecutionConfig,
9
+ FeatureOverrideConfig,
10
+ MetadataConfig,
11
+ MitigationConfig,
12
+ PairRuleConfig,
13
+ ScoringConfig,
14
+ SourcePolicyConfig,
15
+ SourceProfileConfig,
16
+ )
17
+
18
+ __all__ = [
19
+ "BlockingConfig",
20
+ "ChunkingConfig",
21
+ "ClusteringConfig",
22
+ "EntityResolutionRunConfig",
23
+ "ExecutionConfig",
24
+ "FeatureOverrideConfig",
25
+ "MetadataConfig",
26
+ "MitigationConfig",
27
+ "PairRuleConfig",
28
+ "ScoringConfig",
29
+ "SourcePolicyConfig",
30
+ "SourceProfileConfig",
31
+ ]
@@ -0,0 +1,552 @@
1
+ """Centralized run configuration for entity-resolution stages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
8
+
9
+ from hsds_entity_resolution.types.domain import EntityType
10
+
11
+ _SUPPORTED_BLOCKING_OVERLAP_CHANNELS = {
12
+ "email",
13
+ "phone",
14
+ "website",
15
+ "taxonomy",
16
+ "location",
17
+ "address_exact",
18
+ }
19
+
20
+ _ML_SECTION_REMOVED_MESSAGE = (
21
+ "The ML scoring section was removed in hsds-entity-resolution 1.2.0: the score is "
22
+ "deterministic plus NLP only, and the shadow/legacy calibration scores are gone. "
23
+ "Remove {keys} from this config; deterministic_section_weight and nlp_section_weight "
24
+ "must now sum to 1.0."
25
+ )
26
+
27
+
28
+ def _reject_removed_ml_keys(data: Any, *, removed: tuple[str, ...]) -> Any:
29
+ """Raise a migration error when a config still carries removed ML 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(_ML_SECTION_REMOVED_MESSAGE.format(keys=", ".join(present)))
43
+ return data
44
+
45
+
46
+ _SUPPORTED_SIGNAL_NAMES = {
47
+ "shared_email",
48
+ "shared_phone",
49
+ "shared_domain",
50
+ "shared_taxonomy",
51
+ "shared_address",
52
+ "address_plus_taxonomy",
53
+ "address_plus_taxonomy_plus_contact",
54
+ "shared_identifier",
55
+ "name_similarity",
56
+ "organization_name_similarity",
57
+ }
58
+
59
+
60
+ class BaseStrictModel(BaseModel):
61
+ """Shared strict pydantic model behavior."""
62
+
63
+ model_config = ConfigDict(extra="forbid")
64
+
65
+
66
+ class BlockingConfig(BaseStrictModel):
67
+ """Candidate blocking and fanout controls."""
68
+
69
+ similarity_threshold: float = Field(default=0.75, ge=0.0, le=1.0)
70
+ max_candidates_per_entity: int = Field(default=50, ge=1, le=500)
71
+ 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
+
76
+ @field_validator("overlap_prefilter_channels")
77
+ @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
97
+
98
+
99
+ class ChunkingConfig(BaseStrictModel):
100
+ """Memory-bounded chunking controls for monolithic (single-shard) ER stages.
101
+
102
+ Defaults keep historical unbounded behavior: unset values mean "no chunking"
103
+ and "no contact-overlap caps". Consumers enable knobs explicitly when large
104
+ explicit-backfill jobs must stay within a fixed pod memory budget.
105
+ """
106
+
107
+ generate_anchor_chunk_size: int | None = Field(
108
+ default=None,
109
+ ge=1,
110
+ le=500_000,
111
+ description=(
112
+ "When set, process generate-candidate anchors in batches of this size "
113
+ "instead of one monolithic loop."
114
+ ),
115
+ )
116
+ score_candidate_chunk_size: int | None = Field(
117
+ default=None,
118
+ ge=1,
119
+ le=500_000,
120
+ description=(
121
+ "When set, score candidate pairs in batches of this size to bound peak "
122
+ "memory from full-frame materialization."
123
+ ),
124
+ )
125
+ max_contact_overlap_pairs_per_anchor: int | None = Field(
126
+ default=None,
127
+ ge=1,
128
+ le=100_000,
129
+ description=(
130
+ "When set, cap default contact-overlap pairs retained per anchor. "
131
+ "Unset preserves prior uncapped contact-overlap expansion."
132
+ ),
133
+ )
134
+ max_contact_index_fanout: int | None = Field(
135
+ default=None,
136
+ ge=1,
137
+ le=100_000,
138
+ 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."
141
+ ),
142
+ )
143
+
144
+
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
+ class DeterministicSignalConfig(BaseStrictModel):
179
+ """Configuration for one deterministic overlap signal."""
180
+
181
+ enabled: bool = True
182
+ weight: float = Field(default=0.2, ge=0.0, le=0.6)
183
+
184
+
185
+ class DeterministicConfig(BaseStrictModel):
186
+ """Deterministic scoring controls."""
187
+
188
+ shared_email: DeterministicSignalConfig
189
+ shared_phone: DeterministicSignalConfig
190
+ shared_domain: DeterministicSignalConfig
191
+ shared_taxonomy: DeterministicSignalConfig
192
+ shared_address: DeterministicSignalConfig
193
+ address_plus_taxonomy: DeterministicSignalConfig = Field(
194
+ default_factory=lambda: DeterministicSignalConfig(enabled=False, weight=0.0)
195
+ )
196
+ address_plus_taxonomy_plus_contact: DeterministicSignalConfig = Field(
197
+ default_factory=lambda: DeterministicSignalConfig(enabled=False, weight=0.0)
198
+ )
199
+ shared_identifier: DeterministicSignalConfig
200
+ organization_name_similarity: DeterministicSignalConfig = Field(
201
+ default_factory=lambda: DeterministicSignalConfig(enabled=False, weight=0.0)
202
+ )
203
+
204
+
205
+ class NlpConfig(BaseStrictModel):
206
+ """Name/description fuzzy matching controls."""
207
+
208
+ fuzzy_algorithm: str = "sequence_matcher"
209
+ fuzzy_threshold: float = Field(default=0.88, ge=0.6, le=0.98)
210
+ number_mismatch_veto_enabled: bool = True
211
+ standalone_fuzzy_threshold: float = Field(default=0.94, ge=0.7, le=0.99)
212
+
213
+
214
+ class SignalSuppressionConfig(BaseStrictModel):
215
+ """Suppress one signal when all configured trigger signals contributed."""
216
+
217
+ signal: str
218
+ when_all_present: list[str]
219
+
220
+ @model_validator(mode="after")
221
+ def validate_signal_names(self) -> SignalSuppressionConfig:
222
+ """Validate signal identifiers used by suppression rules."""
223
+ names = [self.signal, *self.when_all_present]
224
+ unsupported = sorted(set(names).difference(_SUPPORTED_SIGNAL_NAMES))
225
+ if unsupported:
226
+ message = f"Unsupported signal suppression names: {unsupported!r}"
227
+ raise ValueError(message)
228
+ if not self.when_all_present:
229
+ message = "Signal suppression requires at least one trigger signal"
230
+ raise ValueError(message)
231
+ return self
232
+
233
+
234
+ class FeatureOverrideConfig(BaseStrictModel):
235
+ """Pair-rule scoring overrides applied on top of entity-type defaults."""
236
+
237
+ deterministic: dict[str, DeterministicSignalConfig] = Field(default_factory=dict)
238
+ nlp_enabled: bool | None = None
239
+ deterministic_section_weight: float | None = Field(default=None, ge=0.0, le=1.0)
240
+ nlp_section_weight: float | None = Field(default=None, ge=0.0, le=1.0)
241
+ duplicate_threshold: float | None = Field(default=None, ge=0.5, le=0.99)
242
+ maybe_threshold: float | None = Field(default=None, ge=0.3, le=0.95)
243
+ low_maybe_threshold: float | None = Field(default=None, ge=0.2, le=0.9)
244
+ min_review_embedding_similarity: float | None = Field(default=None, ge=0.0, le=1.0)
245
+ min_duplicate_embedding_similarity: float | None = Field(default=None, ge=0.0, le=1.0)
246
+ embedding_floor_exempt_signals: list[str] = Field(default_factory=list)
247
+ review_on_signals: list[str] = Field(default_factory=list)
248
+ suppressions: list[SignalSuppressionConfig] = Field(default_factory=list)
249
+
250
+ @model_validator(mode="before")
251
+ @classmethod
252
+ def reject_removed_ml_overrides(cls, data: Any) -> Any:
253
+ """Fail with a migration message when removed ML override keys are passed."""
254
+ return _reject_removed_ml_keys(data, removed=("ml_gate_threshold", "ml_section_weight"))
255
+
256
+ @model_validator(mode="after")
257
+ def validate_feature_overrides(self) -> FeatureOverrideConfig:
258
+ """Validate override keys and threshold ordering when both are present."""
259
+ unsupported = sorted(set(self.deterministic).difference(_SUPPORTED_SIGNAL_NAMES))
260
+ if unsupported:
261
+ message = f"Unsupported deterministic override names: {unsupported!r}"
262
+ raise ValueError(message)
263
+ non_deterministic = sorted(set(self.deterministic).intersection({"name_similarity"}))
264
+ if non_deterministic:
265
+ message = (
266
+ "Non-deterministic signals cannot use deterministic overrides: "
267
+ f"{non_deterministic!r}"
268
+ )
269
+ raise ValueError(message)
270
+ unsupported_exemptions = sorted(
271
+ set(self.embedding_floor_exempt_signals).difference(_SUPPORTED_SIGNAL_NAMES)
272
+ )
273
+ if unsupported_exemptions:
274
+ message = (
275
+ f"Unsupported embedding floor exemption signal names: {unsupported_exemptions!r}"
276
+ )
277
+ raise ValueError(message)
278
+ unsupported_review_overrides = sorted(
279
+ set(self.review_on_signals).difference(_SUPPORTED_SIGNAL_NAMES)
280
+ )
281
+ if unsupported_review_overrides:
282
+ message = (
283
+ f"Unsupported review_on_signals signal names: {unsupported_review_overrides!r}"
284
+ )
285
+ raise ValueError(message)
286
+ if (
287
+ self.duplicate_threshold is not None
288
+ and self.maybe_threshold is not None
289
+ and self.duplicate_threshold <= self.maybe_threshold
290
+ ):
291
+ message = "duplicate_threshold must be strictly greater than maybe_threshold"
292
+ raise ValueError(message)
293
+ if (
294
+ self.maybe_threshold is not None
295
+ and self.low_maybe_threshold is not None
296
+ and self.maybe_threshold <= self.low_maybe_threshold
297
+ ):
298
+ message = "maybe_threshold must be strictly greater than low_maybe_threshold"
299
+ raise ValueError(message)
300
+ if (
301
+ self.min_review_embedding_similarity is not None
302
+ and self.min_duplicate_embedding_similarity is not None
303
+ and self.min_duplicate_embedding_similarity < self.min_review_embedding_similarity
304
+ ):
305
+ message = (
306
+ "min_duplicate_embedding_similarity must be greater than or equal to "
307
+ "min_review_embedding_similarity"
308
+ )
309
+ raise ValueError(message)
310
+ section_values = [self.deterministic_section_weight, self.nlp_section_weight]
311
+ if any(value is not None for value in section_values):
312
+ if not all(value is not None for value in section_values):
313
+ message = "Section weight overrides must set both section weights together"
314
+ raise ValueError(message)
315
+ total = sum(float(value) for value in section_values if value is not None)
316
+ if abs(total - 1.0) > 0.001:
317
+ message = "Section weights must sum to 1.0 +/- 0.001"
318
+ raise ValueError(message)
319
+ return self
320
+
321
+
322
+ class PairRuleConfig(BaseStrictModel):
323
+ """Generic pair policy selected from source relation/profile metadata."""
324
+
325
+ rule_id: str
326
+ entity_types: list[EntityType] = Field(default_factory=lambda: ["organization", "service"])
327
+ source_relation: Literal[
328
+ "any",
329
+ "same_source",
330
+ "cross_source",
331
+ "same_profile",
332
+ "cross_source_same_profile",
333
+ "cross_profile",
334
+ ] = "any"
335
+ source_profiles: list[str] = Field(default_factory=list)
336
+ feature_overrides: FeatureOverrideConfig = Field(default_factory=FeatureOverrideConfig)
337
+
338
+
339
+ class SourceProfileConfig(BaseStrictModel):
340
+ """Host-assigned abstract source profile membership."""
341
+
342
+ source_schemas: list[str] = Field(default_factory=list)
343
+
344
+ @field_validator("source_schemas")
345
+ @classmethod
346
+ def normalize_source_schemas(cls, values: list[str]) -> list[str]:
347
+ """Normalize source-schema names for case-insensitive matching."""
348
+ normalized = [value.strip().upper() for value in values if value.strip()]
349
+ return list(dict.fromkeys(normalized))
350
+
351
+
352
+ class SourcePolicyConfig(BaseStrictModel):
353
+ """Generic source-aware policy extension supplied by host applications."""
354
+
355
+ source_profiles: dict[str, SourceProfileConfig] = Field(default_factory=dict)
356
+ admission_rules: list[AdmissionRuleConfig] = Field(default_factory=list)
357
+ pair_rules: list[PairRuleConfig] = Field(default_factory=list)
358
+
359
+ @model_validator(mode="after")
360
+ def validate_source_policy(self) -> SourcePolicyConfig:
361
+ """Validate rule references against configured source profiles."""
362
+ profile_ids = set(self.source_profiles)
363
+ referenced: set[str] = set()
364
+ for rule in [*self.admission_rules, *self.pair_rules]:
365
+ referenced.update(rule.source_profiles)
366
+ unknown = sorted(referenced.difference(profile_ids))
367
+ if unknown:
368
+ message = f"Unknown source profile references: {unknown!r}"
369
+ raise ValueError(message)
370
+ return self
371
+
372
+
373
+ class ScoringConfig(BaseStrictModel):
374
+ """Top-level scoring constants for one run scope."""
375
+
376
+ deterministic_section_weight: float = Field(default=0.5625, ge=0.0, le=1.0)
377
+ nlp_section_weight: float = Field(default=0.4375, ge=0.0, le=1.0)
378
+ duplicate_threshold: float = Field(default=0.82, ge=0.5, le=0.99)
379
+ maybe_threshold: float = Field(default=0.68, ge=0.3, le=0.95)
380
+ low_maybe_threshold: float = Field(default=0.58, ge=0.2, le=0.9)
381
+ min_reason_count_for_keep: int = Field(default=1, ge=0, le=5)
382
+ deterministic: DeterministicConfig
383
+ nlp: NlpConfig
384
+
385
+ @model_validator(mode="before")
386
+ @classmethod
387
+ def reject_removed_ml_scoring(cls, data: Any) -> Any:
388
+ """Fail with a migration message when removed ML or calibration keys are passed."""
389
+ return _reject_removed_ml_keys(data, removed=("calibration", "ml", "ml_section_weight"))
390
+
391
+ @model_validator(mode="after")
392
+ def validate_weighting_rules(self) -> ScoringConfig:
393
+ """Validate cross-field constraints required by the RFC."""
394
+ total = self.deterministic_section_weight + self.nlp_section_weight
395
+ if abs(total - 1.0) > 0.001:
396
+ message = "Section weights must sum to 1.0 +/- 0.001"
397
+ raise ValueError(message)
398
+ if self.duplicate_threshold <= self.maybe_threshold:
399
+ message = "duplicate_threshold must be strictly greater than maybe_threshold"
400
+ raise ValueError(message)
401
+ if self.maybe_threshold <= self.low_maybe_threshold:
402
+ message = "maybe_threshold must be strictly greater than low_maybe_threshold"
403
+ raise ValueError(message)
404
+ return self
405
+
406
+
407
+ class MitigationConfig(BaseStrictModel):
408
+ """Mitigation stage controls and thresholds."""
409
+
410
+ enabled: bool = False
411
+ min_embedding_similarity: float = Field(default=0.65, ge=0.0, le=1.0)
412
+ require_reason_match: bool = True
413
+
414
+
415
+ class ClusteringConfig(BaseStrictModel):
416
+ """Correlation clustering solver controls."""
417
+
418
+ algorithm: str = "correlative_greedy_v1"
419
+ max_iter: int = Field(default=20, ge=1, le=500)
420
+ min_edge_weight: float = Field(default=0.0, ge=-1.0, le=1.0)
421
+ min_cluster_size: int = Field(default=2, ge=2, le=5000)
422
+
423
+
424
+ class ExecutionConfig(BaseStrictModel):
425
+ """Execution behavior controls."""
426
+
427
+ strict_validation_mode: bool = True
428
+ emit_removals_only: bool = True
429
+
430
+
431
+ class MetadataConfig(BaseStrictModel):
432
+ """Run metadata and version identity."""
433
+
434
+ team_id: str
435
+ scope_id: str
436
+ entity_type: EntityType
437
+ policy_version: str = "hsds-er-v1"
438
+ model_version: str = "embedding-only-v1"
439
+
440
+
441
+ class EntityResolutionRunConfig(BaseStrictModel):
442
+ """Resolved centralized constants used across all stages in one run."""
443
+
444
+ blocking: BlockingConfig
445
+ scoring: ScoringConfig
446
+ mitigation: MitigationConfig
447
+ clustering: ClusteringConfig
448
+ execution: ExecutionConfig
449
+ metadata: MetadataConfig
450
+ source_policy: SourcePolicyConfig = Field(default_factory=SourcePolicyConfig)
451
+ chunking: ChunkingConfig = Field(default_factory=ChunkingConfig)
452
+
453
+ @classmethod
454
+ def defaults_for_entity_type(
455
+ cls,
456
+ *,
457
+ team_id: str,
458
+ scope_id: str,
459
+ entity_type: EntityType,
460
+ policy_version: str = "hsds-er-v1",
461
+ model_version: str = "embedding-only-v1",
462
+ ) -> EntityResolutionRunConfig:
463
+ """Build RFC-aligned defaults for organization or service scope."""
464
+ blocking = BlockingConfig(max_candidates_per_entity=125 if entity_type == "service" else 50)
465
+ deterministic_weights = _build_deterministic_defaults(entity_type=entity_type)
466
+ scoring_values = _build_scoring_defaults(entity_type=entity_type)
467
+ return cls(
468
+ blocking=blocking,
469
+ scoring=ScoringConfig(
470
+ deterministic=deterministic_weights,
471
+ nlp=NlpConfig(
472
+ fuzzy_threshold=scoring_values["fuzzy_threshold"],
473
+ standalone_fuzzy_threshold=scoring_values["standalone_fuzzy_threshold"],
474
+ ),
475
+ deterministic_section_weight=scoring_values["deterministic_section_weight"],
476
+ nlp_section_weight=scoring_values["nlp_section_weight"],
477
+ duplicate_threshold=scoring_values["duplicate_threshold"],
478
+ maybe_threshold=scoring_values["maybe_threshold"],
479
+ low_maybe_threshold=scoring_values["low_maybe_threshold"],
480
+ min_reason_count_for_keep=1,
481
+ ),
482
+ mitigation=MitigationConfig(),
483
+ clustering=ClusteringConfig(),
484
+ execution=ExecutionConfig(),
485
+ metadata=MetadataConfig(
486
+ team_id=team_id,
487
+ scope_id=scope_id,
488
+ entity_type=entity_type,
489
+ policy_version=policy_version,
490
+ model_version=model_version,
491
+ ),
492
+ chunking=ChunkingConfig(),
493
+ )
494
+
495
+
496
+ def _build_deterministic_defaults(*, entity_type: EntityType) -> DeterministicConfig:
497
+ """Return per-entity-type deterministic signal defaults."""
498
+ if entity_type == "organization":
499
+ return DeterministicConfig(
500
+ shared_email=DeterministicSignalConfig(weight=0.22),
501
+ shared_phone=DeterministicSignalConfig(weight=0.20),
502
+ shared_domain=DeterministicSignalConfig(weight=0.06),
503
+ shared_taxonomy=DeterministicSignalConfig(weight=0.08),
504
+ shared_address=DeterministicSignalConfig(weight=0.25),
505
+ address_plus_taxonomy=DeterministicSignalConfig(enabled=False, weight=0.0),
506
+ address_plus_taxonomy_plus_contact=DeterministicSignalConfig(enabled=False, weight=0.0),
507
+ shared_identifier=DeterministicSignalConfig(weight=0.25),
508
+ organization_name_similarity=DeterministicSignalConfig(enabled=False, weight=0.0),
509
+ )
510
+ return DeterministicConfig(
511
+ shared_email=DeterministicSignalConfig(weight=0.16),
512
+ shared_phone=DeterministicSignalConfig(weight=0.22),
513
+ shared_domain=DeterministicSignalConfig(weight=0.04),
514
+ shared_taxonomy=DeterministicSignalConfig(weight=0.12),
515
+ shared_address=DeterministicSignalConfig(weight=0.34),
516
+ address_plus_taxonomy=DeterministicSignalConfig(enabled=False, weight=0.0),
517
+ address_plus_taxonomy_plus_contact=DeterministicSignalConfig(enabled=False, weight=0.0),
518
+ shared_identifier=DeterministicSignalConfig(enabled=False, weight=0.0),
519
+ organization_name_similarity=DeterministicSignalConfig(enabled=False, weight=0.0),
520
+ )
521
+
522
+
523
+ def _build_scoring_defaults(*, entity_type: EntityType) -> dict[str, float]:
524
+ """Return scalar scoring defaults aligned with RFC baseline table.
525
+
526
+ Section weights are the pre-1.2.0 deterministic/NLP weights renormalised over the
527
+ two sections that were ever active (organization 0.45/0.35, service 0.40/0.40), so
528
+ every score computed without the removed ML section is reproduced exactly.
529
+ """
530
+ if entity_type == "organization":
531
+ return {
532
+ "deterministic_section_weight": 0.5625,
533
+ "nlp_section_weight": 0.4375,
534
+ "fuzzy_threshold": 0.88,
535
+ "standalone_fuzzy_threshold": 0.94,
536
+ "duplicate_threshold": 0.82,
537
+ "maybe_threshold": 0.68,
538
+ "low_maybe_threshold": 0.58,
539
+ }
540
+ # Service thresholds bracket the two observed score clusters:
541
+ # phone + name only: score ≈ 0.665 → needs human review
542
+ # phone + name + address: score ≈ 0.733 → high-confidence, auto-cluster
543
+ # duplicate_threshold = 0.70, maybe_threshold = 0.62
544
+ return {
545
+ "deterministic_section_weight": 0.5,
546
+ "nlp_section_weight": 0.5,
547
+ "fuzzy_threshold": 0.86,
548
+ "standalone_fuzzy_threshold": 0.92,
549
+ "duplicate_threshold": 0.70,
550
+ "maybe_threshold": 0.62,
551
+ "low_maybe_threshold": 0.54,
552
+ }
@@ -0,0 +1,5 @@
1
+ """Core entity resolution algorithms and orchestration logic."""
2
+
3
+ from hsds_entity_resolution.core.pipeline import run_incremental
4
+
5
+ __all__ = ["run_incremental"]