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