Stage-5 cross-run canonical stability — TECH (ID-81.3)
Stage-5 cross-run canonical stability — TECH (ID-81.3)
Section titled “Stage-5 cross-run canonical stability — TECH (ID-81.3)”Spec slug: ID-81-canonical-stability
Subtask: ID-81.3 (TECH, follows ratified {81.2} PRODUCT; precedes {81.4} PLAN per Q-PLANNER-2 fresh-per-Subtask discipline)
Parent Task: ID-81 — Stage-5 cross-run canonical stability (is_existing_canonical seeding)
Author: task-planner (opus-4-8 [1m], thinking: max, isolation=worktree) on cmux-worker-subo-id81-27a24fe2.
This TECH translates the 14 ratified PRODUCT invariants into one Proposed change each. §3 is the 1:1 invariant→change deliverable. UK English throughout.
§1. Context
Section titled “§1. Context”1.1 What is being built
Section titled “1.1 What is being built”Stage-5’s _run_stage_5_resolution (scripts/cocoindex_pipeline/stage_5.py:108-307) resolves each run’s entity_mentions in op_id-scoped isolation (ID-53 Inv-5) — it reads only WHERE op_id = $current (_select_run_entity_mentions, :82-105), so a new run cannot see canonicals prior runs (or app-side NULL-op_id writes) already materialised. We add existing-canonical seeding: an op-agnostic READ supplies the already-existing canonical names of the relevant entity_type to resolve_entities via its is_existing_canonical callback + existing_policy=PINNED, so a new mention matching an existing canonical chains UNDER it. The write-back path (the bl-225 collapse + op_id-scoped DELETE/UPDATE, :197-307) is UNCHANGED — that invariance is the load-bearing Inv-5 / Inv-11 / bl-225 safety argument (§3 Inv-7/Inv-10/Inv-11; §6).
PRODUCT.md defines the observable behaviour (Inv-1..Inv-14); this TECH does not restate it.
1.2 Code-intelligence orientation (this TECH author, fresh confirmation)
Section titled “1.2 Code-intelligence orientation (this TECH author, fresh confirmation)”Per .gitnexus/CLAUDE.md “Always Do” + the write-tech-spec orientation step. This TECH re-ran gitnexus_context (not paraphrased — verbatim below) against the indexed knowledge-hub graph:
gitnexus_context({name: "_run_stage_5_resolution", repo: "knowledge-hub"})→uid:Function:scripts/cocoindex_pipeline/stage_5.py:_run_stage_5_resolution(startLine 107, endLine 306).- incoming.calls:
flow.py:app_main(the single production caller) + the three regression tests (test_collapse_no_update_needed,test_collapse_with_survivor_update,test_zero_confidence_survivor_not_treated_as_missingintest_cocoindex_stage_5_resolution.py). - outgoing.calls:
_preload_entity_aliases,_select_run_entity_mentions,_FlowStageCounter.increment. processes: [] (Stage-5 is an out-of-bandapp_maincontinuation, not part of an indexed reactive flow).
This corroborates RESEARCH §1 verbatim: single production caller (app_main), LOW fanout. The blast radius of the seeding change is bounded to _run_stage_5_resolution + the one NEW private reader this TECH adds (_select_existing_canonical_roster, §3 Inv-6). gitnexus carries no per-symbol risk verdict in context; the single-production-caller fact IS the orientation evidence. Executor tool-discipline is embedded in §5 (impact-before-edit + detect-changes-before-commit, Inv-2/Inv-3/Inv-7 propagation per .ast-dataflow/CLAUDE.md).
1.3 Relevant code (read in full for this TECH)
Section titled “1.3 Relevant code (read in full for this TECH)”scripts/cocoindex_pipeline/stage_5.py:108-307—_run_stage_5_resolution. The seam::131alias preload (Inv-10 — UNCHANGED).:134-136op_id-scoped row read (UNCHANGED).:145-154name_pairsbuild with alias-applied canonicals (UNCHANGED —name_pairsis the op_id-scoped write-back domain).:180-182names_by_typefan-out — the seed-merge insertion point is AFTER this loop.:186-195theresolve_entities(...)call — the wiring site (today NOis_existing_canonical/existing_policyargs;sorted(names)is passed positionally).:197-307bl-225 collapse + Step-6 op_id-scoped DELETE (AND op_id = $2,:270) / UPDATE (WHERE id = $2 AND op_id = $3,:282) — UNCHANGED.
scripts/cocoindex_pipeline/_coco_api.py:55-132— the façade._SYMBOL_SOURCES(:55-74) re-exportsresolve_entities,ResolvedEntities,PairDecisionbut NOTExistingCanonicalPolicy— confirmed ABSENT first-hand.__all__(:76-88) and theTYPE_CHECKINGblock (:116-132) both omit it. PEP-562 lazy__getattr__(:91-108).scripts/cocoindex_pipeline/entity_embedder.py:67-107—KhEntityEmbedder(no cache; thin LiteLLM pass-through). Confirms the embedding-cost seam: every name insorted(set(entities))is embedded (cost bound = candidate-prefilter, not cache — §3 Inv via §4 PERF).scripts/cocoindex_pipeline/pair_resolver.py:83-237—KhPairResolver, entity_type-keyed determinism cache (ID-53 Inv-14). UNCHANGED by this feature. Under PINNED, existings skip the resolver entirely (pass_1), so the resolver-call volume drops, not rises.scripts/tests/test_cocoindex_stage_5_resolution.py— the real-body test fixture. Critical: the_stub_resolver_chainhelper (:266-293) defines_fake_resolve_entities(names, *, embedder, resolve_pair)— its signature does NOT acceptis_existing_canonical/existing_policy. The wiring change WILL break this stub unless the stub is updated to absorb the two new kwargs (§3 Inv-1 / §5 testing).- cocoindex 1.0.3 source
cocoindex/ops/entity_resolution/__init__.py(re-read first-hand):resolve_entities(entities, *, embedder, resolve_pair, is_existing_canonical=None, existing_policy=PINNED, on_resolution=None, max_distance=0.3, top_n=5)(:184-194) — defaultexisting_policyisPINNED.entity_list = sorted(set(entities))(:201) — pass_1 order derives from the sorted-set of the SAME iterable; this is the determinism mechanism for Inv-14.is_existingcomputed per name viais_existing_canonical(name)(:216-220) — a per-name boolean predicate, NOT an identity test.- PINNED split (
:262-264):pass_1 = [is_existing],pass_2 = [not is_existing]. _new_winsunder PINNED (:350-353):if matched_info.is_existing: return False— new NEVER wins against an existing match (the Inv-3 hard guarantee).
1.4 Migration-history finding (decisive — re-verified first-hand)
Section titled “1.4 Migration-history finding (decisive — re-verified first-hand)”pg_trgm is currently NOT installed on prod or staging. Migration 20260428122115_relocate_extensions_to_extensions_schema.sql:41-50 DROP EXTENSION pg_trgm (it had zero in-use indexes; prod never had it). The squash baseline had installed it WITH SCHEMA "public" (20260416102457:48) — the relocate migration’s deliberate intent is that extensions live in the extensions schema, never public. Consequence for the migration (§4): the gin_trgm_ops index migration MUST re-create the extension WITH SCHEMA extensions (not public), and reach the % operator / gin_trgm_ops opclass via SET search_path = public, extensions (the pattern at 20260528122543:13). There are ZERO existing gin_trgm_ops indexes in the corpus — this is a greenfield trigram surface.
§2. Design overview
Section titled “§2. Design overview”Four mechanical slices, each mapping to a coherent ownership boundary (decomposed by {81.4} PLAN.md):
- Migration —
CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions+ agin (canonical_name extensions.gin_trgm_ops)index onentity_mentions. CLI only (supabase migration new+db push). - Façade — add
ExistingCanonicalPolicyto_coco_api.py(_SYMBOL_SOURCES+__all__+TYPE_CHECKING). - Seed-roster reader — a new private async function
_select_existing_canonical_roster(db_pool, entity_type, run_names)instage_5.pyreturning the candidate-prefiltered op-agnostic roster, plus theis_existing_canonicalset-membership predicate. - Wiring — merge the per-type roster into
names_by_type[entity_type](after:182, before:186) and addis_existing_canonical=+existing_policy=ExistingCanonicalPolicy.PINNEDto theresolve_entities(...)call.
The seed merges into the resolver’s input name set only — never into name_pairs (the op_id-scoped write-back domain). This keeps Inv-5 by-construction (§6).
flowchart TD A["_select_run_entity_mentions(op_id)<br/>op_id-scoped READ (UNCHANGED)"] --> B["name_pairs<br/>(op_id-scoped write-back domain — NEVER extended)"] B --> C["names_by_type[entity_type] fan-out (:180-182)"] D["_select_existing_canonical_roster(entity_type, run_names)<br/>op-AGNOSTIC READ + pg_trgm prefilter (NEW)"] --> E["roster_by_type[entity_type]"] C --> F["names_by_type[et] |= roster_by_type[et]<br/>(SEED MERGE — input set only, after :182)"] E --> F F --> G["resolve_entities(sorted(names_by_type[et]),<br/>is_existing_canonical=in roster_by_type[et],<br/>existing_policy=PINNED)"] G --> H["bl-225 collapse + Step-6 DELETE/UPDATE<br/>iterates name_pairs ONLY, AND op_id=$current<br/>(UNCHANGED — :197-307)"] B --> H§3. Proposed changes — one per PRODUCT invariant (1:1)
Section titled “§3. Proposed changes — one per PRODUCT invariant (1:1)”Each row is the implementation realising the correspondingly-numbered PRODUCT invariant. Several invariants are realised by the SAME code construct (e.g. the PINNED wiring realises Inv-1/2/3/4; the by-construction write-scope realises Inv-7/Inv-11). Where so, the change is described once and cross-referenced. Acceptance criteria live in PRODUCT.md; the test that proves each is in §5.
Inv-1 — New mention chains under existing canonical (PINNED seeding)
Section titled “Inv-1 — New mention chains under existing canonical (PINNED seeding)”Change PC-1. At the resolve_entities call (stage_5.py:186-195), add two keyword arguments:
resolved_by_type[entity_type] = await resolve_entities( sorted(names_by_type[entity_type]), # names already merged with the seed roster (PC-6) embedder=KhEntityEmbedder(), resolve_pair=KhPairResolver(db_pool=db_pool, op_id=meta.op_id, entity_type=entity_type), is_existing_canonical=lambda name, _roster=roster_by_type[entity_type]: name in _roster, # PC-6 predicate existing_policy=ExistingCanonicalPolicy.PINNED,)is_existing_canonical is the per-name set-membership predicate (PC-6). Under PINNED, a new mention near a seeded existing chains under it via _new_wins→return False (cocoindex :350-353). The lambda name, _roster=... default-arg binding captures the per-type roster (avoids the late-binding closure trap in the per-type loop). Test-stub coupling: the _stub_resolver_chain fake at test_...:278 MUST be updated to _fake_resolve_entities(names, *, embedder, resolve_pair, is_existing_canonical=None, existing_policy=None) or the existing three tests break at call time (§5).
Inv-2 — Same entity across runs → one canonical
Section titled “Inv-2 — Same entity across runs → one canonical”Change PC-2. No NEW code beyond PC-1+PC-6 — Inv-2 is the cross-run consequence of PINNED seeding + the roster reading prior-run canonicals. The implementation guarantee is: run 2’s roster includes run 1’s pinned canonical (PC-6 op-agnostic read), and PINNED chains run 2’s near-match under it (PC-1). Verified by integration test (§5, MUST NOT run against shared staging this session).
Inv-3 — Prior-run canonical never demoted/renamed/repointed
Section titled “Inv-3 — Prior-run canonical never demoted/renamed/repointed”Change PC-3. Realised entirely by existing_policy=PINNED (PC-1). cocoindex :262-264 puts every existing in pass_1 (seeded as its own canonical, no resolver call); _new_wins :350-353 returns False for any matched existing. No KH-side code enforces this — it is the cocoindex PINNED contract, empirically verified PRESENT (RESEARCH §Verification). The KhPairResolver “longer-name-wins” preference (pair_resolver.py:139-141) is correctly OVERRIDDEN by PINNED for matched existings; no change to pair_resolver.py.
Inv-4 — Two existing canonicals never merged
Section titled “Inv-4 — Two existing canonicals never merged”Change PC-4. Realised by existing_policy=PINNED (PC-1): both existings are in pass_1, neither runs the resolver against the other (cocoindex :269-279). No KH-side code. Unit-asserted via the seed roster supplying two distinct existings with no matching new mention (§5).
Inv-5 — Existing with no matching new mention → no resolution event
Section titled “Inv-5 — Existing with no matching new mention → no resolution event”Change PC-5. Realised by PINNED pass_1 semantics (PC-1) + the fact that a seeded existing with no near in-flight mention is never a name_pairs member, so the write-back never touches it. Concretely: a seeded existing that no in-flight name_pairs row resolves to forms NO collision_group (the group key is (content_item_id, entity_type, resolved) built ONLY from name_pairs, stage_5.py:221-235), so it appears in neither updates nor deletes. Unit-asserted: updates == [] AND deletes == [] when the run’s only mentions are unrelated (§5).
Inv-6 — Roster is op-AGNOSTIC (prior-run + NULL-op_id eligible) + candidate-prefiltered
Section titled “Inv-6 — Roster is op-AGNOSTIC (prior-run + NULL-op_id eligible) + candidate-prefiltered”Change PC-6 (the new reader — the core of this feature). Add a private async function to stage_5.py, sited immediately after _select_run_entity_mentions (:105):
async def _select_existing_canonical_roster( db_pool: asyncpg.Pool, entity_type: str, run_names: set[str]) -> set[str]: """Op-agnostic existing-canonical roster for one entity_type, candidate-prefiltered.
Reads DISTINCT canonical_name across ALL op_ids (prior-run rows + NULL-op_id app-side rows) for `entity_type` (ID-81 Inv-6), bounded to canonicals lexically plausible against this run's names (pg_trgm similarity OR exact case-fold), per ID-81 Inv-1/§4 PERF. Returns a SET for O(1) set-membership (the `is_existing_canonical` predicate, Inv-7 self-membership caveat: a name that is both 'existing' and 'in this run' is simply is_existing=True — pinned, never demoted). NOT a UNION with entity_aliases (Inv-12). Workspace-agnostic at v1 (Inv-9 — no workspace_id at Stage-5).
`run_names` is the per-type set from `names_by_type[entity_type]` (`stage_5.py:180`, typed `dict[str, set[str]]`) — a set, NOT a list. """ if not run_names: return set() probe = list(run_names) # asyncpg ANY() binds a list; order irrelevant (set membership) rows = await db_pool.fetch( "SELECT DISTINCT canonical_name " "FROM public.entity_mentions " "WHERE entity_type = $1 " # SCHEMA-QUALIFIED operator: the asyncpg pool session search_path is the # default ("$user", public) — it does NOT include `extensions` (where # pg_trgm lives, §4), so a bare `%` is UNRESOLVABLE at runtime. Qualify # via OPERATOR(extensions.%) so the trigram operator resolves regardless # of search_path. (§6 HIGH-risk row — this is the safe form to copy.) " AND ( canonical_name OPERATOR(extensions.%) ANY($2::text[]) " # pg_trgm near-match " OR lower(canonical_name) = ANY($3::text[]) )", # exact case-fold entity_type, probe, [n.lower() for n in probe], ) return {row["canonical_name"] for row in rows}The OPERATOR(extensions.%) is the schema-qualified pg_trgm similarity operator (default pg_trgm.similarity_threshold = 0.3). It MUST be schema-qualified: the asyncpg pool (flow.py:2547) runs with the default session search_path ("$user", public), which does NOT include extensions, so a bare canonical_name % ANY(...) is unresolvable and errors at runtime (§6 HIGH risk). The qualified OPERATOR(extensions.%) resolves the trigram operator independent of search_path. (Alternative — NOT chosen here, but acceptable: add server_settings={"search_path": "public, extensions"} to the pool at flow.py:2547 and use a bare %; that widens the pool’s search_path for ALL queries, so the schema-qualified operator is the narrower, lower-blast-radius fix.) The exact case-fold arm (lower(canonical_name) = ANY(...)) is backed by the EXISTING idx_entity_mentions_canonical_lower btree (20260416102457:4803) — it catches the high-value “same string, different casing, ingested in a prior run” case for free even if a trigram miss occurs. The OPERATOR(extensions.%) arm is backed by the NEW gin_trgm_ops index (§4). is_existing_canonical predicate: a name in roster set-membership test (PC-1 lambda), NOT an identity test — RESEARCH §2 self-membership caveat honoured.
Inv-7 — Op-agnostic READ, op_id-scoped WRITE (Inv-5 preserved by-construction)
Section titled “Inv-7 — Op-agnostic READ, op_id-scoped WRITE (Inv-5 preserved by-construction)”Change PC-7 (the by-construction guarantee — the most load-bearing, and it is a NON-change). The seed roster (PC-6) returns STRINGS, merged into names_by_type[entity_type] (the resolver INPUT set), NEVER into name_pairs. Code-shape proof:
name_pairs(stage_5.py:145-154) is built ONLY from_select_run_entity_mentions(op_id)rows — op_id-scoped. PC-6 does NOT touch this list. It is never extended with seed members.- The seed merge (PC-6→PC-1) targets
names_by_type[entity_type]AFTER the:180-182fan-out and BEFORE:186. Only the per-type NAME SET fed toresolve_entitiesgrows. - The bl-225 collapse (
:218-253) iteratesname_pairsexclusively;collision_groupskeys are built fromname_pairstuples (:221-235). A seeded foreign-op canonical, having noname_pairsrow, contributes norow_idtoupdatesordeletes. - Step-6 DELETE retains
AND op_id = $2(:270); UPDATE retainsWHERE id = $2 AND op_id = $3(:282). Both guards UNCHANGED.
Therefore a prior-run / NULL-op_id canonical is READ (a string) but its ROW is physically unreachable by any write. Inv-5 holds by-construction; seeding does not weaken it. The Executor MUST NOT add seed members to name_pairs under any refactor — that is the single line of defence and §5 asserts it.
Inv-8 — Roster is entity_type-scoped
Section titled “Inv-8 — Roster is entity_type-scoped”Change PC-8. Realised by PC-6’s WHERE entity_type = $1 + the EXISTING per-entity_type batch loop (stage_5.py:186). The reader is called once per entity_type WITHIN that loop, so an organisation roster is never offered to a technology batch. No new scoping code beyond the $1 bind. Unit-asserted: a technology “Cisco” mention does not chain under an organisation “Cisco” seed (§5).
Inv-9 — Workspace-agnostic at v1 (DESIGN-NOTE, no test obligation)
Section titled “Inv-9 — Workspace-agnostic at v1 (DESIGN-NOTE, no test obligation)”Change PC-9 (deliberate absence-of-feature). PC-6 partitions by entity_type ONLY — there is NO workspace_id filter, because no queryable workspace edge exists at Stage-5 (RESEARCH §2: entity_mentions has no workspace_id; the content_item_workspaces junction is ingest-empty per ID-69 BI-1). This is verified by CODE REVIEW at implementation time (the reader carries no workspace predicate), not a unit test — per PRODUCT Inv-9 acceptance (DESIGN-NOTE). OQ-81-WORKSPACE-SCOPE pending; a parent override is a spec amendment.
Inv-10 — bl-225 collapse holds under seeding-induced same-document collision
Section titled “Inv-10 — bl-225 collapse holds under seeding-induced same-document collision”Change PC-10 (a NON-change to the collapse, + a NEW regression test). The bl-225 collapse logic (stage_5.py:197-307) is UNTOUCHED. Seeding changes only WHICH value rows resolve to — resolved can now be a seeded existing canonical — but the grouping by (content_item_id, entity_type, resolved), the highest-confidence-then-smallest-id survivor pick (:246-248), the DELETE-first ordering (:265-273), and the structured cocoindex.stage_5.collapsed log (:293-302) all absorb a seeding-induced same-doc collision identically. The implementation work is the NEW regression test (§5, Inv-10) — mirrors test_collapse_with_survivor_update with a seeded resolution target.
Inv-11 — Foreign-op rows READ as input, NEVER UPDATEd/DELETEd (ID-53 Inv-5 non-regression)
Section titled “Inv-11 — Foreign-op rows READ as input, NEVER UPDATEd/DELETEd (ID-53 Inv-5 non-regression)”Change PC-11. Same construct as PC-7 (restated as the explicit ID-53 Inv-5 criterion). No additional code; §5 adds the deliberate assertion that every deletes/updates row id was selected by _select_run_entity_mentions(op_id=current) and that the (foreign-op) seed roster contributes no row id to either list.
Inv-12 — Alias rewrite still applied BEFORE resolution; NOT folded into the seed roster
Section titled “Inv-12 — Alias rewrite still applied BEFORE resolution; NOT folded into the seed roster”Change PC-12. _preload_entity_aliases (:66-79) + the alias-application in the name_pairs build (:145-154) are UNCHANGED — the alias rewrite stays pre-resolve_entities. PC-6’s reader source is entity_mentions.canonical_name ONLY (no UNION with entity_aliases). Because the roster reads the SAME column the alias-applied name_pairs resolve against, the roster and the run operate in one alias-applied namespace. The run_names passed to PC-6 (the prefilter probe) are the alias-applied per-doc canonicals (built from name_pairs, §3 PC-6 wiring), keeping the prefilter consistent with the alias namespace. No KhPairResolver / alias change.
Inv-13 — Unresolved mention retains per-document canonical
Section titled “Inv-13 — Unresolved mention retains per-document canonical”Change PC-13. Realised by the EXISTING canonical_of self-return + the is None→self guard (stage_5.py:228-235). Seeding ADDS chaining targets to the resolver’s candidate set (via the merged names_by_type and the faiss index); it never forces a match — a name with no candidate within max_distance=0.3 (after the prefilter and inside cocoindex’s faiss search, :244-260) still resolves to itself. No change. §5 adds coverage: a unique mention with a NON-empty seed roster still resolves to itself and is absent from updates.
Inv-14 — Re-run produces same mapping; seeds determinise via the entities iterable
Section titled “Inv-14 — Re-run produces same mapping; seeds determinise via the entities iterable”Change PC-14. The seed roster is merged INTO names_by_type[entity_type] and passed to resolve_entities as sorted(names_by_type[entity_type]) (PC-1) — i.e. seeds are MEMBERS of the entities iterable, so cocoindex’s internal sorted(set(entities)) (:201) normalises pass_1 order across runs. The Executor MUST NOT pass seeds out-of-band (e.g. a separate pre-seeding call or appending after the resolver returns) — that would un-determinise pass_1 add-order. The KhPairResolver determinism cache (ID-53 Inv-14) is unchanged and replays prior pair decisions. PINNED additionally guarantees no cross-run canonical flip. This is PRODUCT Inv-14’s “TECH wiring constraint” — honoured here. Integration-tested via full_reprocess (§5, MUST NOT run against shared staging this session).
Façade prerequisite (wiring dependency for PC-1)
Section titled “Façade prerequisite (wiring dependency for PC-1)”Change PC-FACADE. Add ExistingCanonicalPolicy to _coco_api.py (confirmed ABSENT first-hand):
_SYMBOL_SOURCES(:55-74): add"ExistingCanonicalPolicy": ("cocoindex.ops.entity_resolution", "ExistingCanonicalPolicy"),__all__(:76-88): add"ExistingCanonicalPolicy",(keep alphabetical).TYPE_CHECKINGblock (:124-128): addExistingCanonicalPolicyto thefrom cocoindex.ops.entity_resolution import (...)tuple.
is_existing_canonical is a plain Callable argument — no symbol re-export needed; only the policy enum. stage_5.py then imports it function-locally alongside resolve_entities (:168-170): from scripts.cocoindex_pipeline._coco_api import resolve_entities, ExistingCanonicalPolicy. Façade-guard count bump (REQUIRED): test_coco_api_facade.py asserts a HARDCODED symbol count (len(...) == 11) over _SYMBOL_SOURCES / __all__; adding ExistingCanonicalPolicy takes that to 12, so the count assertion (and any explicit symbol-list assertion) MUST be bumped in the SAME slice as PC-FACADE or the existing façade test fails (§5).
§4. PERF plan
Section titled “§4. PERF plan”| Concern | Plan |
|---|---|
| Primary embedding-cost bound | Candidate-prefilter (PC-6): seed only existing canonicals lexically plausible against this run’s names, per entity_type. Collapses the unbounded O(corpus) historical roster to “existings near this run”. The exact case-fold arm reuses the run’s own embeddings for free (those names are in names_by_type regardless). RESEARCH §4 DECISION (c)+(e). |
gin_trgm_ops migration | NEW migration via CLI only (supabase migration new id81_entity_mentions_trgm_index + db push — NEVER MCP execute_sql / apply_migration per CLAUDE.md). Body: (1) CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions; (2) CREATE INDEX IF NOT EXISTS idx_entity_mentions_canonical_trgm ON public.entity_mentions USING gin (canonical_name extensions.gin_trgm_ops);. Schema-qualify the opclass as extensions.gin_trgm_ops (the extension is NOT in public — §1.4). No new function → no SET search_path / REVOKE anon obligation (DDL-only migration). |
pg_trgm extension prerequisite (decisive — re-verified) | pg_trgm is currently NOT installed (dropped by 20260428122115, §1.4). The migration’s CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA extensions is therefore a real install, not a no-op. Executor MUST verify post-push: SELECT extname, n.nspname FROM pg_extension e JOIN pg_namespace n ON e.extnamespace=n.oid WHERE extname='pg_trgm'; returns extensions (NOT public). The % operator resolves via search_path = public, extensions. |
| faiss IndexFlatIP build | Second-order (RESEARCH §4): exact IP search over a few-thousand 1024-d vectors is sub-second on faiss-cpu==1.14.2. Embedding round-trips dominate, which the prefilter bounds. No faiss tuning needed. |
| Per-entity_type batching | ALREADY present (stage_5.py:186). PC-6 is called inside that loop, so each batch’s roster + faiss index stay one-type-scoped. No new batching. |
| Resolver-call volume | Under PINNED, existings skip the resolver (pass_1). Seeding therefore REDUCES KhPairResolver LLM/cache calls relative to a hypothetical PREFERRED, never raises them. |
| Persistent embedding cache | DEFERRED (v1 non-goal, PRODUCT §6) — embedding reuse via the cocoindex public API is impossible (no precomputed-embedding param; entity_mentions has no embedding column). Build only if telemetry shows the prefiltered roster still dominates. |
Recall envelope (deliberate, not a defect): the lexical prefilter MAY miss an embedding-space near-match that is not lexically close — rare for short domain-specific entity names where the embedder mostly catches casing/punctuation/abbreviation variants that ARE lexically close. PRODUCT §6 documents this as an accepted v1 recall loss. The exact case-fold arm guarantees the dominant “same string, prior run” case is never missed.
§5. Testing and validation
Section titled “§5. Testing and validation”All Python tests run via python3 -m pytest scripts/tests/ (CLAUDE.md). Real-body unit tests extend scripts/tests/test_cocoindex_stage_5_resolution.py using the existing _stub_resolver_chain pattern. Tests verify real behaviour, not implementation (docs/reference/test-philosophy.md).
Prerequisite test-harness change (BLOCKS all unit tests): update _stub_resolver_chain’s _fake_resolve_entities (test_...:278) to absorb the new kwargs: async def _fake_resolve_entities(names, *, embedder, resolve_pair, is_existing_canonical=None, existing_policy=None). Extend _FakePool.fetch to answer the new _select_existing_canonical_roster query (the SELECT DISTINCT canonical_name ... WHERE entity_type = $1 AND (... % ... OR lower(...) = ...) branch) by returning a test-supplied roster per entity_type. WITHOUT this, the three EXISTING tests break at call time (the new kwargs are passed unconditionally). Bump the test_coco_api_facade.py hardcoded symbol-count assertion (len(...) == 11 → 12) when ExistingCanonicalPolicy is added (PC-FACADE) — same slice, or the façade test fails.
| PRODUCT Inv | Test (new unless noted) | Shape | Asserts |
|---|---|---|---|
| Inv-1 | test_new_mention_chains_under_seeded_existing | real-body unit, _stub_resolver_chain + roster supplied | seed roster supplies “ISO 27001”; in-flight mention “ISO27001” resolves to “ISO 27001”; the in-flight row’s canonical = seeded value |
| Inv-2 | cross-run integration | integration (Python, scripts/tests or __tests__/integration shape) — MUST NOT run against shared staging this session | same entity + type in run 1 & run 2 (distinct op_ids) → SELECT DISTINCT canonical_name returns ONE value (run 1’s) |
| Inv-3 | cross-run integration (PINNED-override-of-longer-name) | integration — MUST NOT run against shared staging | run 1 pins a short canonical; run 2’s longer variant chains under it; prior-run row unchanged. NB bl-223: the “eir 2004”→“environmental information regulations 2004” illustration is dist ~0.51 > the 0.3 gate = unrealistic (would never near-match); use a within-0.3 case/punct longer-variant per test_cocoindex_stage_5_crossrun_integration.py |
| Inv-4 | test_two_existing_seeds_never_merged | real-body unit | two distinct existings of one type, no matching new mention → len(updates)==0, no seed in deletes |
| Inv-5 | test_seeded_existing_no_match_no_event | real-body unit | seed an existing of type T; run’s only mentions unrelated → updates==[] AND deletes==[] |
| Inv-6 | test_roster_includes_prior_run_and_null_op_canonical + integration NULL-op case | real-body unit (roster contents) + integration (NULL-op_id chaining) — MUST NOT run against shared staging | roster passed to resolver includes a supplied prior-run canonical of matching type; integration: a near-match chains under a NULL-op_id-only canonical |
| Inv-7 | test_foreign_op_row_byte_for_byte_unchanged | real-body unit | snapshot a foreign-op row (all columns) before; supply its canonical as a chaining target; assert byte-identical after (no DELETE/UPDATE touched it); DELETE/UPDATE retain AND op_id guards |
| Inv-8 | test_roster_entity_type_scoped | real-body unit | ”Cisco” seeded as organisation; technology “Cisco” mention does NOT chain under it (org seed absent from technology batch’s roster) |
| Inv-9 | CODE REVIEW (no test) | review at implementation time | _select_existing_canonical_roster carries NO workspace predicate (DESIGN-NOTE per PRODUCT Inv-9) |
| Inv-10 | test_seeded_target_bl225_collapse (NEW regression — REQUIRED) | real-body unit, mirrors test_collapse_with_survivor_update | seed an existing as resolution target; TWO same-doc per-doc canonicals both resolve to it → collapse to one survivor (highest conf, smallest id), loser in deletes, survivor UPDATE lands seeded canonical, NO UniqueViolation, cocoindex.stage_5.collapsed log fires |
| Inv-11 | test_only_in_flight_op_rows_written | real-body unit | every id in deletes/updates was selected by _select_run_entity_mentions(op_id=current); seed roster (foreign-op strings) contributes no row id to either list |
| Inv-12 | test_alias_applied_pre_resolution_and_roster_not_aliased | real-body unit (NEW alias coverage — the 3 existing tests return [] for the alias fetch, so this is NOT a mere extension) | MUST supply a NON-empty entity_aliases row via _FakePool.fetch (FROM public.entity_aliases branch returns [{"alias": ..., "canonical": ...}]) so the alias preload path is actually exercised; assert the alias is applied to the per-doc canonical BEFORE resolution; roster source is entity_mentions.canonical_name only (NOT a UNION with entity_aliases) |
| Inv-13 | test_unresolved_mention_retains_canonical_with_seed_roster | real-body unit | obscure mention with no near-match among run names OR seed roster → row’s canonical = per-doc value, absent from updates |
| Inv-14 | cross-run full_reprocess integration | integration — MUST NOT run against shared staging | run 1 seeding-based chaining, then full_reprocess run 2 → (per-doc-name → resolved-canonical) pair set matches run 1 byte-for-byte |
Integration-test guard (explicit): the cross-run integration tests (Inv-2/3/6-NULLop/14) require two real pipeline runs against a DB. They MUST NOT be authored to run against the parent’s shared staging branch (turayklvaunphgbgscat) during THIS spec/implementation session — they are tagged for a controlled run (local Supabase stack or a dedicated ephemeral branch) the parent orchestrates. The unit tests (real-body, fully stubbed resolver chain + in-memory _FakePool) carry the per-invariant proof obligation that does NOT need a live DB; the integration tests prove the end-to-end cross-run consequence.
§6. Risks and mitigations
Section titled “§6. Risks and mitigations”| Risk | Severity | Mitigation |
|---|---|---|
Inv-5 regression — a refactor adds seed members to name_pairs | CRITICAL (highest-risk invariant) | The by-construction guarantee (PC-7) is the sole defence: seeds merge into names_by_type ONLY, never name_pairs; DELETE/UPDATE keep AND op_id. §5 Inv-7/Inv-11 tests assert it. The Executor brief (§7) names this as the non-negotiable invariant. |
pg_trgm absent → migration is a real install, not a no-op | HIGH | §1.4 finding: extension was dropped. Migration CREATE EXTENSION ... WITH SCHEMA extensions; Executor verifies post-push the extension landed in extensions (not public) and the % operator resolves via search_path. |
% operator unresolved (wrong search_path / wrong schema) | HIGH | Opclass schema-qualified extensions.gin_trgm_ops; the seed query relies on session search_path including extensions for the % operator. If asyncpg’s session search_path excludes extensions, the % query errors loudly (no silent failure — surfaces to the flow’s outer except per §P-10). Executor MUST confirm the asyncpg connection’s effective search_path includes extensions, or schema-qualify via OPERATOR(extensions.%) in the SQL. |
| Test-stub signature drift breaks the 3 existing tests | MEDIUM | §5 prerequisite: update _fake_resolve_entities + _FakePool.fetch BEFORE wiring PC-1. Caught immediately by pytest. |
| Schema parity prod ↔ staging | MEDIUM | The migration runs on staging first (turayklvaunphgbgscat); schema-parity.yml guards prod↔staging diff. pg_trgm must land identically on both — verify before any prod push. |
| Prefilter recall loss (lexical miss of embedding near-match) | LOW (accepted) | Documented v1 envelope (PRODUCT §6, §4). Exact case-fold arm guarantees the dominant case. Operator-recoverable via admin entity_aliases rewrite. |
full_reprocess self-membership mis-flag | LOW | The roster is a SET; a name both “existing” and “in this run” is just is_existing=True (pinned, never demoted). PC-6 predicate is set-membership, not identity (RESEARCH §2 caveat). §5 Inv-14 integration covers it. |
§7. Executor tool-discipline (carry into implementation per Inv-2/Inv-3 propagation)
Section titled “§7. Executor tool-discipline (carry into implementation per Inv-2/Inv-3 propagation)”Per .ast-dataflow/CLAUDE.md Inv-2 (Planner duty) / Inv-3 (Executor duty), the {81.4}+ implementation Subtask briefs MUST instruct the Executor to:
- Before editing
_run_stage_5_resolutionor adding_select_existing_canonical_roster: rungitnexus_impact({target: "_run_stage_5_resolution", direction: "upstream", repo: "knowledge-hub"})and record verdict / caller-count / top-3 flows in the implementation journal. (TECH orientation found: single production callerflow.py:app_main, LOW fanout,processes: []— the Executor confirms this still holds at edit time.) - Before commit: run
gitnexus_detect_changes()and verify only the expected symbols (_run_stage_5_resolution, the new reader, the_coco_apifaçade symbol) changed. - Python string-literal sweep: ast-dataflow does NOT cover Python — for any SQL-string change (the new
%/gin_trgm_opsquery), grepscripts/+supabase/migrations/rather than relying on ts-morph (.ast-dataflow/CLAUDE.mdPython/SQL caveat). - Non-negotiable invariant: seeds merge into
names_by_typeONLY, NEVERname_pairs; DELETE/UPDATE keepAND op_id = $current(the by-construction Inv-5 / Inv-11 guarantee, §3 PC-7).
§8. References
Section titled “§8. References”docs/specs/ID-81-canonical-stability/PRODUCT.md— the 14 invariants this TECH maps 1:1 (ratified, Checker PASS_WITH_NOTES).docs/specs/ID-81-canonical-stability/RESEARCH.md— DECISIONs (a) SOURCE, (b) PINNED, (c)+(e) prefilter-not-cache, (d) Inv-5/bl-225 by-construction; §Verification (cocoindex==1.0.3 PRESENT); OQ-81-POLICY / OQ-81-WORKSPACE-SCOPE.docs/specs/id-53-stage-5-entity-resolution/PRODUCT.md+TECH.md— Inv-5 (op_id write-scope), Inv-10 (alias preload), Inv-14 (PairResolver cache), Inv-20 (unresolved persistence), Inv-21 (cross-tenant OUT) — the non-regression contract.scripts/cocoindex_pipeline/stage_5.py/_coco_api.py/entity_embedder.py/pair_resolver.py— the seam + collaborators (read in full).scripts/tests/test_cocoindex_stage_5_resolution.py— the real-body test fixture extended in §5.supabase/migrations/20260428122115_relocate_extensions_to_extensions_schema.sql+20260416102457_pre_squash_reconciliation.sql+20260528122543_id53_entity_mentions_op_id_and_pair_cache.sql— the migration history establishing theextensions-schema placement constraint (§1.4) and the existingidx_entity_mentions_canonical/_lowerindexes.cocoindex/ops/entity_resolution/__init__.py(pinnedcocoindex[postgres]==1.0.3) —resolve_entitiessignature + PINNED semantics, re-verified first-hand.CLAUDE.md— DDL via CLI only; UK English; no silent failures;python3 -m pytest scripts/tests/; verify-and-relinkproject-refbeforedb push.
§9. Verification (OQ-3 / Q-EX2 — external-library citations)
Section titled “§9. Verification (OQ-3 / Q-EX2 — external-library citations)”TECH introduces NO new external-library citation beyond RESEARCH §Verification. The cited symbols (cocoindex.ops.entity_resolution.{resolve_entities, ExistingCanonicalPolicy}) were re-verified first-hand by reading cocoindex/ops/entity_resolution/__init__.py at the pinned cocoindex[postgres]==1.0.3 (requirements.txt):
resolve_entities— PRESENT. Signature(entities, *, embedder, resolve_pair, is_existing_canonical: Callable[[str], bool] | None = None, existing_policy: ExistingCanonicalPolicy = ExistingCanonicalPolicy.PINNED, on_resolution=None, max_distance=0.3, top_n=5) -> ResolvedEntities(:184-194). Matches PC-1 exactly; defaultexisting_policy=PINNED.ExistingCanonicalPolicy— PRESENT.StrEnummembersPINNED/PREFERRED(:57-66). PINNED_new_winsif matched_info.is_existing: return False(:350-353).is_existing_canonical— PRESENT as a per-nameCallable[[str], bool]predicate (:216-220), NOT an identity test — confirms PC-6’s set-membership design.- Façade gap re-confirmed:
_coco_api.py:55-88_SYMBOL_SOURCES/__all__omitExistingCanonicalPolicy(PC-FACADE prerequisite). The symbol exists upstream; it is merely not re-exported — a wiring add, NOT a drift defect. - Verdict: PRESENT — no
ABSENT/SIGNATURE_DRIFT/BEHAVIOUR_DRIFT. Source-read + RESEARCH §Verification runtime introspection agree. Spec is safe to return for ratification.
End of TECH.md. ID-81.3: 14 PRODUCT invariants → 14 Proposed changes (PC-1..PC-14) + 1 façade prerequisite (PC-FACADE). Two NON-changes carry the load (PC-7/PC-11 by-construction Inv-5; PC-10 bl-225 untouched). One CLI migration (pg_trgm re-install WITH SCHEMA extensions + gin_trgm_ops index — extension confirmed currently ABSENT). One new private reader (_select_existing_canonical_roster). Test-stub signature update is a hard prerequisite. PINNED + workspace-agnostic per authored-on defaults (OQ-81-POLICY / OQ-81-WORKSPACE-SCOPE still pending parent — override = spec amendment).