graphrefly_graph/describe.rs
1//! `Graph::describe()` — JSON form of canonical spec §3.6 + Appendix B.
2//!
3//! Static JSON form (Slice E+) + reactive describe (Slice F+). Pretty
4//! / mermaid / d2 / stage-log / explain / reachable variants are
5//! deferred (subsequent slices).
6//!
7//! # Value rendering — raw vs. binding-rendered (F sub-slice, 2026-05-10)
8//!
9//! Canonical TS surfaces `value: T` directly. The Rust port preserves
10//! the handle-protocol cleaving plane (Core operates on opaque
11//! `HandleId` integers; binding-side owns `HandleId → T`) by surfacing
12//! `value: DescribeValue`:
13//!
14//! - `DescribeValue::Handle(HandleId)` — raw u64 view, used by
15//! `Graph::describe()` (the default). Suitable for parity tests
16//! that compare against TS by mapping handles through the binding
17//! manually, and for debug contexts that don't have a debug
18//! binding wired up.
19//! - `DescribeValue::Rendered(serde_json::Value)` — binding-rendered
20//! view, used by `Graph::describe_with_debug(debug)`. The caller
21//! passes a [`DebugBindingBoundary`] impl that knows how to
22//! project each registered value into a JSON form. This is the
23//! "developer-friendly" surface — looks just like TS's `value: T`
24//! in the serialized JSON because the rendering happens at the
25//! binding boundary, off the Core hot path.
26//!
27//! Each value field serializes uniformly: as a u64 number for
28//! `Handle`, or as the binding's chosen JSON shape for `Rendered`.
29//! `None` (sentinel cache) serializes as `null` in both modes.
30
31use std::sync::{Arc, Weak};
32
33use graphrefly_core::{
34 Core, HandleId, NodeId, NodeKind, OperatorOp, TerminalKind, TopologyEvent,
35 TopologySubscription, NO_HANDLE,
36};
37use indexmap::IndexMap;
38use parking_lot::Mutex;
39use serde::{Serialize, Serializer};
40
41use crate::debug::DebugBindingBoundary;
42use crate::graph::{Graph, GraphInner};
43
44/// Top-level `describe()` output (canonical Appendix B JSON schema).
45///
46/// `nodes` is insertion-ordered (matches namespace registration
47/// order) — load-bearing for stable serialized output.
48#[derive(Debug, Clone, Serialize)]
49pub struct GraphDescribeOutput {
50 /// Graph name as set at construction / mount.
51 pub name: String,
52 /// Local nodes by name.
53 pub nodes: IndexMap<String, NodeDescribe>,
54 /// Local edges (dep → consumer).
55 pub edges: Vec<EdgeDescribe>,
56 /// Mounted child names (recurse via `Graph::node(child).describe()`).
57 pub subgraphs: Vec<String>,
58}
59
60/// Per-node descriptor.
61#[derive(Debug, Clone, Serialize)]
62pub struct NodeDescribe {
63 /// `"state"` / `"derived"` / `"dynamic"` / `"producer"`.
64 /// Producer-vs-state inference: a state node with no fn-id but
65 /// `has_fired_once=true` may stem from a producer pattern; the
66 /// rust-side classifier just reports `kind` directly. (Producer
67 /// inference is a binding-side concern — see canonical §3.6.1.)
68 #[serde(rename = "type")]
69 pub r#type: NodeTypeStr,
70 /// Lifecycle status (canonical Appendix B enum).
71 pub status: NodeStatus,
72 /// Current cache value (F sub-slice, 2026-05-10). `None` when
73 /// the cache is sentinel (`NO_HANDLE`). Otherwise:
74 ///
75 /// - `DescribeValue::Handle(HandleId)` — raw u64 (from
76 /// [`Graph::describe`]).
77 /// - `DescribeValue::Rendered(serde_json::Value)` — binding-
78 /// rendered (from [`Graph::describe_with_debug`]).
79 ///
80 /// Serialization is uniform: the inner u64 or JSON value
81 /// appears directly in the output (no enum tag).
82 pub value: Option<DescribeValue>,
83 /// Dep names in declaration order. Unnamed deps surface as
84 /// `_anon_<NodeId>` to keep the output lossless without
85 /// elevating Core-only nodes into the namespace.
86 pub deps: Vec<String>,
87 /// Operator discriminant (e.g. `"map"`, `"filter"`, `"combine"`).
88 /// `None` for non-operator nodes. Slice V5: surfaces the
89 /// `OperatorOp` variant name so consumers can distinguish
90 /// operator kinds (was previously just `type: "operator"`).
91 #[serde(default, skip_serializing_if = "Option::is_none", rename = "operator")]
92 pub operator_kind: Option<String>,
93 /// Free-form metadata per canonical Appendix B (e.g. `{
94 /// "description": "...", "type": "integer", "range": [1, 10] }`).
95 /// Always `None` in this slice — the metadata-storage primitive
96 /// on Core hasn't shipped yet. Reserved as `Option<serde_json::Value>`
97 /// so the JSON shape stays forward-compatible (omitted via
98 /// `skip_serializing_if` when None to keep current outputs slim).
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub meta: Option<serde_json::Value>,
101}
102
103/// Per-node cache value in `describe` output. Surfaced as `value:
104/// <u64>` when produced by [`Graph::describe`] (raw handle view), or
105/// as `value: <T>` when produced by
106/// [`Graph::describe_with_debug`] (binding-rendered view). Serialized
107/// uniformly without an enum tag — consumers see either a number
108/// or whatever JSON shape the binding emits.
109#[derive(Debug, Clone, PartialEq)]
110pub enum DescribeValue {
111 /// Raw handle view. Default for [`Graph::describe`]. The
112 /// serialized JSON is a `Number` (the u64 raw view of the
113 /// handle).
114 Handle(HandleId),
115 /// Binding-rendered view. Produced by
116 /// [`Graph::describe_with_debug`] via the supplied
117 /// [`DebugBindingBoundary`]. The serialized JSON is whatever
118 /// shape the binding's `handle_to_debug` returned (string,
119 /// number, object — fully under binding control).
120 Rendered(serde_json::Value),
121}
122
123impl Serialize for DescribeValue {
124 fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
125 match self {
126 DescribeValue::Handle(h) => ser.serialize_u64(h.raw()),
127 DescribeValue::Rendered(v) => v.serialize(ser),
128 }
129 }
130}
131
132/// Edge between two named nodes (or a named node and an anonymous
133/// dep, surfaced as `_anon_<NodeId>`).
134#[derive(Debug, Clone, Serialize)]
135pub struct EdgeDescribe {
136 pub from: String,
137 pub to: String,
138}
139
140/// Canonical Appendix B `type` enum.
141#[derive(Debug, Clone, Copy, Serialize)]
142#[serde(rename_all = "lowercase")]
143pub enum NodeTypeStr {
144 State,
145 Derived,
146 Dynamic,
147 /// Reserved for future producer-pattern classification — the Rust
148 /// port doesn't infer this kind today; emitted only when the
149 /// binding side has annotated it.
150 Producer,
151 /// Reserved for future side-effect classification. Same caveat
152 /// as `Producer`.
153 Effect,
154 /// Reserved for the operator catalog when M3 lands.
155 Operator,
156}
157
158/// Canonical Appendix B `status` enum.
159#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
160#[serde(rename_all = "lowercase")]
161pub enum NodeStatus {
162 /// State node with sentinel cache (never had a value).
163 Sentinel,
164 /// Compute node that has not yet fired (first-run gate not satisfied).
165 Pending,
166 /// DIRTY queued; tier-3 settle has not flushed yet.
167 Dirty,
168 /// Has a value, no terminal, no DIRTY pending.
169 Settled,
170 /// Same as `Settled` for static descriptors — wave-internal
171 /// "resolved-this-wave" doesn't survive flush. Reserved for
172 /// reactive-describe later.
173 Resolved,
174 /// Terminated via `[COMPLETE]`.
175 Completed,
176 /// Terminated via `[ERROR, h]`.
177 Errored,
178}
179
180impl Graph {
181 /// Snapshot the graph's topology + lifecycle state. JSON form only
182 /// in this slice (see module docs).
183 ///
184 /// `value` fields serialize as raw u64 handles. Pass a
185 /// [`DebugBindingBoundary`] to
186 /// [`Self::describe_with_debug`](Self::describe_with_debug)
187 /// instead if you want `value: T`-shaped output.
188 #[must_use]
189 pub fn describe(&self) -> GraphDescribeOutput {
190 self.describe_inner(None)
191 }
192
193 /// Variant of [`Self::describe`] that renders each node's
194 /// `value` via the supplied [`DebugBindingBoundary`].
195 ///
196 /// Useful when consuming `describe()` output to display values
197 /// to humans (e.g., debugging UIs, log scrapers) — the JSON
198 /// surfaces the binding's `T` shape rather than opaque u64
199 /// handles.
200 ///
201 /// The trait is intentionally outside
202 /// [`graphrefly_core::BindingBoundary`] so the hot-path FFI
203 /// surface stays narrow. Bindings opt in by implementing both.
204 /// Pre-1.0: bindings that don't ship `DebugBindingBoundary`
205 /// simply force callers to use raw [`Self::describe`] (no
206 /// fallback). See [`crate::debug`] for the trait's contract.
207 #[must_use]
208 pub fn describe_with_debug(&self, debug: &dyn DebugBindingBoundary) -> GraphDescribeOutput {
209 self.describe_inner(Some(debug))
210 }
211
212 fn describe_inner(&self, debug: Option<&dyn DebugBindingBoundary>) -> GraphDescribeOutput {
213 let inner = self.inner.lock();
214 let graph_name = inner.name.clone();
215 let local_names: IndexMap<NodeId, String> = inner
216 .names
217 .iter()
218 .map(|(name, id)| (*id, name.clone()))
219 .collect();
220 let subgraphs: Vec<String> = inner.children.keys().cloned().collect();
221 let names_iter: Vec<(String, NodeId)> =
222 inner.names.iter().map(|(n, id)| (n.clone(), *id)).collect();
223 drop(inner);
224
225 let mut nodes: IndexMap<String, NodeDescribe> = IndexMap::new();
226 let mut edges: Vec<EdgeDescribe> = Vec::new();
227
228 for (name, id) in &names_iter {
229 let kind = self.core.kind_of(*id).unwrap_or(NodeKind::State);
230 let cache = self.core.cache_of(*id);
231 let terminal = self.core.is_terminal(*id);
232 let dirty = self.core.is_dirty(*id);
233 let fired = self.core.has_fired_once(*id);
234
235 let dep_ids = self.core.deps_of(*id);
236 let dep_names: Vec<String> = dep_ids
237 .iter()
238 .map(|d| {
239 local_names
240 .get(d)
241 .cloned()
242 .unwrap_or_else(|| format!("_anon_{}", d.raw()))
243 })
244 .collect();
245 for dep_name in &dep_names {
246 edges.push(EdgeDescribe {
247 from: dep_name.clone(),
248 to: name.clone(),
249 });
250 }
251
252 // F sub-slice (2026-05-10): pick raw vs binding-rendered
253 // value. Sentinel cache (NO_HANDLE) → None regardless of
254 // mode. Real handle: route through debug binding when
255 // supplied, else surface raw.
256 let value = if cache == NO_HANDLE {
257 None
258 } else if let Some(debug) = debug {
259 Some(DescribeValue::Rendered(debug.handle_to_debug(cache)))
260 } else {
261 Some(DescribeValue::Handle(cache))
262 };
263
264 let operator_kind = match kind {
265 NodeKind::Operator(op) => Some(operator_op_name(op)),
266 _ => None,
267 };
268 nodes.insert(
269 name.clone(),
270 NodeDescribe {
271 r#type: type_str_of(kind),
272 status: status_of(kind, cache, terminal, dirty, fired),
273 value,
274 deps: dep_names,
275 operator_kind,
276 meta: None,
277 },
278 );
279 }
280
281 GraphDescribeOutput {
282 name: graph_name,
283 nodes,
284 edges,
285 subgraphs,
286 }
287 }
288}
289
290fn type_str_of(kind: NodeKind) -> NodeTypeStr {
291 match kind {
292 NodeKind::State => NodeTypeStr::State,
293 NodeKind::Producer => NodeTypeStr::Producer,
294 NodeKind::Derived => NodeTypeStr::Derived,
295 NodeKind::Dynamic => NodeTypeStr::Dynamic,
296 NodeKind::Operator(_) => NodeTypeStr::Operator,
297 }
298}
299
300/// Slice V5: surfaces the `OperatorOp` variant name as a lowercase
301/// string for the `operator` field in `NodeDescribe`.
302fn operator_op_name(op: OperatorOp) -> String {
303 match op {
304 OperatorOp::Map { .. } => "map",
305 OperatorOp::Filter { .. } => "filter",
306 OperatorOp::Scan { .. } => "scan",
307 OperatorOp::Reduce { .. } => "reduce",
308 OperatorOp::DistinctUntilChanged { .. } => "distinctUntilChanged",
309 OperatorOp::Pairwise { .. } => "pairwise",
310 OperatorOp::Combine { .. } => "combine",
311 OperatorOp::WithLatestFrom { .. } => "withLatestFrom",
312 OperatorOp::Merge => "merge",
313 OperatorOp::Take { .. } => "take",
314 OperatorOp::Skip { .. } => "skip",
315 OperatorOp::TakeWhile { .. } => "takeWhile",
316 OperatorOp::Last { .. } => "last",
317 OperatorOp::Tap { .. } => "tap",
318 OperatorOp::TapFirst { .. } => "tapFirst",
319 OperatorOp::Valve => "valve",
320 OperatorOp::Settle { .. } => "settle",
321 }
322 .to_owned()
323}
324
325/// Canonical-spec §3.6.1 status mapping.
326///
327/// Precedence (high to low): `errored` > `completed` > `dirty` >
328/// (cache-cleared discriminator) > (`settled` if `cache != NO_HANDLE`)
329/// > (`pending` for unfired compute) > (`sentinel` for state).
330///
331/// # R1.3.7.b post-INVALIDATE classification (Slice F, A8 — 2026-05-07)
332///
333/// Per canonical R1.3.7.b: "The emitting node's status transitions to
334/// 'sentinel' (no value, nothing pending) — NOT 'dirty' (value about to
335/// change) — because INVALIDATE has cleared the cache outright with no new
336/// value pending."
337///
338/// Implementation: a *fired* compute node with `cache == NO_HANDLE` and no
339/// terminal and no DIRTY pending has been `INVALIDATE`-d (the only path that
340/// clears the cache without setting a terminal). Report `Sentinel`, NOT
341/// `Settled` (the prior bug). State nodes use the same logic — `cache == NO_HANDLE`
342/// always means `Sentinel` regardless of `fired`.
343///
344/// # Reactive-describe note
345///
346/// When both `terminal.is_some()` AND `dirty == true` (a wave that began
347/// before the terminal was installed and still has unflushed tier-1 traffic),
348/// this static classifier reports the terminal status. Reactive describe will
349/// need a `terminating` substate to surface the unflushed wave — not modeled
350/// here because the static walk happens between waves in practice.
351fn status_of(
352 kind: NodeKind,
353 cache: HandleId,
354 terminal: Option<TerminalKind>,
355 dirty: bool,
356 fired: bool,
357) -> NodeStatus {
358 match terminal {
359 Some(TerminalKind::Error(_)) => return NodeStatus::Errored,
360 Some(TerminalKind::Complete) => return NodeStatus::Completed,
361 None => {}
362 }
363 if dirty {
364 return NodeStatus::Dirty;
365 }
366 // R1.3.7.b: `cache == NO_HANDLE` discriminates Sentinel vs Settled
367 // BEFORE the `fired` check, so post-INVALIDATE on fired compute nodes
368 // correctly reports `Sentinel` (was incorrectly `Settled` pre-A8).
369 if cache == NO_HANDLE {
370 return match kind {
371 NodeKind::State => NodeStatus::Sentinel,
372 NodeKind::Producer | NodeKind::Derived | NodeKind::Dynamic | NodeKind::Operator(_) => {
373 if fired {
374 // Compute node that previously fired but currently has
375 // sentinel cache → INVALIDATE wiped it. R1.3.7.b says
376 // status is `sentinel`, not `pending` (pending = first-fire
377 // gate not yet satisfied).
378 NodeStatus::Sentinel
379 } else {
380 NodeStatus::Pending
381 }
382 }
383 };
384 }
385 NodeStatus::Settled
386}
387
388// -------------------------------------------------------------------
389// Reactive describe (canonical §3.6.1 `reactive: true` mode)
390// -------------------------------------------------------------------
391
392/// Sink type for reactive describe — receives a fresh `GraphDescribeOutput`
393/// on every namespace change.
394pub type DescribeSink = Arc<dyn Fn(&GraphDescribeOutput) + Send + Sync>;
395
396/// RAII handle for a reactive describe subscription. Dropping it stops
397/// the namespace listener and frees the describe-sink.
398///
399/// The reactive describe fires synchronously from Graph-level
400/// namespace mutations (`add`, `remove`, `destroy`, `mount`,
401/// `unmount`, and the cascaded teardowns of `core.teardown`). Each
402/// fire re-snapshots the full `Graph::describe()` and delivers it
403/// to the sink.
404#[must_use = "ReactiveDescribeHandle holds the subscription; dropping it unsubscribes"]
405pub struct ReactiveDescribeHandle {
406 graph: Graph,
407 ns_sink_id: u64,
408 /// Slice V3 D5: Core topology subscription for `DepsChanged` events.
409 /// When deps change (via `set_deps`), edges in describe output change
410 /// even though the namespace hasn't changed. Dropping this field
411 /// automatically unsubscribes from Core topology events.
412 topo_sub: Option<TopologySubscription>,
413}
414
415impl Drop for ReactiveDescribeHandle {
416 fn drop(&mut self) {
417 // Drop topology sub BEFORE unsubscribing namespace sink to avoid
418 // potential deadlock if the topology sink fires during unsubscribe.
419 self.topo_sub.take();
420 self.graph.unsubscribe_namespace_change(self.ns_sink_id);
421 }
422}
423
424// Send + Sync compile-time assertion.
425const _: fn() = || {
426 fn assert_send_sync<T: Send + Sync>() {}
427 assert_send_sync::<ReactiveDescribeHandle>();
428};
429
430impl Graph {
431 /// Subscribe to live topology snapshots. The sink fires immediately
432 /// with the current [`GraphDescribeOutput`] (push-on-subscribe per
433 /// canonical §2.5.2 / R3.6.1) and then again with a fresh snapshot
434 /// every time a node is added, removed, mounted, unmounted, or the
435 /// graph is destroyed.
436 ///
437 /// Returns a [`ReactiveDescribeHandle`] — dropping it unsubscribes.
438 ///
439 /// This is the `reactive: true` mode from canonical §3.6.1. The
440 /// `reactive: "diff"` (changeset) mode is deferred to Phase 14.
441 ///
442 /// Note: `set_deps` topology changes fire via Core's topology
443 /// primitive, not this Graph-level namespace hook. If callers also
444 /// need `set_deps` notifications, compose with
445 /// [`graphrefly_core::Core::subscribe_topology`].
446 ///
447 /// The sink captures only a [`Weak`] reference to the graph's inner
448 /// state, so the `namespace_sinks` → sink → Graph → `namespace_sinks`
449 /// Arc cycle is broken at the sink edge (see P6 in the Slice F /qa
450 /// closing notes).
451 pub fn describe_reactive(&self, sink: DescribeSink) -> ReactiveDescribeHandle {
452 // Push-on-subscribe: fire current snapshot once before installing
453 // the listener. Sink runs without any Graph lock held.
454 sink(&self.describe());
455
456 // Capture Weak<inner> + Core (clone) to break the
457 // namespace_sinks → sink → Graph → namespace_sinks Arc cycle.
458 // If the user leaks the handle, the graph still drops cleanly
459 // because the sink's Weak ref does not keep `inner` alive.
460 let weak_inner: Weak<Mutex<GraphInner>> = Arc::downgrade(&self.inner);
461 let core: Core = self.core.clone();
462 let sink_for_ns = sink.clone();
463 let ns_sink = Arc::new(move || {
464 let Some(arc_inner) = weak_inner.upgrade() else {
465 return;
466 };
467 let graph = Graph {
468 core: core.clone(),
469 inner: arc_inner,
470 };
471 let snapshot = graph.describe();
472 sink_for_ns(&snapshot);
473 });
474 let ns_sink_id = self.subscribe_namespace_change(ns_sink);
475
476 // Slice V3 D5: subscribe to Core topology events so that
477 // `set_deps` changes (which alter edges without touching the
478 // namespace) also trigger a describe update.
479 let weak_inner_topo: Weak<Mutex<GraphInner>> = Arc::downgrade(&self.inner);
480 let core_topo: Core = self.core.clone();
481 let topo_sink: Arc<dyn Fn(&TopologyEvent) + Send + Sync> =
482 Arc::new(move |event: &TopologyEvent| {
483 if matches!(event, TopologyEvent::DepsChanged { .. }) {
484 let Some(arc_inner) = weak_inner_topo.upgrade() else {
485 return;
486 };
487 let graph = Graph {
488 core: core_topo.clone(),
489 inner: arc_inner,
490 };
491 let snapshot = graph.describe();
492 sink(&snapshot);
493 }
494 });
495 let topo_sub = self.core.subscribe_topology(topo_sink);
496
497 ReactiveDescribeHandle {
498 graph: self.clone(),
499 ns_sink_id,
500 topo_sub: Some(topo_sub),
501 }
502 }
503}