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,698 @@
1
+ """The `graph` primitive (retrieval §3, p2 §4/§6): traversal over the snapshot.
2
+
3
+ Three typed, zero-LLM operations over the published P2 snapshot:
4
+
5
+ - **neighborhood(entity, hops, predicates?)** — everything within N hops,
6
+ distance-ranked, with an EXPLICIT truncation marker whenever a hub
7
+ exceeds the page cap (S18: never a silent top-k).
8
+ - **path(a, b, max_hops)** — how two entities connect, shortest-first. A
9
+ path is a COMPOUND result: it revalidates as a unit, so a dropped edge
10
+ drops the whole path rather than silently yielding a shorter, false
11
+ connection (S17/S21).
12
+ - **citation_path(from_doc, to_doc, max_hops)** — the document graph
13
+ (`DOC_CROSSREF`), for "which documents ultimately cite X" (S22).
14
+
15
+ Both entity operations accept the temporal parameters and echo what they
16
+ applied. As-of traversal uses the engine's **inline recursive-pattern
17
+ predicate** — evaluated per edge DURING the neighbor scan, verified live in
18
+ the WP-4.1 spike battery — never the post-hoc `all(r IN rels(p) …)` form.
19
+ Projected graphs are not an option: they feed the algorithm extensions only
20
+ and cannot be `MATCH`-traversed (D44).
21
+
22
+ Three engine constraints shape every query here (recorded with canaries in
23
+ `plan/analysis/p2_spike_battery.md`): recursive bounds cap at 30 hops; NULL
24
+ parameters cannot participate in typed comparisons, so temporal conjuncts
25
+ are composed conditionally; and a plain variable-length match enumerates
26
+ paths combinatorially, so `SHORTEST` is load-bearing for reachability.
27
+ """
28
+
29
+ from collections.abc import Callable
30
+ from datetime import datetime
31
+ from datetime import UTC
32
+ from typing import cast
33
+ from typing import Final
34
+ from uuid import UUID
35
+
36
+ import ladybug
37
+
38
+ from rememberstack.model import Envelope
39
+ from rememberstack.model import Freshness
40
+ from rememberstack.model import Grain
41
+ from rememberstack.model import GraphEdge
42
+ from rememberstack.model import GraphNode
43
+ from rememberstack.model import GraphPath
44
+ from rememberstack.model import Negative
45
+ from rememberstack.model import NegativeKind
46
+ from rememberstack.model import Truncation
47
+
48
+ MAX_ENGINE_HOPS: Final = 30
49
+ """The engine's recursive upper bound (WP-4.1 spike d2). Requests above it
50
+ are clamped and disclosed, never silently honored or failed."""
51
+
52
+ DEFAULT_NEIGHBORHOOD_CAP: Final = 500
53
+ """WP-5.6's measured neighbor page cap (under 64 KiB at the S49 hub)."""
54
+
55
+ COUNT_CAP: Final = 10_000
56
+ """How far the total-count probe walks before reporting an inexact total —
57
+ a hub must never turn an honest count into an unbounded scan."""
58
+
59
+ _TRANSIENT_MARKERS: Final = ("Overflow", "INT128", "out of range")
60
+ """Substrings of the intermittent engine faults a single retry clears."""
61
+
62
+
63
+ class _TransientEngineError(Exception):
64
+ """A transient engine fault a retry did not clear (→ typed boundary)."""
65
+
66
+
67
+ class GraphQueries:
68
+ """Snapshot traversal: neighborhoods, paths, as-of, distance ranking."""
69
+
70
+ def __init__(self, *, reader: object) -> None:
71
+ """Bind to a snapshot reader (the WP-4.2 `GraphSnapshotReader`)."""
72
+ self._reader = reader
73
+
74
+ def neighborhood(
75
+ self,
76
+ *,
77
+ entity_id: UUID,
78
+ hops: int = 2,
79
+ predicates: tuple[str, ...] = (),
80
+ valid_at: datetime | None = None,
81
+ believed_at: datetime | None = None,
82
+ limit: int = DEFAULT_NEIGHBORHOOD_CAP,
83
+ continuation: str | None = None,
84
+ ) -> Envelope:
85
+ """Everything within `hops` of the entity, distance-ranked (S18/S19).
86
+
87
+ Ranking is graph distance first (nearer is more relevant — the D9
88
+ graph-distance rerank in its native form), then name, then a stable
89
+ id tiebreak, so pages are deterministic. The applied `valid_at`
90
+ defaults to NOW so "current" means currently-valid (S18) and is
91
+ always echoed; historical reads pass an explicit instant.
92
+ """
93
+ if limit < 1:
94
+ raise ValueError("limit must be at least 1")
95
+ try:
96
+ return self._neighborhood(
97
+ entity_id=entity_id,
98
+ hops=hops,
99
+ predicates=predicates,
100
+ valid_at=valid_at,
101
+ believed_at=believed_at,
102
+ limit=limit,
103
+ continuation=continuation,
104
+ )
105
+ except _TransientEngineError as error:
106
+ return self._transient(error=error)
107
+
108
+ def _neighborhood(
109
+ self,
110
+ *,
111
+ entity_id: UUID,
112
+ hops: int,
113
+ predicates: tuple[str, ...],
114
+ valid_at: datetime | None,
115
+ believed_at: datetime | None,
116
+ limit: int,
117
+ continuation: str | None,
118
+ ) -> Envelope:
119
+ """The neighborhood body (a transient engine fault surfaces above)."""
120
+ connection = self._connection()
121
+ if connection is None:
122
+ return self._no_snapshot()
123
+ offset = self._decode_continuation(continuation)
124
+ if offset is None:
125
+ return self._stale_continuation()
126
+ applied_valid_at = valid_at or datetime.now(tz=UTC)
127
+ if not self._entity_exists(connection, entity_id=entity_id):
128
+ return self._unknown_entity(entity_id=entity_id)
129
+ clamped = min(max(hops, 1), MAX_ENGINE_HOPS)
130
+ predicate_filter = " AND r.predicate IN $predicates" if predicates else ""
131
+ guard = _temporal_predicate(valid_at=applied_valid_at, believed_at=believed_at)
132
+ # SHORTEST is load-bearing, not an optimization: a plain
133
+ # variable-length match ENUMERATES every path, which explodes
134
+ # combinatorially on a cyclic graph (a 30-hop undirected walk never
135
+ # returns). SHORTEST gives one result per reachable node — exactly
136
+ # what a distance-ranked neighborhood is — in BFS time.
137
+ pattern = (
138
+ f"MATCH (a:Entity {{id: $entity_id}})"
139
+ f" -[r:RELATES* SHORTEST 1..{clamped}"
140
+ f" (r, n | WHERE {guard}{predicate_filter})]-"
141
+ f" (b:Entity)"
142
+ )
143
+ parameters: dict[str, object] = {"entity_id": entity_id}
144
+ if predicates:
145
+ parameters["predicates"] = list(predicates)
146
+ _bind_temporal(parameters, valid_at=applied_valid_at, believed_at=believed_at)
147
+ total, exact = self._count_reachable(
148
+ connection, pattern=pattern, parameters=parameters
149
+ )
150
+ rows = _rows(
151
+ connection,
152
+ f"{pattern} RETURN b.id, b.name, b.type, length(r) AS hops"
153
+ " ORDER BY hops, b.name, b.id SKIP $offset LIMIT $fetch", # noqa: S608
154
+ {**parameters, "offset": offset, "fetch": limit + 1},
155
+ fresh=self._reconnect,
156
+ )
157
+ page_rows = rows[:limit]
158
+ nodes = tuple(
159
+ GraphNode(
160
+ entity_id=cast("UUID", row[0]),
161
+ name=cast("str", row[1]),
162
+ type=cast("str", row[2]),
163
+ hops=cast("int", row[3]),
164
+ )
165
+ for row in page_rows
166
+ )
167
+ if not nodes and offset == 0:
168
+ return self._empty(
169
+ explanation=(
170
+ f"entity {entity_id} exists but no neighbor within"
171
+ f" {clamped} hop(s) satisfies the requested filters"
172
+ ),
173
+ valid_at=applied_valid_at,
174
+ believed_at=believed_at,
175
+ )
176
+ # The bounded count probe is metadata, never the paging boundary.
177
+ # Fetching one extra row lets a 10^5-edge hub continue beyond
178
+ # COUNT_CAP without turning the total-count probe into an unbounded scan.
179
+ more = len(rows) > limit
180
+ estimated_total = (
181
+ total if exact else max(total, offset + len(nodes) + int(more))
182
+ )
183
+ return Envelope(
184
+ grain=Grain.FACT,
185
+ as_of_valid_at=applied_valid_at,
186
+ as_of_believed_at=believed_at,
187
+ nodes=nodes,
188
+ freshness=self._freshness(),
189
+ truncation=Truncation(
190
+ truncated=more or hops > MAX_ENGINE_HOPS,
191
+ returned=len(nodes),
192
+ estimated_total=estimated_total,
193
+ total_is_exact=exact,
194
+ continuation=(
195
+ self._encode_continuation(offset + len(nodes)) if more else None
196
+ ),
197
+ ),
198
+ )
199
+
200
+ def path(
201
+ self,
202
+ *,
203
+ from_entity_id: UUID,
204
+ to_entity_id: UUID,
205
+ max_hops: int = 4,
206
+ valid_at: datetime | None = None,
207
+ believed_at: datetime | None = None,
208
+ ) -> Envelope:
209
+ """How two entities connect, shortest-first (S17/S21).
210
+
211
+ The path returns as a unit with every traversed edge — each edge
212
+ carrying its STORED direction, never the traversal's, so a fact
213
+ read backwards is still reported as the fact it is.
214
+ """
215
+ try:
216
+ return self._path(
217
+ from_entity_id=from_entity_id,
218
+ to_entity_id=to_entity_id,
219
+ max_hops=max_hops,
220
+ valid_at=valid_at,
221
+ believed_at=believed_at,
222
+ )
223
+ except _TransientEngineError as error:
224
+ return self._transient(error=error)
225
+
226
+ def _path(
227
+ self,
228
+ *,
229
+ from_entity_id: UUID,
230
+ to_entity_id: UUID,
231
+ max_hops: int,
232
+ valid_at: datetime | None,
233
+ believed_at: datetime | None,
234
+ ) -> Envelope:
235
+ """The path body (a transient engine fault surfaces above)."""
236
+ connection = self._connection()
237
+ if connection is None:
238
+ return self._no_snapshot()
239
+ applied_valid_at = valid_at or datetime.now(tz=UTC)
240
+ for endpoint in (from_entity_id, to_entity_id):
241
+ if not self._entity_exists(connection, entity_id=endpoint):
242
+ return self._unknown_entity(entity_id=endpoint)
243
+ clamped = min(max(max_hops, 1), MAX_ENGINE_HOPS)
244
+ guard = _temporal_predicate(valid_at=applied_valid_at, believed_at=believed_at)
245
+ query = f"""
246
+ MATCH p = (a:Entity {{id: $from_id}})
247
+ -[r:RELATES* SHORTEST 1..{clamped}
248
+ (r, n | WHERE {guard})]-
249
+ (b:Entity {{id: $to_id}})
250
+ RETURN length(p) AS hops, nodes(p) AS path_nodes, rels(p) AS path_edges
251
+ """ # noqa: S608 — `clamped` is a validated int
252
+ # NB: the engine does not support list comprehensions over path
253
+ # elements (`[x IN nodes(p) | …]` → "Variable x is not in scope") —
254
+ # `nodes(p)`/`rels(p)` return full property maps, read below
255
+ parameters: dict[str, object] = {
256
+ "from_id": from_entity_id,
257
+ "to_id": to_entity_id,
258
+ }
259
+ _bind_temporal(parameters, valid_at=applied_valid_at, believed_at=believed_at)
260
+ rows = _rows(connection, query, parameters, fresh=self._reconnect)
261
+ if not rows:
262
+ return self._empty(
263
+ explanation=(
264
+ f"both entities exist, but no path of {clamped} hop(s) or"
265
+ " fewer connects them under the applied temporal filters"
266
+ ),
267
+ valid_at=applied_valid_at,
268
+ believed_at=believed_at,
269
+ )
270
+ paths = tuple(_path_from_row(row) for row in rows)
271
+ return Envelope(
272
+ grain=Grain.FACT,
273
+ as_of_valid_at=applied_valid_at,
274
+ as_of_believed_at=believed_at,
275
+ paths=paths,
276
+ edges=tuple(edge for path in paths for edge in path.edges),
277
+ nodes=tuple(node for path in paths for node in path.nodes),
278
+ freshness=self._freshness(),
279
+ truncation=Truncation(
280
+ truncated=max_hops > MAX_ENGINE_HOPS,
281
+ returned=len(paths),
282
+ estimated_total=len(paths),
283
+ ),
284
+ )
285
+
286
+ def citation_path(
287
+ self, *, from_doc_id: UUID, to_doc_id: UUID, max_hops: int = 6
288
+ ) -> Envelope:
289
+ """Which documents ultimately cite which (S22): the document graph.
290
+
291
+ `DOC_CROSSREF` carries no validity window (structural metadata,
292
+ not a bi-temporal fact), so this traversal takes no temporal
293
+ parameters — the honest shape rather than a decorative one.
294
+ """
295
+ try:
296
+ return self._citation_path(
297
+ from_doc_id=from_doc_id, to_doc_id=to_doc_id, max_hops=max_hops
298
+ )
299
+ except _TransientEngineError as error:
300
+ return self._transient(error=error)
301
+
302
+ def _citation_path(
303
+ self, *, from_doc_id: UUID, to_doc_id: UUID, max_hops: int
304
+ ) -> Envelope:
305
+ """The citation-path body (a transient engine fault surfaces above)."""
306
+ connection = self._connection()
307
+ if connection is None:
308
+ return self._no_snapshot()
309
+ clamped = min(max(max_hops, 1), MAX_ENGINE_HOPS)
310
+ for endpoint in (from_doc_id, to_doc_id):
311
+ if not self._document_exists(connection, doc_id=endpoint):
312
+ return self._unknown_entity(entity_id=endpoint, kind="document")
313
+ query = f"""
314
+ MATCH p = (a:Document {{id: $from_id}})
315
+ -[r:DOC_CROSSREF* SHORTEST 1..{clamped}]->
316
+ (b:Document {{id: $to_id}})
317
+ RETURN length(p) AS hops, nodes(p) AS path_nodes, rels(p) AS path_edges
318
+ """ # noqa: S608 — `clamped` is a validated int
319
+ rows = _rows(
320
+ connection,
321
+ query,
322
+ {"from_id": from_doc_id, "to_id": to_doc_id},
323
+ fresh=self._reconnect,
324
+ )
325
+ if not rows:
326
+ return self._empty(
327
+ explanation=(
328
+ f"both documents exist, but no citation chain of"
329
+ f" {clamped} hop(s) or fewer connects them"
330
+ ),
331
+ valid_at=None,
332
+ believed_at=None,
333
+ )
334
+ paths = tuple(_citation_path_from_row(row) for row in rows)
335
+ return Envelope(
336
+ grain=Grain.FACT,
337
+ paths=paths,
338
+ nodes=tuple(node for path in paths for node in path.nodes),
339
+ edges=tuple(edge for path in paths for edge in path.edges),
340
+ freshness=self._freshness(),
341
+ truncation=Truncation(
342
+ truncated=max_hops > MAX_ENGINE_HOPS,
343
+ returned=len(paths),
344
+ estimated_total=len(paths),
345
+ ),
346
+ )
347
+
348
+ def _count_reachable(
349
+ self,
350
+ connection: ladybug.Connection,
351
+ *,
352
+ pattern: str,
353
+ parameters: dict[str, object],
354
+ ) -> tuple[int, bool]:
355
+ """How many members the neighborhood holds, bounded by COUNT_CAP.
356
+
357
+ An honest `estimated_total` needs a real count — but a hub must not
358
+ turn it into an unbounded scan, so the probe stops at the cap and
359
+ says so (`total_is_exact=False`).
360
+ """
361
+ rows = _rows(
362
+ connection,
363
+ f"{pattern} RETURN b.id LIMIT $count_cap", # noqa: S608
364
+ {**parameters, "count_cap": COUNT_CAP},
365
+ fresh=self._reconnect,
366
+ )
367
+ return len(rows), len(rows) < COUNT_CAP
368
+
369
+ def _entity_exists(
370
+ self, connection: ladybug.Connection, *, entity_id: UUID
371
+ ) -> bool:
372
+ """Whether the graph knows this entity (unknown_entity vs empty)."""
373
+ rows = _rows(
374
+ connection,
375
+ "MATCH (e:Entity {id: $entity_id}) RETURN e.id LIMIT 1",
376
+ {"entity_id": entity_id},
377
+ fresh=self._reconnect,
378
+ )
379
+ return bool(rows)
380
+
381
+ def _document_exists(self, connection: ladybug.Connection, *, doc_id: UUID) -> bool:
382
+ """Whether the graph knows this document lineage."""
383
+ rows = _rows(
384
+ connection,
385
+ "MATCH (d:Document {id: $doc_id}) RETURN d.id LIMIT 1",
386
+ {"doc_id": doc_id},
387
+ fresh=self._reconnect,
388
+ )
389
+ return bool(rows)
390
+
391
+ def _encode_continuation(self, offset: int) -> str:
392
+ """A snapshot-BOUND cursor: pages from a swapped snapshot are refused.
393
+
394
+ A raw offset would silently skip or duplicate members when the
395
+ reader hot-swaps between pages; binding the snapshot version makes
396
+ that visible instead (Codex review).
397
+ """
398
+ return f"{getattr(self._reader, 'version', '')}:{offset}"
399
+
400
+ def _decode_continuation(self, continuation: str | None) -> int | None:
401
+ """The offset a cursor names, or None when it belongs elsewhere."""
402
+ if continuation is None:
403
+ return 0
404
+ version, _, raw_offset = continuation.rpartition(":")
405
+ if version != str(getattr(self._reader, "version", "")):
406
+ return None
407
+ try:
408
+ return max(int(raw_offset), 0)
409
+ except ValueError:
410
+ return None
411
+
412
+ def _connection(self) -> ladybug.Connection | None:
413
+ """The snapshot connection, or None when nothing is published yet."""
414
+ try:
415
+ return cast("ladybug.Connection", self._reader.connection()) # type: ignore[attr-defined]
416
+ except RuntimeError:
417
+ return None
418
+
419
+ def _reconnect(self) -> ladybug.Connection:
420
+ """A FRESH connection to the same snapshot, for a transient-fault retry.
421
+
422
+ A reader that can mint one (the real `GraphSnapshotReader`) gives a
423
+ clean per-connection scan state; a stub without the method falls
424
+ back to the cached connection, so the retry still runs.
425
+ """
426
+ fresh = getattr(self._reader, "fresh_connection", None)
427
+ if callable(fresh):
428
+ return cast("ladybug.Connection", fresh())
429
+ return cast("ladybug.Connection", self._reader.connection()) # type: ignore[attr-defined]
430
+
431
+ def _freshness(self) -> Freshness:
432
+ """Stamp WHICH snapshot answered and WHEN it published (S42)."""
433
+ return Freshness(
434
+ pg_live_ts=datetime.now(tz=UTC),
435
+ p2_snapshot_version=getattr(self._reader, "version", None),
436
+ p2_snapshot_ts=getattr(self._reader, "published_at", None),
437
+ )
438
+
439
+ def _no_snapshot(self) -> Envelope:
440
+ """A typed boundary: the graph plane has never published (S39)."""
441
+ return Envelope(
442
+ grain=Grain.FACT,
443
+ freshness=Freshness(pg_live_ts=datetime.now(tz=UTC)),
444
+ negative=Negative(
445
+ kind=NegativeKind.BOUNDARY,
446
+ explanation="no P2 graph snapshot has been published yet",
447
+ workaround=(
448
+ "run the graph rebuild worker, or use lookup/search on the"
449
+ " live spine which needs no projection"
450
+ ),
451
+ ),
452
+ )
453
+
454
+ def _stale_continuation(self) -> Envelope:
455
+ """The cursor belongs to a superseded snapshot (S18 honesty)."""
456
+ return Envelope(
457
+ grain=Grain.FACT,
458
+ freshness=self._freshness(),
459
+ negative=Negative(
460
+ kind=NegativeKind.BOUNDARY,
461
+ explanation=(
462
+ "the continuation cursor belongs to a superseded graph"
463
+ " snapshot; paging across a snapshot swap would skip or"
464
+ " duplicate members"
465
+ ),
466
+ workaround="restart the traversal to page over the current snapshot",
467
+ ),
468
+ )
469
+
470
+ def _unknown_entity(self, *, entity_id: UUID, kind: str = "entity") -> Envelope:
471
+ """The endpoint is not in the graph at all (S29's first branch)."""
472
+ return Envelope(
473
+ grain=Grain.FACT,
474
+ freshness=self._freshness(),
475
+ negative=Negative(
476
+ kind=NegativeKind.UNKNOWN_ENTITY,
477
+ explanation=f"{kind} {entity_id} is not present in the graph snapshot",
478
+ workaround=(
479
+ "resolve the name first, or check whether the entity was"
480
+ " merged into a survivor or arrived after this snapshot"
481
+ ),
482
+ ),
483
+ )
484
+
485
+ def _transient(self, *, error: _TransientEngineError) -> Envelope:
486
+ """A retryable engine fault, surfaced honestly (never a raw crash).
487
+
488
+ The embedded engine intermittently overflows an internal counter on
489
+ a SHORTEST traversal under memory pressure; one retry already ran.
490
+ The caller sees a typed boundary with "retry" as the workaround
491
+ rather than an INT128 RuntimeError.
492
+ """
493
+ return Envelope(
494
+ grain=Grain.FACT,
495
+ freshness=self._freshness(),
496
+ negative=Negative(
497
+ kind=NegativeKind.BOUNDARY,
498
+ explanation=(
499
+ "the graph engine hit a transient internal fault on this"
500
+ f" traversal ({error}); one retry did not clear it"
501
+ ),
502
+ workaround="retry the query, or narrow the hop bound",
503
+ ),
504
+ )
505
+
506
+ def _empty(
507
+ self,
508
+ *,
509
+ explanation: str,
510
+ valid_at: datetime | None,
511
+ believed_at: datetime | None,
512
+ ) -> Envelope:
513
+ """A typed known_empty: the traversal ran and found nothing (S29)."""
514
+ return Envelope(
515
+ grain=Grain.FACT,
516
+ as_of_valid_at=valid_at,
517
+ as_of_believed_at=believed_at,
518
+ freshness=self._freshness(),
519
+ negative=Negative(
520
+ kind=NegativeKind.KNOWN_EMPTY,
521
+ explanation=explanation,
522
+ workaround="widen the hop bound, relax predicates, or drop the as-of",
523
+ ),
524
+ )
525
+
526
+
527
+ def _temporal_predicate(
528
+ *, valid_at: datetime | None, believed_at: datetime | None
529
+ ) -> str:
530
+ """The as-of guard, evaluated PER EDGE during the neighbor scan (D44).
531
+
532
+ Composed CONDITIONALLY, never with NULL parameters: the engine infers a
533
+ NULL parameter's type as BOOL and refuses to compare it with TIMESTAMP
534
+ (`Cannot compare types TIMESTAMP and BOOL`) — found live here, pinned
535
+ by a canary in the spike battery.
536
+
537
+ Valid time filters the world-time window (defaulted to now by the
538
+ callers, so "current" means currently-valid and is always echoed);
539
+ system time filters what we believed then, and without it the read is
540
+ current belief (`invalidated_at IS NULL`, D6). The NULL-column guards
541
+ keep SQL three-valued semantics (spike f).
542
+ """
543
+ conjuncts: list[str] = []
544
+ if valid_at is not None:
545
+ conjuncts.append(
546
+ "(r.valid_from IS NULL OR r.valid_from <= $valid_at)"
547
+ " AND (r.valid_until IS NULL OR r.valid_until > $valid_at)"
548
+ )
549
+ if believed_at is not None:
550
+ conjuncts.append(
551
+ "r.ingested_at <= $believed_at"
552
+ " AND (r.invalidated_at IS NULL OR r.invalidated_at > $believed_at)"
553
+ )
554
+ else:
555
+ conjuncts.append("r.invalidated_at IS NULL") # current belief
556
+ return " AND ".join(f"({conjunct})" for conjunct in conjuncts)
557
+
558
+
559
+ def _bind_temporal(
560
+ parameters: dict[str, object],
561
+ *,
562
+ valid_at: datetime | None,
563
+ believed_at: datetime | None,
564
+ ) -> None:
565
+ """Bind only the temporal parameters the predicate actually references."""
566
+ if valid_at is not None:
567
+ parameters["valid_at"] = _naive(valid_at)
568
+ if believed_at is not None:
569
+ parameters["believed_at"] = _naive(believed_at)
570
+
571
+
572
+ def _path_from_row(row: list[object]) -> GraphPath:
573
+ """Assemble one compound entity path from its node and edge maps."""
574
+ raw_nodes = cast("list[dict[str, object]]", row[1])
575
+ raw_edges = cast("list[dict[str, object]]", row[2])
576
+ nodes = tuple(
577
+ GraphNode(
578
+ entity_id=cast("UUID", node["id"]),
579
+ name=cast("str", node["name"]),
580
+ type=cast("str", node["type"]),
581
+ hops=index,
582
+ )
583
+ for index, node in enumerate(raw_nodes)
584
+ )
585
+ edges = tuple(
586
+ GraphEdge(
587
+ relation_id=cast("UUID", edge["relation_id"]),
588
+ # the STORED direction, not the traversal's: a backwards
589
+ # crossing must never invert the fact (Codex review)
590
+ subject_id=cast("UUID", edge["subject_id"]),
591
+ object_id=cast("UUID", edge["object_id"]),
592
+ predicate=cast("str", edge["predicate"]),
593
+ fact=cast("str | None", edge.get("fact")),
594
+ evidence_count=cast("int", edge["evidence_count"]),
595
+ valid_from=_utc(edge.get("valid_from")),
596
+ valid_until=_utc(edge.get("valid_until")),
597
+ ingested_at=_utc(edge.get("ingested_at")),
598
+ invalidated_at=_utc(edge.get("invalidated_at")),
599
+ )
600
+ for edge in raw_edges
601
+ )
602
+ return GraphPath(length=cast("int", row[0]), nodes=nodes, edges=edges)
603
+
604
+
605
+ def _citation_path_from_row(row: list[object]) -> GraphPath:
606
+ """Assemble one document citation chain (no validity windows)."""
607
+ raw_nodes = cast("list[dict[str, object]]", row[1])
608
+ raw_edges = cast("list[dict[str, object]]", row[2])
609
+ nodes = tuple(
610
+ GraphNode(
611
+ entity_id=cast("UUID", node["id"]),
612
+ name=cast("str", node.get("title") or ""),
613
+ type="Document",
614
+ hops=index,
615
+ )
616
+ for index, node in enumerate(raw_nodes)
617
+ )
618
+ edges = tuple(
619
+ GraphEdge(
620
+ relation_id=cast("UUID", edge["from_doc_id"]),
621
+ subject_id=cast("UUID", edge["from_doc_id"]),
622
+ object_id=cast("UUID", edge["to_doc_id"]),
623
+ predicate=cast("str", edge["kind"]),
624
+ fact=cast("str | None", edge.get("context")),
625
+ evidence_count=0,
626
+ valid_from=None,
627
+ valid_until=None,
628
+ ingested_at=None,
629
+ invalidated_at=None,
630
+ )
631
+ for edge in raw_edges
632
+ )
633
+ return GraphPath(length=cast("int", row[0]), nodes=nodes, edges=edges)
634
+
635
+
636
+ def _is_transient(error: RuntimeError) -> bool:
637
+ """Whether an engine RuntimeError is the retryable overflow fault."""
638
+ return any(marker in str(error) for marker in _TRANSIENT_MARKERS)
639
+
640
+
641
+ def _run_rows(
642
+ connection: ladybug.Connection, query: str, parameters: dict[str, object]
643
+ ) -> list[list[object]]:
644
+ """Execute one Cypher statement on a connection and drain its rows."""
645
+ result = connection.execute(query, parameters)
646
+ assert isinstance(result, ladybug.QueryResult)
647
+ rows: list[list[object]] = []
648
+ while result.has_next():
649
+ rows.append(cast("list[object]", result.get_next()))
650
+ return rows
651
+
652
+
653
+ def _rows(
654
+ connection: ladybug.Connection,
655
+ query: str,
656
+ parameters: dict[str, object],
657
+ *,
658
+ fresh: Callable[[], ladybug.Connection] | None = None,
659
+ ) -> list[list[object]]:
660
+ """Run one Cypher statement and materialize its rows, transient-safe.
661
+
662
+ The embedded engine intermittently raises an internal
663
+ ``Overflow exception: INT128 is out of range`` under memory pressure on
664
+ a `SHORTEST` traversal — nondeterministic, and the identical query
665
+ clears on retry (recorded as a canary-less finding in the spike
666
+ report, since it does not reproduce deterministically). A retrieval
667
+ primitive must never throw a raw engine overflow at an agent, so on
668
+ that fault this retries once — on a FRESH connection when `fresh` is
669
+ given, since a wedged per-connection scan state must not be able to
670
+ break the read permanently — and, if it still fails, raises
671
+ `_TransientEngineError` for the caller to turn into a typed boundary.
672
+ """
673
+ try:
674
+ return _run_rows(connection, query, parameters)
675
+ except RuntimeError as first:
676
+ if not _is_transient(first):
677
+ raise
678
+ retry_connection = fresh() if fresh is not None else connection
679
+ try:
680
+ return _run_rows(retry_connection, query, parameters)
681
+ except RuntimeError as second:
682
+ if _is_transient(second):
683
+ raise _TransientEngineError(str(second)) from second
684
+ raise
685
+
686
+
687
+ def _naive(value: datetime | None) -> datetime | None:
688
+ """The graph stores naive UTC; parameters must match (spike f)."""
689
+ if value is None:
690
+ return None
691
+ return value.astimezone(UTC).replace(tzinfo=None)
692
+
693
+
694
+ def _utc(value: object) -> datetime | None:
695
+ """Re-attach UTC to a naive graph timestamp for the envelope."""
696
+ if isinstance(value, datetime):
697
+ return value.replace(tzinfo=UTC)
698
+ return None