Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.d/0.86.4-tied-organization-similarity.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 8 additions & 4 deletions docs/adr/0010-corporate-hierarchy-auto-creation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“On a miss” now includes a tied top score. A tie is an ambiguous existing catalog match, not an unseen name. Creation must not run. Keep ADR 0010 for names that no candidate scores at or above min_similarity.

(`lineageweave.corporate_hierarchy_inference.CorporateHierarchyInferenceClient`)
to propose this organization's place in the Group -> Company ->
Expand Down Expand Up @@ -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/
5 changes: 3 additions & 2 deletions docs/adr/0019-role-catalog-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions docs/adr/0021-tied-organization-similarity.md
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Persist does not call resolve. It calls get_or_create_corporate_entity. The “stores no cataloged_corporate_entity_id / writes no mention / does not create a row” claim is false when orchestrator + Searxng are configured (main.py summary persist; compose always sets SEARXNG_BASE_URL).

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keyman affiliation also uses get_or_create, not resolve. Only entity-relationship counterparties treat None as “do not invent a link.” The shared-function claim is not true on the buyer persist path.

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*.
12 changes: 12 additions & 0 deletions docs/doctoring/RELATED_NODE_TEAM_ORG_REFERENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.86.2",
"version": "0.86.4",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion lineageweave/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@
"sentence_excerpts",
]

__version__ = "0.86.2"
__version__ = "0.86.4"
30 changes: 21 additions & 9 deletions lineageweave/corporate_hierarchy_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,26 +63,36 @@ 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(
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 len(best_ids) != 1:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This None is correct for resolve. Persist and Keyman call get_or_create_corporate_entity, which treats the same None as “create if inference + verification are live.” A tie that already cleared min_similarity must not enter _create_entity. Distinguish miss (below threshold) from tie (ambiguous existing catalog match).

return None
return best_ids[0]
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
29 changes: 29 additions & 0 deletions tests/test_corporate_hierarchy_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
18 changes: 18 additions & 0 deletions tests/test_ingestion_transaction_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
56 changes: 56 additions & 0 deletions tests/test_person_mention_projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This persist uses default Null clients, so it cannot catch create-on-tie. Repeat it with available inference + corroborating verification and assert no AUTO- row, null catalog_node_id, and zero post_organization_mention.

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))
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading