diff --git a/AGENTS.md b/AGENTS.md index f18919507..28e61d946 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,13 @@ Opening a cutoff-rewritten title shows **Body this run knew** from `source_post_revision` beside the live rewrite (ADR 0025 / v2.1.0). Do not invent the earlier sentence when no revision covers the cutoff. +A corporate-entity similarity result has three outcomes: unique, miss, +or tie (ADR 0026). A tie is not a miss. Keep the organization name +unbound and do not create an `AUTO-` catalog row, even when live name +resolution, hierarchy inference, and verification are available. Keyman +must test the raw organization name before any abbreviation rewrite so a +rewrite cannot turn an existing tie into an apparent creation miss. + ## CI gates `.github/workflows/tests.yml` runs the full suite on every PR to `main`. diff --git a/CHANGELOG.d/tied-organization-no-auto-create.md b/CHANGELOG.d/tied-organization-no-auto-create.md new file mode 100644 index 000000000..64842f48e --- /dev/null +++ b/CHANGELOG.d/tied-organization-no-auto-create.md @@ -0,0 +1,6 @@ +# Tied organization names do not create catalog rows + +A tied top organization similarity score now stays unbound. Even with live +name resolution, hierarchy inference, and verification, the ingestion path +does not insert an `AUTO-` catalog row. Only a genuine below-threshold miss +may enter the corroborated creation path (ADR 0026). diff --git a/backend/app/corporate_entity_ingestion.py b/backend/app/corporate_entity_ingestion.py index 57baadc5e..cb1d3e1f1 100644 --- a/backend/app/corporate_entity_ingestion.py +++ b/backend/app/corporate_entity_ingestion.py @@ -1,14 +1,15 @@ - """Resolve an organization mention to the corporate hierarchy catalog. -Existing similarity matches are reused. A previously unseen entity is -created only after inference proposes its complete hierarchy placement -and external verification corroborates that placement. Parent failure, -cycles, and excessive depth all fail closed. See ADR 0010. +Existing unique similarity matches are reused. A tied top score stays +unbound and does not create a row (ADR 0026). A previously unseen entity +-- no candidate at or above the similarity threshold -- is created only +after inference proposes its complete hierarchy placement and external +verification corroborates that placement. Parent failure, cycles, and +excessive depth all fail closed. See ADR 0010. Creation writes take one named Postgres advisory transaction lock (``pg_advisory_xact_lock``) after network inference/verification, then -reload catalog candidates before inserting. See ADR 0012. +reload catalog candidates before inserting. See ADR 0012. """ from __future__ import annotations @@ -23,8 +24,10 @@ HierarchyProposal, ) from lineageweave.corporate_hierarchy_resolution import ( + RESOLUTION_TIE, + RESOLUTION_UNIQUE, CorporateEntityCandidate, - resolve_corporate_entity, + score_corporate_entity, ) from lineageweave.relation_verification import ( STATUS_CORROBORATED, @@ -112,9 +115,13 @@ async def get_or_create_corporate_entity( ) -> str | None: """Return a verified catalog id, otherwise ``None``. - A proposed parent must independently corroborate and resolve before - the child can be inserted. Repeated names in the recursion path are - cycles, including multi-node cycles such as A -> B -> A. + A unique similarity match is reused. A tied top score stays unbound + and does not create a third same-named row (ADR 0026). Only a genuine + miss -- no candidate at or above ``min_similarity`` -- may enter ADR + 0010 inference. A proposed parent must independently corroborate and + resolve before the child can be inserted. Repeated names in the + recursion path are cycles, including multi-node cycles such as + A -> B -> A. """ normalized_name = organization_name.strip() if not normalized_name: @@ -123,9 +130,11 @@ async def get_or_create_corporate_entity( if visit_key in _visited_names: return None - existing_id = resolve_corporate_entity(normalized_name, candidates) - if existing_id is not None: - return existing_id + existing = score_corporate_entity(normalized_name, candidates) + if existing.kind == RESOLUTION_UNIQUE and existing.catalog_id is not None: + return existing.catalog_id + if existing.kind == RESOLUTION_TIE: + return None if _depth >= _MAX_HIERARCHY_DEPTH or not inference_client.available: return None @@ -176,13 +185,15 @@ async def get_or_create_corporate_entity( "select pg_advisory_xact_lock(hashtext($1))", _CREATION_LOCK_KEY, ) - fresh_existing_id = resolve_corporate_entity( + fresh = score_corporate_entity( normalized_name, await _reload_candidates(conn), ) - if fresh_existing_id is not None: - _remember_candidate(candidates, fresh_existing_id, normalized_name) - return fresh_existing_id + if fresh.kind == RESOLUTION_UNIQUE and fresh.catalog_id is not None: + _remember_candidate(candidates, fresh.catalog_id, normalized_name) + return fresh.catalog_id + if fresh.kind == RESOLUTION_TIE: + return None new_id = await _create_entity( conn, normalized_name, diff --git a/backend/app/keyman_ingestion.py b/backend/app/keyman_ingestion.py index 906442ba4..97c81525f 100644 --- a/backend/app/keyman_ingestion.py +++ b/backend/app/keyman_ingestion.py @@ -39,6 +39,11 @@ search-corroborated hierarchy placement (level + parent) before creating a real new row, so the "통합 고객사 계열 tree AI" requirement is actually populated from real extraction, not left permanently empty. + +Tie boundary (ADR 0026): a raw organization name whose distinct catalog +candidates share the top qualifying similarity score stays unbound before +abbreviation rewriting. Live name resolution therefore cannot turn known +ambiguity into an apparent miss and manufacture a third `AUTO-` row. """ from __future__ import annotations @@ -52,7 +57,11 @@ CorporateHierarchyInferenceClient, NullCorporateHierarchyInferenceClient, ) -from lineageweave.corporate_hierarchy_resolution import CorporateEntityCandidate +from lineageweave.corporate_hierarchy_resolution import ( + RESOLUTION_TIE, + CorporateEntityCandidate, + score_corporate_entity, +) from lineageweave.keyman_extraction import KeymanExtractionClient, PersonMention from lineageweave.organization_name_resolution import ( NullOrganizationNameResolutionClient, @@ -113,7 +122,6 @@ async def _upsert_person(conn: asyncpg.Connection, mention: PersonMention) -> st return str(row["person_id"]) - async def _upsert_affiliation( conn: asyncpg.Connection, person_id: str, @@ -166,6 +174,38 @@ async def _upsert_affiliation( ) +async def _resolve_affiliated_organization( + conn: asyncpg.Connection, + organization_name: str, + context_text: str, + resolution_client: OrganizationNameResolutionClient, + verification_client: RelationVerificationClient, + hierarchy_inference_client: CorporateHierarchyInferenceClient, + candidates: list[CorporateEntityCandidate], +) -> tuple[str, str, str | None]: + """Resolve one affiliation without rewriting a known raw-name tie.""" + raw_outcome = score_corporate_entity(organization_name, candidates) + if raw_outcome.kind == RESOLUTION_TIE: + return organization_name, organization_name, None + + resolved_name = await resolve_organization_name( + conn, + resolution_client, + verification_client, + organization_name, + context_text, + ) + corporate_entity_id = await get_or_create_corporate_entity( + conn, + resolved_name, + context_text, + hierarchy_inference_client, + verification_client, + candidates, + ) + return organization_name, resolved_name, corporate_entity_id + + async def ingest_post_keymen( conn: asyncpg.Connection, client: KeymanExtractionClient, @@ -206,22 +246,17 @@ async def ingest_post_keymen( for mention in mentions: resolved_orgs: list[tuple[str, str, str | None]] = [] for organization_name in mention.affiliated_organization_names: - resolved_name = await resolve_organization_name( - conn, - resolution_client, - verification_client, - organization_name, - post_body, - ) - corporate_entity_id = await get_or_create_corporate_entity( - conn, - resolved_name, - post_body, - hierarchy_inference_client, - verification_client, - candidates, + resolved_orgs.append( + await _resolve_affiliated_organization( + conn, + organization_name, + post_body, + resolution_client, + verification_client, + hierarchy_inference_client, + candidates, + ) ) - resolved_orgs.append((organization_name, resolved_name, corporate_entity_id)) resolved_by_mention.append((mention, resolved_orgs)) normalized_mentions: list[PersonMention] = [] diff --git a/docs/adr/0026-tied-organization-similarity.md b/docs/adr/0026-tied-organization-similarity.md new file mode 100644 index 000000000..9225525dc --- /dev/null +++ b/docs/adr/0026-tied-organization-similarity.md @@ -0,0 +1,97 @@ +# ADR 0026 — Tied organization similarity stays unbound + +**Decision status:** Accepted +**Date:** 2026-08-17 + +## Context + +Role-and-responsibility and Keyman ingestion resolve free-text organization +names against `corporate_entity`. The former resolver returned either one +catalog id or `None`. That collapsed two materially different outcomes: + +1. no candidate met the minimum similarity threshold; and +2. two or more distinct catalog ids shared the best score. + +A genuine miss may enter ADR 0010's inference-and-corroboration path. A tie +must not. Treating a tie as a miss can create a third, deterministic +`AUTO-...` row for a name that is already represented by multiple catalog +records. It can also bind whichever homonym happened to appear first in an +unordered candidate result. + +Keyman adds another boundary: it may run a verified abbreviation rewrite +before hierarchy resolution. If a raw tied name is rewritten first, the new +string can appear to be a miss and incorrectly enter the creation path. + +Fellegi and Sunter's record-linkage decision framework retains an uncertain +region rather than forcing a match. In this product, an equal top score is +that review state. String similarity remains candidate generation, not proof +of identity. + +## Decision + +`score_corporate_entity` classifies each organization mention as: + +- `unique`: exactly one distinct catalog id has the top score at or above + the threshold; +- `miss`: no candidate reaches the threshold; or +- `tie`: multiple distinct catalog ids share the top qualifying score. + +Only `unique` returns a catalog id. Only `miss` may continue into ADR 0010 +inference and corroborated creation. `tie` returns unbound immediately. + +The same classification is repeated after the advisory creation lock and +catalog reload. If concurrent writes make the refreshed result a tie, no +insert occurs. + +Keyman evaluates the raw organization name before abbreviation rewriting. +A raw tie bypasses name resolution and hierarchy inference, remains text, +and stores no new catalog id. This prevents a resolver rewrite from turning +known ambiguity into an apparent miss. + +Duplicate candidate rows carrying the same `corporate_entity_id` are one +candidate, not a tie. + +```mermaid +flowchart TD + mention[Organization mention] --> raw[Score raw catalog candidates] + raw --> outcome{Resolution outcome} + outcome -->|unique| bind[Bind unique catalog id] + outcome -->|tie| hold[Keep unbound; no AUTO row] + outcome -->|miss| enrich[Optional verified name resolution] + enrich --> score[Score resolved name] + score --> resolved{Resolution outcome} + resolved -->|unique| bind + resolved -->|tie| hold + resolved -->|miss| create[ADR 0010 infer and corroborate] + create --> lock[Lock and reload candidates] + lock --> refreshed{Refreshed outcome} + refreshed -->|unique| bind + refreshed -->|tie| hold + refreshed -->|miss| insert[Insert AUTO row] +``` + +## Consequences + +- Equal top scores are deterministic and fail closed rather than depending + on row order. +- A tied organization name never creates an `AUTO-` catalog row, including + with live resolver, inference, and verification clients. +- Genuine misses retain the existing, corroborated hierarchy creation path. +- Buyers see ambiguous organization names as text until the catalog has a + unique identity decision. +- Future collective entity resolution may use relational context to resolve + ties, but must publish a reviewed unique result before binding. + +## References — APA 7th + +Bhattacharya, I., & Getoor, L. (2007). Collective entity resolution in +relational data. *ACM Transactions on Knowledge Discovery from Data, 1*(1), +Article 5. https://doi.org/10.1145/1217299.1217304 + +Christen, P. (2012). *Data matching: Concepts and techniques for record +linkage, entity resolution, and duplicate detection*. Springer. +https://doi.org/10.1007/978-3-642-31164-2 + +Fellegi, I. P., & Sunter, A. B. (1969). A theory for record linkage. +*Journal of the American Statistical Association, 64*(328), 1183–1210. +https://doi.org/10.2307/2286061 diff --git a/lineageweave/corporate_hierarchy_resolution.py b/lineageweave/corporate_hierarchy_resolution.py index 1fa480cc3..652f827b2 100644 --- a/lineageweave/corporate_hierarchy_resolution.py +++ b/lineageweave/corporate_hierarchy_resolution.py @@ -18,6 +18,8 @@ upgrade path once real usage shows single-mention similarity scoring under- or over-resolving in practice -- it is not implemented here because nothing yet demonstrates the need for it over this simpler, cheaper stage. +A tied top score therefore stays unbound (ADR 0026; Fellegi & Sunter, +1969) instead of first-winning a homonym. """ from __future__ import annotations @@ -26,8 +28,13 @@ from collections.abc import Sequence from dataclasses import dataclass from difflib import SequenceMatcher +from typing import Literal DEFAULT_MIN_SIMILARITY = 0.6 +RESOLUTION_UNIQUE = "unique" +RESOLUTION_MISS = "miss" +RESOLUTION_TIE = "tie" +CorporateEntityResolutionKind = Literal["unique", "miss", "tie"] # Legal-entity suffixes stripped before comparison so "Acme Electronics # Korea Ltd." and "Acme Electronics Korea" don't get penalized for a @@ -39,10 +46,7 @@ def normalize_organization_name(name: str) -> str: - """Lowercases, strips punctuation and common legal-entity suffixes, and - collapses whitespace -- the normalization both sides of a similarity - comparison go through. - """ + """Lowercase and normalize one organization name for comparison.""" lowered = _PUNCTUATION_PATTERN.sub("", name.strip().lower()) lowered = _SUFFIX_PATTERN.sub("", lowered) return _WHITESPACE_PATTERN.sub(" ", lowered).strip() @@ -56,31 +60,109 @@ class CorporateEntityCandidate: entity_name: str -def resolve_corporate_entity( +@dataclass(frozen=True) +class CorporateEntityResolution: + """Candidate-generation outcome for one mentioned organization name. + + ``None`` from :func:`resolve_corporate_entity` used to mean both + "no catalog row is close enough" and "two catalog rows tied." + Those are different decisions. A miss may enter ADR 0010 creation. + A tie must not invent a third same-named row (ADR 0026; Fellegi & + Sunter, 1969). + + Attributes: + kind: ``unique`` stores ``catalog_id``. ``miss`` means no + candidate cleared ``min_similarity``. ``tie`` means two + or more distinct catalog ids share the top score at or + above the threshold. + catalog_id: The unique winner, or ``None``. + top_score: Highest similarity seen, or ``0.0`` when the + mention is empty. + top_catalog_ids: Distinct catalog ids that share + ``top_score``. Empty on a miss that never scored. + """ + + kind: CorporateEntityResolutionKind + catalog_id: str | None + top_score: float + top_catalog_ids: tuple[str, ...] + + +def score_corporate_entity( mentioned_name: str, candidates: Sequence[CorporateEntityCandidate], min_similarity: float = DEFAULT_MIN_SIMILARITY, -) -> str | None: - """Returns the best-matching candidate's `corporate_entity_id`, or - `None` if no candidate clears `min_similarity`. +) -> CorporateEntityResolution: + """Classify a mention as a unique match, a miss, or a tied match. - Returning `None` for a genuine non-match is the point, not a failure - case to work around: a wrong hierarchy link corrupts every downstream - Knowledge Graph traversal through it, so "no confident match" must - stay a real, distinguishable outcome from "matched entity X." + Duplicate snapshot rows for the same ``corporate_entity_id`` count + as one candidate. Only a unique top score may bind; a tie stays + unbound, while a genuine miss may enter the separately corroborated + creation path defined by ADR 0010. """ normalized_mention = normalize_organization_name(mentioned_name) if not normalized_mention: - return None + return CorporateEntityResolution( + kind=RESOLUTION_MISS, + catalog_id=None, + top_score=0.0, + top_catalog_ids=(), + ) - best_id: str | None = None + best_ids: list[str] = [] best_score = 0.0 for candidate in candidates: score = SequenceMatcher( - None, normalized_mention, normalize_organization_name(candidate.entity_name) + None, + normalized_mention, + normalize_organization_name(candidate.entity_name), ).ratio() if score > best_score: best_score = score - best_id = candidate.corporate_entity_id + best_ids = [candidate.corporate_entity_id] + elif ( + score == best_score + and score > 0.0 + and candidate.corporate_entity_id not in best_ids + ): + best_ids.append(candidate.corporate_entity_id) - return best_id if best_score >= min_similarity else None + if best_score < min_similarity or not best_ids: + return CorporateEntityResolution( + kind=RESOLUTION_MISS, + catalog_id=None, + top_score=best_score, + top_catalog_ids=tuple(best_ids), + ) + if len(best_ids) != 1: + return CorporateEntityResolution( + kind=RESOLUTION_TIE, + catalog_id=None, + top_score=best_score, + top_catalog_ids=tuple(best_ids), + ) + return CorporateEntityResolution( + kind=RESOLUTION_UNIQUE, + catalog_id=best_ids[0], + top_score=best_score, + top_catalog_ids=tuple(best_ids), + ) + + +def resolve_corporate_entity( + mentioned_name: str, + candidates: Sequence[CorporateEntityCandidate], + min_similarity: float = DEFAULT_MIN_SIMILARITY, +) -> str | None: + """Return the unique best-matching catalog id, or ``None``. + + ``None`` is the fail-closed outcome when no candidate clears + ``min_similarity`` or when distinct candidates share the top score. + Callers that may create a row must use :func:`score_corporate_entity` + to distinguish a miss from a tie (ADR 0026). + """ + return score_corporate_entity( + mentioned_name, + candidates, + min_similarity, + ).catalog_id diff --git a/tests/test_corporate_hierarchy_resolution.py b/tests/test_corporate_hierarchy_resolution.py index 218fd4e00..72a7927c0 100644 --- a/tests/test_corporate_hierarchy_resolution.py +++ b/tests/test_corporate_hierarchy_resolution.py @@ -1,18 +1,15 @@ -"""Tests for lineageweave.corporate_hierarchy_resolution, against a -synthetic hierarchy fixture structurally identical to the one already used -in tests/test_schema.py's real-database test (Acme Group -> Acme -Electronics Korea -> Acme Electronics Gwangju Plant), so the correct -resolution is known by construction: an abbreviation or trailing legal -suffix of one of these three names must resolve to it, and an unrelated -organization name must not resolve to anything. -""" +"""Tests for deterministic corporate-hierarchy candidate resolution.""" from __future__ import annotations from lineageweave.corporate_hierarchy_resolution import ( + RESOLUTION_MISS, + RESOLUTION_TIE, + RESOLUTION_UNIQUE, CorporateEntityCandidate, normalize_organization_name, resolve_corporate_entity, + score_corporate_entity, ) _CANDIDATES = [ @@ -23,11 +20,17 @@ def test_exact_name_resolves() -> None: - assert resolve_corporate_entity("Acme Electronics Korea", _CANDIDATES) == "korea-id" + assert resolve_corporate_entity( + "Acme Electronics Korea", + _CANDIDATES, + ) == "korea-id" def test_trailing_legal_suffix_still_resolves() -> None: - assert resolve_corporate_entity("Acme Electronics Korea Ltd.", _CANDIDATES) == "korea-id" + assert resolve_corporate_entity( + "Acme Electronics Korea Ltd.", + _CANDIDATES, + ) == "korea-id" def test_abbreviation_still_resolves() -> None: @@ -35,20 +38,25 @@ def test_abbreviation_still_resolves() -> None: def test_resolves_to_the_correct_sibling_not_a_different_one() -> None: - """The whole point of similarity scoring over "any partial match": - a mention close to the Gwangju plant must resolve to the plant, not - accidentally to the parent "Acme Electronics Korea" it shares most of - its name with. - """ - assert resolve_corporate_entity("Acme Gwangju Plant", _CANDIDATES) == "gwangju-id" - - -def test_unrelated_organization_does_not_resolve() -> None: - """A genuine non-match must return None, not the closest-available - guess -- a wrong hierarchy link corrupts every downstream Knowledge - Graph traversal through it. - """ - assert resolve_corporate_entity("Totally Different Company", _CANDIDATES) is None + """A plant-like mention resolves to the plant rather than its parent.""" + assert resolve_corporate_entity( + "Acme Gwangju Plant", + _CANDIDATES, + ) == "gwangju-id" + + +def test_unrelated_organization_is_a_miss() -> None: + """A below-threshold candidate set is distinct from an equal-score tie.""" + outcome = score_corporate_entity( + "Totally Different Company", + _CANDIDATES, + ) + assert outcome.kind == RESOLUTION_MISS + assert outcome.catalog_id is None + assert resolve_corporate_entity( + "Totally Different Company", + _CANDIDATES, + ) is None def test_empty_mention_does_not_resolve() -> None: @@ -60,6 +68,45 @@ def test_no_candidates_does_not_resolve() -> None: assert resolve_corporate_entity("Acme Electronics Korea", []) is None +def test_tied_same_display_name_stays_unbound() -> None: + """Distinct same-named catalog rows are a tie, not a first-wins match.""" + homonyms = [ + CorporateEntityCandidate("homonym-a", "Tied Energy"), + CorporateEntityCandidate("homonym-b", "Tied Energy"), + ] + outcome = score_corporate_entity("Tied Energy", homonyms) + assert outcome.kind == RESOLUTION_TIE + assert outcome.catalog_id is None + assert set(outcome.top_catalog_ids) == {"homonym-a", "homonym-b"} + assert resolve_corporate_entity("Tied Energy", homonyms) is None + + +def test_duplicate_snapshot_rows_for_one_catalog_id_are_unique() -> None: + """Duplicate query rows do not manufacture an identity tie.""" + duplicated = [ + CorporateEntityCandidate("korea-id", "Acme Electronics Korea"), + CorporateEntityCandidate("korea-id", "Acme Electronics Korea"), + ] + outcome = score_corporate_entity("Acme Electronics Korea", duplicated) + assert outcome.kind == RESOLUTION_UNIQUE + assert outcome.catalog_id == "korea-id" + + +def test_unique_exact_name_still_wins_beside_unrelated_homonyms() -> None: + mixed = [ + *_CANDIDATES, + CorporateEntityCandidate("homonym-a", "Tied Energy"), + CorporateEntityCandidate("homonym-b", "Tied Energy"), + ] + assert resolve_corporate_entity( + "Acme Electronics Korea", + mixed, + ) == "korea-id" + assert resolve_corporate_entity("Tied Energy", mixed) is None + + def test_normalize_strips_suffix_punctuation_and_case() -> None: - assert normalize_organization_name("Acme Electronics Korea, Ltd.") == "acme electronics korea" + assert normalize_organization_name( + "Acme Electronics Korea, Ltd." + ) == "acme electronics korea" assert normalize_organization_name(" ACME Group ") == "acme group" diff --git a/tests/test_corporate_hierarchy_resolution_branches.py b/tests/test_corporate_hierarchy_resolution_branches.py new file mode 100644 index 000000000..a2f4287f4 --- /dev/null +++ b/tests/test_corporate_hierarchy_resolution_branches.py @@ -0,0 +1,29 @@ +"""Branch-complete edge cases for corporate entity resolution.""" + +from lineageweave.corporate_hierarchy_resolution import ( + RESOLUTION_MISS, + CorporateEntityCandidate, + score_corporate_entity, +) + + +def test_zero_similarity_candidate_remains_a_miss() -> None: + """A zero score does not enter the tied-candidate set.""" + outcome = score_corporate_entity( + "aaa", + [CorporateEntityCandidate("bbb-id", "bbb")], + ) + assert outcome.kind == RESOLUTION_MISS + assert outcome.top_score == 0.0 + assert outcome.top_catalog_ids == () + + +def test_zero_threshold_without_candidates_is_still_a_miss() -> None: + """An empty candidate set cannot become a unique zero-score match.""" + outcome = score_corporate_entity( + "Synthetic Energy", + [], + min_similarity=0.0, + ) + assert outcome.kind == RESOLUTION_MISS + assert outcome.catalog_id is None diff --git a/tests/test_tied_organization_no_create.py b/tests/test_tied_organization_no_create.py new file mode 100644 index 000000000..c595fae39 --- /dev/null +++ b/tests/test_tied_organization_no_create.py @@ -0,0 +1,147 @@ +"""Regression tests that keep tied organization names out of AUTO rows.""" + +from __future__ import annotations + +import asyncio +import uuid +from types import SimpleNamespace +from typing import Any + +from backend.app import corporate_entity_ingestion, keyman_ingestion +from lineageweave.corporate_hierarchy_inference import HierarchyProposal +from lineageweave.corporate_hierarchy_resolution import CorporateEntityCandidate +from lineageweave.relation_verification import STATUS_CORROBORATED + + +_TIED_CANDIDATES = [ + CorporateEntityCandidate("tied-a", "Tied Energy"), + CorporateEntityCandidate("tied-b", "Tied Energy"), +] + + +class _LiveInferenceClient: + """Return a creatable root-company proposal if a tie leaks through.""" + + available = True + + def __init__(self) -> None: + self.calls = 0 + + def infer(self, organization_name: str, context_text: str) -> HierarchyProposal: + self.calls += 1 + return HierarchyProposal(level_code="company", parent_name=None) + + +class _LiveVerificationClient: + """Corroborate every proposal if a tie leaks through.""" + + available = True + + def __init__(self) -> None: + self.calls = 0 + + def verify(self, subject: str, relation: str) -> SimpleNamespace: + self.calls += 1 + return SimpleNamespace(status_code=STATUS_CORROBORATED) + + +class _Transaction: + """Minimal async transaction context manager.""" + + async def __aenter__(self) -> "_Transaction": + return self + + async def __aexit__(self, exc_type: Any, exc: Any, traceback: Any) -> bool: + return False + + +class _ReloadTieConnection: + """Expose a tie only after inference and the advisory lock.""" + + def __init__(self) -> None: + self.insert_attempted = False + + def transaction(self) -> _Transaction: + return _Transaction() + + async def execute(self, query: str, *args: Any) -> str: + assert "pg_advisory_xact_lock" in query + return "SELECT 1" + + async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: + assert "from corporate_entity" in query + return [ + { + "corporate_entity_id": uuid.uuid4(), + "entity_name": "Tied Energy", + }, + { + "corporate_entity_id": uuid.uuid4(), + "entity_name": "Tied Energy", + }, + ] + + async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]: + self.insert_attempted = True + raise AssertionError("a refreshed tie must not insert an AUTO row") + + +def test_initial_tie_never_reaches_live_inference_or_creation() -> None: + """A known tie returns before any live external client is consulted.""" + inference = _LiveInferenceClient() + verification = _LiveVerificationClient() + + result = asyncio.run( + corporate_entity_ingestion.get_or_create_corporate_entity( + object(), + "Tied Energy", + "Synthetic context", + inference, + verification, + list(_TIED_CANDIDATES), + ) + ) + + assert result is None + assert inference.calls == 0 + assert verification.calls == 0 + + +def test_tie_discovered_under_creation_lock_does_not_insert() -> None: + """Concurrent homonyms discovered after inference still fail closed.""" + connection = _ReloadTieConnection() + inference = _LiveInferenceClient() + verification = _LiveVerificationClient() + + result = asyncio.run( + corporate_entity_ingestion.get_or_create_corporate_entity( + connection, + "Tied Energy", + "Synthetic context", + inference, + verification, + [], + ) + ) + + assert result is None + assert inference.calls == 1 + assert verification.calls == 1 + assert connection.insert_attempted is False + + +def test_keyman_raw_tie_blocks_abbreviation_rewrite_and_auto_creation() -> None: + """Keyman checks the raw tied name before any resolver rewrite.""" + result = asyncio.run( + keyman_ingestion._resolve_affiliated_organization( + object(), + "Tied Energy", + "Synthetic context", + object(), + object(), + object(), + list(_TIED_CANDIDATES), + ) + ) + + assert result == ("Tied Energy", "Tied Energy", None)