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`.