diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3a4d0ac4a..4eb540485 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -852,7 +852,10 @@ new table needed. `lineageweave/knowledge_graph.py`'s `backend/app/post_summary_ingestion.py`'s `persist_post_summary` now resolves each R&R actor's identity, stores that id on `post_summary_role` (ADR 0019 — `entity_name` is not unique), and calls -the same `persist_edges_for_post` Keyman ingestion already uses. A person R&R +the same `persist_edges_for_post` Keyman ingestion already uses. +`resolve_corporate_entity` returns no id when two catalog rows share +the top similarity score (ADR 0021), so the popup keeps that name as +text instead of walking a homonym. A person R&R actor is opportunistically joined to an existing `cataloged_person` row by name (never originated by R&R itself -- documented gap in the ADR: `cataloged_person` needs `person_side_code`, which R&R's prompt does diff --git a/CHANGELOG.d/0.86.4-tied-organization-similarity.md b/CHANGELOG.d/0.86.4-tied-organization-similarity.md new file mode 100644 index 000000000..c6f1d8ef9 --- /dev/null +++ b/CHANGELOG.d/0.86.4-tied-organization-similarity.md @@ -0,0 +1,3 @@ +An R&R organization name that matches two catalog rows at the same +similarity score stays text. Open the post after the catalog collision +is unique, then click. diff --git a/CHANGELOG.md b/CHANGELOG.md index d30ae14aa..a6f73d113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.86.4] - 2026-08-16 + +### Fixed + +- An R&R organization name that matches two catalog rows at the same + similarity score stays text (ADR 0021). Open the post — there is no + button until the catalog collision is unique. A unique exact match + among other same-named neighbors still walks the resolved org. + ## [0.86.2] - 2026-08-16 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c5a1828f2..704b82df1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,3 +21,6 @@ Opening a cutoff title shows the live post -- compare it with the cutoff before treating the body as reconstructed evidence (ADR 0016). `POST /api/analysis-runs` records Pending on an authorized cutoff capture (ADR 0017) and does not reconstruct lineage. +Two catalog orgs that share a display name and the same similarity +score stay unbound (ADR 0021). Open the post — that name is text +until a unique catalog id is stored. diff --git a/docs/adr/0010-corporate-hierarchy-auto-creation.md b/docs/adr/0010-corporate-hierarchy-auto-creation.md index d034fe9b2..08796d183 100644 --- a/docs/adr/0010-corporate-hierarchy-auto-creation.md +++ b/docs/adr/0010-corporate-hierarchy-auto-creation.md @@ -24,9 +24,10 @@ until a verified creation path exists. extends the existing resolution pipeline with a creation fallback, not a competing algorithm: -1. Try `resolve_corporate_entity` (similarity matching, unchanged, - Bhattacharya & Getoor, 2007) first -- an already-cataloged entity - still resolves exactly as before. +1. Try `resolve_corporate_entity` (similarity matching, + Bhattacharya & Getoor, 2007; tied top scores stay unbound, ADR + 0021) first -- an already-cataloged entity still resolves exactly + as before when the top score is unique. 2. On a miss, ask an LLM (`lineageweave.corporate_hierarchy_inference.CorporateHierarchyInferenceClient`) to propose this organization's place in the Group -> Company -> @@ -91,10 +92,13 @@ reuse-the-verification-client pattern and [ADR 0009](0009-cross-post-actor-identity.md)'s cross-post identity work -- an R&R organization actor's identity is now genuinely resolved to a real corporate hierarchy node, not left as free text even when no -prior mention of it existed anywhere in the dataset. +prior mention of it existed anywhere in the dataset. A tied similarity +score stays unbound ([ADR 0021](0021-tied-organization-similarity.md)). ## 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 +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 + Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization system reference*. World Wide Web Consortium. https://www.w3.org/TR/skos-reference/ diff --git a/docs/adr/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md index 32b5d0a09..923a0001d 100644 --- a/docs/adr/0019-role-catalog-identity.md +++ b/docs/adr/0019-role-catalog-identity.md @@ -40,8 +40,9 @@ Two same-named mentions stay unbound rather than guessing. ## Consequences - Open a post whose R&R names an organization that shares a display - name with another catalog row. The button walks the resolved id, not - the homonym. + name with another catalog row. When persist stored a unique id, the + button walks that id, not the homonym. When write-time similarity + ties, the name stays text (ADR 0021). - Clicking that name still uses `GET /api/corporate-entities/{id}/related` or `GET /api/teams/{id}/related`. Authz stays person/entity-parity: a team mentioned only on another corp's private post is 403; an diff --git a/docs/adr/0021-tied-organization-similarity.md b/docs/adr/0021-tied-organization-similarity.md new file mode 100644 index 000000000..91311a8fa --- /dev/null +++ b/docs/adr/0021-tied-organization-similarity.md @@ -0,0 +1,80 @@ +# ADR 0021 — Tied organization similarity stays unbound + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0019 stores the catalog id on `post_summary_role` so fetch no +longer rejoins `corporate_entity.entity_name`. Persist still calls +`resolve_corporate_entity` before that write. That function kept the +first candidate whose similarity score was strictly greater than the +running best. Two catalog rows can share a display name (different +`corporate_entity_code`, different parents). Both score 1.0. The +winner was then whichever row an unordered `select` happened to +return. + +The buyer path is: open the post, click the R&R organization name, +walk related nodes. A first-wins bind walks the homonym. ADR 0019's +backfill already refuses to guess when two same-named mentions exist +on one post (`HAVING count(*) = 1`). Write-time resolution did not. + +String similarity is only the candidate-generation stage of +collective entity resolution (Bhattacharya & Getoor, 2007). Fellegi +and Sunter (1969) leave an uncertain pair for clerical review rather +than forcing a link. Christen (2012) treats that hold-out as part of +the matching decision, not a defect to paper over. + +```mermaid +flowchart TD + mention["R&R organization name"] --> score["Score every catalog row"] + score --> unique{"One unique top score at or above the threshold?"} + unique -->|yes| bind["Store that catalog id on the role row"] + unique -->|no| unbound["Leave the name as text — no related-node button"] + bind --> walk["Click walks GET /api/corporate-entities/{id}/related"] + unbound --> later["Resolve the catalog collision, then persist again"] +``` + +## Decision + +`resolve_corporate_entity` returns a catalog id only when exactly one +candidate holds the top score and that score clears `min_similarity`. +A tied top score returns `None`. `persist_post_summary` then stores +no `cataloged_corporate_entity_id` and writes no +`post_organization_mention`. The popup shows the name as text, not a +button. + +The same function is shared with Keyman affiliation matching and +entity-relationship counterparty resolution. Those paths already +treat `None` as "do not invent a link." + +This does not create a `corporate_entity` row. Null inference / +verification clients stay unavailable. Do not invent a TEPP theta. + +## Consequences + +- Open a post whose R&R names `Tied Energy` when two catalog rows + share that display name. The name is not a button. Resolve the + catalog collision (distinct codes, parents, or a verified unique + match), persist the summary again, then click. +- A unique exact match among other homonym neighbors still binds. +- The same catalog id listed twice in the candidate snapshot is one + winner, not a tie. + +## References + +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 + +International Organization for Standardization. (2023). *ISO/IEC +11179-1:2023: Information technology—Metadata registries (MDR)—Part 1: +Framework*. diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md index fc7ded98f..9471d8446 100644 --- a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md +++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md @@ -29,3 +29,15 @@ https://doi.org/10.1007/978-1-4842-5540-7 International Organization for Standardization. (2023). *ISO/IEC 11179-1:2023: Information technology—Metadata registries (MDR)—Part 1: Framework*. + +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/frontend/package.json b/frontend/package.json index fb52f7948..05f877ba2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.2", + "version": "0.86.4", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index efe84890b..93c53ac54 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.2" +__version__ = "0.86.4" diff --git a/lineageweave/corporate_hierarchy_resolution.py b/lineageweave/corporate_hierarchy_resolution.py index 1fa480cc3..30ce357fe 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 0021; Fellegi & Sunter, +1969) instead of first-winning a homonym. """ from __future__ import annotations @@ -61,19 +63,21 @@ def resolve_corporate_entity( 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`. + """Return the unique best-matching catalog id, or ``None``. - 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." + ``None`` is the honest outcome when no candidate clears + ``min_similarity`` **or** two or more candidates share the top + score. A wrong hierarchy link corrupts every downstream Knowledge + Graph walk, so a tied homonym must not become a button (ADR 0021; + Fellegi & Sunter, 1969; Bhattacharya & Getoor, 2007). Duplicate + rows for the same ``corporate_entity_id`` still count as one + candidate. """ normalized_mention = normalize_organization_name(mentioned_name) if not normalized_mention: return None - best_id: str | None = None + best_ids: list[str] = [] best_score = 0.0 for candidate in candidates: score = SequenceMatcher( @@ -81,6 +85,14 @@ def resolve_corporate_entity( ).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 len(best_ids) != 1: + return None + return best_ids[0] diff --git a/pyproject.toml b/pyproject.toml index 6e41c7ca7..7254de99e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.2" +version = "0.86.4" description = "Reconstructs git-branch-style lineage DAGs from scattered short records using multi-channel score fusion and LLM adjudication." readme = "README.md" license = { text = "MIT" } diff --git a/tests/test_corporate_hierarchy_resolution.py b/tests/test_corporate_hierarchy_resolution.py index 218fd4e00..d59c081b4 100644 --- a/tests/test_corporate_hierarchy_resolution.py +++ b/tests/test_corporate_hierarchy_resolution.py @@ -60,6 +60,35 @@ def test_no_candidates_does_not_resolve() -> None: assert resolve_corporate_entity("Acme Electronics Korea", []) is None +def test_tied_same_display_name_does_not_resolve() -> None: + """Two catalog orgs can share a display name. First-wins is a homonym.""" + homonyms = [ + CorporateEntityCandidate("mentioned-id", "Homonym Energy"), + CorporateEntityCandidate("other-id", "Homonym Energy"), + ] + assert resolve_corporate_entity("Homonym Energy", homonyms) is None + + +def test_tied_same_display_name_ignores_duplicate_candidate_rows() -> None: + """The same catalog id listed twice is still one unique winner.""" + duplicated = [ + CorporateEntityCandidate("korea-id", "Acme Electronics Korea"), + CorporateEntityCandidate("korea-id", "Acme Electronics Korea"), + ] + assert resolve_corporate_entity("Acme Electronics Korea", duplicated) == "korea-id" + + +def test_unique_exact_name_still_wins_among_homonym_neighbors() -> None: + """A unique exact match stays a button; only a tied top score fails closed.""" + mixed = [ + CorporateEntityCandidate("korea-id", "Acme Electronics Korea"), + CorporateEntityCandidate("homonym-a", "Homonym Energy"), + CorporateEntityCandidate("homonym-b", "Homonym Energy"), + ] + assert resolve_corporate_entity("Acme Electronics Korea", mixed) == "korea-id" + assert resolve_corporate_entity("Homonym 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 Group ") == "acme group" diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index d2994e2c4..1de2da17e 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -507,3 +507,21 @@ def test_role_catalog_identity_is_stored_on_the_role_row() -> None: assert "cataloged_corporate_entity_id" in upgrade assert "0019_role_catalog_identity.sql" in dockerfile assert "ADR 0019" in changelog + + +def test_tied_organization_similarity_fails_closed() -> None: + """ADR 0021: write-time resolution must not first-win a tied top score.""" + root = Path(__file__).resolve().parents[1] + source = (root / "lineageweave" / "corporate_hierarchy_resolution.py").read_text( + encoding="utf-8" + ) + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + adr = (root / "docs" / "adr" / "0021-tied-organization-similarity.md").read_text( + encoding="utf-8" + ) + resolve_source = source.split("def resolve_corporate_entity", 1)[1] + assert "len(best_ids) != 1" in resolve_source + assert "score > best_score" in resolve_source + assert "ADR 0021" in changelog + assert "Tied organization similarity stays unbound" in adr + assert "Fellegi" in adr diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 81e63a75f..14ae7efae 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -542,3 +542,59 @@ def test_homonym_organization_role_binds_the_resolved_catalog_id( database_dsn, post_id, _summary_person_id = projection_database.split("|") asyncio.run(_exercise_homonym_organization_role_binding(database_dsn, post_id)) + + +async def _exercise_tied_organization_similarity_stays_unbound( + database_dsn: str, + post_id: str, +) -> None: + """Write-time resolution must not first-win a same-named catalog pair.""" + + connection = await asyncpg.connect(database_dsn) + try: + await connection.execute( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values + ('HOMONYM-TIED-A', 'Tied Energy', 'company'), + ('HOMONYM-TIED-B', 'Tied Energy', 'company') + """ + ) + payload = await persist_post_summary( + connection, + post_id, + PostSummary( + korean_summary="동점 조직은 버튼을 만들지 않는다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name="Tied Energy", + responsibility="납품 일정 확정", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), + ), + ), + ) + roles = payload["roles_and_responsibilities"] + assert len(roles) == 1 + assert roles[0]["catalog_node_id"] is None + assert roles[0]["catalog_node_type_code"] is None + fetched = await fetch_persisted_summary(connection, post_id) + assert fetched is not None + assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] is None + mention_count = await connection.fetchval( + "select count(*) from post_organization_mention where post_id = $1", + post_id, + ) + assert mention_count == 0 + finally: + await connection.close() + + +def test_tied_organization_similarity_stays_unbound( + projection_database: str, +) -> None: + """ADR 0021: open the post — the shared name is text, not a homonym button.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_tied_organization_similarity_stays_unbound(database_dsn, post_id)) diff --git a/uv.lock b/uv.lock index e1d2860cf..0f0f81957 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.2" +version = "0.86.4" source = { virtual = "." } dependencies = [ { name = "certifi" },