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