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 divergence (TS spec)
8//!
9//! Canonical TS surfaces `value: T` directly. The Rust port surfaces
10//! `value: Option<HandleId>` — Core operates on opaque `HandleId`
11//! integers, and the binding-side registry is the only place
12//! `HandleId → T` resolution happens. Bindings (`graphrefly-bindings-js`,
13//! `graphrefly-bindings-py`) provide a thin wrapper that swaps each
14//! handle for the registered value before serializing for end-user
15//! consumption. Documented divergence per §11 Implementation Deltas
16//! (handle-protocol cleaving plane).
17
18use std::sync::{Arc, Weak};
19
20use graphrefly_core::{Core, HandleId, NodeId, NodeKind, TerminalKind, NO_HANDLE};
21use indexmap::IndexMap;
22use parking_lot::Mutex;
23use serde::{Serialize, Serializer};
24
25use crate::graph::{Graph, GraphInner};
26
27/// Top-level `describe()` output (canonical Appendix B JSON schema).
28///
29/// `nodes` is insertion-ordered (matches namespace registration
30/// order) — load-bearing for stable serialized output.
31#[derive(Debug, Clone, Serialize)]
32pub struct GraphDescribeOutput {
33    /// Graph name as set at construction / mount.
34    pub name: String,
35    /// Local nodes by name.
36    pub nodes: IndexMap<String, NodeDescribe>,
37    /// Local edges (dep → consumer).
38    pub edges: Vec<EdgeDescribe>,
39    /// Mounted child names (recurse via `Graph::node(child).describe()`).
40    pub subgraphs: Vec<String>,
41}
42
43/// Per-node descriptor.
44#[derive(Debug, Clone, Serialize)]
45pub struct NodeDescribe {
46    /// `"state"` / `"derived"` / `"dynamic"` / `"producer"`.
47    /// Producer-vs-state inference: a state node with no fn-id but
48    /// `has_fired_once=true` may stem from a producer pattern; the
49    /// rust-side classifier just reports `kind` directly. (Producer
50    /// inference is a binding-side concern — see canonical §3.6.1.)
51    #[serde(rename = "type")]
52    pub r#type: NodeTypeStr,
53    /// Lifecycle status (canonical Appendix B enum).
54    pub status: NodeStatus,
55    /// Raw handle of the node's current cache. `None` when the cache
56    /// is sentinel (`NO_HANDLE`). Bindings render to `T` before
57    /// surfacing to end users.
58    #[serde(serialize_with = "ser_opt_handle")]
59    pub value: Option<HandleId>,
60    /// Dep names in declaration order. Unnamed deps surface as
61    /// `_anon_<NodeId>` to keep the output lossless without
62    /// elevating Core-only nodes into the namespace.
63    pub deps: Vec<String>,
64    /// Free-form metadata per canonical Appendix B (e.g. `{
65    /// "description": "...", "type": "integer", "range": [1, 10] }`).
66    /// Always `None` in this slice — the metadata-storage primitive
67    /// on Core hasn't shipped yet. Reserved as `Option<serde_json::Value>`
68    /// so the JSON shape stays forward-compatible (omitted via
69    /// `skip_serializing_if` when None to keep current outputs slim).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub meta: Option<serde_json::Value>,
72}
73
74/// Edge between two named nodes (or a named node and an anonymous
75/// dep, surfaced as `_anon_<NodeId>`).
76#[derive(Debug, Clone, Serialize)]
77pub struct EdgeDescribe {
78    pub from: String,
79    pub to: String,
80}
81
82/// Canonical Appendix B `type` enum.
83#[derive(Debug, Clone, Copy, Serialize)]
84#[serde(rename_all = "lowercase")]
85pub enum NodeTypeStr {
86    State,
87    Derived,
88    Dynamic,
89    /// Reserved for future producer-pattern classification — the Rust
90    /// port doesn't infer this kind today; emitted only when the
91    /// binding side has annotated it.
92    Producer,
93    /// Reserved for future side-effect classification. Same caveat
94    /// as `Producer`.
95    Effect,
96    /// Reserved for the operator catalog when M3 lands.
97    Operator,
98}
99
100/// Canonical Appendix B `status` enum.
101#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
102#[serde(rename_all = "lowercase")]
103pub enum NodeStatus {
104    /// State node with sentinel cache (never had a value).
105    Sentinel,
106    /// Compute node that has not yet fired (first-run gate not satisfied).
107    Pending,
108    /// DIRTY queued; tier-3 settle has not flushed yet.
109    Dirty,
110    /// Has a value, no terminal, no DIRTY pending.
111    Settled,
112    /// Same as `Settled` for static descriptors — wave-internal
113    /// "resolved-this-wave" doesn't survive flush. Reserved for
114    /// reactive-describe later.
115    Resolved,
116    /// Terminated via `[COMPLETE]`.
117    Completed,
118    /// Terminated via `[ERROR, h]`.
119    Errored,
120}
121
122impl Graph {
123    /// Snapshot the graph's topology + lifecycle state. JSON form only
124    /// in this slice (see module docs).
125    #[must_use]
126    pub fn describe(&self) -> GraphDescribeOutput {
127        let inner = self.inner.lock();
128        let graph_name = inner.name.clone();
129        let local_names: IndexMap<NodeId, String> = inner
130            .names
131            .iter()
132            .map(|(name, id)| (*id, name.clone()))
133            .collect();
134        let subgraphs: Vec<String> = inner.children.keys().cloned().collect();
135        let names_iter: Vec<(String, NodeId)> =
136            inner.names.iter().map(|(n, id)| (n.clone(), *id)).collect();
137        drop(inner);
138
139        let mut nodes: IndexMap<String, NodeDescribe> = IndexMap::new();
140        let mut edges: Vec<EdgeDescribe> = Vec::new();
141
142        for (name, id) in &names_iter {
143            let kind = self.core.kind_of(*id).unwrap_or(NodeKind::State);
144            let cache = self.core.cache_of(*id);
145            let terminal = self.core.is_terminal(*id);
146            let dirty = self.core.is_dirty(*id);
147            let fired = self.core.has_fired_once(*id);
148
149            let dep_ids = self.core.deps_of(*id);
150            let dep_names: Vec<String> = dep_ids
151                .iter()
152                .map(|d| {
153                    local_names
154                        .get(d)
155                        .cloned()
156                        .unwrap_or_else(|| format!("_anon_{}", d.raw()))
157                })
158                .collect();
159            for dep_name in &dep_names {
160                edges.push(EdgeDescribe {
161                    from: dep_name.clone(),
162                    to: name.clone(),
163                });
164            }
165
166            nodes.insert(
167                name.clone(),
168                NodeDescribe {
169                    r#type: type_str_of(kind),
170                    status: status_of(kind, cache, terminal, dirty, fired),
171                    value: if cache == NO_HANDLE {
172                        None
173                    } else {
174                        Some(cache)
175                    },
176                    deps: dep_names,
177                    meta: None,
178                },
179            );
180        }
181
182        GraphDescribeOutput {
183            name: graph_name,
184            nodes,
185            edges,
186            subgraphs,
187        }
188    }
189}
190
191/// Serialize `Option<HandleId>` as `null` or its raw u64.
192///
193/// Takes `&Option<T>` not `Option<&T>` because `serde`'s
194/// `serialize_with` API mandates the former signature.
195#[allow(clippy::ref_option)]
196fn ser_opt_handle<S: Serializer>(value: &Option<HandleId>, ser: S) -> Result<S::Ok, S::Error> {
197    match value {
198        Some(h) => ser.serialize_some(&h.raw()),
199        None => ser.serialize_none(),
200    }
201}
202
203fn type_str_of(kind: NodeKind) -> NodeTypeStr {
204    match kind {
205        NodeKind::State => NodeTypeStr::State,
206        NodeKind::Producer => NodeTypeStr::Producer,
207        NodeKind::Derived => NodeTypeStr::Derived,
208        NodeKind::Dynamic => NodeTypeStr::Dynamic,
209        NodeKind::Operator(_) => NodeTypeStr::Operator,
210    }
211}
212
213/// Canonical-spec §3.6.1 status mapping.
214///
215/// Precedence (high to low): `errored` > `completed` > `dirty` >
216/// (cache-cleared discriminator) > (`settled` if `cache != NO_HANDLE`)
217/// > (`pending` for unfired compute) > (`sentinel` for state).
218///
219/// # R1.3.7.b post-INVALIDATE classification (Slice F, A8 — 2026-05-07)
220///
221/// Per canonical R1.3.7.b: "The emitting node's status transitions to
222/// 'sentinel' (no value, nothing pending) — NOT 'dirty' (value about to
223/// change) — because INVALIDATE has cleared the cache outright with no new
224/// value pending."
225///
226/// Implementation: a *fired* compute node with `cache == NO_HANDLE` and no
227/// terminal and no DIRTY pending has been `INVALIDATE`-d (the only path that
228/// clears the cache without setting a terminal). Report `Sentinel`, NOT
229/// `Settled` (the prior bug). State nodes use the same logic — `cache == NO_HANDLE`
230/// always means `Sentinel` regardless of `fired`.
231///
232/// # Reactive-describe note
233///
234/// When both `terminal.is_some()` AND `dirty == true` (a wave that began
235/// before the terminal was installed and still has unflushed tier-1 traffic),
236/// this static classifier reports the terminal status. Reactive describe will
237/// need a `terminating` substate to surface the unflushed wave — not modeled
238/// here because the static walk happens between waves in practice.
239fn status_of(
240    kind: NodeKind,
241    cache: HandleId,
242    terminal: Option<TerminalKind>,
243    dirty: bool,
244    fired: bool,
245) -> NodeStatus {
246    match terminal {
247        Some(TerminalKind::Error(_)) => return NodeStatus::Errored,
248        Some(TerminalKind::Complete) => return NodeStatus::Completed,
249        None => {}
250    }
251    if dirty {
252        return NodeStatus::Dirty;
253    }
254    // R1.3.7.b: `cache == NO_HANDLE` discriminates Sentinel vs Settled
255    // BEFORE the `fired` check, so post-INVALIDATE on fired compute nodes
256    // correctly reports `Sentinel` (was incorrectly `Settled` pre-A8).
257    if cache == NO_HANDLE {
258        return match kind {
259            NodeKind::State => NodeStatus::Sentinel,
260            NodeKind::Producer | NodeKind::Derived | NodeKind::Dynamic | NodeKind::Operator(_) => {
261                if fired {
262                    // Compute node that previously fired but currently has
263                    // sentinel cache → INVALIDATE wiped it. R1.3.7.b says
264                    // status is `sentinel`, not `pending` (pending = first-fire
265                    // gate not yet satisfied).
266                    NodeStatus::Sentinel
267                } else {
268                    NodeStatus::Pending
269                }
270            }
271        };
272    }
273    NodeStatus::Settled
274}
275
276// -------------------------------------------------------------------
277// Reactive describe (canonical §3.6.1 `reactive: true` mode)
278// -------------------------------------------------------------------
279
280/// Sink type for reactive describe — receives a fresh `GraphDescribeOutput`
281/// on every namespace change.
282pub type DescribeSink = Arc<dyn Fn(&GraphDescribeOutput) + Send + Sync>;
283
284/// RAII handle for a reactive describe subscription. Dropping it stops
285/// the namespace listener and frees the describe-sink.
286///
287/// The reactive describe fires synchronously from Graph-level
288/// namespace mutations (`add`, `remove`, `destroy`, `mount`,
289/// `unmount`, and the cascaded teardowns of `core.teardown`). Each
290/// fire re-snapshots the full `Graph::describe()` and delivers it
291/// to the sink.
292#[must_use = "ReactiveDescribeHandle holds the subscription; dropping it unsubscribes"]
293pub struct ReactiveDescribeHandle {
294    graph: Graph,
295    ns_sink_id: u64,
296}
297
298impl Drop for ReactiveDescribeHandle {
299    fn drop(&mut self) {
300        self.graph.unsubscribe_namespace_change(self.ns_sink_id);
301    }
302}
303
304// Send + Sync compile-time assertion.
305const _: fn() = || {
306    fn assert_send_sync<T: Send + Sync>() {}
307    assert_send_sync::<ReactiveDescribeHandle>();
308};
309
310impl Graph {
311    /// Subscribe to live topology snapshots. The sink fires immediately
312    /// with the current [`GraphDescribeOutput`] (push-on-subscribe per
313    /// canonical §2.5.2 / R3.6.1) and then again with a fresh snapshot
314    /// every time a node is added, removed, mounted, unmounted, or the
315    /// graph is destroyed.
316    ///
317    /// Returns a [`ReactiveDescribeHandle`] — dropping it unsubscribes.
318    ///
319    /// This is the `reactive: true` mode from canonical §3.6.1. The
320    /// `reactive: "diff"` (changeset) mode is deferred to Phase 14.
321    ///
322    /// Note: `set_deps` topology changes fire via Core's topology
323    /// primitive, not this Graph-level namespace hook. If callers also
324    /// need `set_deps` notifications, compose with
325    /// [`graphrefly_core::Core::subscribe_topology`].
326    ///
327    /// The sink captures only a [`Weak`] reference to the graph's inner
328    /// state, so the `namespace_sinks` → sink → Graph → `namespace_sinks`
329    /// Arc cycle is broken at the sink edge (see P6 in the Slice F /qa
330    /// closing notes).
331    pub fn describe_reactive(&self, sink: DescribeSink) -> ReactiveDescribeHandle {
332        // Push-on-subscribe: fire current snapshot once before installing
333        // the listener. Sink runs without any Graph lock held.
334        sink(&self.describe());
335
336        // Capture Weak<inner> + Core (clone) to break the
337        // namespace_sinks → sink → Graph → namespace_sinks Arc cycle.
338        // If the user leaks the handle, the graph still drops cleanly
339        // because the sink's Weak ref does not keep `inner` alive.
340        let weak_inner: Weak<Mutex<GraphInner>> = Arc::downgrade(&self.inner);
341        let core: Core = self.core.clone();
342        let ns_sink = Arc::new(move || {
343            let Some(arc_inner) = weak_inner.upgrade() else {
344                // Graph dropped; silent no-op.
345                return;
346            };
347            let graph = Graph {
348                core: core.clone(),
349                inner: arc_inner,
350            };
351            let snapshot = graph.describe();
352            sink(&snapshot);
353        });
354        let ns_sink_id = self.subscribe_namespace_change(ns_sink);
355        ReactiveDescribeHandle {
356            graph: self.clone(),
357            ns_sink_id,
358        }
359    }
360}