Skip to main content

graphrefly_graph/
graph.rs

1//! `Graph` container — a **Core-free** namespace + mount tree (D246).
2//!
3//! D246 β-simplification: the actor-model [`Core`] is move-only and
4//! single-owner; the embedder owns it (via
5//! [`graphrefly_core::OwnedCore`]). `Graph` carries **no `Core` and no
6//! `&Core`** — it is purely the named namespace + mount tree. There is
7//! **one** `Graph` type (no `SubgraphRef`/`GraphOps`/`NamespaceHandle`,
8//! no `'g` lifetime): a subgraph is just another `Graph` handle into a
9//! child node of the tree (a cheap `Arc` clone). Every Core-touching op
10//! takes an explicit `&Core` first argument (D246 rule 2 — the owner
11//! always has it; one arg, trivially-correct ownership). Pure-namespace
12//! ops (`node`, `name_of`, `try_resolve`, …) take no `Core`.
13//!
14//! `Graph` is `Clone` (an `Rc` bump) and intentionally `!Send + !Sync`
15//! (D246/S2c single-owner: `Rc<RefCell<GraphInner>>`). It lives on, and
16//! is touched only by, the one thread that owns the `Core` — captured
17//! into owner-side `Sink`/`MailboxOp::Defer` closures (also `!Send`
18//! post-D248). It replaces the old `NamespaceHandle`.
19//!
20//! `mount` / `describe` / `observe` / `snapshot` live in sibling
21//! modules and follow the same `&Core`-explicit convention.
22
23use std::cell::RefCell;
24use std::rc::{Rc, Weak};
25
26use graphrefly_core::{
27    Core, EqualsMode, FnId, HandleId, LockId, NodeId, PauseError, ResumeReport, SetDepsError, Sink,
28    SubscriptionId, TopologySubscriptionId,
29};
30use indexmap::IndexMap;
31
32use crate::debug::DebugBindingBoundary;
33use crate::describe::{
34    describe_of, describe_reactive_in, DescribeSink, GraphDescribeOutput, ReactiveDescribeHandle,
35};
36use crate::mount::{GraphRemoveAudit, MountError};
37use crate::observe::{GraphObserveAll, GraphObserveAllReactive, GraphObserveOne};
38
39/// Namespace path separator (canonical spec R3.5.1).
40pub(crate) const PATH_SEP: &str = "::";
41
42/// Errors from [`Graph::remove`].
43#[derive(Debug, thiserror::Error)]
44pub enum RemoveError {
45    #[error("Graph::remove: name `{0}` not found (neither a node nor a mounted subgraph)")]
46    NotFound(String),
47    #[error("Graph::remove: graph has been destroyed")]
48    Destroyed,
49}
50
51/// Signal kind for [`Graph::signal`] (canonical R3.7.1).
52#[derive(Debug, Clone, Copy)]
53pub enum SignalKind {
54    /// Wipe caches (with meta filtering per R3.7.2).
55    Invalidate,
56    /// Pause every named node with the given lock.
57    Pause(LockId),
58    /// Resume every named node with the given lock.
59    Resume(LockId),
60    /// Mark every named node as terminal with `COMPLETE`.
61    Complete,
62    /// Mark every named node as terminal with `ERROR` carrying the handle.
63    Error(HandleId),
64}
65
66/// Path resolution errors returned by [`Graph::try_resolve_checked`].
67#[derive(Debug, thiserror::Error)]
68pub enum PathError {
69    /// Path is empty.
70    #[error("Path is empty")]
71    Empty,
72    /// A `..` segment was used but the graph has no parent.
73    #[error("Path segment `..` used on root graph (no parent)")]
74    NoParent,
75    /// A segment named a child graph that doesn't exist.
76    #[error("Path segment `{0}` does not match any child graph")]
77    ChildNotFound(String),
78    /// The graph has been destroyed.
79    #[error("Graph has been destroyed")]
80    Destroyed,
81}
82
83/// Errors from name registration.
84#[derive(Debug, thiserror::Error)]
85pub enum NameError {
86    #[error("Graph::add: name `{0}` already registered in this graph")]
87    Collision(String),
88    #[error("Graph: name `{0}` may not contain the `::` path separator")]
89    InvalidName(String),
90    #[error("Graph: name `{0}` uses the reserved `_anon_` prefix (collides with anonymous node describe format)")]
91    ReservedPrefix(String),
92    #[error("Graph: graph has been destroyed; further registration refused")]
93    Destroyed,
94}
95
96/// Callback type for graph-level namespace change notifications, used
97/// by reactive describe and reactive `observe_all`.
98///
99/// D246 (rule 2/6): namespace changes (`add`/`remove`/`mount`/`unmount`/
100/// `destroy`) are **owner-invoked** — the caller holds `&Core` — so the
101/// sink receives that `&Core` and may re-snapshot / re-subscribe
102/// synchronously owner-side. (The in-wave `DepsChanged`/`NodeTornDown`
103/// re-entry path is the one that defers via `MailboxOp::Defer` — see
104/// `describe.rs`/`observe.rs`.)
105pub(crate) type NamespaceChangeSink = Rc<dyn Fn(&Core)>;
106
107static_assertions::assert_not_impl_any!(NamespaceChangeSink: Send, Sync);
108
109/// Inner namespace + mount-tree state for one graph level. D246: holds
110/// **no `Core`** — purely the named namespace, the mounted children,
111/// and the namespace-change sinks. D246/S2c/D247: single-owner ⇒
112/// `Rc<RefCell<GraphInner>>` (the prior `Arc<Mutex<…>>` was
113/// shared-Core-era legacy); the shareable-handle + parent-`Weak` cycle
114/// stays, single-threaded.
115pub struct GraphInner {
116    pub(crate) name: String,
117    /// Local namespace: name → `NodeId`. Insertion order is load-bearing
118    /// for `describe()` stability.
119    pub(crate) names: IndexMap<String, NodeId>,
120    /// Reverse lookup.
121    pub(crate) names_inverse: IndexMap<NodeId, String>,
122    /// Mounted child subgraphs — each is just another level's inner
123    /// state (no `Core`; the single owned `Core` is the embedder's, and
124    /// is threaded as `&Core` through every Core-touching tree-walk).
125    pub(crate) children: IndexMap<String, Rc<RefCell<GraphInner>>>,
126    /// Parent inner-state pointer (for `ancestors()`). Weak to break
127    /// the strong cycle.
128    pub(crate) parent: Option<Weak<RefCell<GraphInner>>>,
129    /// True after `destroy()` completes — subsequent mutations refuse.
130    pub(crate) destroyed: bool,
131    /// Namespace-change sinks — fired from `add()`, `remove()`, etc.
132    /// after the inner lock is dropped. Keyed by subscription id.
133    pub(crate) namespace_sinks: IndexMap<u64, NamespaceChangeSink>,
134    pub(crate) next_ns_sink_id: u64,
135    /// R3.1.2 — factory provenance set via [`Graph::tag_factory`]. `None`
136    /// when not tagged. Surfaces at the top of [`describe()`] output as
137    /// `factory` + `factoryArgs` keys (cross-track-ledger §1 D283;
138    /// D285 substrate landing 2026-05-24).
139    pub(crate) factory: Option<String>,
140    /// R3.1.2 — factory args paired with [`Self::factory`]. Stored as
141    /// `serde_json::Value` so the JSON shape round-trips through
142    /// `describe()` byte-identically to the pure-ts arm. `None` when
143    /// no args were supplied (or when `tag_factory(name)` was called
144    /// without args after a prior tag — pure-ts QA F8 invariant: a
145    /// second call without args MUST clear stale args).
146    pub(crate) factory_args: Option<serde_json::Value>,
147}
148
149/// Graph container — the one Core-free namespace + mount-tree handle
150/// (canonical §3, D246).
151///
152/// `Clone` is a cheap `Rc` bump; a subgraph (from `mount*`/`ancestors`)
153/// is just a `Graph` into a child level. Intentionally `!Send + !Sync`
154/// (D246/S2c single-owner): no `Core`, no borrow — captured into
155/// owner-side sinks on the one thread that owns the `Core`. Pass the
156/// embedder's `&Core` (e.g. `owned.core()`) into every Core-touching
157/// method.
158#[derive(Clone)]
159pub struct Graph {
160    pub(crate) inner: Rc<RefCell<GraphInner>>,
161}
162
163impl std::fmt::Debug for Graph {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        let inner = self.inner.borrow_mut();
166        f.debug_struct("Graph")
167            .field("name", &inner.name)
168            .field("node_count", &inner.names.len())
169            .field("subgraph_count", &inner.children.len())
170            .field("destroyed", &inner.destroyed)
171            .finish_non_exhaustive()
172    }
173}
174
175// `state`/`derived`/`dynamic` use `.expect()` on invariant-unreachable
176// `register_*` error paths (caller-validated) — same documented stance
177// the pre-D246 `GraphOps` trait carried via this exact allow.
178#[allow(clippy::missing_panics_doc, clippy::must_use_candidate)]
179impl Graph {
180    /// Construct a named, empty root graph. D246: the graph is
181    /// Core-free — the embedder owns the `Core` (see
182    /// [`graphrefly_core::OwnedCore`]) and passes `&Core` into
183    /// Core-touching ops.
184    #[must_use]
185    pub fn new(name: impl Into<String>) -> Self {
186        Self::with_parent(name.into(), None)
187    }
188
189    pub(crate) fn with_parent(name: String, parent: Option<Weak<RefCell<GraphInner>>>) -> Self {
190        Self {
191            inner: Rc::new(RefCell::new(GraphInner {
192                name,
193                names: IndexMap::new(),
194                names_inverse: IndexMap::new(),
195                children: IndexMap::new(),
196                parent,
197                destroyed: false,
198                namespace_sinks: IndexMap::new(),
199                next_ns_sink_id: 0,
200                factory: None,
201                factory_args: None,
202            })),
203        }
204    }
205
206    /// R3.1.2 — annotate the graph with the factory function name + args
207    /// used to construct it. Provenance for [`describe()`], snapshot
208    /// replay, and debugging. Surfaces at the top of `describe()` output
209    /// as `factory` + `factoryArgs` keys.
210    ///
211    /// **Invariant (pure-ts QA F8, [`graph.ts:1686-1689`](https://github.com/graphrefly/graphrefly-ts/blob/main/packages/pure-ts/src/graph/graph.ts)):**
212    /// a second call WITHOUT `factory_args` MUST clear stale args
213    /// (re-assignment to `None`, not a no-op) — otherwise
214    /// `tag_factory("a", {...})` then `tag_factory("b")` would pair
215    /// `"b"` with `{...}` and report mismatched provenance.
216    ///
217    /// **Cross-arm spec citation:** canonical R3.1.2 at
218    /// `docs/implementation-plan-13.6-canonical-spec.md:768`
219    /// (graphrefly-ts). Drops the spec's `this`-chain return per the
220    /// D267 / D282 async-everywhere `Impl` convention — every dispatcher-
221    /// touching parity method returns the post-call observable shape, not
222    /// a chainable handle.
223    ///
224    /// **No reactive emission.** Pure-ts bumps `_topologyVersion` for
225    /// SPEC-PERSISTENCE bookkeeping (DS-14.5.A Q1), but explicitly emits
226    /// no `TopologyEvent`. Rust has no equivalent bookkeeping field —
227    /// since `describe()` reads `factory` / `factory_args` fresh on every
228    /// call, subsequent topology events observe the latest tag
229    /// automatically (no cache invalidation needed). Parity test
230    /// `tag-factory.test.ts:96-140` pins this contract cross-arm.
231    pub fn tag_factory(&self, factory: impl Into<String>, factory_args: Option<serde_json::Value>) {
232        let mut inner = self.inner.borrow_mut();
233        inner.factory = Some(factory.into());
234        // QA F8: always re-assign — second call without args clears stale
235        // args (otherwise `tag_factory("a", Some({...}))` then
236        // `tag_factory("b", None)` would keep `{...}` paired with `"b"`,
237        // which is mismatched provenance).
238        inner.factory_args = factory_args;
239    }
240
241    /// Wrap an existing inner level as a `Graph` handle (mount/ancestors).
242    pub(crate) fn from_inner(inner: Rc<RefCell<GraphInner>>) -> Self {
243        Self { inner }
244    }
245
246    /// This level's namespace handle (the `Graph` ↔ tree-walk seam).
247    #[inline]
248    pub(crate) fn inner_arc(&self) -> &Rc<RefCell<GraphInner>> {
249        &self.inner
250    }
251
252    /// Whether `name` is a legal local node/subgraph name.
253    #[must_use]
254    pub fn is_valid_name(name: &str) -> bool {
255        !name.contains(PATH_SEP) && !name.starts_with("_anon_")
256    }
257
258    /// The graph's name as set at construction (or via `mount`).
259    #[must_use]
260    pub fn name(&self) -> String {
261        self.inner.borrow_mut().name.clone()
262    }
263
264    // --- namespace-change sinks (used by observe / describe / mount) ---
265
266    /// Subscribe to namespace changes (add, remove, mount, unmount,
267    /// destroy). The sink fires AFTER the inner lock is dropped, with
268    /// the owner's `&Core`. Returns a subscription id.
269    pub fn subscribe_namespace_change(&self, sink: NamespaceChangeSink) -> u64 {
270        register_ns_sink(&self.inner, sink)
271    }
272
273    /// Unsubscribe a namespace-change sink by id (owner-invoked,
274    /// D246 rule 3 — no RAII below the binding).
275    pub fn unsubscribe_namespace_change(&self, id: u64) {
276        unregister_ns_sink(&self.inner, id);
277    }
278
279    // --------------------- describe / observe (§3.6) -------------------
280
281    /// Snapshot the graph's topology + lifecycle state (JSON form).
282    /// `value` fields are raw u64 handles.
283    #[must_use]
284    pub fn describe(&self, core: &Core) -> GraphDescribeOutput {
285        describe_of(core, &self.inner, None)
286    }
287
288    /// [`Self::describe`] with each `value` rendered via `debug`.
289    #[must_use]
290    pub fn describe_with_debug(
291        &self,
292        core: &Core,
293        debug: &dyn DebugBindingBoundary,
294    ) -> GraphDescribeOutput {
295        describe_of(core, &self.inner, Some(debug))
296    }
297
298    /// Subscribe to live topology snapshots (canonical §3.6.1
299    /// `reactive: true`). Push-on-subscribe + re-fires on namespace
300    /// changes and `set_deps`. D246 rule 3: returns an id-bearing
301    /// handle with an explicit owner-invoked
302    /// [`ReactiveDescribeHandle::detach`] — no RAII `Drop`.
303    #[must_use = "dropping the handle without calling .detach(&core) leaks the topology sub"]
304    pub fn describe_reactive(&self, core: &Core, sink: &DescribeSink) -> ReactiveDescribeHandle {
305        describe_reactive_in(core, &self.inner, sink)
306    }
307
308    /// Tap a single node's downstream message stream. Pure-namespace
309    /// resolution; pass `&Core` to the returned handle's `subscribe`.
310    #[must_use = "GraphObserveOne is only useful via .subscribe(...) — dropping it silently no-ops"]
311    pub fn observe(&self, path: &str) -> GraphObserveOne {
312        let id = self.node(path);
313        GraphObserveOne::new(self.clone(), id)
314    }
315
316    /// Tap every named node in this graph (snapshot at `subscribe()`).
317    #[must_use = "GraphObserveAll is only useful via .subscribe(...) — dropping it silently no-ops"]
318    pub fn observe_all(&self) -> GraphObserveAll {
319        GraphObserveAll::new(self.clone())
320    }
321
322    /// Tap every named node AND auto-subscribe late-added nodes.
323    #[must_use = "GraphObserveAllReactive is only useful via .subscribe(...) — dropping it silently no-ops"]
324    pub fn observe_all_reactive(&self) -> GraphObserveAllReactive {
325        GraphObserveAllReactive::new(self.clone())
326    }
327
328    /// Fire all namespace-change sinks owner-side (D246 rule 2 —
329    /// caller holds `&Core`).
330    pub fn fire_namespace_change(&self, core: &Core) {
331        fire_ns(core, &self.inner);
332    }
333
334    // ------------------------- namespace (§3.5) -------------------------
335
336    /// Register an existing `node_id` under `name` in this graph's
337    /// namespace.
338    ///
339    /// # Errors
340    /// See [`NameError`].
341    pub fn add(
342        &self,
343        core: &Core,
344        node_id: NodeId,
345        name: impl Into<String>,
346    ) -> Result<NodeId, NameError> {
347        let name = name.into();
348        validate_name(&name)?;
349        {
350            let mut inner = self.inner.borrow_mut();
351            if inner.destroyed {
352                return Err(NameError::Destroyed);
353            }
354            if inner.names.contains_key(&name) {
355                return Err(NameError::Collision(name));
356            }
357            inner.names.insert(name.clone(), node_id);
358            inner.names_inverse.insert(node_id, name);
359        }
360        self.fire_namespace_change(core);
361        Ok(node_id)
362    }
363
364    /// Resolve a path to a `NodeId`. Panics if missing.
365    #[must_use]
366    pub fn node(&self, path: &str) -> NodeId {
367        self.try_resolve(path)
368            .unwrap_or_else(|| panic!("Graph::node: no node at path `{path}`"))
369    }
370
371    /// Non-panicking [`Self::node`].
372    #[must_use]
373    pub fn try_resolve(&self, path: &str) -> Option<NodeId> {
374        self.try_resolve_checked(path).ok().flatten()
375    }
376
377    /// Path resolution with typed errors (pure-namespace; never touches
378    /// `Core`).
379    ///
380    /// # Errors
381    /// See [`PathError`].
382    pub fn try_resolve_checked(&self, path: &str) -> Result<Option<NodeId>, PathError> {
383        resolve_checked(&self.inner, path)
384    }
385
386    /// Reverse lookup: the local name for a `node_id`.
387    #[must_use]
388    pub fn name_of(&self, node_id: NodeId) -> Option<String> {
389        self.inner.borrow_mut().names_inverse.get(&node_id).cloned()
390    }
391
392    /// Number of named nodes in this graph.
393    #[must_use]
394    pub fn node_count(&self) -> usize {
395        self.inner.borrow_mut().names.len()
396    }
397
398    /// Snapshot of local node names in insertion order.
399    #[must_use]
400    pub fn node_names(&self) -> Vec<String> {
401        self.inner.borrow_mut().names.keys().cloned().collect()
402    }
403
404    /// Snapshot of mounted child names in insertion order.
405    #[must_use]
406    pub fn child_names(&self) -> Vec<String> {
407        self.inner.borrow_mut().children.keys().cloned().collect()
408    }
409
410    /// Look up an immediate child mount by name. Returns `None` when no
411    /// child mount with that exact name exists.
412    ///
413    /// /qa G1.1 (2026-05-22): added to support multi-segment-path
414    /// navigation in `apply_wal_frame` mount/unmount arms (`graph.ts`'s
415    /// `_collectSubgraphs` emits `"parent::child::nested"` paths; the
416    /// Rust storage replay must walk segments to reach the right
417    /// owner graph before calling `mount_new` / `unmount`). Reverse of
418    /// [`Self::child_names`]; combine with the segments of a
419    /// [`PATH_SEP`]-joined path to descend the mount tree.
420    #[must_use]
421    pub fn child(&self, name: &str) -> Option<Graph> {
422        self.inner
423            .borrow_mut()
424            .children
425            .get(name)
426            .cloned()
427            .map(Graph::from_inner)
428    }
429
430    /// Returns `true` after [`Self::destroy`] has been called.
431    #[must_use]
432    pub fn is_destroyed(&self) -> bool {
433        self.inner.borrow_mut().destroyed
434    }
435
436    // ---------------------- sugar constructors (§3.9) -------------------
437
438    /// Register a state node under `name`.
439    ///
440    /// # Errors
441    /// See [`NameError`].
442    pub fn state(
443        &self,
444        core: &Core,
445        name: impl Into<String>,
446        initial: Option<HandleId>,
447    ) -> Result<NodeId, NameError> {
448        let id = core
449            .register_state(initial.unwrap_or(graphrefly_core::NO_HANDLE), false)
450            .expect("invariant: register_state has no error variants reachable for caller-controlled inputs");
451        self.add(core, id, name)
452    }
453
454    /// Register a static-derived node (fn fires on every dep change).
455    ///
456    /// # Errors
457    /// See [`NameError`].
458    pub fn derived(
459        &self,
460        core: &Core,
461        name: impl Into<String>,
462        deps: &[NodeId],
463        fn_id: FnId,
464        equals: EqualsMode,
465    ) -> Result<NodeId, NameError> {
466        let id = core
467            .register_derived(deps, fn_id, equals, false)
468            .expect("invariant: caller has validated dep ids before calling register_derived");
469        self.add(core, id, name)
470    }
471
472    /// Register a dynamic-derived node (fn declares read dep indices).
473    ///
474    /// # Errors
475    /// See [`NameError`].
476    pub fn dynamic(
477        &self,
478        core: &Core,
479        name: impl Into<String>,
480        deps: &[NodeId],
481        fn_id: FnId,
482        equals: EqualsMode,
483    ) -> Result<NodeId, NameError> {
484        let id = core
485            .register_dynamic(deps, fn_id, equals, false)
486            .expect("invariant: caller has validated dep ids before calling register_dynamic");
487        self.add(core, id, name)
488    }
489
490    // -------------------- named-sugar wrappers (§3.2.1) -----------------
491
492    /// Emit a value on a named state node.
493    pub fn set(&self, core: &Core, name: &str, handle: HandleId) {
494        let id = self.node(name);
495        core.emit(id, handle);
496    }
497
498    /// Read the cached value of a named node.
499    #[must_use]
500    pub fn get(&self, core: &Core, name: &str) -> HandleId {
501        let id = self.node(name);
502        core.cache_of(id)
503    }
504
505    /// Clear the cache of a named node and cascade `[INVALIDATE]`.
506    pub fn invalidate_by_name(&self, core: &Core, name: &str) {
507        let id = self.node(name);
508        core.invalidate(id);
509    }
510
511    /// Mark a named node terminal with COMPLETE.
512    pub fn complete_by_name(&self, core: &Core, name: &str) {
513        let id = self.node(name);
514        core.complete(id);
515    }
516
517    /// Mark a named node terminal with ERROR.
518    pub fn error_by_name(&self, core: &Core, name: &str, error_handle: HandleId) {
519        let id = self.node(name);
520        core.error(id, error_handle);
521    }
522
523    // --------------------------- remove (§3.2.3) ------------------------
524
525    /// Remove a named node OR mounted subgraph (R3.2.3 / R3.7.3
526    /// ordering — namespace cleared AFTER the TEARDOWN cascade).
527    ///
528    /// # Errors
529    /// See [`RemoveError`].
530    pub fn remove(&self, core: &Core, name: &str) -> Result<GraphRemoveAudit, RemoveError> {
531        {
532            let inner = self.inner.borrow_mut();
533            if inner.destroyed {
534                return Err(RemoveError::Destroyed);
535            }
536            if inner.children.contains_key(name) {
537                drop(inner);
538                return self.unmount(core, name).map_err(|e| match e {
539                    MountError::Destroyed => RemoveError::Destroyed,
540                    _ => RemoveError::NotFound(name.to_owned()),
541                });
542            }
543        }
544        let node_id = {
545            let inner = self.inner.borrow_mut();
546            if inner.destroyed {
547                return Err(RemoveError::Destroyed);
548            }
549            *inner
550                .names
551                .get(name)
552                .ok_or_else(|| RemoveError::NotFound(name.to_owned()))?
553        };
554        core.teardown(node_id);
555        {
556            let mut inner = self.inner.borrow_mut();
557            inner.names.shift_remove(name);
558            inner.names_inverse.shift_remove(&node_id);
559        }
560        self.fire_namespace_change(core);
561        Ok(GraphRemoveAudit {
562            node_count: 1,
563            mount_count: 0,
564        })
565    }
566
567    // --------------------------- edges (§3.3.1) -------------------------
568
569    /// Derive `[from, to]` edge name pairs. `recursive` qualifies names
570    /// across the mount tree with `::`.
571    #[must_use]
572    pub fn edges(&self, core: &Core, recursive: bool) -> Vec<(String, String)> {
573        let names_map = collect_qualified_names_in(&self.inner, "", recursive);
574        edges_in(core, &self.inner, "", recursive, &names_map)
575    }
576
577    // ------------------- lifecycle pass-throughs (§3.7) -----------------
578
579    /// Subscribe a sink. Returns a [`SubscriptionId`]; pass it back to
580    /// [`Self::unsubscribe`] (owner-invoked, synchronous — D246 rule 3;
581    /// no RAII below the binding).
582    #[must_use = "the SubscriptionId must be kept to later unsubscribe; dropping it leaks the sink"]
583    pub fn subscribe(&self, core: &Core, node_id: NodeId, sink: Sink) -> SubscriptionId {
584        core.subscribe(node_id, sink)
585    }
586
587    /// Detach a sink previously registered via [`Self::subscribe`].
588    pub fn unsubscribe(&self, core: &Core, node_id: NodeId, sub_id: SubscriptionId) {
589        core.unsubscribe(node_id, sub_id);
590    }
591
592    /// Detach a Core topology subscription by id.
593    pub fn unsubscribe_topology(&self, core: &Core, id: TopologySubscriptionId) {
594        core.unsubscribe_topology(id);
595    }
596
597    /// Emit a value on a state node.
598    pub fn emit(&self, core: &Core, node_id: NodeId, new_handle: HandleId) {
599        core.emit(node_id, new_handle);
600    }
601
602    /// Read a node's current cache.
603    #[must_use]
604    pub fn cache_of(&self, core: &Core, node_id: NodeId) -> HandleId {
605        core.cache_of(node_id)
606    }
607
608    /// Whether the node's fn has fired at least once.
609    #[must_use]
610    pub fn has_fired_once(&self, core: &Core, node_id: NodeId) -> bool {
611        core.has_fired_once(node_id)
612    }
613
614    /// Mark the node terminal with COMPLETE.
615    pub fn complete(&self, core: &Core, node_id: NodeId) {
616        core.complete(node_id);
617    }
618
619    /// Mark the node terminal with ERROR.
620    pub fn error(&self, core: &Core, node_id: NodeId, error_handle: HandleId) {
621        core.error(node_id, error_handle);
622    }
623
624    /// Tear the node down (R2.6.4).
625    pub fn teardown(&self, core: &Core, node_id: NodeId) {
626        core.teardown(node_id);
627    }
628
629    /// Clear the node's cache and cascade `[INVALIDATE]`.
630    pub fn invalidate(&self, core: &Core, node_id: NodeId) {
631        core.invalidate(node_id);
632    }
633
634    /// Acquire a pause lock.
635    ///
636    /// # Errors
637    /// See [`PauseError`].
638    pub fn pause(&self, core: &Core, node_id: NodeId, lock_id: LockId) -> Result<(), PauseError> {
639        core.pause(node_id, lock_id)
640    }
641
642    /// Release a pause lock.
643    ///
644    /// # Errors
645    /// See [`PauseError`].
646    pub fn resume(
647        &self,
648        core: &Core,
649        node_id: NodeId,
650        lock_id: LockId,
651    ) -> Result<Option<ResumeReport>, PauseError> {
652        core.resume(node_id, lock_id)
653    }
654
655    /// Allocate a fresh `LockId`.
656    #[must_use]
657    pub fn alloc_lock_id(&self, core: &Core) -> LockId {
658        core.alloc_lock_id()
659    }
660
661    /// Atomically rewire a node's deps.
662    ///
663    /// # Errors
664    /// See [`SetDepsError`].
665    ///
666    /// # Hazards
667    ///
668    /// Re-entrant `set_deps` from inside the firing node's own fn
669    /// corrupts Dynamic `tracked` indices (D1 in
670    /// `~/src/graphrefly-rs/docs/porting-deferred.md`). Acceptable v1:
671    /// most callers are external orchestrators, not the firing node.
672    pub fn set_deps(
673        &self,
674        core: &Core,
675        n: NodeId,
676        new_deps: &[NodeId],
677    ) -> Result<(), SetDepsError> {
678        core.set_deps(n, new_deps)
679    }
680
681    /// Mark the node as resubscribable (R2.2.7).
682    pub fn set_resubscribable(&self, core: &Core, node_id: NodeId, resubscribable: bool) {
683        core.set_resubscribable(node_id, resubscribable);
684    }
685
686    /// Attach `companion` as a meta companion of `parent` (R1.3.9.d).
687    pub fn add_meta_companion(&self, core: &Core, parent: NodeId, companion: NodeId) {
688        core.add_meta_companion(parent, companion);
689    }
690
691    /// Coalesce multiple emissions into a single wave.
692    pub fn batch<F: FnOnce()>(&self, core: &Core, f: F) {
693        core.batch(f);
694    }
695
696    // --------------------- graph-level lifecycle (§3.7) -----------------
697
698    /// General broadcast (canonical R3.7.1).
699    pub fn signal(&self, core: &Core, kind: SignalKind) {
700        match kind {
701            SignalKind::Invalidate => self.signal_invalidate(core),
702            SignalKind::Pause(lock_id) => {
703                for id in collect_signal_ids_with_meta_filter(core, &self.inner) {
704                    let _ = core.pause(id, lock_id);
705                }
706            }
707            SignalKind::Resume(lock_id) => {
708                for id in collect_signal_ids_with_meta_filter(core, &self.inner) {
709                    let _ = core.resume(id, lock_id);
710                }
711            }
712            SignalKind::Complete => {
713                for id in collect_signal_ids_with_meta_filter(core, &self.inner) {
714                    core.complete(id);
715                }
716            }
717            SignalKind::Error(h) => {
718                let ids = collect_signal_ids_with_meta_filter(core, &self.inner);
719                // F1 /qa fix: each core.error() releases one caller share.
720                // Pre-retain (N-1) so the handle survives all N releases.
721                for _ in 1..ids.len() {
722                    core.binding_ptr().retain_handle(h);
723                }
724                for id in ids {
725                    core.error(id, h);
726                }
727            }
728        }
729    }
730
731    /// Broadcast `[INVALIDATE]` across this graph + mount tree
732    /// (meta-companion filtered per R3.7.2; Graph locks dropped before
733    /// any `Core::invalidate`). Idempotent on a destroyed graph.
734    pub fn signal_invalidate(&self, core: &Core) {
735        let to_invalidate = collect_signal_invalidate_ids(core, &self.inner);
736        for id in to_invalidate {
737            core.invalidate(id);
738        }
739    }
740
741    /// Tear down every named node + recursively into mounted children,
742    /// then clear namespace + mount-tree state. R3.7.3 ordering
743    /// (children-first, then own teardown, then clear, then fire
744    /// ns-change) preserved verbatim.
745    pub fn destroy(&self, core: &Core) {
746        destroy_subtree(core, &self.inner);
747    }
748
749    // --------------------------- mount (§3.4) ---------------------------
750
751    /// Embed an existing `child` subgraph under `name`. Fires ns-change
752    /// sinks owner-side (P3) — hence `&Core` (D246 rule 2).
753    ///
754    /// # Errors
755    /// See [`MountError`].
756    pub fn mount(
757        &self,
758        core: &Core,
759        name: impl Into<String>,
760        child: &Graph,
761    ) -> Result<Graph, MountError> {
762        crate::mount::mount(core, &self.inner, name.into(), child)
763    }
764
765    /// Create an empty subgraph (shares the embedder's one `Core`).
766    ///
767    /// # Errors
768    /// See [`MountError`].
769    pub fn mount_new(&self, core: &Core, name: impl Into<String>) -> Result<Graph, MountError> {
770        crate::mount::mount_new(core, &self.inner, name.into())
771    }
772
773    /// Builder pattern: create an empty subgraph, run `builder`, return it.
774    ///
775    /// # Errors
776    /// See [`MountError`].
777    pub fn mount_with<F: FnOnce(&Graph)>(
778        &self,
779        core: &Core,
780        name: impl Into<String>,
781        builder: F,
782    ) -> Result<Graph, MountError> {
783        let child = self.mount_new(core, name)?;
784        builder(&child);
785        Ok(child)
786    }
787
788    /// Detach a previously-mounted subgraph (TEARDOWN cascade).
789    ///
790    /// # Errors
791    /// See [`MountError`].
792    pub fn unmount(&self, core: &Core, name: &str) -> Result<GraphRemoveAudit, MountError> {
793        crate::mount::unmount(core, &self.inner, name)
794    }
795
796    /// Parent chain (root last). `include_self = true` prepends this
797    /// graph.
798    #[must_use]
799    pub fn ancestors(&self, include_self: bool) -> Vec<Graph> {
800        crate::mount::ancestors(&self.inner, include_self)
801    }
802}
803
804// =====================================================================
805// D246/D237: free fns over (&Core, &Rc<RefCell<GraphInner>>)
806// =====================================================================
807
808fn validate_name(name: &str) -> Result<(), NameError> {
809    if name.contains(PATH_SEP) {
810        Err(NameError::InvalidName(name.to_owned()))
811    } else if name.starts_with("_anon_") {
812        Err(NameError::ReservedPrefix(name.to_owned()))
813    } else {
814        Ok(())
815    }
816}
817
818/// Register a namespace-change sink on one graph level. Returns its id.
819pub(crate) fn register_ns_sink(
820    inner_arc: &Rc<RefCell<GraphInner>>,
821    sink: NamespaceChangeSink,
822) -> u64 {
823    let mut inner = inner_arc.borrow_mut();
824    let id = inner.next_ns_sink_id;
825    inner.next_ns_sink_id += 1;
826    inner.namespace_sinks.insert(id, sink);
827    id
828}
829
830/// Remove a namespace-change sink by id (inner-only; no `Core`).
831pub(crate) fn unregister_ns_sink(inner_arc: &Rc<RefCell<GraphInner>>, id: u64) {
832    inner_arc.borrow_mut().namespace_sinks.shift_remove(&id);
833}
834
835/// Pure-namespace path resolution (R3.5.1/R3.5.2). Never touches `Core`.
836pub(crate) fn resolve_checked(
837    inner_arc: &Rc<RefCell<GraphInner>>,
838    path: &str,
839) -> Result<Option<NodeId>, PathError> {
840    if path.is_empty() {
841        return Err(PathError::Empty);
842    }
843    let inner = inner_arc.borrow_mut();
844    if inner.destroyed {
845        return Err(PathError::Destroyed);
846    }
847    let segments: Vec<&str> = path.split(PATH_SEP).collect();
848    let first = segments[0];
849    if first == ".." {
850        let parent_weak = inner.parent.as_ref().ok_or(PathError::NoParent)?;
851        let parent_inner = parent_weak.upgrade().ok_or(PathError::NoParent)?;
852        drop(inner);
853        if segments.len() == 1 {
854            return Ok(None);
855        }
856        let rest = segments[1..].join(PATH_SEP);
857        resolve_checked(&parent_inner, &rest)
858    } else if segments.len() > 1 {
859        let child = inner
860            .children
861            .get(first)
862            .cloned()
863            .ok_or_else(|| PathError::ChildNotFound(first.to_string()))?;
864        drop(inner);
865        let rest = segments[1..].join(PATH_SEP);
866        resolve_checked(&child, &rest)
867    } else {
868        Ok(inner.names.get(first).copied())
869    }
870}
871
872/// Fire one graph level's namespace-change sinks with the owner's
873/// `&Core` (D246 rule 2 owner-side). Sinks run with no Graph lock held.
874pub(crate) fn fire_ns(core: &Core, inner_arc: &Rc<RefCell<GraphInner>>) {
875    let sinks: Vec<NamespaceChangeSink> = {
876        let inner = inner_arc.borrow_mut();
877        inner.namespace_sinks.values().cloned().collect()
878    };
879    for sink in sinks {
880        sink(core);
881    }
882}
883
884/// `destroy()` over `Rc<RefCell<GraphInner>>` + root `&Core`. Ordering
885/// preserved verbatim (R3.7.3).
886pub(crate) fn destroy_subtree(core: &Core, inner_arc: &Rc<RefCell<GraphInner>>) {
887    let (own_ids, child_clones) = {
888        let mut inner = inner_arc.borrow_mut();
889        if inner.destroyed {
890            return; // Idempotent.
891        }
892        inner.destroyed = true;
893        let own = inner.names.values().copied().collect::<Vec<_>>();
894        let kids = inner.children.values().cloned().collect::<Vec<_>>();
895        (own, kids)
896    };
897    for child in &child_clones {
898        destroy_subtree(core, child);
899    }
900    for id in own_ids {
901        core.teardown(id);
902    }
903    {
904        let mut inner = inner_arc.borrow_mut();
905        inner.names.clear();
906        inner.names_inverse.clear();
907        inner.children.clear();
908    }
909    // Fire the final namespace change (reactive describe/observe see the
910    // emptied graph) BEFORE dropping the sinks — so observers get the
911    // destroy notification, then the sinks are released.
912    fire_ns(core, inner_arc);
913    // QA-A1: clear the namespace-change sinks on destroy. Without this a
914    // reactive describe/observe ns-sink (registered via
915    // `register_ns_sink`, NOT an `OwnedCore`-tracked Core sub) would
916    // outlive `destroy()` for the whole `GraphInner` lifetime — the
917    // documented `graph.destroy(core)` teardown fallback must actually
918    // collect it. (The handle's Core *topology* sub is still owner-
919    // detach-only — see the corrected `#[must_use]` text.)
920    inner_arc.borrow_mut().namespace_sinks.clear();
921}
922
923/// Tree-wide gather for `signal_pause`/`resume`/`complete`/`error`
924/// (meta-companion filtered per D4). `Rc<RefCell<GraphInner>>` worklist
925/// + the single root `&Core`.
926fn collect_signal_ids_with_meta_filter(core: &Core, root: &Rc<RefCell<GraphInner>>) -> Vec<NodeId> {
927    let mut out: Vec<NodeId> = Vec::new();
928    let mut worklist: Vec<Rc<RefCell<GraphInner>>> = vec![root.clone()];
929    while let Some(inner_arc) = worklist.pop() {
930        let (own_ids, meta_set, child_clones) = {
931            let inner = inner_arc.borrow_mut();
932            if inner.destroyed {
933                continue;
934            }
935            let mut meta_set: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
936            for &parent_id in inner.names.values() {
937                for child_id in core.meta_companions_of(parent_id) {
938                    meta_set.insert(child_id);
939                }
940            }
941            (
942                inner.names.values().copied().collect::<Vec<_>>(),
943                meta_set,
944                inner.children.values().cloned().collect::<Vec<_>>(),
945            )
946        };
947        for id in own_ids {
948            if meta_set.contains(&id) {
949                continue;
950            }
951            out.push(id);
952        }
953        worklist.extend(child_clones);
954    }
955    out
956}
957
958/// Iterative gather for `signal_invalidate` (DFS, meta-filtered).
959fn collect_signal_invalidate_ids(core: &Core, root: &Rc<RefCell<GraphInner>>) -> Vec<NodeId> {
960    let mut out: Vec<NodeId> = Vec::new();
961    let mut worklist: Vec<Rc<RefCell<GraphInner>>> = vec![root.clone()];
962    while let Some(inner_arc) = worklist.pop() {
963        let (own_ids, meta_set, child_clones) = {
964            let inner = inner_arc.borrow_mut();
965            if inner.destroyed {
966                continue;
967            }
968            let mut meta_set: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
969            for &parent_id in inner.names.values() {
970                for child_id in core.meta_companions_of(parent_id) {
971                    meta_set.insert(child_id);
972                }
973            }
974            (
975                inner.names.values().copied().collect::<Vec<_>>(),
976                meta_set,
977                inner.children.values().cloned().collect::<Vec<_>>(),
978            )
979        };
980        for id in own_ids {
981            if meta_set.contains(&id) {
982                continue;
983            }
984            out.push(id);
985        }
986        worklist.extend(child_clones);
987    }
988    out
989}
990
991/// Build an `id → qualified-name` map across this graph + (if
992/// `recursive`) its mount tree. Pure-namespace (no `Core`).
993pub(crate) fn collect_qualified_names_in(
994    inner_arc: &Rc<RefCell<GraphInner>>,
995    prefix: &str,
996    recursive: bool,
997) -> IndexMap<NodeId, String> {
998    let inner = inner_arc.borrow_mut();
999    let mut map: IndexMap<NodeId, String> = inner
1000        .names
1001        .iter()
1002        .map(|(n, id)| (*id, format!("{prefix}{n}")))
1003        .collect();
1004    let children: Vec<(String, Rc<RefCell<GraphInner>>)> = if recursive {
1005        inner
1006            .children
1007            .iter()
1008            .map(|(n, g)| (n.clone(), g.clone()))
1009            .collect()
1010    } else {
1011        Vec::new()
1012    };
1013    drop(inner);
1014    for (child_name, child_inner) in children {
1015        let child_prefix = format!("{prefix}{child_name}::");
1016        let child_map = collect_qualified_names_in(&child_inner, &child_prefix, true);
1017        for (id, name) in child_map {
1018            map.entry(id).or_insert(name);
1019        }
1020    }
1021    map
1022}
1023
1024/// Derive `[from, to]` edge name pairs. Needs the root `&Core`.
1025pub(crate) fn edges_in(
1026    core: &Core,
1027    inner_arc: &Rc<RefCell<GraphInner>>,
1028    prefix: &str,
1029    recursive: bool,
1030    names_map: &IndexMap<NodeId, String>,
1031) -> Vec<(String, String)> {
1032    let inner = inner_arc.borrow_mut();
1033    let qualified: Vec<(String, NodeId)> = inner
1034        .names
1035        .iter()
1036        .map(|(n, id)| (format!("{prefix}{n}"), *id))
1037        .collect();
1038    let children: Vec<(String, Rc<RefCell<GraphInner>>)> = if recursive {
1039        inner
1040            .children
1041            .iter()
1042            .map(|(n, g)| (n.clone(), g.clone()))
1043            .collect()
1044    } else {
1045        Vec::new()
1046    };
1047    drop(inner);
1048    let mut result: Vec<(String, String)> = Vec::new();
1049    for (to_name, id) in &qualified {
1050        let dep_ids = core.deps_of(*id);
1051        for dep_id in dep_ids {
1052            let from_name = names_map
1053                .get(&dep_id)
1054                .cloned()
1055                .unwrap_or_else(|| format!("{prefix}_anon_{}", dep_id.raw()));
1056            result.push((from_name, to_name.clone()));
1057        }
1058    }
1059    for (child_name, child_inner) in children {
1060        let child_prefix = format!("{prefix}{child_name}::");
1061        result.extend(edges_in(core, &child_inner, &child_prefix, true, names_map));
1062    }
1063    result
1064}
1065
1066// D247/D248: the QA-A4 `Graph: Send + Sync + 'static` assertion is
1067// **deleted**. Under D246/S2c single-owner the namespace tree is
1068// `Rc<RefCell<GraphInner>>` (the `Arc<Mutex<>>` + `Send+Sync` was
1069// shared-Core-era legacy), so `Graph` is intentionally `!Send + !Sync`
1070// — it lives on, and is touched only by, the one thread that owns the
1071// `Core`.