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,425 @@
1
+ """The E1 chain (D58): anchor-stabilized chunking, context prefixes, embeddings.
2
+
3
+ The chunk stage packs the representation's block grid into section-bounded
4
+ chunks and records their reuse keys; the embed stage writes each chunk's
5
+ context prefix (the D63 conventional-mode branch), embeds prefix + text as one
6
+ per-document batch, and lands the vectors in the P1 chunk index.
7
+ """
8
+
9
+ from datetime import datetime
10
+ import json
11
+ from typing import Final
12
+ from uuid import UUID
13
+ from uuid import uuid4
14
+
15
+ from pydantic import Field
16
+ from pydantic_settings import BaseSettings
17
+ from pydantic_settings import SettingsConfigDict
18
+
19
+ from rememberstack.core import CHUNKER_VERSION
20
+ from rememberstack.core import chunker_version
21
+ from rememberstack.core import ChunkerParams
22
+ from rememberstack.core import extraction_input_hash
23
+ from rememberstack.core import pack_blocks
24
+ from rememberstack.model import Block
25
+ from rememberstack.model import CarryForwardSource
26
+ from rememberstack.model import ChunkForEmbedding
27
+ from rememberstack.model import ChunkRecord
28
+ from rememberstack.model import ChunkSource
29
+ from rememberstack.model import ClaimedWork
30
+ from rememberstack.model import ContextPrefix
31
+ from rememberstack.model import EmbeddingRequest
32
+ from rememberstack.model import EmbeddingUpdate
33
+ from rememberstack.model import EnqueueWork
34
+ from rememberstack.model import ModelRequest
35
+ from rememberstack.model import NonRetryableHandlerError
36
+ from rememberstack.model import ObjectKey
37
+ from rememberstack.model import P1ChunkRow
38
+ from rememberstack.model import PackedChunk
39
+ from rememberstack.model import PipelineStage
40
+ from rememberstack.ports.cost_meter import CostMeterPort
41
+ from rememberstack.ports.model_provider import ModelProviderPort
42
+ from rememberstack.ports.object_store import ObjectStorePort
43
+ from rememberstack.ports.p1_index import ChunkIndexPort
44
+ from rememberstack.spine.chunk_catalog import ChunkCatalog
45
+ from rememberstack.workers.base import HandlerOutcome
46
+
47
+ E1_CHUNK_VERSION: Final = CHUNKER_VERSION
48
+ """The chunk stage's component version IS the chunker version (D12/D58)."""
49
+
50
+ E1_EMBED_VERSION: Final = "e1-embed-2026.07"
51
+ """The embed stage's component version (model identity rides settings/stamps)."""
52
+
53
+ E1_PREFIXER_VERSION: Final = "e1-prefix-2026.07"
54
+ """The context-prefix call's prompt generation (D58; conventional mode, D63)."""
55
+
56
+ E2_EXTRACTOR_VERSION: Final = "e2-extract-2026.07"
57
+ """The extractor generation baked into extraction_input_hash (D56); the E2
58
+ stage (WP-1.3) binds its handler to this same constant."""
59
+
60
+ _PREFIX_PROMPT_TEMPLATE: Final = (
61
+ "In one sentence, state where this passage sits in the document — "
62
+ "document title, section, and what surrounds it. Passage from "
63
+ "{title!r}, section path {section_path}, chunk {ordinal}:\n\n{head}"
64
+ )
65
+
66
+
67
+ class E1Settings(BaseSettings):
68
+ """The E1 model bindings: per-deployment port configuration (D61/D63)."""
69
+
70
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_E1_")
71
+
72
+ embedding_model: str = Field(default="qwen/qwen3-embedding-8b")
73
+ prefix_model: str = Field(default="openai/gpt-5.6-luna")
74
+
75
+
76
+ class ChunkHandler:
77
+ """The chunk stage: block grid → section-bounded, anchor-stabilized runs."""
78
+
79
+ def __init__(
80
+ self,
81
+ *,
82
+ catalog: ChunkCatalog,
83
+ artifact_store: ObjectStorePort,
84
+ params: ChunkerParams,
85
+ ) -> None:
86
+ """Bind the handler to its catalog, the artifacts bucket, and the params."""
87
+ self._catalog = catalog
88
+ self._artifact_store = artifact_store
89
+ self._params = params
90
+ self._chunker_version = chunker_version(params=params)
91
+
92
+ def handle(self, *, work: ClaimedWork, meter: CostMeterPort) -> HandlerOutcome:
93
+ """Pack one representation into chunks and chain the embed stage.
94
+
95
+ Replay before regenerate (D7): rows this chunker generation already
96
+ packed for the version are kept as-is and the stage just re-chains.
97
+ """
98
+ del meter
99
+ source = self._catalog.chunk_source(
100
+ representation_id=_payload_uuid(work=work, field="representation_id")
101
+ )
102
+ if self._catalog.existing_chunk_ids(
103
+ representation_id=source.representation_id,
104
+ chunker_version=self._chunker_version,
105
+ ):
106
+ return _embed_follow_up(work=work, source=source)
107
+ document_md = self._artifact_store.read_bytes(
108
+ key=ObjectKey(source.markdown_uri)
109
+ ).decode("utf-8")
110
+ blocks_doc = json.loads(
111
+ self._artifact_store.read_bytes(key=ObjectKey(source.blocks_uri))
112
+ )
113
+ blocks = tuple(Block.model_validate(block) for block in blocks_doc["blocks"])
114
+ packed = pack_blocks(
115
+ blocks=blocks,
116
+ sections=source.sections,
117
+ document_md=document_md,
118
+ params=self._params,
119
+ )
120
+ self._catalog.record_chunks(
121
+ records=tuple(
122
+ _chunk_record(
123
+ source=source,
124
+ packed=packed,
125
+ index=index,
126
+ chunker_version=self._chunker_version,
127
+ )
128
+ for index in range(len(packed))
129
+ )
130
+ )
131
+ return _embed_follow_up(work=work, source=source)
132
+
133
+
134
+ class EmbedChunksHandler:
135
+ """The embed stage: context prefixes + one embedding batch per document.
136
+
137
+ The conventional-mode branch binds (D63): each chunk gets a generated
138
+ "where this sits" prefix, and prefix + verbatim text embed together. The
139
+ batch never crosses a document (D58's billing and lane rule).
140
+ """
141
+
142
+ def __init__(
143
+ self,
144
+ *,
145
+ catalog: ChunkCatalog,
146
+ artifact_store: ObjectStorePort,
147
+ model_provider: ModelProviderPort,
148
+ chunk_index: ChunkIndexPort,
149
+ settings: E1Settings,
150
+ params: ChunkerParams,
151
+ ) -> None:
152
+ """Bind the handler to its catalog, stores, provider, and P1 index.
153
+
154
+ `params` names the chunker generation whose rows this stage embeds —
155
+ the same parameters the composing profile gave the chunk stage.
156
+ """
157
+ self._catalog = catalog
158
+ self._artifact_store = artifact_store
159
+ self._model_provider = model_provider
160
+ self._chunk_index = chunk_index
161
+ self._settings = settings
162
+ self._chunker_version = chunker_version(params=params)
163
+
164
+ def handle(self, *, work: ClaimedWork, meter: CostMeterPort) -> HandlerOutcome:
165
+ """Prefix, embed, and index every chunk of one document version.
166
+
167
+ The D56/A3 carry-forward runs here: an unchanged chunk (same content
168
+ hash as a prior version's chunk in this lineage) keeps that chunk's
169
+ stored prefix and copies its vector — the model is called only for
170
+ chunks the edit actually touched. LLM output is never regenerated
171
+ for unchanged regions: it is the cost being avoided, and its
172
+ non-determinism would make every derived byte drift.
173
+ """
174
+ source = self._catalog.chunk_source(
175
+ representation_id=_payload_uuid(work=work, field="representation_id")
176
+ )
177
+ chunks = self._catalog.chunks_for_embedding(
178
+ representation_id=source.representation_id,
179
+ chunker_version=self._chunker_version,
180
+ )
181
+ if not chunks:
182
+ return HandlerOutcome() # an empty document has nothing to index
183
+ document_md = self._artifact_store.read_bytes(
184
+ key=ObjectKey(source.markdown_uri)
185
+ ).decode("utf-8")
186
+ carry = self._catalog.carry_forward_sources(
187
+ deployment_id=work.deployment_id,
188
+ doc_id=source.doc_id,
189
+ version_id=source.version_id,
190
+ prefixer_version=E1_PREFIXER_VERSION,
191
+ embedding_version=self._settings.embedding_model,
192
+ )
193
+ carried_vectors = self._carried_vectors(work=work, chunks=chunks, carry=carry)
194
+ prefixes = tuple(
195
+ self._resolve_prefix(
196
+ source=source,
197
+ chunk=chunk,
198
+ document_md=document_md,
199
+ carry=carry,
200
+ meter=meter,
201
+ )
202
+ for chunk in chunks
203
+ )
204
+ texts = tuple(
205
+ f"{prefix}\n\n{document_md[chunk.char_start : chunk.char_end]}"
206
+ for prefix, chunk in zip(prefixes, chunks, strict=True)
207
+ )
208
+ fresh = tuple(
209
+ index
210
+ for index in range(len(chunks))
211
+ if chunks[index].chunk_id not in carried_vectors
212
+ )
213
+ if fresh:
214
+ response = self._model_provider.embed(
215
+ request=EmbeddingRequest(
216
+ model=self._settings.embedding_model,
217
+ texts=tuple(texts[index] for index in fresh),
218
+ )
219
+ )
220
+ meter.record(
221
+ call_key="embed_chunks", tier="embedding", usage=response.usage
222
+ )
223
+ fresh_vectors = dict(
224
+ zip(
225
+ (chunks[index].chunk_id for index in fresh),
226
+ response.vectors,
227
+ strict=True,
228
+ )
229
+ )
230
+ else:
231
+ fresh_vectors = {}
232
+ vectors = {**carried_vectors, **fresh_vectors}
233
+ self._chunk_index.upsert_chunks(
234
+ rows=tuple(
235
+ P1ChunkRow(
236
+ chunk_id=chunk.chunk_id,
237
+ deployment_id=work.deployment_id,
238
+ doc_id=chunk.doc_id,
239
+ version_id=chunk.version_id,
240
+ section_role=chunk.section_role,
241
+ text=text,
242
+ vector=vectors[chunk.chunk_id],
243
+ )
244
+ for chunk, text in zip(chunks, texts, strict=True)
245
+ )
246
+ )
247
+ self._catalog.record_embeddings(
248
+ updates=tuple(
249
+ EmbeddingUpdate(
250
+ chunk_id=chunk.chunk_id,
251
+ embedding_ref=str(chunk.chunk_id),
252
+ embedding_version=self._settings.embedding_model,
253
+ context_prefix=prefix,
254
+ prefixer_version=E1_PREFIXER_VERSION,
255
+ )
256
+ for chunk, prefix in zip(chunks, prefixes, strict=True)
257
+ )
258
+ )
259
+ return HandlerOutcome(
260
+ follow_up=(
261
+ EnqueueWork(
262
+ deployment_id=work.deployment_id,
263
+ target_kind=work.target_kind,
264
+ target_id=work.target_id,
265
+ stage=PipelineStage.EXTRACT_CLAIMS,
266
+ component_version=E2_EXTRACTOR_VERSION,
267
+ content_hash=work.content_hash,
268
+ lane=work.lane,
269
+ payload={
270
+ "version_id": str(source.version_id),
271
+ "representation_id": str(source.representation_id),
272
+ },
273
+ ),
274
+ )
275
+ )
276
+
277
+ def _carried_vectors(
278
+ self,
279
+ *,
280
+ work: ClaimedWork,
281
+ chunks: tuple[ChunkForEmbedding, ...],
282
+ carry: dict[str, CarryForwardSource],
283
+ ) -> dict[UUID, tuple[float, ...]]:
284
+ """Copy prior versions' vectors for unchanged chunks (D56).
285
+
286
+ A carried chunk whose vector is missing from the index (pruned or
287
+ never landed) simply falls back to the fresh-embed path — reuse is
288
+ an economy, never a correctness dependency.
289
+ """
290
+ wanted = {
291
+ str(carry[chunk.chunk_content_hash].chunk_id): chunk.chunk_id
292
+ for chunk in chunks
293
+ if chunk.chunk_content_hash in carry
294
+ }
295
+ if not wanted:
296
+ return {}
297
+ stored = self._chunk_index.chunk_vectors(
298
+ deployment_id=str(work.deployment_id), chunk_ids=tuple(wanted)
299
+ )
300
+ return {
301
+ wanted[prior_id]: vector
302
+ for prior_id, vector in stored.items()
303
+ if prior_id in wanted
304
+ }
305
+
306
+ def _resolve_prefix(
307
+ self,
308
+ *,
309
+ source: ChunkSource,
310
+ chunk: ChunkForEmbedding,
311
+ document_md: str,
312
+ carry: dict[str, CarryForwardSource],
313
+ meter: CostMeterPort,
314
+ ) -> str:
315
+ """One chunk's "where this sits" sentence: replayed if already stored.
316
+
317
+ Resolution order (D7 replay, then D56 carry-forward, then the model):
318
+ the row's own stored prefix of this generation; a prior version's
319
+ stored prefix for the same content hash; only then a model call.
320
+ """
321
+ if (
322
+ chunk.context_prefix is not None
323
+ and chunk.prefixer_version == E1_PREFIXER_VERSION
324
+ ):
325
+ return chunk.context_prefix
326
+ carried = carry.get(chunk.chunk_content_hash)
327
+ if carried is not None:
328
+ return carried.context_prefix
329
+ head = document_md[chunk.char_start : chunk.char_end][:400]
330
+ prompt = _PREFIX_PROMPT_TEMPLATE.format(
331
+ title=source.title or "untitled",
332
+ section_path=chunk.section_path,
333
+ ordinal=chunk.ordinal,
334
+ head=head,
335
+ )
336
+ response = self._model_provider.generate(
337
+ request=ModelRequest(model=self._settings.prefix_model, prompt=prompt),
338
+ response_type=ContextPrefix,
339
+ )
340
+ meter.record(
341
+ call_key=f"prefix:{chunk.chunk_id}", tier="prefix", usage=response.usage
342
+ )
343
+ return response.output.prefix
344
+
345
+
346
+ def _chunk_record(
347
+ *,
348
+ source: ChunkSource,
349
+ packed: tuple[PackedChunk, ...],
350
+ index: int,
351
+ chunker_version: str,
352
+ ) -> ChunkRecord:
353
+ """Build one chunk row, deriving its D56 reuse key from stable inputs only."""
354
+ chunk = packed[index]
355
+ neighbor_hashes = tuple(
356
+ packed[neighbor].chunk_content_hash
357
+ for neighbor in (index - 1, index + 1)
358
+ if 0 <= neighbor < len(packed)
359
+ )
360
+ header_facts = (
361
+ source.title or "",
362
+ source.source_kind,
363
+ _isoformat_or_empty(value=source.source_modified_at),
364
+ _isoformat_or_empty(value=source.published_at),
365
+ source.language or "",
366
+ )
367
+ return ChunkRecord(
368
+ chunk_id=uuid4(),
369
+ deployment_id=source.deployment_id,
370
+ doc_id=source.doc_id,
371
+ version_id=source.version_id,
372
+ representation_id=source.representation_id,
373
+ section_id=chunk.section_id,
374
+ ordinal=chunk.ordinal,
375
+ block_start=chunk.block_start,
376
+ block_end=chunk.block_end,
377
+ chunk_content_hash=chunk.chunk_content_hash,
378
+ extraction_input_hash=extraction_input_hash(
379
+ own_block_hashes=(chunk.chunk_content_hash,),
380
+ neighbor_block_hashes=neighbor_hashes,
381
+ header_facts=header_facts,
382
+ extractor_version=E2_EXTRACTOR_VERSION,
383
+ structurer_version=source.structurer_version,
384
+ ),
385
+ char_start=chunk.char_start,
386
+ char_end=chunk.char_end,
387
+ token_count=chunk.token_count,
388
+ chunker_version=chunker_version,
389
+ )
390
+
391
+
392
+ def _embed_follow_up(*, work: ClaimedWork, source: ChunkSource) -> HandlerOutcome:
393
+ """Chain the embed stage for one (version, representation)."""
394
+ return HandlerOutcome(
395
+ follow_up=(
396
+ EnqueueWork(
397
+ deployment_id=work.deployment_id,
398
+ target_kind=work.target_kind,
399
+ target_id=work.target_id,
400
+ stage=PipelineStage.EMBED_CHUNK,
401
+ component_version=E1_EMBED_VERSION,
402
+ content_hash=work.content_hash,
403
+ lane=work.lane,
404
+ payload={
405
+ "version_id": str(source.version_id),
406
+ "representation_id": str(source.representation_id),
407
+ },
408
+ ),
409
+ )
410
+ )
411
+
412
+
413
+ def _isoformat_or_empty(*, value: datetime | None) -> str:
414
+ """Render an optional datetime deterministically for the reuse key."""
415
+ return "" if value is None else value.isoformat()
416
+
417
+
418
+ def _payload_uuid(*, work: ClaimedWork, field: str) -> UUID:
419
+ """Read a required UUID from the claimed payload; absence is non-retryable."""
420
+ value = (work.payload or {}).get(field)
421
+ if not isinstance(value, str):
422
+ raise NonRetryableHandlerError(
423
+ f"stage {work.stage} work {work.processing_id} carries no {field!r} payload"
424
+ )
425
+ return UUID(value)