From 980c2988eb248e82e0cb3615284602c57f1e804f Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Wed, 29 Jul 2026 11:14:49 +0200 Subject: [PATCH 1/2] feat: consume D79 section summaries as orientation --- src/rememberstack/model/chunks.py | 1 + src/rememberstack/spine/chunk_catalog.py | 3 +- src/rememberstack/workers/e1.py | 51 +++-- src/rememberstack/workers/e2.py | 14 +- .../workers/section_orientation.py | 78 +++++++ .../workers/test_claimify_loss_ledger.py | 51 ++++- .../workers/test_d79_summary_consumption.py | 211 ++++++++++++++++++ src/tests/workers/test_e0_chain.py | 10 + src/tests/workers/test_e2_chain.py | 20 +- src/tests/workers/test_reuse_lifecycle.py | 81 ++++++- 10 files changed, 499 insertions(+), 21 deletions(-) create mode 100644 src/rememberstack/workers/section_orientation.py create mode 100644 src/tests/workers/test_d79_summary_consumption.py diff --git a/src/rememberstack/model/chunks.py b/src/rememberstack/model/chunks.py index 21eb8f22..19324729 100644 --- a/src/rememberstack/model/chunks.py +++ b/src/rememberstack/model/chunks.py @@ -27,6 +27,7 @@ class SectionSpan(BaseModel): role: str block_start: int = Field(ge=0) block_end: int = Field(ge=-1) + summary: str | None = None class ChunkSource(BaseModel): diff --git a/src/rememberstack/spine/chunk_catalog.py b/src/rememberstack/spine/chunk_catalog.py index 6a81a6b0..e8205b58 100644 --- a/src/rememberstack/spine/chunk_catalog.py +++ b/src/rememberstack/spine/chunk_catalog.py @@ -161,7 +161,8 @@ def record_embeddings(self, *, updates: tuple[EmbeddingUpdate, ...]) -> None: _SELECT_SECTIONS = text( """ - SELECT s.section_id, s.node_path, s.role, s.block_start, s.block_end + SELECT s.section_id, s.node_path, s.role, s.block_start, s.block_end, + s.summary FROM document_sections s JOIN document_representations r ON r.representation_id = s.representation_id diff --git a/src/rememberstack/workers/e1.py b/src/rememberstack/workers/e1.py index 86a842f5..32a4e922 100644 --- a/src/rememberstack/workers/e1.py +++ b/src/rememberstack/workers/e1.py @@ -43,6 +43,8 @@ from rememberstack.ports.p1_index import ChunkIndexPort from rememberstack.spine.chunk_catalog import ChunkCatalog from rememberstack.workers.base import HandlerOutcome +from rememberstack.workers.section_orientation import render_section_orientation +from rememberstack.workers.section_orientation import SECTION_ORIENTATION_VERSION E1_CHUNK_VERSION: Final = CHUNKER_VERSION """The chunk stage's component version IS the chunker version (D12/D58).""" @@ -50,20 +52,26 @@ E1_EMBED_VERSION: Final = "e1-embed-2026.07" """The embed stage's component version (model identity rides settings/stamps).""" -E1_PREFIXER_VERSION: Final = "e1-prefix-2026.07b:temp0-1" +E1_PREFIXER_VERSION: Final = f"e1-prefix-2026.07c:temp0-1:{SECTION_ORIENTATION_VERSION}" """The context-prefix call's prompt generation (D58; conventional mode, D63). -07b pins temperature=0.0 — generation parameters are part of provenance.""" +07b pins temperature=0.0 — generation parameters are part of provenance. +07c adds D79's bounded current-generation section-summary orientation.""" -E2_EXTRACTOR_VERSION: Final = "e2-extract-2026.07e:loss-ledger-1" +E2_EXTRACTOR_VERSION: Final = ( + f"e2-extract-2026.07f:loss-ledger-1:{SECTION_ORIENTATION_VERSION}" +) """The extractor generation baked into extraction_input_hash (D56); the E2 -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).""" +stage (WP-1.3) binds its handler to this same constant. 07f adds D79 summary +orientation to the bundle without making summaries hash or grounding inputs; +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 — " "document title, section, and what surrounds it. Passage from " - "{title!r}, section path {section_path}, chunk {ordinal}:\n\n{head}" + "{title!r}, section path {section_path}, chunk {ordinal}" + "{section_orientation}:\n\n{head}" ) @@ -317,12 +325,7 @@ def _resolve_prefix( if carried is not None: return carried.context_prefix head = document_md[chunk.char_start : chunk.char_end][:400] - prompt = _PREFIX_PROMPT_TEMPLATE.format( - title=source.title or "untitled", - section_path=chunk.section_path, - ordinal=chunk.ordinal, - head=head, - ) + prompt = _prefix_prompt(source=source, chunk=chunk, head=head) response = self._model_provider.generate( request=ModelRequest( model=self._settings.prefix_model, prompt=prompt, temperature=0.0 @@ -335,6 +338,28 @@ def _resolve_prefix( return response.output.prefix +def _prefix_prompt(*, source: ChunkSource, chunk: ChunkForEmbedding, head: str) -> str: + """Render the prefix input; degraded summaries preserve the old bytes.""" + orientation = render_section_orientation( + sections=source.sections, target_path=chunk.section_path + ) + section_orientation = ( + "" + if orientation is None + else ( + "\nSECTION SUMMARIES (orientation only; never quote as source):\n" + f"{orientation}" + ) + ) + return _PREFIX_PROMPT_TEMPLATE.format( + title=source.title or "untitled", + section_path=chunk.section_path, + ordinal=chunk.ordinal, + section_orientation=section_orientation, + head=head, + ) + + def _chunk_record( *, source: ChunkSource, diff --git a/src/rememberstack/workers/e2.py b/src/rememberstack/workers/e2.py index 64060c9c..8ef2b329 100644 --- a/src/rememberstack/workers/e2.py +++ b/src/rememberstack/workers/e2.py @@ -52,6 +52,7 @@ from rememberstack.workers.base import HandlerOutcome from rememberstack.workers.e1 import E2_EXTRACTOR_VERSION from rememberstack.workers.e3 import E3_NORMALIZER_VERSION +from rememberstack.workers.section_orientation import render_section_orientation _logger = logging.getLogger(__name__) @@ -73,6 +74,7 @@ source_span must be a verbatim substring of the target chunk. Report one outcome per candidate, exactly one of: {outcomes}. The drop_* values carry the reason in the value itself; there is no separate reason field. +SECTION SUMMARIES are orientation only and are never quotable source text. {bundle}""" @@ -85,9 +87,10 @@ candidate. For each claim return: claim_text (standalone), source_span (the verbatim chunk substring it derives from), added_context (every substring you ADDED, each tagged header|neighbour|prefix with the exact text as it appears -in that bundle element), entailment_self_verdict (does chunk+bundle entail the -claim), is_attributed. When the source states or implies WHEN a fact holds or -happened, resolve relative dates USING ONLY THE BUNDLE (as with +in that bundle element; SECTION SUMMARIES are orientation only, never quotable +and never an added_context source), entailment_self_verdict (does chunk+bundle +entail the claim), is_attributed. When the source states or implies WHEN a fact +holds or happened, resolve relative dates USING ONLY THE BUNDLE (as with decontextualization) and emit valid_kind, valid_from_iso, valid_until_iso, and valid_precision. Use ISO-8601 dates (YYYY-MM-DD) or datetimes WITH an explicit offset or Z; never emit a datetime without an offset. Otherwise leave @@ -547,9 +550,14 @@ def _bundle_text( ) -> str: """Assemble the D31 context bundle for one target chunk.""" chunk = chunks[index] + summaries = render_section_orientation( + sections=source.sections, target_path=chunk.section_path + ) return ( f"DOCUMENT HEADER: {_header_text(source=source)}\n" f"SECTION: path {chunk.section_path}, role {chunk.section_role}\n" + "SECTION SUMMARIES (orientation only; never quote as source):\n" + f"{summaries or '(none)'}\n" f"CONTEXT PREFIX: {chunk.context_prefix or '(none)'}\n" f"PREVIOUS CHUNK:\n{_neighbour_text(chunks=chunks, index=index - 1, document_md=document_md, section_path=chunk.section_path)}\n" f"NEXT CHUNK:\n{_neighbour_text(chunks=chunks, index=index + 1, document_md=document_md, section_path=chunk.section_path)}\n" diff --git a/src/rememberstack/workers/section_orientation.py b/src/rememberstack/workers/section_orientation.py new file mode 100644 index 00000000..a70b6093 --- /dev/null +++ b/src/rememberstack/workers/section_orientation.py @@ -0,0 +1,78 @@ +"""Bounded D79 section-summary orientation shared by E1 and E2.""" + +from typing import Final + +from rememberstack.model import SectionSpan + +SECTION_ORIENTATION_MAX_CHARS: Final = 2_048 +"""Hard cap for the complete target + ancestor summary rendering.""" + +SECTION_ORIENTATION_VERSION: Final = ( + "d79-section-orientation-v1:" + f"max-chars{SECTION_ORIENTATION_MAX_CHARS}:target-first:unicode-ellipsis" +) +"""Pins ordering and truncation semantics for E1/E2 provenance versions.""" + + +def render_section_orientation( + *, sections: tuple[SectionSpan, ...], target_path: str +) -> str | None: + """Render non-null target/ancestor summaries under one hard character cap. + + The target is first so the most local orientation cannot be crowded out by + a deep ancestor chain. Ancestors then run nearest-first. Missing summaries + contribute no line; an entirely degraded generation returns ``None``. + """ + by_path = {section.node_path: section for section in sections} + paths = _target_and_ancestor_paths(target_path=target_path) + lines = tuple( + f"{'TARGET' if index == 0 else 'ANCESTOR'} {path}: {summary}" + for index, path in enumerate(paths) + if (section := by_path.get(path)) is not None + and (summary := _one_line(section.summary)) is not None + ) + if not lines: + return None + return _bounded_lines(lines=lines) + + +def _target_and_ancestor_paths(*, target_path: str) -> tuple[str, ...]: + """Return the target followed by its materialized-path ancestors.""" + paths = [target_path] + while "." in paths[-1]: + paths.append(paths[-1].rsplit(".", 1)[0]) + return tuple(paths) + + +def _one_line(value: str | None) -> str | None: + """Normalize defensive legacy values; D79 outputs are already one-line.""" + if value is None: + return None + normalized = " ".join(value.split()) + return normalized or None + + +def _bounded_lines(*, lines: tuple[str, ...]) -> str: + """Join full lines until the cap, ellipsizing the final fitting fragment.""" + rendered: list[str] = [] + used = 0 + for line in lines: + separator_chars = 1 if rendered else 0 + remaining = SECTION_ORIENTATION_MAX_CHARS - used - separator_chars + if remaining <= 0: + break + if len(line) <= remaining: + rendered.append(line) + used += separator_chars + len(line) + continue + rendered.append(_ellipsize(value=line, max_chars=remaining)) + break + return "\n".join(rendered) + + +def _ellipsize(*, value: str, max_chars: int) -> str: + if len(value) <= max_chars: + return value + if max_chars <= 1: + return "…"[:max_chars] + return value[: max_chars - 1] + "…" diff --git a/src/tests/workers/test_claimify_loss_ledger.py b/src/tests/workers/test_claimify_loss_ledger.py index 4ff1f358..e4d3b1f4 100644 --- a/src/tests/workers/test_claimify_loss_ledger.py +++ b/src/tests/workers/test_claimify_loss_ledger.py @@ -19,6 +19,7 @@ from rememberstack.model import ClaimRecord from rememberstack.model import DecisionRecord from rememberstack.model import DecisionType +from rememberstack.model import SectionSpan from rememberstack.model import SelectionCandidate from rememberstack.model import SelectionOutcome from rememberstack.workers import E2Settings @@ -39,6 +40,7 @@ _CHUNK = UUID("81000000-0000-0000-0000-000000000003") _VERSION = UUID("81000000-0000-0000-0000-000000000004") _REPR = UUID("81000000-0000-0000-0000-000000000005") +_SECTION = UUID("81000000-0000-0000-0000-000000000006") # A short document with two keep-worthy spans and one advisory drop target. _DOC_MD = ( @@ -66,7 +68,16 @@ def _source() -> ChunkSource: published_at=None, language="en", structurer_version="test-structurer", - sections=(), + sections=( + SectionSpan( + section_id=_SECTION, + node_path="0", + role="body", + block_start=0, + block_end=0, + summary="Project Atlas was internally codenamed Project Orion.", + ), + ), ) @@ -82,7 +93,7 @@ def _chunk(*, document_md: str = _DOC_MD) -> ChunkForEmbedding: chunk_content_hash="sha256:fixture", extraction_input_hash="sha256:fixture-in", section_role="body", - section_path="/", + section_path="0", context_prefix="Sits in the Project Atlas launch report.", prefixer_version="test-prefixer", ) @@ -334,6 +345,42 @@ def test_handler_rejection_suppresses_omission_only_for_its_keep() -> None: assert rows[DecisionType.CLAIMIFY_OMITTED] == [_KEEP_STANCE] +def test_handler_rejects_summary_only_added_context_fact_injection() -> None: + """Summary text is visible orientation but cannot ground an addition.""" + recorder = _run_extract( + selection={"candidates": [{"source_span": _KEEP_LAUNCH, "outcome": "keep"}]}, + claimify={ + "claims": [ + { + "claim_text": "Project Orion launched in 2024.", + "source_span": "Project Atlas launched in 2024", + "added_context": [ + {"text": "Project Orion", "source_kind": "summary"} + ], + "entailment_self_verdict": True, + } + ] + }, + ) + + assert recorder.claims == () + rejection = next( + decision + for decision in recorder.decisions + if decision.decision_type is DecisionType.GROUNDING_REJECTED + ) + assert rejection.edit_detail == { + "gate": "added_context_unverified", + "claim_span": "Project Atlas launched in 2024", + "kind": "summary", + "text": "Project Orion", + } + assert not any( + decision.decision_type is DecisionType.CLAIMIFY_OMITTED + for decision in recorder.decisions + ) + + 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.""" diff --git a/src/tests/workers/test_d79_summary_consumption.py b/src/tests/workers/test_d79_summary_consumption.py new file mode 100644 index 00000000..f73a1823 --- /dev/null +++ b/src/tests/workers/test_d79_summary_consumption.py @@ -0,0 +1,211 @@ +"""Unit-speed proofs for D79's bounded, orientation-only E1/E2 consumption.""" + +from uuid import UUID + +from rememberstack.model import ChunkForEmbedding +from rememberstack.model import ChunkSource +from rememberstack.model import PackedChunk +from rememberstack.model import SectionSpan +from rememberstack.workers.e1 import _chunk_record +from rememberstack.workers.e1 import _prefix_prompt +from rememberstack.workers.e2 import _bundle_text +from rememberstack.workers.section_orientation import render_section_orientation +from rememberstack.workers.section_orientation import SECTION_ORIENTATION_MAX_CHARS + +_DEPLOYMENT = UUID("82000000-0000-0000-0000-000000000001") +_DOC = UUID("82000000-0000-0000-0000-000000000002") +_VERSION = UUID("82000000-0000-0000-0000-000000000003") +_REPRESENTATION = UUID("82000000-0000-0000-0000-000000000004") +_TARGET_SECTION = UUID("82000000-0000-0000-0000-000000000007") + + +def _section( + *, + section_id: int, + path: str, + summary: str | None, + block_start: int = 0, + block_end: int = 0, +) -> SectionSpan: + return SectionSpan( + section_id=UUID(int=section_id), + node_path=path, + role="body", + block_start=block_start, + block_end=block_end, + summary=summary, + ) + + +def _source(*, sections: tuple[SectionSpan, ...]) -> ChunkSource: + return ChunkSource( + deployment_id=_DEPLOYMENT, + doc_id=_DOC, + version_id=_VERSION, + representation_id=_REPRESENTATION, + markdown_uri="mem://document.md", + blocks_uri="mem://blocks.json", + title="Orientation handbook", + source_kind="upload", + source_modified_at=None, + published_at=None, + language="en", + structurer_version="test-structurer", + sections=sections, + ) + + +def _chunk(*, section_path: str = "0.2.1") -> ChunkForEmbedding: + return ChunkForEmbedding( + chunk_id=UUID("82000000-0000-0000-0000-000000000005"), + doc_id=_DOC, + version_id=_VERSION, + ordinal=7, + char_start=0, + char_end=12, + chunk_content_hash="sha256:chunk", + extraction_input_hash="sha256:input", + section_role="body", + section_path=section_path, + context_prefix="Existing source-derived prefix.", + prefixer_version="test-prefixer", + ) + + +def _orientation_sections() -> tuple[SectionSpan, ...]: + return ( + _section(section_id=1, path="0", summary="The complete handbook."), + _section(section_id=2, path="0.2", summary="Chapter two covers routing."), + _section( + section_id=_TARGET_SECTION.int, + path="0.2.1", + summary="This subsection explains bounded orientation.", + ), + _section(section_id=4, path="0.9", summary="A sibling must never leak."), + ) + + +def test_e1_prefix_prompt_contains_target_and_ancestor_summaries() -> None: + """The fresh-prefix call sees local orientation, nearest ancestor first.""" + prompt = _prefix_prompt( + source=_source(sections=_orientation_sections()), + chunk=_chunk(), + head="Target text.", + ) + + target = "TARGET 0.2.1: This subsection explains bounded orientation." + chapter = "ANCESTOR 0.2: Chapter two covers routing." + root = "ANCESTOR 0: The complete handbook." + assert target in prompt + assert chapter in prompt + assert root in prompt + assert prompt.index(target) < prompt.index(chapter) < prompt.index(root) + assert "A sibling must never leak." not in prompt + + +def test_e1_prefix_prompt_is_hard_capped_with_ellipsis() -> None: + """One shared named cap bounds the complete target + ancestor rendering.""" + sections = ( + _section(section_id=1, path="0", summary="root"), + _section( + section_id=_TARGET_SECTION.int, + path="0.2.1", + summary="x" * (SECTION_ORIENTATION_MAX_CHARS * 2), + ), + ) + orientation = render_section_orientation(sections=sections, target_path="0.2.1") + + assert orientation is not None + assert len(orientation) == SECTION_ORIENTATION_MAX_CHARS + assert orientation.startswith("TARGET 0.2.1: ") + assert orientation.endswith("…") + assert "ANCESTOR 0:" not in orientation + + +def test_e1_absent_summaries_preserve_the_pre_wave_prompt_shape() -> None: + """A degraded generation contributes zero bytes to the prefix prompt.""" + prompt = _prefix_prompt( + source=_source( + sections=( + _section(section_id=1, path="0", summary=None), + _section(section_id=_TARGET_SECTION.int, path="0.2.1", summary=None), + ) + ), + chunk=_chunk(), + head="Target text.", + ) + + assert prompt == ( + "In one sentence, state where this passage sits in the document — " + "document title, section, and what surrounds it. Passage from " + "'Orientation handbook', section path 0.2.1, chunk 7:\n\nTarget text." + ) + + +def test_summaries_do_not_enter_extraction_input_hash() -> None: + """Two chunk-record runs differing only in summaries keep the D56 key.""" + packed = ( + PackedChunk( + ordinal=0, + section_id=_TARGET_SECTION, + block_start=0, + block_end=0, + char_start=0, + char_end=12, + chunk_content_hash="sha256:stable-blocks", + token_count=2, + ), + ) + before = _chunk_record( + source=_source( + sections=( + _section(section_id=1, path="0", summary="Old root orientation."), + _section( + section_id=_TARGET_SECTION.int, + path="0.2.1", + summary="Old local orientation.", + ), + ) + ), + packed=packed, + index=0, + chunker_version="test-chunker", + ) + after = _chunk_record( + source=_source( + sections=( + _section(section_id=1, path="0", summary="New root orientation."), + _section( + section_id=_TARGET_SECTION.int, + path="0.2.1", + summary="New local orientation.", + ), + ) + ), + packed=packed, + index=0, + chunker_version="test-chunker", + ) + + assert before.extraction_input_hash == after.extraction_input_hash + + +def test_e2_bundle_places_section_summaries_before_context_prefix() -> None: + """The D31 element is present but remains separate from grounding kinds.""" + document_md = "Target text." + bundle = _bundle_text( + source=_source(sections=_orientation_sections()), + chunks=(_chunk(),), + index=0, + document_md=document_md, + ) + + section_at = bundle.index("SECTION: path 0.2.1, role body") + summaries_at = bundle.index( + "SECTION SUMMARIES (orientation only; never quote as source):" + ) + prefix_at = bundle.index("CONTEXT PREFIX: Existing source-derived prefix.") + assert section_at < summaries_at < prefix_at + assert "TARGET 0.2.1: This subsection explains bounded orientation." in bundle + assert "ANCESTOR 0.2: Chapter two covers routing." in bundle + assert "ANCESTOR 0: The complete handbook." in bundle diff --git a/src/tests/workers/test_e0_chain.py b/src/tests/workers/test_e0_chain.py index 782a446e..c1e0a599 100644 --- a/src/tests/workers/test_e0_chain.py +++ b/src/tests/workers/test_e0_chain.py @@ -39,6 +39,7 @@ from rememberstack.model import SkeletonStats from rememberstack.model import SnappedSection from rememberstack.model import StructureRouteTag +from rememberstack.spine import ChunkCatalog from rememberstack.spine import DeploymentBootstrapper from rememberstack.spine import DocumentCatalog from rememberstack.spine import ForgetCatalog @@ -1305,6 +1306,15 @@ def parser_must_not_run(**kwargs: object) -> object: str(row["summary"]) for row in second_sections } assert second_sections[0]["placement_path"] == "/field-research/beta/" + consumed = ChunkCatalog(engine=rig.engine).chunk_source( + representation_id=representation["representation_id"] # type: ignore[arg-type] + ) + assert [section.summary for section in consumed.sections] == [ + row["summary"] for row in second_sections + ] + assert not {row["summary"] for row in first_sections}.intersection( + section.summary for section in consumed.sections + ) assert not any( request.model != "summary/model-beta" for request in second_provider.generated_requests diff --git a/src/tests/workers/test_e2_chain.py b/src/tests/workers/test_e2_chain.py index a58f4de7..62baf390 100644 --- a/src/tests/workers/test_e2_chain.py +++ b/src/tests/workers/test_e2_chain.py @@ -180,6 +180,11 @@ def __init__(self, *, engine: Engine, root: Path) -> None: artifact_store = LocalFSObjectStore(root=root / "artifacts") self.provider = FakeModelProvider( generate_payloads={ + "FallbackStructureResponse": {"sections": []}, + "RootSummaryPlacementResponse": { + "summary": "A Project Atlas launch report.", + "placement_path": "/projects/atlas/", + }, "ContextPrefix": {"prefix": _PREFIX}, "SelectionResponse": _SELECTION_PAYLOAD, "ClaimifyResponse": _CLAIMIFY_PAYLOAD, @@ -214,7 +219,9 @@ def __init__(self, *, engine: Engine, root: Path) -> None: registry.register( stage=PipelineStage.STRUCTURE, handler=StructureHandler( - catalog=document_catalog, artifact_store=artifact_store + catalog=document_catalog, + artifact_store=artifact_store, + model_provider=self.provider, ), ) registry.register( @@ -402,6 +409,17 @@ def test_claims_land_grounded_with_drops_ledgered_and_stance_kept(rig: _E2Rig) - request.temperature == 0.0 for request in rig.provider.generated_requests ) assert all(call["cost_usd"] == 0 for call in metered_calls) + summary_line = "TARGET 0: A Project Atlas launch report." + assert any( + "state where this passage sits" in prompt and summary_line in prompt + for prompt in rig.provider.generated_prompts + ) + assert any( + "Selection stage of a claim extractor" in prompt + and "SECTION SUMMARIES (orientation only; never quote as source):" in prompt + and summary_line in prompt + for prompt in rig.provider.generated_prompts + ) def test_rerunning_extraction_replays_without_model_calls(rig: _E2Rig) -> None: diff --git a/src/tests/workers/test_reuse_lifecycle.py b/src/tests/workers/test_reuse_lifecycle.py index 5fb9e2c8..636b673b 100644 --- a/src/tests/workers/test_reuse_lifecycle.py +++ b/src/tests/workers/test_reuse_lifecycle.py @@ -86,6 +86,14 @@ def _paragraphs(*, edited: bool) -> str: def _canned(prompt: str, type_name: str) -> dict[str, object]: """Deterministic Selection/Claimify payloads grounded in the real bundle.""" + if type_name == "FallbackStructureResponse": + return {"sections": []} + if type_name == "RootSummaryPlacementResponse": + marker = "edited" if "entirely different" in prompt else "original" + return { + "summary": f"The {marker} reuse-spike document.", + "placement_path": "/reuse-spike/", + } if type_name == "ContextPrefix": return {"prefix": "Sits in the reuse spike corpus."} match = _TARGET_PATTERN.search(prompt) @@ -175,7 +183,11 @@ def __init__(self, *, engine: Engine, root: Path) -> None: ) registry.register( stage=PipelineStage.STRUCTURE, - handler=StructureHandler(catalog=catalog, artifact_store=artifact_store), + handler=StructureHandler( + catalog=catalog, + artifact_store=artifact_store, + model_provider=self.provider, + ), ) registry.register( stage=PipelineStage.CHUNK, @@ -334,6 +346,73 @@ def test_reuse_cost_is_proportional_to_the_edit(rig: _ReuseRig) -> None: ) +def test_summary_change_keeps_hash_and_reuses_unchanged_chunks(rig: _ReuseRig) -> None: + """An ancestor re-summary never fans out into chunk re-extraction.""" + first = rig.observe(content=_paragraphs(edited=False)) + rig.drain() + baseline_selection = rig.selection_calls() + with rig.engine.connect() as connection: + first_summary = connection.execute( + text( + "SELECT s.summary FROM document_sections s" + " JOIN document_representations r" + " ON r.representation_id = s.representation_id" + " AND r.current_structure_generation_id = s.structure_generation_id" + " WHERE s.version_id = :version_id AND s.node_path = '0'" + ), + {"version_id": first.version_id}, + ).scalar_one() + + second = rig.observe(content=_paragraphs(edited=True)) + rig.drain() + selection_v2 = rig.selection_calls() - baseline_selection + with rig.engine.connect() as connection: + second_summary = connection.execute( + text( + "SELECT s.summary FROM document_sections s" + " JOIN document_representations r" + " ON r.representation_id = s.representation_id" + " AND r.current_structure_generation_id = s.structure_generation_id" + " WHERE s.version_id = :version_id AND s.node_path = '0'" + ), + {"version_id": second.version_id}, + ).scalar_one() + v2_chunk_count = connection.execute( + text("SELECT count(*) FROM chunks WHERE version_id = :version_id"), + {"version_id": second.version_id}, + ).scalar_one() + reused = ( + connection.execute( + text( + "SELECT c2.extraction_input_hash AS second_hash," + " c1.extraction_input_hash AS first_hash" + " FROM chunks c2" + " JOIN chunks c1" + " ON c1.doc_id = c2.doc_id" + " AND c1.extraction_input_hash = c2.extraction_input_hash" + " WHERE c1.version_id = :first_version" + " AND c2.version_id = :second_version" + " AND EXISTS (SELECT 1 FROM chunk_claims cc" + " WHERE cc.chunk_id = c2.chunk_id)" + " AND NOT EXISTS (SELECT 1 FROM claims cl" + " WHERE cl.chunk_id = c2.chunk_id)" + " LIMIT 1" + ), + { + "first_version": first.version_id, + "second_version": second.version_id, + }, + ) + .mappings() + .one() + ) + + assert first_summary == "The original reuse-spike document." + assert second_summary == "The edited reuse-spike document." + assert reused["first_hash"] == reused["second_hash"] + assert 0 < selection_v2 < v2_chunk_count + + def test_reused_chunks_carry_identical_claims_and_prefixes(rig: _ReuseRig) -> None: """Re-attachment is exact: an unchanged chunk's occurrence links point at the SAME immutable claim rows as version 1, and its stored prefix is From 16fc17f6cf12b4300b60e3f7361b6a2572b57542 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Wed, 29 Jul 2026 12:32:18 +0200 Subject: [PATCH 2/2] fix: apply Wave-3 review findings (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grok + Opus both APPROVE-WITH-FIXES; all findings applied: - The summary->prefix second-order channel (Opus MAJOR, adjudicated "status quo on quotability, widening of reach"): the E1 instruction now constrains OUTPUT (describe where the passage sits; never restate a summary's assertions) and the accepted residual is recorded in the design consumption bullet and a dated D79 amendment, alongside the carried-prefix staleness corollary. - Cross-generation orientation guard implemented, not just documented: ChunkForEmbedding carries section_id (loaded in the embedding SELECT), and render_section_orientation degrades to None when the chunk's section id does not match the current generation's row at that path — a coincident node path across a skeleton regeneration can no longer attach another section's summaries. Unit test included. - Fact-injection probes extended to every LEGAL added_context kind (header/neighbour/prefix) with summary-only poison text. - E2's degraded "(none)" rendering pinned by test; asymmetry with E1's zero-bytes documented as bundle-idiom consistency (adjudicated against the symmetry suggestion). - Reuse test de-tautologized (reuse breadth pinned by count) and its docstring scoped honestly; NEW pure summary-seat-swap chain test proves the un-conflated D79 promise: new generation minted, pointer moved, zero Selection calls, zero ContextPrefix calls, byte-identical extraction_input_hash. Fast gate local (ruff, pyright, targeted suites); the full matrix is CI's job per the new split policy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GKENhTLJg1HqhbdwCmmkbc --- decisions.md | 10 ++ plan/designs/e0_files_design.md | 15 ++- src/rememberstack/model/chunks.py | 4 + src/rememberstack/spine/chunk_catalog.py | 2 +- src/rememberstack/workers/e1.py | 17 +++- src/rememberstack/workers/e2.py | 4 +- .../workers/section_orientation.py | 22 ++++- .../workers/test_claimify_loss_ledger.py | 20 ++++ .../workers/test_d79_summary_consumption.py | 38 ++++++++ src/tests/workers/test_reuse_lifecycle.py | 93 ++++++++++++++++++- 10 files changed, 214 insertions(+), 11 deletions(-) diff --git a/decisions.md b/decisions.md index 6772ad98..4e08f088 100644 --- a/decisions.md +++ b/decisions.md @@ -2836,3 +2836,13 @@ shared tokenizer) with heading metadata (raw level, normalized title) exposed un `blockizer_version` bump — never a second parallel parse. Scope fence: the check judges the tree, not the text under it — intra-section reading-order scrambles are the conversion-layer track (D38, issue #168). Design detail: `plan/designs/e0_files_design.md` §4.1. + +**Amendment (2026-07-29, Wave-3 review of #165): the summary→prefix second-order channel.** +Summaries feed the E1 prefix input (as this decision mandates) and the stored prefix is a +quotable `added_context` element, so a summary-informed prefix is a bounded second-order path +by which non-neighbouring content can reach the grounding surface. Adjudicated: status quo on +quotability, widening of reach; accepted as layer-3/4 audit territory with an output-constraining +prefix instruction (describe location only, never restate a summary's assertions). Also +recorded: carried prefixes keep their summary generation until content re-chunks or the +prefixer version bumps — the no-fan-out corollary. Detail: `plan/designs/e0_files_design.md` +§4.1 consumption bullet. diff --git a/plan/designs/e0_files_design.md b/plan/designs/e0_files_design.md index 9a837f46..882bc0ae 100644 --- a/plan/designs/e0_files_design.md +++ b/plan/designs/e0_files_design.md @@ -419,7 +419,20 @@ unchanged): so a re-summarization — including an ancestor summary changing because a sibling leaf was edited — never silently invalidates or re-extracts unchanged chunks. Better summaries improve *future* extractions; they never fan out into document-wide - reprocessing. + reprocessing. Corollary (2026-07-29, Wave-3 review): the same no-fan-out rule means + better summaries reach the **prefix** only through re-chunked content or a + prefixer-version bump — a carried prefix keeps the summary generation it was written + under indefinitely, by design. + + **Accepted second-order channel (2026-07-29, Wave-3 review).** Because summaries feed the + E1 prefix input and the stored prefix is itself a quotable `added_context` element, a + summary-informed prefix is a bounded second-order path by which content from + NON-neighbouring sections (a bottom-up parent one-liner distills sibling subtrees) can + reach the grounding surface — a reach the `_neighbour_text` same-scope rule deliberately + forbids for direct quotation. Status quo on *quotability* (the prefix was always LLM + text), a widening of *reach*. Accepted because the prefix's honesty was always layer-3/4 + audit territory; mitigated in-prompt by instructing the prefixer to use summaries only to + DESCRIBE WHERE the passage sits, never to restate a summary's factual assertions. - **Provenance and migration.** `structurer_version` splits into generations: skeleton (deterministic parser + fallback contract), **skeleton check** (the sanity-check bullet above), role pass, summary seat, and placement — each hash-stamped per D12. Structure/summary generations are immutable with a current pointer diff --git a/src/rememberstack/model/chunks.py b/src/rememberstack/model/chunks.py index 19324729..835b9b99 100644 --- a/src/rememberstack/model/chunks.py +++ b/src/rememberstack/model/chunks.py @@ -102,6 +102,10 @@ class ChunkForEmbedding(BaseModel): extraction_input_hash: str section_role: str section_path: str + section_id: UUID | None = None + """The section row the chunk was cut under — the cross-generation guard + input for summary orientation (optional: legacy constructors omit it and + the guard simply does not arm).""" context_prefix: str | None prefixer_version: str | None diff --git a/src/rememberstack/spine/chunk_catalog.py b/src/rememberstack/spine/chunk_catalog.py index e8205b58..1d27dbf5 100644 --- a/src/rememberstack/spine/chunk_catalog.py +++ b/src/rememberstack/spine/chunk_catalog.py @@ -201,7 +201,7 @@ def record_embeddings(self, *, updates: tuple[EmbeddingUpdate, ...]) -> None: """ SELECT c.chunk_id, c.doc_id, c.version_id, c.ordinal, c.char_start, c.char_end, c.context_prefix, c.prefixer_version, - c.chunk_content_hash, c.extraction_input_hash, + c.chunk_content_hash, c.extraction_input_hash, c.section_id, s.role AS section_role, s.node_path AS section_path FROM chunks c JOIN document_sections s ON s.section_id = c.section_id diff --git a/src/rememberstack/workers/e1.py b/src/rememberstack/workers/e1.py index 32a4e922..c26fbe68 100644 --- a/src/rememberstack/workers/e1.py +++ b/src/rememberstack/workers/e1.py @@ -339,15 +339,26 @@ def _resolve_prefix( def _prefix_prompt(*, source: ChunkSource, chunk: ChunkForEmbedding, head: str) -> str: - """Render the prefix input; degraded summaries preserve the old bytes.""" + """Render the prefix input; a degraded generation contributes zero bytes. + + The instruction constrains the prefixer's OUTPUT, not just its reading: + summaries are abstractive LLM text, and the stored prefix is a quotable + added_context element downstream — a prefix that restates a summary's + claim would launder second-order content into the grounding surface + (review finding; accepted residual is location-description only). + """ orientation = render_section_orientation( - sections=source.sections, target_path=chunk.section_path + sections=source.sections, + target_path=chunk.section_path, + target_section_id=chunk.section_id, ) section_orientation = ( "" if orientation is None else ( - "\nSECTION SUMMARIES (orientation only; never quote as source):\n" + "\nSECTION SUMMARIES (background only — use them to describe" + " WHERE the passage sits; never restate or assert a fact from a" + " summary in your sentence):\n" f"{orientation}" ) ) diff --git a/src/rememberstack/workers/e2.py b/src/rememberstack/workers/e2.py index 8ef2b329..4959a81b 100644 --- a/src/rememberstack/workers/e2.py +++ b/src/rememberstack/workers/e2.py @@ -551,7 +551,9 @@ def _bundle_text( """Assemble the D31 context bundle for one target chunk.""" chunk = chunks[index] summaries = render_section_orientation( - sections=source.sections, target_path=chunk.section_path + sections=source.sections, + target_path=chunk.section_path, + target_section_id=chunk.section_id, ) return ( f"DOCUMENT HEADER: {_header_text(source=source)}\n" diff --git a/src/rememberstack/workers/section_orientation.py b/src/rememberstack/workers/section_orientation.py index a70b6093..38c2376d 100644 --- a/src/rememberstack/workers/section_orientation.py +++ b/src/rememberstack/workers/section_orientation.py @@ -1,6 +1,7 @@ """Bounded D79 section-summary orientation shared by E1 and E2.""" from typing import Final +from uuid import UUID from rememberstack.model import SectionSpan @@ -15,15 +16,34 @@ def render_section_orientation( - *, sections: tuple[SectionSpan, ...], target_path: str + *, + sections: tuple[SectionSpan, ...], + target_path: str, + target_section_id: UUID | None = None, ) -> str | None: """Render non-null target/ancestor summaries under one hard character cap. The target is first so the most local orientation cannot be crowded out by a deep ancestor chain. Ancestors then run nearest-first. Missing summaries contribute no line; an entirely degraded generation returns ``None``. + + ``target_section_id`` is the cross-generation guard (review finding): the + chunk grid carries the section ids of the generation it was cut under, + while ``sections`` is always the CURRENT generation. When the caller + passes the chunk's section id and the current generation's row at + ``target_path`` is a different section, the whole rendering degrades to + ``None`` — a path that merely coincides across a skeleton regeneration + must not attach another section's summaries. Rendering semantics for + matching generations are unchanged, so the contract version holds. """ by_path = {section.node_path: section for section in sections} + target_row = by_path.get(target_path) + if ( + target_section_id is not None + and target_row is not None + and target_row.section_id != target_section_id + ): + return None paths = _target_and_ancestor_paths(target_path=target_path) lines = tuple( f"{'TARGET' if index == 0 else 'ANCESTOR'} {path}: {summary}" diff --git a/src/tests/workers/test_claimify_loss_ledger.py b/src/tests/workers/test_claimify_loss_ledger.py index e4d3b1f4..76ad4e41 100644 --- a/src/tests/workers/test_claimify_loss_ledger.py +++ b/src/tests/workers/test_claimify_loss_ledger.py @@ -488,3 +488,23 @@ def test_decision_type_enum_includes_loss_ledger_values() -> None: ) assert a.decision_id != b.decision_id assert isinstance(a.decision_id, UUID) + + +def test_summary_text_fails_membership_under_every_legal_kind() -> None: + """The fact-injection probe, legal-kind edition (review): text that exists + ONLY in a section summary must fail layer-2 membership no matter which + legal element the model attributes it to — the poison has to be absent + from header, neighbour, AND prefix, not merely rejected as an unknown + kind.""" + for kind in ("header", "neighbour", "prefix"): + result = _ground( + candidate=CandidateClaim( + claim_text="Project Orion launched in 2024.", + source_span="Project Atlas launched in 2024", + added_context=(AddedContext(text="Project Orion", source_kind=kind),), + entailment_self_verdict=True, + ) + ) + assert isinstance(result, GroundingRejection), kind + assert result.gate is GroundingGate.ADDED_CONTEXT_UNVERIFIED, kind + assert result.kind == kind diff --git a/src/tests/workers/test_d79_summary_consumption.py b/src/tests/workers/test_d79_summary_consumption.py index f73a1823..6dea14f2 100644 --- a/src/tests/workers/test_d79_summary_consumption.py +++ b/src/tests/workers/test_d79_summary_consumption.py @@ -209,3 +209,41 @@ def test_e2_bundle_places_section_summaries_before_context_prefix() -> None: assert "TARGET 0.2.1: This subsection explains bounded orientation." in bundle assert "ANCESTOR 0.2: Chapter two covers routing." in bundle assert "ANCESTOR 0: The complete handbook." in bundle + + +def test_e2_bundle_renders_none_for_a_degraded_generation() -> None: + """E2 keeps the element with the bundle's '(none)' idiom when every + summary is null — asymmetric with E1's zero bytes by design (the bundle + is a fixed element contract, the prefix prompt is not).""" + sections = tuple( + _section(section_id=index + 1, path=path, summary=None) + for index, path in enumerate(("0", "0.2", "0.2.1")) + ) + bundle = _bundle_text( + source=_source(sections=sections), + chunks=(_chunk(),), + index=0, + document_md="chunk body...", + ) + assert "SECTION SUMMARIES (orientation only; never quote as source):" in bundle + assert "\n(none)\n" in bundle + + +def test_orientation_degrades_on_cross_generation_section_mismatch() -> None: + """A chunk cut under a superseded skeleton must not attach the current + generation's summaries via a merely-coincident node path (review): when + the chunk's section id does not match the current row at that path, the + whole rendering degrades to None.""" + sections = _orientation_sections() + assert ( + render_section_orientation( + sections=sections, target_path="0.2.1", target_section_id=_TARGET_SECTION + ) + is not None + ) + assert ( + render_section_orientation( + sections=sections, target_path="0.2.1", target_section_id=UUID(int=999) + ) + is None + ) diff --git a/src/tests/workers/test_reuse_lifecycle.py b/src/tests/workers/test_reuse_lifecycle.py index 636b673b..1338ff5d 100644 --- a/src/tests/workers/test_reuse_lifecycle.py +++ b/src/tests/workers/test_reuse_lifecycle.py @@ -13,6 +13,7 @@ from pathlib import Path import re from uuid import UUID +from uuid import uuid4 from alembic import command from alembic.config import Config @@ -25,15 +26,18 @@ from rememberstack.adapters.selfhost import LanceChunkIndex from rememberstack.adapters.selfhost import LocalFSObjectStore from rememberstack.adapters.testing import FakeModelProvider +from rememberstack.adapters.testing import NoopCostMeter from rememberstack.core import chunker_version from rememberstack.core import ChunkerParams from rememberstack.core import ConversionRouter from rememberstack.core import MarkdownPassthroughConverter +from rememberstack.model import ClaimedWork from rememberstack.model import DeploymentBootstrapInput from rememberstack.model import DocumentUpload from rememberstack.model import IngestedVersion from rememberstack.model import PipelineStage from rememberstack.model import ProcessingLane +from rememberstack.model import ProcessingTarget from rememberstack.model import RunResultOutcome from rememberstack.spine import ChunkCatalog from rememberstack.spine import ClaimCatalog @@ -45,6 +49,7 @@ from rememberstack.spine.settings import load_database_settings from rememberstack.workers import ChunkHandler from rememberstack.workers import ConvertHandler +from rememberstack.workers import E0_STRUCTURE_VERSION from rememberstack.workers import E1Settings from rememberstack.workers import E2_EXTRACTOR_VERSION from rememberstack.workers import E2Settings @@ -52,6 +57,7 @@ from rememberstack.workers import ExtractClaimsHandler from rememberstack.workers import HandlerRegistry from rememberstack.workers import StructureHandler +from rememberstack.workers import SummarySettings from rememberstack.workers import UploadIngestor from rememberstack.workers import Worker @@ -166,6 +172,8 @@ def __init__(self, *, engine: Engine, root: Path) -> None: self.chunk_catalog = ChunkCatalog(engine=engine) self.claim_catalog = ClaimCatalog(engine=engine) catalog = DocumentCatalog(engine=engine) + self.document_catalog = catalog + self.artifact_store = artifact_store self.ingestor = UploadIngestor( catalog=catalog, raw_store=raw_store, admission=ForgetCatalog(engine=engine) ) @@ -347,7 +355,10 @@ def test_reuse_cost_is_proportional_to_the_edit(rig: _ReuseRig) -> None: def test_summary_change_keeps_hash_and_reuses_unchanged_chunks(rig: _ReuseRig) -> None: - """An ancestor re-summary never fans out into chunk re-extraction.""" + """A content edit that also changes the ancestor summary re-extracts only + the edited chunks — reuse breadth pinned by count. (This conflates a + content edit with the summary change by construction; the PURE + summary-swap isolation proof is the dedicated test below.)""" first = rig.observe(content=_paragraphs(edited=False)) rig.drain() baseline_selection = rig.selection_calls() @@ -396,7 +407,6 @@ def test_summary_change_keeps_hash_and_reuses_unchanged_chunks(rig: _ReuseRig) - " WHERE cc.chunk_id = c2.chunk_id)" " AND NOT EXISTS (SELECT 1 FROM claims cl" " WHERE cl.chunk_id = c2.chunk_id)" - " LIMIT 1" ), { "first_version": first.version_id, @@ -404,12 +414,14 @@ def test_summary_change_keeps_hash_and_reuses_unchanged_chunks(rig: _ReuseRig) - }, ) .mappings() - .one() + .all() ) assert first_summary == "The original reuse-spike document." assert second_summary == "The edited reuse-spike document." - assert reused["first_hash"] == reused["second_hash"] + # Reuse breadth, not a tautology (review): every v2 chunk either reused a + # v1 extraction (rows here) or was freshly selected (selection_v2). + assert len(reused) == v2_chunk_count - selection_v2 assert 0 < selection_v2 < v2_chunk_count @@ -509,3 +521,76 @@ def test_extractor_bump_re_extracts_reused_chunks(rig: _ReuseRig) -> None: assert not rig.claim_catalog.chunk_already_extracted( chunk_id=reused_chunk, extractor_version="e2-extract-9999.01" ) + + +def test_pure_summary_seat_swap_reextracts_and_reprefixes_nothing( + rig: _ReuseRig, +) -> None: + """D79's literal promise, un-conflated (review): a re-summarization with + UNCHANGED content — a summary-seat swap minting a new generation — drives + zero new Selection calls, zero new ContextPrefix calls, and leaves every + chunk's extraction_input_hash byte-identical.""" + first = rig.observe(content=_paragraphs(edited=False)) + rig.drain() + base_selection = rig.selection_calls() + base_prefix = rig.prefix_calls() + with rig.engine.connect() as connection: + representation = connection.execute( + text( + "SELECT representation_id, current_structure_generation_id" + " FROM document_representations WHERE version_id = :version_id" + ), + {"version_id": first.version_id}, + ).one() + hashes_before = connection.execute( + text( + "SELECT ordinal, extraction_input_hash FROM chunks" + " WHERE version_id = :version_id ORDER BY ordinal" + ), + {"version_id": first.version_id}, + ).all() + + StructureHandler( + catalog=rig.document_catalog, + artifact_store=rig.artifact_store, + model_provider=rig.provider, + summary_settings=SummarySettings(model="summary/model-swapped"), + ).handle( + work=ClaimedWork( + processing_id=uuid4(), + deployment_id=_DEPLOYMENT_ID, + target_kind=ProcessingTarget.DOCUMENT_VERSION, + target_id=first.version_id, + stage=PipelineStage.STRUCTURE, + component_version=E0_STRUCTURE_VERSION, + content_hash=first.content_hash, + lane=ProcessingLane.STEADY, + attempt=1, + payload={ + "version_id": str(first.version_id), + "representation_id": str(representation.representation_id), + }, + ), + meter=NoopCostMeter(), + ) + rig.drain() + + with rig.engine.connect() as connection: + moved = connection.execute( + text( + "SELECT current_structure_generation_id" + " FROM document_representations WHERE version_id = :version_id" + ), + {"version_id": first.version_id}, + ).scalar_one() + hashes_after = connection.execute( + text( + "SELECT ordinal, extraction_input_hash FROM chunks" + " WHERE version_id = :version_id ORDER BY ordinal" + ), + {"version_id": first.version_id}, + ).all() + assert moved != representation.current_structure_generation_id + assert hashes_after == hashes_before + assert rig.selection_calls() == base_selection + assert rig.prefix_calls() == base_prefix