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,735 @@
1
+ """Deterministic Plane-K routing, scheduling, and single-commit driver."""
2
+
3
+ from collections.abc import Collection
4
+ from collections.abc import Mapping
5
+ from concurrent.futures import Future
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from pathlib import Path
8
+ from pathlib import PurePosixPath
9
+ from tempfile import TemporaryDirectory
10
+ import traceback
11
+ from typing import Final
12
+ from typing import Protocol
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.core import knowledge_compile_order
21
+ from rememberstack.core import knowledge_content_hash
22
+ from rememberstack.core import validate_knowledge_page_output
23
+ from rememberstack.model import KnowledgeCommitCycleResult
24
+ from rememberstack.model import KnowledgeCompilationFailure
25
+ from rememberstack.model import KnowledgeCompileArtifact
26
+ from rememberstack.model import KnowledgeCompileContext
27
+ from rememberstack.model import KnowledgeConvertKindProposal
28
+ from rememberstack.model import KnowledgeEvidenceDelta
29
+ from rememberstack.model import KnowledgeEvidenceTarget
30
+ from rememberstack.model import KnowledgeMergePagesProposal
31
+ from rememberstack.model import KnowledgeMovePageProposal
32
+ from rememberstack.model import KnowledgePageCompileOutput
33
+ from rememberstack.model import KnowledgePageCompileRequest
34
+ from rememberstack.model import KnowledgePageKind
35
+ from rememberstack.model import KnowledgePendingCycle
36
+ from rememberstack.model import KnowledgePendingPlanDecision
37
+ from rememberstack.model import KnowledgeRetirePageProposal
38
+ from rememberstack.ports import KGitRemotePort
39
+ from rememberstack.spine.knowledge import KnowledgeControlPlane
40
+ from rememberstack.workers.knowledge_authored import KnowledgeAuthoredSynchronizer
41
+
42
+ KNOWLEDGE_DRIVER_VERSION: Final = "k-driver-2026.07"
43
+
44
+
45
+ class KnowledgeCommitSettings(BaseSettings):
46
+ """Settings-owned bound on concurrent disjoint page compilers."""
47
+
48
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_K_DRIVER_")
49
+
50
+ max_parallel_pages: int = Field(gt=0)
51
+
52
+
53
+ class KnowledgePageCompiler(Protocol):
54
+ """The future WP-6.3/6.4 page compiler consumed by this mechanical loop."""
55
+
56
+ def compile_page(
57
+ self, *, request: KnowledgePageCompileRequest
58
+ ) -> KnowledgePageCompileOutput:
59
+ """Return one structured page without publishing or mutating another page."""
60
+ ...
61
+
62
+
63
+ class KnowledgeRoutingDriver:
64
+ """Route an evidence delta, recompute manifests, and mark exact stale pages."""
65
+
66
+ def __init__(self, *, control_plane: KnowledgeControlPlane) -> None:
67
+ """Bind the deterministic driver to its Postgres control plane."""
68
+ self._control_plane = control_plane
69
+
70
+ def route_and_mark_stale(
71
+ self,
72
+ *,
73
+ deployment_id: UUID,
74
+ delta: KnowledgeEvidenceDelta,
75
+ contexts: Mapping[UUID, KnowledgeCompileContext],
76
+ tombstone: bool = False,
77
+ ) -> tuple[UUID, ...]:
78
+ """Narrow by keys/citations, then mark only manifest mismatches.
79
+
80
+ Routing may intentionally over-select because four inverted key kinds
81
+ cannot encode every rule parameter. The complete manifest comparison
82
+ is the correctness gate, so a coarse match can never fabricate stale
83
+ state.
84
+ """
85
+ routed = self._control_plane.route_delta(
86
+ deployment_id=deployment_id, delta=delta
87
+ )
88
+ stale = self._control_plane.stale_artifacts(
89
+ deployment_id=deployment_id, contexts=contexts, artifact_ids=routed
90
+ )
91
+ marked = self._control_plane.mark_stale(artifacts=stale)
92
+ self._control_plane.route_notifications(
93
+ deployment_id=deployment_id, delta=delta, tombstone=tombstone
94
+ )
95
+ return marked
96
+
97
+ def mark_all_manifest_drift(
98
+ self, *, deployment_id: UUID, contexts: Mapping[UUID, KnowledgeCompileContext]
99
+ ) -> tuple[UUID, ...]:
100
+ """Catch sidecar, summary, rule, or writer-version drift without an E delta."""
101
+ stale = self._control_plane.stale_artifacts(
102
+ deployment_id=deployment_id, contexts=contexts
103
+ )
104
+ return self._control_plane.mark_stale(artifacts=stale)
105
+
106
+
107
+ class KnowledgeCommitDriver:
108
+ """Compile a dependency-ordered batch and publish it through one git writer."""
109
+
110
+ def __init__(
111
+ self,
112
+ *,
113
+ control_plane: KnowledgeControlPlane,
114
+ git_remote: KGitRemotePort,
115
+ compiler: KnowledgePageCompiler,
116
+ settings: KnowledgeCommitSettings,
117
+ ) -> None:
118
+ """Bind the commit loop to its Postgres, git, and page-compiler seams."""
119
+ self._control_plane = control_plane
120
+ self._git_remote = git_remote
121
+ self._compiler = compiler
122
+ self._settings = settings
123
+ self._authored = KnowledgeAuthoredSynchronizer(control_plane=control_plane)
124
+
125
+ def run_cycle(
126
+ self,
127
+ *,
128
+ deployment_id: UUID,
129
+ exclusions_by_artifact: Mapping[UUID, Collection[KnowledgeEvidenceTarget]],
130
+ ) -> KnowledgeCommitCycleResult:
131
+ """Recover prior work, compile stale pages, publish once, then finalize."""
132
+ with self._control_plane.commit_lease(deployment_id=deployment_id):
133
+ with TemporaryDirectory(prefix="rememberstack-k-cycle-") as temporary:
134
+ worktree = Path(temporary)
135
+ checkout = self._git_remote.checkout(destination=worktree)
136
+ recovered = self._recover_pending_cycles(
137
+ deployment_id=deployment_id,
138
+ worktree=worktree,
139
+ git_revision=checkout.root,
140
+ )
141
+ authored = self._authored.sync_checkout(
142
+ deployment_id=deployment_id,
143
+ worktree=worktree,
144
+ git_revision=checkout.root,
145
+ )
146
+ self._control_plane.materialize_due_dispatches(
147
+ deployment_id=deployment_id,
148
+ component_version=KNOWLEDGE_DRIVER_VERSION,
149
+ )
150
+ pending_decisions = self._control_plane.pending_plan_decisions(
151
+ deployment_id=deployment_id
152
+ )
153
+ plan_files_changed = _reconcile_plan_files(
154
+ decisions=pending_decisions, worktree=worktree
155
+ )
156
+ quarantined = self._quarantine_compiled_drift(
157
+ deployment_id=deployment_id, worktree=worktree
158
+ )
159
+ artifacts = self._control_plane.compile_artifacts(
160
+ deployment_id=deployment_id
161
+ )
162
+ schedule = knowledge_compile_order(artifacts=artifacts)
163
+ if not schedule:
164
+ revision = checkout.root
165
+ if plan_files_changed:
166
+ revision = self._git_remote.publish(worktree=worktree).root
167
+ stamped = self._control_plane.stamp_ready_plan_decisions(
168
+ deployment_id=deployment_id,
169
+ git_commit=revision,
170
+ present_paths=_present_paths(worktree=worktree),
171
+ )
172
+ return KnowledgeCommitCycleResult(
173
+ checkout_revision=checkout.root,
174
+ published_revision=(
175
+ revision if revision != checkout.root else None
176
+ ),
177
+ recovered_cycle_ids=recovered,
178
+ quarantined_artifact_ids=quarantined,
179
+ stamped_plan_decision_ids=stamped,
180
+ registered_authored_artifact_ids=(
181
+ authored.registered_artifact_ids
182
+ ),
183
+ synced_authored_artifact_ids=authored.synced_artifact_ids,
184
+ authored_lint_flag_artifact_ids=(
185
+ authored.lint_flag_artifact_ids
186
+ ),
187
+ )
188
+
189
+ known_paths = self._control_plane.artifact_git_paths(
190
+ deployment_id=deployment_id
191
+ )
192
+ compiled = self._compile_schedule(
193
+ artifacts=artifacts,
194
+ schedule=schedule,
195
+ known_paths=known_paths,
196
+ worktree=worktree,
197
+ exclusions_by_artifact=exclusions_by_artifact,
198
+ )
199
+ for artifact, output in compiled:
200
+ _write_compiled_page(
201
+ worktree=worktree,
202
+ git_path=artifact.git_path,
203
+ markdown=output.markdown,
204
+ )
205
+
206
+ cycle_id = uuid4()
207
+ compilations = tuple(output.compilation for _, output in compiled)
208
+ self._control_plane.record_pending_compilations(
209
+ cycle_id=cycle_id, compilations=compilations
210
+ )
211
+ published = self._git_remote.publish(worktree=worktree)
212
+ self._control_plane.commit_compilations(
213
+ compilations=compilations, git_commit=published.root
214
+ )
215
+ stamped = self._control_plane.stamp_ready_plan_decisions(
216
+ deployment_id=deployment_id,
217
+ git_commit=published.root,
218
+ present_paths=_present_paths(worktree=worktree),
219
+ )
220
+ return KnowledgeCommitCycleResult(
221
+ checkout_revision=checkout.root,
222
+ published_revision=published.root,
223
+ compiled_artifact_ids=tuple(
224
+ artifact.artifact_id for artifact, _ in compiled
225
+ ),
226
+ recovered_cycle_ids=recovered,
227
+ quarantined_artifact_ids=quarantined,
228
+ stamped_plan_decision_ids=stamped,
229
+ registered_authored_artifact_ids=(authored.registered_artifact_ids),
230
+ synced_authored_artifact_ids=authored.synced_artifact_ids,
231
+ authored_lint_flag_artifact_ids=authored.lint_flag_artifact_ids,
232
+ )
233
+
234
+ def _quarantine_compiled_drift(
235
+ self, *, deployment_id: UUID, worktree: Path
236
+ ) -> tuple[UUID, ...]:
237
+ """Preserve direct compiled-body changes before scheduling any compiler."""
238
+ quarantined: list[UUID] = []
239
+ for state in self._control_plane.compiled_content_states(
240
+ deployment_id=deployment_id
241
+ ):
242
+ target = _worktree_target(worktree=worktree, git_path=state.git_path)
243
+ if target.is_file():
244
+ try:
245
+ edited_markdown = target.read_text(encoding="utf-8")
246
+ detected_hash = knowledge_content_hash(markdown=edited_markdown)
247
+ except UnicodeError:
248
+ edited_markdown = (
249
+ "# Quarantined compiled-page edit\n\n"
250
+ "The directly edited body is not valid UTF-8.\n"
251
+ )
252
+ detected_hash = knowledge_content_hash(markdown=edited_markdown)
253
+ else:
254
+ edited_markdown = (
255
+ "# Quarantined compiled-page deletion\n\n"
256
+ "The compiled body was deleted directly from the checkout.\n"
257
+ )
258
+ detected_hash = knowledge_content_hash(markdown="")
259
+ if detected_hash == state.content_hash:
260
+ continue
261
+ self._control_plane.quarantine_compiled_edit(
262
+ artifact_id=state.artifact_id,
263
+ detected_content_hash=detected_hash,
264
+ edited_markdown=edited_markdown,
265
+ driver_version=KNOWLEDGE_DRIVER_VERSION,
266
+ )
267
+ quarantined.append(state.artifact_id)
268
+ return tuple(quarantined)
269
+
270
+ def _recover_pending_cycles(
271
+ self, *, deployment_id: UUID, worktree: Path, git_revision: str
272
+ ) -> tuple[UUID, ...]:
273
+ """Finalize cycles present at HEAD and abandon those never published."""
274
+ artifacts = {
275
+ artifact.artifact_id: artifact
276
+ for artifact in self._control_plane.compile_artifacts(
277
+ deployment_id=deployment_id
278
+ )
279
+ }
280
+ recovered: list[UUID] = []
281
+ for cycle in self._control_plane.pending_cycles(deployment_id=deployment_id):
282
+ if _pending_cycle_matches_worktree(
283
+ cycle=cycle, artifacts=artifacts, worktree=worktree
284
+ ):
285
+ self._control_plane.commit_compilations(
286
+ compilations=cycle.compilations, git_commit=git_revision
287
+ )
288
+ recovered.append(cycle.cycle_id)
289
+ else:
290
+ self._control_plane.fail_pending_cycle(
291
+ deployment_id=deployment_id,
292
+ cycle_id=cycle.cycle_id,
293
+ failure="remote HEAD does not contain the pending cycle output",
294
+ )
295
+ return tuple(recovered)
296
+
297
+ def _compile_schedule(
298
+ self,
299
+ *,
300
+ artifacts: tuple[KnowledgeCompileArtifact, ...],
301
+ schedule: tuple[KnowledgeCompileArtifact, ...],
302
+ known_paths: Collection[str],
303
+ worktree: Path,
304
+ exclusions_by_artifact: Mapping[UUID, Collection[KnowledgeEvidenceTarget]],
305
+ ) -> tuple[tuple[KnowledgeCompileArtifact, KnowledgePageCompileOutput], ...]:
306
+ """Compile disjoint pages by wave while carrying fresh summaries upward."""
307
+ summaries = {
308
+ artifact.artifact_id: artifact.page_summary
309
+ for artifact in artifacts
310
+ if artifact.page_summary is not None
311
+ }
312
+ model_summaries: dict[UUID | None, str] = {
313
+ artifact.scope_id: artifact.page_summary
314
+ for artifact in artifacts
315
+ if artifact.artifact_kind == "model_page"
316
+ and artifact.page_summary is not None
317
+ }
318
+ changed_summaries: set[UUID] = set()
319
+ changed_model_scopes: set[UUID | None] = set()
320
+ outputs: list[tuple[KnowledgeCompileArtifact, KnowledgePageCompileOutput]] = []
321
+ children = {
322
+ artifact.artifact_id: tuple(
323
+ child
324
+ for child in artifacts
325
+ if child.parent_artifact_id == artifact.artifact_id
326
+ )
327
+ for artifact in artifacts
328
+ }
329
+ with ThreadPoolExecutor(
330
+ max_workers=self._settings.max_parallel_pages,
331
+ thread_name_prefix="rememberstack-k-page",
332
+ ) as executor:
333
+ for wave in _compile_waves(artifacts=artifacts, schedule=schedule):
334
+ eligible = tuple(
335
+ artifact
336
+ for artifact in wave
337
+ if artifact.stale
338
+ or {
339
+ child.artifact_id for child in children[artifact.artifact_id]
340
+ }.intersection(changed_summaries)
341
+ or (
342
+ artifact.artifact_kind != "model_page"
343
+ and artifact.scope_id in changed_model_scopes
344
+ )
345
+ )
346
+ futures: list[
347
+ tuple[KnowledgeCompileArtifact, Future[KnowledgePageCompileOutput]]
348
+ ] = []
349
+ for artifact in eligible:
350
+ child_summaries = {
351
+ child.artifact_id: summaries[child.artifact_id]
352
+ for child in children[artifact.artifact_id]
353
+ if child.artifact_id in summaries
354
+ }
355
+ futures.append(
356
+ (
357
+ artifact,
358
+ executor.submit(
359
+ self._compile_one,
360
+ artifact=artifact,
361
+ child_summaries=child_summaries,
362
+ shared_model_summary=(
363
+ None
364
+ if artifact.artifact_kind == "model_page"
365
+ else model_summaries.get(artifact.scope_id)
366
+ ),
367
+ known_paths=known_paths,
368
+ worktree=worktree,
369
+ exclusions=tuple(
370
+ exclusions_by_artifact.get(artifact.artifact_id, ())
371
+ ),
372
+ ),
373
+ )
374
+ )
375
+ wave_outputs, errors = self._collect_parallel_wave(futures=futures)
376
+ if errors:
377
+ self._record_aborted_wave(outputs=wave_outputs, errors=errors)
378
+ for artifact, output in wave_outputs:
379
+ new_summary = output.compilation.page_summary
380
+ if new_summary != artifact.page_summary:
381
+ changed_summaries.add(artifact.artifact_id)
382
+ if artifact.artifact_kind == "model_page":
383
+ changed_model_scopes.add(artifact.scope_id)
384
+ summaries[artifact.artifact_id] = new_summary
385
+ if artifact.artifact_kind == "model_page":
386
+ model_summaries[artifact.scope_id] = new_summary
387
+ outputs.append((artifact, output))
388
+ return tuple(outputs)
389
+
390
+ def _compile_one(
391
+ self,
392
+ *,
393
+ artifact: KnowledgeCompileArtifact,
394
+ child_summaries: Mapping[UUID, str],
395
+ shared_model_summary: str | None,
396
+ known_paths: Collection[str],
397
+ worktree: Path,
398
+ exclusions: tuple[KnowledgeEvidenceTarget, ...],
399
+ ) -> KnowledgePageCompileOutput:
400
+ """Compile and validate one page without touching shared checkout state."""
401
+ previous_markdown = (
402
+ None
403
+ if artifact.content_hash is None
404
+ else _read_optional_compiler_input(
405
+ worktree=worktree, git_path=artifact.git_path
406
+ )
407
+ )
408
+ curation_markdown = (
409
+ None
410
+ if artifact.curation_path is None
411
+ else _read_optional_compiler_input(
412
+ worktree=worktree, git_path=artifact.curation_path
413
+ )
414
+ )
415
+ output = self._compiler.compile_page(
416
+ request=KnowledgePageCompileRequest(
417
+ artifact=artifact,
418
+ child_summaries=dict(child_summaries),
419
+ shared_model_summary=shared_model_summary,
420
+ curation_hash=(
421
+ None
422
+ if curation_markdown is None
423
+ else knowledge_content_hash(markdown=curation_markdown)
424
+ ),
425
+ curation_markdown=curation_markdown,
426
+ previous_markdown=previous_markdown,
427
+ exclusions=exclusions,
428
+ )
429
+ )
430
+ try:
431
+ validate_knowledge_page_output(
432
+ artifact=artifact,
433
+ output=output,
434
+ known_git_paths=known_paths,
435
+ exclusions=exclusions,
436
+ )
437
+ self._control_plane.validate_citations(
438
+ deployment_id=artifact.deployment_id,
439
+ citations=output.compilation.citations,
440
+ )
441
+ except Exception as error:
442
+ compilation = output.compilation
443
+ failure_trace = traceback.format_exc()
444
+ try:
445
+ self._control_plane.record_failed_compilation(
446
+ failure=KnowledgeCompilationFailure(
447
+ compilation_id=compilation.compilation_id,
448
+ deployment_id=compilation.deployment_id,
449
+ artifact_id=compilation.artifact_id,
450
+ inputs_hash=compilation.inputs_hash,
451
+ candidate_count=compilation.candidate_count,
452
+ claims_cut_count=compilation.claims_cut_count,
453
+ writer_version=compilation.writer_version,
454
+ failure=failure_trace,
455
+ session_transcript_uri=compilation.session_transcript_uri,
456
+ )
457
+ )
458
+ except Exception as ledger_error:
459
+ raise ExceptionGroup(
460
+ "page validation and failure-ledger recording both failed",
461
+ (error, ledger_error),
462
+ ) from None
463
+ raise
464
+ return output
465
+
466
+ def _collect_parallel_wave(
467
+ self,
468
+ *,
469
+ futures: list[
470
+ tuple[KnowledgeCompileArtifact, Future[KnowledgePageCompileOutput]]
471
+ ],
472
+ ) -> tuple[
473
+ list[tuple[KnowledgeCompileArtifact, KnowledgePageCompileOutput]],
474
+ list[Exception],
475
+ ]:
476
+ """Await a whole wave so sibling sessions cannot outlive an aborted cycle."""
477
+ outputs: list[tuple[KnowledgeCompileArtifact, KnowledgePageCompileOutput]] = []
478
+ errors: list[Exception] = []
479
+ for artifact, future in futures:
480
+ try:
481
+ outputs.append((artifact, future.result()))
482
+ except Exception as error:
483
+ errors.append(error)
484
+ return outputs, errors
485
+
486
+ def _record_aborted_wave(
487
+ self,
488
+ *,
489
+ outputs: list[tuple[KnowledgeCompileArtifact, KnowledgePageCompileOutput]],
490
+ errors: list[Exception],
491
+ ) -> None:
492
+ """Ledger successful siblings discarded because their wave could not publish."""
493
+ ledger_errors: list[Exception] = []
494
+ for _, output in outputs:
495
+ compilation = output.compilation
496
+ try:
497
+ self._control_plane.record_failed_compilation(
498
+ failure=KnowledgeCompilationFailure(
499
+ compilation_id=compilation.compilation_id,
500
+ deployment_id=compilation.deployment_id,
501
+ artifact_id=compilation.artifact_id,
502
+ inputs_hash=compilation.inputs_hash,
503
+ candidate_count=compilation.candidate_count,
504
+ claims_cut_count=compilation.claims_cut_count,
505
+ writer_version=compilation.writer_version,
506
+ failure="parallel compile wave aborted because a sibling failed",
507
+ session_transcript_uri=compilation.session_transcript_uri,
508
+ )
509
+ )
510
+ except Exception as ledger_error:
511
+ ledger_errors.append(ledger_error)
512
+ combined = [*errors, *ledger_errors]
513
+ if len(combined) == 1:
514
+ raise combined[0]
515
+ raise ExceptionGroup("parallel compile wave failed", combined)
516
+
517
+
518
+ def _compile_waves(
519
+ *,
520
+ artifacts: tuple[KnowledgeCompileArtifact, ...],
521
+ schedule: tuple[KnowledgeCompileArtifact, ...],
522
+ ) -> tuple[tuple[KnowledgeCompileArtifact, ...], ...]:
523
+ """Group a valid child-first order into disjoint dependency waves."""
524
+ by_id = {artifact.artifact_id: artifact for artifact in artifacts}
525
+ depths: dict[UUID, int] = {}
526
+
527
+ def depth(artifact: KnowledgeCompileArtifact) -> int:
528
+ known = depths.get(artifact.artifact_id)
529
+ if known is not None:
530
+ return known
531
+ parent = (
532
+ None
533
+ if artifact.parent_artifact_id is None
534
+ else by_id.get(artifact.parent_artifact_id)
535
+ )
536
+ value = 0 if parent is None else depth(parent) + 1
537
+ depths[artifact.artifact_id] = value
538
+ return value
539
+
540
+ models = tuple(
541
+ artifact for artifact in schedule if artifact.artifact_kind == "model_page"
542
+ )
543
+ root_indexes = tuple(
544
+ artifact
545
+ for artifact in schedule
546
+ if artifact.artifact_kind != "model_page"
547
+ and artifact.parent_artifact_id is None
548
+ and artifact.git_path == "_index.md"
549
+ )
550
+ regular = tuple(
551
+ artifact
552
+ for artifact in schedule
553
+ if artifact not in models and artifact not in root_indexes
554
+ )
555
+ waves: list[tuple[KnowledgeCompileArtifact, ...]] = []
556
+ if models:
557
+ waves.append(models)
558
+ for level in sorted({depth(artifact) for artifact in regular}, reverse=True):
559
+ waves.append(
560
+ tuple(artifact for artifact in regular if depth(artifact) == level)
561
+ )
562
+ if root_indexes:
563
+ waves.append(root_indexes)
564
+ return tuple(waves)
565
+
566
+
567
+ def _reconcile_plan_files(
568
+ *, decisions: tuple[KnowledgePendingPlanDecision, ...], worktree: Path
569
+ ) -> bool:
570
+ """Apply accepted structure to the disposable checkout without generating prose."""
571
+ changed = False
572
+ for decision in decisions:
573
+ proposal = decision.proposal
574
+ if isinstance(proposal, KnowledgeMovePageProposal):
575
+ changed = (
576
+ _move_worktree_file(
577
+ worktree=worktree,
578
+ old_path=proposal.old_git_path,
579
+ new_path=proposal.new_git_path,
580
+ )
581
+ or changed
582
+ )
583
+ changed = (
584
+ _move_worktree_file(
585
+ worktree=worktree,
586
+ old_path=proposal.old_curation_path,
587
+ new_path=proposal.new_curation_path,
588
+ )
589
+ or changed
590
+ )
591
+ elif isinstance(proposal, KnowledgeMergePagesProposal):
592
+ for artifact_id in proposal.source_artifact_ids:
593
+ changed = (
594
+ _remove_worktree_file(
595
+ worktree=worktree, git_path=decision.artifact_paths[artifact_id]
596
+ )
597
+ or changed
598
+ )
599
+ elif isinstance(proposal, KnowledgeRetirePageProposal):
600
+ changed = (
601
+ _remove_worktree_file(
602
+ worktree=worktree,
603
+ git_path=decision.artifact_paths[proposal.artifact_id],
604
+ )
605
+ or changed
606
+ )
607
+ elif (
608
+ isinstance(proposal, KnowledgeConvertKindProposal)
609
+ and proposal.to_kind is KnowledgePageKind.COMPILED
610
+ ):
611
+ changed = (
612
+ _preserve_handover_body(
613
+ worktree=worktree,
614
+ body_path=decision.artifact_paths[proposal.artifact_id],
615
+ curation_path=proposal.curation_path,
616
+ )
617
+ or changed
618
+ )
619
+ return changed
620
+
621
+
622
+ def _move_worktree_file(*, worktree: Path, old_path: str, new_path: str) -> bool:
623
+ """Move one plan-owned file idempotently inside the checkout."""
624
+ if old_path == new_path:
625
+ return False
626
+ source = _worktree_target(worktree=worktree, git_path=old_path)
627
+ target = _worktree_target(worktree=worktree, git_path=new_path)
628
+ if source.exists() and target.exists():
629
+ raise ValueError(f"plan move has both source and target files: {old_path}")
630
+ if not source.exists():
631
+ return False
632
+ if not source.is_file():
633
+ raise ValueError(f"plan move source is not a file: {old_path}")
634
+ target.parent.mkdir(parents=True, exist_ok=True)
635
+ source.replace(target)
636
+ return True
637
+
638
+
639
+ def _remove_worktree_file(*, worktree: Path, git_path: str) -> bool:
640
+ """Remove one retired machine-owned page idempotently from the checkout."""
641
+ target = _worktree_target(worktree=worktree, git_path=git_path)
642
+ if not target.exists():
643
+ return False
644
+ if not target.is_file():
645
+ raise ValueError(f"retired artifact path is not a file: {git_path}")
646
+ target.unlink()
647
+ return True
648
+
649
+
650
+ def _preserve_handover_body(
651
+ *, worktree: Path, body_path: str, curation_path: str | None
652
+ ) -> bool:
653
+ """Copy an author's former body into curation before a writer takes ownership."""
654
+ if curation_path is None:
655
+ raise ValueError("authored handover has no curation path")
656
+ body = _read_optional_compiler_input(worktree=worktree, git_path=body_path)
657
+ if body is None:
658
+ raise ValueError("authored handover body is missing")
659
+ target = _worktree_target(worktree=worktree, git_path=curation_path)
660
+ preserved = (
661
+ "# Authored handover source\n\n"
662
+ "The body below was preserved when this page became compiled.\n\n"
663
+ f"{body}"
664
+ )
665
+ if target.is_file():
666
+ existing = target.read_text(encoding="utf-8")
667
+ if body in existing:
668
+ return False
669
+ preserved = f"{existing.rstrip()}\n\n{preserved}"
670
+ target.parent.mkdir(parents=True, exist_ok=True)
671
+ target.write_text(preserved, encoding="utf-8")
672
+ return True
673
+
674
+
675
+ def _present_paths(*, worktree: Path) -> tuple[str, ...]:
676
+ """Return normalized repository file paths used by plan readiness checks."""
677
+ return tuple(
678
+ sorted(
679
+ path.relative_to(worktree).as_posix()
680
+ for path in worktree.rglob("*")
681
+ if path.is_file() and ".git" not in path.relative_to(worktree).parts
682
+ )
683
+ )
684
+
685
+
686
+ def _pending_cycle_matches_worktree(
687
+ *,
688
+ cycle: KnowledgePendingCycle,
689
+ artifacts: Mapping[UUID, KnowledgeCompileArtifact],
690
+ worktree: Path,
691
+ ) -> bool:
692
+ """Return whether remote HEAD contains every exact pending page body."""
693
+ for compilation in cycle.compilations:
694
+ artifact = artifacts.get(compilation.artifact_id)
695
+ if artifact is None:
696
+ return False
697
+ try:
698
+ target = _worktree_target(worktree=worktree, git_path=artifact.git_path)
699
+ except ValueError:
700
+ return False
701
+ if not target.is_file():
702
+ return False
703
+ try:
704
+ markdown = target.read_text(encoding="utf-8")
705
+ except UnicodeError:
706
+ return False
707
+ if knowledge_content_hash(markdown=markdown) != compilation.content_hash:
708
+ return False
709
+ return True
710
+
711
+
712
+ def _write_compiled_page(*, worktree: Path, git_path: str, markdown: str) -> None:
713
+ """Atomically replace one validated path inside the disposable checkout."""
714
+ target = _worktree_target(worktree=worktree, git_path=git_path)
715
+ target.parent.mkdir(parents=True, exist_ok=True)
716
+ temporary = target.with_name(f".{target.name}.{uuid4()}.tmp")
717
+ temporary.write_text(markdown, encoding="utf-8")
718
+ temporary.replace(target)
719
+
720
+
721
+ def _read_optional_compiler_input(*, worktree: Path, git_path: str) -> str | None:
722
+ """Read one repository-native compiler input without widening its path surface."""
723
+ target = _worktree_target(worktree=worktree, git_path=git_path)
724
+ if not target.is_file():
725
+ return None
726
+ return target.read_text(encoding="utf-8")
727
+
728
+
729
+ def _worktree_target(*, worktree: Path, git_path: str) -> Path:
730
+ """Map a validated repository-native POSIX path into a local checkout."""
731
+ target = worktree.joinpath(*PurePosixPath(git_path).parts)
732
+ root = worktree.resolve()
733
+ if target.is_symlink() or not target.resolve().is_relative_to(root):
734
+ raise ValueError(f"compiled page path escapes worktree: {git_path}")
735
+ return target