diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b69851..8cc24335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,7 +166,7 @@ jobs: trap - EXIT test "$(docker compose --env-file .env.example exec -T postgres \ psql -U rememberstack -d rememberstack -Atc \ - 'SELECT version_num FROM alembic_version')" = 'p7_05_0017' + 'SELECT version_num FROM alembic_version')" = 'p1_03_0018' test "$(docker compose --env-file .env.example exec -T postgres \ psql -U rememberstack -d rememberstack -Atc \ 'SELECT count(*) FROM deployments')" = '1' diff --git a/decisions.md b/decisions.md index ae331d99..75f727c4 100644 --- a/decisions.md +++ b/decisions.md @@ -697,6 +697,16 @@ eval metrics come for free. **Context.** The same durable-state discipline the resolution and supersession layers use, applied to the extraction transcript. (C8.) +**Amendment (2026-07-27, issue #161).** The transcript also records the Claimify-stage losses that +were previously silent: `claimify_omitted` (a kept span the model returned no claim for — including +spans not verbatim-findable in the document, which formerly vanished traceless) and +`grounding_rejected` (a returned claim a D32 gate rejected, with the gate named in `edit_detail`). +Accounting rules: every returned claim independently ends accepted or rejected; every keep with no +range-attributable returned claim gets exactly one omission row; orphan rejections suppress no +omission. On D56 zero-claim reuse the prior transcript is copied forward — the `no_info` marker is +fabricated only when the prior transcript is itself empty. Detail: +`plan/designs/e2_e3_claims_relations_design.md` §3 amendment. + ## D34. E2 Selection is the value filter — there is no pre-extraction value gate **Decision.** Junk-control lives at the **proposition grain**, in-call: Selection (D31) decides diff --git a/plan/designs/e2_e3_claims_relations_design.md b/plan/designs/e2_e3_claims_relations_design.md index 73fa9246..5efbc2ff 100644 --- a/plan/designs/e2_e3_claims_relations_design.md +++ b/plan/designs/e2_e3_claims_relations_design.md @@ -148,17 +148,52 @@ in the bundle. The dropped opinion never reaches grounding. Two safeguards keep aggressive Selection safe: -- **A decision ledger (D33).** Every Selection drop and every decontextualization edit is written to an - append-only, version-stamped `claim_extraction_decisions` table. A better prompt can later re-examine - *only the drops*; a rebuild reads stored claims + decisions and never re-calls the model (the LLM - rungs are replay-from-storage, like any non-deterministic stage — D7); the per-chunk worker is - idempotent on content-hash + extractor version (D12). +- **A decision ledger (D33).** Every Selection drop, every decontextualization edit, and every + Claimify-stage loss is written to an append-only, version-stamped `claim_extraction_decisions` + table. A better prompt can later re-examine *only the drops*; a rebuild reads stored claims + + decisions and never re-calls the model (the LLM rungs are replay-from-storage, like any + non-deterministic stage — D7); the per-chunk worker is idempotent on content-hash + extractor + version (D12). - **A recall envelope (D35).** Selection biases toward KEEP when unsure; **never-drop classes** (quantities, dates, named-entity + predicate, change-of-state language) are protected even if phrased opinionatedly; a low-confidence `kept_flagged` outcome marks-for-review instead of hard-deleting; and planted rare-fact canaries fail CI if Selection drops them. Drop-rates are tuned against **per-fact** loss, never a corpus average — a uniquely-attested fact has no second copy to fall back on. +**Amendment (2026-07-27, issue #161):** Selection keep/keep_flagged is not the end of the story. +Between a keep and an accepted `claims` row sit Claimify (the fused decontextualize+decompose call) +and the deterministic D32 grounding gates. Without ledger rows for those stages, a keep that never +lands a claim is indistinguishable from a keep that produced a claim the gate rejected — and plain +(unflagged) keeps that die are completely invisible. The D33 transcript therefore also records: + +| `decision_type` | When written | `source_span` | `edit_detail` | +|---|---|---|---| +| `claimify_omitted` | A kept Selection span for which Claimify returned **no claim at all** (the model simply skipped it). One row per dead keep. | The Selection span. | null | +| `grounding_rejected` | A Claimify-returned claim rejected by a D32 gate. One row per rejected claim. | The claim's returned `source_span` (even if not findable in the chunk). | `{"gate": "span_not_found" \| "outside_kept_ranges" \| "added_context_unverified", "claim_span": }`; for `added_context_unverified` also `{"kind": ..., "text": }`. | + +**Invariant — every kept span is accounted for end-to-end.** Two independent rules (revised +2026-07-27 after review — one keep can decompose into several returned claims with mixed fates, +so "exactly one category per keep" was wrong): + +1. **Every Claimify-returned claim** independently ends either **accepted** (a `claims` row, plus + any `decontext_edit` / `selection_keep_flagged` pairing that already applied) or + **`grounding_rejected`** (one row naming which gate fired). A mixed outcome — same keep, one + claim accepted, another rejected — records both and is not an omission. +2. **Every keep or keep_flagged span with no attributable returned claim** gets exactly one + `claimify_omitted` row. Attribution is **anchored-range overlap only** (the claim's resolved + char range overlaps the keep's) — text containment is deliberately excluded so one claim + cannot suppress omission rows for unrelated keeps that merely share text. Two conservative + consequences: a returned claim whose span anchors nowhere is an **orphan rejection** (its + `grounding_rejected` row stands; it suppresses no omission), and a Selection span that is not + verbatim-findable can never be marked "tried," so it always gets its omission row — the case + that previously vanished with no trace. + +Cross-model extraction comparisons can then show *why* a stronger model lands more claims (fewer +omissions vs fewer gate rejections) instead of only *that* it does. On D56 chunk reuse with zero +attached claims, the prior occurrence's transcript is copied forward verbatim — the synthetic +`no_info` marker is written only when the prior transcript is itself empty, so reuse never +rewrites a real loss reason. + ## 4. Why there is no value gate (the non-goal) It is tempting to put a cheap "is this section even worth extracting?" gate *before* E2. We diff --git a/plan/designs/postgres_schema_design.md b/plan/designs/postgres_schema_design.md index 04eea6d5..e9465e48 100644 --- a/plan/designs/postgres_schema_design.md +++ b/plan/designs/postgres_schema_design.md @@ -1356,8 +1356,8 @@ verbatim `source_span` + offsets + the `added_context` substrings (D32), so grou -- change, and claim validity is never superseded (D3) — adjudicated validity lives only on relations. -- A row in claims is an ACCEPTED claim: the deterministic grounding gate (anchor + window -- membership, D32 layers 1-2) MUST pass, enforced by the CHECK — a claim that fails the gate is --- never produced (it becomes a ledger entry or is discarded), so the flags exist for audit and are --- always true here. Large (~5×10⁷) ⇒ monthly partition by ingested_at; logical FKs (D23). +-- never produced (it becomes a grounding_rejected ledger entry, #161; never silently discarded), +-- so the flags exist for audit and are always true here. Large (~5×10⁷) ⇒ monthly partition by ingested_at; logical FKs (D23). -- ───────────────────────────────────────────────────────────────────────── CREATE TABLE claims ( claim_id uuid NOT NULL, @@ -1421,10 +1421,14 @@ CREATE INDEX ix_claims_audit ON claims (deployment_id) WHERE audit_status = ' -- ───────────────────────────────────────────────────────────────────────── -- claim_extraction_decisions — the append-only, version-stamped extraction transcript (D33). It --- records every Selection DROP (with reason), every low-confidence KEEP-FLAG, and every --- decontextualization EDIT. Plain keeps are NOT recorded (they ARE the claims row) — keeping the --- table sized for drops+flags+edits, not every keep. Rebuild reads stored claims + these decisions --- and never re-calls the model (D7). Large ⇒ monthly partition by decided_at. +-- records every Selection DROP (with reason), every low-confidence KEEP-FLAG, every +-- decontextualization EDIT, and (issue #161, 2026-07-27) the Claimify-stage losses: +-- CLAIMIFY_OMITTED (a kept span the model returned no claim for) and GROUNDING_REJECTED (a +-- returned claim a D32 gate rejected — gate named in edit_detail; these are ledgered, not +-- discarded). Plain keeps that land are NOT recorded (they ARE the claims row); a plain keep +-- that dies now leaves a loss row, so the table sizes for drops+flags+edits+losses, still not +-- every keep. Rebuild reads stored claims + these decisions and never re-calls the model (D7). +-- Large ⇒ monthly partition by decided_at. -- INVARIANT: a kept_flagged claim is the pair (claims row with kept_flagged=true) + (a -- selection_keep_flagged decision here); the ledger is the replay source from which -- claims.kept_flagged is reconstituted on rebuild. @@ -1435,7 +1439,7 @@ CREATE TABLE claim_extraction_decisions ( doc_id uuid NOT NULL, -- LOGICAL FK → documents chunk_id uuid NOT NULL, -- LOGICAL FK → chunks claim_id uuid, -- LOGICAL FK → claims; set for decontext edits + selection_keep_flagged; NULL for selection_drop (no claim produced) - decision_type extraction_decision_type NOT NULL, -- selection_drop | selection_keep_flagged | decontext_edit + decision_type extraction_decision_type NOT NULL, -- selection_drop | selection_keep_flagged | decontext_edit | claimify_omitted | grounding_rejected (#161) source_span text, -- the proposition/sentence the decision was about reason selection_drop_reason, -- for drops: opinion|advice|hypothetical|generic|question|intro|conclusion|no_info|ambiguous|references_boilerplate (D31) edit_detail jsonb, -- for decontext edits: what was resolved/added and from which bundle source diff --git a/src/rememberstack/model/claims.py b/src/rememberstack/model/claims.py index 1a6da85f..8131dd62 100644 --- a/src/rememberstack/model/claims.py +++ b/src/rememberstack/model/claims.py @@ -207,6 +207,8 @@ class DecisionType(StrEnum): SELECTION_DROP = "selection_drop" SELECTION_KEEP_FLAGGED = "selection_keep_flagged" DECONTEXT_EDIT = "decontext_edit" + CLAIMIFY_OMITTED = "claimify_omitted" + GROUNDING_REJECTED = "grounding_rejected" class DecisionRecord(BaseModel): diff --git a/src/rememberstack/spine/claim_catalog.py b/src/rememberstack/spine/claim_catalog.py index 39917bf7..280476cb 100644 --- a/src/rememberstack/spine/claim_catalog.py +++ b/src/rememberstack/spine/claim_catalog.py @@ -105,6 +105,23 @@ def attach_reused_claims( ) return prior_links + def copy_reused_decisions(self, *, chunk_id: UUID, prior_chunk_id: UUID) -> int: + """Replicate a prior chunk's decision transcript under a reused chunk. + + The D56 zero-claim reuse rung: a prior extraction with no accepted + claims may still carry claimify_omitted / grounding_rejected rows + (#161) — the reused occurrence adopts that transcript verbatim + instead of fabricating a no_info marker. Returns how many rows were + copied; zero means the prior transcript is empty and the caller owns + the terminal marker. + """ + with self._engine.begin() as connection: + result = connection.execute( + _COPY_CHUNK_DECISIONS, + {"chunk_id": chunk_id, "prior_chunk_id": prior_chunk_id}, + ) + return result.rowcount or 0 + def claims_for_chunks( self, *, chunk_ids: tuple[UUID, ...] ) -> tuple[ClaimForNormalization, ...]: @@ -271,6 +288,25 @@ def record_extraction( """ ).bindparams(bindparam("edit_detail", type_=JSON)) +_COPY_CHUNK_DECISIONS = text( + """ + -- D56 reuse of a zero-claim extraction: replicate the prior occurrence's + -- transcript verbatim under the new chunk so the loss accounting (#161) + -- survives reuse. Fresh ids and decided_at=now(): this row records when + -- THIS occurrence adopted the outcome, on the current partition. + INSERT INTO claim_extraction_decisions ( + decision_id, deployment_id, doc_id, chunk_id, claim_id, + decision_type, source_span, reason, edit_detail, + protected_class, extractor_version + ) + SELECT gen_random_uuid(), deployment_id, doc_id, :chunk_id, claim_id, + decision_type, source_span, reason, edit_detail, + protected_class, extractor_version + FROM claim_extraction_decisions + WHERE chunk_id = :prior_chunk_id + """ +) + _SELECT_CLAIMS_FOR_CHUNKS = text( """ SELECT claim_id, doc_id, chunk_id, claim_text, is_attributed diff --git a/src/rememberstack/spine/migrations/versions/p1_03_0018_claimify_loss_ledger.py b/src/rememberstack/spine/migrations/versions/p1_03_0018_claimify_loss_ledger.py new file mode 100644 index 00000000..ca436707 --- /dev/null +++ b/src/rememberstack/spine/migrations/versions/p1_03_0018_claimify_loss_ledger.py @@ -0,0 +1,32 @@ +"""Add Claimify-stage loss ledger values to extraction_decision_type (#161). + +Every kept Selection span must end in one of {accepted claim(s), +grounding_rejected row(s), claimify_omitted row}. The two new enum values +make the previously silent Claimify omissions and D32 gate rejections +auditable on the append-only D33 transcript. Additive only; no new indexes +on the partitioned claim_extraction_decisions table. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "p1_03_0018" +down_revision: str | None = "p7_05_0017" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Append the two loss-ledger values (additive; existing rows untouched).""" + op.execute( + "ALTER TYPE extraction_decision_type ADD VALUE IF NOT EXISTS 'claimify_omitted'" + ) + op.execute( + "ALTER TYPE extraction_decision_type" + " ADD VALUE IF NOT EXISTS 'grounding_rejected'" + ) + + +def downgrade() -> None: + """PostgreSQL cannot remove an enum value in place; additive no-op.""" diff --git a/src/rememberstack/workers/e1.py b/src/rememberstack/workers/e1.py index 19473144..f66ac1c1 100644 --- a/src/rememberstack/workers/e1.py +++ b/src/rememberstack/workers/e1.py @@ -54,10 +54,11 @@ """The context-prefix call's prompt generation (D58; conventional mode, D63). 07b pins temperature=0.0 — generation parameters are part of provenance.""" -E2_EXTRACTOR_VERSION: Final = "e2-extract-2026.07d:temp0-1" +E2_EXTRACTOR_VERSION: Final = "e2-extract-2026.07e:loss-ledger-1" """The extractor generation baked into extraction_input_hash (D56); the E2 -stage (WP-1.3) binds its handler to this same constant. 07d pins -temperature=0.0 on the Selection call (Claimify already carried it).""" +stage (WP-1.3) binds its handler to this same constant. 07e ledgers Claimify +omissions and grounding-gate rejections on the D33 transcript (#161); 07d +pinned temperature=0.0 on the Selection call (Claimify already carried it).""" _PREFIX_PROMPT_TEMPLATE: Final = ( "In one sentence, state where this passage sits in the document — " diff --git a/src/rememberstack/workers/e2.py b/src/rememberstack/workers/e2.py index 29d25f8c..64060c9c 100644 --- a/src/rememberstack/workers/e2.py +++ b/src/rememberstack/workers/e2.py @@ -6,11 +6,15 @@ grounding gate (D32 layers 1-2) accepts a claim only if its verbatim source span anchors inside the chunk and every added substring exists in the bundle element it was attributed to — a check the model cannot talk its way past. +Every kept span ends in accepted claim(s), grounding_rejected row(s), or a +claimify_omitted row so Claimify-stage losses are never silent (#161). """ +from dataclasses import dataclass from datetime import date from datetime import datetime from datetime import UTC +from enum import StrEnum import logging from typing import Final from uuid import UUID @@ -53,6 +57,10 @@ _OUTCOMES: Final = "|".join(outcome.value for outcome in SelectionOutcome) +# Truncation ceiling for claim_span / invented text stored in edit_detail jsonb — +# keeps the ledger row small without hiding the gate identity (#161). +_LEDGER_SPAN_MAX: Final = 512 + _SELECTION_PROMPT: Final = """You are the Selection stage of a claim extractor. Judge every proposition in the TARGET CHUNK: keep statements making a specific, verifiable proposition (state, event, decision, quantity, policy, relationship). @@ -181,12 +189,20 @@ def _reuse_prior_extraction( prior_chunk_id=prior, ) if attached == 0: - # the prior chunk carries no claims — a terminal no_info: carry - # the marker forward so replay stays closed for this chunk too - self._catalog.record_extraction( - claims=(), - decisions=(_empty_extraction_marker(source=source, chunk=chunk),), + # the prior chunk carries no claims. Zero claims no longer means + # no_info — the prior may hold claimify_omitted / + # grounding_rejected rows (#161) — so carry the prior transcript + # forward verbatim; fabricate the no_info marker only when the + # prior transcript is itself empty. Either way replay stays + # closed for this chunk. + copied = self._catalog.copy_reused_decisions( + chunk_id=chunk.chunk_id, prior_chunk_id=prior ) + if copied == 0: + self._catalog.record_extraction( + claims=(), + decisions=(_empty_extraction_marker(source=source, chunk=chunk),), + ) return True def _extract_chunk( @@ -227,9 +243,11 @@ def _extract_chunk( ) claims: list[ClaimRecord] = [] if keeps: - kept_ranges = _kept_ranges( - keeps=keeps, chunk=chunk, document_md=document_md + keep_ranges = tuple( + _keep_range(keep=keep, chunk=chunk, document_md=document_md) + for keep in keeps ) + kept_ranges = tuple(span for span in keep_ranges if span is not None) flagged_spans = { candidate.source_span for candidate in keeps @@ -252,8 +270,18 @@ def _extract_chunk( usage=response_call.usage, ) response = response_call.output + # Per-keep "model tried" marker for claimify_omitted accounting. + # Attribution is RANGE-OVERLAP ONLY: a returned claim marks + # exactly the keeps whose anchored ranges its own anchored range + # overlaps. Text containment is deliberately not used — it would + # let one claim suppress omission rows for unrelated keeps that + # merely share text. Consequences, both conservative: a claim + # whose span anchors nowhere is an orphan rejection and + # suppresses no omission; a keep whose span anchors nowhere can + # never be marked tried and always gets its omission row (#161). + keep_had_return = [False] * len(keeps) for candidate in response.claims: - record = _grounded_claim( + result = _grounded_claim( candidate=candidate, source=source, chunk=chunk, @@ -263,16 +291,38 @@ def _extract_chunk( flagged_spans=flagged_spans, kept_ranges=kept_ranges, ) - if record is None: + claim_range = _span_range( + span=candidate.source_span, chunk=chunk, document_md=document_md + ) + if claim_range is not None: + for index_keep, keep_range in enumerate(keep_ranges): + if keep_range is not None and _ranges_overlap( + claim_range, keep_range + ): + keep_had_return[index_keep] = True + if isinstance(result, GroundingRejection): _logger.warning( - "grounding gate rejected candidate %r on chunk %s", + "grounding gate %s rejected candidate %r on chunk %s", + result.gate.value, candidate.claim_text, chunk.chunk_id, ) + decisions.append( + _grounding_rejected_decision( + source=source, chunk=chunk, rejection=result + ) + ) continue - claims.append(record) - if record.added_context: - decisions.append(_edit_decision(source=source, record=record)) + claims.append(result) + if result.added_context: + decisions.append(_edit_decision(source=source, record=result)) + for keep, had_return in zip(keeps, keep_had_return, strict=True): + if not had_return: + decisions.append( + _claimify_omitted_decision( + source=source, chunk=chunk, keep=keep + ) + ) decisions = _link_flagged_decisions(decisions=decisions, claims=claims) if not claims and not decisions: # terminal marker (D7): an extraction that found nothing claim-worthy @@ -283,6 +333,24 @@ def _extract_chunk( ) +class GroundingGate(StrEnum): + """Which deterministic D32 gate rejected a Claimify-returned claim (#161).""" + + SPAN_NOT_FOUND = "span_not_found" + OUTSIDE_KEPT_RANGES = "outside_kept_ranges" + ADDED_CONTEXT_UNVERIFIED = "added_context_unverified" + + +@dataclass(frozen=True) +class GroundingRejection: + """A Claimify candidate that failed a grounding gate (never a claims row).""" + + gate: GroundingGate + claim_span: str + kind: str | None = None + text: str | None = None + + def _grounded_claim( *, candidate: CandidateClaim, @@ -293,29 +361,34 @@ def _grounded_claim( document_md: str, flagged_spans: set[str], kept_ranges: tuple[tuple[int, int], ...], -) -> ClaimRecord | None: +) -> ClaimRecord | GroundingRejection: """Apply the deterministic grounding gate (D32 layers 1-2). Layer 1 (anchor): the source span must be a real in-bounds slice of the target chunk, and must overlap a span Selection kept — the fused call can never resurrect a dropped proposition. Layer 2 (window membership): every added substring must verbatim-exist in the bundle element it was - attributed to. A failed check returns None — the candidate never becomes - a claims row. Semantic invention behind a real span is layer-3/4 - territory: the in-call self-verdict is stored advisory, and the sampled - independent audit owns the honest measurement. + attributed to. A failed check returns which gate fired so the D33 ledger + can record ``grounding_rejected`` (#161); the accept path is unchanged. + Semantic invention behind a real span is layer-3/4 territory: the in-call + self-verdict is stored advisory, and the sampled independent audit owns + the honest measurement. """ - anchor_at = document_md.find( - candidate.source_span, chunk.char_start, chunk.char_end - ) + claim_span = candidate.source_span + anchor_at = document_md.find(claim_span, chunk.char_start, chunk.char_end) if anchor_at < 0: - return None - anchor_end = anchor_at + len(candidate.source_span) + return GroundingRejection( + gate=GroundingGate.SPAN_NOT_FOUND, claim_span=claim_span + ) + anchor_end = anchor_at + len(claim_span) if not any( anchor_at < kept_end and kept_start < anchor_end for kept_start, kept_end in kept_ranges ): - return None # Selection is enforced, not advisory + # Selection is enforced, not advisory + return GroundingRejection( + gate=GroundingGate.OUTSIDE_KEPT_RANGES, claim_span=claim_span + ) for added in candidate.added_context: element = _bundle_element( kind=added.source_kind, @@ -325,7 +398,12 @@ def _grounded_claim( document_md=document_md, ) if element is None or added.text not in element: - return None + return GroundingRejection( + gate=GroundingGate.ADDED_CONTEXT_UNVERIFIED, + claim_span=claim_span, + kind=added.source_kind, + text=added.text, + ) valid_from, valid_until, valid_precision, valid_kind = _parse_claim_valid_time( candidate=candidate ) @@ -336,13 +414,13 @@ def _grounded_claim( chunk_id=chunk.chunk_id, section_id=None, claim_text=candidate.claim_text, - source_span=candidate.source_span, + source_span=claim_span, char_start=anchor_at, - char_end=anchor_at + len(candidate.source_span), + char_end=anchor_at + len(claim_span), added_context=candidate.added_context, is_attributed=candidate.is_attributed, entailment_self_verdict=candidate.entailment_self_verdict, - kept_flagged=candidate.source_span in flagged_spans, + kept_flagged=claim_span in flagged_spans, extractor_version=E2_EXTRACTOR_VERSION, claim_valid_from=valid_from, claim_valid_until=valid_until, @@ -533,16 +611,51 @@ def _neighbour_text( return "(none)" +def _span_range( + *, span: str, chunk: ChunkForEmbedding, document_md: str +) -> tuple[int, int] | None: + """Absolute char range of a span inside the chunk, or None if unfindable. + + Repeated text resolves to its first occurrence — a pre-existing bias + shared by keeps and claims alike, so overlap attribution stays symmetric. + """ + found = document_md.find(span, chunk.char_start, chunk.char_end) + if found < 0: + return None + return found, found + len(span) + + +def _keep_range( + *, keep: SelectionCandidate, chunk: ChunkForEmbedding, document_md: str +) -> tuple[int, int] | None: + """Absolute char range of one kept Selection span, or None if unfindable.""" + return _span_range(span=keep.source_span, chunk=chunk, document_md=document_md) + + def _kept_ranges( *, keeps: tuple[SelectionCandidate, ...], chunk: ChunkForEmbedding, document_md: str ) -> tuple[tuple[int, int], ...]: """Absolute char ranges of the kept Selection spans inside the chunk.""" - ranges: list[tuple[int, int]] = [] - for keep in keeps: - found = document_md.find(keep.source_span, chunk.char_start, chunk.char_end) - if found >= 0: - ranges.append((found, found + len(keep.source_span))) - return tuple(ranges) + return tuple( + span + for keep in keeps + if (span := _keep_range(keep=keep, chunk=chunk, document_md=document_md)) + is not None + ) + + +def _ranges_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool: + """Half-open char-range overlap — the sole claimify_omitted attribution + rule (#161). Text containment is deliberately excluded: it would let one + returned claim suppress omission rows for unrelated keeps sharing text.""" + return a[0] < b[1] and b[0] < a[1] + + +def _truncate_for_ledger(text: str) -> str: + """Bound edit_detail text so a long span cannot bloat the ledger row.""" + if len(text) <= _LEDGER_SPAN_MAX: + return text + return text[: _LEDGER_SPAN_MAX - 1] + "…" def _link_flagged_decisions( @@ -590,6 +703,52 @@ def _empty_extraction_marker( ) +def _grounding_rejected_decision( + *, source: ChunkSource, chunk: ChunkForEmbedding, rejection: GroundingRejection +) -> DecisionRecord: + """One D33 row for a Claimify claim that failed a grounding gate (#161).""" + edit_detail: dict[str, object] = { + "gate": rejection.gate.value, + "claim_span": _truncate_for_ledger(rejection.claim_span), + } + if rejection.gate is GroundingGate.ADDED_CONTEXT_UNVERIFIED: + # kind is model-returned text too — bound every persisted field + edit_detail["kind"] = _truncate_for_ledger(rejection.kind or "") + edit_detail["text"] = _truncate_for_ledger(rejection.text or "") + return DecisionRecord( + decision_id=uuid4(), + deployment_id=source.deployment_id, + doc_id=source.doc_id, + chunk_id=chunk.chunk_id, + claim_id=None, + decision_type=DecisionType.GROUNDING_REJECTED, + source_span=_truncate_for_ledger(rejection.claim_span), + reason=None, + edit_detail=edit_detail, + protected_class=None, + extractor_version=E2_EXTRACTOR_VERSION, + ) + + +def _claimify_omitted_decision( + *, source: ChunkSource, chunk: ChunkForEmbedding, keep: SelectionCandidate +) -> DecisionRecord: + """One D33 row for a kept span Claimify returned no claim for (#161).""" + return DecisionRecord( + decision_id=uuid4(), + deployment_id=source.deployment_id, + doc_id=source.doc_id, + chunk_id=chunk.chunk_id, + claim_id=None, + decision_type=DecisionType.CLAIMIFY_OMITTED, + source_span=keep.source_span, + reason=None, + edit_detail=None, + protected_class=keep.protected_class, + extractor_version=E2_EXTRACTOR_VERSION, + ) + + def _normalize_follow_up(*, work: ClaimedWork, source: ChunkSource) -> HandlerOutcome: """Continue an extracted version even when it contains no chunks.""" return HandlerOutcome( diff --git a/src/tests/spine/test_migrations.py b/src/tests/spine/test_migrations.py index 1c842041..82cc9f12 100644 --- a/src/tests/spine/test_migrations.py +++ b/src/tests/spine/test_migrations.py @@ -96,6 +96,7 @@ def test_revision_graph_is_one_linear_structural_chain() -> None: "p6_06_0015", "p7_02_0016", "p7_05_0017", + "p1_03_0018", ) assert len(script.get_heads()) == 1 @@ -262,5 +263,5 @@ def test_postgresql_fresh_downgrade_reupgrade_mutation_and_noop_lifecycle() -> N head_before_noop = _head_revision(database_url=database_url) command.upgrade(config=config, revision="head") head_after_noop = _head_revision(database_url=database_url) - assert head_before_noop == head_after_noop == "p7_05_0017" + assert head_before_noop == head_after_noop == "p1_03_0018" assert _inventory(database_url=database_url) == restored_inventory diff --git a/src/tests/workers/test_claimify_loss_ledger.py b/src/tests/workers/test_claimify_loss_ledger.py new file mode 100644 index 00000000..4ff1f358 --- /dev/null +++ b/src/tests/workers/test_claimify_loss_ledger.py @@ -0,0 +1,443 @@ +"""Unit proofs for Claimify-stage loss ledgering (#161). + +Force each D32 grounding gate, assert the exact decision rows, and prove the +no-double-count rule: a keep that produced a rejected claim is not also +claimify_omitted. Postgres-free so they always run; the enum migration insert +proof lives in test_claimify_loss_ledger_pg.py. +""" + +from typing import cast +from typing import TYPE_CHECKING +from uuid import UUID + +from rememberstack.adapters.testing import FakeModelProvider +from rememberstack.adapters.testing import NoopCostMeter +from rememberstack.model import AddedContext +from rememberstack.model import CandidateClaim +from rememberstack.model import ChunkForEmbedding +from rememberstack.model import ChunkSource +from rememberstack.model import ClaimRecord +from rememberstack.model import DecisionRecord +from rememberstack.model import DecisionType +from rememberstack.model import SelectionCandidate +from rememberstack.model import SelectionOutcome +from rememberstack.workers import E2Settings +from rememberstack.workers.e2 import _claimify_omitted_decision +from rememberstack.workers.e2 import _grounded_claim +from rememberstack.workers.e2 import _grounding_rejected_decision +from rememberstack.workers.e2 import ExtractClaimsHandler +from rememberstack.workers.e2 import GroundingGate +from rememberstack.workers.e2 import GroundingRejection + +if TYPE_CHECKING: + from rememberstack.ports.object_store import ObjectStorePort + from rememberstack.spine.chunk_catalog import ChunkCatalog + from rememberstack.spine.claim_catalog import ClaimCatalog + +_DEPLOYMENT = UUID("81000000-0000-0000-0000-000000000001") +_DOC = UUID("81000000-0000-0000-0000-000000000002") +_CHUNK = UUID("81000000-0000-0000-0000-000000000003") +_VERSION = UUID("81000000-0000-0000-0000-000000000004") +_REPR = UUID("81000000-0000-0000-0000-000000000005") + +# A short document with two keep-worthy spans and one advisory drop target. +_DOC_MD = ( + "Project Atlas launched in 2024 in three markets.\n" + "The team considers it a runaway success.\n" + "You should try it yourself.\n" +) +_KEEP_LAUNCH = "Project Atlas launched in 2024 in three markets." +_KEEP_STANCE = "The team considers it a runaway success." +_DROP_ADVICE = "You should try it yourself." + + +def _source() -> ChunkSource: + """A minimal ChunkSource for grounding / decision construction.""" + return ChunkSource( + deployment_id=_DEPLOYMENT, + doc_id=_DOC, + version_id=_VERSION, + representation_id=_REPR, + markdown_uri="mem://doc.md", + blocks_uri="mem://blocks.json", + title="Atlas launch report", + source_kind="upload", + source_modified_at=None, + published_at=None, + language="en", + structurer_version="test-structurer", + sections=(), + ) + + +def _chunk(*, document_md: str = _DOC_MD) -> ChunkForEmbedding: + """One chunk spanning the whole fixture document.""" + return ChunkForEmbedding( + chunk_id=_CHUNK, + doc_id=_DOC, + version_id=_VERSION, + ordinal=0, + char_start=0, + char_end=len(document_md), + chunk_content_hash="sha256:fixture", + extraction_input_hash="sha256:fixture-in", + section_role="body", + section_path="/", + context_prefix="Sits in the Project Atlas launch report.", + prefixer_version="test-prefixer", + ) + + +def _kept_ranges_for(*spans: str) -> tuple[tuple[int, int], ...]: + """Absolute ranges for the given verbatim spans inside the fixture doc.""" + ranges: list[tuple[int, int]] = [] + for span in spans: + at = _DOC_MD.find(span) + assert at >= 0, span + ranges.append((at, at + len(span))) + return tuple(ranges) + + +def _ground( + *, + candidate: CandidateClaim, + kept_spans: tuple[str, ...] = (_KEEP_LAUNCH, _KEEP_STANCE), +) -> object: + """Run the grounding gate against the fixture document and keeps.""" + chunk = _chunk() + return _grounded_claim( + candidate=candidate, + source=_source(), + chunk=chunk, + chunks=(chunk,), + index=0, + document_md=_DOC_MD, + flagged_spans=set(), + kept_ranges=_kept_ranges_for(*kept_spans), + ) + + +def test_gate_span_not_found_writes_grounding_rejected() -> None: + """A claim span absent from the chunk is ledgered as span_not_found.""" + claim_span = "Atlas was cancelled in March" + result = _ground( + candidate=CandidateClaim( + claim_text="Atlas was cancelled in March.", + source_span=claim_span, + entailment_self_verdict=True, + ) + ) + assert isinstance(result, GroundingRejection) + assert result.gate is GroundingGate.SPAN_NOT_FOUND + assert result.claim_span == claim_span + + decision = _grounding_rejected_decision( + source=_source(), chunk=_chunk(), rejection=result + ) + assert decision.decision_type is DecisionType.GROUNDING_REJECTED + assert decision.source_span == claim_span + assert decision.claim_id is None + assert decision.edit_detail == {"gate": "span_not_found", "claim_span": claim_span} + + +def test_gate_outside_kept_ranges_writes_grounding_rejected() -> None: + """A verbatim span Selection dropped is ledgered as outside_kept_ranges.""" + result = _ground( + candidate=CandidateClaim( + claim_text="You should try Project Atlas.", + source_span=_DROP_ADVICE, + entailment_self_verdict=True, + ) + ) + assert isinstance(result, GroundingRejection) + assert result.gate is GroundingGate.OUTSIDE_KEPT_RANGES + assert result.claim_span == _DROP_ADVICE + + decision = _grounding_rejected_decision( + source=_source(), chunk=_chunk(), rejection=result + ) + assert decision.decision_type is DecisionType.GROUNDING_REJECTED + assert decision.source_span == _DROP_ADVICE + assert decision.edit_detail == { + "gate": "outside_kept_ranges", + "claim_span": _DROP_ADVICE, + } + + +def test_gate_added_context_unverified_writes_grounding_rejected() -> None: + """An invented addition is ledgered with kind and text in edit_detail.""" + claim_span = "Project Atlas launched in 2024" + result = _ground( + candidate=CandidateClaim( + claim_text="Project Atlas launched in San Francisco.", + source_span=claim_span, + added_context=( + AddedContext(text="in San Francisco", source_kind="neighbour"), + ), + entailment_self_verdict=True, + ) + ) + assert isinstance(result, GroundingRejection) + assert result.gate is GroundingGate.ADDED_CONTEXT_UNVERIFIED + assert result.kind == "neighbour" + assert result.text == "in San Francisco" + + decision = _grounding_rejected_decision( + source=_source(), chunk=_chunk(), rejection=result + ) + assert decision.decision_type is DecisionType.GROUNDING_REJECTED + assert decision.source_span == claim_span + assert decision.edit_detail == { + "gate": "added_context_unverified", + "claim_span": claim_span, + "kind": "neighbour", + "text": "in San Francisco", + } + + +def test_accept_path_still_returns_claim_record() -> None: + """A well-grounded claim remains a ClaimRecord (accept path unchanged).""" + result = _ground( + candidate=CandidateClaim( + claim_text="Project Atlas launched in 2024.", + source_span="Project Atlas launched in 2024", + entailment_self_verdict=True, + ) + ) + assert isinstance(result, ClaimRecord) + assert result.source_span == "Project Atlas launched in 2024" + + +def test_claimify_omitted_row_for_keep_with_no_returned_claim() -> None: + """A keep Claimify skipped entirely gets exactly one claimify_omitted row.""" + keep = SelectionCandidate( + source_span=_KEEP_LAUNCH, outcome=SelectionOutcome.KEEP, protected_class="date" + ) + decision = _claimify_omitted_decision(source=_source(), chunk=_chunk(), keep=keep) + assert decision.decision_type is DecisionType.CLAIMIFY_OMITTED + assert decision.source_span == _KEEP_LAUNCH + assert decision.claim_id is None + assert decision.reason is None + assert decision.edit_detail is None + assert decision.protected_class == "date" + + +class _RecordingCatalog: + """Captures record_extraction so handler accounting is directly assertable.""" + + def __init__(self) -> None: + self.claims: tuple[ClaimRecord, ...] = () + self.decisions: tuple[DecisionRecord, ...] = () + + def record_extraction( + self, *, claims: tuple[ClaimRecord, ...], decisions: tuple[DecisionRecord, ...] + ) -> None: + self.claims = claims + self.decisions = decisions + + +_SELECTION_BOTH_KEEPS: dict[str, object] = { + "candidates": [ + {"source_span": _KEEP_LAUNCH, "outcome": "keep", "protected_class": "date"}, + {"source_span": _KEEP_STANCE, "outcome": "keep_flagged"}, + {"source_span": _DROP_ADVICE, "outcome": "drop_advice"}, + ] +} + + +def _run_extract( + *, selection: dict[str, object], claimify: dict[str, object] +) -> _RecordingCatalog: + """Drive the REAL handler's per-chunk extraction with canned payloads. + + This is the non-vacuous proof Codex asked for: the omission / + no-double-count rules are asserted on what ``_extract_chunk`` actually + records, not on hand-rolled reproductions of its loop. + """ + recorder = _RecordingCatalog() + handler = ExtractClaimsHandler( + catalog=cast("ClaimCatalog", recorder), + chunk_catalog=cast("ChunkCatalog", object()), + artifact_store=cast("ObjectStorePort", object()), + model_provider=FakeModelProvider( + generate_payloads={ + "SelectionResponse": selection, + "ClaimifyResponse": claimify, + } + ), + settings=E2Settings(), + chunker_version="test-chunker", + ) + chunk = _chunk() + handler._extract_chunk( + source=_source(), + chunks=(chunk,), + index=0, + document_md=_DOC_MD, + meter=NoopCostMeter(), + ) + return recorder + + +def _loss_rows(recorder: _RecordingCatalog) -> dict[DecisionType, list[str]]: + """The loss-ledger rows the handler recorded, keyed by type → spans.""" + rows: dict[DecisionType, list[str]] = { + DecisionType.CLAIMIFY_OMITTED: [], + DecisionType.GROUNDING_REJECTED: [], + } + for decision in recorder.decisions: + if decision.decision_type in rows: + rows[decision.decision_type].append(decision.source_span or "") + return rows + + +def test_handler_zero_return_marks_every_keep_omitted() -> None: + """Claimify returns nothing: plain AND flagged keeps each get one omission.""" + recorder = _run_extract(selection=_SELECTION_BOTH_KEEPS, claimify={"claims": []}) + assert recorder.claims == () + rows = _loss_rows(recorder) + assert sorted(rows[DecisionType.CLAIMIFY_OMITTED]) == sorted( + [_KEEP_LAUNCH, _KEEP_STANCE] + ) + assert rows[DecisionType.GROUNDING_REJECTED] == [] + omitted = [ + d + for d in recorder.decisions + if d.decision_type is DecisionType.CLAIMIFY_OMITTED + ] + assert {d.source_span: d.protected_class for d in omitted} == { + _KEEP_LAUNCH: "date", + _KEEP_STANCE: None, + } + + +def test_handler_rejection_suppresses_omission_only_for_its_keep() -> None: + """A rejected attempt on one keep leaves the other keep's omission intact.""" + recorder = _run_extract( + selection=_SELECTION_BOTH_KEEPS, + claimify={ + "claims": [ + { + "claim_text": "Project Atlas launched in San Francisco.", + "source_span": "Project Atlas launched in 2024", + "added_context": [ + {"text": "in San Francisco", "source_kind": "neighbour"} + ], + "entailment_self_verdict": True, + } + ] + }, + ) + assert recorder.claims == () + rows = _loss_rows(recorder) + assert rows[DecisionType.GROUNDING_REJECTED] == ["Project Atlas launched in 2024"] + assert rows[DecisionType.CLAIMIFY_OMITTED] == [_KEEP_STANCE] + + +def test_handler_mixed_accept_and_reject_yields_no_omission() -> None: + """One keep, two returned claims (one accepted, one rejected): both rows + persist and the keep is NOT claimify_omitted.""" + recorder = _run_extract( + selection=_SELECTION_BOTH_KEEPS, + claimify={ + "claims": [ + { + "claim_text": "Project Atlas launched in 2024.", + "source_span": "Project Atlas launched in 2024", + "entailment_self_verdict": True, + }, + { + "claim_text": "Project Atlas launched in San Francisco.", + "source_span": "Project Atlas launched in 2024", + "added_context": [ + {"text": "in San Francisco", "source_kind": "neighbour"} + ], + "entailment_self_verdict": True, + }, + ] + }, + ) + assert [claim.claim_text for claim in recorder.claims] == [ + "Project Atlas launched in 2024." + ] + rows = _loss_rows(recorder) + assert rows[DecisionType.GROUNDING_REJECTED] == ["Project Atlas launched in 2024"] + assert rows[DecisionType.CLAIMIFY_OMITTED] == [_KEEP_STANCE] + + +def test_handler_orphan_rejection_suppresses_no_omission() -> None: + """A claim anchoring outside every kept range (or nowhere) is an orphan + rejection: it is ledgered, and every keep still gets its omission row.""" + recorder = _run_extract( + selection=_SELECTION_BOTH_KEEPS, + claimify={ + "claims": [ + { + "claim_text": "You should try Project Atlas.", + "source_span": _DROP_ADVICE, + "entailment_self_verdict": True, + }, + { + "claim_text": "Atlas was cancelled.", + "source_span": "Atlas was cancelled in March", + "entailment_self_verdict": True, + }, + ] + }, + ) + assert recorder.claims == () + rows = _loss_rows(recorder) + assert sorted(str(s) for s in rows[DecisionType.GROUNDING_REJECTED]) == sorted( + [_DROP_ADVICE, "Atlas was cancelled in March"] + ) + assert sorted(rows[DecisionType.CLAIMIFY_OMITTED]) == sorted( + [_KEEP_LAUNCH, _KEEP_STANCE] + ) + + +def test_handler_unfindable_keep_always_gets_its_omission_row() -> None: + """A Selection keep whose span is not verbatim in the document — the case + that vanished with no trace before #161 — is ledgered claimify_omitted, + while findable keeps account normally.""" + recorder = _run_extract( + selection={ + "candidates": [ + {"source_span": _KEEP_LAUNCH, "outcome": "keep"}, + {"source_span": "Atlas rules the world", "outcome": "keep_flagged"}, + ] + }, + claimify={ + "claims": [ + { + "claim_text": "Project Atlas launched in 2024.", + "source_span": "Project Atlas launched in 2024", + "entailment_self_verdict": True, + } + ] + }, + ) + assert [claim.claim_text for claim in recorder.claims] == [ + "Project Atlas launched in 2024." + ] + rows = _loss_rows(recorder) + assert rows[DecisionType.CLAIMIFY_OMITTED] == ["Atlas rules the world"] + assert rows[DecisionType.GROUNDING_REJECTED] == [] + + +def test_decision_type_enum_includes_loss_ledger_values() -> None: + """Python DecisionType must expose the two new wire values.""" + assert DecisionType.CLAIMIFY_OMITTED.value == "claimify_omitted" + assert DecisionType.GROUNDING_REJECTED.value == "grounding_rejected" + # decision ids remain unique per row even when spans collide: + a = _grounding_rejected_decision( + source=_source(), + chunk=_chunk(), + rejection=GroundingRejection(gate=GroundingGate.SPAN_NOT_FOUND, claim_span="x"), + ) + b = _grounding_rejected_decision( + source=_source(), + chunk=_chunk(), + rejection=GroundingRejection(gate=GroundingGate.SPAN_NOT_FOUND, claim_span="x"), + ) + assert a.decision_id != b.decision_id + assert isinstance(a.decision_id, UUID) diff --git a/src/tests/workers/test_claimify_loss_ledger_pg.py b/src/tests/workers/test_claimify_loss_ledger_pg.py new file mode 100644 index 00000000..71f2aaa3 --- /dev/null +++ b/src/tests/workers/test_claimify_loss_ledger_pg.py @@ -0,0 +1,220 @@ +"""Postgres-gated proof: extraction_decision_type gains loss-ledger values (#161). + +Applies migrations to head, inserts claimify_omitted and grounding_rejected +rows, and reads them back. Skips when REMEMBERSTACK_DATABASE_URL is unset. +""" + +from collections.abc import Iterator +from pathlib import Path +from uuid import UUID +from uuid import uuid4 + +from alembic import command +from alembic.config import Config +from pydantic import ValidationError +import pytest +from sqlalchemy import create_engine +from sqlalchemy import text +from sqlalchemy.engine import Engine + +from rememberstack.spine.settings import load_database_settings + +_ROOT = Path(__file__).resolve().parents[3] +_DEPLOYMENT_ID = UUID("82000000-0000-0000-0000-000000000001") +_DOC_ID = UUID("82000000-0000-0000-0000-000000000002") +_CHUNK_ID = UUID("82000000-0000-0000-0000-000000000003") + + +@pytest.fixture(scope="module") +def database_engine() -> Iterator[Engine]: + """Upgrade to head and expose the accepted PostgreSQL integration engine.""" + try: + database_url = load_database_settings().sqlalchemy_url() + except ValidationError: + pytest.skip( + "REMEMBERSTACK_DATABASE_URL is required for real PostgreSQL chain proofs" + ) + config = Config(str(_ROOT / "alembic.ini")) + config.set_main_option("sqlalchemy.url", database_url) + command.downgrade(config=config, revision="base") + command.upgrade(config=config, revision="head") + engine = create_engine(database_url) + try: + yield engine + finally: + engine.dispose() + + +def test_extraction_decision_type_enum_includes_loss_ledger_values( + database_engine: Engine, +) -> None: + """Migration p1_03_0018 adds claimify_omitted and grounding_rejected.""" + with database_engine.connect() as connection: + labels = tuple( + connection.execute( + text( + "SELECT enumlabel FROM pg_enum" + " JOIN pg_type ON pg_type.oid = pg_enum.enumtypid" + " WHERE pg_type.typname = 'extraction_decision_type'" + " ORDER BY enumsortorder" + ) + ).scalars() + ) + assert "claimify_omitted" in labels + assert "grounding_rejected" in labels + # prior values remain (additive migration): + assert "selection_drop" in labels + assert "selection_keep_flagged" in labels + assert "decontext_edit" in labels + + +def test_loss_ledger_rows_insert_and_read_back(database_engine: Engine) -> None: + """Rows with the new decision types persist on the partitioned table. + + claim_extraction_decisions carries only logical FKs to docs/chunks, so a + bare insert proves the enum + partition path without scaffolding parents. + """ + with database_engine.begin() as connection: + connection.execute(statement=text("TRUNCATE TABLE claim_extraction_decisions")) + + omitted_id = uuid4() + rejected_id = uuid4() + with database_engine.begin() as connection: + connection.execute( + text( + "INSERT INTO claim_extraction_decisions (" + " decision_id, deployment_id, doc_id, chunk_id, claim_id," + " decision_type, source_span, reason, edit_detail," + " protected_class, extractor_version" + ") VALUES (" + " :id, :d, :doc, :chunk, NULL," + " 'claimify_omitted', :span, NULL, NULL," + " 'date', 'e2-extract-2026.07e:loss-ledger-1'" + ")" + ), + { + "id": omitted_id, + "d": _DEPLOYMENT_ID, + "doc": _DOC_ID, + "chunk": _CHUNK_ID, + "span": "I applied to adoption agencies!", + }, + ) + connection.execute( + text( + "INSERT INTO claim_extraction_decisions (" + " decision_id, deployment_id, doc_id, chunk_id, claim_id," + " decision_type, source_span, reason, edit_detail," + " protected_class, extractor_version" + ") VALUES (" + " :id, :d, :doc, :chunk, NULL," + " 'grounding_rejected', :span, NULL," + " CAST(:detail AS jsonb)," + " NULL, 'e2-extract-2026.07e:loss-ledger-1'" + ")" + ), + { + "id": rejected_id, + "d": _DEPLOYMENT_ID, + "doc": _DOC_ID, + "chunk": _CHUNK_ID, + "span": "Atlas was cancelled in March", + "detail": ( + '{"gate": "span_not_found",' + ' "claim_span": "Atlas was cancelled in March"}' + ), + }, + ) + + with database_engine.connect() as connection: + rows = ( + connection.execute( + text( + "SELECT decision_id, decision_type, source_span, edit_detail" + " FROM claim_extraction_decisions" + " WHERE decision_type IN" + " ('claimify_omitted', 'grounding_rejected')" + " ORDER BY decision_type" + ) + ) + .mappings() + .all() + ) + + assert len(rows) == 2 + by_type = {row["decision_type"]: row for row in rows} + omitted = by_type["claimify_omitted"] + assert omitted["decision_id"] == omitted_id + assert omitted["source_span"] == "I applied to adoption agencies!" + assert omitted["edit_detail"] is None + rejected = by_type["grounding_rejected"] + assert rejected["decision_id"] == rejected_id + assert rejected["source_span"] == "Atlas was cancelled in March" + assert rejected["edit_detail"]["gate"] == "span_not_found" + assert rejected["edit_detail"]["claim_span"] == "Atlas was cancelled in March" + + +def test_copy_reused_decisions_carries_the_prior_transcript_forward( + database_engine: Engine, +) -> None: + """D56 zero-claim reuse copies the prior chunk's loss rows verbatim under + the new chunk with fresh decision ids; an empty prior copies nothing, so + the caller knows to write the terminal marker instead (#161).""" + from rememberstack.spine.claim_catalog import ClaimCatalog + + with database_engine.begin() as connection: + connection.execute(statement=text("TRUNCATE TABLE claim_extraction_decisions")) + + prior_chunk = uuid4() + reused_chunk = uuid4() + prior_id = uuid4() + with database_engine.begin() as connection: + connection.execute( + text( + "INSERT INTO claim_extraction_decisions (" + " decision_id, deployment_id, doc_id, chunk_id, claim_id," + " decision_type, source_span, reason, edit_detail," + " protected_class, extractor_version" + ") VALUES (" + " :id, :d, :doc, :chunk, NULL," + " 'claimify_omitted', :span, NULL, NULL," + " 'date', 'e2-extract-2026.07e:loss-ledger-1'" + ")" + ), + { + "id": prior_id, + "d": _DEPLOYMENT_ID, + "doc": _DOC_ID, + "chunk": prior_chunk, + "span": "I applied to adoption agencies!", + }, + ) + + catalog = ClaimCatalog(engine=database_engine) + copied = catalog.copy_reused_decisions( + chunk_id=reused_chunk, prior_chunk_id=prior_chunk + ) + assert copied == 1 + + with database_engine.connect() as connection: + rows = ( + connection.execute( + text( + "SELECT decision_id, decision_type, source_span," + " protected_class, extractor_version" + " FROM claim_extraction_decisions WHERE chunk_id = :chunk" + ), + {"chunk": reused_chunk}, + ) + .mappings() + .all() + ) + assert len(rows) == 1 + assert rows[0]["decision_type"] == "claimify_omitted" + assert rows[0]["source_span"] == "I applied to adoption agencies!" + assert rows[0]["protected_class"] == "date" + assert rows[0]["extractor_version"] == "e2-extract-2026.07e:loss-ledger-1" + assert rows[0]["decision_id"] != prior_id # fresh id, same content + + # an empty prior transcript copies nothing — caller owns the marker + assert catalog.copy_reused_decisions(chunk_id=uuid4(), prior_chunk_id=uuid4()) == 0 diff --git a/src/tests/workers/test_e2_chain.py b/src/tests/workers/test_e2_chain.py index 445e8e98..a58f4de7 100644 --- a/src/tests/workers/test_e2_chain.py +++ b/src/tests/workers/test_e2_chain.py @@ -342,11 +342,13 @@ def test_claims_land_grounded_with_drops_ledgered_and_stance_kept(rig: _E2Rig) - # the attributed stance is kept as an attributed claim (D59): assert stance["is_attributed"] - # drops, flags, and edits are ledgered (D33); Selection is enforced — the - # fused call's attempt to resurrect the dropped advice span never landed: + # drops, flags, edits, and Claimify-stage rejections are ledgered (D33/#161); + # Selection is enforced — the fused call's attempt to resurrect the dropped + # advice span never landed (it becomes grounding_rejected, not a claims row): by_kind = {decision["decision_type"]: decision for decision in decisions} assert set(by_kind) == { "decontext_edit", + "grounding_rejected", "selection_drop", "selection_keep_flagged", } @@ -359,7 +361,31 @@ def test_claims_land_grounded_with_drops_ledgered_and_stance_kept(rig: _E2Rig) - flagged_claim = connection.execute( text("SELECT claim_id FROM claims WHERE kept_flagged") ).scalar_one() + rejections = ( + connection.execute( + text( + "SELECT source_span, edit_detail" + " FROM claim_extraction_decisions" + " WHERE decision_type = 'grounding_rejected'" + " ORDER BY source_span" + ) + ) + .mappings() + .all() + ) assert by_kind["selection_keep_flagged"]["claim_id"] == flagged_claim + # three silent deaths become three named gate rejections (#161): + assert [row["source_span"] for row in rejections] == [ + "Atlas was cancelled in March", + "Project Atlas launched in 2024", + "You should try it yourself.", + ] + gates = {row["source_span"]: row["edit_detail"]["gate"] for row in rejections} + assert gates["Atlas was cancelled in March"] == "span_not_found" + assert gates["Project Atlas launched in 2024"] == "added_context_unverified" + assert gates["You should try it yourself."] == "outside_kept_ranges" + # both keeps produced accepted claims — no claimify_omitted rows: + assert "claimify_omitted" not in by_kind assert links == len(claims) assert [call["call_key"].split(":", 1)[0] for call in metered_calls] == [