graphrefly 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 (47) hide show
  1. graphrefly/__init__.py +160 -0
  2. graphrefly/compat/__init__.py +18 -0
  3. graphrefly/compat/async_utils.py +228 -0
  4. graphrefly/compat/asyncio_runner.py +89 -0
  5. graphrefly/compat/trio_runner.py +81 -0
  6. graphrefly/core/__init__.py +142 -0
  7. graphrefly/core/clock.py +20 -0
  8. graphrefly/core/dynamic_node.py +749 -0
  9. graphrefly/core/guard.py +277 -0
  10. graphrefly/core/meta.py +149 -0
  11. graphrefly/core/node.py +963 -0
  12. graphrefly/core/protocol.py +460 -0
  13. graphrefly/core/runner.py +107 -0
  14. graphrefly/core/subgraph_locks.py +296 -0
  15. graphrefly/core/sugar.py +138 -0
  16. graphrefly/core/versioning.py +193 -0
  17. graphrefly/extra/__init__.py +313 -0
  18. graphrefly/extra/adapters.py +2149 -0
  19. graphrefly/extra/backoff.py +287 -0
  20. graphrefly/extra/backpressure.py +113 -0
  21. graphrefly/extra/checkpoint.py +307 -0
  22. graphrefly/extra/composite.py +303 -0
  23. graphrefly/extra/cron.py +133 -0
  24. graphrefly/extra/data_structures.py +707 -0
  25. graphrefly/extra/resilience.py +727 -0
  26. graphrefly/extra/sources.py +766 -0
  27. graphrefly/extra/tier1.py +1067 -0
  28. graphrefly/extra/tier2.py +1802 -0
  29. graphrefly/graph/__init__.py +31 -0
  30. graphrefly/graph/graph.py +2249 -0
  31. graphrefly/integrations/__init__.py +1 -0
  32. graphrefly/integrations/fastapi.py +767 -0
  33. graphrefly/patterns/__init__.py +5 -0
  34. graphrefly/patterns/ai.py +2132 -0
  35. graphrefly/patterns/cqrs.py +515 -0
  36. graphrefly/patterns/memory.py +639 -0
  37. graphrefly/patterns/messaging.py +553 -0
  38. graphrefly/patterns/orchestration.py +536 -0
  39. graphrefly/patterns/reactive_layout/__init__.py +81 -0
  40. graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
  41. graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
  42. graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
  43. graphrefly/py.typed +1 -0
  44. graphrefly-0.1.0.dist-info/METADATA +253 -0
  45. graphrefly-0.1.0.dist-info/RECORD +47 -0
  46. graphrefly-0.1.0.dist-info/WHEEL +4 -0
  47. graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,2249 @@
1
+ """Graph container — GRAPHREFLY-SPEC §3.1–3.8 (Phase 1.1–1.4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import fnmatch
7
+ import json
8
+ import os
9
+ import threading
10
+ from collections import deque
11
+ from contextlib import contextmanager, suppress
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING, Any, ClassVar
14
+
15
+ from graphrefly.core.clock import monotonic_ns
16
+ from graphrefly.core.guard import GuardDenied, normalize_actor
17
+ from graphrefly.core.meta import describe_node
18
+ from graphrefly.core.node import NodeImpl
19
+ from graphrefly.core.protocol import Messages, MessageType, is_batching, message_tier
20
+ from graphrefly.core.sugar import state
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable, Iterator
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class TraceEntry:
28
+ """Single entry in the :meth:`Graph.trace_log` ring buffer."""
29
+
30
+ timestamp_ns: int
31
+ path: str
32
+ reason: str
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class ObserveResult:
37
+ """Structured observation result from :meth:`Graph.observe` with ``structured=True``.
38
+
39
+ Accumulates events from the observation and provides a ``dispose()`` method
40
+ to unsubscribe.
41
+ """
42
+
43
+ values: dict[str, Any] = field(default_factory=dict)
44
+ dirty_count: int = 0
45
+ resolved_count: int = 0
46
+ events: list[dict[str, Any]] = field(default_factory=list)
47
+ completed_cleanly: bool = False
48
+ errored: bool = False
49
+ _dispose_fn: Callable[[], None] | None = field(default=None, repr=False)
50
+
51
+ def dispose(self) -> None:
52
+ """Unsubscribe from the observation."""
53
+ if self._dispose_fn is not None:
54
+ self._dispose_fn()
55
+ self._dispose_fn = None
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class SpyHandle:
60
+ """Handle returned by :meth:`Graph.spy` (TS parity).
61
+
62
+ Exposes the structured accumulator as ``result`` and a top-level ``dispose()``.
63
+ """
64
+
65
+ result: ObserveResult
66
+
67
+ def dispose(self) -> None:
68
+ self.result.dispose()
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class GraphDiffResult:
73
+ """Result of :meth:`Graph.diff` comparing two describe outputs."""
74
+
75
+ nodesAdded: list[str] = field(default_factory=list)
76
+ nodesRemoved: list[str] = field(default_factory=list)
77
+ nodesChanged: list[dict[str, Any]] = field(default_factory=list)
78
+ edgesAdded: list[dict[str, str]] = field(default_factory=list)
79
+ edgesRemoved: list[dict[str, str]] = field(default_factory=list)
80
+ subgraphsAdded: list[str] = field(default_factory=list)
81
+ subgraphsRemoved: list[str] = field(default_factory=list)
82
+
83
+
84
+ @dataclass(slots=True, frozen=True)
85
+ class GraphAutoCheckpointHandle:
86
+ dispose: Callable[[], None]
87
+
88
+
89
+ #: Separator for qualified paths (e.g. ``"parent::child::node"``).
90
+ PATH_SEP = "::"
91
+
92
+ #: Reserved path segment for companion meta nodes (``parent::__meta__::field``).
93
+ #: Local node and mount names must not equal this string.
94
+ GRAPH_META_SEGMENT = "__meta__"
95
+
96
+ #: Backward-compat alias — prefer :data:`GRAPH_META_SEGMENT`.
97
+ META_PATH_SEG = GRAPH_META_SEGMENT
98
+
99
+ #: Snapshot envelope version for :meth:`Graph.snapshot` / :meth:`Graph.restore` /
100
+ #: :meth:`Graph.from_snapshot` (GRAPHREFLY-SPEC §3.8).
101
+ GRAPH_SNAPSHOT_VERSION = 1
102
+
103
+ _GRAPH_DIAGRAM_DIRECTIONS = frozenset({"TD", "LR", "BT", "RL"})
104
+
105
+ #: Sentinel: :meth:`Graph.describe` without ``actor`` returns the full graph (backward compat).
106
+ _DESCRIBE_UNSCOPED = object()
107
+
108
+
109
+ def _node_allows_observe(n: NodeImpl[Any], actor: dict[str, Any]) -> bool:
110
+ return n.allows_observe(actor)
111
+
112
+
113
+ def _parse_snapshot_envelope(data: dict[str, Any]) -> dict[str, Any]:
114
+ """Validate flat snapshot envelope (``version`` field) and return ``data``."""
115
+ ver = data.get("version")
116
+ if ver != GRAPH_SNAPSHOT_VERSION:
117
+ msg = f"unsupported snapshot version {ver!r} (expected {GRAPH_SNAPSHOT_VERSION})"
118
+ raise ValueError(msg)
119
+ for key in ("name", "nodes", "edges", "subgraphs"):
120
+ if key not in data:
121
+ msg = f"snapshot missing required key {key!r}"
122
+ raise ValueError(msg)
123
+ if not isinstance(data["name"], str):
124
+ msg = f"snapshot 'name' must be a str, got {type(data['name']).__name__}"
125
+ raise TypeError(msg)
126
+ if not isinstance(data["nodes"], dict):
127
+ msg = f"snapshot 'nodes' must be a dict, got {type(data['nodes']).__name__}"
128
+ raise TypeError(msg)
129
+ if not isinstance(data["edges"], list):
130
+ msg = f"snapshot 'edges' must be a list, got {type(data['edges']).__name__}"
131
+ raise TypeError(msg)
132
+ if not isinstance(data["subgraphs"], list):
133
+ msg = f"snapshot 'subgraphs' must be a list, got {type(data['subgraphs']).__name__}"
134
+ raise TypeError(msg)
135
+ return data
136
+
137
+
138
+ def _path_has_meta_segment(path: str) -> bool:
139
+ return GRAPH_META_SEGMENT in path.split(PATH_SEP)
140
+
141
+
142
+ def _normalize_diagram_direction(direction: str | None) -> str:
143
+ if direction is None:
144
+ return "LR"
145
+ if direction not in _GRAPH_DIAGRAM_DIRECTIONS:
146
+ valid = ", ".join(sorted(_GRAPH_DIAGRAM_DIRECTIONS))
147
+ raise ValueError(f"invalid diagram direction {direction!r}; expected one of: {valid}")
148
+ return direction
149
+
150
+
151
+ def _d2_direction_from_graph_direction(direction: str) -> str:
152
+ if direction == "TD":
153
+ return "down"
154
+ if direction == "BT":
155
+ return "up"
156
+ if direction == "RL":
157
+ return "left"
158
+ return "right"
159
+
160
+
161
+ def _escape_mermaid_label(value: str) -> str:
162
+ return value.replace("\\", "\\\\").replace('"', '\\"')
163
+
164
+
165
+ def _escape_d2_label(value: str) -> str:
166
+ return value.replace("\\", "\\\\").replace('"', '\\"')
167
+
168
+
169
+ _SPY_ANSI_THEME: dict[str, str] = {
170
+ "data": "\u001b[32m",
171
+ "dirty": "\u001b[33m",
172
+ "resolved": "\u001b[36m",
173
+ "complete": "\u001b[34m",
174
+ "error": "\u001b[31m",
175
+ "derived": "\u001b[35m",
176
+ "path": "\u001b[90m",
177
+ "reset": "\u001b[0m",
178
+ }
179
+ _SPY_NO_COLOR_THEME: dict[str, str] = {
180
+ "data": "",
181
+ "dirty": "",
182
+ "resolved": "",
183
+ "complete": "",
184
+ "error": "",
185
+ "derived": "",
186
+ "path": "",
187
+ "reset": "",
188
+ }
189
+
190
+
191
+ def _resolve_spy_theme(theme: str | dict[str, str] | None) -> dict[str, str]:
192
+ if theme in (None, "ansi"):
193
+ return dict(_SPY_ANSI_THEME)
194
+ if theme == "none":
195
+ return dict(_SPY_NO_COLOR_THEME)
196
+ if isinstance(theme, dict):
197
+ out = dict(_SPY_NO_COLOR_THEME)
198
+ for key, value in theme.items():
199
+ if key in out and isinstance(value, str):
200
+ out[key] = value
201
+ return out
202
+ return dict(_SPY_NO_COLOR_THEME)
203
+
204
+
205
+ def _message_type_label(msg_type: Any) -> str | None:
206
+ if msg_type is MessageType.DATA:
207
+ return "data"
208
+ if msg_type is MessageType.DIRTY:
209
+ return "dirty"
210
+ if msg_type is MessageType.RESOLVED:
211
+ return "resolved"
212
+ if msg_type is MessageType.COMPLETE:
213
+ return "complete"
214
+ if msg_type is MessageType.ERROR:
215
+ return "error"
216
+ return None
217
+
218
+
219
+ def _describe_data(value: Any) -> str:
220
+ if isinstance(value, str):
221
+ return json.dumps(value)
222
+ if isinstance(value, bool | int | float) or value is None:
223
+ return str(value)
224
+ try:
225
+ return json.dumps(value)
226
+ except Exception:
227
+ return "[unserializable]"
228
+
229
+
230
+ def _clear_graph_registry(root: Graph) -> None:
231
+ """Remove all mounts, nodes, and edges without sending messages (after TEARDOWN)."""
232
+ with root._locked():
233
+ mounts = list(root._mounts.items())
234
+ root._mounts.clear()
235
+ root._nodes.clear()
236
+ root._edges.clear()
237
+ for _mn, ch in mounts:
238
+ _clear_graph_registry(ch)
239
+
240
+
241
+ def _ensure_qualified_mount(root: Graph, qualified: str) -> None:
242
+ """Create ``mount::...`` chain on ``root`` if missing (``from_snapshot`` helper)."""
243
+ parts = qualified.split(PATH_SEP)
244
+ g = root
245
+ for seg in parts:
246
+ with g._locked():
247
+ in_mounts = seg in g._mounts
248
+ in_nodes = seg in g._nodes
249
+ if in_nodes:
250
+ msg = f"snapshot mount path {qualified!r} collides with existing node {seg!r}"
251
+ raise ValueError(msg)
252
+ if not in_mounts:
253
+ g.mount(seg, Graph(seg))
254
+ with g._locked():
255
+ g = g._mounts[seg]
256
+
257
+
258
+ def _owner_graph_and_local(root: Graph, path: str) -> tuple[Graph, str]:
259
+ parts = path.split(PATH_SEP)
260
+ if not parts or any(not p for p in parts):
261
+ raise ValueError(f"invalid path {path!r}")
262
+ if _path_has_meta_segment(path):
263
+ msg = f"expected primary node path without {GRAPH_META_SEGMENT!r} segment, got {path!r}"
264
+ raise ValueError(msg)
265
+ *mounts, local = parts
266
+ g = root
267
+ for seg in mounts:
268
+ with g._locked():
269
+ try:
270
+ g = g._mounts[seg]
271
+ except KeyError as e:
272
+ raise KeyError(f"unknown mount {seg!r} in path {path!r}") from e
273
+ return g, local
274
+
275
+
276
+ class Graph:
277
+ """Named registry of nodes with explicit edges (pure wires, no transforms).
278
+
279
+ Qualified paths use ``::`` as the segment separator
280
+ (e.g. ``"parent::child::node"``). Registry mutations are serialized on an
281
+ :class:`threading.RLock` when ``thread_safe`` is ``True`` (default).
282
+
283
+ :meth:`connect` is idempotent; :meth:`disconnect` raises :exc:`ValueError`
284
+ if the edge was never registered. Neither method mutates ``NodeImpl``
285
+ dependency lists.
286
+
287
+ :meth:`mount` embeds a child graph (GRAPHREFLY-SPEC §3.4–3.5).
288
+ :meth:`signal` broadcasts to every registered node, its meta companions, and
289
+ all mounted subgraphs. :meth:`describe` returns Appendix-B-shaped JSON;
290
+ :meth:`observe` exposes a live message stream.
291
+
292
+ Args:
293
+ name: Registry name used in ``describe()`` output and diagnostics.
294
+ opts: Optional dict with keys ``thread_safe`` (bool, default ``True``)
295
+ and ``trace_size`` (int ring-buffer size for :meth:`annotate`).
296
+
297
+ Example:
298
+ ```python
299
+ from graphrefly import Graph, state
300
+ g = Graph("demo")
301
+ x = state(0, name="x")
302
+ y = state(0, name="y")
303
+ g.add("x", x)
304
+ g.add("y", y)
305
+ g.connect("x", "y")
306
+ g.set("x", 1)
307
+ assert g.get("y") == 1
308
+ ```
309
+ """
310
+
311
+ inspector_enabled: ClassVar[bool] = os.environ.get("NODE_ENV") != "production"
312
+ _factories: ClassVar[list[tuple[str, Callable[[str, dict[str, Any]], NodeImpl[Any]]]]] = []
313
+
314
+ __slots__ = (
315
+ "_annotations",
316
+ "_auto_checkpoint_disposers",
317
+ "_default_versioning_level",
318
+ "_edges",
319
+ "_lock",
320
+ "_mounts",
321
+ "_name",
322
+ "_nodes",
323
+ "_thread_safe",
324
+ "_trace_ring",
325
+ )
326
+
327
+ def __init__(self, name: str, opts: dict[str, Any] | None = None) -> None:
328
+ """Create a graph. ``opts`` may include ``thread_safe`` (default ``True``)."""
329
+ if not name:
330
+ msg = "Graph name must be non-empty"
331
+ raise ValueError(msg)
332
+ if PATH_SEP in name:
333
+ msg = f"Graph name must not contain {PATH_SEP!r} (got {name!r})"
334
+ raise ValueError(msg)
335
+ o = dict(opts or {})
336
+ self._name = name
337
+ self._thread_safe = bool(o.get("thread_safe", True))
338
+ self._lock: threading.RLock | None = threading.RLock() if self._thread_safe else None
339
+ self._nodes: dict[str, NodeImpl[Any]] = {}
340
+ self._mounts: dict[str, Graph] = {}
341
+ self._edges: set[tuple[str, str]] = set()
342
+ self._annotations: dict[str, str] = {}
343
+ self._trace_ring: deque[TraceEntry] = deque(maxlen=1000)
344
+ self._auto_checkpoint_disposers: set[Callable[[], None]] = set()
345
+ self._default_versioning_level: int | None = None
346
+
347
+ @property
348
+ def name(self) -> str:
349
+ return self._name
350
+
351
+ @classmethod
352
+ def register_factory(
353
+ cls, pattern: str, factory: Callable[[str, dict[str, Any]], NodeImpl[Any]]
354
+ ) -> None:
355
+ if not pattern:
356
+ raise ValueError("Graph.register_factory requires a non-empty pattern")
357
+ cls.unregister_factory(pattern)
358
+ cls._factories.append((pattern, factory))
359
+
360
+ @classmethod
361
+ def unregister_factory(cls, pattern: str) -> None:
362
+ cls._factories = [entry for entry in cls._factories if entry[0] != pattern]
363
+
364
+ @classmethod
365
+ def _factory_for_path(cls, path: str) -> Callable[[str, dict[str, Any]], NodeImpl[Any]] | None:
366
+ for pattern, factory in reversed(cls._factories):
367
+ if fnmatch.fnmatchcase(path, pattern):
368
+ return factory
369
+ return None
370
+
371
+ @contextmanager
372
+ def _locked(self) -> Iterator[None]:
373
+ lock = self._lock
374
+ if lock is not None:
375
+ with lock:
376
+ yield
377
+ else:
378
+ yield
379
+
380
+ def add(self, node_name: str, n: NodeImpl[Any]) -> None:
381
+ """Register a node under the given name in this graph.
382
+
383
+ Sets ``n``'s internal name to ``node_name`` when the node has no name set.
384
+ Raises :exc:`ValueError` if ``node_name`` is already registered.
385
+
386
+ Args:
387
+ node_name: Local name (no ``::`` separators).
388
+ n: The :class:`~graphrefly.core.node.NodeImpl` to register.
389
+
390
+ Example:
391
+ ```python
392
+ from graphrefly import Graph, state
393
+ g = Graph("g")
394
+ g.add("x", state(0))
395
+ ```
396
+ """
397
+ if not node_name:
398
+ raise ValueError("node name must be non-empty")
399
+ if node_name == GRAPH_META_SEGMENT:
400
+ msg = f"reserved name {GRAPH_META_SEGMENT!r} cannot be used as a local node name"
401
+ raise ValueError(msg)
402
+ if PATH_SEP in node_name:
403
+ msg = f"local node name must not contain {PATH_SEP!r} (path separator)"
404
+ raise ValueError(msg)
405
+ with self._locked():
406
+ if node_name in self._mounts:
407
+ raise KeyError(f"name {node_name!r} is already a mounted subgraph")
408
+ if node_name in self._nodes:
409
+ raise KeyError(f"duplicate node name: {node_name!r}")
410
+ for existing_name, existing in self._nodes.items():
411
+ if existing is n:
412
+ raise ValueError(f"node instance already registered as {existing_name!r}")
413
+ if n._name is None:
414
+ object.__setattr__(n, "_name", node_name)
415
+ if self._default_versioning_level is not None:
416
+ n._apply_versioning(self._default_versioning_level)
417
+ self._nodes[node_name] = n
418
+
419
+ def set_versioning(self, level: int | None) -> None:
420
+ """Set default versioning level for all nodes in this graph (roadmap §6.0).
421
+
422
+ Retroactively upgrades already-registered nodes. Nodes added later via
423
+ :meth:`add` inherit this level unless they already have versioning.
424
+
425
+ **Scope:** Does not propagate to mounted subgraphs. Call
426
+ ``set_versioning`` on each child graph separately if needed.
427
+
428
+ Args:
429
+ level: ``0`` for V0, ``1`` for V1, or ``None`` to clear.
430
+ """
431
+ self._default_versioning_level = level
432
+ if level is None:
433
+ return
434
+ with self._locked():
435
+ for n in self._nodes.values():
436
+ n._apply_versioning(level)
437
+
438
+ def mount(self, mount_name: str, child: Graph) -> None:
439
+ """Embed a child graph under ``mount_name`` (GRAPHREFLY-SPEC §3.4).
440
+
441
+ Child nodes become addressable as ``"mount_name::local_name"`` from this
442
+ graph. Mount and top-level node names must not collide.
443
+
444
+ Args:
445
+ mount_name: Local name for the mount point (no ``::`` separators).
446
+ child: The :class:`Graph` to embed.
447
+
448
+ Example:
449
+ ```python
450
+ from graphrefly import Graph, state
451
+ parent = Graph("parent")
452
+ child = Graph("child")
453
+ child.add("v", state(1))
454
+ parent.mount("sub", child)
455
+ assert parent.get("sub::v") == 1
456
+ ```
457
+ """
458
+ if not mount_name:
459
+ raise ValueError("mount name must be non-empty")
460
+ if PATH_SEP in mount_name:
461
+ msg = f"mount name must not contain {PATH_SEP!r}"
462
+ raise ValueError(msg)
463
+ if child is self:
464
+ raise ValueError("cannot mount a graph into itself")
465
+ if mount_name == GRAPH_META_SEGMENT:
466
+ msg = f"reserved name {GRAPH_META_SEGMENT!r} cannot be used as a mount name"
467
+ raise ValueError(msg)
468
+ with self._locked():
469
+ if mount_name in self._nodes:
470
+ raise KeyError(f"name {mount_name!r} is already a registered node")
471
+ if mount_name in self._mounts:
472
+ raise KeyError(f"duplicate mount name: {mount_name!r}")
473
+ for _m, g in self._mounts.items():
474
+ if g is child:
475
+ raise ValueError("this child graph is already mounted here")
476
+ if child._graph_reachable(self):
477
+ raise ValueError("mount would create a cycle in the graph hierarchy")
478
+ self._mounts[mount_name] = child
479
+
480
+ def remove(self, node_name: str) -> None:
481
+ """Unregister a node or unmount a subgraph and send ``[[TEARDOWN]]`` to it.
482
+
483
+ Removes the name from the internal registry and sends teardown outside the
484
+ lock to avoid deadlocking against per-node subgraph write locks.
485
+
486
+ Args:
487
+ node_name: Local name of a registered node or mount point.
488
+
489
+ Example:
490
+ ```python
491
+ from graphrefly import Graph, state
492
+ g = Graph("g")
493
+ g.add("x", state(0))
494
+ g.remove("x")
495
+ ```
496
+ """
497
+ if PATH_SEP in node_name:
498
+ msg = "remove() expects a single segment (local node or mount name on this graph)"
499
+ raise ValueError(msg)
500
+ with self._locked():
501
+ if node_name in self._mounts:
502
+ child = self._mounts.pop(node_name)
503
+ self._edges = {
504
+ (f, t) for f, t in self._edges if not self._edge_touches_mount(f, t, node_name)
505
+ }
506
+ elif node_name in self._nodes:
507
+ child = None
508
+ n = self._nodes.pop(node_name)
509
+ self._edges = {(f, t) for f, t in self._edges if f != node_name and t != node_name}
510
+ else:
511
+ raise KeyError(node_name)
512
+ if child is not None:
513
+ _teardown_mounted_graph(child)
514
+ return
515
+ # Registry already dropped this name — teardown must not fail on guards (orphan risk).
516
+ n.down([(MessageType.TEARDOWN,)], internal=True)
517
+
518
+ def _edge_touches_mount(self, f: str, t: str, mount_name: str) -> bool:
519
+ prefix = f"{mount_name}{PATH_SEP}"
520
+ return f == mount_name or t == mount_name or f.startswith(prefix) or t.startswith(prefix)
521
+
522
+ def resolve(self, path: str) -> NodeImpl[Any]:
523
+ """Return the node at a ``::``-qualified path (GRAPHREFLY-SPEC §3.5).
524
+
525
+ Traverses mounts as needed; handles ``__meta__`` segments for companion
526
+ nodes. If the first segment matches this graph's name it is stripped.
527
+ Raises :exc:`KeyError` for unknown path segments.
528
+
529
+ Args:
530
+ path: Fully-qualified path (e.g. ``"parent::child::node"``).
531
+
532
+ Returns:
533
+ The :class:`~graphrefly.core.node.NodeImpl` at that path.
534
+
535
+ Example:
536
+ ```python
537
+ from graphrefly import Graph, state
538
+ g = Graph("g")
539
+ g.add("x", state(42))
540
+ assert g.resolve("x").get() == 42
541
+ ```
542
+ """
543
+ parts = path.split(PATH_SEP)
544
+ if not parts or any(not p for p in parts):
545
+ raise ValueError(f"path must be one or more non-empty {PATH_SEP!r}-separated segments")
546
+ if parts[0] == self._name:
547
+ parts = parts[1:]
548
+ if not parts:
549
+ raise ValueError(f"resolve path ends at graph name only: {path!r}")
550
+ return self._resolve_parts_unlocked(parts, path)
551
+
552
+ def _resolve_parts_unlocked(self, parts: list[str], path: str) -> NodeImpl[Any]:
553
+ head, *tail = parts
554
+ with self._locked():
555
+ if head in self._nodes:
556
+ base = self._nodes[head]
557
+ if not tail:
558
+ return base
559
+ return _finish_resolve_from_node(base, tail, path)
560
+ if head in self._mounts:
561
+ if not tail:
562
+ raise KeyError(f"{path!r} names a subgraph, not a node")
563
+ child = self._mounts[head]
564
+ else:
565
+ raise KeyError(path)
566
+ return child._resolve_parts_unlocked(tail, path)
567
+
568
+ def _resolve_endpoint(self, path: str) -> tuple[Graph, str, NodeImpl[Any]]:
569
+ """Graph that owns the endpoint, local name in that graph, and node."""
570
+ parts = path.split(PATH_SEP)
571
+ if not parts or any(not p for p in parts):
572
+ raise ValueError(f"path must be one or more non-empty {PATH_SEP!r}-separated segments")
573
+ if GRAPH_META_SEGMENT in parts:
574
+ msg = (
575
+ "connect/disconnect endpoints must be registered graph nodes, "
576
+ f"not meta paths (got {path!r})"
577
+ )
578
+ raise ValueError(msg)
579
+ try:
580
+ return self._resolve_endpoint_parts_unlocked(parts, path)
581
+ except KeyError as e:
582
+ raise KeyError(f"unknown node: {path!r}") from e
583
+
584
+ def _resolve_endpoint_parts_unlocked(
585
+ self, parts: list[str], path: str
586
+ ) -> tuple[Graph, str, NodeImpl[Any]]:
587
+ head, *tail = parts
588
+ with self._locked():
589
+ if head in self._nodes:
590
+ if tail:
591
+ raise KeyError(f"{path!r}: {head!r} is a node, not a subgraph")
592
+ return (self, head, self._nodes[head])
593
+ if head in self._mounts:
594
+ if not tail:
595
+ raise KeyError(f"{path!r} names a subgraph, not a node")
596
+ child = self._mounts[head]
597
+ else:
598
+ raise KeyError(path)
599
+ return child._resolve_endpoint_parts_unlocked(tail, path)
600
+
601
+ def _graph_reachable(self, target: Graph) -> bool:
602
+ if self is target:
603
+ return True
604
+ with self._locked():
605
+ children = list(self._mounts.values())
606
+ return any(c._graph_reachable(target) for c in children)
607
+
608
+ def signal(
609
+ self,
610
+ messages: Messages,
611
+ *,
612
+ actor: Any | None = None,
613
+ internal: bool = False,
614
+ ) -> None:
615
+ """Deliver messages to every registered node, meta companion, and mounted subgraph.
616
+
617
+ When a node has a guard, it is checked with action ``"signal"``; nodes that
618
+ reject it are silently skipped.
619
+
620
+ Args:
621
+ messages: The :class:`~graphrefly.core.protocol.Messages` to broadcast.
622
+ actor: Optional actor context checked against each node's guard.
623
+
624
+ Example:
625
+ ```python
626
+ from graphrefly import Graph, state
627
+ from graphrefly.core.protocol import MessageType
628
+ g = Graph("g")
629
+ g.add("x", state(0))
630
+ g.signal([(MessageType.INVALIDATE,)])
631
+ ```
632
+ """
633
+ _signal_graph(self, messages, set(), normalize_actor(actor), "", internal=internal)
634
+
635
+ def destroy(self) -> None:
636
+ """Teardown all nodes and clear every registry in this graph and its mounts.
637
+
638
+ Sends ``[[TEARDOWN]]`` to every node in the same visitation order as
639
+ :meth:`signal` (GRAPHREFLY-SPEC §3.7), then recursively clears mounted subgraphs.
640
+
641
+ Example:
642
+ ```python
643
+ from graphrefly import Graph, state
644
+ g = Graph("g")
645
+ g.add("x", state(0))
646
+ g.destroy()
647
+ ```
648
+ """
649
+ _signal_graph(
650
+ self,
651
+ [(MessageType.TEARDOWN,)],
652
+ set(),
653
+ normalize_actor(None),
654
+ "",
655
+ internal=True,
656
+ )
657
+ for dispose in list(self._auto_checkpoint_disposers):
658
+ with suppress(Exception):
659
+ dispose()
660
+ self._auto_checkpoint_disposers.clear()
661
+ _clear_graph_registry(self)
662
+
663
+ def snapshot(self) -> dict[str, Any]:
664
+ """Serialize graph structure and current node values to a JSON-friendly dict (§3.8).
665
+
666
+ Wraps :meth:`describe` output in a versioned envelope required by
667
+ :meth:`restore` / :meth:`from_snapshot`. The format is stable for
668
+ :meth:`to_json` determinism.
669
+
670
+ Returns:
671
+ A ``dict`` with keys ``version``, ``name``, ``nodes``, ``edges``,
672
+ and ``subgraphs``.
673
+
674
+ Example:
675
+ ```python
676
+ from graphrefly import Graph, state
677
+ g = Graph("g")
678
+ g.add("x", state(42))
679
+ snap = g.snapshot()
680
+ assert snap["version"] == 1
681
+ ```
682
+ """
683
+ body = self.describe()
684
+ nodes_sorted = dict(sorted(body["nodes"].items()))
685
+ subgraphs_sorted = sorted(body["subgraphs"])
686
+ return {
687
+ "version": GRAPH_SNAPSHOT_VERSION,
688
+ "name": body["name"],
689
+ "nodes": nodes_sorted,
690
+ "edges": body["edges"],
691
+ "subgraphs": subgraphs_sorted,
692
+ }
693
+
694
+ def to_json(self) -> str:
695
+ """Return deterministic JSON text for the current :meth:`snapshot` (§3.8).
696
+
697
+ Produces compact, sorted-key JSON with a trailing newline (suitable for
698
+ version control). Raises :exc:`TypeError` when a node value is not
699
+ JSON-serializable.
700
+
701
+ Returns:
702
+ A compact JSON ``str`` ending with a newline.
703
+
704
+ Example:
705
+ ```python
706
+ from graphrefly import Graph, state
707
+ g = Graph("g")
708
+ g.add("x", state(0))
709
+ assert g.to_json().startswith("{")
710
+ ```
711
+ """
712
+ return (
713
+ json.dumps(
714
+ self.snapshot(),
715
+ ensure_ascii=False,
716
+ sort_keys=True,
717
+ separators=(",", ":"),
718
+ )
719
+ + "\n"
720
+ )
721
+
722
+ def auto_checkpoint(
723
+ self,
724
+ adapter: Any,
725
+ *,
726
+ debounce_ms: int = 500,
727
+ compact_every: int = 10,
728
+ filter: Callable[[str, dict[str, Any]], bool] | None = None,
729
+ on_error: Callable[[Exception], None] | None = None,
730
+ ) -> GraphAutoCheckpointHandle:
731
+ """Arm debounced reactive persistence via graph-wide observe stream.
732
+
733
+ Trigger gate uses :func:`message_tier`: only message batches containing
734
+ tier >= 2 tuples schedule a checkpoint.
735
+ """
736
+
737
+ lock = threading.Lock()
738
+ timer: threading.Timer | None = None
739
+ seq = 0
740
+ pending = False
741
+ last_describe: dict[str, Any] | None = None
742
+ debounce_s = max(0.0, float(debounce_ms) / 1000.0)
743
+ compact_every = max(1, int(compact_every))
744
+
745
+ def flush() -> None:
746
+ nonlocal timer, seq, pending, last_describe
747
+ with lock:
748
+ timer = None
749
+ if not pending:
750
+ return
751
+ pending = False
752
+ try:
753
+ described = self.describe()
754
+ snapshot = {**described, "version": GRAPH_SNAPSHOT_VERSION}
755
+ seq += 1
756
+ if last_describe is None or seq % compact_every == 0:
757
+ adapter.save({"mode": "full", "snapshot": snapshot, "seq": seq})
758
+ else:
759
+ diff = Graph.diff(last_describe, described)
760
+ adapter.save(
761
+ {"mode": "diff", "diff": diff.__dict__, "snapshot": snapshot, "seq": seq}
762
+ )
763
+ last_describe = described
764
+ except Exception as exc:
765
+ if on_error is not None:
766
+ on_error(exc)
767
+
768
+ def schedule() -> None:
769
+ nonlocal timer, pending
770
+ with lock:
771
+ pending = True
772
+ if timer is not None:
773
+ timer.cancel()
774
+ timer = threading.Timer(debounce_s, flush)
775
+ timer.daemon = True
776
+ timer.start()
777
+
778
+ def on_msgs(path: str, msgs: Messages) -> None:
779
+ if not any(message_tier(m[0]) >= 2 for m in msgs):
780
+ return
781
+ if filter is not None:
782
+ node_desc = self.describe()["nodes"].get(path)
783
+ if not isinstance(node_desc, dict) or not filter(path, node_desc):
784
+ return
785
+ schedule()
786
+
787
+ observe_stream = self.observe()
788
+ if not isinstance(observe_stream, GraphObserveSource):
789
+ raise TypeError("auto_checkpoint expected GraphObserveSource from observe()")
790
+ unsub = observe_stream.subscribe(on_msgs)
791
+
792
+ def dispose() -> None:
793
+ nonlocal timer
794
+ unsub()
795
+ with lock:
796
+ if timer is not None:
797
+ timer.cancel()
798
+ timer = None
799
+ self._auto_checkpoint_disposers.discard(dispose)
800
+
801
+ self._auto_checkpoint_disposers.add(dispose)
802
+ return GraphAutoCheckpointHandle(dispose=dispose)
803
+
804
+ def restore(self, data: dict[str, Any], *, only: str | list[str] | None = None) -> None:
805
+ """Apply ``value`` fields from a prior :meth:`snapshot` onto this graph (§3.8).
806
+
807
+ Only ``state`` and ``producer`` entries with a ``value`` key are written;
808
+ derived/operator nodes recompute from the restored sources.
809
+ Raises :exc:`ValueError` on snapshot version mismatch.
810
+
811
+ Args:
812
+ data: A snapshot dict previously produced by :meth:`snapshot`.
813
+
814
+ Example:
815
+ ```python
816
+ from graphrefly import Graph, state
817
+ g = Graph("g")
818
+ x = state(0)
819
+ g.add("x", x)
820
+ snap = g.snapshot()
821
+ x.down([("DATA", 99)])
822
+ g.restore(snap)
823
+ assert g.get("x") == 0
824
+ ```
825
+ """
826
+ _parse_snapshot_envelope(data)
827
+ if data["name"] != self._name:
828
+ msg = (
829
+ f'Graph "{self._name}": restore snapshot name '
830
+ f'"{data["name"]}" does not match this graph'
831
+ )
832
+ raise ValueError(msg)
833
+ only_patterns = None if only is None else ([only] if isinstance(only, str) else list(only))
834
+ for path in sorted(data["nodes"]):
835
+ if only_patterns is not None and not any(
836
+ fnmatch.fnmatchcase(path, p) for p in only_patterns
837
+ ):
838
+ continue
839
+ spec = data["nodes"][path]
840
+ if not isinstance(spec, dict) or "value" not in spec:
841
+ continue
842
+ ntype = spec.get("type")
843
+ if ntype in ("derived", "operator", "effect"):
844
+ continue
845
+ with contextlib.suppress(KeyError, ValueError):
846
+ self.set(path, spec["value"])
847
+
848
+ @classmethod
849
+ def from_snapshot(
850
+ cls,
851
+ data: dict[str, Any],
852
+ build: Any | None = None,
853
+ ) -> Graph:
854
+ """Build a new graph from :meth:`snapshot` data (§3.8).
855
+
856
+ When *build* is provided it is called with the empty graph first, letting
857
+ callers register derived nodes before :meth:`restore` is applied. State
858
+ nodes not pre-registered are created automatically.
859
+
860
+ Args:
861
+ data: A snapshot dict produced by :meth:`snapshot`.
862
+ build: Optional callable ``(graph) -> None`` to pre-register derived nodes.
863
+
864
+ Returns:
865
+ A new :class:`Graph` with state nodes restored from ``data``.
866
+
867
+ Example:
868
+ ```python
869
+ from graphrefly import Graph, state
870
+ g = Graph("g")
871
+ g.add("x", state(42))
872
+ snap = g.snapshot()
873
+ g2 = Graph.from_snapshot(snap)
874
+ assert g2.get("x") == 42
875
+ ```
876
+ """
877
+ _parse_snapshot_envelope(data)
878
+ root = cls(data["name"])
879
+ if build is not None:
880
+ build(root)
881
+ root.restore(data)
882
+ return root
883
+ for q in sorted(data["subgraphs"], key=lambda p: (p.count(PATH_SEP), p)):
884
+ _ensure_qualified_mount(root, q)
885
+ primary_paths = [
886
+ p
887
+ for p in sorted(data["nodes"])
888
+ if not _path_has_meta_segment(p) and isinstance(data["nodes"][p], dict)
889
+ ]
890
+ pending: dict[str, dict[str, Any]] = {p: data["nodes"][p] for p in primary_paths}
891
+ created: dict[str, NodeImpl[Any]] = {}
892
+ progressed = True
893
+ while pending and progressed:
894
+ progressed = False
895
+ for path in list(sorted(pending)):
896
+ spec = pending[path]
897
+ deps = spec.get("deps")
898
+ dep_paths = (
899
+ [d for d in deps if isinstance(d, str)] if isinstance(deps, list) else []
900
+ )
901
+ if not all(dep in created for dep in dep_paths):
902
+ continue
903
+ owner, local = _owner_graph_and_local(root, path)
904
+ meta_plain = spec.get("meta")
905
+ meta_kw: dict[str, Any] = dict(meta_plain) if isinstance(meta_plain, dict) else {}
906
+ ntype = spec.get("type", "state")
907
+ if ntype == "state":
908
+ new_node = state(spec.get("value"), meta=meta_kw)
909
+ else:
910
+ factory = cls._factory_for_path(path)
911
+ if factory is None:
912
+ continue
913
+ new_node = factory(
914
+ local,
915
+ {
916
+ "path": path,
917
+ "type": ntype,
918
+ "value": spec.get("value"),
919
+ "meta": meta_kw,
920
+ "deps": dep_paths,
921
+ "resolved_deps": [created[d] for d in dep_paths],
922
+ },
923
+ )
924
+ with owner._locked():
925
+ if local in owner._nodes or local in owner._mounts:
926
+ msg = f"snapshot path {path!r} collides with an existing name"
927
+ raise ValueError(msg)
928
+ owner.add(local, new_node)
929
+ created[path] = new_node
930
+ pending.pop(path, None)
931
+ progressed = True
932
+ if pending:
933
+ unresolved = ", ".join(sorted(pending))
934
+ raise ValueError(
935
+ "Graph.from_snapshot could not reconstruct nodes without build callback: "
936
+ f"{unresolved}. Register factories with Graph.register_factory(pattern, factory)."
937
+ )
938
+ for edge in data["edges"]:
939
+ if not isinstance(edge, dict):
940
+ continue
941
+ ef = edge.get("from")
942
+ et = edge.get("to")
943
+ if not isinstance(ef, str) or not isinstance(et, str):
944
+ continue
945
+ with suppress(Exception):
946
+ root.connect(ef, et)
947
+ root.restore(data)
948
+ return root
949
+
950
+ def describe(
951
+ self,
952
+ *,
953
+ actor: Any = _DESCRIBE_UNSCOPED,
954
+ filter: dict[str, Any] | Callable[..., bool] | None = None,
955
+ ) -> dict[str, Any]:
956
+ """Static structure snapshot (GRAPHREFLY-SPEC §3.6, Appendix B).
957
+
958
+ ``nodes`` keys are qualified paths (including ``::__meta__::`` for companions).
959
+ ``edges`` use the same qualified naming. ``subgraphs`` lists every mount point
960
+ in the hierarchy with paths from this graph's root.
961
+
962
+ With ``actor=...``, only nodes the actor may observe are included; **edges** whose
963
+ ``from`` or ``to`` is hidden are dropped (roadmap 1.5 D). **Subgraphs** are kept
964
+ only if at least one visible node path lies under that mount prefix.
965
+ Omitting ``actor`` preserves the previous unfiltered behavior.
966
+
967
+ With ``filter=...``, further restrict the output:
968
+
969
+ - If ``filter`` is a ``dict``, each key is matched against node description fields
970
+ (e.g. ``{"type": "state"}`` keeps only nodes whose ``type`` is ``"state"``).
971
+ - If ``filter`` is a callable, it receives ``(path, node_desc)`` and must return
972
+ ``True`` to include the node.
973
+
974
+ Args:
975
+ actor: Optional actor for guard-based filtering.
976
+ filter: Optional dict or predicate to filter nodes in the output.
977
+ """
978
+ targets = _collect_observe_targets(self, "")
979
+ paths_by_id = {id(n): p for p, n in targets}
980
+ nodes_out = {p: _node_describe_for_graph(n, paths_by_id) for p, n in targets}
981
+ edges_out = _collect_edges_qualified(self, "")
982
+ edges_out.sort(key=lambda e: (e["from"], e["to"]))
983
+ subgraphs_out = _collect_subgraphs_qualified(self, "")
984
+ nodes_map: dict[str, Any] = nodes_out
985
+ raw: dict[str, Any] = {
986
+ "name": self._name,
987
+ "nodes": nodes_map,
988
+ "edges": edges_out,
989
+ "subgraphs": subgraphs_out,
990
+ }
991
+ if actor is not _DESCRIBE_UNSCOPED:
992
+ a = normalize_actor(actor)
993
+ visible = {p for p, n in targets if _node_allows_observe(n, a)}
994
+ nodes_map = {k: v for k, v in nodes_map.items() if k in visible}
995
+ edges_out = [e for e in edges_out if e["from"] in visible and e["to"] in visible]
996
+ sep = PATH_SEP
997
+ subgraphs_out = [
998
+ s
999
+ for s in subgraphs_out
1000
+ if any(p == s or p.startswith(f"{s}{sep}") for p in visible)
1001
+ ]
1002
+ raw = {**raw, "nodes": nodes_map, "edges": edges_out, "subgraphs": subgraphs_out}
1003
+ if filter is not None:
1004
+ if callable(filter):
1005
+ nodes_map = {p: d for p, d in raw["nodes"].items() if filter(p, d)}
1006
+ elif isinstance(filter, dict):
1007
+
1008
+ def _match(desc: dict[str, Any]) -> bool:
1009
+ for k, v in filter.items():
1010
+ if k in ("deps_includes", "depsIncludes"):
1011
+ deps = desc.get("deps")
1012
+ if not isinstance(deps, list) or str(v) not in deps:
1013
+ return False
1014
+ continue
1015
+ if k in ("meta_has", "metaHas"):
1016
+ meta = desc.get("meta")
1017
+ if not isinstance(meta, dict) or str(v) not in meta:
1018
+ return False
1019
+ continue
1020
+ if desc.get(k) != v:
1021
+ return False
1022
+ return True
1023
+
1024
+ nodes_map = {p: d for p, d in raw["nodes"].items() if _match(d)}
1025
+ visible_after_filter = set(nodes_map.keys())
1026
+ edges_out = [
1027
+ e
1028
+ for e in raw["edges"]
1029
+ if e["from"] in visible_after_filter and e["to"] in visible_after_filter
1030
+ ]
1031
+ sep = PATH_SEP
1032
+ subgraphs_out = [
1033
+ s
1034
+ for s in raw["subgraphs"]
1035
+ if any(p == s or p.startswith(f"{s}{sep}") for p in visible_after_filter)
1036
+ ]
1037
+ raw = {**raw, "nodes": nodes_map, "edges": edges_out, "subgraphs": subgraphs_out}
1038
+ return raw
1039
+
1040
+ def observe(
1041
+ self,
1042
+ path: str | None = None,
1043
+ *,
1044
+ actor: Any | None = None,
1045
+ structured: bool = False,
1046
+ timeline: bool = False,
1047
+ causal: bool = False,
1048
+ derived: bool = False,
1049
+ ) -> GraphObserveSource | ObserveResult:
1050
+ """Live message stream for one node (and its path) or the whole graph (§3.6).
1051
+
1052
+ Use :meth:`GraphObserveSource.subscribe` to attach a sink. Graph-wide mode
1053
+ prefixes each batch with the node's qualified path.
1054
+
1055
+ Nodes with a ``guard`` require ``actor`` such that ``guard(actor, "observe")`` is
1056
+ true (default actor is system — :func:`~graphrefly.core.guard.system_actor`).
1057
+
1058
+ When ``structured=True`` (or ``timeline`` / ``causal`` / ``derived``),
1059
+ returns an :class:`ObserveResult` that accumulates
1060
+ events, tracks counts, and provides a ``dispose()`` method.
1061
+
1062
+ Args:
1063
+ path: Optional node path (``None`` for graph-wide).
1064
+ actor: Optional actor for guard checking.
1065
+ structured: If ``True``, return an :class:`ObserveResult` instead of
1066
+ a raw :class:`GraphObserveSource`.
1067
+ timeline: Include ``timestamp_ns`` and ``in_batch`` on events.
1068
+ causal: Include trigger dep info (single-path derived/compute nodes).
1069
+ derived: Include per-evaluation dep snapshots (single-path derived/compute nodes).
1070
+ """
1071
+ source = GraphObserveSource(self, path, actor)
1072
+ wants_structured = structured or timeline or causal or derived
1073
+ if not wants_structured or not self.inspector_enabled:
1074
+ return source
1075
+ result = ObserveResult()
1076
+ last_trigger_dep_index: int | None = None
1077
+ last_run_dep_values: list[Any] | None = None
1078
+ detach_hook: Callable[[], None] | None = None
1079
+
1080
+ def _base_event(
1081
+ evt_type: str, *, data: Any = None, event_path: str | None = None
1082
+ ) -> dict[str, Any]:
1083
+ entry: dict[str, Any] = {"type": evt_type}
1084
+ if event_path is not None:
1085
+ entry["path"] = event_path
1086
+ if data is not None:
1087
+ entry["data"] = data
1088
+ if timeline:
1089
+ entry["timestamp_ns"] = monotonic_ns()
1090
+ entry["in_batch"] = is_batching()
1091
+ return entry
1092
+
1093
+ if (causal or derived) and path is not None:
1094
+ n = self.node(path)
1095
+ if isinstance(n, NodeImpl):
1096
+
1097
+ def _hook(event: dict[str, Any]) -> None:
1098
+ nonlocal last_trigger_dep_index, last_run_dep_values
1099
+ kind = event.get("kind")
1100
+ if kind == "dep_message":
1101
+ idx = event.get("dep_index")
1102
+ last_trigger_dep_index = int(idx) if isinstance(idx, int) else None
1103
+ return
1104
+ if kind == "run":
1105
+ dep_vals_raw = event.get("dep_values")
1106
+ dep_vals = (
1107
+ list(dep_vals_raw)
1108
+ if isinstance(dep_vals_raw, list)
1109
+ else list(dep_vals_raw or [])
1110
+ )
1111
+ last_run_dep_values = dep_vals
1112
+ if derived:
1113
+ de = _base_event("derived")
1114
+ de["dep_values"] = dep_vals
1115
+ result.events.append(de)
1116
+
1117
+ detach_hook = n._set_inspector_hook(_hook)
1118
+
1119
+ if path is not None:
1120
+
1121
+ def _sink(msgs: Messages) -> None:
1122
+ for m in msgs:
1123
+ t = m[0]
1124
+ if t is MessageType.DATA:
1125
+ val = m[1] if len(m) > 1 else None
1126
+ result.values[path] = val
1127
+ event = _base_event("data", data=val)
1128
+ if causal and last_run_dep_values is not None:
1129
+ event["trigger_dep_index"] = last_trigger_dep_index
1130
+ trigger_dep = None
1131
+ if (
1132
+ isinstance(last_trigger_dep_index, int)
1133
+ and last_trigger_dep_index >= 0
1134
+ and isinstance(n, NodeImpl)
1135
+ and last_trigger_dep_index < len(n._deps)
1136
+ ):
1137
+ trigger_dep = n._deps[last_trigger_dep_index]
1138
+ event["trigger_dep_name"] = trigger_dep.name
1139
+ # V0 backfill: include triggering dep's version (§6.0b).
1140
+ tv = trigger_dep.v if trigger_dep is not None else None
1141
+ if tv is not None:
1142
+ event["trigger_version"] = {"id": tv.id, "version": tv.version}
1143
+ event["dep_values"] = list(last_run_dep_values)
1144
+ result.events.append(event)
1145
+ elif t is MessageType.DIRTY:
1146
+ result.dirty_count += 1
1147
+ result.events.append(_base_event("dirty"))
1148
+ elif t is MessageType.RESOLVED:
1149
+ result.resolved_count += 1
1150
+ event = _base_event("resolved")
1151
+ if causal and last_run_dep_values is not None:
1152
+ event["trigger_dep_index"] = last_trigger_dep_index
1153
+ trigger_dep = None
1154
+ if (
1155
+ isinstance(last_trigger_dep_index, int)
1156
+ and last_trigger_dep_index >= 0
1157
+ and isinstance(n, NodeImpl)
1158
+ and last_trigger_dep_index < len(n._deps)
1159
+ ):
1160
+ trigger_dep = n._deps[last_trigger_dep_index]
1161
+ event["trigger_dep_name"] = trigger_dep.name
1162
+ # V0 backfill: include triggering dep's version (§6.0b).
1163
+ tv = trigger_dep.v if trigger_dep is not None else None
1164
+ if tv is not None:
1165
+ event["trigger_version"] = {"id": tv.id, "version": tv.version}
1166
+ event["dep_values"] = list(last_run_dep_values)
1167
+ result.events.append(event)
1168
+ elif t is MessageType.COMPLETE:
1169
+ if not result.errored:
1170
+ result.completed_cleanly = True
1171
+ result.events.append(_base_event("complete"))
1172
+ elif t is MessageType.ERROR:
1173
+ result.errored = True
1174
+ result.events.append(
1175
+ _base_event("error", data=m[1] if len(m) > 1 else None)
1176
+ )
1177
+
1178
+ unsub = source.subscribe(_sink)
1179
+ else:
1180
+
1181
+ def _graph_sink(qpath: str, msgs: Messages) -> None:
1182
+ for m in msgs:
1183
+ t = m[0]
1184
+ if t is MessageType.DATA:
1185
+ val = m[1] if len(m) > 1 else None
1186
+ result.values[qpath] = val
1187
+ result.events.append(_base_event("data", data=val, event_path=qpath))
1188
+ elif t is MessageType.DIRTY:
1189
+ result.dirty_count += 1
1190
+ result.events.append(_base_event("dirty", event_path=qpath))
1191
+ elif t is MessageType.RESOLVED:
1192
+ result.resolved_count += 1
1193
+ result.events.append(_base_event("resolved", event_path=qpath))
1194
+ elif t is MessageType.COMPLETE:
1195
+ if not result.errored:
1196
+ result.completed_cleanly = True
1197
+ result.events.append(_base_event("complete", event_path=qpath))
1198
+ elif t is MessageType.ERROR:
1199
+ result.errored = True
1200
+ result.events.append(
1201
+ _base_event(
1202
+ "error",
1203
+ data=m[1] if len(m) > 1 else None,
1204
+ event_path=qpath,
1205
+ )
1206
+ )
1207
+
1208
+ unsub = source.subscribe(_graph_sink)
1209
+
1210
+ def _dispose() -> None:
1211
+ unsub()
1212
+ if detach_hook is not None:
1213
+ detach_hook()
1214
+
1215
+ result._dispose_fn = _dispose
1216
+ return result
1217
+
1218
+ def spy(
1219
+ self,
1220
+ path: str | None = None,
1221
+ *,
1222
+ actor: Any | None = None,
1223
+ include_types: list[str] | tuple[str, ...] | None = None,
1224
+ exclude_types: list[str] | tuple[str, ...] | None = None,
1225
+ theme: str | dict[str, str] | None = "ansi",
1226
+ format: str = "pretty",
1227
+ logger: Callable[[str, dict[str, Any]], None] | None = None,
1228
+ timeline: bool = True,
1229
+ causal: bool = False,
1230
+ derived: bool = False,
1231
+ ) -> SpyHandle:
1232
+ """Attach a live debugger that logs protocol events as they arrive.
1233
+
1234
+ Wraps :meth:`observe` and emits each formatted event via ``output``
1235
+ (default ``print``). Supports one-node and graph-wide modes, event
1236
+ filtering, and ANSI color themes. Returns a :class:`SpyHandle` with the
1237
+ accumulated :class:`ObserveResult` and a ``dispose()`` method.
1238
+
1239
+ Args:
1240
+ path: Node path to observe (``None`` = entire graph).
1241
+ output: Callable receiving each formatted log line (default ``print``).
1242
+ theme: ANSI color theme: ``"ansi"`` (default), ``"none"``, or a custom
1243
+ ``dict`` of ANSI codes keyed by event type.
1244
+ filter: Set of message type labels to include (``None`` = all types).
1245
+ actor: Optional actor context for guarded nodes.
1246
+
1247
+ Returns:
1248
+ A :class:`SpyHandle` wrapping the live :class:`ObserveResult`.
1249
+
1250
+ Example:
1251
+ ```python
1252
+ from graphrefly import Graph, state
1253
+ g = Graph("g")
1254
+ g.add("x", state(0))
1255
+ handle = g.spy("x")
1256
+ g.set("x", 1)
1257
+ handle.dispose()
1258
+ ```
1259
+ """
1260
+ include = set(include_types) if include_types is not None else None
1261
+ exclude = set(exclude_types or [])
1262
+ colors = _resolve_spy_theme(theme)
1263
+ sink = logger or (lambda line, _event: print(line))
1264
+
1265
+ def should_log(event_type: str) -> bool:
1266
+ if include is not None and event_type not in include:
1267
+ return False
1268
+ return event_type not in exclude
1269
+
1270
+ def render_event(event: dict[str, Any]) -> str:
1271
+ if format == "json":
1272
+ try:
1273
+ return json.dumps(event)
1274
+ except Exception:
1275
+ fallback = {
1276
+ "type": event.get("type"),
1277
+ "path": event.get("path"),
1278
+ "data": "[unserializable]",
1279
+ }
1280
+ return json.dumps(fallback)
1281
+ event_type = str(event.get("type") or "event")
1282
+ color = colors.get(event_type, "")
1283
+ path_part = ""
1284
+ if "path" in event:
1285
+ path_part = f"{colors['path']}{event['path']}{colors['reset']} "
1286
+ has_data = "data" in event and event["data"] is not None
1287
+ data_part = f" {_describe_data(event['data'])}" if has_data else ""
1288
+ trigger = ""
1289
+ if event.get("trigger_dep_name") is not None:
1290
+ trigger = f" <- {event['trigger_dep_name']}"
1291
+ elif event.get("trigger_dep_index") is not None:
1292
+ trigger = f" <- #{event['trigger_dep_index']}"
1293
+ batch_part = " [batch]" if event.get("in_batch") else ""
1294
+ return (
1295
+ f"{path_part}{color}{event_type.upper()}{colors['reset']}"
1296
+ f"{data_part}{trigger}{batch_part}"
1297
+ )
1298
+
1299
+ # --- Helper: build an event dict from a raw message and accumulate into result ---
1300
+ def _push_event(result: ObserveResult, event_path: str | None, m: tuple[Any, ...]) -> None:
1301
+ event_type = _message_type_label(m[0])
1302
+ if event_type is None:
1303
+ return
1304
+ event: dict[str, Any] = {"type": event_type}
1305
+ if event_path is not None:
1306
+ event["path"] = event_path
1307
+ if timeline:
1308
+ event["timestamp_ns"] = monotonic_ns()
1309
+ event["in_batch"] = is_batching()
1310
+ if event_type in ("data", "error"):
1311
+ event["data"] = m[1] if len(m) > 1 else None
1312
+ if event_type == "data" and event_path is not None:
1313
+ result.values[event_path] = event.get("data")
1314
+ elif event_type == "dirty":
1315
+ result.dirty_count += 1
1316
+ elif event_type == "resolved":
1317
+ result.resolved_count += 1
1318
+ elif event_type == "complete" and not result.errored:
1319
+ result.completed_cleanly = True
1320
+ elif event_type == "error":
1321
+ result.errored = True
1322
+ result.events.append(event)
1323
+ if should_log(event_type):
1324
+ sink(render_event(event), event)
1325
+
1326
+ # --- Inspector-disabled fallback: manual accumulator via raw observe ---
1327
+ if not self.inspector_enabled:
1328
+ result = ObserveResult()
1329
+ unsub: Callable[[], None]
1330
+
1331
+ if path is not None:
1332
+ stream = self.observe(path, actor=actor)
1333
+ if not isinstance(stream, GraphObserveSource):
1334
+ msg = "spy expected GraphObserveSource in raw mode"
1335
+ raise TypeError(msg)
1336
+
1337
+ def _on_path_msgs(msgs: Any) -> None:
1338
+ for m in msgs:
1339
+ _push_event(result, path, m)
1340
+
1341
+ unsub = stream.subscribe(_on_path_msgs)
1342
+ else:
1343
+ stream = self.observe(actor=actor)
1344
+ if not isinstance(stream, GraphObserveSource):
1345
+ msg = "spy expected GraphObserveSource in raw mode"
1346
+ raise TypeError(msg)
1347
+
1348
+ def _on_qpath_msgs(qpath: str, msgs: Any) -> None:
1349
+ for m in msgs:
1350
+ _push_event(result, qpath, m)
1351
+
1352
+ unsub = stream.subscribe(_on_qpath_msgs)
1353
+
1354
+ result._dispose_fn = unsub
1355
+ return SpyHandle(result=result)
1356
+
1357
+ # --- Inspector-enabled path: use structured observe + flush loop ---
1358
+ structured_candidate = self.observe(
1359
+ path,
1360
+ actor=actor,
1361
+ structured=True,
1362
+ timeline=timeline,
1363
+ causal=causal,
1364
+ derived=derived,
1365
+ )
1366
+ result = (
1367
+ structured_candidate
1368
+ if isinstance(structured_candidate, ObserveResult)
1369
+ else ObserveResult()
1370
+ )
1371
+ structured_cleanup = (
1372
+ structured_candidate._dispose_fn
1373
+ if isinstance(structured_candidate, ObserveResult)
1374
+ else None
1375
+ )
1376
+
1377
+ cursor = 0
1378
+
1379
+ def flush_new_events() -> None:
1380
+ nonlocal cursor
1381
+ next_events = result.events[cursor:]
1382
+ cursor = len(result.events)
1383
+ for event in next_events:
1384
+ event_type = str(event.get("type") or "")
1385
+ if not should_log(event_type):
1386
+ continue
1387
+ sink(render_event(event), event)
1388
+
1389
+ if path is not None:
1390
+
1391
+ def on_messages(msgs: Messages) -> None:
1392
+ for m in msgs:
1393
+ flush_new_events()
1394
+ if isinstance(structured_candidate, ObserveResult):
1395
+ continue
1396
+ _push_event(result, path, m)
1397
+
1398
+ stream_raw = self.observe(path, actor=actor)
1399
+ if not isinstance(stream_raw, GraphObserveSource):
1400
+ msg = "spy expected GraphObserveSource in raw mode"
1401
+ raise TypeError(msg)
1402
+ unsub = stream_raw.subscribe(on_messages)
1403
+ else:
1404
+
1405
+ def on_graph_messages(qpath: str, msgs: Messages) -> None:
1406
+ for m in msgs:
1407
+ flush_new_events()
1408
+ if isinstance(structured_candidate, ObserveResult):
1409
+ continue
1410
+ _push_event(result, qpath, m)
1411
+
1412
+ stream_raw = self.observe(actor=actor)
1413
+ if not isinstance(stream_raw, GraphObserveSource):
1414
+ msg = "spy expected GraphObserveSource in raw mode"
1415
+ raise TypeError(msg)
1416
+ unsub = stream_raw.subscribe(on_graph_messages)
1417
+
1418
+ def _dispose() -> None:
1419
+ unsub()
1420
+ flush_new_events()
1421
+ if structured_cleanup is not None:
1422
+ structured_cleanup()
1423
+
1424
+ result._dispose_fn = _dispose
1425
+ return SpyHandle(result=result)
1426
+
1427
+ def dump_graph(
1428
+ self,
1429
+ *,
1430
+ actor: Any | None = None,
1431
+ filter: Any = None,
1432
+ format: str = "pretty",
1433
+ indent: int = 2,
1434
+ include_edges: bool = True,
1435
+ include_subgraphs: bool = True,
1436
+ logger: Callable[[str], None] | None = None,
1437
+ ) -> str:
1438
+ """Return a CLI/debug-friendly text dump of the graph topology.
1439
+
1440
+ Built on :meth:`describe`; formats node names, types, statuses, and edges.
1441
+
1442
+ Args:
1443
+ actor: Optional actor context for scoping guarded nodes.
1444
+
1445
+ Returns:
1446
+ A multi-line ``str`` summary of the graph.
1447
+
1448
+ Example:
1449
+ ```python
1450
+ from graphrefly import Graph, state
1451
+ g = Graph("g")
1452
+ g.add("x", state(1))
1453
+ print(g.dump_graph())
1454
+ ```
1455
+ """
1456
+ described = self.describe(actor=actor, filter=filter)
1457
+ if format == "json":
1458
+ payload = {
1459
+ "name": described["name"],
1460
+ "nodes": described["nodes"],
1461
+ "edges": described["edges"] if include_edges else [],
1462
+ "subgraphs": described["subgraphs"] if include_subgraphs else [],
1463
+ }
1464
+ text = json.dumps(payload, indent=indent, sort_keys=True)
1465
+ if logger is not None:
1466
+ logger(text)
1467
+ return text
1468
+
1469
+ lines: list[str] = [f"Graph {described['name']}", "Nodes:"]
1470
+ for node_path in sorted(described["nodes"]):
1471
+ entry = described["nodes"][node_path]
1472
+ lines.append(
1473
+ f"- {node_path} ({entry.get('type')}/{entry.get('status')}): "
1474
+ f"{_describe_data(entry.get('value'))}"
1475
+ )
1476
+ if include_edges:
1477
+ lines.append("Edges:")
1478
+ for edge in described["edges"]:
1479
+ lines.append(f"- {edge['from']} -> {edge['to']}")
1480
+ if include_subgraphs:
1481
+ lines.append("Subgraphs:")
1482
+ for subgraph in described["subgraphs"]:
1483
+ lines.append(f"- {subgraph}")
1484
+ text = "\n".join(lines)
1485
+ if logger is not None:
1486
+ logger(text)
1487
+ return text
1488
+
1489
+ def node(self, path: str) -> NodeImpl[Any]:
1490
+ """Return the node for a local name or a ``::``-qualified path.
1491
+
1492
+ Args:
1493
+ path: Local name or fully-qualified path.
1494
+
1495
+ Returns:
1496
+ The :class:`~graphrefly.core.node.NodeImpl` at that path.
1497
+
1498
+ Example:
1499
+ ```python
1500
+ from graphrefly import Graph, state
1501
+ g = Graph("g")
1502
+ g.add("x", state(0))
1503
+ assert g.node("x").get() == 0
1504
+ ```
1505
+ """
1506
+ if PATH_SEP in path:
1507
+ return self.resolve(path)
1508
+ with self._locked():
1509
+ try:
1510
+ return self._nodes[path]
1511
+ except KeyError as e:
1512
+ raise KeyError(path) from e
1513
+
1514
+ def get(self, node_name: str) -> Any:
1515
+ """Return the current cached value of the node at ``node_name``.
1516
+
1517
+ Shorthand for ``graph.node(node_name).get()``. Accepts ``::``-qualified paths.
1518
+
1519
+ Args:
1520
+ node_name: Local name or qualified path.
1521
+
1522
+ Returns:
1523
+ The node's last settled value, or ``None`` if not yet settled.
1524
+
1525
+ Example:
1526
+ ```python
1527
+ from graphrefly import Graph, state
1528
+ g = Graph("g")
1529
+ g.add("x", state(7))
1530
+ assert g.get("x") == 7
1531
+ ```
1532
+ """
1533
+ return self.node(node_name).get()
1534
+
1535
+ def set(
1536
+ self,
1537
+ node_name: str,
1538
+ value: Any,
1539
+ *,
1540
+ actor: Any | None = None,
1541
+ internal: bool = False,
1542
+ ) -> None:
1543
+ """Set the value of a node by pushing a ``DATA`` message.
1544
+
1545
+ Shorthand for ``graph.node(node_name).down([(DATA, value)], actor=actor)``.
1546
+ Accepts ``::``-qualified paths.
1547
+
1548
+ Args:
1549
+ node_name: Local name or qualified path.
1550
+ value: New value to push as ``DATA``.
1551
+ actor: Optional actor context checked against the node's guard.
1552
+
1553
+ Example:
1554
+ ```python
1555
+ from graphrefly import Graph, state
1556
+ g = Graph("g")
1557
+ g.add("x", state(0))
1558
+ g.set("x", 42)
1559
+ assert g.get("x") == 42
1560
+ ```
1561
+ """
1562
+ try:
1563
+ self.node(node_name).down(
1564
+ [(MessageType.DATA, value)],
1565
+ actor=actor,
1566
+ internal=internal,
1567
+ guard_action="write",
1568
+ )
1569
+ except GuardDenied as e:
1570
+ raise GuardDenied(e.actor, node_name, e.action) from e
1571
+
1572
+ def connect(self, from_path: str, to_path: str) -> None:
1573
+ """Record a pure graph wire from ``from_path`` to ``to_path``.
1574
+
1575
+ ``to`` must already list ``from`` as a constructor dependency. This is an
1576
+ idempotent bookkeeping operation; it does not change ``NodeImpl`` dep lists.
1577
+ Both paths accept local names or ``mount::...`` qualified paths.
1578
+
1579
+ Args:
1580
+ from_path: Path of the upstream (source) node.
1581
+ to_path: Path of the downstream (sink) node.
1582
+
1583
+ Example:
1584
+ ```python
1585
+ from graphrefly import Graph, state, node
1586
+ g = Graph("g")
1587
+ x = state(1)
1588
+ y = node([x], lambda deps, _: deps[0])
1589
+ g.add("x", x); g.add("y", y)
1590
+ g.connect("x", "y")
1591
+ ```
1592
+ """
1593
+ if not from_path or not to_path:
1594
+ msg = "connect/disconnect paths must be non-empty"
1595
+ raise ValueError(msg)
1596
+ from_g, from_local, from_n = self._resolve_endpoint(from_path)
1597
+ to_g, to_local, to_n = self._resolve_endpoint(to_path)
1598
+ if from_n is to_n:
1599
+ raise ValueError("cannot connect a node to itself")
1600
+ if not any(d is from_n for d in to_n._deps):
1601
+ raise ValueError(
1602
+ f"connect({from_path!r}, {to_path!r}): target must include the source "
1603
+ "node in its dependency list at construction (pure wire)"
1604
+ )
1605
+ if from_g is to_g:
1606
+ from_g._connect_local(from_local, to_local)
1607
+ else:
1608
+ key = (from_path, to_path)
1609
+ with self._locked():
1610
+ if key in self._edges:
1611
+ return
1612
+ self._edges.add(key)
1613
+
1614
+ def _connect_local(self, from_name: str, to_name: str) -> None:
1615
+ with self._locked():
1616
+ key = (from_name, to_name)
1617
+ if key in self._edges:
1618
+ return
1619
+ try:
1620
+ from_n = self._nodes[from_name]
1621
+ except KeyError as e:
1622
+ raise KeyError(f"unknown node: {from_name!r}") from e
1623
+ try:
1624
+ to_n = self._nodes[to_name]
1625
+ except KeyError as e:
1626
+ raise KeyError(f"unknown node: {to_name!r}") from e
1627
+ if from_n is to_n:
1628
+ raise ValueError("cannot connect a node to itself")
1629
+ if not any(d is from_n for d in to_n._deps):
1630
+ raise ValueError(
1631
+ f"connect({from_name!r}, {to_name!r}): {to_name!r} must include "
1632
+ f"{from_name!r} in its dependency list at construction (pure wire)"
1633
+ )
1634
+ self._edges.add(key)
1635
+
1636
+ def disconnect(self, from_path: str, to_path: str) -> None:
1637
+ """Remove a registered edge between two nodes.
1638
+
1639
+ **Registry-only (§C resolved):** This drops the edge from the graph's edge
1640
+ registry only. It does **not** mutate the target node's constructor-time
1641
+ dependency list, bitmasks, or upstream subscriptions. Message flow follows
1642
+ constructor-time deps, not the edge registry. For runtime dep rewiring, use
1643
+ :func:`~graphrefly.core.dynamic_node.dynamic_node`.
1644
+
1645
+ Raises :exc:`ValueError` if the edge was never registered. Both paths
1646
+ accept ``::``-qualified formats.
1647
+
1648
+ Args:
1649
+ from_path: Path of the upstream node.
1650
+ to_path: Path of the downstream node.
1651
+
1652
+ Example:
1653
+ ```python
1654
+ from graphrefly import Graph, state, node
1655
+ g = Graph("g")
1656
+ x = state(0)
1657
+ y = node([x], lambda deps, _: deps[0])
1658
+ g.add("x", x); g.add("y", y)
1659
+ g.connect("x", "y")
1660
+ g.disconnect("x", "y")
1661
+ ```
1662
+ """
1663
+ if not from_path or not to_path:
1664
+ msg = "connect/disconnect paths must be non-empty"
1665
+ raise ValueError(msg)
1666
+ from_g, from_local, _f = self._resolve_endpoint(from_path)
1667
+ to_g, to_local, _t = self._resolve_endpoint(to_path)
1668
+ if from_g is to_g:
1669
+ from_g._disconnect_local(from_local, to_local)
1670
+ else:
1671
+ key = (from_path, to_path)
1672
+ with self._locked():
1673
+ if key not in self._edges:
1674
+ msg = f'Graph "{self._name}": no registered edge {from_path} → {to_path}'
1675
+ raise ValueError(msg)
1676
+ self._edges.discard(key)
1677
+
1678
+ def _disconnect_local(self, from_name: str, to_name: str) -> None:
1679
+ with self._locked():
1680
+ key = (from_name, to_name)
1681
+ if key not in self._edges:
1682
+ msg = f'Graph "{self._name}": no registered edge {from_name} → {to_name}'
1683
+ raise ValueError(msg)
1684
+ self._edges.discard(key)
1685
+
1686
+ def edges(self) -> frozenset[tuple[str, str]]:
1687
+ """Return all registered ``(from_name, to_name)`` edge pairs (read-only).
1688
+
1689
+ Returns:
1690
+ A ``frozenset`` of ``(from_name, to_name)`` string tuples.
1691
+
1692
+ Example:
1693
+ ```python
1694
+ from graphrefly import Graph, state, node
1695
+ g = Graph("g")
1696
+ x = state(0); y = node([x], lambda d, _: d[0])
1697
+ g.add("x", x); g.add("y", y)
1698
+ g.connect("x", "y")
1699
+ assert ("x", "y") in g.edges()
1700
+ ```
1701
+ """
1702
+ with self._locked():
1703
+ return frozenset(self._edges)
1704
+
1705
+ def to_mermaid(self, *, direction: str = "LR") -> str:
1706
+ """Export current topology as Mermaid flowchart text.
1707
+
1708
+ Uses :meth:`describe` to render qualified node paths and registered edges.
1709
+
1710
+ Args:
1711
+ direction: ``"TD"``, ``"LR"``, ``"BT"``, or ``"RL"`` (default ``"LR"``).
1712
+
1713
+ Returns:
1714
+ Mermaid source string.
1715
+ """
1716
+ direction = _normalize_diagram_direction(direction)
1717
+ described = self.describe()
1718
+ paths = sorted(described["nodes"].keys())
1719
+ ids = {path: f"n{i}" for i, path in enumerate(paths)}
1720
+ lines: list[str] = [f"flowchart {direction}"]
1721
+ for path in paths:
1722
+ lines.append(f' {ids[path]}["{_escape_mermaid_label(path)}"]')
1723
+ drawn: set[tuple[str, str]] = set()
1724
+ for edge in described["edges"]:
1725
+ from_id = ids.get(edge["from"])
1726
+ to_id = ids.get(edge["to"])
1727
+ if from_id is None or to_id is None:
1728
+ continue
1729
+ pair = (edge["from"], edge["to"])
1730
+ if pair not in drawn:
1731
+ drawn.add(pair)
1732
+ lines.append(f" {from_id} --> {to_id}")
1733
+ for path in paths:
1734
+ node_desc = described["nodes"][path]
1735
+ for dep in node_desc.get("deps", []):
1736
+ pair = (dep, path)
1737
+ if pair in drawn:
1738
+ continue
1739
+ from_id = ids.get(dep)
1740
+ to_id = ids.get(path)
1741
+ if from_id is None or to_id is None:
1742
+ continue
1743
+ drawn.add(pair)
1744
+ lines.append(f" {from_id} --> {to_id}")
1745
+ return "\n".join(lines)
1746
+
1747
+ def to_d2(self, *, direction: str = "LR") -> str:
1748
+ """Export current topology as D2 diagram text.
1749
+
1750
+ Uses :meth:`describe` to render qualified node paths and registered edges.
1751
+
1752
+ Args:
1753
+ direction: ``"TD"``, ``"LR"``, ``"BT"``, or ``"RL"`` (default ``"LR"``).
1754
+
1755
+ Returns:
1756
+ D2 source string.
1757
+ """
1758
+ direction = _normalize_diagram_direction(direction)
1759
+ described = self.describe()
1760
+ paths = sorted(described["nodes"].keys())
1761
+ ids = {path: f"n{i}" for i, path in enumerate(paths)}
1762
+ lines: list[str] = [f"direction: {_d2_direction_from_graph_direction(direction)}"]
1763
+ for path in paths:
1764
+ lines.append(f'{ids[path]}: "{_escape_d2_label(path)}"')
1765
+ drawn: set[tuple[str, str]] = set()
1766
+ for edge in described["edges"]:
1767
+ from_id = ids.get(edge["from"])
1768
+ to_id = ids.get(edge["to"])
1769
+ if from_id is None or to_id is None:
1770
+ continue
1771
+ pair = (edge["from"], edge["to"])
1772
+ if pair not in drawn:
1773
+ drawn.add(pair)
1774
+ lines.append(f"{from_id} -> {to_id}")
1775
+ for path in paths:
1776
+ node_desc = described["nodes"][path]
1777
+ for dep in node_desc.get("deps", []):
1778
+ pair = (dep, path)
1779
+ if pair in drawn:
1780
+ continue
1781
+ from_id = ids.get(dep)
1782
+ to_id = ids.get(path)
1783
+ if from_id is None or to_id is None:
1784
+ continue
1785
+ drawn.add(pair)
1786
+ lines.append(f"{from_id} -> {to_id}")
1787
+ return "\n".join(lines)
1788
+
1789
+ def annotate(self, path: str, reason: str) -> None:
1790
+ """Store an annotation for ``path`` and record it in the trace ring buffer.
1791
+
1792
+ Annotations are informational labels attached to node paths for debugging
1793
+ and auditing. Each call also appends a :class:`TraceEntry` to the ring buffer.
1794
+
1795
+ Args:
1796
+ path: Qualified node path.
1797
+ reason: Human-readable annotation text.
1798
+ """
1799
+ if not self.inspector_enabled:
1800
+ return
1801
+ self.resolve(path)
1802
+ with self._locked():
1803
+ self._annotations[path] = reason
1804
+ self._trace_ring.append(
1805
+ TraceEntry(timestamp_ns=monotonic_ns(), path=path, reason=reason)
1806
+ )
1807
+
1808
+ def trace_log(self) -> list[TraceEntry]:
1809
+ """Return a copy of the trace ring buffer (most recent last).
1810
+
1811
+ Returns:
1812
+ A list of :class:`TraceEntry` objects.
1813
+ """
1814
+ if not self.inspector_enabled:
1815
+ return []
1816
+ with self._locked():
1817
+ return list(self._trace_ring)
1818
+
1819
+ @staticmethod
1820
+ def diff(a: dict[str, Any], b: dict[str, Any]) -> GraphDiffResult:
1821
+ """Structural diff of two :meth:`describe` outputs.
1822
+
1823
+ Compares ``nodes``, ``edges``, and ``subgraphs`` between snapshots *a* and *b*.
1824
+
1825
+ Args:
1826
+ a: First describe output (the "before" state).
1827
+ b: Second describe output (the "after" state).
1828
+
1829
+ Returns:
1830
+ A :class:`GraphDiffResult` with added, removed, and changed items.
1831
+ """
1832
+ a_nodes = set(a.get("nodes", {}).keys())
1833
+ b_nodes = set(b.get("nodes", {}).keys())
1834
+ added_nodes = sorted(b_nodes - a_nodes)
1835
+ removed_nodes = sorted(a_nodes - b_nodes)
1836
+ changed_nodes: list[dict[str, Any]] = []
1837
+ for p in sorted(a_nodes & b_nodes):
1838
+ na = a["nodes"][p]
1839
+ nb = b["nodes"][p]
1840
+ if not isinstance(na, dict) or not isinstance(nb, dict):
1841
+ if na != nb:
1842
+ changed_nodes.append({"path": p, "field": "node", "from": na, "to": nb})
1843
+ continue
1844
+ # V0 optimization: skip value comparison when both nodes have matching versions.
1845
+ av = na.get("v")
1846
+ bv = nb.get("v")
1847
+ if (
1848
+ av is not None
1849
+ and bv is not None
1850
+ and av.get("id") == bv.get("id")
1851
+ and av.get("version") == bv.get("version")
1852
+ ):
1853
+ for key in ("type", "status"):
1854
+ va = na.get(key)
1855
+ vb = nb.get(key)
1856
+ if va != vb:
1857
+ changed_nodes.append({"path": p, "field": key, "from": va, "to": vb})
1858
+ continue
1859
+ for key in ("type", "status", "value"):
1860
+ va = na.get(key)
1861
+ vb = nb.get(key)
1862
+ if va != vb:
1863
+ changed_nodes.append({"path": p, "field": key, "from": va, "to": vb})
1864
+
1865
+ a_edges = {(e["from"], e["to"]) for e in a.get("edges", [])}
1866
+ b_edges = {(e["from"], e["to"]) for e in b.get("edges", [])}
1867
+ added_edges = [{"from": f, "to": t} for f, t in sorted(b_edges - a_edges)]
1868
+ removed_edges = [{"from": f, "to": t} for f, t in sorted(a_edges - b_edges)]
1869
+
1870
+ a_subs = set(a.get("subgraphs", []))
1871
+ b_subs = set(b.get("subgraphs", []))
1872
+ added_subgraphs = sorted(b_subs - a_subs)
1873
+ removed_subgraphs = sorted(a_subs - b_subs)
1874
+
1875
+ return GraphDiffResult(
1876
+ nodesAdded=added_nodes,
1877
+ nodesRemoved=removed_nodes,
1878
+ nodesChanged=changed_nodes,
1879
+ edgesAdded=added_edges,
1880
+ edgesRemoved=removed_edges,
1881
+ subgraphsAdded=added_subgraphs,
1882
+ subgraphsRemoved=removed_subgraphs,
1883
+ )
1884
+
1885
+
1886
+ def reachable(
1887
+ described: dict[str, Any],
1888
+ from_path: str,
1889
+ direction: str,
1890
+ *,
1891
+ max_depth: int | None = None,
1892
+ ) -> list[str]:
1893
+ """Perform a reachability query over a :meth:`Graph.describe` snapshot.
1894
+
1895
+ Traversal combines dependency links (``deps``) and explicit graph edges
1896
+ (``edges``):
1897
+
1898
+ - ``"upstream"``: follows ``deps`` and incoming edges.
1899
+ - ``"downstream"``: follows reverse-``deps`` and outgoing edges.
1900
+
1901
+ Args:
1902
+ described: Output of ``graph.describe()``.
1903
+ from_path: Start path (qualified node path).
1904
+ direction: Either ``"upstream"`` or ``"downstream"``.
1905
+ max_depth: Optional hop limit; ``0`` returns an empty list.
1906
+
1907
+ Returns:
1908
+ Sorted list of reachable paths, excluding ``from_path``.
1909
+
1910
+ Example:
1911
+ ```python
1912
+ from graphrefly import Graph, state, node, reachable
1913
+ g = Graph("g")
1914
+ x = state(0)
1915
+ y = node([x], lambda deps, _: deps[0])
1916
+ g.add("x", x); g.add("y", y)
1917
+ g.connect("x", "y")
1918
+ desc = g.describe()
1919
+ assert "x" in reachable(desc, "y", "upstream")
1920
+ ```
1921
+ """
1922
+ if not from_path:
1923
+ return []
1924
+ if direction not in {"upstream", "downstream"}:
1925
+ msg = f"reachable direction must be 'upstream' or 'downstream', got {direction!r}"
1926
+ raise ValueError(msg)
1927
+ if max_depth is not None:
1928
+ if type(max_depth) is not int or max_depth < 0:
1929
+ msg = f"reachable max_depth must be an int >= 0, got {max_depth!r}"
1930
+ raise ValueError(msg)
1931
+ if max_depth == 0:
1932
+ return []
1933
+
1934
+ nodes_raw = described.get("nodes", {})
1935
+ edges_raw = described.get("edges", [])
1936
+ if not isinstance(nodes_raw, dict):
1937
+ return []
1938
+ if not isinstance(edges_raw, list):
1939
+ edges_raw = []
1940
+
1941
+ deps_by_path: dict[str, list[str]] = {}
1942
+ reverse_deps: dict[str, set[str]] = {}
1943
+ incoming_edges: dict[str, set[str]] = {}
1944
+ outgoing_edges: dict[str, set[str]] = {}
1945
+ universe: set[str] = set()
1946
+
1947
+ for path, node_desc in nodes_raw.items():
1948
+ if not isinstance(path, str):
1949
+ continue
1950
+ universe.add(path)
1951
+ deps: list[str] = []
1952
+ if isinstance(node_desc, dict):
1953
+ raw_deps = node_desc.get("deps")
1954
+ if isinstance(raw_deps, list):
1955
+ deps = [d for d in raw_deps if isinstance(d, str) and d]
1956
+ deps_by_path[path] = deps
1957
+ for dep in deps:
1958
+ universe.add(dep)
1959
+ reverse_deps.setdefault(dep, set()).add(path)
1960
+
1961
+ for edge in edges_raw:
1962
+ if not isinstance(edge, dict):
1963
+ continue
1964
+ edge_from = edge.get("from")
1965
+ edge_to = edge.get("to")
1966
+ if not isinstance(edge_from, str) or not edge_from:
1967
+ continue
1968
+ if not isinstance(edge_to, str) or not edge_to:
1969
+ continue
1970
+ universe.add(edge_from)
1971
+ universe.add(edge_to)
1972
+ outgoing_edges.setdefault(edge_from, set()).add(edge_to)
1973
+ incoming_edges.setdefault(edge_to, set()).add(edge_from)
1974
+
1975
+ if from_path not in universe:
1976
+ return []
1977
+
1978
+ def neighbors(path: str) -> list[str]:
1979
+ if direction == "upstream":
1980
+ return deps_by_path.get(path, []) + sorted(incoming_edges.get(path, set()))
1981
+ return sorted(reverse_deps.get(path, set())) + sorted(outgoing_edges.get(path, set()))
1982
+
1983
+ visited: set[str] = {from_path}
1984
+ out: set[str] = set()
1985
+ queue: deque[tuple[str, int]] = deque([(from_path, 0)])
1986
+ while queue:
1987
+ cur, depth = queue.popleft()
1988
+ if max_depth is not None and depth >= max_depth:
1989
+ continue
1990
+ for nb in neighbors(cur):
1991
+ if not nb or nb in visited:
1992
+ continue
1993
+ visited.add(nb)
1994
+ out.add(nb)
1995
+ queue.append((nb, depth + 1))
1996
+ return sorted(out)
1997
+
1998
+
1999
+ def _finish_resolve_from_node(base: NodeImpl[Any], tail: list[str], path: str) -> NodeImpl[Any]:
2000
+ if not tail:
2001
+ return base
2002
+ if tail[0] == GRAPH_META_SEGMENT:
2003
+ return _resolve_meta_chain(base, tail, path)
2004
+ msg = f"{path!r}: cannot traverse past a registered node (not a subgraph)"
2005
+ raise KeyError(msg)
2006
+
2007
+
2008
+ def _resolve_meta_chain(n: NodeImpl[Any], parts: list[str], path: str) -> NodeImpl[Any]:
2009
+ if not parts:
2010
+ return n
2011
+ if parts[0] != GRAPH_META_SEGMENT:
2012
+ msg = f"expected {GRAPH_META_SEGMENT!r} segment in meta path {path!r}"
2013
+ raise KeyError(msg)
2014
+ if len(parts) < 2:
2015
+ msg = f"meta path requires a key after {GRAPH_META_SEGMENT!r} in {path!r}"
2016
+ raise ValueError(msg)
2017
+ key = parts[1]
2018
+ try:
2019
+ child = n.meta[key]
2020
+ except KeyError as e:
2021
+ raise KeyError(f"unknown meta key {key!r} in path {path!r}") from e
2022
+ rest = parts[2:]
2023
+ return _resolve_meta_chain(child, rest, path) if rest else child
2024
+
2025
+
2026
+ _META_FILTERED_TYPES = frozenset(
2027
+ {MessageType.TEARDOWN, MessageType.INVALIDATE, MessageType.COMPLETE, MessageType.ERROR}
2028
+ )
2029
+
2030
+
2031
+ def _filter_meta_messages(messages: Messages) -> Messages:
2032
+ """Strip lifecycle-destructive messages before delivering to meta companions (spec §2.3)."""
2033
+ return [m for m in messages if m[0] not in _META_FILTERED_TYPES]
2034
+
2035
+
2036
+ def _signal_node_subtree(
2037
+ n: NodeImpl[Any],
2038
+ messages: Messages,
2039
+ visited: set[int],
2040
+ actor: dict[str, Any],
2041
+ path: str,
2042
+ *,
2043
+ internal: bool = False,
2044
+ ) -> None:
2045
+ nid = id(n)
2046
+ if nid in visited:
2047
+ return
2048
+ visited.add(nid)
2049
+ if internal:
2050
+ n.down(messages, internal=True)
2051
+ else:
2052
+ try:
2053
+ n.down(messages, actor=actor, guard_action="signal")
2054
+ except GuardDenied as e:
2055
+ raise GuardDenied(e.actor, path, e.action) from e
2056
+ meta_msgs = _filter_meta_messages(messages)
2057
+ if not meta_msgs:
2058
+ return
2059
+ for k in sorted(n.meta):
2060
+ mp = f"{path}{PATH_SEP}{GRAPH_META_SEGMENT}{PATH_SEP}{k}"
2061
+ _signal_node_subtree(n.meta[k], meta_msgs, visited, actor, mp, internal=internal)
2062
+
2063
+
2064
+ def _collect_observe_targets(g: Graph, prefix: str) -> list[tuple[str, NodeImpl[Any]]]:
2065
+ """Collect all observe targets and sort by full qualified path (code-point order).
2066
+
2067
+ Cross-language alignment: both Python and TypeScript use full-path code-point
2068
+ sort so that ``observe()`` subscription order is deterministic and identical
2069
+ across implementations.
2070
+ """
2071
+ out: list[tuple[str, NodeImpl[Any]]] = []
2072
+ _collect_observe_targets_unsorted(g, prefix, out)
2073
+ out.sort(key=lambda pair: pair[0])
2074
+ return out
2075
+
2076
+
2077
+ def _collect_observe_targets_unsorted(
2078
+ g: Graph, prefix: str, out: list[tuple[str, NodeImpl[Any]]]
2079
+ ) -> None:
2080
+ """Recursively collect all targets without sorting (helper for full-path sort)."""
2081
+ with g._locked():
2082
+ mounts = list(g._mounts.items())
2083
+ node_items = list(g._nodes.items())
2084
+ for mn, ch in mounts:
2085
+ wp = f"{prefix}{mn}{PATH_SEP}" if prefix else f"{mn}{PATH_SEP}"
2086
+ _collect_observe_targets_unsorted(ch, wp, out)
2087
+ for name, n in node_items:
2088
+ bp = f"{prefix}{name}" if prefix else name
2089
+ out.append((bp, n))
2090
+ _append_meta_observe_targets(n, bp, out)
2091
+
2092
+
2093
+ def _append_meta_observe_targets(
2094
+ n: NodeImpl[Any], base_path: str, out: list[tuple[str, NodeImpl[Any]]]
2095
+ ) -> None:
2096
+ for k in sorted(n.meta):
2097
+ m = n.meta[k]
2098
+ mp = f"{base_path}{PATH_SEP}{GRAPH_META_SEGMENT}{PATH_SEP}{k}"
2099
+ out.append((mp, m))
2100
+ _append_meta_observe_targets(m, mp, out)
2101
+
2102
+
2103
+ def _node_describe_for_graph(n: NodeImpl[Any], paths_by_id: dict[int, str]) -> dict[str, Any]:
2104
+ d = describe_node(n)
2105
+ d["deps"] = [paths_by_id.get(id(dep), dep.name or "") for dep in n._deps]
2106
+ d.pop("name", None)
2107
+ return d
2108
+
2109
+
2110
+ def _collect_edges_qualified(g: Graph, prefix: str) -> list[dict[str, str]]:
2111
+ rows: list[dict[str, str]] = []
2112
+ with g._locked():
2113
+ loc_edges = frozenset(g._edges)
2114
+ mounts = sorted(g._mounts.items())
2115
+ for f, t in loc_edges:
2116
+ if PATH_SEP in f or PATH_SEP in t:
2117
+ rows.append({"from": f, "to": t})
2118
+ else:
2119
+ fq = f"{prefix}{f}" if prefix else f
2120
+ tq = f"{prefix}{t}" if prefix else t
2121
+ rows.append({"from": fq, "to": tq})
2122
+ for mn, ch in mounts:
2123
+ wp = f"{prefix}{mn}{PATH_SEP}" if prefix else f"{mn}{PATH_SEP}"
2124
+ rows.extend(_collect_edges_qualified(ch, wp))
2125
+ return rows
2126
+
2127
+
2128
+ def _collect_subgraphs_qualified(g: Graph, prefix: str) -> list[str]:
2129
+ out: list[str] = []
2130
+ with g._locked():
2131
+ mounts = list(g._mounts.items())
2132
+ for mn, ch in mounts:
2133
+ q = f"{prefix}{mn}" if prefix else mn
2134
+ out.append(q)
2135
+ out.extend(_collect_subgraphs_qualified(ch, f"{q}{PATH_SEP}"))
2136
+ return out
2137
+
2138
+
2139
+ class GraphObserveSource:
2140
+ """Live observation handle from :meth:`Graph.observe` (GRAPHREFLY-SPEC §3.6)."""
2141
+
2142
+ __slots__ = ("_actor", "_graph", "_path")
2143
+
2144
+ def __init__(self, graph: Graph, path: str | None, actor: Any | None) -> None:
2145
+ self._graph = graph
2146
+ self._path = path
2147
+ self._actor = actor
2148
+
2149
+ def subscribe(self, sink: Any) -> Any:
2150
+ """Attach ``sink``.
2151
+
2152
+ With a single-node path, ``sink(messages)`` receives :class:`Messages`.
2153
+
2154
+ With the graph-wide stream (``path is None``), ``sink(qualified_path, messages)``.
2155
+ Returns an unsubscribe callable.
2156
+ """
2157
+ act = self._actor
2158
+ if self._path is not None:
2159
+ n = self._graph.node(self._path)
2160
+ try:
2161
+ return n.subscribe(sink, actor=act)
2162
+ except GuardDenied as e:
2163
+ raise GuardDenied(e.actor, self._path, e.action) from e
2164
+ unsubs: list[Any] = []
2165
+ a = normalize_actor(act)
2166
+ for qpath, n in _collect_observe_targets(self._graph, ""):
2167
+ if not _node_allows_observe(n, a):
2168
+ continue
2169
+
2170
+ def on_msgs(msgs: Messages, *, _p: str = qpath) -> None:
2171
+ sink(_p, msgs)
2172
+
2173
+ unsubs.append(n.subscribe(on_msgs, actor=act))
2174
+
2175
+ def cleanup() -> None:
2176
+ for u in unsubs:
2177
+ u()
2178
+
2179
+ return cleanup
2180
+
2181
+ def up(self, messages: Messages, path: str | None = None) -> None:
2182
+ """Send messages upstream toward the observed node's sources.
2183
+
2184
+ For single-node observation (``self._path is not None``), *path* is
2185
+ ignored and messages go to the observed node. For graph-wide
2186
+ observation, *path* must be provided to target a specific node.
2187
+
2188
+ If the target node's guard denies the action, the messages are
2189
+ silently dropped (aligned with TS). This prevents ``GuardDenied``
2190
+ from propagating into backpressure controller callbacks.
2191
+ """
2192
+ target_path = path if self._path is None else self._path
2193
+ if target_path is None:
2194
+ msg = "up() on graph-wide observe requires a path argument"
2195
+ raise ValueError(msg)
2196
+ n = self._graph.node(target_path)
2197
+ up_fn = getattr(n, "up", None)
2198
+ if up_fn is not None:
2199
+ try:
2200
+ up_fn(messages)
2201
+ except GuardDenied:
2202
+ return
2203
+
2204
+
2205
+ def _teardown_mounted_graph(root: Graph) -> None:
2206
+ with root._locked():
2207
+ mounts = list(root._mounts.values())
2208
+ for m in mounts:
2209
+ _teardown_mounted_graph(m)
2210
+ with root._locked():
2211
+ nodes = list(root._nodes.values())
2212
+ for n in nodes:
2213
+ n.down([(MessageType.TEARDOWN,)], internal=True)
2214
+
2215
+
2216
+ def _signal_graph(
2217
+ g: Graph,
2218
+ messages: Messages,
2219
+ visited: set[int],
2220
+ actor: dict[str, Any],
2221
+ prefix: str,
2222
+ *,
2223
+ internal: bool = False,
2224
+ ) -> None:
2225
+ with g._locked():
2226
+ mounts = sorted(g._mounts.items())
2227
+ nodes = sorted(g._nodes.items())
2228
+ for mn, m in mounts:
2229
+ wp = f"{prefix}{mn}{PATH_SEP}" if prefix else f"{mn}{PATH_SEP}"
2230
+ _signal_graph(m, messages, visited, actor, wp, internal=internal)
2231
+ for name, n in nodes:
2232
+ q = f"{prefix}{name}" if prefix else name
2233
+ _signal_node_subtree(n, messages, visited, actor, q, internal=internal)
2234
+
2235
+
2236
+ __all__ = [
2237
+ "GRAPH_META_SEGMENT",
2238
+ "GRAPH_SNAPSHOT_VERSION",
2239
+ "GraphAutoCheckpointHandle",
2240
+ "Graph",
2241
+ "GraphDiffResult",
2242
+ "GraphObserveSource",
2243
+ "META_PATH_SEG",
2244
+ "ObserveResult",
2245
+ "PATH_SEP",
2246
+ "SpyHandle",
2247
+ "TraceEntry",
2248
+ "reachable",
2249
+ ]