Skip to main content

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}