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,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
|