Skip to main content

ftui_layout/
dep_graph.rs

1//! Dependency graph for incremental layout invalidation (bd-3p4y1.1).
2//!
3//! # Design
4//!
5//! The dependency graph tracks which layout computations depend on which
6//! inputs. When an input changes (constraint, content, style, children),
7//! only the affected subtree is marked dirty and re-evaluated.
8//!
9//! ## Data Structure: Per-Node Adjacency Lists
10//!
11//! Each node stores fixed-size metadata (36 bytes) inline. Edges are
12//! stored in per-node `Vec<NodeId>` lists for both forward (deps) and
13//! reverse (dependents) directions, ensuring correct adjacency even
14//! when edges are added in arbitrary order.
15//!
16//! ### Complexity
17//!
18//! | Operation            | Time         | Space           |
19//! |----------------------|--------------|-----------------|
20//! | Create node          | O(1) amort.  | +36 bytes       |
21//! | Mark dirty           | O(1)         | —               |
22//! | Propagate dirty      | O(k)         | O(k) queue      |
23//! | Add dependency edge  | O(V+E) cycle | +4 bytes/edge   |
24//! | Cycle detection      | O(V + E)     | O(V) coloring   |
25//! | Query dirty set      | O(n) scan    | —               |
26//!
27//! Where k = dirty nodes + their transitive dependents, n = total nodes.
28//!
29//! ### Memory: 40 bytes per node (struct only)
30//!
31//! ```text
32//! DepNode {
33//!     generation: u32,       //  4 bytes — dirty-check generation
34//!     dirty_gen: u32,        //  4 bytes — generation when last dirtied
35//!     constraint_hash: u64,  //  8 bytes — detect constraint changes
36//!     content_hash: u64,     //  8 bytes — detect content changes
37//!     style_hash: u64,       //  8 bytes — detect style changes
38//!     parent: u32,           //  4 bytes — parent NodeId (u32::MAX = none)
39//! }                          // = 40 bytes (36 raw + 4 alignment padding)
40//! ```
41//!
42//! # Dirty Propagation
43//!
44//! When a node is marked dirty, BFS traverses reverse edges (dependents)
45//! and marks each reachable node dirty. The dirty set is the transitive
46//! closure of the initially dirty nodes under the "depends-on" relation.
47//!
48//! # Cycle Detection
49//!
50//! Layout cycles are bugs. The graph detects cycles on edge insertion
51//! using DFS reachability: before adding A → B, check that B cannot
52//! already reach A via existing edges. This is O(V + E) worst case
53//! but typically fast due to shallow widget trees.
54//!
55//! # Deterministic Traversal
56//!
57//! Dirty nodes are visited in DFS pre-order (matching full layout
58//! traversal), ensuring bit-identical output regardless of whether
59//! the computation is incremental or full.
60
61use std::collections::VecDeque;
62use std::fmt;
63
64// ============================================================================
65// NodeId
66// ============================================================================
67
68/// Lightweight handle into the dependency graph.
69///
70/// Uses `u32` for compactness (supports up to ~4 billion nodes).
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
72pub struct NodeId(u32);
73
74impl NodeId {
75    /// Sentinel value meaning "no parent".
76    const NONE: u32 = u32::MAX;
77
78    /// Create a NodeId from a raw u32 index.
79    #[must_use]
80    pub fn from_raw(index: u32) -> Self {
81        Self(index)
82    }
83
84    /// Get the raw u32 index.
85    #[must_use]
86    pub fn raw(self) -> u32 {
87        self.0
88    }
89}
90
91impl fmt::Display for NodeId {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        write!(f, "N{}", self.0)
94    }
95}
96
97// ============================================================================
98// InputHash — what changed
99// ============================================================================
100
101/// Identifies which input domain changed on a node.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum InputKind {
104    /// Size constraints (min/max/preferred width/height).
105    Constraint,
106    /// Widget content (text, children list, embedded content).
107    Content,
108    /// Style properties (padding, margin, border, flex properties).
109    Style,
110}
111
112// ============================================================================
113// DepNode
114// ============================================================================
115
116/// Per-node metadata in the dependency graph. 40 bytes (36 raw + alignment).
117///
118/// Edge adjacency is stored separately in `Vec<Vec<NodeId>>` to ensure
119/// correctness when edges are added in arbitrary order.
120#[derive(Clone)]
121struct DepNode {
122    /// Global generation counter at creation time.
123    generation: u32,
124    /// Generation when this node was last marked dirty.
125    /// Dirty if `dirty_gen >= generation` (after last clean).
126    dirty_gen: u32,
127    /// Hash of constraint inputs.
128    constraint_hash: u64,
129    /// Hash of content inputs.
130    content_hash: u64,
131    /// Hash of style inputs.
132    style_hash: u64,
133    /// Parent node (u32::MAX = root/no parent).
134    parent: u32,
135}
136
137impl DepNode {
138    fn new(generation: u32) -> Self {
139        Self {
140            generation,
141            dirty_gen: 0,
142            constraint_hash: 0,
143            content_hash: 0,
144            style_hash: 0,
145            parent: NodeId::NONE,
146        }
147    }
148
149    fn is_dirty(&self) -> bool {
150        self.dirty_gen >= self.generation
151    }
152}
153
154// ============================================================================
155// CycleError
156// ============================================================================
157
158/// Error returned when adding an edge would create a cycle.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct CycleError {
161    pub from: NodeId,
162    pub to: NodeId,
163}
164
165impl fmt::Display for CycleError {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        write!(
168            f,
169            "layout cycle detected: {} → {} would create a cycle",
170            self.from, self.to
171        )
172    }
173}
174
175impl std::error::Error for CycleError {}
176
177// ============================================================================
178// DepGraph
179// ============================================================================
180
181/// Dependency graph for incremental layout invalidation.
182///
183/// Tracks layout nodes and their dependencies. When a node's input
184/// changes, the graph propagates dirtiness to all transitive dependents.
185///
186/// # Examples
187///
188/// ```
189/// use ftui_layout::dep_graph::{DepGraph, InputKind};
190///
191/// let mut graph = DepGraph::new();
192///
193/// // Create a simple parent → child dependency.
194/// let parent = graph.add_node();
195/// let child = graph.add_node();
196/// graph.add_edge(child, parent).unwrap();  // child depends on parent
197///
198/// // Changing the parent dirties the child.
199/// graph.mark_changed(parent, InputKind::Constraint, 42);
200/// let dirty = graph.propagate();
201/// assert!(dirty.contains(&parent));
202/// assert!(dirty.contains(&child));
203/// ```
204pub struct DepGraph {
205    nodes: Vec<DepNode>,
206    /// Forward adjacency: `fwd_adj[i]` = nodes that node `i` depends on.
207    fwd_adj: Vec<Vec<NodeId>>,
208    /// Reverse adjacency: `rev_adj[i]` = nodes that depend on node `i`.
209    rev_adj: Vec<Vec<NodeId>>,
210    /// Current generation counter for dirty tracking.
211    current_gen: u32,
212    /// Pending dirty nodes (not yet propagated).
213    pending_dirty: Vec<NodeId>,
214    /// Free list for recycled node slots.
215    free_list: Vec<u32>,
216}
217
218impl DepGraph {
219    /// Create an empty dependency graph.
220    #[must_use]
221    pub fn new() -> Self {
222        Self {
223            nodes: Vec::new(),
224            fwd_adj: Vec::new(),
225            rev_adj: Vec::new(),
226            current_gen: 1,
227            pending_dirty: Vec::new(),
228            free_list: Vec::new(),
229        }
230    }
231
232    /// Create a graph with pre-allocated capacity.
233    #[must_use]
234    pub fn with_capacity(node_cap: usize, _edge_cap: usize) -> Self {
235        Self {
236            nodes: Vec::with_capacity(node_cap),
237            fwd_adj: Vec::with_capacity(node_cap),
238            rev_adj: Vec::with_capacity(node_cap),
239            current_gen: 1,
240            pending_dirty: Vec::new(),
241            free_list: Vec::new(),
242        }
243    }
244
245    /// Add a new node to the graph. Returns its stable identifier.
246    pub fn add_node(&mut self) -> NodeId {
247        if let Some(slot) = self.free_list.pop() {
248            self.nodes[slot as usize] = DepNode::new(self.current_gen);
249            self.fwd_adj[slot as usize].clear();
250            self.rev_adj[slot as usize].clear();
251            NodeId(slot)
252        } else {
253            let id = self.nodes.len() as u32;
254            self.nodes.push(DepNode::new(self.current_gen));
255            self.fwd_adj.push(Vec::new());
256            self.rev_adj.push(Vec::new());
257            NodeId(id)
258        }
259    }
260
261    /// Remove a node, recycling its slot. Edges are lazily cleaned.
262    pub fn remove_node(&mut self, id: NodeId) {
263        let idx = id.0 as usize;
264        if idx < self.nodes.len() {
265            self.nodes[idx].generation = 0;
266            self.nodes[idx].dirty_gen = 0;
267            self.fwd_adj[idx].clear();
268            self.rev_adj[idx].clear();
269            self.free_list.push(id.0);
270        }
271    }
272
273    /// Total number of live nodes.
274    #[must_use]
275    pub fn node_count(&self) -> usize {
276        self.nodes.len() - self.free_list.len()
277    }
278
279    /// Total number of edges (forward).
280    #[must_use]
281    pub fn edge_count(&self) -> usize {
282        self.fwd_adj.iter().map(|v| v.len()).sum()
283    }
284
285    /// Set the parent of a node (structural tree relationship).
286    pub fn set_parent(&mut self, child: NodeId, parent: NodeId) {
287        if (child.0 as usize) < self.nodes.len() {
288            self.nodes[child.0 as usize].parent = parent.0;
289        }
290    }
291
292    /// Get the parent of a node, if any.
293    #[must_use]
294    pub fn parent(&self, id: NodeId) -> Option<NodeId> {
295        let node = self.nodes.get(id.0 as usize)?;
296        if node.parent == NodeId::NONE {
297            None
298        } else {
299            Some(NodeId(node.parent))
300        }
301    }
302
303    /// Add a dependency edge: `from` depends on `to`.
304    ///
305    /// Returns `Err(CycleError)` if this would create a cycle.
306    pub fn add_edge(&mut self, from: NodeId, to: NodeId) -> Result<(), CycleError> {
307        // Self-loops are cycles.
308        if from == to {
309            return Err(CycleError { from, to });
310        }
311
312        // Check: can `to` already reach `from` via existing forward edges?
313        // If so, adding `from → to` would create a cycle.
314        if self.can_reach(to, from) {
315            return Err(CycleError { from, to });
316        }
317
318        // Add forward edge: from depends on to.
319        self.fwd_adj[from.0 as usize].push(to);
320        // Add reverse edge: to is depended on by from.
321        self.rev_adj[to.0 as usize].push(from);
322
323        Ok(())
324    }
325
326    /// Check if `from` can reach `to` via forward edges (DFS).
327    fn can_reach(&self, from: NodeId, to: NodeId) -> bool {
328        let mut visited = vec![false; self.nodes.len()];
329        let mut stack = vec![from];
330
331        while let Some(current) = stack.pop() {
332            if current == to {
333                return true;
334            }
335            let idx = current.0 as usize;
336            if idx >= self.nodes.len() || visited[idx] {
337                continue;
338            }
339            visited[idx] = true;
340
341            if self.nodes[idx].generation == 0 {
342                continue; // Dead node.
343            }
344            for &dep in &self.fwd_adj[idx] {
345                if !visited[dep.0 as usize] {
346                    stack.push(dep);
347                }
348            }
349        }
350        false
351    }
352
353    /// Mark a node's input as changed. The node and its transitive
354    /// dependents will be dirtied on the next `propagate()` call.
355    ///
356    /// The `new_hash` is compared against the stored hash for the given
357    /// `kind`. If unchanged, the node is not dirtied (deduplication).
358    pub fn mark_changed(&mut self, id: NodeId, kind: InputKind, new_hash: u64) {
359        let idx = id.0 as usize;
360        if idx >= self.nodes.len() {
361            return;
362        }
363        let node = &mut self.nodes[idx];
364        if node.generation == 0 {
365            return; // Dead node.
366        }
367
368        let old_hash = match kind {
369            InputKind::Constraint => &mut node.constraint_hash,
370            InputKind::Content => &mut node.content_hash,
371            InputKind::Style => &mut node.style_hash,
372        };
373
374        if *old_hash == new_hash {
375            return; // No actual change.
376        }
377        *old_hash = new_hash;
378
379        // Mark dirty.
380        node.dirty_gen = self.current_gen;
381        self.pending_dirty.push(id);
382    }
383
384    /// Force-mark a node as dirty without hash comparison.
385    pub fn mark_dirty(&mut self, id: NodeId) {
386        let idx = id.0 as usize;
387        if idx >= self.nodes.len() {
388            return;
389        }
390        let node = &mut self.nodes[idx];
391        if node.generation == 0 {
392            return;
393        }
394        node.dirty_gen = self.current_gen;
395        self.pending_dirty.push(id);
396    }
397
398    /// Propagate dirtiness from pending dirty nodes to all transitive
399    /// dependents via BFS on reverse edges.
400    ///
401    /// Returns the complete dirty set in **DFS pre-order** (matching
402    /// full layout traversal order) for deterministic recomputation.
403    pub fn propagate(&mut self) -> Vec<NodeId> {
404        if self.pending_dirty.is_empty() {
405            return Vec::new();
406        }
407
408        // BFS to find all transitive dependents.
409        let mut queue = VecDeque::new();
410        let mut visited = vec![false; self.nodes.len()];
411
412        for &id in &self.pending_dirty {
413            let idx = id.0 as usize;
414            if idx < self.nodes.len() && !visited[idx] {
415                visited[idx] = true;
416                queue.push_back(id);
417            }
418        }
419        self.pending_dirty.clear();
420
421        while let Some(current) = queue.pop_front() {
422            let idx = current.0 as usize;
423            if self.nodes[idx].generation == 0 {
424                continue;
425            }
426
427            // Mark dirty.
428            self.nodes[idx].dirty_gen = self.current_gen;
429
430            // Enqueue all reverse-edge dependents.
431            for i in 0..self.rev_adj[idx].len() {
432                let dependent = self.rev_adj[idx][i];
433                let dep_idx = dependent.0 as usize;
434                if dep_idx < self.nodes.len() && !visited[dep_idx] {
435                    visited[dep_idx] = true;
436                    queue.push_back(dependent);
437                }
438            }
439        }
440
441        // Collect dirty set in DFS pre-order for deterministic traversal.
442        self.collect_dirty_dfs_preorder()
443    }
444
445    /// Collect all dirty nodes in DFS pre-order from roots.
446    fn collect_dirty_dfs_preorder(&self) -> Vec<NodeId> {
447        // Find roots: dirty nodes with no dirty parent.
448        let mut roots = Vec::new();
449        for (i, node) in self.nodes.iter().enumerate() {
450            if node.generation == 0 || !node.is_dirty() {
451                continue;
452            }
453            let is_root = if node.parent == NodeId::NONE {
454                true
455            } else {
456                let parent = &self.nodes[node.parent as usize];
457                parent.generation == 0 || !parent.is_dirty()
458            };
459            if is_root {
460                roots.push(NodeId(i as u32));
461            }
462        }
463        roots.sort(); // Deterministic ordering.
464
465        // DFS pre-order from each root.
466        let mut result = Vec::new();
467        let mut visited = vec![false; self.nodes.len()];
468
469        for root in roots {
470            self.dfs_preorder(root, &mut result, &mut visited);
471        }
472        result
473    }
474
475    /// DFS pre-order traversal of dirty nodes via reverse edges (children).
476    fn dfs_preorder(&self, id: NodeId, result: &mut Vec<NodeId>, visited: &mut [bool]) {
477        let idx = id.0 as usize;
478        if idx >= self.nodes.len() || visited[idx] {
479            return;
480        }
481        let node = &self.nodes[idx];
482        if node.generation == 0 || !node.is_dirty() {
483            return;
484        }
485        visited[idx] = true;
486        result.push(id);
487
488        // Visit dependents (children in the dirty tree).
489        let mut children: Vec<NodeId> = self.rev_adj[idx]
490            .iter()
491            .filter(|d| {
492                let di = d.0 as usize;
493                di < self.nodes.len()
494                    && !visited[di]
495                    && self.nodes[di].generation != 0
496                    && self.nodes[di].is_dirty()
497            })
498            .copied()
499            .collect();
500        children.sort(); // Deterministic.
501        for child in children {
502            self.dfs_preorder(child, result, visited);
503        }
504    }
505
506    /// Check if a node is currently dirty.
507    #[must_use]
508    pub fn is_dirty(&self, id: NodeId) -> bool {
509        self.nodes
510            .get(id.0 as usize)
511            .is_some_and(|n| n.generation != 0 && n.is_dirty())
512    }
513
514    /// Clean a single node (mark as not dirty).
515    pub fn clean(&mut self, id: NodeId) {
516        if let Some(node) = self.nodes.get_mut(id.0 as usize) {
517            node.generation = self.current_gen;
518            node.dirty_gen = 0;
519        }
520    }
521
522    /// Clean all nodes and advance the generation.
523    pub fn clean_all(&mut self) {
524        self.current_gen = self.current_gen.wrapping_add(1);
525        if self.current_gen == 0 {
526            self.current_gen = 1; // Skip 0 (dead sentinel).
527        }
528        for node in &mut self.nodes {
529            if node.generation != 0 {
530                node.generation = self.current_gen;
531                node.dirty_gen = 0;
532            }
533        }
534        self.pending_dirty.clear();
535    }
536
537    /// Get the constraint hash for a node.
538    #[must_use]
539    pub fn constraint_hash(&self, id: NodeId) -> Option<u64> {
540        self.nodes
541            .get(id.0 as usize)
542            .filter(|n| n.generation != 0)
543            .map(|n| n.constraint_hash)
544    }
545
546    /// Get the content hash for a node.
547    #[must_use]
548    pub fn content_hash(&self, id: NodeId) -> Option<u64> {
549        self.nodes
550            .get(id.0 as usize)
551            .filter(|n| n.generation != 0)
552            .map(|n| n.content_hash)
553    }
554
555    /// Get the style hash for a node.
556    #[must_use]
557    pub fn style_hash(&self, id: NodeId) -> Option<u64> {
558        self.nodes
559            .get(id.0 as usize)
560            .filter(|n| n.generation != 0)
561            .map(|n| n.style_hash)
562    }
563
564    /// Iterate all live, dirty node IDs.
565    pub fn dirty_nodes(&self) -> impl Iterator<Item = NodeId> + '_ {
566        self.nodes
567            .iter()
568            .enumerate()
569            .filter(|(_, n)| n.generation != 0 && n.is_dirty())
570            .map(|(i, _)| NodeId(i as u32))
571    }
572
573    /// Count of currently dirty nodes.
574    #[must_use]
575    pub fn dirty_count(&self) -> usize {
576        self.nodes
577            .iter()
578            .filter(|n| n.generation != 0 && n.is_dirty())
579            .count()
580    }
581
582    /// Get forward dependencies for a node (what it depends on).
583    #[must_use]
584    pub fn dependencies(&self, id: NodeId) -> &[NodeId] {
585        let idx = id.0 as usize;
586        if idx >= self.nodes.len() || self.nodes[idx].generation == 0 {
587            return &[];
588        }
589        &self.fwd_adj[idx]
590    }
591
592    /// Get reverse dependencies for a node (what depends on it).
593    #[must_use]
594    pub fn dependents(&self, id: NodeId) -> &[NodeId] {
595        let idx = id.0 as usize;
596        if idx >= self.nodes.len() || self.nodes[idx].generation == 0 {
597            return &[];
598        }
599        &self.rev_adj[idx]
600    }
601
602    /// Invalidate all nodes (equivalent to full layout).
603    /// Used as a fallback when incremental is not possible.
604    pub fn invalidate_all(&mut self) {
605        for (i, node) in self.nodes.iter_mut().enumerate() {
606            if node.generation != 0 {
607                node.dirty_gen = self.current_gen;
608                self.pending_dirty.push(NodeId(i as u32));
609            }
610        }
611    }
612}
613
614impl Default for DepGraph {
615    fn default() -> Self {
616        Self::new()
617    }
618}
619
620// ============================================================================
621// Tests
622// ============================================================================
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627
628    #[test]
629    fn add_node_returns_sequential_ids() {
630        let mut g = DepGraph::new();
631        let a = g.add_node();
632        let b = g.add_node();
633        let c = g.add_node();
634        assert_eq!(a.raw(), 0);
635        assert_eq!(b.raw(), 1);
636        assert_eq!(c.raw(), 2);
637        assert_eq!(g.node_count(), 3);
638    }
639
640    #[test]
641    fn remove_node_recycles_slot() {
642        let mut g = DepGraph::new();
643        let a = g.add_node();
644        let _b = g.add_node();
645        g.remove_node(a);
646        assert_eq!(g.node_count(), 1);
647        let c = g.add_node();
648        assert_eq!(c.raw(), 0); // Recycled slot.
649        assert_eq!(g.node_count(), 2);
650    }
651
652    #[test]
653    fn add_edge_creates_dependency() {
654        let mut g = DepGraph::new();
655        let a = g.add_node();
656        let b = g.add_node();
657        g.add_edge(a, b).unwrap();
658        assert_eq!(g.dependencies(a), &[b]);
659        assert_eq!(g.dependents(b), &[a]);
660        assert_eq!(g.edge_count(), 1);
661    }
662
663    #[test]
664    fn self_loop_detected() {
665        let mut g = DepGraph::new();
666        let a = g.add_node();
667        let err = g.add_edge(a, a).unwrap_err();
668        assert_eq!(err, CycleError { from: a, to: a });
669    }
670
671    #[test]
672    fn two_node_cycle_detected() {
673        let mut g = DepGraph::new();
674        let a = g.add_node();
675        let b = g.add_node();
676        g.add_edge(a, b).unwrap();
677        let err = g.add_edge(b, a).unwrap_err();
678        assert_eq!(err, CycleError { from: b, to: a });
679    }
680
681    #[test]
682    fn three_node_cycle_detected() {
683        let mut g = DepGraph::new();
684        let a = g.add_node();
685        let b = g.add_node();
686        let c = g.add_node();
687        g.add_edge(a, b).unwrap();
688        g.add_edge(b, c).unwrap();
689        let err = g.add_edge(c, a).unwrap_err();
690        assert_eq!(err, CycleError { from: c, to: a });
691    }
692
693    #[test]
694    fn dag_allows_diamond() {
695        // A → B, A → C, B → D, C → D (diamond, no cycle).
696        let mut g = DepGraph::new();
697        let a = g.add_node();
698        let b = g.add_node();
699        let c = g.add_node();
700        let d = g.add_node();
701        g.add_edge(a, b).unwrap();
702        g.add_edge(a, c).unwrap();
703        g.add_edge(b, d).unwrap();
704        g.add_edge(c, d).unwrap();
705        assert_eq!(g.edge_count(), 4);
706    }
707
708    #[test]
709    fn mark_changed_deduplicates() {
710        let mut g = DepGraph::new();
711        let a = g.add_node();
712        g.mark_changed(a, InputKind::Constraint, 42);
713        assert!(g.is_dirty(a));
714
715        // Same hash → no additional dirtying.
716        g.clean(a);
717        g.mark_changed(a, InputKind::Constraint, 42);
718        assert!(!g.is_dirty(a)); // Hash unchanged.
719    }
720
721    #[test]
722    fn mark_changed_different_hash() {
723        let mut g = DepGraph::new();
724        let a = g.add_node();
725        g.mark_changed(a, InputKind::Constraint, 42);
726        g.clean(a);
727        g.mark_changed(a, InputKind::Constraint, 99); // Different hash.
728        assert!(g.is_dirty(a));
729    }
730
731    #[test]
732    fn propagate_single_node() {
733        let mut g = DepGraph::new();
734        let a = g.add_node();
735        g.mark_dirty(a);
736        let dirty = g.propagate();
737        assert_eq!(dirty, vec![a]);
738    }
739
740    #[test]
741    fn propagate_parent_to_child() {
742        let mut g = DepGraph::new();
743        let parent = g.add_node();
744        let child = g.add_node();
745        g.add_edge(child, parent).unwrap(); // child depends on parent
746        g.set_parent(child, parent);
747
748        g.mark_dirty(parent);
749        let dirty = g.propagate();
750        assert!(dirty.contains(&parent));
751        assert!(dirty.contains(&child));
752    }
753
754    #[test]
755    fn propagate_chain() {
756        // A → B → C: dirtying A should dirty B and C.
757        let mut g = DepGraph::new();
758        let a = g.add_node();
759        let b = g.add_node();
760        let c = g.add_node();
761        g.add_edge(b, a).unwrap(); // B depends on A
762        g.add_edge(c, b).unwrap(); // C depends on B
763        g.set_parent(b, a);
764        g.set_parent(c, b);
765
766        g.mark_dirty(a);
767        let dirty = g.propagate();
768        assert_eq!(dirty.len(), 3);
769        assert!(dirty.contains(&a));
770        assert!(dirty.contains(&b));
771        assert!(dirty.contains(&c));
772    }
773
774    #[test]
775    fn propagate_only_affected_subtree() {
776        // A → B, A → C. D is independent.
777        let mut g = DepGraph::new();
778        let a = g.add_node();
779        let b = g.add_node();
780        let c = g.add_node();
781        let d = g.add_node();
782        g.add_edge(b, a).unwrap();
783        g.add_edge(c, a).unwrap();
784
785        g.mark_dirty(a);
786        let dirty = g.propagate();
787        assert!(dirty.contains(&a));
788        assert!(dirty.contains(&b));
789        assert!(dirty.contains(&c));
790        assert!(!dirty.contains(&d));
791    }
792
793    #[test]
794    fn propagate_diamond_deduplicates() {
795        // D depends on B and C, both depend on A.
796        let mut g = DepGraph::new();
797        let a = g.add_node();
798        let b = g.add_node();
799        let c = g.add_node();
800        let d = g.add_node();
801        g.add_edge(b, a).unwrap();
802        g.add_edge(c, a).unwrap();
803        g.add_edge(d, b).unwrap();
804        g.add_edge(d, c).unwrap();
805
806        g.mark_dirty(a);
807        let dirty = g.propagate();
808        assert_eq!(dirty.len(), 4);
809        // Each node appears exactly once.
810        let unique: std::collections::HashSet<_> = dirty.iter().collect();
811        assert_eq!(unique.len(), 4);
812    }
813
814    #[test]
815    fn clean_all_resets() {
816        let mut g = DepGraph::new();
817        let a = g.add_node();
818        let b = g.add_node();
819        g.add_edge(b, a).unwrap();
820        g.mark_dirty(a);
821        g.propagate();
822        assert!(g.is_dirty(a));
823        assert!(g.is_dirty(b));
824
825        g.clean_all();
826        assert!(!g.is_dirty(a));
827        assert!(!g.is_dirty(b));
828        assert_eq!(g.dirty_count(), 0);
829    }
830
831    #[test]
832    fn invalidate_all_dirties_everything() {
833        let mut g = DepGraph::new();
834        let a = g.add_node();
835        let b = g.add_node();
836        let c = g.add_node();
837
838        g.invalidate_all();
839        let dirty = g.propagate();
840        assert_eq!(dirty.len(), 3);
841        assert!(dirty.contains(&a));
842        assert!(dirty.contains(&b));
843        assert!(dirty.contains(&c));
844    }
845
846    #[test]
847    fn parent_child_relationship() {
848        let mut g = DepGraph::new();
849        let a = g.add_node();
850        let b = g.add_node();
851        assert_eq!(g.parent(a), None);
852        g.set_parent(b, a);
853        assert_eq!(g.parent(b), Some(a));
854    }
855
856    #[test]
857    fn input_hashes_stored_independently() {
858        let mut g = DepGraph::new();
859        let a = g.add_node();
860        g.mark_changed(a, InputKind::Constraint, 1);
861        g.mark_changed(a, InputKind::Content, 2);
862        g.mark_changed(a, InputKind::Style, 3);
863
864        assert_eq!(g.constraint_hash(a), Some(1));
865        assert_eq!(g.content_hash(a), Some(2));
866        assert_eq!(g.style_hash(a), Some(3));
867    }
868
869    #[test]
870    fn dead_node_is_not_dirty() {
871        let mut g = DepGraph::new();
872        let a = g.add_node();
873        g.mark_dirty(a);
874        g.remove_node(a);
875        assert!(!g.is_dirty(a));
876    }
877
878    #[test]
879    fn propagate_skips_dead_nodes() {
880        let mut g = DepGraph::new();
881        let a = g.add_node();
882        let b = g.add_node();
883        let c = g.add_node();
884        g.add_edge(b, a).unwrap();
885        g.add_edge(c, b).unwrap();
886
887        // Remove B from the chain.
888        g.remove_node(b);
889        g.mark_dirty(a);
890        let dirty = g.propagate();
891        // B is dead, so C should not be reached (B's reverse edges are cleared).
892        assert!(dirty.contains(&a));
893        assert!(!dirty.contains(&c));
894    }
895
896    #[test]
897    fn propagate_empty_returns_empty() {
898        let mut g = DepGraph::new();
899        let _a = g.add_node();
900        let dirty = g.propagate();
901        assert!(dirty.is_empty());
902    }
903
904    #[test]
905    fn large_tree_propagation() {
906        // 1000-node tree: root → 10 children → 10 grandchildren each.
907        let mut g = DepGraph::with_capacity(1000, 1000);
908        let root = g.add_node();
909        let mut leaves = Vec::new();
910
911        for _ in 0..10 {
912            let child = g.add_node();
913            g.add_edge(child, root).unwrap();
914            g.set_parent(child, root);
915
916            for _ in 0..10 {
917                let grandchild = g.add_node();
918                g.add_edge(grandchild, child).unwrap();
919                g.set_parent(grandchild, child);
920                leaves.push(grandchild);
921            }
922        }
923        assert_eq!(g.node_count(), 111); // 1 + 10 + 100
924
925        // Dirty root → all 111 nodes dirty.
926        g.mark_dirty(root);
927        let dirty = g.propagate();
928        assert_eq!(dirty.len(), 111);
929
930        // Clean and dirty one leaf → only that leaf.
931        g.clean_all();
932        g.mark_dirty(leaves[42]);
933        let dirty = g.propagate();
934        assert_eq!(dirty.len(), 1);
935        assert_eq!(dirty[0], leaves[42]);
936    }
937
938    #[test]
939    fn node_size_under_64_bytes() {
940        assert!(
941            std::mem::size_of::<DepNode>() <= 64,
942            "DepNode is {} bytes, exceeds 64-byte budget",
943            std::mem::size_of::<DepNode>(),
944        );
945    }
946
947    #[test]
948    fn node_size_exactly_40_bytes() {
949        // 4+4+8+8+8+4 = 36 raw, but u64 alignment pads to 40.
950        assert_eq!(
951            std::mem::size_of::<DepNode>(),
952            40,
953            "DepNode should be 40 bytes, got {}",
954            std::mem::size_of::<DepNode>(),
955        );
956    }
957
958    #[test]
959    fn multiple_input_changes_single_propagation() {
960        let mut g = DepGraph::new();
961        let a = g.add_node();
962        let b = g.add_node();
963        g.add_edge(b, a).unwrap();
964
965        // Change constraint AND content before propagating.
966        g.mark_changed(a, InputKind::Constraint, 10);
967        g.mark_changed(a, InputKind::Content, 20);
968        let dirty = g.propagate();
969        assert_eq!(dirty.len(), 2); // a and b
970    }
971
972    #[test]
973    fn propagate_dfs_preorder() {
974        // Tree: R → A → (B, C). Should visit R, A, B, C in that order.
975        let mut g = DepGraph::new();
976        let r = g.add_node(); // 0
977        let a = g.add_node(); // 1
978        let b = g.add_node(); // 2
979        let c = g.add_node(); // 3
980        g.add_edge(a, r).unwrap();
981        g.add_edge(b, a).unwrap();
982        g.add_edge(c, a).unwrap();
983        g.set_parent(a, r);
984        g.set_parent(b, a);
985        g.set_parent(c, a);
986
987        g.mark_dirty(r);
988        let dirty = g.propagate();
989        assert_eq!(dirty.len(), 4);
990        // DFS pre-order from root: R first, then A, then B and C.
991        assert_eq!(dirty[0], r);
992        assert_eq!(dirty[1], a);
993        // B and C are siblings, ordered by NodeId.
994        assert_eq!(dirty[2], b);
995        assert_eq!(dirty[3], c);
996    }
997
998    #[test]
999    fn dirty_count_accurate() {
1000        let mut g = DepGraph::new();
1001        let a = g.add_node();
1002        let b = g.add_node();
1003        let c = g.add_node();
1004        assert_eq!(g.dirty_count(), 0);
1005
1006        g.mark_dirty(a);
1007        g.mark_dirty(b);
1008        g.propagate();
1009        assert_eq!(g.dirty_count(), 2);
1010
1011        g.clean(a);
1012        assert_eq!(g.dirty_count(), 1);
1013
1014        g.clean_all();
1015        assert_eq!(g.dirty_count(), 0);
1016
1017        // Suppress unused variable warning.
1018        let _ = c;
1019    }
1020
1021    #[test]
1022    fn dependencies_and_dependents_api() {
1023        let mut g = DepGraph::new();
1024        let a = g.add_node();
1025        let b = g.add_node();
1026        let c = g.add_node();
1027        g.add_edge(b, a).unwrap(); // b depends on a
1028        g.add_edge(c, a).unwrap(); // c depends on a
1029
1030        assert_eq!(g.dependencies(b), &[a]);
1031        assert_eq!(g.dependencies(c), &[a]);
1032        assert_eq!(g.dependents(a).len(), 2);
1033    }
1034}