Skip to main content

graphrefly_graph/
graph.rs

1//! `Graph` container — namespace, sugar constructors, and lifecycle
2//! pass-throughs over `Arc<Core>`.
3//!
4//! `mount` / `describe` / `observe` live in sibling modules.
5
6use std::sync::{Arc, Weak};
7
8use graphrefly_core::{
9    BindingBoundary, Core, EqualsMode, FnId, HandleId, LockId, NodeId, PauseError, ResumeReport,
10    SetDepsError, Sink, Subscription,
11};
12use indexmap::IndexMap;
13use parking_lot::Mutex;
14
15use crate::mount::{GraphRemoveAudit, MountError};
16
17/// Namespace path separator (canonical spec R3.5.1).
18pub(crate) const PATH_SEP: &str = "::";
19
20/// Errors from [`Graph::remove`].
21#[derive(Debug, thiserror::Error)]
22pub enum RemoveError {
23    #[error("Graph::remove: name `{0}` not found (neither a node nor a mounted subgraph)")]
24    NotFound(String),
25    #[error("Graph::remove: graph has been destroyed")]
26    Destroyed,
27}
28
29/// Signal kind for [`Graph::signal`] (canonical R3.7.1).
30#[derive(Debug, Clone, Copy)]
31pub enum SignalKind {
32    /// Wipe caches (with meta filtering per R3.7.2).
33    Invalidate,
34    /// Pause every named node with the given lock.
35    Pause(LockId),
36    /// Resume every named node with the given lock.
37    Resume(LockId),
38    /// Mark every named node as terminal with `COMPLETE`.
39    /// Slice V3: extends `SignalKind` per D3 in porting-deferred.md.
40    Complete,
41    /// Mark every named node as terminal with `ERROR` carrying the given handle.
42    /// Slice V3: extends `SignalKind` per D3 in porting-deferred.md.
43    Error(HandleId),
44}
45
46/// Path resolution errors returned by [`Graph::try_resolve_checked`].
47/// Slice V3: introduces typed path errors per porting-deferred.md.
48#[derive(Debug, thiserror::Error)]
49pub enum PathError {
50    /// Path is empty.
51    #[error("Path is empty")]
52    Empty,
53    /// A `..` segment was used but the graph has no parent.
54    #[error("Path segment `..` used on root graph (no parent)")]
55    NoParent,
56    /// A segment named a child graph that doesn't exist.
57    #[error("Path segment `{0}` does not match any child graph")]
58    ChildNotFound(String),
59    /// The graph has been destroyed.
60    #[error("Graph has been destroyed")]
61    Destroyed,
62}
63
64/// Errors from name registration.
65#[derive(Debug, thiserror::Error)]
66pub enum NameError {
67    #[error("Graph::add: name `{0}` already registered in this graph")]
68    Collision(String),
69    #[error("Graph: name `{0}` may not contain the `::` path separator")]
70    InvalidName(String),
71    #[error("Graph: name `{0}` uses the reserved `_anon_` prefix (collides with anonymous node describe format)")]
72    ReservedPrefix(String),
73    #[error("Graph: graph has been destroyed; further registration refused")]
74    Destroyed,
75}
76
77/// Internal mutable state — namespace, mount tree, lifecycle bookkeeping.
78///
79/// Kept behind a single `Mutex<GraphInner>` because all mutations are
80/// short structural updates (insert into `IndexMap`, insert child `Graph`,
81/// pop name on unmount). The `Core` dispatcher behind `Arc<Core>` has its
82/// own lock for the wave engine — these two locks never nest in the
83/// same direction (`Graph` → `Core` only, never `Core` → `Graph`), avoiding
84/// deadlock by acquisition order.
85/// Callback type for graph-level namespace change notifications.
86/// Used by reactive describe and reactive `observe_all` to react to
87/// `add()`, `remove()`, mount/unmount, and `destroy()` — events that
88/// change the graph's namespace AFTER Core registration completes.
89pub(crate) type NamespaceChangeSink = Arc<dyn Fn() + Send + Sync>;
90
91pub(crate) struct GraphInner {
92    pub(crate) name: String,
93    /// Local namespace: name → `NodeId`. Insertion order is load-bearing
94    /// for `describe()` stability (canonical Appendix C "Git-versioned
95    /// graphs" scenario).
96    pub(crate) names: IndexMap<String, NodeId>,
97    /// Reverse lookup. Single-owner (one node has at most one local
98    /// name in this graph; if `add` is called twice with different
99    /// names, the second errors via `NameError::Collision`).
100    pub(crate) names_inverse: IndexMap<NodeId, String>,
101    /// Mounted child subgraphs. Insertion order = describe `subgraphs`
102    /// order. Each child shares this graph's `Arc<Core>` (cross-Core
103    /// mount is post-M6 per session-doc Open Question 1).
104    pub(crate) children: IndexMap<String, Graph>,
105    /// Parent inner-state pointer (for `ancestors()`). Weak to break
106    /// the strong cycle: parent owns child via `children`, so child's
107    /// back-pointer must not pin the parent alive.
108    pub(crate) parent: Option<Weak<Mutex<GraphInner>>>,
109    /// True after `destroy()` completes — subsequent mutations refuse.
110    pub(crate) destroyed: bool,
111    /// Namespace-change sinks — fired from `add()`, `remove()`, etc.
112    /// after the inner lock is dropped. Keyed by subscription id.
113    pub(crate) namespace_sinks: IndexMap<u64, NamespaceChangeSink>,
114    pub(crate) next_ns_sink_id: u64,
115}
116
117/// Graph container — Phase 1+ public API.
118///
119/// Holds an [`Arc<Core>`] internally plus an [`Arc<Mutex<GraphInner>>`]
120/// for namespace + mount-tree state. Cloning is a cheap refcount bump
121/// on both Arcs — pass `Graph` by value to threads (or share via
122/// `Arc<Graph>` when an outer reference count is needed).
123///
124/// Two clones of the same `Graph` share BOTH the dispatcher state AND
125/// the namespace. Mutations from one clone are visible to all clones.
126#[derive(Clone)]
127pub struct Graph {
128    pub(crate) core: Core,
129    pub(crate) inner: Arc<Mutex<GraphInner>>,
130}
131
132impl std::fmt::Debug for Graph {
133    /// Compact `Debug` summary — `name` + counts. Avoids printing the
134    /// full namespace map (which would lock + clone strings each call,
135    /// surprising under `dbg!()`). Use [`Graph::describe`] for a full
136    /// snapshot.
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        let inner = self.inner.lock();
139        f.debug_struct("Graph")
140            .field("name", &inner.name)
141            .field("node_count", &inner.names.len())
142            .field("subgraph_count", &inner.children.len())
143            .field("destroyed", &inner.destroyed)
144            .finish_non_exhaustive()
145    }
146}
147
148impl Graph {
149    /// Construct a named, empty root graph wired to the given binding.
150    ///
151    /// `name` becomes the graph's identity for `describe()` output and
152    /// `ancestors()`. May contain colons (`":"`), but the double-colon
153    /// path separator (`"::"`) is reserved; use [`Graph::is_valid_name`]
154    /// to pre-check user input.
155    #[must_use]
156    pub fn new(name: impl Into<String>, binding: Arc<dyn BindingBoundary>) -> Self {
157        Self::with_core(name.into(), Core::new(binding), None)
158    }
159
160    pub(crate) fn with_core(
161        name: String,
162        core: Core,
163        parent: Option<Weak<Mutex<GraphInner>>>,
164    ) -> Self {
165        Self {
166            core,
167            inner: Arc::new(Mutex::new(GraphInner {
168                name,
169                names: IndexMap::new(),
170                names_inverse: IndexMap::new(),
171                children: IndexMap::new(),
172                parent,
173                destroyed: false,
174                namespace_sinks: IndexMap::new(),
175                next_ns_sink_id: 0,
176            })),
177        }
178    }
179
180    /// Construct a fresh root [`Graph`] wrapping an existing [`Core`].
181    /// Used by binding crates (napi-rs, pyo3, wasm) where the same
182    /// `Core` is shared between a `Graph` and direct binding-side
183    /// dispatch (`BenchOperators::register_*`, etc.) so the namespace
184    /// and the operator surface see the same node ids.
185    ///
186    /// Sister method to [`Self::new`]; the difference is that `new`
187    /// constructs a fresh `Core` from a binding, while this one accepts
188    /// a `Core` the caller already owns (typically cloned from
189    /// `BenchCore`).
190    #[must_use]
191    pub fn with_existing_core(name: impl Into<String>, core: Core) -> Self {
192        Self::with_core(name.into(), core, None)
193    }
194
195    /// The graph's name as set at construction (or via `mount` / `mount_new`).
196    #[must_use]
197    pub fn name(&self) -> String {
198        self.inner.lock().name.clone()
199    }
200
201    /// Borrow the underlying [`Core`] — the M1 dispatcher. Useful when you
202    /// need a Core-level method that hasn't yet been surfaced on `Graph`.
203    /// Phase 4+ patterns commonly hold onto the `Core` directly.
204    #[must_use]
205    pub fn core(&self) -> &Core {
206        &self.core
207    }
208
209    /// Whether `name` is a legal local node/subgraph name.
210    /// Rejects names containing `::` (path separator) and names
211    /// starting with `_anon_` (reserved for anonymous-node describe
212    /// format, Slice V3 collision fix).
213    #[must_use]
214    pub fn is_valid_name(name: &str) -> bool {
215        !name.contains(PATH_SEP) && !name.starts_with("_anon_")
216    }
217
218    /// Subscribe to namespace changes (add, remove, mount, unmount,
219    /// destroy). The sink fires AFTER the inner lock is dropped, so
220    /// it can safely call `describe()` or other Graph methods.
221    ///
222    /// Returns a subscription id for unsubscription.
223    pub(crate) fn subscribe_namespace_change(&self, sink: NamespaceChangeSink) -> u64 {
224        let mut inner = self.inner.lock();
225        let id = inner.next_ns_sink_id;
226        inner.next_ns_sink_id += 1;
227        inner.namespace_sinks.insert(id, sink);
228        id
229    }
230
231    /// Unsubscribe a namespace-change sink by id.
232    pub(crate) fn unsubscribe_namespace_change(&self, id: u64) {
233        self.inner.lock().namespace_sinks.shift_remove(&id);
234    }
235
236    /// Fire all namespace-change sinks. Called AFTER inner lock is
237    /// dropped from `add()`, `remove()`, etc.
238    pub(crate) fn fire_namespace_change(&self) {
239        let sinks: Vec<NamespaceChangeSink> = {
240            let inner = self.inner.lock();
241            inner.namespace_sinks.values().cloned().collect()
242        };
243        for sink in sinks {
244            sink();
245        }
246    }
247
248    fn validate_name(name: &str) -> Result<(), NameError> {
249        if name.contains(PATH_SEP) {
250            Err(NameError::InvalidName(name.to_owned()))
251        } else if name.starts_with("_anon_") {
252            Err(NameError::ReservedPrefix(name.to_owned()))
253        } else {
254            Ok(())
255        }
256    }
257
258    // -------------------------------------------------------------------
259    // Namespace (canonical spec §3.5)
260    // -------------------------------------------------------------------
261
262    /// Register an existing `node_id` under `name` in this graph's
263    /// namespace. The node is assumed already-registered with the
264    /// underlying [`Core`] (via [`Graph::core`] or sugar constructors).
265    ///
266    /// Returns the same `node_id` for chaining. Returns
267    /// [`NameError::Destroyed`] if the graph has been
268    /// [`Self::destroy`]ed (symmetric with [`crate::MountError::Destroyed`]).
269    pub fn add(&self, node_id: NodeId, name: impl Into<String>) -> Result<NodeId, NameError> {
270        let name = name.into();
271        Self::validate_name(&name)?;
272        let mut inner = self.inner.lock();
273        if inner.destroyed {
274            return Err(NameError::Destroyed);
275        }
276        if inner.names.contains_key(&name) {
277            return Err(NameError::Collision(name));
278        }
279        inner.names.insert(name.clone(), node_id);
280        inner.names_inverse.insert(node_id, name);
281        drop(inner);
282        self.fire_namespace_change();
283        Ok(node_id)
284    }
285
286    /// Resolve a path to a `NodeId`. Panics if missing — use
287    /// [`Self::try_resolve`] for the non-panicking variant.
288    ///
289    /// Paths use `::` separators: `"validate"` (local), `"payment::validate"`
290    /// (one mount level), `"system::payment::validate"` (two levels).
291    ///
292    /// # Panics
293    ///
294    /// Panics if any path segment is unknown.
295    #[must_use]
296    pub fn node(&self, path: &str) -> NodeId {
297        self.try_resolve(path)
298            .unwrap_or_else(|| panic!("Graph::node: no node at path `{path}`"))
299    }
300
301    /// Non-panicking variant of [`Self::node`]. Returns `None` for any
302    /// resolution failure. For typed error reporting, use
303    /// [`Self::try_resolve_checked`].
304    #[must_use]
305    pub fn try_resolve(&self, path: &str) -> Option<NodeId> {
306        self.try_resolve_checked(path).ok().flatten()
307    }
308
309    /// Path resolution with typed errors. Returns:
310    /// - `Ok(Some(id))` — path resolved to a node.
311    /// - `Ok(None)` — path segments resolved but the final name wasn't found.
312    /// - `Err(PathError)` — structural path error (empty, no parent for `..`,
313    ///   child graph not found, destroyed).
314    ///
315    /// Supports `..` segments for parent traversal (R3.5.2):
316    /// `"..::sibling::node"` walks up to parent, then down into `sibling`.
317    ///
318    /// Slice V3: adds typed errors + `..` cross-subgraph paths.
319    pub fn try_resolve_checked(&self, path: &str) -> Result<Option<NodeId>, PathError> {
320        if path.is_empty() {
321            return Err(PathError::Empty);
322        }
323        let inner = self.inner.lock();
324        if inner.destroyed {
325            return Err(PathError::Destroyed);
326        }
327
328        let segments: Vec<&str> = path.split(PATH_SEP).collect();
329        let first = segments[0];
330
331        if first == ".." {
332            // Parent traversal.
333            let parent_weak = inner.parent.as_ref().ok_or(PathError::NoParent)?;
334            let parent_inner = parent_weak.upgrade().ok_or(PathError::NoParent)?;
335            // Reconstruct parent Graph from the Arc<Mutex<GraphInner>>.
336            let parent = Graph {
337                core: self.core.clone(),
338                inner: parent_inner,
339            };
340            drop(inner);
341            if segments.len() == 1 {
342                // `..` alone — nonsensical (parent is a graph, not a node).
343                return Ok(None);
344            }
345            let rest = segments[1..].join(PATH_SEP);
346            parent.try_resolve_checked(&rest)
347        } else if segments.len() > 1 {
348            // Multi-segment: first is a child graph name.
349            let child = inner
350                .children
351                .get(first)
352                .cloned()
353                .ok_or_else(|| PathError::ChildNotFound(first.to_string()))?;
354            drop(inner);
355            let rest = segments[1..].join(PATH_SEP);
356            child.try_resolve_checked(&rest)
357        } else {
358            // Single segment — local lookup.
359            Ok(inner.names.get(first).copied())
360        }
361    }
362
363    /// Reverse lookup: returns the local name for a `node_id`, or `None`
364    /// if the node is unnamed in this graph (it may still be registered
365    /// in Core — `Graph::core().node_ids()` enumerates all of them).
366    #[must_use]
367    pub fn name_of(&self, node_id: NodeId) -> Option<String> {
368        self.inner.lock().names_inverse.get(&node_id).cloned()
369    }
370
371    /// Number of named nodes in this graph (excludes mounted children's
372    /// nodes, and excludes Core-only unnamed nodes). Recursive count is
373    /// available by summing across [`Self::child_names`] + per-child
374    /// `node_count()`.
375    #[must_use]
376    pub fn node_count(&self) -> usize {
377        self.inner.lock().names.len()
378    }
379
380    /// Snapshot of local node names in insertion order.
381    #[must_use]
382    pub fn node_names(&self) -> Vec<String> {
383        self.inner.lock().names.keys().cloned().collect()
384    }
385
386    /// Snapshot of mounted child names in insertion order.
387    #[must_use]
388    pub fn child_names(&self) -> Vec<String> {
389        self.inner.lock().children.keys().cloned().collect()
390    }
391
392    /// Returns `true` after [`Self::destroy`] has been called.
393    #[must_use]
394    pub fn is_destroyed(&self) -> bool {
395        self.inner.lock().destroyed
396    }
397
398    // -------------------------------------------------------------------
399    // Sugar constructors (canonical spec §3.9)
400    //
401    // Each registers via Core THEN inserts into the local namespace.
402    // The pair is not atomic in the Core sense (Core registration
403    // happens before namespace insert), but Core's `register_state` is
404    // synchronous and infallible, so the sequence is safe: a Name
405    // collision rejects the whole call and leaks one Core node id (the
406    // node lives but has no name). Per `feedback_no_backward_compat.md`
407    // we don't catch + un-register; if a future caller needs that, the
408    // Core would need a `unregister_node` API which is part of M2
409    // mount/unmount cleanup — not yet wired.
410    // -------------------------------------------------------------------
411
412    /// Register a state node under `name`. `initial` of `None` starts
413    /// sentinel; `Some(h)` pre-populates the cache. Returns the
414    /// underlying `NodeId`.
415    ///
416    /// # Panics
417    ///
418    /// Structurally never panics — state-node registration has no
419    /// reachable [`graphrefly_core::RegisterError`] variants for any
420    /// caller-supplied input (state nodes have no deps and no operator
421    /// scratch). The `.expect()` is present to satisfy the typed-error
422    /// surface from Slice H; the `Result` return covers `NameError`
423    /// (namespace conflicts) only.
424    pub fn state(
425        &self,
426        name: impl Into<String>,
427        initial: Option<HandleId>,
428    ) -> Result<NodeId, NameError> {
429        let id = self
430            .core
431            .register_state(initial.unwrap_or(graphrefly_core::NO_HANDLE), false)
432            .expect("invariant: register_state has no error variants reachable for caller-controlled inputs");
433        self.add(id, name)
434    }
435
436    /// Register a static-derived node — fn fires on every dep change
437    /// with all deps tracked. Defaults to gated first-run (R2.5.3); use
438    /// [`Self::derived_with_partial`] for the partial-mode variant
439    /// (D011 / R5.4).
440    ///
441    /// # Panics
442    ///
443    /// Panics if any element of `deps` is not a registered node id, or
444    /// if a dep is terminal and not resubscribable. The `Result` return
445    /// covers `NameError` (namespace conflicts); construction-time
446    /// errors at the Core layer ([`graphrefly_core::RegisterError`]) are
447    /// caller-contract violations and surface as panics.
448    pub fn derived(
449        &self,
450        name: impl Into<String>,
451        deps: &[NodeId],
452        fn_id: FnId,
453        equals: EqualsMode,
454    ) -> Result<NodeId, NameError> {
455        let id = self
456            .core
457            .register_derived(deps, fn_id, equals, false)
458            .expect("invariant: caller has validated dep ids before calling register_derived");
459        self.add(id, name)
460    }
461
462    /// Register a dynamic-derived node — fn declares which dep indices
463    /// it actually read this run. Defaults to gated first-run (R2.5.3).
464    ///
465    /// # Panics
466    ///
467    /// Panics if any element of `deps` is not a registered node id, or
468    /// if a dep is terminal and not resubscribable. The `Result` return
469    /// covers `NameError` (namespace conflicts); construction-time
470    /// errors at the Core layer ([`graphrefly_core::RegisterError`]) are
471    /// caller-contract violations and surface as panics.
472    pub fn dynamic(
473        &self,
474        name: impl Into<String>,
475        deps: &[NodeId],
476        fn_id: FnId,
477        equals: EqualsMode,
478    ) -> Result<NodeId, NameError> {
479        let id = self
480            .core
481            .register_dynamic(deps, fn_id, equals, false)
482            .expect("invariant: caller has validated dep ids before calling register_dynamic");
483        self.add(id, name)
484    }
485
486    // -------------------------------------------------------------------
487    // Named-sugar wrappers (canonical spec §3.2.1)
488    //
489    // `set(name, h)` / `get(name)` / `invalidate(name)` / `complete(name)`
490    // / `error(name, h)` — thin resolve-then-forward wrappers so callers
491    // can operate by name instead of `NodeId`.
492    // -------------------------------------------------------------------
493
494    /// Emit a value on a named state node.
495    ///
496    /// # Panics
497    ///
498    /// Panics if `name` does not resolve to a node.
499    pub fn set(&self, name: &str, handle: HandleId) {
500        let id = self.node(name);
501        self.core.emit(id, handle);
502    }
503
504    /// Read the cached value of a named node. Returns
505    /// [`graphrefly_core::NO_HANDLE`] if sentinel.
506    ///
507    /// # Panics
508    ///
509    /// Panics if `name` does not resolve.
510    #[must_use]
511    pub fn get(&self, name: &str) -> HandleId {
512        let id = self.node(name);
513        self.core.cache_of(id)
514    }
515
516    /// Clear the cache of a named node and cascade `[INVALIDATE]`.
517    ///
518    /// # Panics
519    ///
520    /// Panics if `name` does not resolve.
521    pub fn invalidate_by_name(&self, name: &str) {
522        let id = self.node(name);
523        self.core.invalidate(id);
524    }
525
526    /// Mark a named node terminal with COMPLETE.
527    ///
528    /// # Panics
529    ///
530    /// Panics if `name` does not resolve.
531    pub fn complete_by_name(&self, name: &str) {
532        let id = self.node(name);
533        self.core.complete(id);
534    }
535
536    /// Mark a named node terminal with ERROR.
537    ///
538    /// # Panics
539    ///
540    /// Panics if `name` does not resolve.
541    pub fn error_by_name(&self, name: &str, error_handle: HandleId) {
542        let id = self.node(name);
543        self.core.error(id, error_handle);
544    }
545
546    // -------------------------------------------------------------------
547    // Remove (canonical spec §3.2.3)
548    // -------------------------------------------------------------------
549
550    /// Remove a named node OR mounted subgraph. Fires `[TEARDOWN]` on the
551    /// node (which cascades to meta children per R1.3.9.d). For subgraphs,
552    /// delegates to [`Self::unmount`]. Returns [`GraphRemoveAudit`]
553    /// describing what was removed.
554    ///
555    /// Sinks observing TEARDOWN can resolve the node's name via
556    /// [`Graph::name_of`] / [`Graph::try_resolve`] for the duration of
557    /// the cascade — namespace clearing happens AFTER the teardown
558    /// returns (R3.2.3 / R3.7.3 ordering, mirroring `destroy()`).
559    ///
560    /// Returns `Err(RemoveError::NotFound)` if `name` is unknown (neither
561    /// a local node nor a mounted subgraph). Returns
562    /// `Err(RemoveError::Destroyed)` if the graph (or, in the subgraph
563    /// branch, an ancestor in the unmount path) has been destroyed.
564    #[must_use = "remove returns Err on missing name; ignoring may hide bugs"]
565    pub fn remove(&self, name: &str) -> Result<GraphRemoveAudit, RemoveError> {
566        // Check if it's a mounted subgraph first.
567        {
568            let inner = self.inner.lock();
569            if inner.destroyed {
570                return Err(RemoveError::Destroyed);
571            }
572            if inner.children.contains_key(name) {
573                drop(inner);
574                return self.unmount(name).map_err(|e| match e {
575                    crate::mount::MountError::Destroyed => RemoveError::Destroyed,
576                    _ => RemoveError::NotFound(name.to_owned()),
577                });
578            }
579        }
580        // Otherwise resolve the named node WITHOUT clearing the
581        // namespace yet — sinks observing TEARDOWN must be able to
582        // call name_of / try_resolve mid-cascade (R3.7.3 discipline,
583        // mirroring destroy() per Slice E+ /qa B3).
584        let node_id = {
585            let inner = self.inner.lock();
586            if inner.destroyed {
587                return Err(RemoveError::Destroyed);
588            }
589            *inner
590                .names
591                .get(name)
592                .ok_or_else(|| RemoveError::NotFound(name.to_owned()))?
593        };
594        // R3.2.3: TEARDOWN cascades through metas + downstream consumers.
595        // Namespace stays intact during the cascade so sinks can
596        // resolve names.
597        self.core.teardown(node_id);
598        // Now clear the namespace entry.
599        {
600            let mut inner = self.inner.lock();
601            inner.names.shift_remove(name);
602            inner.names_inverse.shift_remove(&node_id);
603        }
604        self.fire_namespace_change();
605        Ok(GraphRemoveAudit {
606            node_count: 1,
607            mount_count: 0,
608        })
609    }
610
611    // -------------------------------------------------------------------
612    // Edges (canonical spec §3.3.1)
613    // -------------------------------------------------------------------
614
615    /// Derive edges from the current topology. Returns `[from, to]`
616    /// pairs where both names are local namespace entries (or
617    /// `_anon_<id>` for unnamed Core-only deps — including deps
618    /// living in sibling graphs that share this graph's Core but
619    /// are not in this graph's local namespace).
620    ///
621    /// When `recursive` is true, recurses into mounted subgraphs and
622    /// qualifies names with `::` path separators.
623    #[must_use]
624    pub fn edges(&self, recursive: bool) -> Vec<(String, String)> {
625        // Pre-compute the qualified-names map across the entire mount
626        // tree (when `recursive`) so a child node's cross-graph dep
627        // (e.g., `sub::z` depending on root's `x`) can resolve to the
628        // parent-namespace name `x` instead of falling through to
629        // `sub::_anon_<id>`. Pre-Slice-Y bug: the per-level names_map
630        // only contained the current graph's names, breaking cross-
631        // graph edge tracing under recursive walking.
632        let names_map = self.collect_qualified_names("", recursive);
633        self.edges_inner("", recursive, &names_map)
634    }
635
636    /// Build an `id → qualified-name` map across this graph and (if
637    /// `recursive`) its mount tree. Cross-mount lookups use the
638    /// nearest registered name (insertion-order semantics via
639    /// `IndexMap`); the same `NodeId` never appears under two different
640    /// graphs in practice (Graph namespace integrity is enforced at
641    /// `add` time).
642    fn collect_qualified_names(&self, prefix: &str, recursive: bool) -> IndexMap<NodeId, String> {
643        let inner = self.inner.lock();
644        let mut map: IndexMap<NodeId, String> = inner
645            .names
646            .iter()
647            .map(|(n, id)| (*id, format!("{prefix}{n}")))
648            .collect();
649        let children: Vec<(String, Graph)> = if recursive {
650            inner
651                .children
652                .iter()
653                .map(|(n, g)| (n.clone(), g.clone()))
654                .collect()
655        } else {
656            Vec::new()
657        };
658        drop(inner);
659        for (child_name, child_graph) in children {
660            let child_prefix = format!("{prefix}{child_name}::");
661            let child_map = child_graph.collect_qualified_names(&child_prefix, true);
662            for (id, name) in child_map {
663                map.entry(id).or_insert(name);
664            }
665        }
666        map
667    }
668
669    fn edges_inner(
670        &self,
671        prefix: &str,
672        recursive: bool,
673        names_map: &IndexMap<NodeId, String>,
674    ) -> Vec<(String, String)> {
675        let inner = self.inner.lock();
676        let qualified: Vec<(String, NodeId)> = inner
677            .names
678            .iter()
679            .map(|(n, id)| (format!("{prefix}{n}"), *id))
680            .collect();
681        let children: Vec<(String, Graph)> = if recursive {
682            inner
683                .children
684                .iter()
685                .map(|(n, g)| (n.clone(), g.clone()))
686                .collect()
687        } else {
688            Vec::new()
689        };
690        drop(inner);
691
692        let mut result: Vec<(String, String)> = Vec::new();
693        for (to_name, id) in &qualified {
694            let dep_ids = self.core.deps_of(*id);
695            for dep_id in dep_ids {
696                let from_name = names_map
697                    .get(&dep_id)
698                    .cloned()
699                    .unwrap_or_else(|| format!("{prefix}_anon_{}", dep_id.raw()));
700                result.push((from_name, to_name.clone()));
701            }
702        }
703        for (child_name, child_graph) in children {
704            let child_prefix = format!("{prefix}{child_name}::");
705            result.extend(child_graph.edges_inner(&child_prefix, true, names_map));
706        }
707        result
708    }
709
710    // -------------------------------------------------------------------
711    // Lifecycle pass-throughs (canonical spec §3.7)
712    // -------------------------------------------------------------------
713
714    /// Subscribe a sink. Returns a [`Subscription`] handle — dropping it
715    /// unsubscribes. See [`graphrefly_core::Core::subscribe`] for full
716    /// handshake semantics.
717    pub fn subscribe(&self, node_id: NodeId, sink: Sink) -> Subscription {
718        self.core.subscribe(node_id, sink)
719    }
720
721    /// Emit a value on a state node.
722    pub fn emit(&self, node_id: NodeId, new_handle: HandleId) {
723        self.core.emit(node_id, new_handle);
724    }
725
726    /// Read a node's current cache. Returns
727    /// [`graphrefly_core::NO_HANDLE`] if sentinel.
728    #[must_use]
729    pub fn cache_of(&self, node_id: NodeId) -> HandleId {
730        self.core.cache_of(node_id)
731    }
732
733    /// Whether the node's fn has fired at least once (compute) OR it has
734    /// had a non-sentinel value (state).
735    #[must_use]
736    pub fn has_fired_once(&self, node_id: NodeId) -> bool {
737        self.core.has_fired_once(node_id)
738    }
739
740    /// Mark the node terminal with COMPLETE.
741    pub fn complete(&self, node_id: NodeId) {
742        self.core.complete(node_id);
743    }
744
745    /// Mark the node terminal with ERROR.
746    pub fn error(&self, node_id: NodeId, error_handle: HandleId) {
747        self.core.error(node_id, error_handle);
748    }
749
750    /// Tear the node down (R2.6.4).
751    pub fn teardown(&self, node_id: NodeId) {
752        self.core.teardown(node_id);
753    }
754
755    /// Clear the node's cache and cascade `[INVALIDATE]` to dependents.
756    pub fn invalidate(&self, node_id: NodeId) {
757        self.core.invalidate(node_id);
758    }
759
760    /// Acquire a pause lock.
761    pub fn pause(&self, node_id: NodeId, lock_id: LockId) -> Result<(), PauseError> {
762        self.core.pause(node_id, lock_id)
763    }
764
765    /// Release a pause lock.
766    pub fn resume(
767        &self,
768        node_id: NodeId,
769        lock_id: LockId,
770    ) -> Result<Option<ResumeReport>, PauseError> {
771        self.core.resume(node_id, lock_id)
772    }
773
774    /// Allocate a fresh `LockId`.
775    #[must_use]
776    pub fn alloc_lock_id(&self) -> LockId {
777        self.core.alloc_lock_id()
778    }
779
780    /// Atomically rewire a node's deps.
781    ///
782    /// # Hazards
783    ///
784    /// **Re-entrant `set_deps` from inside the firing node's own fn
785    /// corrupts Dynamic `tracked` indices** (D1 in
786    /// `~/src/graphrefly-rs/docs/porting-deferred.md`). If a Dynamic
787    /// node `n`'s fn captures a `Graph` clone and re-enters
788    /// `Graph::set_deps(n, ...)` from inside its own
789    /// `BindingBoundary::invoke_fn` call, the dep ordering is
790    /// rewritten while the fn-result `tracked: Some([...])` is being
791    /// staged against the OLD ordering. Phase 3 of `fire_fn` then
792    /// stores those stale indices into `rec.tracked` against the NEW
793    /// dep vector — pointing at different upstream nodes than the fn
794    /// intended to track.
795    ///
796    /// Acceptable v1: most `set_deps` callers are external
797    /// orchestrators, not the firing node itself. The structural
798    /// fix (a thread-local "currently firing" stack with
799    /// `SetDepsError::ReentrantOnFiringNode`) lifts in a later
800    /// slice.
801    pub fn set_deps(&self, n: NodeId, new_deps: &[NodeId]) -> Result<(), SetDepsError> {
802        self.core.set_deps(n, new_deps)
803    }
804
805    /// Mark the node as resubscribable (R2.2.7).
806    pub fn set_resubscribable(&self, node_id: NodeId, resubscribable: bool) {
807        self.core.set_resubscribable(node_id, resubscribable);
808    }
809
810    /// Attach `companion` as a meta companion of `parent` (R1.3.9.d).
811    pub fn add_meta_companion(&self, parent: NodeId, companion: NodeId) {
812        self.core.add_meta_companion(parent, companion);
813    }
814
815    /// Coalesce multiple emissions into a single wave.
816    pub fn batch<F: FnOnce()>(&self, f: F) {
817        self.core.batch(f);
818    }
819
820    // -------------------------------------------------------------------
821    // Graph-level lifecycle (canonical spec §3.7)
822    // -------------------------------------------------------------------
823
824    /// General broadcast (canonical R3.7.1). Sends `kind` to every
825    /// named node in this graph plus recursively into mounted children.
826    ///
827    /// Supported kinds:
828    /// - `SignalKind::Invalidate` — with meta filtering per R3.7.2.
829    /// - `SignalKind::Pause(lock_id)` — pause every named node.
830    /// - `SignalKind::Resume(lock_id)` — resume every named node.
831    ///
832    /// Idempotent on a destroyed graph (no-op).
833    pub fn signal(&self, kind: SignalKind) {
834        match kind {
835            SignalKind::Invalidate => self.signal_invalidate(),
836            SignalKind::Pause(lock_id) => self.signal_pause(lock_id),
837            SignalKind::Resume(lock_id) => self.signal_resume(lock_id),
838            SignalKind::Complete => self.signal_complete(),
839            SignalKind::Error(h) => self.signal_error(h),
840        }
841    }
842
843    /// Two-phase tree-wide gather for `signal_pause` / `signal_resume` /
844    /// `signal_complete` / `signal_error`. Same iterative worklist pattern
845    /// as `collect_signal_invalidate_ids` but with meta-companion
846    /// filtering added per D4 (Slice V3). Returns the flat list of
847    /// `NodeId`s to signal.
848    fn collect_signal_ids_with_meta_filter(&self) -> Vec<NodeId> {
849        let mut out: Vec<NodeId> = Vec::new();
850        let mut worklist: Vec<Graph> = vec![self.clone()];
851        while let Some(graph) = worklist.pop() {
852            let (own_ids, meta_set, child_clones) = {
853                let inner = graph.inner.lock();
854                if inner.destroyed {
855                    continue;
856                }
857                let mut meta_set: std::collections::HashSet<NodeId> =
858                    std::collections::HashSet::new();
859                for &parent_id in inner.names.values() {
860                    for child_id in graph.core.meta_companions_of(parent_id) {
861                        meta_set.insert(child_id);
862                    }
863                }
864                (
865                    inner.names.values().copied().collect::<Vec<_>>(),
866                    meta_set,
867                    inner.children.values().cloned().collect::<Vec<_>>(),
868                )
869            };
870            for id in own_ids {
871                if meta_set.contains(&id) {
872                    continue;
873                }
874                out.push(id);
875            }
876            worklist.extend(child_clones);
877        }
878        out
879    }
880
881    fn signal_pause(&self, lock_id: LockId) {
882        // Slice V3 D4: filter meta companions, same as signal_invalidate.
883        let ids = self.collect_signal_ids_with_meta_filter();
884        for id in ids {
885            let _ = self.core.pause(id, lock_id);
886        }
887    }
888
889    fn signal_resume(&self, lock_id: LockId) {
890        // Slice V3 D4: filter meta companions, same as signal_invalidate.
891        let ids = self.collect_signal_ids_with_meta_filter();
892        for id in ids {
893            let _ = self.core.resume(id, lock_id);
894        }
895    }
896
897    /// Broadcast `[COMPLETE]` to every named node in this graph plus
898    /// recursively into mounted children (meta-filtered per D4).
899    /// Idempotent on destroyed graph.
900    /// Slice V3: extends `SignalKind` per D3 in porting-deferred.md.
901    fn signal_complete(&self) {
902        let ids = self.collect_signal_ids_with_meta_filter();
903        for id in ids {
904            self.core.complete(id);
905        }
906    }
907
908    /// Broadcast `[ERROR, handle]` to every named node in this graph plus
909    /// recursively into mounted children (meta-filtered per D4). Each
910    /// `Core::error` call consumes one caller refcount share (via
911    /// `try_error`'s unconditional `release_handle`), so we pre-retain
912    /// N−1 additional shares before the loop. Idempotent on destroyed
913    /// graph.
914    /// Slice V3: extends `SignalKind` per D3 in porting-deferred.md.
915    fn signal_error(&self, error_handle: HandleId) {
916        let ids = self.collect_signal_ids_with_meta_filter();
917        // F1 /qa fix: each core.error() releases one caller share.
918        // Pre-retain (N-1) so the handle survives all N releases.
919        for _ in 1..ids.len() {
920            self.core.binding_ptr().retain_handle(error_handle);
921        }
922        for id in ids {
923            self.core.error(id, error_handle);
924        }
925    }
926
927    /// Broadcast `[INVALIDATE]` to every named node in this graph plus
928    /// recursively into mounted children. Meta companions (R1.3.9.d /
929    /// R2.3.3) are filtered out per canonical R3.7.2: their cached
930    /// values are preserved across graph-wide invalidation.
931    ///
932    /// Filter happens at the **graph layer** because the Core invalidate
933    /// cascade does NOT skip meta children — it walks every consumer in
934    /// `children`. The graph-layer filter walks the namespace, builds a
935    /// "set of node ids that are meta-companion-of-some-other-named-node",
936    /// and excludes them from the iterate-and-invalidate loop. Direct
937    /// `Core::invalidate(meta_id)` still wipes a meta's cache — the
938    /// filter applies only to this graph-layer broadcast.
939    ///
940    /// **Two-phase tree-wide gather (E sub-slice, 2026-05-10).** Phase 1
941    /// recursively walks the mount tree under per-graph locks (each
942    /// child locked briefly to extract its names + meta filter + children
943    /// snapshot, then released), accumulating a flat list of `NodeId`s to
944    /// invalidate. Phase 2 runs `Core::invalidate` on the flat list —
945    /// no Graph locks held during the cascade. The split:
946    ///
947    /// - Tightens the snapshot to **whole-mount-tree** scope: an `add`
948    ///   to a subtree-root graph AFTER its snapshot completes won't
949    ///   cause a parent-graph invalidate to fire on the new node, but
950    ///   it also won't sneak into an interleaved invalidate cascade
951    ///   either. Mid-walk topology mutations are visible only to
952    ///   not-yet-snapshotted subgraphs.
953    /// - Preserves the lock-acquisition rule (R3.7.2 / Slice F /qa D2):
954    ///   `Graph::inner` is dropped BEFORE any `Core::invalidate` call.
955    ///   Core's invalidate cascade re-entering the Graph layer (for
956    ///   describe-reactive notifications, etc.) cannot deadlock against
957    ///   a Graph-held lock.
958    /// - Makes invalidate ordering deterministic: DFS pre-order (parent
959    ///   before children) across the entire mount tree.
960    ///
961    /// Idempotent on a destroyed graph (no-op).
962    pub fn signal_invalidate(&self) {
963        let mut to_invalidate: Vec<NodeId> = Vec::new();
964        self.collect_signal_invalidate_ids(&mut to_invalidate);
965        for id in to_invalidate {
966            self.core.invalidate(id);
967        }
968    }
969
970    /// Iterative gather pass for [`Self::signal_invalidate`]. Pushes
971    /// every named node id (modulo meta-companion filter) across this
972    /// graph and its mount subtree into `out`.
973    ///
974    /// Uses an explicit `Vec<Graph>` worklist instead of recursion to
975    /// avoid stack overflow on deep mount trees (porting-deferred.md
976    /// lift point). Each subgraph is locked briefly and independently
977    /// — no nested locks. Skips destroyed subgraphs (idempotent).
978    /// Order is unspecified (invalidate is order-independent).
979    fn collect_signal_invalidate_ids(&self, out: &mut Vec<NodeId>) {
980        let mut worklist: Vec<Graph> = vec![self.clone()];
981        while let Some(graph) = worklist.pop() {
982            let (own_ids, meta_set, child_clones) = {
983                let inner = graph.inner.lock();
984                if inner.destroyed {
985                    continue;
986                }
987                // Build the set of ids that are meta-companions of any other
988                // named node in this graph. Names that point at a meta of
989                // some named parent get filtered.
990                let mut meta_set: std::collections::HashSet<NodeId> =
991                    std::collections::HashSet::new();
992                for &parent_id in inner.names.values() {
993                    for child_id in graph.core.meta_companions_of(parent_id) {
994                        meta_set.insert(child_id);
995                    }
996                }
997                (
998                    inner.names.values().copied().collect::<Vec<_>>(),
999                    meta_set,
1000                    inner.children.values().cloned().collect::<Vec<_>>(),
1001                )
1002            };
1003            for id in own_ids {
1004                if meta_set.contains(&id) {
1005                    continue;
1006                }
1007                out.push(id);
1008            }
1009            worklist.extend(child_clones);
1010        }
1011    }
1012
1013    /// Tear down every named node in this graph plus recursively into
1014    /// mounted children, then clear namespace + mount-tree state.
1015    ///
1016    /// The underlying [`Core`] is NOT dropped — other clones of this
1017    /// graph (or the Core itself) may still be alive. Only namespace
1018    /// + mount tree are cleared.
1019    ///
1020    /// **Cascade order (canonical R3.7.3 — "After cascade, graph
1021    /// internal registries are cleared").** Mark `destroyed = true`
1022    /// to refuse new mutations, snapshot ids + children under the
1023    /// lock, drop the lock, recurse into children, fire
1024    /// `core.teardown(id)` per own id (sinks see TEARDOWN with the
1025    /// namespace still populated — `Graph::name_of` resolves), THEN
1026    /// reacquire the lock and clear the namespace. Without this
1027    /// order, sinks that look up names during the TEARDOWN cascade
1028    /// would see `None`.
1029    pub fn destroy(&self) {
1030        let (own_ids, child_clones) = {
1031            let mut inner = self.inner.lock();
1032            if inner.destroyed {
1033                return; // Idempotent.
1034            }
1035            inner.destroyed = true;
1036            let own = inner.names.values().copied().collect::<Vec<_>>();
1037            let kids = inner.children.values().cloned().collect::<Vec<_>>();
1038            (own, kids)
1039        };
1040        for child in child_clones {
1041            child.destroy();
1042        }
1043        for id in own_ids {
1044            self.core.teardown(id);
1045        }
1046        // Clear registries AFTER the teardown cascade fires so sinks
1047        // observing TEARDOWN can still resolve names via the graph
1048        // namespace. R3.7.3 ordering.
1049        {
1050            let mut inner = self.inner.lock();
1051            inner.names.clear();
1052            inner.names_inverse.clear();
1053            inner.children.clear();
1054        }
1055        self.fire_namespace_change();
1056    }
1057
1058    // -------------------------------------------------------------------
1059    // Mount delegations (impl in `crate::mount`)
1060    // -------------------------------------------------------------------
1061
1062    /// Embed an existing `child` graph as a subgraph under `name`.
1063    /// `child` must share this graph's `Core` (cross-Core mount is
1064    /// post-M6 per session-doc Open Question 1).
1065    ///
1066    /// Returns the registered child for chaining.
1067    pub fn mount(&self, name: impl Into<String>, child: Graph) -> Result<Graph, MountError> {
1068        crate::mount::mount(self, name.into(), child)
1069    }
1070
1071    /// Create an empty subgraph sharing this graph's `Core`, mounted
1072    /// under `name`.
1073    pub fn mount_new(&self, name: impl Into<String>) -> Result<Graph, MountError> {
1074        crate::mount::mount_new(self, name.into())
1075    }
1076
1077    /// Builder pattern: create an empty subgraph, run `builder` against
1078    /// it, then return the registered subgraph.
1079    pub fn mount_with<F: FnOnce(&Graph)>(
1080        &self,
1081        name: impl Into<String>,
1082        builder: F,
1083    ) -> Result<Graph, MountError> {
1084        let child = self.mount_new(name)?;
1085        builder(&child);
1086        Ok(child)
1087    }
1088
1089    /// Detach a previously-mounted subgraph. Tears it down (TEARDOWN
1090    /// cascade across the child's nodes + recursively into the child's
1091    /// mounts) and returns a [`GraphRemoveAudit`] describing what was
1092    /// removed.
1093    pub fn unmount(&self, name: &str) -> Result<GraphRemoveAudit, MountError> {
1094        crate::mount::unmount(self, name)
1095    }
1096
1097    /// Parent chain (root last). `include_self = true` prepends this
1098    /// graph; `false` returns ancestors only.
1099    #[must_use]
1100    pub fn ancestors(&self, include_self: bool) -> Vec<Graph> {
1101        crate::mount::ancestors(self, include_self)
1102    }
1103}