Skip to content

Commit 55d1a3d

Browse files
committed
feat: fix retries and reingestions
1 parent 76b9798 commit 55d1a3d

17 files changed

Lines changed: 930 additions & 57 deletions

File tree

archive/optimizations/cross-language-notes.jsonl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@
3333
{"id":"divergence-batch-api","title":"Intentional divergence: batch() API shape","body":"### Intentional divergence: batch() API shape\n\nTS: callback `batch(() => { ... })`. PY: context manager `with batch(): ...`. Language-idiomatic resource scoping.\n\n**Confirmed 2026-04-07.** Do not raise as a parity finding."}
3434
{"id":"divergence-thread-safety","title":"Intentional divergence: thread safety (PY-only)","body":"### Intentional divergence: thread safety\n\nPY has `thread_safe` node option, subgraph locks, `CancellationToken`, `Runner` protocol. TS has none — JS is single-threaded. Not a parity gap.\n\n**Confirmed 2026-04-07.** Do not raise as a parity finding."}
3535
{"id":"divergence-observable-interop","title":"Intentional divergence: Observable/RxJS interop (TS-only)","body":"### Intentional divergence: Observable interop\n\nTS has `toObservable`/RxJS bridge. PY has no equivalent — no RxJS in PY ecosystem. Not a parity gap.\n\n**Confirmed 2026-04-07.** Do not raise as a parity finding."}
36+
{"id":"divergence-demo-shell","title":"Intentional divergence: demo-shell (TS-only)","body":"### Intentional divergence: demo-shell\n\nTS has `src/patterns/demo-shell.ts` for terminal-based 3-pane demo UI. PY has no equivalent — terminal UI is not a parity target. A PY CLI demo shell may be added as a future roadmap item.\n\n**Confirmed 2026-04-07.** Do not raise as a parity finding."}
37+
{"id":"divergence-triaged-item-inheritance","title":"Intentional divergence: TriagedItem inheritance vs duplication","body":"### Intentional divergence: TriagedItem inheritance\n\nTS `TriagedItem extends IntakeItem` (true subtype). PY `TriagedItem` is a separate frozen dataclass that manually duplicates `source`, `summary`, `evidence`, `affects_areas` etc. from `IntakeItem`. Frozen dataclasses in PY don't support inheritance cleanly. Functionally and in serialization, the shapes are identical.\n\n**Confirmed 2026-04-07.** Do not raise as a parity finding."}
38+
{"id":"divergence-eval-judge-pass-field","title":"Intentional divergence: EvalJudgeScore.pass (TS) vs .pass_ (PY)","body":"### Intentional divergence: EvalJudgeScore.pass field naming\n\nTS uses `pass: boolean`. PY uses `pass_: bool` because `pass` is a Python reserved keyword. Bridge code in PY uses `getattr(s, 'pass_', False)`. For cross-language JSON interchange, consumers must handle the `pass` vs `pass_` key difference.\n\n**Confirmed 2026-04-07.** Do not raise as a parity finding."}
39+
{"id":"divergence-module-organization","title":"Intentional divergence: module file organization","body":"### Intentional divergence: module file organization\n\nTS splits data structures into separate files (`reactive-map.ts`, `reactive-list.ts`, `reactive-log.ts`, `reactive-index.ts`, `pubsub.ts`). PY consolidates all into `data_structures.py`. TS has single `operators.ts` while PY splits into `tier1.py` and `tier2.py`. These are organizational choices — public API surfaces are aligned.\n\n**Confirmed 2026-04-07.** Do not raise as a parity finding."}

src/graphrefly/core/node.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -471,11 +471,7 @@ def _down_auto_value(self, value: Any) -> None:
471471
else:
472472
self.down([(MessageType.DIRTY,), (MessageType.RESOLVED,)], internal=True)
473473
return
474-
if lock is not None:
475-
with lock:
476-
self._cached = cast("T", value)
477-
else:
478-
self._cached = cast("T", value)
474+
# _handle_local_lifecycle (called by down(internal=True)) sets _cached from DATA payload.
479475
if was_dirty:
480476
self.down([(MessageType.DATA, value)], internal=True)
481477
else:

src/graphrefly/graph/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
"""Graph container types and composition primitives for graphrefly."""
22

3+
from graphrefly.graph.codec import (
4+
JSON_CODEC,
5+
DeltaCheckpoint,
6+
EvictedSubgraphInfo,
7+
EvictionPolicy,
8+
GraphCodec,
9+
JsonCodec,
10+
LazyGraphCodec,
11+
WALEntry,
12+
create_dag_cbor_codec,
13+
create_dag_cbor_zstd_codec,
14+
negotiate_codec,
15+
replay_wal,
16+
)
317
from graphrefly.graph.graph import (
418
GRAPH_META_SEGMENT,
519
GRAPH_SNAPSHOT_VERSION,
@@ -15,19 +29,41 @@
1529
TraceEntry,
1630
reachable,
1731
)
32+
from graphrefly.graph.profile import (
33+
GraphProfileResult,
34+
NodeProfile,
35+
graph_profile,
36+
)
37+
from graphrefly.graph.sizeof import sizeof
1838

1939
__all__ = [
40+
"DeltaCheckpoint",
2041
"DescribeResult",
42+
"EvictedSubgraphInfo",
43+
"EvictionPolicy",
2144
"GRAPH_META_SEGMENT",
2245
"GRAPH_SNAPSHOT_VERSION",
2346
"GraphAutoCheckpointHandle",
47+
"GraphCodec",
2448
"Graph",
2549
"GraphDiffResult",
2650
"GraphObserveSource",
51+
"GraphProfileResult",
52+
"JSON_CODEC",
53+
"JsonCodec",
54+
"LazyGraphCodec",
2755
"META_PATH_SEG",
56+
"NodeProfile",
2857
"ObserveResult",
2958
"PATH_SEP",
3059
"SpyHandle",
3160
"TraceEntry",
61+
"WALEntry",
62+
"create_dag_cbor_codec",
63+
"create_dag_cbor_zstd_codec",
64+
"graph_profile",
65+
"negotiate_codec",
3266
"reachable",
67+
"replay_wal",
68+
"sizeof",
3369
]

src/graphrefly/graph/codec.py

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
"""GraphCodec — pluggable serialization for graph snapshots (Phase 8.6).
2+
3+
The codec interface decouples snapshot format from graph internals.
4+
Default is JSON (current behavior). DAG-CBOR and compressed variants
5+
ship as optional codecs.
6+
7+
Tiered representation:
8+
HOT — Python objects (live propagation, no codec involved)
9+
WARM — DAG-CBOR in-memory buffer (lazy hydration, delta checkpoints)
10+
COLD — Arrow/Parquet (bulk storage, ML pipelines, archival)
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import contextlib
16+
import json
17+
from dataclasses import dataclass
18+
from typing import Any, Protocol, runtime_checkable
19+
20+
# ---------------------------------------------------------------------------
21+
# Core codec interface
22+
# ---------------------------------------------------------------------------
23+
24+
25+
@runtime_checkable
26+
class GraphCodec(Protocol):
27+
"""Encode/decode graph snapshots to/from binary.
28+
29+
Implementations must be deterministic: ``encode(x)`` always produces
30+
the same bytes for the same input.
31+
"""
32+
33+
@property
34+
def content_type(self) -> str:
35+
"""MIME-like content type identifier (e.g. ``application/dag-cbor+zstd``)."""
36+
...
37+
38+
@property
39+
def name(self) -> str:
40+
"""Human-readable name for diagnostics."""
41+
...
42+
43+
def encode(self, snapshot: dict[str, Any]) -> bytes:
44+
"""Encode a snapshot to binary."""
45+
...
46+
47+
def decode(self, buffer: bytes) -> dict[str, Any]:
48+
"""Decode binary back to a snapshot."""
49+
...
50+
51+
52+
@runtime_checkable
53+
class LazyGraphCodec(GraphCodec, Protocol):
54+
"""Extended codec that supports lazy (on-demand) node decoding."""
55+
56+
def decode_lazy(self, buffer: bytes) -> dict[str, Any]:
57+
"""Decode envelope and topology; defer node value decoding to access time."""
58+
...
59+
60+
61+
# ---------------------------------------------------------------------------
62+
# Delta checkpoint types (requires V0 — Phase 6.0)
63+
# ---------------------------------------------------------------------------
64+
65+
66+
@dataclass(frozen=True, slots=True)
67+
class DeltaCheckpoint:
68+
"""A delta checkpoint: only the nodes that changed since last checkpoint."""
69+
70+
seq: int
71+
name: str
72+
base_seq: int
73+
nodes: dict[str, dict[str, Any]]
74+
removed: tuple[str, ...]
75+
edges_added: tuple[dict[str, str], ...]
76+
edges_removed: tuple[dict[str, str], ...]
77+
timestamp_ns: int
78+
79+
80+
@dataclass(frozen=True, slots=True)
81+
class WALEntry:
82+
"""WAL entry: either a full snapshot or a delta."""
83+
84+
type: str # "full" | "delta"
85+
snapshot: dict[str, Any] | None = None
86+
delta: DeltaCheckpoint | None = None
87+
seq: int = 0
88+
89+
90+
# ---------------------------------------------------------------------------
91+
# Eviction policy
92+
# ---------------------------------------------------------------------------
93+
94+
95+
@dataclass(frozen=True, slots=True)
96+
class EvictionPolicy:
97+
"""Policy for evicting dormant subgraphs to reduce memory."""
98+
99+
idle_timeout_ms: int
100+
codec: GraphCodec | None = None
101+
102+
103+
@dataclass(frozen=True, slots=True)
104+
class EvictedSubgraphInfo:
105+
"""Metadata about an evicted subgraph, exposed via describe()."""
106+
107+
evicted: bool
108+
last_active_ns: int
109+
serialized_bytes: int
110+
codec_name: str
111+
112+
113+
# ---------------------------------------------------------------------------
114+
# JSON codec (default)
115+
# ---------------------------------------------------------------------------
116+
117+
118+
class JsonCodec:
119+
"""Default JSON codec. Wraps ``json.dumps``/``json.loads`` with
120+
deterministic key ordering.
121+
"""
122+
123+
@property
124+
def content_type(self) -> str:
125+
return "application/json"
126+
127+
@property
128+
def name(self) -> str:
129+
return "json"
130+
131+
def encode(self, snapshot: dict[str, Any]) -> bytes:
132+
return json.dumps(snapshot, sort_keys=True, separators=(",", ":")).encode("utf-8")
133+
134+
def decode(self, buffer: bytes) -> dict[str, Any]:
135+
result: dict[str, Any] = json.loads(buffer.decode("utf-8"))
136+
return result
137+
138+
139+
JSON_CODEC = JsonCodec()
140+
141+
142+
# ---------------------------------------------------------------------------
143+
# DAG-CBOR codec (stub — requires cbor2)
144+
# ---------------------------------------------------------------------------
145+
146+
147+
def create_dag_cbor_codec(cbor_module: Any) -> GraphCodec:
148+
"""Create a DAG-CBOR codec.
149+
150+
Requires ``cbor2`` as a dependency. ~40-50% smaller than JSON,
151+
deterministic encoding.
152+
153+
Args:
154+
cbor_module: A module with ``encode(value) -> bytes`` and
155+
``decode(bytes) -> Any`` callables.
156+
"""
157+
158+
class _DagCborCodec:
159+
@property
160+
def content_type(self) -> str:
161+
return "application/dag-cbor"
162+
163+
@property
164+
def name(self) -> str:
165+
return "dag-cbor"
166+
167+
def encode(self, snapshot: dict[str, Any]) -> bytes:
168+
result: bytes = cbor_module.encode(snapshot)
169+
return result
170+
171+
def decode(self, buffer: bytes) -> dict[str, Any]:
172+
result: dict[str, Any] = cbor_module.decode(buffer)
173+
return result
174+
175+
return _DagCborCodec()
176+
177+
178+
def create_dag_cbor_zstd_codec(
179+
cbor_module: Any,
180+
zstd_module: Any,
181+
) -> GraphCodec:
182+
"""Create a DAG-CBOR + zstd codec. ~80-90% smaller than JSON.
183+
184+
Args:
185+
cbor_module: Module with ``encode``/``decode``.
186+
zstd_module: Module with ``compress``/``decompress``.
187+
"""
188+
189+
class _DagCborZstdCodec:
190+
@property
191+
def content_type(self) -> str:
192+
return "application/dag-cbor+zstd"
193+
194+
@property
195+
def name(self) -> str:
196+
return "dag-cbor-zstd"
197+
198+
def encode(self, snapshot: dict[str, Any]) -> bytes:
199+
result: bytes = zstd_module.compress(cbor_module.encode(snapshot))
200+
return result
201+
202+
def decode(self, buffer: bytes) -> dict[str, Any]:
203+
result: dict[str, Any] = cbor_module.decode(
204+
zstd_module.decompress(buffer)
205+
)
206+
return result
207+
208+
return _DagCborZstdCodec()
209+
210+
211+
# ---------------------------------------------------------------------------
212+
# Codec negotiation
213+
# ---------------------------------------------------------------------------
214+
215+
216+
def negotiate_codec(
217+
local_preference: list[GraphCodec],
218+
remote_content_types: list[str],
219+
) -> GraphCodec | None:
220+
"""Negotiate a common codec between two peers.
221+
222+
Each peer advertises its supported codecs (ordered by preference).
223+
Returns the first codec supported by both, or ``None``.
224+
"""
225+
remote_set = set(remote_content_types)
226+
for codec in local_preference:
227+
if codec.content_type in remote_set:
228+
return codec
229+
return None
230+
231+
232+
# ---------------------------------------------------------------------------
233+
# WAL helpers
234+
# ---------------------------------------------------------------------------
235+
236+
237+
def replay_wal(entries: list[WALEntry]) -> dict[str, Any]:
238+
"""Reconstruct a snapshot from a WAL (full snapshot + sequence of deltas).
239+
240+
Applies deltas in order on top of the base snapshot.
241+
242+
Args:
243+
entries: Ordered WAL entries (must start with a full snapshot).
244+
245+
Returns:
246+
Reconstructed snapshot dict.
247+
248+
Raises:
249+
ValueError: If the WAL is empty or doesn't start with a full snapshot.
250+
"""
251+
if not entries:
252+
raise ValueError("WAL is empty — need at least one full snapshot")
253+
254+
first = entries[0]
255+
if first.type != "full" or first.snapshot is None:
256+
raise ValueError("WAL must start with a full snapshot")
257+
258+
# Deep clone so we can mutate.
259+
import copy
260+
result = copy.deepcopy(first.snapshot)
261+
262+
for entry in entries[1:]:
263+
if entry.type == "full" and entry.snapshot is not None:
264+
result = copy.deepcopy(entry.snapshot)
265+
continue
266+
267+
if entry.type != "delta" or entry.delta is None:
268+
continue
269+
270+
delta = entry.delta
271+
272+
# Apply node changes.
273+
nodes = result.setdefault("nodes", {})
274+
for name, patch in delta.nodes.items():
275+
if name in nodes:
276+
nodes[name]["value"] = patch.get("value")
277+
if "meta" in patch:
278+
nodes[name]["meta"] = patch["meta"]
279+
else:
280+
nodes[name] = patch
281+
282+
# Remove nodes.
283+
for name in delta.removed:
284+
nodes.pop(name, None)
285+
286+
# Apply edge changes.
287+
edges = result.setdefault("edges", [])
288+
for edge in delta.edges_added:
289+
edges.append(edge)
290+
for edge in delta.edges_removed:
291+
with contextlib.suppress(ValueError):
292+
edges.remove(edge)
293+
294+
return result

0 commit comments

Comments
 (0)