ID-101 TECH — cocoindex entity-relationship + holder-rule extraction parity
ID-101 — cocoindex entity-relationship + holder-rule extraction parity (TECH)
Section titled “ID-101 — cocoindex entity-relationship + holder-rule extraction parity (TECH)”Spec chain:
{101.1}RESEARCH (folded into Task description) →{101.2}PRODUCT (ratified-pending) →{101.3}TECH (this document) →{101.4}PLAN (recommended — see §Decomposition recommendation). Artefact kind:{N.3}TECH. Behaviour lives in the siblingPRODUCT.md; this document owns mechanism + validation. Invariant numbers below (Inv-1 … Inv-16) refer to PRODUCT.md §Behavior. Fresh-Planner note (Q-PLANNER-2 / B4): authored by a fresh Planner instance, not the PRODUCT author. The central correction this review pass surfaces is in §Risks R1: the two canonicalisers do NOT agree today, so Inv-5/Inv-13 “byte-identical” is not satisfiable without a canonicaliser port — the carry-over note framed this as “verify agreement”; the empirical check shows it is “build agreement”.
Context
Section titled “Context”PRODUCT.md defines a parity contract: the cocoindex ingest path must capture the same
directed/typed relationship triples (10-type vocabulary) and the same self/supplier holder
attribution that the legacy TS classifyContent path produces, before the ID-45
full-corpus re-ingest. Today the cocoindex path writes entity mentions only and zero
entity_relationships rows, and never stamps holder metadata. This Task closes that gap
inside the Python pipeline. It is extraction-only: no DDL, no consumption surface
(ID-60/ID-71 out of scope per PRODUCT §Non-goals).
Code-intelligence orientation
Section titled “Code-intelligence orientation”Per the “Always Do” section of .gitnexus/CLAUDE.md. ast-dataflow (ts-morph) does not
cover the Python pipeline (per .ast-dataflow/CLAUDE.md — “does not cover Python or SQL”);
the Python surface below was mapped with grep over scripts/cocoindex_pipeline/ and
scripts/tests/, and that is stated explicitly here.
gitnexus_query({query: 'cocoindex entity relationship extraction flow write', repo: 'knowledge-hub'})→processes: [],process_symbols: []. No named execution flow indexes the cocoindex relationship/holder surface (it does not exist yet). Thedefinitionsblock returned the write-path test surface (scripts/tests/test_cocoindex_flow_write_path.py:TestIngestFileStageCountersL364–493,_stub_path_aL1353–1376) and flow helpers (scripts/cocoindex_pipeline/flow.py:_classify_stage_exceptionL264–381,_stamp_if_modelL1511–1534;scripts/cocoindex_pipeline/extraction.py:stamp_extraction_baseL634–712). Verdict: greenfield write tail on an existing flow — the relationship/holder write path is new, but it slots into the heavily-indexedingest_filecomponent.gitnexus_context({name: 'deriveHolderMetadata', repo: 'knowledge-hub'})→ verdict-level context: 1 direct caller (Function:lib/ai/classify.ts:classifyContent); outgoing calls toFunction:lib/entities/entity-aliases.ts:resolveAliasandFunction:lib/entities/entity-dedup.ts:canonicalise;processes: []. This is the exact TS function whose behaviour the Python holder-stamp must reproduce (lines 524–595).gitnexus_context({name: 'canonicalise', repo: 'knowledge-hub'})→ verdict-level context: 6 direct callers includingderiveHolderMetadata,classifyContent,formatEntityDisplayName,runBatchReclassifyJob,scripts/batch-reclassify.ts:main,scripts/normalise-entities.ts:main; outgoing calls toslugToProperCaseandtitleCase(lib/entities/entity-dedup.ts). HIGH blast-radius on the TS side — but this Task does NOT modifycanonicalise; it ports its behaviour into Python. The TS function is the oracle, not a modification target (PRODUCT §Non-goals).
Relevant code (with line references)
Section titled “Relevant code (with line references)”Legacy TS oracle:
lib/ai/classify.ts:1784-1819— the legacy relationship writer. Maps eachresult.relationships[]to a row{ source_entity, relationship_type, target_entity, source_item_id, confidence: 1.0 }, canonicalising both endpoints viaresolveAlias(canonicalise(x)).toLowerCase(), thenupsert(..., { onConflict: 'source_entity,relationship_type,target_entity,source_item_id', ignoreDuplicates: true })inside a try/catch (best-effort).lib/ai/classify.ts:524-595—deriveHolderMetadata(rows, relationships). Pass-1 canonicalholds; Pass-2 S196 synonym fallback (complies_with/evidences, cert target, client-org-or-extracted-org source,holdswins on tie); final loop stamps{ holder: 'self' }or{ holder: 'supplier', supplier_name }ontocertificationrows only, leaving non-matching rows untouched.HOLDS_SYNONYMSset at :522.lib/ai/classify.ts:653-667—ExtractedRelationship(the 10-member union; the parity contract forrelationship_type).lib/entities/entity-dedup.ts:114-196—canonicalise(name, entityType?): 12-step normaliser (slug→Proper Case, ISO/IEC/version, Cyber Essentials, WCAG, company-suffixLtd→Limited,ABBREVIATIONSmap, multi-word title-case, type-aware plural strip, trailing-period strip).lib/entities/entity-aliases.ts:16-95—BASELINE_ALIASES(code-level baseline) +resolveAlias()(reads the DB-backedentity_aliasescache, falls back to baseline).lib/ai/skills/classification.md:698-778— §Relationship Extraction (the 10-type table)- §Holder Disambiguation (trigger phrases, disclaimer paragraphs, supplier-attribution examples). This is the prompt text to port verbatim.
scripts/eval-holder-rule-ts.ts— the existing holder-rule oracle (parity-oracle base):fetchHoldsRelationships(:386),fetchEntityMentions(:407),runEvaluation(:695) with the four thresholds (holder coverage / positive-control recall / precision / residual correction) and the env-drivenCLIENT_ORG_LOWER/EVAL_POSITIVE_CONTROL_ENTITY.
Cocoindex Python pipeline (the surface this Task changes):
scripts/cocoindex_pipeline/extraction.py— the three shipped@coco.fn(memo=True)extractors (extract_classification:988,extract_qa_form:1016,extract_entity_mentions:1042) + their core Pydantic models (EntityMentionExtraction:363) + shared helpers (_anthropic_retry,_anthropic_message,_cached_system_block,_guard_not_truncated,_strip_code_fence, module-levelTypeAdapters :773-779,ANTHROPIC_MODEL,_MAX_TOKENS_*:796-798). This is the ID-94 precedent (PRODUCT Inv-1).scripts/cocoindex_pipeline/prompts.py—CLASSIFICATION_PROMPT,Q_A_FORM_PROMPT,ENTITY_MENTION_PROMPT(cached system blocks). New prompt constant lands here.scripts/cocoindex_pipeline/canonicalisation.py:22-49—canonicalise_entity_name(name, entity_type): NFKD-fold + lowercase + ISO-only rules. This does NOT match the TScanonicalise+resolveAliaschain (see §Risks R1) — the divergence is the headline parity seam.scripts/cocoindex_pipeline/flow.py—ENTITY_MENTIONS_SCHEMA:1267-1280; theingest_filecomponent signature :1581-1597; Stage-3 extractor invocations :1933-1943; the entity_mentions dedup + declare-row loop :2142-2200; themount_table_targetblock inapp_main:3006-3064; themount_eacharg wiring :3154-3165. The relationship writer + holder stamp slot into this component.scripts/tests/test_cocoindex_flow_write_path.py— the write-path test surface (TestIngestFileWritePath:150,TestIngestFileStageCounters:365,TestMountEachArityContract:537,test_ingest_file_signature_matches_mount_each_extra_args:637). Adding aner_targetarg changes theingest_filearity → these arity tests must be updated (Inv-3 write site).
Proposed changes
Section titled “Proposed changes”Each change cites the PRODUCT invariant(s) it satisfies. The write-path mirrors the legacy TS writer 1:1 except where Python/cocoindex idioms differ (noted inline).
PC-1 — Relationship-extraction @coco.fn + Pydantic model (Inv-1, Inv-2, Inv-4, Inv-8)
Section titled “PC-1 — Relationship-extraction @coco.fn + Pydantic model (Inv-1, Inv-2, Inv-4, Inv-8)”In scripts/cocoindex_pipeline/extraction.py:
- Pydantic core model
RelationshipExtraction(_ExtractionCore)withextraction_kind: Literal["relationship"] = "relationship",source: str = Field(min_length=1),target: str = Field(min_length=1), andrelationship: Literal[...]enumerating exactly the 10 vocabulary members (holds,complies_with,delivers_to,uses,demonstrated_by,requires,part_of,supersedes,references,evidences). TheLiteralis the Inv-4 enforcement: any predicate outside the 10 failsTypeAdaptervalidation. Mirror the bl-220 stamp-free convention — the core model carries NOop_id/content_items_id(those are flow-stamp fields the LLM must not emit). The LLM returns a JSON array (likeextract_entity_mentions), so the adapter is_relationships_adapter: TypeAdapter[list[RelationshipExtraction]](module-level, built once on import alongside the existing three at :773-779). @coco.fn(memo=True) async def extract_relationships(content_text: str) -> list[RelationshipExtraction]— body is a verbatim structural copy ofextract_entity_mentions(:1042-1068):anthropic.AsyncAnthropic(),_anthropic_retry( lambda: _anthropic_message(client, model=ANTHROPIC_MODEL, max_tokens=_MAX_TOKENS_RELATIONSHIPS, system=_cached_system_block(RELATIONSHIP_PROMPT), messages=[{"role": "user", "content": content_text}])),_guard_not_truncated(...),_strip_code_fence(...),return _relationships_adapter.validate_json(response_text). Add_MAX_TOKENS_RELATIONSHIPS = 16384(relationship arrays are moderate, like entity mentions). Inv-1 compliance: this is the@coco.fn+ direct-Anthropic-SDK + Pydantic pattern. It MUST NOT referencecocoindex.ExtractByLlm/cocoindex.LlmSpec(ABSENT incocoindex[postgres]==1.0.7— see §Verification). Inv-8 compliance: the prompt instructs “if none are found, return[]”; the empty array writes zero rows, matching the legacyif (result.relationships?.length)guard.
Why a separate extractor (not bundled into
extract_entity_mentions)? ID-36 §6 row 4 ratified one-extractor-one-call (no bundled “extract everything” prompt); memoisation keys oncontent_textper extractor, so a separate call keeps the relationship cache independent of mention-prompt edits. This matches the three-extractor precedent.
PC-2 — Ported relationship prompt (Inv-2, Inv-12)
Section titled “PC-2 — Ported relationship prompt (Inv-2, Inv-12)”In scripts/cocoindex_pipeline/prompts.py, add RELATIONSHIP_PROMPT — a JSON-only
instruction prompt that ports verbatim the content of
lib/ai/skills/classification.md:698-778:
- §Relationship Extraction (the 10-type table with meanings + examples; “Only include relationships that are clearly stated or strongly implied … If none are found, [return an empty array]”).
- §Holder Disambiguation in full — the sentence-level trigger-phrase list (“held by”,
“managed by”, “maintained by”, “via supplier”, “delivered through”, “outsourced to”,
“provided by”, “operated by”), the content-level disclaimer-paragraph rule, the
supplier-attribution worked example, and the “when no supplier signal … author org as
source” rule (Inv-12 — the LLM must attribute third-party
holdsat source; the derivation step cannot recover a misattribution).
Follow the prompts.py house style: force JSON-only (no fences), enumerate the 10 valid
relationship values verbatim, instruct a JSON array of { source, relationship, target }
objects, omit the flow-stamp fields, UK English. The placeholder {CLIENT_ORGANISATION_NAME}
in the source skill is resolved by the LLM from document context exactly as the legacy prompt
intends (the prompt does not interpolate the client name — it carries the rule). Byte
stability matters — the prompt is the prompt-cache key (_cached_system_block).
PC-3 — Cross-language canonicalisation port (Inv-5, Inv-13) — HIGHEST RISK
Section titled “PC-3 — Cross-language canonicalisation port (Inv-5, Inv-13) — HIGHEST RISK”This is the central decision. PRODUCT Inv-5 and Inv-13 require byte-identical canonical
strings between the legacy resolveAlias(canonicalise(x)).toLowerCase() chain and the
cocoindex path, so triples and holder comparisons collide/agree across paths. The existing
Python canonicalise_entity_name does not reproduce the TS chain (§Risks R1, empirically
confirmed). Decision:
Add a new function canonicalise_for_relationship(name: str) -> str in
scripts/cocoindex_pipeline/canonicalisation.py that reproduces the TS
resolveAlias(canonicalise(name)).toLowerCase() chain step-for-step:
- The 12-step
canonicalisebody (entity-dedup.ts:114-196): slug→Proper Case, ISO basic / extended / version-strip, Cyber Essentials, WCAG, company-suffix (Ltd→Limited,PLC,Inc), theABBREVIATIONSmap, multi-word title-case, type-aware plural strip, trailing period. The relationship writer callscanonicalise(x)with noentityTypearg, so the type-aware plural-strip branch (step 11) is inert for relationship endpoints — the Python port for relationship endpoints SHOULD likewise pass no entity type, keeping the plural branch off (this matters: matching the legacy call shape, not just the function). resolveAlias: apply the alias map. The legacyresolveAliasreads the DB-backedentity_aliasescache and falls back toBASELINE_ALIASES. Tradeoff/decision: the pipeline is Python and has the asyncpg pool (DB_CTX) available, but readingentity_aliasesper-call would add a query to a best-effort write tail. Chosen approach: portBASELINE_ALIASES(the 13-entry code-level baseline,entity-aliases.ts:16-31) as a Python dict and apply it; OPTIONALLY load DB aliases once per flow run into a module cache (mirroring the TS 5-minute TTL cache) if the canonical-string parity oracle (PC-6) shows DB-only aliases causing cross-path divergence. v1 ships the baseline port; the DB alias load is a documented follow-up gated on oracle evidence (avoids over-building before the oracle proves it necessary)..toLowerCase()(Python.lower()).
Both the relationship write (PC-4) and the holder-stamp comparison (PC-5) call
canonicalise_for_relationship. Do NOT reuse canonicalise_entity_name for relationship
endpoints — it is the per-document entity-mention canonicaliser with different (ISO-only,
NFKD-folding) semantics and is correct for its own Stage-5 purpose; conflating the two would
reintroduce the divergence. A scripts/tests/-level cross-language golden test (PC-6
oracle, the canonical-string lane) pins this: a shared fixture of raw→expected pairs asserted
identical by both the TS canonicalise/resolveAlias and the Python port.
PC-4 — entity_relationships write target + declare-row loop (Inv-3, Inv-5, Inv-6, Inv-7)
Section titled “PC-4 — entity_relationships write target + declare-row loop (Inv-3, Inv-5, Inv-6, Inv-7)”In scripts/cocoindex_pipeline/flow.py:
ENTITY_RELATIONSHIPS_SCHEMA = TableSchema(...)besideENTITY_MENTIONS_SCHEMA(:1267), declaring only the columns the pipeline writes (thecontent_text_hash-style OMIT convention for PG-defaulted/GENERATED columns):id uuid NOT NULL,source_entity text NOT NULL,relationship_type text NOT NULL,target_entity text NOT NULL,source_item_id uuid(the content-item id, mirroring the legacysource_item_id = itemId),confidence numeric,op_id uuid(per-flow stamp, matching the em/qa pattern).primary_key = ("id",). Inv-3: same column set the legacy writer writes (legacyconfidence = 1.0).er_target = await mount_table_target(DB_CTX, "entity_relationships", ENTITY_RELATIONSHIPS_SCHEMA, managed_by=ManagedBy.USER)inapp_main(:3026 block, afterem_target).managed_by=USER= rows only, never DDL — the table + 10-typeCHECKentity_relationships_unique_tupleNULLS NOT DISTINCTindex already exist (PRODUCT §Verification; migration20260421171520_…). No DDL in this Task.
- Thread
er_targetinto the component: add it to theingest_filesignature (:1581 — afterem_target, before the keyword-only*block), to thebound_ingest_file/_ingest_file_implcall chain (:1744, :1892), and to thecoco.mount_each(...)extra-args tuple (:3154-3165). This is an arity change — updatetest_ingest_file_signature_matches_mount_each_extra_argsandTestMountEachArityContract(PC-7). - Declare-row loop after the entity_mentions loop (:2200), guarded
if relationships:(Inv-8 — zero rows when empty). For each extracted relationship:source_c = canonicalise_for_relationship(rel.source),target_c = canonicalise_for_relationship(rel.target)(PC-3 — Inv-5).- Drop predicates outside the 10-type set defensively (Inv-4): the Pydantic
Literalalready rejects them at extraction, but if a future prompt edit leaks one, therelationship_typeis validated against the vocabulary before declare; out-of-set → skip + log (do not crash — the legacy path relies on the DBCHECK, but cocoindex’s declare-row should not hand the DB a row theCHECKwill reject mid-batch). - Deterministic PK seeded on the natural tuple (mirroring the em natural-key PK fix
{66.16}/BUG-F at :2156):
id = uuid.uuid5(_KH_PIPELINE_DOC_NS, f"er:{rel_path}:{source_c}:{relationship_type}:{target_c}"). This makes re-ingest idempotent (Inv-6): the same tuple on a later run declares the same PK → cocoindex UPSERTs the existing row (theNULLS NOT DISTINCTunique tuple in the DB is the belt-and-braces; the deterministic PK is the cocoindex-side mechanism, exactly as entity_mentions does it). De-dup repeated identical tuples within a single document into a dict keyed on(source_c, relationship_type, target_c)before declaring (same shape as_em_dedupat :2161) so two identical declares in one run don’t race the PK. er_target.declare_row(row={"id": ..., "source_entity": source_c, "relationship_type": relationship_type, "target_entity": target_c, "source_item_id": content_item_id, "confidence": 1.0, "op_id": op_id})._bump("postgres_upsert")per row (id-28 Inv-17 counter parity with em — id-28’s register, outside this document’s declared Inv-1 … Inv-16 range per the header note; qualified by the S515 id-402 sweep).
Idempotency note (Inv-6). The legacy TS path relies on
ignoreDuplicates: true+ theNULLS NOT DISTINCTunique index (a23505collision treated as success). cocoindex’smount_table_targetUPSERTs on the declared PK (id), not on the unique tuple — so the deterministicuuid5PK is what delivers idempotency on the cocoindex side. The DB unique tuple still guards against a different PK colliding on the same tuple (e.g. a future seed change); in that edge case the INSERT raises23505, which the flow-scope try/except (PC-… best-effort, Inv-7) catches and logs. Net behaviour matches PRODUCT Inv-6: repeated tuples never accumulate duplicate rows.
PC-5 — Holder-metadata stamp (ported deriveHolderMetadata) (Inv-9..Inv-14)
Section titled “PC-5 — Holder-metadata stamp (ported deriveHolderMetadata) (Inv-9..Inv-14)”Port deriveHolderMetadata (classify.ts:524-595) into Python and invoke it on the deduped
entity-mention set before the em declare-row loop writes metadata. Add
derive_holder_metadata(mentions, relationships, client_org_lower) -> int to a pipeline
module (recommend a new scripts/cocoindex_pipeline/holder_rule.py for testability, imported
by flow.py):
- Inputs: the deduped mention list (the
_em_dedup.values()set, post per-doc canonicalisation) and the extractedrelationshipslist.client_org_lowerresolves from the same branding source the TS path uses — decision: the pipeline has noBRANDINGTS module; source the client-org name from the pipeline’s existing client-config env (the same valueeval-holder-rule-ts.tsreads viaBRANDING.organisationName). Surface it as a required env knob (fail-fast if unset, mirroring theNEXT_PUBLIC_CLIENT_IDguard ateval-holder-rule-ts.ts:756) so the pipeline never mis-derives every cert assupplieragainst a fallback org name (Inv-9/Inv-13 correctness). - Pass 1 (canonical
holds): buildholds_by_target: dict[str,str]mappingcanonicalise_for_relationship(rel.target)→canonicalise_for_relationship(rel.source)for everyrel.relationship == "holds"(last-wins on collision, matching the TS comment at :532-535). Inv-13: both endpoints normalised via PC-3, identical to the TS comparison. - Pass 2 (S196 synonym fallback — Inv-11): build
cert_targets/org_sourcessets from the mentions (byentity_typeand per-doccanonical_name— careful: the TS usesrow.canonical_name, which is already the canonical lowercase form; the Python mentions carrycanonicalise_entity_name-formcanonical_name, NOT the relationship canonicaliser form — see §Risks R2 for the reconciliation). For eachcomplies_with/evidencesrel: accept as a holder signal only when (a) target is a cert, (b) source is the client org OR an extracted org, (c) no canonicalholdsalready exists for that target.holdswins on tie. - Stamp loop (Inv-9, Inv-10, Inv-14): for each
certificationmention only, look upholds_by_target[mention canonical]; if found, setmetadata = { holder: 'self' }when the source equalsclient_org_lower, elsemetadata = { holder: 'supplier', supplier_name: <source> }. Inv-10 (load-bearing): mentions with no holds/synonym signal are left untouched — never defaulted to'self'. Inv-14: non-certificationmentions are never stamped. - Wire into the em declare-row loop (:2173-2199): the existing loop writes
metadata = { source_span_start, source_span_end }. The holder stamp must merge holder keys into that dict (NOT overwrite — the span keys are load-bearing for Stage-5 provenance). So: runderive_holder_metadatato compute aholder_by_mention_idmap, then in the declare loop setmetadata = { "source_span_start": ..., "source_span_end": ..., **holder_md }whereholder_mdis{}for untouched mentions (Inv-10),{ "holder": "self" }, or{ "holder": "supplier", "supplier_name": ... }. This preserves the existing metadata shape and only adds holder keys when a signal exists.
Why before the declare loop, not after. cocoindex’s
mount_table_targetUPSERTs the whole row; there is no post-writeUPDATE metadatahook in the v1 pipeline (themount_table_target“no per-row callback” constraint, flow.py:31). So the holder keys MUST be present in themetadatadict at declare time. This is the structural reason the stamp runs inline, unlike the legacy path which mutatesrowsin place then upserts.
PC-6 — Cross-path parity oracle (Inv-2, Inv-9, Inv-16)
Section titled “PC-6 — Cross-path parity oracle (Inv-2, Inv-9, Inv-16)”The parity oracle is the central validation decision (PRODUCT §Notes). Three lanes, escalating in fidelity:
- Canonical-string golden test (deterministic, CI-runnable — the critical lane). A
shared fixture
scripts/tests/fixtures/canonicalisation_parity.jsonof[{raw, expected}]pairs covering everycanonicalisebranch (slug, ISO variants, Cyber Essentials, WCAG,Ltd→Limited, abbreviation-map entries, multi-word title-case, alias-map entries). Two assertions read the same fixture: a TS test (Vitest, assertingresolveAlias(canonicalise(raw)).toLowerCase() === expected) and a Python test (pytest, assertingcanonicalise_for_relationship(raw) == expected). This pins Inv-5/Inv-13 byte-identity deterministically without an LLM. This is the gate that must pass before the ID-45 re-ingest — it is the only lane that proves the highest-risk seam. - Holder-rule unit parity (deterministic). Port the
deriveHolderMetadataunit fixtures into a Python pytest overderive_holder_metadata: feed identical(mentions, relationships)inputs and assert identical holder-state output (self/supplier+name/ untouched) for the self case, supplier-disclaimer case, S196 synonym case, the “untouched-not-self” Inv-10 case, and the non-cert Inv-14 case. No LLM — pins Inv-9/Inv-10/Inv-11/Inv-14 logic parity. - End-to-end cross-path eval (non-deterministic — extend
eval-holder-rule-ts.ts). Reuse the existing oracle’s data-read + threshold machinery against a small pinned fixture corpus (the residual + positive-control items the script already knows). Run the document through BOTH paths (legacyclassifyContentand the cocoindex extractor) and compare on set-equality of{source_entity, relationship_type, target_entity}triples after canonicalisation (Inv-2), tolerant of ordering, and exact holder-state match per cert (Inv-9). Non-determinism handling: the LLM may vary triple wording run-to-run, so (a) compare on the canonicalised triple set, not raw strings; (b) treat a triple present in one path but not the other as a parity miss only if it recurs across N≥3 runs (transient single-run variation is logged, not failed); (c) pin the extraction model + prompt (the prompt-cache key already pins prompt bytes). The deterministic lanes (1)+(2) are the hard gate; lane (3) is the empirical confidence check on the live LLM seam and may run--dry-runfirst.
Decision: extend, don’t rewrite,
eval-holder-rule-ts.ts. ItsfetchHoldsRelationships/fetchEntityMentions/ threshold scaffolding (four thresholds, env-driven client org + positive-control entity) is exactly the read+compare machinery the cross-path eval needs. Add a--path=cocoindex|legacy|bothmode and a triple-set comparator; keep the existing--mode=runlegacy behaviour intact (it is still the TS oracle of record).
PC-7 — Test-surface updates (Inv-3, Inv-7, Inv-15)
Section titled “PC-7 — Test-surface updates (Inv-3, Inv-7, Inv-15)”- Update
test_cocoindex_flow_write_path.py:TestMountEachArityContract+test_ingest_file_signature_matches_mount_each_extra_args(:537, :637) for the newer_targetpositional arg; add aTestIngestFileRelationshipWritePathasserting the declare-row payload shape (canonical endpoints,confidence=1.0, deterministic PK, zero-rows-on-empty) and a holder-stamp assertion (cert→metadata.holder, non-cert untouched, span keys preserved). - Best-effort / non-blocking (Inv-7, Inv-15): the relationship write + holder stamp run
inside the same flow-scope try/except that already guards
ingest_file(app_main:3140-3149 swallows per-item exceptions → batch continues, emitting a structured stage-error log via_emit_stage_error_log/_classify_stage_exception). A relationship/holder failure for one document must NOT abort that document’s other declares (em/qa/classification) nor the batch, and MUST be logged (not silently swallowed). Add a test asserting a raised relationship-write error is logged + the em/qa declares still happen (mirror the existing stage-counter silent-noop test at :448). - Guard tests:
pipeline-parity.test.tsandmcp-fixture-sync.test.tsrun on every test (CLAUDE.md). Adding an extractor/prompt may trip pipeline-parity — update its fixture if the parity guard enumerates extractors/prompts.
Testing and validation
Section titled “Testing and validation”Maps each PRODUCT invariant to a concrete check (PRODUCT.md has no Validation section by design — it lives here).
| Inv | What it asserts | Verification |
|---|---|---|
| 1 | @coco.fn direct-Anthropic mechanism, no ExtractByLlm | Grep test: extract_relationships decorated @coco.fn(memo=True), calls anthropic.AsyncAnthropic; static assert no ExtractByLlm/LlmSpec reference (extend TestNoFictionalApiSurvives, :1233). |
| 2 | Triple-set parity with legacy for same input | PC-6 lane 3 (cross-path eval, canonicalised triple-set equality over pinned corpus, N≥3 runs). |
| 3 | One row/triple, same column set, confidence default | PC-7 declare-row payload test (column set == legacy, confidence=1.0). |
| 4 | relationship_type ∈ 10-type set; out-of-set dropped not crashed | Pydantic Literal unit test (invalid predicate → ValidationError); PC-4 defensive-drop test (out-of-set rel → skipped + logged, batch survives). |
| 5 | Canonical endpoints byte-identical to legacy | PC-6 lane 1 (cross-language golden test — the hard gate). |
| 6 | Idempotent across re-ingest | PC-7 test: two ingest runs of one doc → identical PKs, no duplicate rows (mirror TestStablePrimaryKeysAcrossRuns :698). |
| 7 | Relationship write best-effort/non-blocking + logged | PC-7 failure-injection test (raise on relationship declare → em/qa declares still happen, error logged). |
| 8 | Zero rows when no relationships | PC-7 test: extractor returns [] → no er_target.declare_row call. |
| 9 | Holder metadata parity with deriveHolderMetadata | PC-6 lane 2 (holder-rule unit parity) + lane 3 (e2e holder-state match). |
| 10 | Untouched-not-self when no signal | PC-6 lane 2 explicit case (cert with no holds/synonym → metadata has no holder key). |
| 11 | S196 synonym fallback parity | PC-6 lane 2 synonym cases (accept complies_with/evidences only under all three conditions; holds wins on tie). |
| 12 | LLM attributes 3rd-party holds at source | Prompt-content test: RELATIONSHIP_PROMPT contains the verbatim trigger-phrase + disclaimer rules (string-presence assertions); lane-3 supplier-disclaimer fixture produces holder: 'supplier'. |
| 13 | Holder-source comparison on canonical/lowercased form | PC-6 lane 1 (shared canonicaliser) + lane 2 (self/supplier split on canonicalised source). |
| 14 | Holder stamp only on certification mentions | PC-6 lane 2 non-cert case (org/regulation mention → never stamped). |
| 15 | Holder stamp best-effort/non-blocking + logged | PC-7 failure-injection test (raise in derive_holder_metadata → em declare still happens with span-only metadata, error logged). |
| 16 | Cross-path row shape indistinguishable | Aggregate: lanes 1+2 (canonical + holder shape) + PC-7 column-set test prove a consumer cannot tell which path wrote a row. |
Run commands. Python: python3 -m pytest scripts/tests/test_cocoindex_flow_write_path.py scripts/tests/test_holder_rule_parity.py scripts/tests/test_canonicalisation_parity.py (run
from the worktree CWD — CLAUDE.md namespace-package hazard). TS golden + eval: bun run test __tests__/.../canonicalisation-parity.test.ts and bun run scripts/eval-holder-rule-ts.ts --path=both --dry-run (the eval needs dangerouslyDisableSandbox: true — Bun fetch hangs
behind the SOCKS proxy on Supabase reads, per the script header).
Risks and mitigations
Section titled “Risks and mitigations”R1 (HIGH — the central risk) — the two canonicalisers do not agree today. PRODUCT Inv-5
/ Inv-13 state byte-identical canonical strings, and the carry-over note framed this as
“verify the two canonicalisers agree”. The empirical check shows they do not:
scripts/cocoindex_pipeline/canonicalisation.py:canonicalise_entity_name is NFKD-fold +
lowercase + ISO-only rules, with no company-suffix normalisation, no WCAG, no
ABBREVIATIONS map, no multi-word title-case, and no resolveAlias alias layer — all of
which the TS resolveAlias(canonicalise(x)).toLowerCase() chain applies. They coincide for a
plain ISO27001→iso 27001 case but diverge for e.g. Acme Ltd (TS → acme limited,
Python → acme ltd), Wcag 2 1 Aa, abbreviation-map entries, and any DB-backed alias.
Mitigation: PC-3 ports the full TS chain into a dedicated
canonicalise_for_relationship, and PC-6 lane 1 pins it with a shared cross-language golden
fixture that MUST pass before ID-45 re-ingest. Do not reuse canonicalise_entity_name
for relationship endpoints. This single decision is the difference between cross-path triples
colliding correctly and silently forking into duplicate rows post-re-ingest.
R2 (MEDIUM) — entity-mention canonical_name vs relationship-canonical mismatch in the
holder Pass-2 set membership. deriveHolderMetadata Pass-2 builds certTargets/orgSources
from row.canonical_name (TS) and compares against resolveAlias(canonicalise(rel.x))…. In
the cocoindex path, entity_mentions.canonical_name is the canonicalise_entity_name form
(ISO-only), while relationship endpoints are the canonicalise_for_relationship form. If
these two forms differ for the same raw entity, the Pass-2 certTargets.has(targetLower) check
will miss and the synonym fallback silently won’t fire. Mitigation: in
derive_holder_metadata, build the cert/org membership sets by applying
canonicalise_for_relationship to each mention’s entity_name (raw) — i.e. compare both
sides in the relationship-canonical space — rather than trusting the stored
canonicalise_entity_name canonical_name. Document this divergence-bridge explicitly in
the Python docstring; add a lane-2 fixture where the two canonicalisers would otherwise
disagree to lock it.
R3 (MEDIUM) — ingest_file arity change is a breaking signature change. Adding
er_target shifts the positional contract that mount_each and three arity tests assert
(:537, :637). Mitigation: PC-7 updates the arity tests in the same slice as the signature
change; insert er_target after em_target, before the keyword-only * to keep the
mount-each extra-args order stable and minimise churn.
R4 (LOW) — client-org env knob unset → mass mis-attribution. If the pipeline’s client-org
name resolves to a fallback, every holds source ≠ fallback → every cert stamped supplier
(the exact failure eval-holder-rule-ts.ts:756 guards against). Mitigation: fail-fast
guard in derive_holder_metadata (raise if the client-org env is unset), and the best-effort
wrapper logs the raise rather than corrupting metadata silently.
R5 (LOW) — prompt-cache invalidation churn. RELATIONSHIP_PROMPT is a prompt-cache key
(_cached_system_block); any byte edit invalidates the server-side cache for that extractor.
Mitigation: port the prompt once, verbatim, and freeze it; the PC-6 lane-1 fixture covers
canonicalisation, not prompt text, so prompt tweaks don’t cascade into canonical-string tests.
Decomposition recommendation ({101.4} PLAN)
Section titled “Decomposition recommendation ({101.4} PLAN)”A {101.4} PLAN decomposition IS warranted (PRODUCT estimated > 2h, multi-slice; this
TECH confirms it: new extractor + new prompt + canonicaliser port + write target/loop +
holder-stamp port + a three-lane parity oracle, with R1/R2/R3 each touching a different
file). Recommended slice boundaries (all sibling-only within ID-101 — see below), ordered
by dependency:
- Canonicaliser port + golden test (PC-3, PC-6 lane 1). Foundational — every later slice
canonicalises through it. Ships
canonicalise_for_relationship+ the shared TS/Python golden fixture. No dependency. - Relationship extractor + prompt (PC-1, PC-2). The
@coco.fn+ Pydantic model + portedRELATIONSHIP_PROMPT. No dependency (parallelisable with slice 1). - Write target + declare-row loop (PC-4, PC-7 arity/write-path tests). Depends on slices 1 (canonicaliser) and 2 (extractor output shape).
- Holder-rule port + stamp wiring (PC-5, PC-6 lane 2). Depends on slices 1 (canonicaliser,
incl. R2 bridge) and 2 (relationship inputs); touches the em declare loop slice 3 also edits
— sequence after slice 3 to avoid a merge collision on the declare loop, OR fold the em
metadata-merge into slice 3 and keep the holder logic (
holder_rule.py) as slice 4. - Cross-path eval extension (PC-6 lane 3). Depends on slices 2+3+4 (needs both paths wired). The empirical confidence gate; can run last.
This is 5 slices, well within the 25-Subtask soft ceiling. I am NOT authoring the PLAN —
this is the recommendation for the Orchestrator to dispatch a {101.4} Planner.
Sibling-only dependency check (§3.3 / A6 forcing function): PASS. Every slice dependency
above is intra-ID-101 (slice→slice within the same Task). No cross-Task Subtask dependency
surfaced. The ID-45 relationship is a Task-level ordering gate (the C1 pre-re-ingest gate
in Task.dependencies), not a Subtask-level dependency — consistent with the PRODUCT author’s
note. No escalation required.
Verification
Section titled “Verification”Per the Q-EX2 pre-ratification empirical-verification forcing function (external-library API citations import-and-call verified against the installed pin before ratification):
- Date: 09/06/2026.
- Pinned versions:
cocoindex[postgres]==1.0.7(requirements.txt:54),anthropic==0.79.0(requirements.txt:3). - Symbols checked (cocoindex):
cocoindex.ExtractByLlm,cocoindex.LlmSpec— re-confirmed ABSENT (PRODUCT §Verification carried the import-and-call result;hasattrbothFalse). The mechanism this TECH specs (@coco.fn(memo=True)+ directanthropic.AsyncAnthropic+ PydanticTypeAdapter) is the only viable one on the pin and is the shipped ID-94 pattern (extract_qa_form/extract_entity_mentions,extraction.py:1016-1068). Result: PRESENT (the precedent extractors + their helpers_anthropic_retry,_cached_system_block,_guard_not_truncated,_strip_code_fence, module-levelTypeAdapters are all present and used exactly as PC-1 reuses them). - Symbols checked (anthropic):
anthropic.AsyncAnthropic,client.messages.create(wrapped by_anthropic_message) — already exercised by the three shipped extractors on this pin; no new anthropic symbol is introduced by this Task. Result: PRESENT (in-use on the pinned version). - Canonicaliser divergence (empirical, the R1 evidence):
grepoverscripts/cocoindex_pipeline/canonicalisation.pyconfirms no alias / abbreviation / company-suffix / WCAG / title-case handling — the Pythoncanonicalise_entity_namedoes NOT reproduce the TSresolveAlias(canonicalise(x)).toLowerCase()chain. Result: SIGNATURE/BEHAVIOUR DRIFT between the two canonicalisers → PC-3 ports the TS chain; the PRODUCT Inv-5/Inv-13 “byte-identical” claim is satisfiable only via that port (not by reusing the existing Python canonicaliser). This is a within-KH parity finding, not an external-API drift — no spec-blocking escalation, but it reframes the carry-over “verify agreement” as “build agreement” and is called out as R1. - Schema preconditions (confirmed present, NOT created by this Task):
entity_relationships10-typeCHECK+entity_relationships_unique_tupleNULLS NOT DISTINCTindex (supabase/migrations/20260421171520_entity_relationships_unique_tuple_constraint.sql);entity_mentions.metadataJSONB holds the{ holder, supplier_name }shape (lib/ai/classify.ts:584-589).managed_by=ManagedBy.USERoner_target= rows only, no DDL.