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.
- rememberstack/__init__.py +9 -0
- rememberstack/adapters/__init__.py +42 -0
- rememberstack/adapters/codex_writer.py +221 -0
- rememberstack/adapters/markitdown_converter.py +42 -0
- rememberstack/adapters/openrouter.py +136 -0
- rememberstack/adapters/selfhost/__init__.py +54 -0
- rememberstack/adapters/selfhost/forget.py +66 -0
- rememberstack/adapters/selfhost/git.py +374 -0
- rememberstack/adapters/selfhost/lance.py +328 -0
- rememberstack/adapters/selfhost/minio.py +279 -0
- rememberstack/adapters/selfhost/mounts.py +249 -0
- rememberstack/adapters/selfhost/object_store.py +130 -0
- rememberstack/adapters/selfhost/projection.py +80 -0
- rememberstack/adapters/selfhost/queue.py +137 -0
- rememberstack/adapters/selfhost/telemetry.py +45 -0
- rememberstack/adapters/selfhost/watcher.py +70 -0
- rememberstack/adapters/testing/__init__.py +15 -0
- rememberstack/adapters/testing/cost_meter.py +13 -0
- rememberstack/adapters/testing/model_provider.py +83 -0
- rememberstack/adapters/testing/queue.py +43 -0
- rememberstack/adapters/testing/telemetry.py +22 -0
- rememberstack/client.py +19 -0
- rememberstack/core/__init__.py +127 -0
- rememberstack/core/blockizer.py +189 -0
- rememberstack/core/chunker.py +216 -0
- rememberstack/core/consumption_skill.py +275 -0
- rememberstack/core/conversion.py +76 -0
- rememberstack/core/core_manifest.py +598 -0
- rememberstack/core/extension_packs.py +124 -0
- rememberstack/core/forget.py +17 -0
- rememberstack/core/knowledge_authored.py +276 -0
- rememberstack/core/knowledge_compile.py +215 -0
- rememberstack/core/knowledge_fact_sheet.py +210 -0
- rememberstack/core/knowledge_hashing.py +68 -0
- rememberstack/core/knowledge_planner.py +64 -0
- rememberstack/core/knowledge_writer.py +175 -0
- rememberstack/core/ranking.py +200 -0
- rememberstack/core/recipe_linter.py +149 -0
- rememberstack/core/section_snap.py +209 -0
- rememberstack/core/storage_routing.py +27 -0
- rememberstack/eval/__init__.py +53 -0
- rememberstack/eval/consumption.py +141 -0
- rememberstack/eval/contradiction.py +184 -0
- rememberstack/eval/harness.py +136 -0
- rememberstack/eval/lifecycle.py +400 -0
- rememberstack/eval/operational_scale.py +49 -0
- rememberstack/eval/resolution.py +255 -0
- rememberstack/eval/retrieval_spikes.py +50 -0
- rememberstack/eval/skeleton.py +231 -0
- rememberstack/llm/__init__.py +1 -0
- rememberstack/model/__init__.py +589 -0
- rememberstack/model/adjudication.py +100 -0
- rememberstack/model/auth.py +27 -0
- rememberstack/model/blocks.py +30 -0
- rememberstack/model/chunks.py +190 -0
- rememberstack/model/claims.py +162 -0
- rememberstack/model/client.py +98 -0
- rememberstack/model/clustering.py +54 -0
- rememberstack/model/component_version.py +124 -0
- rememberstack/model/consumption.py +88 -0
- rememberstack/model/conversion.py +31 -0
- rememberstack/model/deployment.py +53 -0
- rememberstack/model/documents.py +168 -0
- rememberstack/model/envelope.py +513 -0
- rememberstack/model/evaluation.py +72 -0
- rememberstack/model/forget.py +143 -0
- rememberstack/model/git.py +13 -0
- rememberstack/model/knowledge.py +840 -0
- rememberstack/model/knowledge_authored.py +325 -0
- rememberstack/model/knowledge_planner.py +431 -0
- rememberstack/model/lifecycle.py +42 -0
- rememberstack/model/model_provider.py +78 -0
- rememberstack/model/mounts.py +24 -0
- rememberstack/model/object_store.py +21 -0
- rememberstack/model/operational_scale.py +59 -0
- rememberstack/model/operations.py +153 -0
- rememberstack/model/processing.py +228 -0
- rememberstack/model/queue.py +73 -0
- rememberstack/model/recipes.py +83 -0
- rememberstack/model/relations.py +79 -0
- rememberstack/model/resolution.py +83 -0
- rememberstack/model/retrieval_spikes.py +62 -0
- rememberstack/model/sections.py +120 -0
- rememberstack/model/telemetry.py +30 -0
- rememberstack/ports/__init__.py +29 -0
- rememberstack/ports/auth.py +16 -0
- rememberstack/ports/connector.py +23 -0
- rememberstack/ports/cost_meter.py +17 -0
- rememberstack/ports/forget.py +20 -0
- rememberstack/ports/git.py +20 -0
- rememberstack/ports/model_provider.py +28 -0
- rememberstack/ports/mounts.py +16 -0
- rememberstack/ports/object_store.py +27 -0
- rememberstack/ports/p1_index.py +92 -0
- rememberstack/ports/purge.py +93 -0
- rememberstack/ports/queue.py +23 -0
- rememberstack/ports/telemetry.py +21 -0
- rememberstack/profiles/__init__.py +22 -0
- rememberstack/profiles/selfhost.py +324 -0
- rememberstack/profiles/selfhost_forget.py +158 -0
- rememberstack/profiles/selfhost_operations.py +95 -0
- rememberstack/py.typed +1 -0
- rememberstack/spine/__init__.py +93 -0
- rememberstack/spine/admission.py +26 -0
- rememberstack/spine/backfill.py +168 -0
- rememberstack/spine/catalog_contract.py +742 -0
- rememberstack/spine/chunk_catalog.py +237 -0
- rememberstack/spine/claim_catalog.py +298 -0
- rememberstack/spine/clustering.py +740 -0
- rememberstack/spine/component_versions.py +208 -0
- rememberstack/spine/consumption.py +81 -0
- rememberstack/spine/deployment_bootstrap.py +445 -0
- rememberstack/spine/document_catalog.py +621 -0
- rememberstack/spine/entity_registry.py +205 -0
- rememberstack/spine/extension_packs.py +220 -0
- rememberstack/spine/fact_catalog.py +571 -0
- rememberstack/spine/forget.py +1753 -0
- rememberstack/spine/knowledge.py +5467 -0
- rememberstack/spine/lifecycle.py +1071 -0
- rememberstack/spine/migrations/__init__.py +1 -0
- rememberstack/spine/migrations/_helpers.py +153 -0
- rememberstack/spine/migrations/env.py +58 -0
- rememberstack/spine/migrations/script.py.mako +27 -0
- rememberstack/spine/migrations/versions/__init__.py +1 -0
- rememberstack/spine/migrations/versions/p0_02_0001_extensions_enums.py +189 -0
- rememberstack/spine/migrations/versions/p0_02_0002_infrastructure_registries.py +321 -0
- rememberstack/spine/migrations/versions/p0_02_0003_entities_evaluation_e0_e1.py +631 -0
- rememberstack/spine/migrations/versions/p0_02_0004_claims_facts_evidence.py +411 -0
- rememberstack/spine/migrations/versions/p0_02_0005_projection_knowledge_retrieval.py +391 -0
- rememberstack/spine/migrations/versions/p0_02_0006_partitions_views.py +158 -0
- rememberstack/spine/migrations/versions/p2_06_0007_invalidated_outcome.py +26 -0
- rememberstack/spine/migrations/versions/p3_01_0008_document_version_target.py +58 -0
- rememberstack/spine/migrations/versions/p3_05_0009_reconcile_stage.py +27 -0
- rememberstack/spine/migrations/versions/p3_07_0010_lifecycle_eval_suite.py +25 -0
- rememberstack/spine/migrations/versions/p4_01_0011_survivor_view_rewrite.py +57 -0
- rememberstack/spine/migrations/versions/p6_02_0012_knowledge_compile_recovery.py +58 -0
- rememberstack/spine/migrations/versions/p6_04_0013_knowledge_writer_ledger.py +46 -0
- rememberstack/spine/migrations/versions/p6_05_0014_knowledge_planner_runtime.py +217 -0
- rememberstack/spine/migrations/versions/p6_06_0015_authored_dispatch_runtime.py +38 -0
- rememberstack/spine/migrations/versions/p7_02_0016_operational_eval_suite.py +19 -0
- rememberstack/spine/migrations/versions/p7_05_0017_hard_forget.py +55 -0
- rememberstack/spine/observation_adjudication.py +778 -0
- rememberstack/spine/operations.py +298 -0
- rememberstack/spine/projection.py +662 -0
- rememberstack/spine/recipes.py +276 -0
- rememberstack/spine/resolver.py +763 -0
- rememberstack/spine/review.py +650 -0
- rememberstack/spine/settings.py +22 -0
- rememberstack/spine/supersession.py +510 -0
- rememberstack/spine/sync.py +128 -0
- rememberstack/spine/work_ledger.py +816 -0
- rememberstack/surfaces/__init__.py +110 -0
- rememberstack/surfaces/cli.py +447 -0
- rememberstack/surfaces/consumption_skill.py +87 -0
- rememberstack/surfaces/graph_queries.py +698 -0
- rememberstack/surfaces/http_api.py +377 -0
- rememberstack/surfaces/mcp.py +67 -0
- rememberstack/surfaces/query_engine.py +1591 -0
- rememberstack/surfaces/recipe_executor.py +185 -0
- rememberstack/surfaces/recipe_surface.py +219 -0
- rememberstack/surfaces/remote_mcp.py +133 -0
- rememberstack/surfaces/sdk.py +324 -0
- rememberstack/workers/__init__.py +155 -0
- rememberstack/workers/base.py +312 -0
- rememberstack/workers/e0.py +577 -0
- rememberstack/workers/e1.py +425 -0
- rememberstack/workers/e2.py +525 -0
- rememberstack/workers/e3.py +434 -0
- rememberstack/workers/forget.py +299 -0
- rememberstack/workers/knowledge_authored.py +146 -0
- rememberstack/workers/knowledge_driver.py +735 -0
- rememberstack/workers/knowledge_fact_sheet.py +123 -0
- rememberstack/workers/knowledge_planner.py +325 -0
- rememberstack/workers/knowledge_writer.py +393 -0
- rememberstack/workers/operations.py +42 -0
- rememberstack/workers/p1.py +234 -0
- rememberstack/workers/p2.py +513 -0
- rememberstack/workers/p2_analytics.py +276 -0
- rememberstack/workers/p3.py +673 -0
- rememberstack/workers/reconcile.py +485 -0
- rememberstack/workers/sync.py +168 -0
- rememberstack-0.1.0.dist-info/METADATA +213 -0
- rememberstack-0.1.0.dist-info/RECORD +186 -0
- rememberstack-0.1.0.dist-info/WHEEL +4 -0
- rememberstack-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|