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