rememberstack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. rememberstack/__init__.py +9 -0
  2. rememberstack/adapters/__init__.py +42 -0
  3. rememberstack/adapters/codex_writer.py +221 -0
  4. rememberstack/adapters/markitdown_converter.py +42 -0
  5. rememberstack/adapters/openrouter.py +136 -0
  6. rememberstack/adapters/selfhost/__init__.py +54 -0
  7. rememberstack/adapters/selfhost/forget.py +66 -0
  8. rememberstack/adapters/selfhost/git.py +374 -0
  9. rememberstack/adapters/selfhost/lance.py +328 -0
  10. rememberstack/adapters/selfhost/minio.py +279 -0
  11. rememberstack/adapters/selfhost/mounts.py +249 -0
  12. rememberstack/adapters/selfhost/object_store.py +130 -0
  13. rememberstack/adapters/selfhost/projection.py +80 -0
  14. rememberstack/adapters/selfhost/queue.py +137 -0
  15. rememberstack/adapters/selfhost/telemetry.py +45 -0
  16. rememberstack/adapters/selfhost/watcher.py +70 -0
  17. rememberstack/adapters/testing/__init__.py +15 -0
  18. rememberstack/adapters/testing/cost_meter.py +13 -0
  19. rememberstack/adapters/testing/model_provider.py +83 -0
  20. rememberstack/adapters/testing/queue.py +43 -0
  21. rememberstack/adapters/testing/telemetry.py +22 -0
  22. rememberstack/client.py +19 -0
  23. rememberstack/core/__init__.py +127 -0
  24. rememberstack/core/blockizer.py +189 -0
  25. rememberstack/core/chunker.py +216 -0
  26. rememberstack/core/consumption_skill.py +275 -0
  27. rememberstack/core/conversion.py +76 -0
  28. rememberstack/core/core_manifest.py +598 -0
  29. rememberstack/core/extension_packs.py +124 -0
  30. rememberstack/core/forget.py +17 -0
  31. rememberstack/core/knowledge_authored.py +276 -0
  32. rememberstack/core/knowledge_compile.py +215 -0
  33. rememberstack/core/knowledge_fact_sheet.py +210 -0
  34. rememberstack/core/knowledge_hashing.py +68 -0
  35. rememberstack/core/knowledge_planner.py +64 -0
  36. rememberstack/core/knowledge_writer.py +175 -0
  37. rememberstack/core/ranking.py +200 -0
  38. rememberstack/core/recipe_linter.py +149 -0
  39. rememberstack/core/section_snap.py +209 -0
  40. rememberstack/core/storage_routing.py +27 -0
  41. rememberstack/eval/__init__.py +53 -0
  42. rememberstack/eval/consumption.py +141 -0
  43. rememberstack/eval/contradiction.py +184 -0
  44. rememberstack/eval/harness.py +136 -0
  45. rememberstack/eval/lifecycle.py +400 -0
  46. rememberstack/eval/operational_scale.py +49 -0
  47. rememberstack/eval/resolution.py +255 -0
  48. rememberstack/eval/retrieval_spikes.py +50 -0
  49. rememberstack/eval/skeleton.py +231 -0
  50. rememberstack/llm/__init__.py +1 -0
  51. rememberstack/model/__init__.py +589 -0
  52. rememberstack/model/adjudication.py +100 -0
  53. rememberstack/model/auth.py +27 -0
  54. rememberstack/model/blocks.py +30 -0
  55. rememberstack/model/chunks.py +190 -0
  56. rememberstack/model/claims.py +162 -0
  57. rememberstack/model/client.py +98 -0
  58. rememberstack/model/clustering.py +54 -0
  59. rememberstack/model/component_version.py +124 -0
  60. rememberstack/model/consumption.py +88 -0
  61. rememberstack/model/conversion.py +31 -0
  62. rememberstack/model/deployment.py +53 -0
  63. rememberstack/model/documents.py +168 -0
  64. rememberstack/model/envelope.py +513 -0
  65. rememberstack/model/evaluation.py +72 -0
  66. rememberstack/model/forget.py +143 -0
  67. rememberstack/model/git.py +13 -0
  68. rememberstack/model/knowledge.py +840 -0
  69. rememberstack/model/knowledge_authored.py +325 -0
  70. rememberstack/model/knowledge_planner.py +431 -0
  71. rememberstack/model/lifecycle.py +42 -0
  72. rememberstack/model/model_provider.py +78 -0
  73. rememberstack/model/mounts.py +24 -0
  74. rememberstack/model/object_store.py +21 -0
  75. rememberstack/model/operational_scale.py +59 -0
  76. rememberstack/model/operations.py +153 -0
  77. rememberstack/model/processing.py +228 -0
  78. rememberstack/model/queue.py +73 -0
  79. rememberstack/model/recipes.py +83 -0
  80. rememberstack/model/relations.py +79 -0
  81. rememberstack/model/resolution.py +83 -0
  82. rememberstack/model/retrieval_spikes.py +62 -0
  83. rememberstack/model/sections.py +120 -0
  84. rememberstack/model/telemetry.py +30 -0
  85. rememberstack/ports/__init__.py +29 -0
  86. rememberstack/ports/auth.py +16 -0
  87. rememberstack/ports/connector.py +23 -0
  88. rememberstack/ports/cost_meter.py +17 -0
  89. rememberstack/ports/forget.py +20 -0
  90. rememberstack/ports/git.py +20 -0
  91. rememberstack/ports/model_provider.py +28 -0
  92. rememberstack/ports/mounts.py +16 -0
  93. rememberstack/ports/object_store.py +27 -0
  94. rememberstack/ports/p1_index.py +92 -0
  95. rememberstack/ports/purge.py +93 -0
  96. rememberstack/ports/queue.py +23 -0
  97. rememberstack/ports/telemetry.py +21 -0
  98. rememberstack/profiles/__init__.py +22 -0
  99. rememberstack/profiles/selfhost.py +324 -0
  100. rememberstack/profiles/selfhost_forget.py +158 -0
  101. rememberstack/profiles/selfhost_operations.py +95 -0
  102. rememberstack/py.typed +1 -0
  103. rememberstack/spine/__init__.py +93 -0
  104. rememberstack/spine/admission.py +26 -0
  105. rememberstack/spine/backfill.py +168 -0
  106. rememberstack/spine/catalog_contract.py +742 -0
  107. rememberstack/spine/chunk_catalog.py +237 -0
  108. rememberstack/spine/claim_catalog.py +298 -0
  109. rememberstack/spine/clustering.py +740 -0
  110. rememberstack/spine/component_versions.py +208 -0
  111. rememberstack/spine/consumption.py +81 -0
  112. rememberstack/spine/deployment_bootstrap.py +445 -0
  113. rememberstack/spine/document_catalog.py +621 -0
  114. rememberstack/spine/entity_registry.py +205 -0
  115. rememberstack/spine/extension_packs.py +220 -0
  116. rememberstack/spine/fact_catalog.py +571 -0
  117. rememberstack/spine/forget.py +1753 -0
  118. rememberstack/spine/knowledge.py +5467 -0
  119. rememberstack/spine/lifecycle.py +1071 -0
  120. rememberstack/spine/migrations/__init__.py +1 -0
  121. rememberstack/spine/migrations/_helpers.py +153 -0
  122. rememberstack/spine/migrations/env.py +58 -0
  123. rememberstack/spine/migrations/script.py.mako +27 -0
  124. rememberstack/spine/migrations/versions/__init__.py +1 -0
  125. rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
  126. rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
  127. rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
  128. rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
  129. rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
  130. rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
  131. rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
  132. rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
  133. rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
  134. rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
  135. rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
  136. rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
  137. rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
  138. rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
  139. rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
  140. rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
  141. rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
  142. rememberstack/spine/observation_adjudication.py +778 -0
  143. rememberstack/spine/operations.py +298 -0
  144. rememberstack/spine/projection.py +662 -0
  145. rememberstack/spine/recipes.py +276 -0
  146. rememberstack/spine/resolver.py +763 -0
  147. rememberstack/spine/review.py +650 -0
  148. rememberstack/spine/settings.py +22 -0
  149. rememberstack/spine/supersession.py +510 -0
  150. rememberstack/spine/sync.py +128 -0
  151. rememberstack/spine/work_ledger.py +816 -0
  152. rememberstack/surfaces/__init__.py +110 -0
  153. rememberstack/surfaces/cli.py +447 -0
  154. rememberstack/surfaces/consumption_skill.py +87 -0
  155. rememberstack/surfaces/graph_queries.py +698 -0
  156. rememberstack/surfaces/http_api.py +377 -0
  157. rememberstack/surfaces/mcp.py +67 -0
  158. rememberstack/surfaces/query_engine.py +1591 -0
  159. rememberstack/surfaces/recipe_executor.py +185 -0
  160. rememberstack/surfaces/recipe_surface.py +219 -0
  161. rememberstack/surfaces/remote_mcp.py +133 -0
  162. rememberstack/surfaces/sdk.py +324 -0
  163. rememberstack/workers/__init__.py +155 -0
  164. rememberstack/workers/base.py +312 -0
  165. rememberstack/workers/e0.py +577 -0
  166. rememberstack/workers/e1.py +425 -0
  167. rememberstack/workers/e2.py +525 -0
  168. rememberstack/workers/e3.py +434 -0
  169. rememberstack/workers/forget.py +299 -0
  170. rememberstack/workers/knowledge_authored.py +146 -0
  171. rememberstack/workers/knowledge_driver.py +735 -0
  172. rememberstack/workers/knowledge_fact_sheet.py +123 -0
  173. rememberstack/workers/knowledge_planner.py +325 -0
  174. rememberstack/workers/knowledge_writer.py +393 -0
  175. rememberstack/workers/operations.py +42 -0
  176. rememberstack/workers/p1.py +234 -0
  177. rememberstack/workers/p2.py +513 -0
  178. rememberstack/workers/p2_analytics.py +276 -0
  179. rememberstack/workers/p3.py +673 -0
  180. rememberstack/workers/reconcile.py +485 -0
  181. rememberstack/workers/sync.py +168 -0
  182. rememberstack-0.1.0.dist-info/METADATA +213 -0
  183. rememberstack-0.1.0.dist-info/RECORD +186 -0
  184. rememberstack-0.1.0.dist-info/WHEEL +4 -0
  185. rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
  186. rememberstack-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,525 @@
1
+ """The E2 extractor (D31-D35): two-call Claimify over the context bundle.
2
+
3
+ Per chunk: a Selection call judges every proposition (keep / keep-flagged /
4
+ drop — drops and flags go to the D33 ledger), then one fused call
5
+ decontextualizes, decomposes, and self-grounds the keeps. The deterministic
6
+ grounding gate (D32 layers 1-2) accepts a claim only if its verbatim source
7
+ span anchors inside the chunk and every added substring exists in the bundle
8
+ element it was attributed to — a check the model cannot talk its way past.
9
+ """
10
+
11
+ import logging
12
+ from typing import Final
13
+ from uuid import UUID
14
+ from uuid import uuid4
15
+
16
+ from pydantic import Field
17
+ from pydantic_settings import BaseSettings
18
+ from pydantic_settings import SettingsConfigDict
19
+
20
+ from rememberstack.model import CandidateClaim
21
+ from rememberstack.model import ChunkForEmbedding
22
+ from rememberstack.model import ChunkSource
23
+ from rememberstack.model import ClaimedWork
24
+ from rememberstack.model import ClaimifyResponse
25
+ from rememberstack.model import ClaimRecord
26
+ from rememberstack.model import DecisionRecord
27
+ from rememberstack.model import DecisionType
28
+ from rememberstack.model import EnqueueWork
29
+ from rememberstack.model import ModelRequest
30
+ from rememberstack.model import NonRetryableHandlerError
31
+ from rememberstack.model import ObjectKey
32
+ from rememberstack.model import PipelineStage
33
+ from rememberstack.model import SelectionCandidate
34
+ from rememberstack.model import SelectionResponse
35
+ from rememberstack.model import SelectionVerdict
36
+ from rememberstack.ports.cost_meter import CostMeterPort
37
+ from rememberstack.ports.model_provider import ModelProviderPort
38
+ from rememberstack.ports.object_store import ObjectStorePort
39
+ from rememberstack.spine.chunk_catalog import ChunkCatalog
40
+ from rememberstack.spine.claim_catalog import ClaimCatalog
41
+ from rememberstack.workers.base import HandlerOutcome
42
+ from rememberstack.workers.e1 import E2_EXTRACTOR_VERSION
43
+ from rememberstack.workers.e3 import E3_NORMALIZER_VERSION
44
+
45
+ _logger = logging.getLogger(__name__)
46
+
47
+ _SELECTION_PROMPT: Final = """You are the Selection stage of a claim extractor.
48
+ Judge every proposition in the TARGET CHUNK: keep statements making a specific,
49
+ verifiable proposition (state, event, decision, quantity, policy, relationship).
50
+ Drop unattributed opinions, advice, hypotheticals, generic truisms, questions,
51
+ section intros/conclusions, and "we don't know" statements. An ATTRIBUTED
52
+ stance ("X said/believes/opposes Y") is a KEEP. Never-drop classes even if
53
+ phrased opinionatedly: quantities, dates, named-entity+predicate,
54
+ change-of-state. When unsure, prefer keep_flagged over drop. Each candidate's
55
+ source_span must be a verbatim substring of the target chunk.
56
+
57
+ {bundle}"""
58
+
59
+ _CLAIMIFY_PROMPT: Final = """You are the decontextualize+decompose+ground stage
60
+ of a claim extractor. For each KEPT proposition below: resolve every pronoun,
61
+ partial name, acronym, and relative date USING ONLY THE BUNDLE (never outside
62
+ knowledge), adding the minimum context needed; split into the simplest
63
+ standalone claims, preserving attribution ("X said Y" stays attributed); if a
64
+ careful reader could not pick one interpretation from the bundle, omit the
65
+ candidate. For each claim return: claim_text (standalone), source_span (the
66
+ verbatim chunk substring it derives from), added_context (every substring you
67
+ ADDED, each tagged header|neighbour|prefix with the exact text as it appears
68
+ in that bundle element), entailment_self_verdict (does chunk+bundle entail the
69
+ claim), is_attributed.
70
+
71
+ KEPT PROPOSITIONS:
72
+ {keeps}
73
+
74
+ {bundle}"""
75
+
76
+
77
+ class E2Settings(BaseSettings):
78
+ """The E2 model binding (D70): interchangeable per-deployment port config."""
79
+
80
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_E2_")
81
+
82
+ extract_model: str = Field(default="openai/gpt-5.6-luna")
83
+
84
+
85
+ class ExtractClaimsHandler:
86
+ """The extract stage: every chunk of one representation through Claimify."""
87
+
88
+ def __init__(
89
+ self,
90
+ *,
91
+ catalog: ClaimCatalog,
92
+ chunk_catalog: ChunkCatalog,
93
+ artifact_store: ObjectStorePort,
94
+ model_provider: ModelProviderPort,
95
+ settings: E2Settings,
96
+ chunker_version: str,
97
+ ) -> None:
98
+ """Bind the handler to its catalogs, store, provider, and generation."""
99
+ self._catalog = catalog
100
+ self._chunk_catalog = chunk_catalog
101
+ self._artifact_store = artifact_store
102
+ self._model_provider = model_provider
103
+ self._settings = settings
104
+ self._chunker_version = chunker_version
105
+
106
+ def handle(self, *, work: ClaimedWork, meter: CostMeterPort) -> HandlerOutcome:
107
+ """Extract claims for one document version, chunk by chunk (D12 replay)."""
108
+ source = self._chunk_catalog.chunk_source(
109
+ representation_id=_payload_uuid(work=work, field="representation_id")
110
+ )
111
+ chunks = self._chunk_catalog.chunks_for_embedding(
112
+ representation_id=source.representation_id,
113
+ chunker_version=self._chunker_version,
114
+ )
115
+ if not chunks:
116
+ return HandlerOutcome()
117
+ document_md = self._artifact_store.read_bytes(
118
+ key=ObjectKey(source.markdown_uri)
119
+ ).decode("utf-8")
120
+ for index, chunk in enumerate(chunks):
121
+ if self._catalog.chunk_already_extracted(
122
+ chunk_id=chunk.chunk_id, extractor_version=E2_EXTRACTOR_VERSION
123
+ ):
124
+ continue # replay: stored claims + decisions are the output (D7)
125
+ if self._reuse_prior_extraction(source=source, chunk=chunk):
126
+ continue # D56: the prior version's claims are re-attached
127
+ self._extract_chunk(
128
+ source=source,
129
+ chunks=chunks,
130
+ index=index,
131
+ document_md=document_md,
132
+ meter=meter,
133
+ )
134
+ return HandlerOutcome(
135
+ follow_up=(
136
+ EnqueueWork(
137
+ deployment_id=work.deployment_id,
138
+ target_kind=work.target_kind,
139
+ target_id=work.target_id,
140
+ stage=PipelineStage.NORMALIZE_RELATIONS,
141
+ component_version=E3_NORMALIZER_VERSION,
142
+ content_hash=work.content_hash,
143
+ lane=work.lane,
144
+ payload={
145
+ "version_id": str(source.version_id),
146
+ "representation_id": str(source.representation_id),
147
+ },
148
+ ),
149
+ )
150
+ )
151
+
152
+ def _reuse_prior_extraction(
153
+ self, *, source: ChunkSource, chunk: ChunkForEmbedding
154
+ ) -> bool:
155
+ """The D56 chunk-grain reuse rung: re-attach instead of re-extract.
156
+
157
+ An unchanged ``extraction_input_hash`` within the lineage means some
158
+ already-extracted chunk read the exact same stable inputs — its
159
+ claims are re-attached to this version's chunk row (occurrence
160
+ links, F4) and no model is called. A prior extraction that found
161
+ nothing claim-worthy carries its terminal marker forward the same
162
+ way. Returns False when the lineage holds no extracted match.
163
+ """
164
+ prior = self._catalog.prior_extracted_chunk(
165
+ deployment_id=source.deployment_id,
166
+ doc_id=source.doc_id,
167
+ version_id=chunk.version_id,
168
+ extraction_input_hash=chunk.extraction_input_hash,
169
+ )
170
+ if prior is None:
171
+ return False
172
+ attached = self._catalog.attach_reused_claims(
173
+ deployment_id=source.deployment_id,
174
+ chunk_id=chunk.chunk_id,
175
+ prior_chunk_id=prior,
176
+ )
177
+ if attached == 0:
178
+ # the prior chunk carries no claims — a terminal no_info: carry
179
+ # the marker forward so replay stays closed for this chunk too
180
+ self._catalog.record_extraction(
181
+ claims=(),
182
+ decisions=(_empty_extraction_marker(source=source, chunk=chunk),),
183
+ )
184
+ return True
185
+
186
+ def _extract_chunk(
187
+ self,
188
+ *,
189
+ source: ChunkSource,
190
+ chunks: tuple[ChunkForEmbedding, ...],
191
+ index: int,
192
+ document_md: str,
193
+ meter: CostMeterPort,
194
+ ) -> None:
195
+ """Run the two Claimify calls for one chunk and land the results."""
196
+ chunk = chunks[index]
197
+ bundle = _bundle_text(
198
+ source=source, chunks=chunks, index=index, document_md=document_md
199
+ )
200
+ selection_call = self._model_provider.generate(
201
+ request=ModelRequest(
202
+ model=self._settings.extract_model,
203
+ prompt=_SELECTION_PROMPT.format(bundle=bundle),
204
+ ),
205
+ response_type=SelectionResponse,
206
+ )
207
+ meter.record(
208
+ call_key=f"selection:{chunk.chunk_id}",
209
+ tier="selection",
210
+ usage=selection_call.usage,
211
+ )
212
+ selection = selection_call.output
213
+ decisions = list(
214
+ _selection_decisions(source=source, chunk=chunk, selection=selection)
215
+ )
216
+ keeps = tuple(
217
+ candidate
218
+ for candidate in selection.candidates
219
+ if candidate.verdict is not SelectionVerdict.DROP
220
+ )
221
+ claims: list[ClaimRecord] = []
222
+ if keeps:
223
+ kept_ranges = _kept_ranges(
224
+ keeps=keeps, chunk=chunk, document_md=document_md
225
+ )
226
+ flagged_spans = {
227
+ candidate.source_span
228
+ for candidate in keeps
229
+ if candidate.verdict is SelectionVerdict.KEEP_FLAGGED
230
+ }
231
+ response_call = self._model_provider.generate(
232
+ request=ModelRequest(
233
+ model=self._settings.extract_model,
234
+ prompt=_CLAIMIFY_PROMPT.format(
235
+ keeps="\n".join(f"- {keep.source_span}" for keep in keeps),
236
+ bundle=bundle,
237
+ ),
238
+ ),
239
+ response_type=ClaimifyResponse,
240
+ )
241
+ meter.record(
242
+ call_key=f"decontextualize:{chunk.chunk_id}",
243
+ tier="decontextualize",
244
+ usage=response_call.usage,
245
+ )
246
+ response = response_call.output
247
+ for candidate in response.claims:
248
+ record = _grounded_claim(
249
+ candidate=candidate,
250
+ source=source,
251
+ chunk=chunk,
252
+ chunks=chunks,
253
+ index=index,
254
+ document_md=document_md,
255
+ flagged_spans=flagged_spans,
256
+ kept_ranges=kept_ranges,
257
+ )
258
+ if record is None:
259
+ _logger.warning(
260
+ "grounding gate rejected candidate %r on chunk %s",
261
+ candidate.claim_text,
262
+ chunk.chunk_id,
263
+ )
264
+ continue
265
+ claims.append(record)
266
+ if record.added_context:
267
+ decisions.append(_edit_decision(source=source, record=record))
268
+ decisions = _link_flagged_decisions(decisions=decisions, claims=claims)
269
+ if not claims and not decisions:
270
+ # terminal marker (D7): an extraction that found nothing claim-worthy
271
+ # is DONE — without it, replay would re-call the model.
272
+ decisions = [_empty_extraction_marker(source=source, chunk=chunk)]
273
+ self._catalog.record_extraction(
274
+ claims=tuple(claims), decisions=tuple(decisions)
275
+ )
276
+
277
+
278
+ def _grounded_claim(
279
+ *,
280
+ candidate: CandidateClaim,
281
+ source: ChunkSource,
282
+ chunk: ChunkForEmbedding,
283
+ chunks: tuple[ChunkForEmbedding, ...],
284
+ index: int,
285
+ document_md: str,
286
+ flagged_spans: set[str],
287
+ kept_ranges: tuple[tuple[int, int], ...],
288
+ ) -> ClaimRecord | None:
289
+ """Apply the deterministic grounding gate (D32 layers 1-2).
290
+
291
+ Layer 1 (anchor): the source span must be a real in-bounds slice of the
292
+ target chunk, and must overlap a span Selection kept — the fused call can
293
+ never resurrect a dropped proposition. Layer 2 (window membership): every
294
+ added substring must verbatim-exist in the bundle element it was
295
+ attributed to. A failed check returns None — the candidate never becomes
296
+ a claims row. Semantic invention behind a real span is layer-3/4
297
+ territory: the in-call self-verdict is stored advisory, and the sampled
298
+ independent audit owns the honest measurement.
299
+ """
300
+ anchor_at = document_md.find(
301
+ candidate.source_span, chunk.char_start, chunk.char_end
302
+ )
303
+ if anchor_at < 0:
304
+ return None
305
+ anchor_end = anchor_at + len(candidate.source_span)
306
+ if not any(
307
+ anchor_at < kept_end and kept_start < anchor_end
308
+ for kept_start, kept_end in kept_ranges
309
+ ):
310
+ return None # Selection is enforced, not advisory
311
+ for added in candidate.added_context:
312
+ element = _bundle_element(
313
+ kind=added.source_kind,
314
+ source=source,
315
+ chunks=chunks,
316
+ index=index,
317
+ document_md=document_md,
318
+ )
319
+ if element is None or added.text not in element:
320
+ return None
321
+ return ClaimRecord(
322
+ claim_id=uuid4(),
323
+ deployment_id=source.deployment_id,
324
+ doc_id=source.doc_id,
325
+ chunk_id=chunk.chunk_id,
326
+ section_id=None,
327
+ claim_text=candidate.claim_text,
328
+ source_span=candidate.source_span,
329
+ char_start=anchor_at,
330
+ char_end=anchor_at + len(candidate.source_span),
331
+ added_context=candidate.added_context,
332
+ is_attributed=candidate.is_attributed,
333
+ entailment_self_verdict=candidate.entailment_self_verdict,
334
+ kept_flagged=candidate.source_span in flagged_spans,
335
+ extractor_version=E2_EXTRACTOR_VERSION,
336
+ )
337
+
338
+
339
+ def _bundle_text(
340
+ *,
341
+ source: ChunkSource,
342
+ chunks: tuple[ChunkForEmbedding, ...],
343
+ index: int,
344
+ document_md: str,
345
+ ) -> str:
346
+ """Assemble the D31 context bundle for one target chunk."""
347
+ chunk = chunks[index]
348
+ return (
349
+ f"DOCUMENT HEADER: {_header_text(source=source)}\n"
350
+ f"SECTION: path {chunk.section_path}, role {chunk.section_role}\n"
351
+ f"CONTEXT PREFIX: {chunk.context_prefix or '(none)'}\n"
352
+ f"PREVIOUS CHUNK:\n{_neighbour_text(chunks=chunks, index=index - 1, document_md=document_md, section_path=chunk.section_path)}\n"
353
+ f"NEXT CHUNK:\n{_neighbour_text(chunks=chunks, index=index + 1, document_md=document_md, section_path=chunk.section_path)}\n"
354
+ f"TARGET CHUNK:\n{document_md[chunk.char_start : chunk.char_end]}"
355
+ )
356
+
357
+
358
+ def _bundle_element(
359
+ *,
360
+ kind: str,
361
+ source: ChunkSource,
362
+ chunks: tuple[ChunkForEmbedding, ...],
363
+ index: int,
364
+ document_md: str,
365
+ ) -> str | None:
366
+ """The bundle element an added substring claims to come from, or None."""
367
+ if kind == "header":
368
+ return _header_text(source=source)
369
+ if kind == "prefix":
370
+ return chunks[index].context_prefix
371
+ if kind == "neighbour":
372
+ return "\n".join(
373
+ _neighbour_text(
374
+ chunks=chunks,
375
+ index=neighbour,
376
+ document_md=document_md,
377
+ section_path=chunks[index].section_path,
378
+ )
379
+ for neighbour in (index - 1, index + 1)
380
+ )
381
+ return None
382
+
383
+
384
+ def _header_text(*, source: ChunkSource) -> str:
385
+ """The deterministic document header shared by every chunk's bundle."""
386
+ modified = source.source_modified_at or source.published_at
387
+ return (
388
+ f"title {source.title or 'untitled'}; source {source.source_kind};"
389
+ f" date {modified.date().isoformat() if modified else 'unknown'};"
390
+ f" language {source.language or 'unknown'}"
391
+ )
392
+
393
+
394
+ def _neighbour_text(
395
+ *,
396
+ chunks: tuple[ChunkForEmbedding, ...],
397
+ index: int,
398
+ document_md: str,
399
+ section_path: str,
400
+ ) -> str:
401
+ """A same-section neighbour's verbatim text, or a placeholder.
402
+
403
+ The D31 bundle rule is same-scope only: an ordinal-adjacent chunk from a
404
+ different section is not a neighbour and can never ground an addition.
405
+ """
406
+ if 0 <= index < len(chunks) and chunks[index].section_path == section_path:
407
+ neighbour = chunks[index]
408
+ return document_md[neighbour.char_start : neighbour.char_end]
409
+ return "(none)"
410
+
411
+
412
+ def _kept_ranges(
413
+ *, keeps: tuple[SelectionCandidate, ...], chunk: ChunkForEmbedding, document_md: str
414
+ ) -> tuple[tuple[int, int], ...]:
415
+ """Absolute char ranges of the kept Selection spans inside the chunk."""
416
+ ranges: list[tuple[int, int]] = []
417
+ for keep in keeps:
418
+ found = document_md.find(keep.source_span, chunk.char_start, chunk.char_end)
419
+ if found >= 0:
420
+ ranges.append((found, found + len(keep.source_span)))
421
+ return tuple(ranges)
422
+
423
+
424
+ def _link_flagged_decisions(
425
+ *, decisions: list[DecisionRecord], claims: list[ClaimRecord]
426
+ ) -> list[DecisionRecord]:
427
+ """Pair each keep-flagged ledger row with its grounded claim (schema §8).
428
+
429
+ The invariant: a kept_flagged claim is the pair (claims row) + (a
430
+ selection_keep_flagged decision naming it). A flag whose span grounded no
431
+ claim keeps claim_id NULL — the flag stands, nothing to pair.
432
+ """
433
+ linked: list[DecisionRecord] = []
434
+ for decision in decisions:
435
+ if decision.decision_type is DecisionType.SELECTION_KEEP_FLAGGED:
436
+ match = next(
437
+ (
438
+ claim
439
+ for claim in claims
440
+ if claim.kept_flagged and claim.source_span == decision.source_span
441
+ ),
442
+ None,
443
+ )
444
+ if match is not None:
445
+ decision = decision.model_copy(update={"claim_id": match.claim_id})
446
+ linked.append(decision)
447
+ return linked
448
+
449
+
450
+ def _empty_extraction_marker(
451
+ *, source: ChunkSource, chunk: ChunkForEmbedding
452
+ ) -> DecisionRecord:
453
+ """The terminal no_info row for a chunk whose extraction found nothing."""
454
+ return DecisionRecord(
455
+ decision_id=uuid4(),
456
+ deployment_id=source.deployment_id,
457
+ doc_id=source.doc_id,
458
+ chunk_id=chunk.chunk_id,
459
+ claim_id=None,
460
+ decision_type=DecisionType.SELECTION_DROP,
461
+ source_span=None,
462
+ reason="no_info",
463
+ edit_detail=None,
464
+ protected_class=None,
465
+ extractor_version=E2_EXTRACTOR_VERSION,
466
+ )
467
+
468
+
469
+ def _selection_decisions(
470
+ *, source: ChunkSource, chunk: ChunkForEmbedding, selection: SelectionResponse
471
+ ) -> tuple[DecisionRecord, ...]:
472
+ """The D33 ledger rows for one Selection call: drops and keep-flags."""
473
+ return tuple(
474
+ DecisionRecord(
475
+ decision_id=uuid4(),
476
+ deployment_id=source.deployment_id,
477
+ doc_id=source.doc_id,
478
+ chunk_id=chunk.chunk_id,
479
+ claim_id=None,
480
+ decision_type=DecisionType.SELECTION_DROP
481
+ if candidate.verdict is SelectionVerdict.DROP
482
+ else DecisionType.SELECTION_KEEP_FLAGGED,
483
+ source_span=candidate.source_span,
484
+ reason=candidate.drop_reason
485
+ if candidate.verdict is SelectionVerdict.DROP
486
+ else None,
487
+ edit_detail=None,
488
+ protected_class=candidate.protected_class,
489
+ extractor_version=E2_EXTRACTOR_VERSION,
490
+ )
491
+ for candidate in selection.candidates
492
+ if candidate.verdict is not SelectionVerdict.KEEP
493
+ )
494
+
495
+
496
+ def _edit_decision(*, source: ChunkSource, record: ClaimRecord) -> DecisionRecord:
497
+ """The D33 decontextualization-edit row for one accepted claim."""
498
+ return DecisionRecord(
499
+ decision_id=uuid4(),
500
+ deployment_id=source.deployment_id,
501
+ doc_id=source.doc_id,
502
+ chunk_id=record.chunk_id,
503
+ claim_id=record.claim_id,
504
+ decision_type=DecisionType.DECONTEXT_EDIT,
505
+ source_span=record.source_span,
506
+ reason=None,
507
+ edit_detail={
508
+ "added": [
509
+ {"text": added.text, "source_kind": added.source_kind}
510
+ for added in record.added_context
511
+ ]
512
+ },
513
+ protected_class=None,
514
+ extractor_version=E2_EXTRACTOR_VERSION,
515
+ )
516
+
517
+
518
+ def _payload_uuid(*, work: ClaimedWork, field: str) -> UUID:
519
+ """Read a required UUID from the claimed payload; absence is non-retryable."""
520
+ value = (work.payload or {}).get(field)
521
+ if not isinstance(value, str):
522
+ raise NonRetryableHandlerError(
523
+ f"stage {work.stage} work {work.processing_id} carries no {field!r} payload"
524
+ )
525
+ return UUID(value)