Skip to main content

graphrefly_core/
subgraph.rs

1//! Per-subgraph union-find registry (Slice X5, 2026-05-08).
2//!
3//! Direct port of [`graphrefly-py`'s
4//! `subgraph_locks.py`](https://github.com/graphrefly/graphrefly-py/blob/main/src/graphrefly/core/subgraph_locks.py)
5//! (locked design via [`SESSION-rust-port-d3-per-subgraph-parallelism.md`](https://github.com/graphrefly/graphrefly-ts/blob/main/archive/docs/SESSION-rust-port-d3-per-subgraph-parallelism.md)
6//! — Q1 = (c-uf split-eager); see decision log D086).
7//!
8//! # Concept
9//!
10//! Each registered node is a member of exactly one connected
11//! component (a "subgraph") at any moment. Two nodes are in the same
12//! subgraph iff they're connected via dep edges (transitively).
13//!
14//! Connected-component membership is tracked via union-find with
15//! union-by-rank + path compression. Each component's root carries
16//! an [`Arc<SubgraphLockBox>`] that owns the partition's
17//! `wave_owner` re-entrant mutex — nodes in the same component share
18//! one lock, disjoint components run truly parallel.
19//!
20//! # Lifecycle hooks
21//!
22//! - [`SubgraphRegistry::ensure_registered`] — called when a node is
23//!   first registered via [`crate::Core::register`]. Allocates a
24//!   fresh singleton component for the new node.
25//! - [`SubgraphRegistry::union_nodes`] — called for each new dep
26//!   edge (in `register` and `set_deps`'s add-edge path). Merges
27//!   the two endpoints' components.
28//! - [`SubgraphRegistry::cleanup_node`] — wired but **not yet called
29//!   in X5**. In Y1 the wave engine will invoke it from sites that
30//!   actually remove a `NodeRecord` from `CoreState.nodes` (today
31//!   `terminate_node` only marks the node terminal — `NodeRecord`s
32//!   persist for the life of `CoreState`). The registry's HashMap
33//!   entries drop together with `CoreState` when the last `Core`
34//!   clone goes, so X5 doesn't leak partitions in practice; the
35//!   per-node cleanup hook becomes load-bearing once Y1 lands a
36//!   `Drop`-via-removal lifecycle for `NodeRecord` (post-Y1 work).
37//! - [`SubgraphRegistry::on_edge_removed`] — called for each removed
38//!   dep edge in `set_deps`'s remove path. Slice X5 commit-1 just
39//!   notes the removal; **Y1 (split-eager)** adds the reachability
40//!   walk that splits disconnected components.
41//!
42//! # Lock-acquisition discipline (Y1)
43//!
44//! [`SubgraphRegistry::lock_for`] returns the component's
45//! [`Arc<SubgraphLockBox>`] — caller acquires `box.wave_owner` and
46//! re-validates that the resolved root hasn't been redirected by a
47//! concurrent `union` (mirrors py's `MAX_LOCK_RETRIES` retry loop).
48//!
49//! # Slice X5 scope
50//!
51//! Substrate types + registry tracking are wired to `Core::register`
52//! and `Core::set_deps`'s edge add path so the union-find state is
53//! maintained as nodes register and topology changes. The wave engine
54//! itself still uses the legacy Core-level `wave_owner` — Y1 (the
55//! wave-engine migration to per-partition `wave_owner`) is carried
56//! forward as an explicit follow-on slice given its scope (every
57//! `begin_batch` / `run_wave` call site + `Core::subscribe` lock
58//! acquisition + `BatchGuard` lock retention + retry-validate
59//! semantics for held-Arc-vs-current-root divergence on union).
60//!
61//! Several substrate methods (`cleanup_node`, `lock_for`,
62//! `lock_for_validate`) and the `SubgraphLockBox::wave_owner` field
63//! are wired but unused in X5; they're annotated per-item with
64//! `#[allow(dead_code)]` until Y1 activates the wave engine through
65//! them. (Per-item rather than module-level shotgun so any genuinely
66//! unused new item still flags as dead code during X5 follow-up
67//! review.)
68
69use std::collections::{HashMap, HashSet};
70use std::sync::Arc;
71
72use parking_lot::ReentrantMutex;
73
74use crate::handle::NodeId;
75
76/// Newtype identifier for a connected-component partition.
77///
78/// Internally a `u64` (the union-find root's `NodeId.raw()`). Distinct
79/// from [`NodeId`] at the type system level — partitions and nodes are
80/// not interchangeable.
81#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
82pub struct SubgraphId(pub(crate) u64);
83
84impl SubgraphId {
85    /// Construct from a [`NodeId`] root. Internal use — partition
86    /// identity is the union-find root's NodeId.
87    #[must_use]
88    pub(crate) fn from_node(node: NodeId) -> Self {
89        Self(node.raw())
90    }
91
92    /// Raw u64 view. Used for total-ordering across multi-partition
93    /// wave acquisitions per Q4=(a) (deadlock-free via ascending
94    /// SubgraphId order).
95    #[must_use]
96    pub fn raw(self) -> u64 {
97        self.0
98    }
99}
100
101impl std::fmt::Display for SubgraphId {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        write!(f, "subgraph#{}", self.0)
104    }
105}
106
107/// Per-component lock box. Holds the partition's `wave_owner`
108/// re-entrant mutex. On `union`, the box of the smaller-rank root is
109/// replaced by the larger-rank root's box — the lock identity is
110/// preserved across merges via the [`Arc`] reference (mirrors py's
111/// `_LockBox.lock` redirect on union).
112///
113/// **Slice X5 commit-1:** the `wave_owner` field is allocated but
114/// not yet used by the wave engine. Y1 / commit-2 migrates
115/// `Core::subscribe` + `Core::begin_batch` to acquire this per-
116/// partition lock instead of the legacy `Core::wave_owner`.
117pub struct SubgraphLockBox {
118    /// Per-partition wave ownership. Cross-thread emits to the same
119    /// partition serialize here; same-thread re-entry passes through.
120    /// Wrapped in `Arc` at the registry level so two roots' boxes
121    /// can share the same mutex identity after `union`.
122    ///
123    /// **Slice X5 status:** allocated but unused — Y1 wires the wave
124    /// engine through it.
125    #[allow(dead_code)]
126    pub(crate) wave_owner: Arc<ReentrantMutex<()>>,
127}
128
129impl SubgraphLockBox {
130    fn new() -> Arc<Self> {
131        Arc::new(Self {
132            wave_owner: Arc::new(ReentrantMutex::new(())),
133        })
134    }
135}
136
137/// Default cap for [`SubgraphRegistry::lock_for`]'s root-validation
138/// retry loop. Mirrors graphrefly-py's `_MAX_LOCK_RETRIES` constant
139/// (`subgraph_locks.py` line 39). Reached only under continuous
140/// `union` activity racing with `lock_for` — pathological in
141/// practice but bounded for safety.
142///
143/// **Slice X5 status:** unused — Y1 wires the wave engine retry loop
144/// against this cap.
145#[allow(dead_code)]
146pub(crate) const MAX_LOCK_RETRIES: u32 = 100;
147
148/// Union-find registry tracking each node's connected-component
149/// membership. Wrapped by [`crate::Core`] in `Arc<Mutex<...>>` so
150/// the wave engine can resolve a node's partition before acquiring
151/// that partition's `wave_owner`.
152pub struct SubgraphRegistry {
153    /// `node_id → parent_id` union-find array. A root has
154    /// `parent[root] == root`.
155    parent: HashMap<NodeId, NodeId>,
156    /// `node_id → rank` for union-by-rank. Only meaningful for roots.
157    rank: HashMap<NodeId, u32>,
158    /// Reverse map for cleanup-time re-rooting: `node_id → set of
159    /// direct children whose `parent` field points at `node_id``.
160    /// On root cleanup, we promote one child to root and re-attach
161    /// the others.
162    children: HashMap<NodeId, HashSet<NodeId>>,
163    /// `root_node_id → Arc<SubgraphLockBox>`. Only roots have entries.
164    /// On `union`, the loser's entry is removed and any future
165    /// `lock_for` calls on its members find the winner's box via
166    /// `find`.
167    boxes: HashMap<NodeId, Arc<SubgraphLockBox>>,
168}
169
170impl SubgraphRegistry {
171    /// Construct an empty registry. `pub(crate)` because all useful
172    /// operations on the registry are crate-internal — only `Core::new`
173    /// needs to construct one. External callers receive
174    /// [`SubgraphId`]s via [`Core::partition_of`] but cannot construct
175    /// a registry from outside the crate.
176    #[must_use]
177    pub(crate) fn new() -> Self {
178        Self {
179            parent: HashMap::new(),
180            rank: HashMap::new(),
181            children: HashMap::new(),
182            boxes: HashMap::new(),
183        }
184    }
185
186    /// Register `node` as the root of a fresh singleton component.
187    /// Idempotent — calling twice on the same node is a no-op.
188    ///
189    /// Called from [`crate::Core::register`] right after the new
190    /// `NodeId` is allocated, BEFORE any `union_nodes` calls for the
191    /// node's deps.
192    pub(crate) fn ensure_registered(&mut self, node: NodeId) {
193        if self.parent.contains_key(&node) {
194            return;
195        }
196        self.parent.insert(node, node);
197        self.rank.insert(node, 0);
198        self.children.insert(node, HashSet::new());
199        self.boxes.insert(node, SubgraphLockBox::new());
200    }
201
202    /// Find the root of `node`'s component, with path compression.
203    /// Panics if `node` is not registered.
204    ///
205    /// **Iterative two-pass implementation** (Slice X5 /qa):
206    /// pass 1 walks up the parent chain to the root; pass 2 re-links
207    /// every node on the path directly to the root + maintains the
208    /// `children` reverse-map. Iterative form mirrors the existing
209    /// `terminate_node` cascade discipline (and graphrefly-py's
210    /// `_find_locked` while-loop) — keeps stack depth O(1) regardless
211    /// of pre-compression chain length, avoiding stack overflow on
212    /// pathological un-compressed trees that can briefly form before
213    /// path compression settles (especially relevant once Y1 puts
214    /// `find` on the hot path under `lock_for`).
215    #[must_use]
216    pub(crate) fn find(&mut self, node: NodeId) -> NodeId {
217        // Pass 1: walk up to root (no mutation).
218        let mut cur = node;
219        loop {
220            let parent = *self
221                .parent
222                .get(&cur)
223                .expect("subgraph_registry::find: node not registered");
224            if parent == cur {
225                break; // cur is root
226            }
227            cur = parent;
228        }
229        let root = cur;
230
231        // Pass 2: path compression — re-link every node on the original
232        // walk directly to root, maintaining the `children` reverse-map.
233        // Skip when already pointing at root (no-op for root itself, and
234        // for already-compressed nodes).
235        let mut walker = node;
236        while walker != root {
237            let parent = *self
238                .parent
239                .get(&walker)
240                .expect("walker on path-to-root must be registered");
241            if parent != root {
242                self.parent.insert(walker, root);
243                if let Some(old_kids) = self.children.get_mut(&parent) {
244                    old_kids.remove(&walker);
245                }
246                self.children.entry(root).or_default().insert(walker);
247            }
248            walker = parent;
249        }
250        root
251    }
252
253    /// Merge the components containing `a` and `b`. Both nodes must
254    /// already be registered. After this call, `find(a) == find(b)`.
255    ///
256    /// Union-by-rank: the smaller-rank root becomes a child of the
257    /// larger. Equal-rank breaks the tie by promoting `a`'s root and
258    /// bumping its rank.
259    ///
260    /// On merge, the loser's [`SubgraphLockBox`] entry is removed
261    /// from `boxes`. Future `lock_for` calls on the loser's members
262    /// resolve to the winner's root → winner's box.
263    pub(crate) fn union_nodes(&mut self, a: NodeId, b: NodeId) {
264        debug_assert!(
265            self.parent.contains_key(&a) && self.parent.contains_key(&b),
266            "union_nodes: both nodes must be registered first"
267        );
268        // Defense-in-depth against bypassed cycle detection: a self-edge
269        // `set_deps(n, &[n])` would reach here as `union_nodes(n, n)`.
270        // Cycle rejection in `Core::set_deps` should catch this BEFORE
271        // we get called, but the registry has no other defense.
272        debug_assert!(
273            a != b,
274            "union_nodes called with self-edge — \
275             Core's cycle detection bypassed?"
276        );
277        let mut root_a = self.find(a);
278        let mut root_b = self.find(b);
279        if root_a == root_b {
280            return;
281        }
282        let rank_a = *self.rank.get(&root_a).unwrap_or(&0);
283        let rank_b = *self.rank.get(&root_b).unwrap_or(&0);
284        if rank_a < rank_b {
285            std::mem::swap(&mut root_a, &mut root_b);
286        }
287        // root_a is the winner (kept). root_b becomes a child.
288        self.parent.insert(root_b, root_a);
289        self.children.entry(root_a).or_default().insert(root_b);
290        if rank_a == rank_b {
291            self.rank.insert(root_a, rank_a + 1);
292        }
293        // Drop the loser's box. Any in-flight readers holding an Arc
294        // clone keep it alive; the registry no longer references it.
295        self.boxes.remove(&root_b);
296    }
297
298    /// Remove `node` from the registry. If `node` was a root, promote
299    /// one of its direct children as the new root and re-link the
300    /// others. Mirrors py's `_on_gc` (`subgraph_locks.py` lines 53–92).
301    ///
302    /// **Slice X5 status:** wired but NOT called from Core. Today
303    /// `Core::terminate_node` marks a node terminal but does NOT remove
304    /// it from `CoreState.nodes` (NodeRecords persist for the life of
305    /// `CoreState`); the registry's HashMap entries drop together with
306    /// `CoreState` when the last `Core` clone goes, so the partition
307    /// state is reclaimed without an explicit per-node cleanup. Y1
308    /// will revisit this — when the wave engine activates per-partition
309    /// `wave_owner`, terminated nodes' partition entries should be
310    /// purged so a stale partition doesn't keep its `Arc<SubgraphLockBox>`
311    /// alive in the registry. Idempotent on unregistered nodes.
312    #[allow(dead_code)]
313    pub(crate) fn cleanup_node(&mut self, node: NodeId) {
314        let Some(parent) = self.parent.get(&node).copied() else {
315            return; // already cleaned up — idempotent.
316        };
317
318        let direct_children: Vec<NodeId> = self
319            .children
320            .get(&node)
321            .map(|s| s.iter().copied().collect())
322            .unwrap_or_default();
323
324        if parent == node {
325            // `node` was the root.
326            if let Some(&new_root) = direct_children.first() {
327                self.parent.insert(new_root, new_root);
328                // Detach `node` from each grandchild's children-set first
329                // (before borrowing children mut for the new_root entry).
330                for child in &direct_children {
331                    if let Some(kids) = self.children.get_mut(child) {
332                        kids.remove(&node);
333                    }
334                }
335                // Re-attach all children except `new_root` to `new_root`.
336                let new_root_kids = self.children.entry(new_root).or_default();
337                for child in direct_children.iter().skip(1).copied() {
338                    self.parent.insert(child, new_root);
339                    new_root_kids.insert(child);
340                }
341                // Box ownership transfers to the new root — same Arc, so
342                // any in-flight `lock_for` holders keep their guard alive.
343                if let Some(box_arc) = self.boxes.remove(&node) {
344                    self.boxes.insert(new_root, box_arc);
345                }
346                let old_rank = self.rank.get(&node).copied().unwrap_or(0);
347                let new_rank = self.rank.entry(new_root).or_insert(0);
348                if old_rank > *new_rank {
349                    *new_rank = old_rank;
350                }
351            } else {
352                // Singleton root — just remove the box.
353                self.boxes.remove(&node);
354            }
355        } else {
356            // `node` was a non-root. Detach from its parent's children;
357            // re-attach its own children to its parent.
358            if let Some(parent_kids) = self.children.get_mut(&parent) {
359                parent_kids.remove(&node);
360                for child in &direct_children {
361                    parent_kids.insert(*child);
362                }
363            }
364            for child in &direct_children {
365                self.parent.insert(*child, parent);
366            }
367        }
368
369        self.children.remove(&node);
370        self.parent.remove(&node);
371        self.rank.remove(&node);
372        // boxes already removed above on root path; non-root path never
373        // had a box entry.
374    }
375
376    /// Hook for an edge removal. Slice X5 commit-1: notes the removal
377    /// but does NOT split (monotonic-merge stepping stone). Y1
378    /// commit-2 adds the reachability walk to detect disconnected
379    /// components and split them into fresh `SubgraphId`s.
380    pub(crate) fn on_edge_removed(&mut self, _from: NodeId, _to: NodeId) {
381        // X5 commit-1: no-op. Monotonic merge — components only grow.
382        // Y1 commit-2 will add:
383        //   if !is_still_connected(from, to, &removed_edges) {
384        //       self.split_component(from, to);
385        //   }
386    }
387
388    /// Resolve `node`'s partition lock box. Caller acquires the
389    /// box's `wave_owner`, then SHOULD re-validate via
390    /// [`Self::lock_for_validate`] that the resolved root hasn't
391    /// shifted under a concurrent `union` (lock-validation retry
392    /// loop, mirroring py `lock_for` lines 154–178).
393    ///
394    /// Returns `None` if `node` is not registered (defensive — should
395    /// not happen in correct call paths).
396    ///
397    /// **Slice X5 status:** unused — Y1 wires the wave engine through it.
398    #[allow(dead_code)]
399    #[must_use]
400    pub(crate) fn lock_for(&mut self, node: NodeId) -> Option<(SubgraphId, Arc<SubgraphLockBox>)> {
401        if !self.parent.contains_key(&node) {
402            return None;
403        }
404        let root = self.find(node);
405        let box_arc = self.boxes.get(&root).cloned()?;
406        Some((SubgraphId::from_node(root), box_arc))
407    }
408
409    /// Re-validate that `node`'s root resolves to `expected_box`. Used
410    /// in the retry loop after acquiring the box's `wave_owner`: if a
411    /// concurrent `union` redirected the root mid-acquire, the held
412    /// lock is for the wrong partition and the caller must release +
413    /// retry.
414    ///
415    /// **Slice X5 status:** unused — Y1 wires the wave engine retry
416    /// loop through it.
417    #[allow(dead_code)]
418    #[must_use]
419    pub(crate) fn lock_for_validate(
420        &mut self,
421        node: NodeId,
422        expected_box: &Arc<SubgraphLockBox>,
423    ) -> bool {
424        let Some(root) = self.parent.get(&node).copied() else {
425            return false;
426        };
427        let actual_root = self.find(root);
428        match self.boxes.get(&actual_root) {
429            Some(actual) => Arc::ptr_eq(actual, expected_box),
430            None => false,
431        }
432    }
433
434    /// Number of registered nodes. Useful for debugging + acceptance
435    /// tests that verify the registry stays in sync with `Core::nodes`.
436    #[must_use]
437    pub fn node_count(&self) -> usize {
438        self.parent.len()
439    }
440
441    /// Number of distinct connected components. Two threads emitting
442    /// into nodes with distinct partitions can run truly parallel
443    /// (Y1+); X5 substrate does not yet exercise that property.
444    #[must_use]
445    pub fn component_count(&self) -> usize {
446        self.boxes.len()
447    }
448
449    /// Resolve `node`'s partition. Returns `None` for unregistered
450    /// nodes. Mutating because path compression may relink under
451    /// `find`.
452    #[must_use]
453    pub fn partition_of(&mut self, node: NodeId) -> Option<SubgraphId> {
454        if !self.parent.contains_key(&node) {
455            return None;
456        }
457        Some(SubgraphId::from_node(self.find(node)))
458    }
459}
460
461// No `Default` impl — the registry is crate-internal infrastructure;
462// `Core::new` is the only construction site. Adding a public `Default`
463// would let external callers build a registry that can do nothing
464// useful (every operation method is `pub(crate)`).
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    fn n(raw: u64) -> NodeId {
471        NodeId::new(raw)
472    }
473
474    #[test]
475    fn singleton_register_creates_one_partition() {
476        let mut r = SubgraphRegistry::new();
477        r.ensure_registered(n(1));
478        assert_eq!(r.node_count(), 1);
479        assert_eq!(r.component_count(), 1);
480        assert_eq!(r.find(n(1)), n(1));
481    }
482
483    #[test]
484    fn union_merges_two_singletons() {
485        let mut r = SubgraphRegistry::new();
486        r.ensure_registered(n(1));
487        r.ensure_registered(n(2));
488        assert_eq!(r.component_count(), 2);
489        r.union_nodes(n(1), n(2));
490        assert_eq!(r.component_count(), 1);
491        assert_eq!(r.find(n(1)), r.find(n(2)));
492    }
493
494    #[test]
495    fn union_idempotent_within_same_component() {
496        let mut r = SubgraphRegistry::new();
497        r.ensure_registered(n(1));
498        r.ensure_registered(n(2));
499        r.union_nodes(n(1), n(2));
500        let comp_before = r.component_count();
501        r.union_nodes(n(1), n(2));
502        assert_eq!(r.component_count(), comp_before);
503    }
504
505    #[test]
506    fn cleanup_singleton_removes_partition() {
507        let mut r = SubgraphRegistry::new();
508        r.ensure_registered(n(1));
509        r.cleanup_node(n(1));
510        assert_eq!(r.node_count(), 0);
511        assert_eq!(r.component_count(), 0);
512    }
513
514    #[test]
515    fn cleanup_root_promotes_child() {
516        let mut r = SubgraphRegistry::new();
517        r.ensure_registered(n(1));
518        r.ensure_registered(n(2));
519        r.union_nodes(n(1), n(2));
520        // After union: one of {1, 2} is root.
521        let root_before = r.find(n(1));
522        let child = if root_before == n(1) { n(2) } else { n(1) };
523        r.cleanup_node(root_before);
524        // The remaining node should be its own root.
525        assert_eq!(r.find(child), child);
526        assert_eq!(r.component_count(), 1);
527    }
528
529    #[test]
530    fn cleanup_non_root_re_links_grandchildren_to_parent() {
531        let mut r = SubgraphRegistry::new();
532        for i in 1..=3 {
533            r.ensure_registered(n(i));
534        }
535        r.union_nodes(n(1), n(2));
536        r.union_nodes(n(2), n(3));
537        let root_before = r.find(n(1));
538        // Pick a non-root node to clean up.
539        let non_root = if root_before == n(1) {
540            n(2)
541        } else if root_before == n(2) {
542            n(1)
543        } else {
544            n(2)
545        };
546        r.cleanup_node(non_root);
547        // Remaining nodes should still find a single root.
548        let other = (1..=3u64)
549            .map(n)
550            .find(|x| *x != root_before && *x != non_root)
551            .expect("third node");
552        assert_eq!(r.find(root_before), r.find(other));
553    }
554
555    #[test]
556    fn lock_for_returns_same_box_for_same_component() {
557        let mut r = SubgraphRegistry::new();
558        r.ensure_registered(n(1));
559        r.ensure_registered(n(2));
560        r.union_nodes(n(1), n(2));
561        let (_sid_a, box_a) = r.lock_for(n(1)).expect("registered");
562        let (_sid_b, box_b) = r.lock_for(n(2)).expect("registered");
563        assert!(Arc::ptr_eq(&box_a, &box_b));
564    }
565
566    #[test]
567    fn lock_for_validate_detects_redirect_after_union() {
568        // Slice X5 /qa P6: deterministic redirect via forced rank
569        // skew. Pre-fix this test asserted conditionally based on
570        // which node union promoted, masking a hypothetical regression
571        // where `lock_for_validate` always returned `true`. Now the
572        // setup forces n(1)'s root to be displaced — the assertion
573        // is unconditionally `false`.
574        let mut r = SubgraphRegistry::new();
575        for i in 1..=4 {
576            r.ensure_registered(n(i));
577        }
578        // Build a higher-rank tree under n(2)'s root: union n(2)+n(3)
579        // and n(2)+n(4), which under union-by-rank with equal initial
580        // ranks promotes n(2) and bumps its rank by the second union.
581        // After this, find(n(2)) == n(2) and rank[n(2)] >= 1.
582        r.union_nodes(n(2), n(3));
583        r.union_nodes(n(2), n(4));
584        let n2_root = r.find(n(2));
585        // n(1) stays its own singleton (rank 0). Resolve its box BEFORE
586        // the cross-tree union.
587        let (_sid_before, box_1_alone) = r.lock_for(n(1)).expect("registered");
588        let n1_root_before = r.find(n(1));
589        assert_eq!(n1_root_before, n(1), "n(1) is still its own root");
590
591        // Cross-tree union: union-by-rank promotes the higher-rank tree
592        // (n(2)'s) — n(1)'s root MUST become n(2)'s root.
593        r.union_nodes(n(1), n(2));
594        let n1_root_after = r.find(n(1));
595        assert_eq!(
596            n1_root_after, n2_root,
597            "union-by-rank promoted n(2)'s tree; n(1)'s root displaced"
598        );
599
600        // The previously-resolved box is now stale: lock_for_validate
601        // must detect the redirect unconditionally.
602        let still_valid = r.lock_for_validate(n(1), &box_1_alone);
603        assert!(
604            !still_valid,
605            "lock_for_validate must detect the box-redirect after union promotes a different root"
606        );
607        // And lock_for now returns a different box.
608        let (_sid_after, box_after) = r.lock_for(n(1)).expect("registered");
609        assert!(
610            !Arc::ptr_eq(&box_1_alone, &box_after),
611            "stale box and active box must be distinct Arc identities"
612        );
613    }
614
615    #[test]
616    fn partition_of_distinct_singletons_differ() {
617        let mut r = SubgraphRegistry::new();
618        r.ensure_registered(n(1));
619        r.ensure_registered(n(2));
620        let p1 = r.partition_of(n(1)).expect("registered");
621        let p2 = r.partition_of(n(2)).expect("registered");
622        assert_ne!(p1, p2);
623    }
624
625    #[test]
626    fn partition_of_unioned_nodes_match() {
627        let mut r = SubgraphRegistry::new();
628        r.ensure_registered(n(1));
629        r.ensure_registered(n(2));
630        r.union_nodes(n(1), n(2));
631        let p1 = r.partition_of(n(1)).expect("registered");
632        let p2 = r.partition_of(n(2)).expect("registered");
633        assert_eq!(p1, p2);
634    }
635}