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,513 @@
1
+ """The P2 graph rebuild pipeline (D7/D44, p2 §5): rebuild-first, snapshots.
2
+
3
+ The writer rebuilds the WHOLE graph from Postgres every cycle — zero drift
4
+ by construction, merges become no-ops, and "rebuildable from Postgres" is
5
+ exercised every run instead of rotting as a disaster-recovery script. The
6
+ flow: consistent export (survivor map materialized once — the spike
7
+ battery's bound strategy) → Parquet → `COPY` into a fresh embedded graph
8
+ (nodes before rels) → validation gate (unresolved survivors or a count
9
+ mismatch ABORT the snapshot) → immutable upload → registry publish.
10
+
11
+ Readers never touch the writer's files: they resolve the latest published
12
+ snapshot from the registry, download it, and open it READ_ONLY (the
13
+ engine's supported many-readers mode), hot-swapping when a newer snapshot
14
+ publishes.
15
+ """
16
+
17
+ from collections.abc import Callable
18
+ from datetime import datetime
19
+ from datetime import UTC
20
+ import hashlib
21
+ import json
22
+ from pathlib import Path
23
+ import shutil
24
+ from typing import Final
25
+ from uuid import UUID
26
+
27
+ import ladybug
28
+ import pyarrow as pa
29
+ import pyarrow.parquet as pq
30
+ from pydantic import Field
31
+ from pydantic_settings import BaseSettings
32
+ from pydantic_settings import SettingsConfigDict
33
+
34
+ from rememberstack.model import ObjectKey
35
+ from rememberstack.ports.object_store import ObjectStorePort
36
+ from rememberstack.spine.projection import GRAPH_NODE_TABLES
37
+ from rememberstack.spine.projection import GRAPH_REL_TABLES
38
+ from rememberstack.spine.projection import ProjectionCatalog
39
+ from rememberstack.workers.p2_analytics import GraphAnalyticsWorker
40
+
41
+ P2_REBUILD_VERSION: Final = "p2-rebuild-2026.07"
42
+ """The rebuild worker's component version (D12)."""
43
+
44
+ GRAPH_DDL: Final = (
45
+ # the D44 COPY contract (translation SYNTHESIS §1): analytics columns are
46
+ # NOT loaded — pagerank/degree are graph-derived post-load (D11)
47
+ "CREATE NODE TABLE Entity(id UUID, type STRING, name STRING,"
48
+ " normalized_name STRING, summary STRING, created_at TIMESTAMP,"
49
+ " PRIMARY KEY (id))",
50
+ "CREATE NODE TABLE Document(id UUID, title STRING, source_uri STRING,"
51
+ " published_at DATE, PRIMARY KEY (id))",
52
+ # subject_id/object_id are stored EXPLICITLY: a traversal may cross an
53
+ # edge backwards, and reading direction from traversal order would
54
+ # reverse the fact ("Acme works_for Alice") — Codex review
55
+ "CREATE REL TABLE RELATES(FROM Entity TO Entity, relation_id UUID,"
56
+ " subject_id UUID, object_id UUID,"
57
+ " predicate STRING, fact STRING, evidence_count INT64,"
58
+ " contradict_count INT64, confidence DOUBLE, contradiction_group UUID,"
59
+ " valid_from TIMESTAMP, valid_until TIMESTAMP, ingested_at TIMESTAMP,"
60
+ " invalidated_at TIMESTAMP)",
61
+ "CREATE REL TABLE MENTIONED_IN(FROM Entity TO Document,"
62
+ " mention_count INT64, first_seen TIMESTAMP)",
63
+ "CREATE REL TABLE DOC_CROSSREF(FROM Document TO Document,"
64
+ " from_doc_id UUID, to_doc_id UUID, kind STRING, context STRING)",
65
+ "CREATE REL TABLE IS_DOCUMENT(FROM Entity TO Document)",
66
+ )
67
+
68
+
69
+ class SnapshotValidationError(Exception):
70
+ """The validation gate aborted the snapshot (recorded in the registry)."""
71
+
72
+
73
+ class GraphRebuildSettings(BaseSettings):
74
+ """The rebuild pipeline's knobs."""
75
+
76
+ model_config = SettingsConfigDict(env_prefix="REMEMBERSTACK_P2_")
77
+
78
+ snapshot_prefix: str = Field(default="graph/snapshots")
79
+
80
+
81
+ def _uuid_text(value: object) -> object:
82
+ """UUIDs travel as strings in Parquet; COPY casts into UUID columns."""
83
+ return str(value) if isinstance(value, UUID) else value
84
+
85
+
86
+ def _naive(value: object) -> object:
87
+ """The graph stores naive UTC timestamps (the views cast to UTC)."""
88
+ if isinstance(value, datetime):
89
+ return value.replace(tzinfo=None)
90
+ return value
91
+
92
+
93
+ _STRING = ("string", _uuid_text)
94
+ _INT = ("int64", lambda value: value)
95
+ _FLOAT = ("float64", lambda value: value)
96
+ _TS = ("timestamp", _naive)
97
+ _DATE = ("date", lambda value: value)
98
+
99
+ _TABLE_COLUMNS: Final[dict[str, tuple[tuple[str, tuple[str, Callable]], ...]]] = {
100
+ "Entity": (
101
+ ("id", _STRING),
102
+ ("type", _STRING),
103
+ ("name", _STRING),
104
+ ("normalized_name", _STRING),
105
+ ("summary", _STRING),
106
+ ("created_at", _TS),
107
+ ),
108
+ "Document": (
109
+ ("id", _STRING),
110
+ ("title", _STRING),
111
+ ("source_uri", _STRING),
112
+ ("published_at", _DATE),
113
+ ),
114
+ # NB: COPY maps Parquet columns POSITIONALLY — the two endpoints first,
115
+ # then rel properties in DDL declaration order. This tuple IS that
116
+ # order; a mismatch silently loads values into the wrong properties.
117
+ "RELATES": (
118
+ ("from", _STRING),
119
+ ("to", _STRING),
120
+ ("relation_id", _STRING),
121
+ ("subject_id", _STRING),
122
+ ("object_id", _STRING),
123
+ ("predicate", _STRING),
124
+ ("fact", _STRING),
125
+ ("evidence_count", _INT),
126
+ ("contradict_count", _INT),
127
+ ("confidence", _FLOAT),
128
+ ("contradiction_group", _STRING),
129
+ ("valid_from", _TS),
130
+ ("valid_until", _TS),
131
+ ("ingested_at", _TS),
132
+ ("invalidated_at", _TS),
133
+ ),
134
+ "MENTIONED_IN": (
135
+ ("from", _STRING),
136
+ ("to", _STRING),
137
+ ("mention_count", _INT),
138
+ ("first_seen", _TS),
139
+ ),
140
+ "DOC_CROSSREF": (
141
+ ("from", _STRING),
142
+ ("to", _STRING),
143
+ ("from_doc_id", _STRING),
144
+ ("to_doc_id", _STRING),
145
+ ("kind", _STRING),
146
+ ("context", _STRING),
147
+ ),
148
+ "IS_DOCUMENT": (("from", _STRING), ("to", _STRING)),
149
+ }
150
+
151
+ _BATCH_ROWS: Final = 10_000
152
+ """Parquet write granularity — matches the export cursor's yield_per."""
153
+
154
+ _ARROW_TYPES: Final = {
155
+ "string": pa.string(),
156
+ "int64": pa.int64(),
157
+ "float64": pa.float64(),
158
+ "timestamp": pa.timestamp("us"),
159
+ "date": pa.date32(),
160
+ }
161
+
162
+
163
+ class GraphRebuildWorker:
164
+ """One full rebuild: export → build → validate → snapshot → publish."""
165
+
166
+ def __init__(
167
+ self,
168
+ *,
169
+ catalog: ProjectionCatalog,
170
+ snapshot_store: ObjectStorePort,
171
+ settings: GraphRebuildSettings | None = None,
172
+ analytics: object | None = None,
173
+ ) -> None:
174
+ """Bind the worker to the spine, the snapshot bucket, and analytics.
175
+
176
+ Analytics are part of a rebuild, not an add-on: without an
177
+ explicit worker one is composed with the default seat, so no
178
+ deployment can publish snapshots that silently leave
179
+ `graph_degree` at zero (Codex review).
180
+ """
181
+ self._catalog = catalog
182
+ self._snapshot_store = snapshot_store
183
+ self._settings = settings or GraphRebuildSettings()
184
+ self._analytics = analytics or GraphAnalyticsWorker(catalog=catalog)
185
+
186
+ def rebuild(
187
+ self, *, deployment_id: UUID, workdir: Path, version: str | None = None
188
+ ) -> dict[str, object]:
189
+ """Run one rebuild cycle end to end; abort loudly on any gate."""
190
+ version = version or datetime.now(tz=UTC).strftime("%Y%m%dT%H%M%S%f")
191
+ prefix = f"{self._settings.snapshot_prefix}/{deployment_id}/{version}"
192
+ snapshot_id = self._catalog.open_snapshot(
193
+ deployment_id=deployment_id,
194
+ plane="P2_graph",
195
+ version=version,
196
+ store_prefix=prefix,
197
+ )
198
+ try:
199
+ return self._run(
200
+ deployment_id=deployment_id,
201
+ snapshot_id=snapshot_id,
202
+ version=version,
203
+ prefix=prefix,
204
+ workdir=workdir,
205
+ )
206
+ except SnapshotValidationError:
207
+ raise # the gates recorded their own reports
208
+ except Exception as error:
209
+ # NO failure may strand a snapshot as eternally 'building' — a
210
+ # thrown COPY (e.g. a rel endpoint absent from the emitted
211
+ # nodes), a Parquet error, an upload error: all land as a
212
+ # recorded failed row (Codex review)
213
+ self._catalog.mark_failed(
214
+ snapshot_id=snapshot_id,
215
+ validation={"gate": "exception", "error": str(error)[:500]},
216
+ )
217
+ raise
218
+
219
+ def _run(
220
+ self,
221
+ *,
222
+ deployment_id: UUID,
223
+ snapshot_id: UUID,
224
+ version: str,
225
+ prefix: str,
226
+ workdir: Path,
227
+ ) -> dict[str, object]:
228
+ """The pipeline body; every exit is a recorded registry state."""
229
+ parquet_dir = workdir / version / "parquet"
230
+ parquet_dir.mkdir(parents=True, exist_ok=True)
231
+ counts: dict[str, int] = {}
232
+ with self._catalog.graph_export() as export:
233
+ offenders = export.unresolved_survivors()
234
+ if offenders:
235
+ validation = {
236
+ "gate": "unresolved_survivors",
237
+ "offenders": [str(entity) for entity in offenders],
238
+ }
239
+ self._catalog.mark_failed(
240
+ snapshot_id=snapshot_id, validation=validation
241
+ )
242
+ raise SnapshotValidationError(
243
+ f"snapshot {version} aborted: {len(offenders)} endpoint(s)"
244
+ " fail to resolve to an active survivor (merge cycle or"
245
+ " corrupt redirect chain)"
246
+ )
247
+ watermark = export.watermark() # on-snapshot (Codex review)
248
+ for table in (*GRAPH_NODE_TABLES, *GRAPH_REL_TABLES):
249
+ counts[table] = self._write_parquet(
250
+ export_rows=export.rows(table=table),
251
+ table=table,
252
+ path=parquet_dir / f"{table}.parquet",
253
+ )
254
+ graph_dir = workdir / version / "graph"
255
+ loaded, computed = _load_graph(
256
+ parquet_dir=parquet_dir,
257
+ graph_dir=graph_dir,
258
+ analytics=self._analytics,
259
+ snapshot_id=snapshot_id,
260
+ )
261
+ mismatched = {
262
+ table: {"exported": counts[table], "loaded": loaded[table]}
263
+ for table in counts
264
+ if counts[table] != loaded[table]
265
+ }
266
+ if mismatched:
267
+ validation = {"gate": "count_mismatch", "tables": mismatched}
268
+ self._catalog.mark_failed(snapshot_id=snapshot_id, validation=validation)
269
+ raise SnapshotValidationError(
270
+ f"snapshot {version} aborted: graph/export count mismatch {mismatched}"
271
+ )
272
+ manifest = self._upload(prefix=prefix, version=version, graph_dir=graph_dir)
273
+ published = self._catalog.publish(
274
+ deployment_id=deployment_id,
275
+ snapshot_id=snapshot_id,
276
+ plane="P2_graph",
277
+ row_counts=counts,
278
+ validation={"gate": "passed", "files": len(manifest)},
279
+ built_from_watermark=watermark,
280
+ )
281
+ if published:
282
+ # analytics persist ONLY for a snapshot that actually published:
283
+ # a failed validation or upload must leave no derived rows
284
+ # behind (Codex review)
285
+ self._analytics.persist( # type: ignore[attr-defined]
286
+ deployment_id=deployment_id,
287
+ snapshot_id=snapshot_id,
288
+ communities=computed[0],
289
+ metrics=computed[1],
290
+ )
291
+ # blast radius reads the PUBLISHED snapshot's degrees only
292
+ self._catalog.refresh_entity_degrees(deployment_id=deployment_id)
293
+ # per-snapshot derived state is GC'd with its snapshot's
294
+ # supersession — it is not history
295
+ self._catalog.collect_superseded_analytics(
296
+ deployment_id=deployment_id, keep_snapshot_id=snapshot_id
297
+ )
298
+ return {
299
+ "snapshot_id": snapshot_id,
300
+ "version": version,
301
+ "row_counts": counts,
302
+ "published": published,
303
+ }
304
+
305
+ def _write_parquet(self, *, export_rows: object, table: str, path: Path) -> int:
306
+ """Stream one table's export into Parquet in BOUNDED batches.
307
+
308
+ Memory stays proportional to the batch, never the table (Codex
309
+ review) — the tens-of-millions transport contract depends on it.
310
+ """
311
+ spec = _TABLE_COLUMNS[table]
312
+ schema = pa.schema([(name, _ARROW_TYPES[kind]) for name, (kind, _) in spec])
313
+ total = 0
314
+ with pq.ParquetWriter(str(path), schema) as writer:
315
+ columns: list[list[object]] = [[] for _ in spec]
316
+ for row in export_rows: # type: ignore[attr-defined]
317
+ total += 1
318
+ for index, (_, (_, caster)) in enumerate(spec):
319
+ columns[index].append(caster(row[index]))
320
+ if total % _BATCH_ROWS == 0:
321
+ writer.write_batch(
322
+ _record_batch(spec=spec, schema=schema, columns=columns)
323
+ )
324
+ columns = [[] for _ in spec]
325
+ if columns[0] or total == 0:
326
+ writer.write_batch(
327
+ _record_batch(spec=spec, schema=schema, columns=columns)
328
+ )
329
+ return total
330
+
331
+ def _upload(self, *, prefix: str, version: str, graph_dir: Path) -> list[str]:
332
+ """Ship the immutable snapshot files + a digest manifest.
333
+
334
+ Per-file sha256 digests let readers verify a download before
335
+ serving it — a truncated or corrupted transfer must never open.
336
+ """
337
+ files: dict[str, str] = {}
338
+ for path in sorted(graph_dir.rglob("*")):
339
+ if not path.is_file():
340
+ continue
341
+ relative = path.relative_to(graph_dir).as_posix()
342
+ content = path.read_bytes()
343
+ self._snapshot_store.write_bytes(
344
+ key=ObjectKey(f"{prefix}/files/{relative}"), content=content
345
+ )
346
+ files[relative] = hashlib.sha256(content).hexdigest()
347
+ self._snapshot_store.write_bytes(
348
+ key=ObjectKey(f"{prefix}/MANIFEST.json"),
349
+ content=json.dumps({"version": version, "files": files}).encode(),
350
+ )
351
+ return sorted(files)
352
+
353
+
354
+ def _record_batch(
355
+ *,
356
+ spec: tuple[tuple[str, tuple[str, Callable]], ...],
357
+ schema: pa.Schema,
358
+ columns: list[list[object]],
359
+ ) -> pa.RecordBatch:
360
+ """One bounded Arrow batch from accumulated column lists."""
361
+ return pa.record_batch(
362
+ [
363
+ pa.array(values, type=_ARROW_TYPES[kind])
364
+ for (_, (kind, _)), values in zip(spec, columns, strict=True)
365
+ ],
366
+ schema=schema,
367
+ )
368
+
369
+
370
+ class GraphSnapshotReader:
371
+ """A read-only consumer of the latest published snapshot (hot-swapping).
372
+
373
+ Resolves the pointer from the registry (never a mutable store object),
374
+ downloads the immutable files once per version, opens READ_ONLY — the
375
+ engine's supported many-readers mode — and swaps when a newer snapshot
376
+ publishes. Old local copies are point-in-time debugging artifacts.
377
+ """
378
+
379
+ def __init__(
380
+ self,
381
+ *,
382
+ catalog: ProjectionCatalog,
383
+ snapshot_store: ObjectStorePort,
384
+ deployment_id: UUID,
385
+ cache_dir: Path,
386
+ ) -> None:
387
+ """Bind the reader to the registry, the bucket, and a local cache."""
388
+ self._catalog = catalog
389
+ self._snapshot_store = snapshot_store
390
+ self._deployment_id = deployment_id
391
+ self._cache_dir = cache_dir
392
+ self._version: str | None = None
393
+ self._published_at: datetime | None = None
394
+ self._database: ladybug.Database | None = None
395
+ self._connection: ladybug.Connection | None = None
396
+
397
+ @property
398
+ def version(self) -> str | None:
399
+ """The snapshot version currently served (None before the first)."""
400
+ return self._version
401
+
402
+ @property
403
+ def published_at(self) -> datetime | None:
404
+ """When the served snapshot published (the S42 freshness stamp)."""
405
+ return self._published_at
406
+
407
+ def refresh(self) -> bool:
408
+ """Serve the latest published snapshot; True when a swap happened."""
409
+ latest = self._catalog.latest_snapshot(
410
+ deployment_id=self._deployment_id, plane="P2_graph"
411
+ )
412
+ if latest is None or latest["version"] == self._version:
413
+ return False
414
+ version = str(latest["version"])
415
+ prefix = str(latest["gcs_uri"])
416
+ local = self._cache_dir / version
417
+ if not local.exists():
418
+ # stage → verify → atomic rename: a half-downloaded or corrupt
419
+ # transfer must never be mistaken for a complete snapshot on a
420
+ # later refresh (Codex review)
421
+ staging = self._cache_dir / f".staging-{version}"
422
+ if staging.exists():
423
+ shutil.rmtree(staging)
424
+ manifest = json.loads(
425
+ self._snapshot_store.read_bytes(
426
+ key=ObjectKey(f"{prefix}/MANIFEST.json")
427
+ )
428
+ )
429
+ if manifest["version"] != version:
430
+ raise RuntimeError(
431
+ f"snapshot manifest names version {manifest['version']!r},"
432
+ f" registry says {version!r}"
433
+ )
434
+ for relative, digest in manifest["files"].items():
435
+ content = self._snapshot_store.read_bytes(
436
+ key=ObjectKey(f"{prefix}/files/{relative}")
437
+ )
438
+ if hashlib.sha256(content).hexdigest() != digest:
439
+ raise RuntimeError(
440
+ f"snapshot file {relative!r} failed its digest check"
441
+ )
442
+ target = staging / relative
443
+ target.parent.mkdir(parents=True, exist_ok=True)
444
+ target.write_bytes(content)
445
+ staging.rename(local)
446
+ self._database = ladybug.Database(str(local / "graph.lbdb"), read_only=True)
447
+ self._connection = ladybug.Connection(self._database)
448
+ self._version = version
449
+ published = latest.get("published_at")
450
+ self._published_at = published if isinstance(published, datetime) else None
451
+ return True
452
+
453
+ def connection(self) -> ladybug.Connection:
454
+ """The read-only connection to the served snapshot."""
455
+ if self._connection is None:
456
+ self.refresh()
457
+ if self._connection is None:
458
+ raise RuntimeError("no published P2 snapshot exists yet")
459
+ return self._connection
460
+
461
+ def fresh_connection(self) -> ladybug.Connection:
462
+ """A NEW connection to the served snapshot's database.
463
+
464
+ The graph primitive uses this to retry a query after a transient
465
+ engine fault on a clean per-connection scan state — a wedged
466
+ connection must not be able to break the read permanently. Same
467
+ read-only database, so it never sees uncommitted or torn data.
468
+ """
469
+ if self._database is None:
470
+ self.refresh()
471
+ if self._database is None:
472
+ raise RuntimeError("no published P2 snapshot exists yet")
473
+ return ladybug.Connection(self._database)
474
+
475
+
476
+ def _load_graph(
477
+ *,
478
+ parquet_dir: Path,
479
+ graph_dir: Path,
480
+ analytics: object = None,
481
+ snapshot_id: UUID | None = None,
482
+ ) -> tuple[dict[str, int], tuple[tuple[dict[str, object], ...], ...]]:
483
+ """COPY the export into a fresh graph — nodes first — and count back.
484
+
485
+ Analytics (PageRank, k-core, WCC, Louvain — D72) run here, on the
486
+ writer's own connection before the snapshot ships: the algorithms need
487
+ a graph, the readers only ever get read-only copies, and the results
488
+ belong in Postgres (D6), never in the projection.
489
+ """
490
+ graph_dir.mkdir(parents=True, exist_ok=True)
491
+ database = ladybug.Database(str(graph_dir / "graph.lbdb"))
492
+ connection = ladybug.Connection(database)
493
+ for ddl in GRAPH_DDL:
494
+ connection.execute(ddl)
495
+ counts: dict[str, int] = {}
496
+ for table in (*GRAPH_NODE_TABLES, *GRAPH_REL_TABLES):
497
+ connection.execute(f"COPY {table} FROM '{parquet_dir / f'{table}.parquet'}'")
498
+ pattern = (
499
+ f"MATCH (n:{table}) RETURN count(*)"
500
+ if table in GRAPH_NODE_TABLES
501
+ else f"MATCH ()-[r:{table}]->() RETURN count(*)"
502
+ )
503
+ result = connection.execute(pattern)
504
+ assert isinstance(result, ladybug.QueryResult)
505
+ counts[table] = int(result.get_next()[0]) # type: ignore[index, arg-type]
506
+ computed: tuple[tuple[dict[str, object], ...], ...] = ((), ())
507
+ if analytics is not None and snapshot_id is not None:
508
+ computed = analytics.compute( # type: ignore[attr-defined]
509
+ snapshot_id=snapshot_id, connection=connection
510
+ )
511
+ connection.close()
512
+ database.close()
513
+ return counts, computed