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