Skip to main content

graphrefly_graph/
describe.rs

1//! `Graph::describe()` — JSON form of canonical spec §3.6 + Appendix B.
2//!
3//! D246: describe logic is a free fn [`describe_of`] over
4//! `(&dyn CoreFull, &Rc<RefCell<GraphInner>>)` so the one [`Graph`]
5//! (`crate::Graph`) reuses it, AND so the in-wave reactive-describe
6//! `MailboxOp::Defer` closure (D246 rule 6) can run it through the
7//! `&dyn CoreFull` it is handed (the one facade carries read-only
8//! inspection). `ReactiveDescribeHandle` holds ids only (Core-free,
9//! `Send`); there is **no RAII `Drop`** (D246 rule 3) — teardown is the
10//! owner-invoked [`ReactiveDescribeHandle::detach`].
11//!
12//! # Value rendering — raw vs. binding-rendered
13//!
14//! Canonical TS surfaces `value: T` directly. The Rust port preserves
15//! the handle-protocol cleaving plane (`value: DescribeValue`):
16//! `Handle(HandleId)` raw u64 (default) or `Rendered(serde_json::Value)`
17//! via [`DebugBindingBoundary`].
18
19use std::cell::RefCell;
20use std::rc::{Rc, Weak};
21
22use graphrefly_core::{
23    Core, CoreFull, HandleId, NodeId, NodeKind, OperatorOp, TerminalKind, TopologyEvent,
24    TopologySubscriptionId, NO_HANDLE,
25};
26use indexmap::IndexMap;
27use serde::{Serialize, Serializer};
28
29use crate::debug::DebugBindingBoundary;
30use crate::graph::{register_ns_sink, unregister_ns_sink, GraphInner};
31
32/// Top-level `describe()` output (canonical Appendix B JSON schema).
33///
34/// # `factory` + `factoryArgs` (R3.1.2, D285)
35///
36/// `factory` / `factory_args` are populated from
37/// [`crate::graph::GraphInner::factory`] / `factory_args` (set via
38/// [`crate::Graph::tag_factory`]). Both use `skip_serializing_if =
39/// "Option::is_none"` to match the pure-ts spread-conditional shape at
40/// `packages/pure-ts/src/graph/graph.ts:3508-3509` — a cold `describe()`
41/// before any `tag_factory` call OMITS the `factory` / `factoryArgs`
42/// keys entirely (not serializes them as `null`). The pure-ts arm
43/// pins this via the `"factoryArgs" in desc` assertion in
44/// `scenarios/graph/tag-factory.test.ts:70` (QA-A2 invariant); the
45/// Rust JSON shape converges via `skip_serializing_if` + `rename =
46/// "factoryArgs"` for the camelCase key parity.
47#[derive(Debug, Clone, Serialize)]
48pub struct GraphDescribeOutput {
49    /// Graph name as set at construction / mount.
50    pub name: String,
51    /// Local nodes by name.
52    pub nodes: IndexMap<String, NodeDescribe>,
53    /// Local edges (dep → consumer).
54    pub edges: Vec<EdgeDescribe>,
55    /// Mounted child names.
56    pub subgraphs: Vec<String>,
57    /// R3.1.2 factory provenance — name set via
58    /// [`crate::Graph::tag_factory`]. Omitted from JSON when `None`
59    /// (matches pure-ts spread-conditional shape).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub factory: Option<String>,
62    /// R3.1.2 factory args — paired with [`Self::factory`]. Omitted
63    /// from JSON when `None`. Serialized as the `factoryArgs` camelCase
64    /// key to match pure-ts output byte-for-byte.
65    #[serde(
66        default,
67        skip_serializing_if = "Option::is_none",
68        rename = "factoryArgs"
69    )]
70    pub factory_args: Option<serde_json::Value>,
71}
72
73/// Per-node descriptor.
74///
75/// # JSON-shape disambiguation (D279, 2026-05-22, E-ii.3 — Rust ↔ TS parity)
76///
77/// Sentinel-vs-JSON-null disambiguation matches the TS `describeNode`
78/// shape (`packages/pure-ts/src/core/meta.ts`
79/// `DescribeNodeOutput { value?: unknown, sentinel?: boolean }`):
80///
81/// - Sentinel cache (`cache == NO_HANDLE` AND `status ==
82///   NodeStatus::Sentinel`) → `value` key omitted from JSON;
83///   `sentinel: true` present.
84/// - Legitimate JSON-null user value (`DescribeValue::Rendered(Value::Null)`
85///   under [`crate::Graph::describe_with_debug`]) → `"value": null`
86///   present; `sentinel` key omitted.
87/// - Any other value → `"value": <v>` present; `sentinel` key omitted.
88///
89/// Pre-D279 the Rust shape diverged: `value: None` (sentinel) and
90/// `value: Some(DescribeValue::Rendered(Value::Null))` (rendered null)
91/// both serialized to `"value": null` — JSON-indistinguishable.
92/// `#[serde(skip_serializing_if = "Option::is_none")]` on both `value`
93/// and `sentinel` enforces the converged TS-shape. Rust additionally
94/// always emits `status` (TS makes it optional via `includeFields`);
95/// the `sentinel` flag is redundant for Rust consumers that check
96/// `status == "sentinel"`, but its presence preserves cross-impl
97/// shape parity (cross-track-ledger §2 row 2026-05-22).
98#[derive(Debug, Clone, Serialize)]
99pub struct NodeDescribe {
100    /// `"state"` / `"derived"` / `"dynamic"` / `"producer"`.
101    #[serde(rename = "type")]
102    pub r#type: NodeTypeStr,
103    /// Lifecycle status (canonical Appendix B enum).
104    pub status: NodeStatus,
105    /// Current cache value. `None` when sentinel (`NO_HANDLE`);
106    /// omitted from serialized JSON via `skip_serializing_if` (D279).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub value: Option<DescribeValue>,
109    /// D279 (2026-05-22): explicit sentinel discriminator matching
110    /// TS's `sentinel?: boolean` field. `Some(true)` when `status ==
111    /// NodeStatus::Sentinel`; `None` otherwise (omitted from JSON via
112    /// `skip_serializing_if`).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub sentinel: Option<bool>,
115    /// Dep names in declaration order. **D301 (Q4 user-locked Option
116    /// B, 2026-05-26):** unnamed deps render as the empty string `""`
117    /// matching TS pure-ts `Node._deps.map(d => d.node.name ?? "")`
118    /// (`packages/pure-ts/src/core/meta.ts:257`) — cross-impl wire-
119    /// shape parity. Pre-D301 Rust emitted `_anon_<NodeId>` which
120    /// leaked `NodeId`s + diverged from TS; the marker is retained at
121    /// the persistence surface (`snapshot.rs:310`) where decode-time
122    /// diagnostic fidelity (`SnapshotError::UnresolvableDeps`)
123    /// outweighs wire-shape parity. See D301 B.b decision-log entry
124    /// for the persistence-vs-presentation rationale.
125    pub deps: Vec<String>,
126    /// Operator discriminant (e.g. `"map"`); `None` for non-operators.
127    #[serde(default, skip_serializing_if = "Option::is_none", rename = "operator")]
128    pub operator_kind: Option<String>,
129    /// Free-form metadata per canonical Appendix B. Always `None` in
130    /// this slice (metadata-storage primitive not yet shipped).
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub meta: Option<serde_json::Value>,
133}
134
135/// Per-node cache value in `describe` output. Serialized uniformly
136/// without an enum tag.
137#[derive(Debug, Clone, PartialEq)]
138pub enum DescribeValue {
139    /// Raw handle view (default for [`crate::Graph::describe`]).
140    Handle(HandleId),
141    /// Binding-rendered view (from [`crate::Graph::describe_with_debug`]).
142    Rendered(serde_json::Value),
143}
144
145impl Serialize for DescribeValue {
146    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
147        match self {
148            DescribeValue::Handle(h) => ser.serialize_u64(h.raw()),
149            DescribeValue::Rendered(v) => v.serialize(ser),
150        }
151    }
152}
153
154/// Edge between two named nodes (or a named node and an empty-string
155/// rendering of an unnamed dep — D301 B, 2026-05-26).
156#[derive(Debug, Clone, Serialize)]
157pub struct EdgeDescribe {
158    pub from: String,
159    pub to: String,
160}
161
162/// Canonical Appendix B `type` enum.
163#[derive(Debug, Clone, Copy, Serialize)]
164#[serde(rename_all = "lowercase")]
165pub enum NodeTypeStr {
166    State,
167    Derived,
168    Dynamic,
169    Producer,
170    Effect,
171    Operator,
172}
173
174/// Canonical Appendix B `status` enum.
175#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
176#[serde(rename_all = "lowercase")]
177pub enum NodeStatus {
178    Sentinel,
179    Pending,
180    Dirty,
181    Settled,
182    Resolved,
183    Completed,
184    Errored,
185}
186
187/// β/D243: describe over the read-only-inspection `&dyn CoreFull` (so
188/// the in-wave `MailboxOp::Defer` reactive-describe closure can run it)
189/// + the namespace handle. Pure read; no `Core`-mutation.
190pub(crate) fn describe_of(
191    core: &dyn CoreFull,
192    inner_arc: &Rc<RefCell<GraphInner>>,
193    debug: Option<&dyn DebugBindingBoundary>,
194) -> GraphDescribeOutput {
195    let (graph_name, local_names, subgraphs, names_iter, factory, factory_args) = {
196        let inner = inner_arc.borrow_mut();
197        let graph_name = inner.name.clone();
198        let local_names: IndexMap<NodeId, String> = inner
199            .names
200            .iter()
201            .map(|(name, id)| (*id, name.clone()))
202            .collect();
203        let subgraphs: Vec<String> = inner.children.keys().cloned().collect();
204        let names_iter: Vec<(String, NodeId)> =
205            inner.names.iter().map(|(n, id)| (n.clone(), *id)).collect();
206        // R3.1.2 (D285): read fresh on every describe call so a
207        // subsequent topology event observes the latest tag without
208        // needing a `_topologyVersion`-equivalent cache invalidation
209        // field. Parity test `tag-factory.test.ts:96-140` covers this.
210        let factory = inner.factory.clone();
211        let factory_args = inner.factory_args.clone();
212        (
213            graph_name,
214            local_names,
215            subgraphs,
216            names_iter,
217            factory,
218            factory_args,
219        )
220    };
221
222    let mut nodes: IndexMap<String, NodeDescribe> = IndexMap::new();
223    let mut edges: Vec<EdgeDescribe> = Vec::new();
224
225    for (name, id) in &names_iter {
226        let kind = core.kind_of(*id).unwrap_or(NodeKind::State);
227        let cache = core.cache_of(*id);
228        let terminal = core.is_terminal(*id);
229        let dirty = core.is_dirty(*id);
230        let fired = core.has_fired_once(*id);
231
232        let dep_ids = core.deps_of(*id);
233        let dep_names: Vec<String> = dep_ids
234            .iter()
235            .map(|d| {
236                // D301 (Q4 user-locked Option B, 2026-05-26): unnamed
237                // deps render as empty string for cross-impl shape
238                // parity with TS pure-ts `Node._deps.map(d => d.node.name
239                // ?? "")` (packages/pure-ts/src/core/meta.ts:257). Pre-
240                // D301 Rust emitted `_anon_<NodeId>` which (a) leaked
241                // NodeIds into the wire shape and (b) diverged from TS.
242                // Loses Rust within-describe anon-dep disambiguation;
243                // no verified consumer at lock time. Upgrade to a
244                // bilateral monotonic counter or structured form
245                // remains forward-compatible non-breaking widening.
246                local_names.get(d).cloned().unwrap_or_default()
247            })
248            .collect();
249        for dep_name in &dep_names {
250            edges.push(EdgeDescribe {
251                from: dep_name.clone(),
252                to: name.clone(),
253            });
254        }
255
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        let status = status_of(kind, cache, terminal, dirty, fired);
269        // D279 (2026-05-22, E-ii.3): explicit sentinel discriminator to
270        // match TS's `sentinel?: boolean` shape. Only set when status is
271        // `Sentinel`; `None` otherwise (omitted from JSON).
272        let sentinel = if status == NodeStatus::Sentinel {
273            Some(true)
274        } else {
275            None
276        };
277        nodes.insert(
278            name.clone(),
279            NodeDescribe {
280                r#type: type_str_of(kind),
281                status,
282                value,
283                sentinel,
284                deps: dep_names,
285                operator_kind,
286                meta: None,
287            },
288        );
289    }
290
291    GraphDescribeOutput {
292        name: graph_name,
293        nodes,
294        edges,
295        subgraphs,
296        factory,
297        factory_args,
298    }
299}
300
301fn type_str_of(kind: NodeKind) -> NodeTypeStr {
302    match kind {
303        NodeKind::State => NodeTypeStr::State,
304        NodeKind::Producer => NodeTypeStr::Producer,
305        NodeKind::Derived => NodeTypeStr::Derived,
306        NodeKind::Dynamic => NodeTypeStr::Dynamic,
307        NodeKind::Operator(_) => NodeTypeStr::Operator,
308    }
309}
310
311fn operator_op_name(op: OperatorOp) -> String {
312    match op {
313        OperatorOp::Map { .. } => "map",
314        OperatorOp::Filter { .. } => "filter",
315        OperatorOp::Scan { .. } => "scan",
316        OperatorOp::Reduce { .. } => "reduce",
317        OperatorOp::DistinctUntilChanged { .. } => "distinctUntilChanged",
318        OperatorOp::Pairwise { .. } => "pairwise",
319        OperatorOp::Combine { .. } => "combine",
320        OperatorOp::WithLatestFrom { .. } => "withLatestFrom",
321        OperatorOp::Merge => "merge",
322        OperatorOp::Take { .. } => "take",
323        OperatorOp::Skip { .. } => "skip",
324        OperatorOp::TakeWhile { .. } => "takeWhile",
325        OperatorOp::Last { .. } => "last",
326        OperatorOp::Tap { .. } => "tap",
327        OperatorOp::TapFirst { .. } => "tapFirst",
328        OperatorOp::Valve => "valve",
329        OperatorOp::Settle { .. } => "settle",
330    }
331    .to_owned()
332}
333
334/// Canonical-spec §3.6.1 status mapping. Precedence: errored >
335/// completed > dirty > (cache-cleared) > settled > pending > sentinel.
336/// R1.3.7.b: `cache == NO_HANDLE` discriminates Sentinel-vs-Settled
337/// BEFORE the `fired` check (post-INVALIDATE fired compute → Sentinel).
338fn status_of(
339    kind: NodeKind,
340    cache: HandleId,
341    terminal: Option<TerminalKind>,
342    dirty: bool,
343    fired: bool,
344) -> NodeStatus {
345    match terminal {
346        Some(TerminalKind::Error(_)) => return NodeStatus::Errored,
347        Some(TerminalKind::Complete) => return NodeStatus::Completed,
348        None => {}
349    }
350    if dirty {
351        return NodeStatus::Dirty;
352    }
353    if cache == NO_HANDLE {
354        return match kind {
355            NodeKind::State => NodeStatus::Sentinel,
356            NodeKind::Producer | NodeKind::Derived | NodeKind::Dynamic | NodeKind::Operator(_) => {
357                if fired {
358                    NodeStatus::Sentinel
359                } else {
360                    NodeStatus::Pending
361                }
362            }
363        };
364    }
365    NodeStatus::Settled
366}
367
368// -------------------------------------------------------------------
369// Reactive describe (canonical §3.6.1 `reactive: true` mode)
370// -------------------------------------------------------------------
371
372/// Sink type for reactive describe. D272 (2026-05-21): single-owner-
373/// thread shape — `Rc<dyn Fn>` matches D248's `!Send + !Sync` Core. The
374/// `assert_not_impl_any!` below locks D248 intent at the type system.
375pub type DescribeSink = Rc<dyn Fn(&GraphDescribeOutput)>;
376
377static_assertions::assert_not_impl_any!(DescribeSink: Send, Sync);
378
379/// Id-bearing handle for a reactive describe subscription.
380///
381/// D246 rule 3: Core-free (`Send`), **no RAII `Drop`** — teardown is
382/// the owner-invoked synchronous [`Self::detach`]. This eliminates the
383/// "unsubscribe in `Drop`" deadlock class. The embedder's
384/// Teardown is the owner-invoked [`Self::detach`]`(core)` — REQUIRED.
385/// The ns-sink is also collected by `graph.destroy(core)`; the Core
386/// topology sub is opened via raw `core.subscribe_topology` and is NOT
387/// `OwnedCore`-tracked, so only `detach(core)` collects it.
388#[must_use = "ReactiveDescribeHandle holds a Core topology sub NOT tracked by OwnedCore; you MUST call detach(core) or it leaks"]
389pub struct ReactiveDescribeHandle {
390    inner: Rc<RefCell<GraphInner>>,
391    ns_sink_id: u64,
392    /// Slice V3 D5: Core topology sub for `DepsChanged` (edges change
393    /// without a namespace change). D246 r6: re-snapshot is in-wave
394    /// `MailboxOp::Defer`'d (the topology event fires inside a Core
395    /// wave; `describe_of` runs via the handed `&dyn CoreFull`).
396    topo_sub_id: TopologySubscriptionId,
397}
398
399impl ReactiveDescribeHandle {
400    /// Owner-invoked, synchronous detach (D246 rule 3). Topology sub
401    /// first (so a topo fire mid-detach can't re-snapshot through a
402    /// half-removed namespace sink), then the namespace sink.
403    pub fn detach(&self, core: &Core) {
404        core.unsubscribe_topology(self.topo_sub_id);
405        unregister_ns_sink(&self.inner, self.ns_sink_id);
406    }
407}
408
409/// Build a reactive-describe subscription. Push-on-subscribe fires
410/// the current snapshot once, then re-fires on every namespace change
411/// (owner-side `&Core`, D246 r2) and on `set_deps` `DepsChanged`
412/// (in-wave `MailboxOp::Defer` → `&dyn CoreFull`, D246 r6).
413pub(crate) fn describe_reactive_in(
414    core: &Core,
415    inner: &Rc<RefCell<GraphInner>>,
416    sink: &DescribeSink,
417) -> ReactiveDescribeHandle {
418    // Push-on-subscribe (no lock held).
419    sink(&describe_of(core, inner, None));
420
421    // Namespace-change path (owner-side `&Core`, β/D231).
422    let weak_inner: Weak<RefCell<GraphInner>> = Rc::downgrade(inner);
423    let sink_ns = sink.clone();
424    let ns_sink: crate::graph::NamespaceChangeSink = Rc::new(move |c: &Core| {
425        let Some(arc_inner) = weak_inner.upgrade() else {
426            return;
427        };
428        sink_ns(&describe_of(c, &arc_inner, None));
429    });
430    let ns_sink_id = register_ns_sink(inner, ns_sink);
431
432    // Topology path (set_deps → `DepsChanged`, fired inside a Core
433    // wave): re-snapshot via an in-wave `MailboxOp::Defer` so it runs
434    // owner-side with a real `&dyn CoreFull` (D243/D233).
435    let weak_inner_topo: Weak<RefCell<GraphInner>> = Rc::downgrade(inner);
436    // D249/S2c: owner-side `!Send` `DeferQueue` (the closure captures a
437    // `Weak<RefCell<GraphInner>>`, `!Send`). Owner-thread-only `Rc` —
438    // fine: this topo sink is `!Send` (D248) and fires owner-side.
439    let deferred = core.defer_queue();
440    let sink_topo = sink.clone();
441    // D246 rule 8 (S4): reusable coalescing slot. Re-snapshot is
442    // idempotent at drain time (`describe_of` reads current state), so
443    // N `DepsChanged` in one wave need only ONE deferred re-snapshot,
444    // not N boxed closures. `scheduled` (owner-thread-only `Cell`) gates
445    // a single `Box` post per drain; the closure clears it so the next
446    // wave re-arms. Behaviour-equivalent (deferred-snapshot acceptable,
447    // D243/D244) — one alloc + one snapshot per wave, not per emission.
448    let scheduled = Rc::new(std::cell::Cell::new(false));
449    let topo_sink: Rc<dyn Fn(&TopologyEvent)> = Rc::new(move |event: &TopologyEvent| {
450        if matches!(event, TopologyEvent::DepsChanged { .. }) {
451            if scheduled.get() {
452                return; // already armed for this drain — coalesce.
453            }
454            // INVARIANT (QA, 2026-05-19): the `upgrade()` check runs
455            // BEFORE `scheduled.set(true)`, so a graph-gone fire never
456            // poisons the slot (`scheduled` stays `false`; a later
457            // fire on the next wave re-tries the upgrade fresh).
458            let Some(arc_inner) = weak_inner_topo.upgrade() else {
459                return;
460            };
461            let s = sink_topo.clone();
462            let sched = Rc::clone(&scheduled);
463            sched.set(true);
464            // The Defer closure captures no `HandleId` (only an
465            // `Arc<sink>` + a `Weak`-upgraded inner) — if the Core
466            // is gone (`false`) the snapshot simply won't fire;
467            // nothing to release (D235 P8 pattern).
468            let _ = deferred.post(Box::new(move |cf: &dyn CoreFull| {
469                sched.set(false);
470                s(&describe_of(cf, &arc_inner, None));
471            }));
472        }
473    });
474    let topo_sub_id = core.subscribe_topology(topo_sink);
475
476    ReactiveDescribeHandle {
477        inner: inner.clone(),
478        ns_sink_id,
479        topo_sub_id,
480    }
481}