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}