From b2758dd5f57612403248aa027917bdc90b09de80 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:07:37 +0000 Subject: [PATCH 1/2] fix: bind R&R catalog ids on the role row (v0.86.2) Organization buttons walked a name join on corporate_entity.entity_name, which is not unique. Persist the resolved catalog foreign keys on post_summary_role and read those columns. Team related now has the same 403/404 coverage as person and entity related. Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 5 +- CHANGELOG.d/0.86.2-role-catalog-identity.md | 3 + CHANGELOG.md | 10 ++ backend/app/post_summary_ingestion.py | 99 +++++++++---------- backend/tests/test_api.py | 97 ++++++++++++++++++ docker/postgres-init/Dockerfile | 1 + docs/adr/0018-related-nodes-team-org-walk.md | 5 +- docs/adr/0019-role-catalog-identity.md | 65 ++++++++++++ .../RELATED_NODE_TEAM_ORG_REFERENCES.md | 12 +++ frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- migrations/0001_initial_schema.sql | 9 ++ migrations/0019_role_catalog_identity.sql | 46 +++++++++ .../rollback/0019_role_catalog_identity.sql | 8 ++ pyproject.toml | 2 +- scripts/seed_demo_data.py | 1 + tests/test_ingestion_transaction_contracts.py | 38 +++++++ tests/test_person_mention_projection.py | 95 +++++++++++++++++- uv.lock | 2 +- 19 files changed, 442 insertions(+), 60 deletions(-) create mode 100644 CHANGELOG.d/0.86.2-role-catalog-identity.md create mode 100644 docs/adr/0019-role-catalog-identity.md create mode 100644 migrations/0019_role_catalog_identity.sql create mode 100644 migrations/rollback/0019_role_catalog_identity.sql diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a8d082f0b..b66c7cde7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -849,8 +849,9 @@ new table needed. `lineageweave/knowledge_graph.py`'s `knowledge_graph_edges_for_post` extended with three new edge kinds (`edge_mention_team`, `edge_team_affiliation`, `edge_mention_organization`); `backend/app/post_summary_ingestion.py`'s `persist_post_summary` now -resolves each R&R actor's identity and calls the same -`persist_edges_for_post` Keyman ingestion already uses. A person R&R +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 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.2-role-catalog-identity.md b/CHANGELOG.d/0.86.2-role-catalog-identity.md new file mode 100644 index 000000000..a56b3d47e --- /dev/null +++ b/CHANGELOG.d/0.86.2-role-catalog-identity.md @@ -0,0 +1,3 @@ +R&R organization buttons walk the catalog id stored on the role row. A +shared display name no longer attaches a homonym. Team related matches +person/entity 403/404. diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8717513..13aeb02f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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.2] - 2026-08-16 + +### Fixed + +- An R&R organization button now walks the catalog id stored on that + role row (ADR 0019). Two catalog orgs can share a display name; open + the post, click the name, and you stay on the resolved org — not a + homonym. `GET /api/teams/{id}/related` matches person/entity authz: + another corp's private-only team is 403; an unknown UUID is 404. + ## [0.86.1] - 2026-08-16 ### Changed diff --git a/backend/app/post_summary_ingestion.py b/backend/app/post_summary_ingestion.py index 36d4fadae..3febf9b21 100644 --- a/backend/app/post_summary_ingestion.py +++ b/backend/app/post_summary_ingestion.py @@ -1,10 +1,11 @@ """Persist and load the popup's Korean summary / key events / R&R. -ADR 0009: an R&R actor is not just per-post free text -- when it is a -team or organization, it is resolved to a shared catalog identity -(``cataloged_team`` / ``corporate_entity``) and a Knowledge Graph -mention edge is written, so the same "설계팀" or organization named -across two posts becomes one linkable node, not two unrelated strings. +ADR 0009 / 0019: an R&R actor is not just per-post free text -- when it +is a team or organization, it is resolved to a shared catalog identity +(``cataloged_team`` / ``corporate_entity``) stored on the role row and +a Knowledge Graph mention edge is written, so the same "설계팀" or +organization named across two posts becomes one linkable node. Fetch +never reconstructs that id by ``entity_name``; that column is not unique. A person actor is opportunistically joined to an *existing* ``cataloged_person`` row by name when Keyman extraction has already cataloged that name. The R&R evidence is written to @@ -57,7 +58,12 @@ async def fetch_persisted_summary( conn: asyncpg.Connection, post_id: str ) -> dict[str, Any] | None: - """Return the stored summary payload, or None when none has been written.""" + """Return the stored summary payload, or None when none has been written. + + ``catalog_node_id`` comes from the role row's catalog foreign keys + (ADR 0019). This function does not join ``corporate_entity`` by + ``entity_name``. + """ header = await conn.fetchrow( "select korean_summary from post_summary_result where post_id = $1", post_id, @@ -72,23 +78,9 @@ async def fetch_persisted_summary( """ select role.actor_name, role.responsibility, role.actor_type_code, role.affiliated_organization_name, - team_mention.team_id, - org_mention.corporate_entity_id + role.cataloged_team_id, + role.cataloged_corporate_entity_id from post_summary_role role - left join cataloged_team team - on role.actor_type_code = 'prov_team' - and team.team_name = role.actor_name - and team.affiliated_organization_name - is not distinct from role.affiliated_organization_name - left join post_team_mention team_mention - on team_mention.post_id = role.post_id - and team_mention.team_id = team.team_id - left join corporate_entity org - on role.actor_type_code = 'prov_organization' - and org.entity_name = role.actor_name - left join post_organization_mention org_mention - on org_mention.post_id = role.post_id - and org_mention.corporate_entity_id = org.corporate_entity_id where role.post_id = $1 order by role.actor_name """, @@ -98,11 +90,11 @@ async def fetch_persisted_summary( for row in roles: catalog_node_id = None catalog_node_type_code = None - if row["team_id"] is not None: - catalog_node_id = str(row["team_id"]) + if row["cataloged_team_id"] is not None: + catalog_node_id = str(row["cataloged_team_id"]) catalog_node_type_code = NODE_TEAM - elif row["corporate_entity_id"] is not None: - catalog_node_id = str(row["corporate_entity_id"]) + elif row["cataloged_corporate_entity_id"] is not None: + catalog_node_id = str(row["cataloged_corporate_entity_id"]) catalog_node_type_code = NODE_CORPORATE_ENTITY payload_roles.append( { @@ -218,44 +210,51 @@ async def _replace_summary_projection( ordinal, event_text, ) - for role in summary.roles_and_responsibilities: + # ADR 0009 / 0019: resolve catalog identity before writing the role + # row so fetch never reconstructs it by a non-unique name. + for role_index, role in enumerate(summary.roles_and_responsibilities): + cataloged_team_id = None + cataloged_corporate_entity_id = None + if role.actor_type_code == ACTOR_TYPE_TEAM: + cataloged_team_id = await upsert_team( + conn, + role.actor_name, + role.affiliated_organization_name, + candidates, + ) + elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION: + cataloged_corporate_entity_id = resolved_organization_ids.get( + role_index + ) await conn.execute( "insert into post_summary_role " "(post_id, actor_name, responsibility, actor_type_code, " - "affiliated_organization_name) values ($1, $2, $3, $4, $5)", + "affiliated_organization_name, cataloged_team_id, " + "cataloged_corporate_entity_id) values " + "($1, $2, $3, $4, $5, $6, $7)", post_id, role.actor_name, role.responsibility, role.actor_type_code, role.affiliated_organization_name, + cataloged_team_id, + cataloged_corporate_entity_id, ) - - # ADR 0009: cross-post identity resolution for team/organization/person - # actors -- see module docstring. - for role_index, role in enumerate(summary.roles_and_responsibilities): - if role.actor_type_code == ACTOR_TYPE_TEAM: - team_id = await upsert_team( - conn, - role.actor_name, - role.affiliated_organization_name, - candidates, - ) + if cataloged_team_id is not None: await conn.execute( "insert into post_team_mention (post_id, team_id) values ($1, $2) " "on conflict do nothing", post_id, - team_id, + cataloged_team_id, + ) + elif cataloged_corporate_entity_id is not None: + await conn.execute( + "insert into post_organization_mention " + "(post_id, corporate_entity_id) values ($1, $2) " + "on conflict do nothing", + post_id, + cataloged_corporate_entity_id, ) - elif role.actor_type_code == ACTOR_TYPE_ORGANIZATION: - corporate_entity_id = resolved_organization_ids.get(role_index) - if corporate_entity_id is not None: - await conn.execute( - "insert into post_organization_mention " - "(post_id, corporate_entity_id) values ($1, $2) " - "on conflict do nothing", - post_id, - corporate_entity_id, - ) elif role.actor_type_code == ACTOR_TYPE_PERSON: person_row = await conn.fetchrow( "select person_id from cataloged_person where person_name = $1 limit 1", diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index bff7f7c64..21c71bc9a 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1201,6 +1201,44 @@ def test_unknown_corporate_entity_related_is_not_found( assert response.status_code == 404 +def test_team_only_on_other_corp_private_post_is_forbidden( + client, demo_analyst_token, seeded_db +) -> None: + """A team mentioned only on another corp's private post must 403.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into cataloged_team (team_name, affiliated_organization_name) " + "values ('비공개 설계팀', 'Other Corp') returning team_id" + ) + team_id = str(cur.fetchone()[0]) + cur.execute( + "insert into post_team_mention (post_id, team_id) values (%s, %s)", + (seeded_db["other_private_post_id"], team_id), + ) + finally: + admin_conn.close() + + response = client.get( + f"/api/teams/{team_id}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 403 + + +def test_unknown_team_related_is_not_found(client, demo_analyst_token) -> None: + """An unknown team UUID must 404, matching person and entity related.""" + + response = client.get( + f"/api/teams/{uuid.uuid4()}/related", + headers={"Authorization": f"Bearer {demo_analyst_token}"}, + ) + assert response.status_code == 404 + + def test_keyman_only_on_other_corp_private_post_is_forbidden(client, demo_analyst_token, seeded_db) -> None: response = client.get( f"/api/keymen/{seeded_db['hidden_person_id']}/related", @@ -1528,6 +1566,65 @@ def test_organization_mention_only_posts_appear_in_entity_related( assert org_only_post_id in related_ids +def test_private_other_corp_organization_mention_does_not_leak( + client, demo_analyst_token, seeded_db +) -> None: + """The org-mention UNION must still apply ABAC per post.""" + + admin_conn = psycopg2.connect(seeded_db["dsn"]) + admin_conn.autocommit = True + try: + with admin_conn.cursor() as cur: + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s) on conflict do nothing", + (seeded_db["other_private_post_id"], seeded_db["own_corp_id"]), + ) + cur.execute( + "insert into post_organization_mention (post_id, corporate_entity_id) " + "values (%s, %s) on conflict do nothing", + (seeded_db["other_private_post_id"], seeded_db["other_corp_id"]), + ) + for entity_id in (seeded_db["own_corp_id"], seeded_db["other_corp_id"]): + for edge in knowledge_graph_edges_for_post( + seeded_db["other_private_post_id"], + [], + organization_corporate_entity_ids=[entity_id], + ): + cur.execute( + "insert into knowledge_graph_edge (" + "source_node_type_code, source_node_id, target_node_type_code, " + "target_node_id, edge_type_code, edge_weight" + ") values (%s, %s, %s, %s, %s, %s) " + "on conflict do nothing", + ( + edge.source_node_type_code, + edge.source_node_id, + edge.target_node_type_code, + edge.target_node_id, + edge.edge_type_code, + edge.edge_weight, + ), + ) + finally: + admin_conn.close() + + headers = {"Authorization": f"Bearer {demo_analyst_token}"} + own = client.get( + f"/api/corporate-entities/{seeded_db['own_corp_id']}/related", + headers=headers, + ) + assert own.status_code == 200, own.text + own_ids = {node["node_id"] for node in own.json()["related"]} + assert seeded_db["other_private_post_id"] not in own_ids + + hidden = client.get( + f"/api/corporate-entities/{seeded_db['other_corp_id']}/related", + headers=headers, + ) + assert hidden.status_code == 403 + + def test_thread_group_run_list_honors_knowledge_cutoff( client, demo_analyst_token, seeded_db ) -> None: diff --git a/docker/postgres-init/Dockerfile b/docker/postgres-init/Dockerfile index d95e2c917..e394d376e 100644 --- a/docker/postgres-init/Dockerfile +++ b/docker/postgres-init/Dockerfile @@ -24,6 +24,7 @@ COPY migrations/0015_organization_name_resolution.sql /docker-entrypoint-initdb. COPY migrations/0016_cross_post_actor_identity.sql /docker-entrypoint-initdb.d/17-cross-post-actor-identity.sql COPY migrations/0017_prov_o_standard_relations.sql /docker-entrypoint-initdb.d/18-prov-o-standard-relations.sql COPY migrations/0018_analysis_run_registry.sql /docker-entrypoint-initdb.d/19-analysis-run-registry.sql +COPY migrations/0019_role_catalog_identity.sql /docker-entrypoint-initdb.d/20-role-catalog-identity.sql # Official image already drops to this account at runtime; declare it so # the Dockerfile itself satisfies DS-0002 (explicit non-root USER). USER postgres diff --git a/docs/adr/0018-related-nodes-team-org-walk.md b/docs/adr/0018-related-nodes-team-org-walk.md index 17e4236a6..ae0a1c331 100644 --- a/docs/adr/0018-related-nodes-team-org-walk.md +++ b/docs/adr/0018-related-nodes-team-org-walk.md @@ -34,8 +34,9 @@ rows with person-affiliation posts so an org-only mention can start a walk. The summary payload exposes `catalog_node_id` / `catalog_node_type_code` -when the R&R actor resolved to a team or organization mention on that -post. The popup turns that name into a related-node button. +from the catalog foreign keys stored on `post_summary_role` (ADR 0019). +The popup turns that name into a related-node button. Do not reconstruct +the id by `corporate_entity.entity_name`. Thread-group run list visibility requires at least one ABAC-visible `source_post` whose `created_at` is at or before `knowledge_cutoff`. diff --git a/docs/adr/0019-role-catalog-identity.md b/docs/adr/0019-role-catalog-identity.md new file mode 100644 index 000000000..32b5d0a09 --- /dev/null +++ b/docs/adr/0019-role-catalog-identity.md @@ -0,0 +1,65 @@ +# ADR 0019 — R&R catalog identity lives on the role row + +**Decision status:** Accepted +**Date:** 2026-08-16 + +## Context + +ADR 0009 writes `post_team_mention` and `post_organization_mention` so a +cataloged team or organization can start a related-node walk. ADR 0018 +exposes `catalog_node_id` on the summary payload by joining those +mentions back to `post_summary_role` through `actor_name`. + +`corporate_entity.entity_name` is not unique. Two catalog rows can share +a display name (different `corporate_entity_code`, different parents). +A fetch join on name therefore: + +- attaches a homonym that this post never resolved, or +- duplicates the role when more than one same-named row exists. + +Mention tables are post-scoped, not role-scoped. They cannot reconstruct +which catalog id was chosen for a specific R&R row. That reconstruction +is a transitive dependency on a non-key attribute, so it is not third +normal form (Codd, 1970; Date, 2019). + +Team identity is already unique on +`(team_name, affiliated_organization_name)`. Organization identity is +not. + +## Decision + +`post_summary_role` stores the resolved catalog foreign keys +(`cataloged_team_id`, `cataloged_corporate_entity_id`) written during +`persist_post_summary`. `fetch_persisted_summary` reads those columns. +It does not join `corporate_entity` by `entity_name`. + +Migration `0019_role_catalog_identity.sql` backfills existing rows from +a post-scoped mention only when the name match is unique on that post. +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. +- 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 + unknown UUID is 404. + +## References + +Codd, E. F. (1970). A relational model of data for large shared data +banks. *Communications of the ACM, 13*(6), 377–387. +https://doi.org/10.1145/362384.362685 + +Date, C. J. (2019). *Database design and relational theory: Normal forms +and all that jazz* (2nd ed.). Apress. +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*. + +Reynolds, D. (Ed.). (2014). *The organization ontology*. World Wide Web +Consortium. https://www.w3.org/TR/vocab-org/ diff --git a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md index 4ecb8fe82..fc7ded98f 100644 --- a/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md +++ b/docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md @@ -17,3 +17,15 @@ rules* (confirmed 2024; Amendment 1:2022). World Wide Web Consortium. (2022). *Time ontology in OWL* (W3C Recommendation). https://www.w3.org/TR/owl-time/ + +Codd, E. F. (1970). A relational model of data for large shared data +banks. *Communications of the ACM, 13*(6), 377–387. +https://doi.org/10.1145/362384.362685 + +Date, C. J. (2019). *Database design and relational theory: Normal forms +and all that jazz* (2nd ed.). Apress. +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*. diff --git a/frontend/package.json b/frontend/package.json index 803acd91b..fb52f7948 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.86.1", + "version": "0.86.2", "type": "module", "scripts": { "dev": "vite", diff --git a/lineageweave/__init__.py b/lineageweave/__init__.py index 48bf6e481..efe84890b 100644 --- a/lineageweave/__init__.py +++ b/lineageweave/__init__.py @@ -55,4 +55,4 @@ "sentence_excerpts", ] -__version__ = "0.86.1" +__version__ = "0.86.2" diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index e2877f447..a94b05a0c 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -411,6 +411,15 @@ create table post_organization_mention ( primary key (post_id, corporate_entity_id) ); +-- ADR 0019: store the resolved catalog id on the role row itself. +-- corporate_entity.entity_name is not unique, and mention tables are +-- post-scoped, so reconstructing identity by name is not 3NF. +alter table post_summary_role + add column cataloged_team_id uuid references cataloged_team (team_id); +alter table post_summary_role + add column cataloged_corporate_entity_id uuid + references corporate_entity (corporate_entity_id); + -- --------------------------------------------------------------------- -- Knowledge graph: person/company/post nodes, typed edges. The type -- codes (which kind of node, which kind of edge) are real enums and DO diff --git a/migrations/0019_role_catalog_identity.sql b/migrations/0019_role_catalog_identity.sql new file mode 100644 index 000000000..2881be9b3 --- /dev/null +++ b/migrations/0019_role_catalog_identity.sql @@ -0,0 +1,46 @@ +-- ADR 0019: bind each R&R role to the catalog row resolved for that +-- role. corporate_entity.entity_name is not unique, so a fetch join on +-- name can attach a homonym or duplicate the role. Mention tables are +-- post-scoped, not role-scoped, and cannot reconstruct that binding. + +alter table post_summary_role + add column if not exists cataloged_team_id uuid + references cataloged_team (team_id); + +alter table post_summary_role + add column if not exists cataloged_corporate_entity_id uuid + references corporate_entity (corporate_entity_id); + +-- Teams already have a unique (team_name, affiliated_organization_name) +-- key. Backfill only when that pair was mentioned on the same post. +update post_summary_role as role + set cataloged_team_id = team.team_id + from cataloged_team as team + join post_team_mention as mention + on mention.team_id = team.team_id + where role.actor_type_code = 'prov_team' + and role.cataloged_team_id is null + and mention.post_id = role.post_id + and team.team_name = role.actor_name + and team.affiliated_organization_name + is not distinct from role.affiliated_organization_name; + +-- Organizations: copy a mention only when exactly one mentioned org on +-- that post has this role's actor_name. Two same-named mentions stay +-- unbound rather than guessing. +update post_summary_role role + set cataloged_corporate_entity_id = matched.corporate_entity_id + from ( + select mention.post_id, + org.entity_name, + min(org.corporate_entity_id) as corporate_entity_id + from post_organization_mention mention + join corporate_entity org + on org.corporate_entity_id = mention.corporate_entity_id + group by mention.post_id, org.entity_name + having count(*) = 1 + ) matched + where role.actor_type_code = 'prov_organization' + and role.cataloged_corporate_entity_id is null + and role.post_id = matched.post_id + and role.actor_name = matched.entity_name; diff --git a/migrations/rollback/0019_role_catalog_identity.sql b/migrations/rollback/0019_role_catalog_identity.sql new file mode 100644 index 000000000..5efafed8b --- /dev/null +++ b/migrations/rollback/0019_role_catalog_identity.sql @@ -0,0 +1,8 @@ +-- Drop role-scoped catalog identity columns added by 0019. +-- Mention tables remain; only the role-row binding is removed. + +alter table post_summary_role + drop column if exists cataloged_team_id; + +alter table post_summary_role + drop column if exists cataloged_corporate_entity_id; diff --git a/pyproject.toml b/pyproject.toml index eb2e26318..6e41c7ca7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" 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/scripts/seed_demo_data.py b/scripts/seed_demo_data.py index f6f575ccc..9d246445d 100644 --- a/scripts/seed_demo_data.py +++ b/scripts/seed_demo_data.py @@ -120,6 +120,7 @@ def seed( cur.execute((migrations / "0015_organization_name_resolution.sql").read_text()) cur.execute((migrations / "0016_cross_post_actor_identity.sql").read_text()) cur.execute((migrations / "0018_analysis_run_registry.sql").read_text()) + cur.execute((migrations / "0019_role_catalog_identity.sql").read_text()) cur.execute( """ insert into common_lookup_value (lookup_category, lookup_code, lookup_label, display_order) values diff --git a/tests/test_ingestion_transaction_contracts.py b/tests/test_ingestion_transaction_contracts.py index 641a43cc6..d2994e2c4 100644 --- a/tests/test_ingestion_transaction_contracts.py +++ b/tests/test_ingestion_transaction_contracts.py @@ -209,12 +209,16 @@ async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]: if "from post_summary_event" in compact: return [{"event_text": "검토 완료"}] if "from post_summary_role" in compact: + assert "entity_name" not in compact + assert "cataloged_corporate_entity_id" in compact return [ { "actor_name": "Synthetic Design Team", "responsibility": "도면 검토", "actor_type_code": ACTOR_TYPE_TEAM, "affiliated_organization_name": "Synthetic Energy", + "cataloged_team_id": None, + "cataloged_corporate_entity_id": None, } ] raise AssertionError(f"unexpected fetch query: {compact}") @@ -354,6 +358,14 @@ async def persist_edges(conn, post_id) -> list[Any]: and event[0] == "execute" and "insert into post_organization_mention" in event[1] ) + role_insert = next( + event[1] + for event in events + if isinstance(event, tuple) + and event[0] == "execute" + and "insert into post_summary_role" in event[1] + ) + assert "cataloged_corporate_entity_id" in role_insert assert resolve_index < enter_index < mention_index < exit_index @@ -469,3 +481,29 @@ def test_release_notes_describe_balanced_outer_emphasis_stripping() -> None: assert "strips balanced outer Markdown emphasis from field values" in content assert "while still accepting emphasized field labels" in content assert "preserves Markdown emphasis in field values" not in content + + +def test_role_catalog_identity_is_stored_on_the_role_row() -> None: + """ADR 0019: fetch must not reconstruct organization identity by name.""" + root = Path(__file__).resolve().parents[1] + fetch_source = ( + root / "backend" / "app" / "post_summary_ingestion.py" + ).read_text(encoding="utf-8") + initial = (root / "migrations" / "0001_initial_schema.sql").read_text( + encoding="utf-8" + ) + upgrade = (root / "migrations" / "0019_role_catalog_identity.sql").read_text( + encoding="utf-8" + ) + dockerfile = ( + root / "docker" / "postgres-init" / "Dockerfile" + ).read_text(encoding="utf-8") + changelog = (root / "CHANGELOG.md").read_text(encoding="utf-8") + fetch_sql = fetch_source.split("async def fetch_persisted_summary", 1)[1] + fetch_sql = fetch_sql.split("async def persist_post_summary", 1)[0] + assert "org.entity_name = role.actor_name" not in fetch_sql + assert "cataloged_corporate_entity_id" in fetch_sql + assert "cataloged_team_id" in initial + assert "cataloged_corporate_entity_id" in upgrade + assert "0019_role_catalog_identity.sql" in dockerfile + assert "ADR 0019" in changelog diff --git a/tests/test_person_mention_projection.py b/tests/test_person_mention_projection.py index 5353c2a91..81e63a75f 100644 --- a/tests/test_person_mention_projection.py +++ b/tests/test_person_mention_projection.py @@ -27,16 +27,25 @@ related_for_start, visible_mention_post_ids, ) -from backend.app.post_summary_ingestion import persist_post_summary +from backend.app import post_summary_ingestion as summary_ingestion +from backend.app.post_summary_ingestion import ( + fetch_persisted_summary, + persist_post_summary, +) from lineageweave.keyman_extraction import OUR_SIDE, PersonMention from lineageweave.knowledge_graph import ( EDGE_MENTION, EDGE_MENTION_TEAM, + NODE_CORPORATE_ENTITY, NODE_PERSON, NODE_POST, NODE_TEAM, ) -from lineageweave.post_summary import PostSummary, RoleResponsibility +from lineageweave.post_summary import ( + ACTOR_TYPE_ORGANIZATION, + PostSummary, + RoleResponsibility, +) _ADMIN_DSN = os.environ.get( "LINEAGEWEAVE_TEST_POSTGRES_ADMIN_DSN", "postgresql://localhost/postgres" @@ -451,3 +460,85 @@ def test_team_only_posts_walk_related_nodes(projection_database: str) -> None: database_dsn, post_id, _summary_person_id = projection_database.split("|") asyncio.run(_exercise_team_only_related_walk(database_dsn, post_id)) + + +async def _exercise_homonym_organization_role_binding( + database_dsn: str, + post_id: str, +) -> None: + """A same-named catalog org that this post did not resolve must stay off the role.""" + + connection = await asyncpg.connect(database_dsn) + try: + mentioned_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-MENTIONED', 'Homonym Energy', 'company') + returning corporate_entity_id + """ + ) + ) + other_id = str( + await connection.fetchval( + """ + insert into corporate_entity + (corporate_entity_code, entity_name, entity_level_code) + values ('HOMONYM-OTHER', 'Homonym Energy', 'company') + returning corporate_entity_id + """ + ) + ) + + async def resolve_mentioned_organization(*_args, **_kwargs) -> str: + return mentioned_id + + original = summary_ingestion.get_or_create_corporate_entity + summary_ingestion.get_or_create_corporate_entity = resolve_mentioned_organization + try: + payload = await persist_post_summary( + connection, + post_id, + PostSummary( + korean_summary="동명이인 조직이 일정만 확정했다.", + roles_and_responsibilities=( + RoleResponsibility( + actor_name="Homonym Energy", + responsibility="납품 일정 확정", + actor_type_code=ACTOR_TYPE_ORGANIZATION, + ), + ), + ), + ) + finally: + summary_ingestion.get_or_create_corporate_entity = original + + roles = payload["roles_and_responsibilities"] + assert len(roles) == 1 + assert roles[0]["catalog_node_id"] == mentioned_id + assert roles[0]["catalog_node_type_code"] == NODE_CORPORATE_ENTITY + fetched = await fetch_persisted_summary(connection, post_id) + assert fetched is not None + assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] == mentioned_id + assert fetched["roles_and_responsibilities"][0]["catalog_node_id"] != other_id + mention_ids = [ + str(row["corporate_entity_id"]) + for row in await connection.fetch( + "select corporate_entity_id from post_organization_mention " + "where post_id = $1", + post_id, + ) + ] + assert mention_ids == [mentioned_id] + finally: + await connection.close() + + +def test_homonym_organization_role_binds_the_resolved_catalog_id( + projection_database: str, +) -> None: + """ADR 0019: two catalog orgs can share a display name; the role keeps one id.""" + + database_dsn, post_id, _summary_person_id = projection_database.split("|") + asyncio.run(_exercise_homonym_organization_role_binding(database_dsn, post_id)) diff --git a/uv.lock b/uv.lock index c759df267..e1d2860cf 100644 --- a/uv.lock +++ b/uv.lock @@ -454,7 +454,7 @@ wheels = [ [[package]] name = "lineageweave" -version = "0.86.1" +version = "0.86.2" source = { virtual = "." } dependencies = [ { name = "certifi" }, From 824f0564680f91da99c491ed49655804f5181ad0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 16:28:47 +0000 Subject: [PATCH 2/2] fix: leave tied organization similarity unbound (v0.86.4) Two catalog orgs that share a display name both score 1.0. First-wins walked the homonym. resolve_corporate_entity now returns no id unless the top score is unique (ADR 0021). Co-authored-by: Seongho Bae --- ARCHITECTURE.md | 5 +- .../0.86.4-tied-organization-similarity.md | 3 + CHANGELOG.md | 9 +++ CLAUDE.md | 3 + .../0010-corporate-hierarchy-auto-creation.md | 12 ++- docs/adr/0019-role-catalog-identity.md | 5 +- docs/adr/0021-tied-organization-similarity.md | 80 +++++++++++++++++++ .../RELATED_NODE_TEAM_ORG_REFERENCES.md | 12 +++ frontend/package.json | 2 +- lineageweave/__init__.py | 2 +- .../corporate_hierarchy_resolution.py | 30 ++++--- pyproject.toml | 2 +- tests/test_corporate_hierarchy_resolution.py | 29 +++++++ tests/test_ingestion_transaction_contracts.py | 18 +++++ tests/test_person_mention_projection.py | 56 +++++++++++++ uv.lock | 2 +- 16 files changed, 250 insertions(+), 20 deletions(-) create mode 100644 CHANGELOG.d/0.86.4-tied-organization-similarity.md create mode 100644 docs/adr/0021-tied-organization-similarity.md 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" },