Skip to content
Merged
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
15 changes: 8 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,13 +295,14 @@ when the event instant is missing. Cited evidence names **Time
axis** so the reader can open that post and see which clock
matched. Do not invent an event date or a theta.

Period leftover pairs (ADR 0017 / 0018 / 0048 / 0049 / 0149) are computed in
`lineageweave/leftover_pairs.py` from the residual after a real
GRM/GPCM score, never invented. Missing cells stay out of the
Gabriel factorization. Closest and farthest post–criterion pairs
persist to `report_leftover_pair` and sit above the member list so
a click opens that post. The grouping comparison strip reuses that
authorized leftover store; a leftover pair for a hidden post is omitted.
Organization chips show a unique search-corroborated SKOS companion
(`Demo Corp (DC)`) and stay unlabeled on a miss or tie (ADR 0008 /
ADR 0170). Do not invent an abbreviation from letters. Synthetic
fixtures only.

The grouping comparison strip (ADR 0149) reuses the authorized leftover
pair store described above; a leftover pair for a hidden post is
omitted.

`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
Expand Down
10 changes: 10 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,16 @@ and the offline synthetic-batch script's paced re-implementation of it
(the batch script's own copy was also missing `role_title` persistence
entirely -- fixed alongside this).

Buyer-facing organization chips (affiliate tree, Keyman affiliation,
counterparty, related corporate node) project the *other* corroborated
label as `organization_alias` only when the pair resolves to the same unique
catalog id already carried by the chip, and render `Demo Corp (DC)` (ADR 0170).
A miss, pending row, same-name catalog tie, id mismatch, or identical labels
stays unlabeled. The mapping is not copied onto affiliation rows; it is read
from `organization_name_resolution` at hydrate time. Seed
writes the synthetic `DC` / `Demo Corp` pair so the walk is clickable
after `make seed`.

Also fixed while running this against synthetic embedded-image fixtures:
`image_content.py`'s `_parse_description` required an exact single-pass
`TEXT:`/`CAPTION:`/`TAGS:` match, which was rejecting real vision
Expand Down
14 changes: 14 additions & 0 deletions CHANGELOG.d/2.14.0-organization-alias-chip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 2.14.0 — Corroborated SKOS companion on organization chips

## Added

- Affiliate-org, Keyman-affiliation, and counterparty-org chips show the other
search-corroborated SKOS label when it is unique (`Demo Corp (DC)`). Related
corporate nodes use that companion in place of the ontology class caption.
A miss, pending row, identical labels, or a tie stays unlabeled.
- `make seed` writes the synthetic `DC` / `Demo Corp` pair as
`verify_corroborated` so the parenthetical is clickable after seed.

## References

Miles & Bechhofer (2009); ADR 0008; ADR 0170.
12 changes: 10 additions & 2 deletions backend/app/affiliate_tree_ingestion.py
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
import asyncpg

from lineageweave.affiliate_tree import AffiliationLeaf, CorporateEntityRow, build_affiliate_forest
from lineageweave.organization_alias import attach_organization_aliases
from lineageweave.voc_evidence import first_excerpt_for, sentence_excerpts

from .knowledge_graph import fetch_post_keymen, labels_for_codes
from .organization_name_resolution_ingestion import fetch_corroborated_organization_aliases


async def fetch_affiliate_forest(conn: asyncpg.Connection, post_id: str) -> list[dict[str, Any]]:
"""Ancestor forest of every organization this post's Keymen touch."""
aliases = await fetch_corroborated_organization_aliases(conn)
entity_rows = await conn.fetch(
"""
select corporate_entity_id, parent_entity_id, entity_name, entity_level_code
Expand All @@ -30,7 +33,7 @@ async def fetch_affiliate_forest(conn: asyncpg.Connection, post_id: str) -> list
for row in entity_rows
)
leaves: list[AffiliationLeaf] = []
for person in await fetch_post_keymen(conn, post_id):
for person in await fetch_post_keymen(conn, post_id, organization_aliases=aliases):
for affiliation in person["affiliations"]:
leaves.append(
AffiliationLeaf(
Expand All @@ -43,6 +46,11 @@ async def fetch_affiliate_forest(conn: asyncpg.Connection, post_id: str) -> list
)
forest = [node.to_dict() for node in build_affiliate_forest(entities, tuple(leaves))]
await _attach_lookup_labels(conn, forest)
attach_organization_aliases(
forest,
aliases,
entity_id_key="entity_id",
)
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
return forest


Expand Down Expand Up @@ -94,7 +102,7 @@ async def fetch_voc_evidence(conn: asyncpg.Connection, post_id: str, voc_type_co
post_id,
)
names: list[str] = [row["counterparty_entity_name"] for row in counterparties]
for person in await fetch_post_keymen(conn, post_id):
for person in await fetch_post_keymen(conn, post_id, organization_aliases=()):
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
names.extend(affiliation["organization_name"] for affiliation in person["affiliations"])
return {
"post_id": post_id,
Expand Down
14 changes: 12 additions & 2 deletions backend/app/entity_relationship_ingestion.py
Comment thread
seonghobae marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
EntityRelationshipClient,
OrganizationRelationship,
)
from lineageweave.organization_alias import attach_organization_aliases

from .organization_name_resolution_ingestion import fetch_corroborated_organization_aliases


async def ingest_post_entity_relationships(
Expand Down Expand Up @@ -94,7 +97,8 @@ async def fetch_post_counterparties(conn: asyncpg.Connection, post_id: str) -> l
"""Classified counterparties with a cataloged org id when the name resolves.

Unresolved names keep ``corporate_entity_id`` null -- a missing
hierarchy match is not a guessed neighborhood.
hierarchy match is not a guessed neighborhood. A unique corroborated
SKOS companion is attached when one exists.
"""
rows = await conn.fetch(
"""
Expand All @@ -113,7 +117,13 @@ async def fetch_post_counterparties(conn: asyncpg.Connection, post_id: str) -> l
CorporateEntityCandidate(str(row["corporate_entity_id"]), row["entity_name"])
for row in candidate_rows
]
return attach_resolved_entity_ids(rows, candidates)
payload = attach_resolved_entity_ids(rows, candidates)
attach_organization_aliases(
payload,
await fetch_corroborated_organization_aliases(conn),
name_key="counterparty_entity_name",
)
return payload


async def fetch_relationship_network(
Expand Down
33 changes: 31 additions & 2 deletions backend/app/knowledge_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@

from backend.app.post_eligibility import SOURCE_POST_ELIGIBILITY_SQL
from lineageweave.ontology import ontology_annotations
from lineageweave.organization_alias import (
OrganizationNameAlias,
attach_organization_alias,
attach_organization_aliases,
)
from lineageweave.knowledge_graph import (
EDGE_AFFILIATION,
EDGE_CO_MENTION,
Expand All @@ -35,6 +40,8 @@
select_related_nodes,
)

from .organization_name_resolution_ingestion import fetch_corroborated_organization_aliases


_GRAPH_PROJECTION_LOCK_KEY = "lineageweave:knowledge_graph_projection"

Expand Down Expand Up @@ -67,7 +74,12 @@ async def labels_for_codes(conn: asyncpg.Connection, codes: list[str]) -> dict[s
return {row["lookup_code"]: row["lookup_label"] for row in rows}


async def fetch_post_keymen(conn: asyncpg.Connection, post_id: str) -> list[dict[str, Any]]:
async def fetch_post_keymen(
conn: asyncpg.Connection,
post_id: str,
*,
organization_aliases: tuple[OrganizationNameAlias, ...] | None = None,
) -> list[dict[str, Any]]:
"""Load mentioned people and their affiliations for one post."""
person_rows = await conn.fetch(
"""
Expand Down Expand Up @@ -106,7 +118,10 @@ async def fetch_post_keymen(conn: asyncpg.Connection, post_id: str) -> list[dict
)

side_labels = await labels_for_codes(conn, [row["person_side_code"] for row in person_rows])
return [
aliases = organization_aliases
if aliases is None:
aliases = await fetch_corroborated_organization_aliases(conn)
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
people = [
{
"person_id": str(row["person_id"]),
"person_name": row["person_name"],
Expand All @@ -118,6 +133,13 @@ async def fetch_post_keymen(conn: asyncpg.Connection, post_id: str) -> list[dict
}
for row in person_rows
]
for person in people:
attach_organization_aliases(
person["affiliations"],
aliases,
name_key="organization_name",
)
return people
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.


async def persist_edges_for_post(
Expand Down Expand Up @@ -511,6 +533,7 @@ async def hydrate_related_nodes(
side_labels = await labels_for_codes(
conn, [row["person_side_code"] for row in people.values()]
)
aliases = await fetch_corroborated_organization_aliases(conn) if corp_ids else ()

payload: list[dict[str, Any]] = []
for node_type_code, node_id, score in parsed:
Expand All @@ -531,6 +554,12 @@ async def hydrate_related_nodes(
item["post_body_truncated"] = posts[node_id]["post_body_truncated"]
elif node_type_code == NODE_CORPORATE_ENTITY and node_id in corps:
item["label"] = corps[node_id]["entity_name"]
attach_organization_alias(
item,
aliases,
name_key="label",
entity_id_key="node_id",
)
elif node_type_code == NODE_TEAM and node_id in teams:
item["label"] = teams[node_id]["team_name"]
else:
Expand Down
52 changes: 48 additions & 4 deletions backend/app/organization_name_resolution_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@

import asyncpg

from lineageweave.organization_alias import OrganizationNameAlias
from lineageweave.organization_name_resolution import (
OrganizationNameResolutionClient,
resolve_and_verify_organization_name,
)
from lineageweave.corporate_hierarchy_resolution import OrganizationNameAlias
from lineageweave.corporate_hierarchy_resolution import (
OrganizationNameAlias as CorporateHierarchyOrganizationNameAlias,
)
from lineageweave.relation_verification import (
STATUS_CORROBORATED,
RelationVerificationClient,
Expand All @@ -26,7 +29,7 @@

async def load_corroborated_organization_name_aliases(
conn: asyncpg.Connection,
) -> list[OrganizationNameAlias]:
) -> list[CorporateHierarchyOrganizationNameAlias]:
"""Return search-corroborated SKOS alt/pref pairs, or an empty list.

Callers with a stub connection that has no ``fetch`` (the early-return
Expand All @@ -38,10 +41,10 @@ async def load_corroborated_organization_name_aliases(
if not callable(fetch):
return []
rows = await fetch(_CORROBORATED_ALIAS_SQL, STATUS_CORROBORATED)
aliases: list[OrganizationNameAlias] = []
aliases: list[CorporateHierarchyOrganizationNameAlias] = []
for row in rows:
aliases.append(
OrganizationNameAlias(
CorporateHierarchyOrganizationNameAlias(
alt_label=row["raw_organization_name"],
pref_label=row["resolved_organization_name"],
)
Expand Down Expand Up @@ -103,3 +106,44 @@ async def resolve_organization_name(
if resolution.verification_status_code == STATUS_CORROBORATED:
return resolution.resolved_organization_name
return raw_name


async def fetch_corroborated_organization_aliases(
conn: asyncpg.Connection,
) -> tuple[OrganizationNameAlias, ...]:
"""Load corroborated pairs with a unique current catalog target, if any.

Pending and uncorroborated rows stay out. The statement is a static
literal; only the status code is bound. Same-named catalog rows fail
closed with a null target id.
"""
rows = await conn.fetch(
"""
select resolution.raw_organization_name,
resolution.resolved_organization_name,
case when count(distinct entity.corporate_entity_id) = 1
then min(entity.corporate_entity_id::text)
else null
end as corporate_entity_id
from organization_name_resolution as resolution
left join corporate_entity as entity
on entity.entity_name = resolution.raw_organization_name
or entity.entity_name = resolution.resolved_organization_name
Comment thread
seonghobae marked this conversation as resolved.
where resolution.verification_status_code = $1
group by resolution.raw_organization_name,
resolution.resolved_organization_name
""",
STATUS_CORROBORATED,
)
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
return tuple(
OrganizationNameAlias(
alt_label=row["raw_organization_name"],
pref_label=row["resolved_organization_name"],
corporate_entity_id=(
str(row["corporate_entity_id"])
if row["corporate_entity_id"] is not None
else None
),
)
for row in rows
)
61 changes: 61 additions & 0 deletions docs/adr/0170-organization-alias-chip-caption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# ADR 0170 — Corroborated SKOS companion labels appear on organization chips

**Decision status:** Accepted
**Date:** 2026-08-23

## Context

[ADR 0008](0008-organization-abbreviation-resolution.md) already persists a
search-corroborated `skos:altLabel` / `skos:prefLabel` pair (Miles &
Bechhofer, 2009) in `organization_name_resolution`. Catalog matching still
compares mentions to `corporate_entity.entity_name`, so a chip that only
prints that name hides the short form the source used. After seed, a buyer
who reads "DC" in a post cannot see that the Demo Corp chip is the same
organization.

Catalog creation and mention-to-catalog resolution remain a separate stack.
This record only decides the buyer-visible caption, but that caption must be
bound to the catalog id already carried by the chip. A display-name match alone
cannot prove identity because `corporate_entity.entity_name` is not unique.

## Decision

1. Load every `verify_corroborated` pair as `OrganizationNameAlias`, bound to a
target `corporate_entity_id` only when exactly one current catalog row has
either stored label. Pending and uncorroborated rows stay out.
2. Attach the other label as `organization_alias` only when the pair's target
id equals the catalog id already stored on the displayed record. An unbound
record, catalog tie, id mismatch, name miss, identical labels, or two
distinct companions stays unlabeled. The product never invents an
abbreviation from letters.
3. Render the chip as `Demo Corp (DC)` when a companion is present, otherwise
the catalog name. Affiliate-org, Keyman-affiliation, and counterparty-org
chips reuse the existing accessible-name keys with that caption.
Related corporate nodes use the companion in place of the ontology class
caption when one is present, and keep the ontology caption otherwise.
4. Seed the synthetic pair `DC` / `Demo Corp` as `verify_corroborated` so the
walk is clickable after `make seed`. Real organization names must not
appear in fixtures.

## Consequences

- The raw/canonical mapping remains in `organization_name_resolution` (3NF).
Chips project the companion at read time; they do not duplicate it onto
affiliation or counterparty rows, and a same-named catalog row cannot borrow
another row's alias.
- Default frontend tests stay on unlabeled names. A stub option supplies the
companion so the parenthetical is covered without changing the unlabeled
walk.
- Fail-closed on a tie matches ADR 0026's "do not guess" discipline for
organization identity.

## Related

Extends [ADR 0008](0008-organization-abbreviation-resolution.md). Complements
[ADR 0002](0002-figma-access-boundary.md) chip presentation. Does not change
catalog create/lock policy in [ADR 0012](0012-corporate-entity-creation-lock.md)
or [ADR 0026](0026-tied-organization-similarity.md).

## References (APA 7th)

Miles, A., & Bechhofer, S. (Eds.). (2009). *SKOS simple knowledge organization system reference*. World Wide Web Consortium. https://www.w3.org/TR/skos-reference/
Loading
Loading