solar_line_core/
dag.rs

1//! DAG (Directed Acyclic Graph) analysis for dependency tracking.
2//!
3//! Mirrors the TypeScript DAG model (`dag-types.ts`) and provides
4//! graph analysis algorithms that can be compiled to WASM for
5//! browser-side interactive analysis.
6
7// ---------------------------------------------------------------------------
8// Types
9// ---------------------------------------------------------------------------
10
11/// Node type within the DAG.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum NodeType {
14    DataSource,
15    Parameter,
16    Analysis,
17    Report,
18    Task,
19}
20
21impl NodeType {
22    pub fn as_str(&self) -> &'static str {
23        match self {
24            NodeType::DataSource => "data_source",
25            NodeType::Parameter => "parameter",
26            NodeType::Analysis => "analysis",
27            NodeType::Report => "report",
28            NodeType::Task => "task",
29        }
30    }
31
32    pub fn parse(s: &str) -> Option<Self> {
33        match s {
34            "data_source" => Some(NodeType::DataSource),
35            "parameter" => Some(NodeType::Parameter),
36            "analysis" => Some(NodeType::Analysis),
37            "report" => Some(NodeType::Report),
38            "task" => Some(NodeType::Task),
39            _ => None,
40        }
41    }
42
43    /// Layer priority for Sugiyama-style layout (lower = further left/top).
44    pub fn layer_order(&self) -> usize {
45        match self {
46            NodeType::DataSource => 0,
47            NodeType::Parameter => 1,
48            NodeType::Analysis => 2,
49            NodeType::Report => 3,
50            NodeType::Task => 4,
51        }
52    }
53}
54
55/// Node status.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum NodeStatus {
58    Valid,
59    Stale,
60    Pending,
61    Active,
62    Blocked,
63}
64
65impl NodeStatus {
66    pub fn as_str(&self) -> &'static str {
67        match self {
68            NodeStatus::Valid => "valid",
69            NodeStatus::Stale => "stale",
70            NodeStatus::Pending => "pending",
71            NodeStatus::Active => "active",
72            NodeStatus::Blocked => "blocked",
73        }
74    }
75
76    pub fn parse(s: &str) -> Option<Self> {
77        match s {
78            "valid" => Some(NodeStatus::Valid),
79            "stale" => Some(NodeStatus::Stale),
80            "pending" => Some(NodeStatus::Pending),
81            "active" => Some(NodeStatus::Active),
82            "blocked" => Some(NodeStatus::Blocked),
83            _ => None,
84        }
85    }
86}
87
88/// A single node in the DAG.
89#[derive(Debug, Clone)]
90pub struct DagNode {
91    pub id: usize,
92    pub node_type: NodeType,
93    pub status: NodeStatus,
94    /// Indices into Dag::nodes of upstream dependencies.
95    pub depends_on: Vec<usize>,
96    /// Tags for filtering (e.g. "episode:01", "source").
97    pub tags: Vec<String>,
98}
99
100/// Complete DAG state: nodes + reverse index.
101pub struct Dag {
102    pub nodes: Vec<DagNode>,
103    /// Forward adjacency: node → list of nodes that depend on it (downstream).
104    forward: Vec<Vec<usize>>,
105}
106
107// ---------------------------------------------------------------------------
108// Error type
109// ---------------------------------------------------------------------------
110
111#[derive(Debug, Clone, PartialEq)]
112pub enum DagError {
113    CycleDetected(Vec<usize>),
114    NodeNotFound(usize),
115    DuplicateEdge { from: usize, to: usize },
116}
117
118// ---------------------------------------------------------------------------
119// Construction
120// ---------------------------------------------------------------------------
121
122impl Dag {
123    /// Build a DAG from a set of nodes. Nodes must be indexed 0..n-1.
124    /// Automatically builds the forward (reverse-edge) index.
125    pub fn new(nodes: Vec<DagNode>) -> Self {
126        let n = nodes.len();
127        let mut forward = vec![Vec::new(); n];
128        for node in &nodes {
129            for &dep in &node.depends_on {
130                if dep < n {
131                    forward[dep].push(node.id);
132                }
133            }
134        }
135        Dag { nodes, forward }
136    }
137
138    pub fn node_count(&self) -> usize {
139        self.nodes.len()
140    }
141
142    pub fn edge_count(&self) -> usize {
143        self.nodes.iter().map(|n| n.depends_on.len()).sum()
144    }
145
146    // -----------------------------------------------------------------------
147    // Basic queries
148    // -----------------------------------------------------------------------
149
150    /// Get direct upstream dependencies of a node.
151    pub fn upstream(&self, node: usize) -> &[usize] {
152        &self.nodes[node].depends_on
153    }
154
155    /// Get direct downstream dependents of a node.
156    pub fn downstream(&self, node: usize) -> &[usize] {
157        &self.forward[node]
158    }
159
160    /// Get all transitive upstream nodes (BFS).
161    pub fn all_upstream(&self, node: usize) -> Vec<usize> {
162        let mut visited = vec![false; self.nodes.len()];
163        let mut queue = Vec::new();
164        let mut result = Vec::new();
165        for &dep in &self.nodes[node].depends_on {
166            if !visited[dep] {
167                visited[dep] = true;
168                queue.push(dep);
169            }
170        }
171        while let Some(current) = queue.pop() {
172            result.push(current);
173            for &dep in &self.nodes[current].depends_on {
174                if !visited[dep] {
175                    visited[dep] = true;
176                    queue.push(dep);
177                }
178            }
179        }
180        result
181    }
182
183    /// Get all transitive downstream nodes (BFS). This is the invalidation cascade.
184    pub fn all_downstream(&self, node: usize) -> Vec<usize> {
185        let mut visited = vec![false; self.nodes.len()];
186        let mut queue = Vec::new();
187        let mut result = Vec::new();
188        for &child in &self.forward[node] {
189            if !visited[child] {
190                visited[child] = true;
191                queue.push(child);
192            }
193        }
194        while let Some(current) = queue.pop() {
195            result.push(current);
196            for &child in &self.forward[current] {
197                if !visited[child] {
198                    visited[child] = true;
199                    queue.push(child);
200                }
201            }
202        }
203        result
204    }
205
206    // -----------------------------------------------------------------------
207    // Topological sort
208    // -----------------------------------------------------------------------
209
210    /// Kahn's algorithm. Returns None if cycle detected.
211    pub fn topological_sort(&self) -> Option<Vec<usize>> {
212        let n = self.nodes.len();
213        let mut in_degree = vec![0usize; n];
214        for node in &self.nodes {
215            in_degree[node.id] = node.depends_on.len();
216        }
217
218        let mut queue: Vec<usize> = (0..n).filter(|&i| in_degree[i] == 0).collect();
219        let mut order = Vec::with_capacity(n);
220
221        while let Some(u) = queue.pop() {
222            order.push(u);
223            for &v in &self.forward[u] {
224                in_degree[v] -= 1;
225                if in_degree[v] == 0 {
226                    queue.push(v);
227                }
228            }
229        }
230
231        if order.len() == n {
232            Some(order)
233        } else {
234            None
235        }
236    }
237
238    // -----------------------------------------------------------------------
239    // Cycle detection (DFS 3-color)
240    // -----------------------------------------------------------------------
241
242    /// Returns the cycle path if one exists, or None.
243    pub fn detect_cycle(&self) -> Option<Vec<usize>> {
244        let n = self.nodes.len();
245        // 0 = white, 1 = gray (in stack), 2 = black (done)
246        let mut color = vec![0u8; n];
247        let mut parent = vec![usize::MAX; n];
248
249        for start in 0..n {
250            if color[start] != 0 {
251                continue;
252            }
253            let mut stack = vec![(start, 0usize)]; // (node, edge_index)
254            color[start] = 1;
255
256            while let Some(&mut (u, ref mut ei)) = stack.last_mut() {
257                if *ei < self.forward[u].len() {
258                    let v = self.forward[u][*ei];
259                    *ei += 1;
260                    if color[v] == 1 {
261                        // Found cycle: trace back
262                        let mut cycle = vec![v, u];
263                        let mut cur = u;
264                        while cur != v {
265                            cur = parent[cur];
266                            if cur == usize::MAX {
267                                break;
268                            }
269                            cycle.push(cur);
270                        }
271                        cycle.reverse();
272                        return Some(cycle);
273                    } else if color[v] == 0 {
274                        color[v] = 1;
275                        parent[v] = u;
276                        stack.push((v, 0));
277                    }
278                } else {
279                    color[u] = 2;
280                    stack.pop();
281                }
282            }
283        }
284        None
285    }
286
287    // -----------------------------------------------------------------------
288    // Depth (longest path from a root)
289    // -----------------------------------------------------------------------
290
291    /// Compute the depth (longest path from any root) of each node.
292    /// Roots (no dependencies) have depth 0.
293    pub fn compute_depths(&self) -> Vec<usize> {
294        let n = self.nodes.len();
295        let mut depth = vec![0usize; n];
296
297        if let Some(order) = self.topological_sort() {
298            for &u in &order {
299                for &v in &self.forward[u] {
300                    if depth[v] < depth[u] + 1 {
301                        depth[v] = depth[u] + 1;
302                    }
303                }
304            }
305        }
306        depth
307    }
308
309    // -----------------------------------------------------------------------
310    // Critical path (longest path through the DAG)
311    // -----------------------------------------------------------------------
312
313    /// Find the longest path (critical path) from any root to any leaf.
314    /// Returns the path as a sequence of node indices.
315    pub fn critical_path(&self) -> Vec<usize> {
316        let depths = self.compute_depths();
317
318        // Find the deepest node
319        let mut max_depth = 0;
320        let mut deepest = 0;
321        for (i, &d) in depths.iter().enumerate() {
322            if d > max_depth {
323                max_depth = d;
324                deepest = i;
325            }
326        }
327
328        // Trace back from deepest to root
329        let mut path = vec![deepest];
330        let mut current = deepest;
331        while !self.nodes[current].depends_on.is_empty() {
332            // Pick the dependency with the highest depth
333            let mut best_dep = self.nodes[current].depends_on[0];
334            let mut best_depth = depths[best_dep];
335            for &dep in &self.nodes[current].depends_on[1..] {
336                if depths[dep] > best_depth {
337                    best_depth = depths[dep];
338                    best_dep = dep;
339                }
340            }
341            path.push(best_dep);
342            current = best_dep;
343        }
344        path.reverse();
345        path
346    }
347
348    // -----------------------------------------------------------------------
349    // Sugiyama-style layered layout
350    // -----------------------------------------------------------------------
351
352    /// Assign layers using a depth-based approach for proper dependency flow.
353    /// Returns (layer_assignment, max_layer).
354    /// Layer 0 = leftmost/topmost (roots).
355    pub fn assign_layers(&self) -> (Vec<usize>, usize) {
356        let depths = self.compute_depths();
357        let max = depths.iter().copied().max().unwrap_or(0);
358        (depths, max)
359    }
360
361    /// Minimize edge crossings within each layer using the barycenter heuristic.
362    /// Takes layer assignments and returns an ordering of nodes within each layer.
363    /// Returns Vec<Vec<usize>> where result[layer] = ordered node indices in that layer.
364    pub fn minimize_crossings(&self, layers: &[usize], max_layer: usize) -> Vec<Vec<usize>> {
365        let mut layer_nodes: Vec<Vec<usize>> = vec![Vec::new(); max_layer + 1];
366        for (i, &layer) in layers.iter().enumerate() {
367            layer_nodes[layer].push(i);
368        }
369
370        // Initial ordering by type priority then tags
371        for layer in &mut layer_nodes {
372            layer.sort_by(|&a, &b| {
373                let ta = self.nodes[a].node_type.layer_order();
374                let tb = self.nodes[b].node_type.layer_order();
375                ta.cmp(&tb).then_with(|| {
376                    // Sort by first tag (episode order)
377                    let tag_a = self.nodes[a].tags.first().map(|s| s.as_str()).unwrap_or("");
378                    let tag_b = self.nodes[b].tags.first().map(|s| s.as_str()).unwrap_or("");
379                    tag_a.cmp(tag_b)
380                })
381            });
382        }
383
384        // Barycenter heuristic: iterate forward and backward passes
385        for _pass in 0..4 {
386            // Forward pass: order layer[i] by average position of dependencies in layer[i-1]
387            for l in 1..=max_layer {
388                let prev_positions = position_map(&layer_nodes[l - 1]);
389                layer_nodes[l].sort_by(|&a, &b| {
390                    let bc_a = barycenter(&self.nodes[a].depends_on, &prev_positions);
391                    let bc_b = barycenter(&self.nodes[b].depends_on, &prev_positions);
392                    bc_a.partial_cmp(&bc_b)
393                        .unwrap_or(core::cmp::Ordering::Equal)
394                });
395            }
396
397            // Backward pass: order layer[i] by average position of downstream in layer[i+1]
398            for l in (0..max_layer).rev() {
399                let next_positions = position_map(&layer_nodes[l + 1]);
400                layer_nodes[l].sort_by(|&a, &b| {
401                    let bc_a = barycenter(&self.forward[a], &next_positions);
402                    let bc_b = barycenter(&self.forward[b], &next_positions);
403                    bc_a.partial_cmp(&bc_b)
404                        .unwrap_or(core::cmp::Ordering::Equal)
405                });
406            }
407        }
408
409        layer_nodes
410    }
411
412    /// Full Sugiyama layout: returns (x, y) positions for each node.
413    /// Coordinates are normalized to [0, 1] range.
414    pub fn layout(&self, width: f64, height: f64) -> Vec<(f64, f64)> {
415        let n = self.nodes.len();
416        if n == 0 {
417            return Vec::new();
418        }
419
420        let (layers, max_layer) = self.assign_layers();
421        let layer_nodes = self.minimize_crossings(&layers, max_layer);
422
423        let mut positions = vec![(0.0, 0.0); n];
424
425        let layer_count = layer_nodes.len();
426        let x_spacing = if layer_count > 1 {
427            width / (layer_count as f64 + 1.0)
428        } else {
429            width / 2.0
430        };
431
432        for (layer_idx, nodes_in_layer) in layer_nodes.iter().enumerate() {
433            let node_count = nodes_in_layer.len();
434            let y_spacing = if node_count > 1 {
435                height / (node_count as f64 + 1.0)
436            } else {
437                height / 2.0
438            };
439
440            for (pos_idx, &node_id) in nodes_in_layer.iter().enumerate() {
441                let x = x_spacing * (layer_idx as f64 + 1.0);
442                let y = if node_count > 1 {
443                    y_spacing * (pos_idx as f64 + 1.0)
444                } else {
445                    height / 2.0
446                };
447                positions[node_id] = (x, y);
448            }
449        }
450
451        positions
452    }
453
454    // -----------------------------------------------------------------------
455    // Subgraph extraction (for focused views)
456    // -----------------------------------------------------------------------
457
458    /// Extract a subgraph containing only nodes matching a predicate,
459    /// plus their transitive dependencies and dependents up to a given depth.
460    /// Returns the set of included node indices.
461    pub fn subgraph<F>(&self, predicate: F, depth: usize) -> Vec<usize>
462    where
463        F: Fn(&DagNode) -> bool,
464    {
465        let mut included = vec![false; self.nodes.len()];
466        let seeds: Vec<usize> = self
467            .nodes
468            .iter()
469            .filter(|n| predicate(n))
470            .map(|n| n.id)
471            .collect();
472
473        for &seed in &seeds {
474            included[seed] = true;
475        }
476
477        // Expand upstream
478        for &seed in &seeds {
479            self.expand_direction(seed, depth, true, &mut included);
480        }
481
482        // Expand downstream
483        for &seed in &seeds {
484            self.expand_direction(seed, depth, false, &mut included);
485        }
486
487        (0..self.nodes.len()).filter(|&i| included[i]).collect()
488    }
489
490    fn expand_direction(
491        &self,
492        start: usize,
493        max_depth: usize,
494        upstream: bool,
495        included: &mut [bool],
496    ) {
497        let mut frontier = vec![(start, 0usize)];
498        while let Some((node, d)) = frontier.pop() {
499            if d >= max_depth {
500                continue;
501            }
502            let neighbors = if upstream {
503                &self.nodes[node].depends_on
504            } else {
505                &self.forward[node]
506            };
507            for &next in neighbors {
508                if !included[next] {
509                    included[next] = true;
510                    frontier.push((next, d + 1));
511                }
512            }
513        }
514    }
515
516    // -----------------------------------------------------------------------
517    // Impact analysis
518    // -----------------------------------------------------------------------
519
520    /// Simulate invalidating a node: returns (cascade_count, affected_by_type).
521    /// affected_by_type[i] = count of affected nodes of type i (indexed by NodeType::layer_order).
522    pub fn impact_analysis(&self, node: usize) -> ImpactResult {
523        let affected = self.all_downstream(node);
524        let mut by_type = [0usize; 5];
525        for &a in &affected {
526            by_type[self.nodes[a].node_type.layer_order()] += 1;
527        }
528        ImpactResult {
529            source: node,
530            cascade_count: affected.len(),
531            affected_nodes: affected,
532            by_type,
533        }
534    }
535
536    // -----------------------------------------------------------------------
537    // Find orphan nodes (no edges in either direction)
538    // -----------------------------------------------------------------------
539
540    pub fn orphans(&self) -> Vec<usize> {
541        self.nodes
542            .iter()
543            .filter(|n| n.depends_on.is_empty() && self.forward[n.id].is_empty())
544            .map(|n| n.id)
545            .collect()
546    }
547
548    // -----------------------------------------------------------------------
549    // Validation
550    // -----------------------------------------------------------------------
551
552    /// Validate the DAG state. Returns a list of issues (empty = valid).
553    /// Issues starting with "ERROR:" are critical; "WARN:" are advisory.
554    pub fn validate(&self) -> Vec<String> {
555        let mut issues = Vec::new();
556
557        // Check for missing dependency targets
558        for node in &self.nodes {
559            for &dep in &node.depends_on {
560                if dep >= self.nodes.len() {
561                    issues.push(format!(
562                        "ERROR: Node {} depends on unknown node {}",
563                        node.id, dep
564                    ));
565                }
566            }
567        }
568
569        // Check for cycles
570        if let Some(cycle) = self.detect_cycle() {
571            let cycle_str: Vec<String> = cycle.iter().map(|i| i.to_string()).collect();
572            issues.push(format!("ERROR: Cycle detected: {}", cycle_str.join(" → ")));
573        }
574
575        // Warn about orphans
576        for id in self.orphans() {
577            issues.push(format!("WARN: Orphan node {} has no connections", id));
578        }
579
580        // Type rules: data sources shouldn't have dependencies
581        for node in &self.nodes {
582            if node.node_type == NodeType::DataSource && !node.depends_on.is_empty() {
583                issues.push(format!(
584                    "WARN: Data source {} has dependencies (unusual)",
585                    node.id
586                ));
587            }
588        }
589
590        // Type rules: parameters shouldn't depend on reports
591        for node in &self.nodes {
592            if node.node_type == NodeType::Parameter {
593                for &dep in &node.depends_on {
594                    if dep < self.nodes.len() && self.nodes[dep].node_type == NodeType::Report {
595                        issues.push(format!(
596                            "WARN: Parameter {} depends on report {} (unusual direction)",
597                            node.id, dep
598                        ));
599                    }
600                }
601            }
602        }
603
604        issues
605    }
606
607    // -----------------------------------------------------------------------
608    // Stale nodes (sorted by dependency order — leaves first)
609    // -----------------------------------------------------------------------
610
611    /// Get stale nodes sorted by dependency depth (shallowest first).
612    pub fn stale_nodes(&self) -> Vec<usize> {
613        let depths = self.compute_depths();
614        let mut stale: Vec<usize> = self
615            .nodes
616            .iter()
617            .filter(|n| n.status == NodeStatus::Stale)
618            .map(|n| n.id)
619            .collect();
620        stale.sort_by_key(|&id| depths[id]);
621        stale
622    }
623
624    // -----------------------------------------------------------------------
625    // Task planning
626    // -----------------------------------------------------------------------
627
628    /// Get task nodes that are ready to work on:
629    /// - Type is Task
630    /// - Status is Pending
631    /// - All dependencies are Valid
632    pub fn plannable_tasks(&self) -> Vec<usize> {
633        self.nodes
634            .iter()
635            .filter(|n| n.node_type == NodeType::Task && n.status == NodeStatus::Pending)
636            .filter(|n| {
637                n.depends_on.iter().all(|&dep| {
638                    dep < self.nodes.len() && self.nodes[dep].status == NodeStatus::Valid
639                })
640            })
641            .map(|n| n.id)
642            .collect()
643    }
644
645    /// Get task nodes that are blocked:
646    /// - Type is Task
647    /// - Status is Pending
648    /// - At least one dependency is not Valid
649    pub fn blocked_tasks(&self) -> Vec<usize> {
650        self.nodes
651            .iter()
652            .filter(|n| n.node_type == NodeType::Task && n.status == NodeStatus::Pending)
653            .filter(|n| {
654                n.depends_on.iter().any(|&dep| {
655                    dep >= self.nodes.len() || self.nodes[dep].status != NodeStatus::Valid
656                })
657            })
658            .map(|n| n.id)
659            .collect()
660    }
661
662    /// Get active (in-progress) task nodes.
663    pub fn active_tasks(&self) -> Vec<usize> {
664        self.nodes
665            .iter()
666            .filter(|n| n.node_type == NodeType::Task && n.status == NodeStatus::Active)
667            .map(|n| n.id)
668            .collect()
669    }
670
671    /// Find groups of plannable tasks that can be executed in parallel
672    /// (no mutual transitive dependencies).
673    pub fn parallel_groups(&self) -> Vec<Vec<usize>> {
674        let plannable = self.plannable_tasks();
675        if plannable.is_empty() {
676            return Vec::new();
677        }
678
679        let mut groups: Vec<Vec<usize>> = Vec::new();
680        let mut assigned = vec![false; self.nodes.len()];
681
682        for &task in &plannable {
683            if assigned[task] {
684                continue;
685            }
686            let mut group = vec![task];
687            assigned[task] = true;
688            let task_up: Vec<bool> = {
689                let up = self.all_upstream(task);
690                let mut v = vec![false; self.nodes.len()];
691                for &u in &up {
692                    v[u] = true;
693                }
694                v
695            };
696            let task_down: Vec<bool> = {
697                let down = self.all_downstream(task);
698                let mut v = vec![false; self.nodes.len()];
699                for &d in &down {
700                    v[d] = true;
701                }
702                v
703            };
704
705            for &other in &plannable {
706                if assigned[other] {
707                    continue;
708                }
709                if task_up[other] || task_down[other] {
710                    continue;
711                }
712                // Also check against all existing group members
713                let mut conflicts = false;
714                for &member in &group {
715                    let m_up = self.all_upstream(member);
716                    let m_down = self.all_downstream(member);
717                    if m_up.contains(&other) || m_down.contains(&other) {
718                        conflicts = true;
719                        break;
720                    }
721                }
722                if !conflicts {
723                    group.push(other);
724                    assigned[other] = true;
725                }
726            }
727
728            groups.push(group);
729        }
730
731        groups
732    }
733
734    /// Summarize the DAG state as a human-readable string.
735    pub fn summarize(&self) -> String {
736        let mut by_type = [0usize; 5];
737        let mut by_status = [0usize; 5]; // valid, stale, pending, active, blocked
738        for node in &self.nodes {
739            by_type[node.node_type.layer_order()] += 1;
740            match node.status {
741                NodeStatus::Valid => by_status[0] += 1,
742                NodeStatus::Stale => by_status[1] += 1,
743                NodeStatus::Pending => by_status[2] += 1,
744                NodeStatus::Active => by_status[3] += 1,
745                NodeStatus::Blocked => by_status[4] += 1,
746            }
747        }
748
749        let type_names = ["data_source", "parameter", "analysis", "report", "task"];
750        let status_names = ["valid", "stale", "pending", "active", "blocked"];
751
752        let mut lines = Vec::new();
753        lines.push(format!(
754            "DAG: {} nodes, {} edges",
755            self.nodes.len(),
756            self.edge_count()
757        ));
758
759        let types_str: Vec<String> = type_names
760            .iter()
761            .zip(by_type.iter())
762            .filter(|(_, &c)| c > 0)
763            .map(|(n, c)| format!("{}={}", n, c))
764            .collect();
765        lines.push(format!("Types: {}", types_str.join(", ")));
766
767        let status_str: Vec<String> = status_names
768            .iter()
769            .zip(by_status.iter())
770            .filter(|(_, &c)| c > 0)
771            .map(|(n, c)| format!("{}={}", n, c))
772            .collect();
773        lines.push(format!("Status: {}", status_str.join(", ")));
774
775        let issues = self.validate();
776        let errors = issues.iter().filter(|i| i.starts_with("ERROR")).count();
777        let warns = issues.iter().filter(|i| i.starts_with("WARN")).count();
778        if errors > 0 {
779            lines.push(format!("Errors: {}", errors));
780        }
781        if warns > 0 {
782            lines.push(format!("Warnings: {}", warns));
783        }
784        for issue in &issues {
785            lines.push(format!("  {}", issue));
786        }
787
788        lines.join("\n")
789    }
790
791    // -----------------------------------------------------------------------
792    // Edge crossing count (for layout quality metric)
793    // -----------------------------------------------------------------------
794
795    /// Count the number of edge crossings given a set of node positions.
796    /// Lower is better for readability.
797    pub fn count_crossings(&self, positions: &[(f64, f64)]) -> usize {
798        let mut edges: Vec<((f64, f64), (f64, f64))> = Vec::new();
799        for node in &self.nodes {
800            for &dep in &node.depends_on {
801                edges.push((positions[dep], positions[node.id]));
802            }
803        }
804
805        let mut crossings = 0;
806        for i in 0..edges.len() {
807            for j in (i + 1)..edges.len() {
808                if segments_intersect(edges[i].0, edges[i].1, edges[j].0, edges[j].1) {
809                    crossings += 1;
810                }
811            }
812        }
813        crossings
814    }
815
816    // -----------------------------------------------------------------------
817    // Dependency chain extraction
818    // -----------------------------------------------------------------------
819
820    /// Find all paths from `source` to `target`. Returns paths sorted by length.
821    /// Limits to `max_paths` to prevent combinatorial explosion.
822    pub fn find_paths(&self, source: usize, target: usize, max_paths: usize) -> Vec<Vec<usize>> {
823        let mut result = Vec::new();
824        let mut path = vec![source];
825        self.dfs_paths(source, target, &mut path, &mut result, max_paths);
826        result.sort_by_key(|p| p.len());
827        result
828    }
829
830    fn dfs_paths(
831        &self,
832        current: usize,
833        target: usize,
834        path: &mut Vec<usize>,
835        result: &mut Vec<Vec<usize>>,
836        max_paths: usize,
837    ) {
838        if result.len() >= max_paths {
839            return;
840        }
841        if current == target {
842            result.push(path.clone());
843            return;
844        }
845        for &next in &self.forward[current] {
846            if !path.contains(&next) {
847                path.push(next);
848                self.dfs_paths(next, target, path, result, max_paths);
849                path.pop();
850            }
851        }
852    }
853}
854
855// ---------------------------------------------------------------------------
856// Result types
857// ---------------------------------------------------------------------------
858
859pub struct ImpactResult {
860    pub source: usize,
861    pub cascade_count: usize,
862    pub affected_nodes: Vec<usize>,
863    /// Counts by type: [data_source, parameter, analysis, report, task]
864    pub by_type: [usize; 5],
865}
866
867// ---------------------------------------------------------------------------
868// Helpers
869// ---------------------------------------------------------------------------
870
871/// Build a map from node_id → position_index within a layer.
872fn position_map(layer: &[usize]) -> Vec<(usize, usize)> {
873    layer
874        .iter()
875        .enumerate()
876        .map(|(pos, &id)| (id, pos))
877        .collect()
878}
879
880/// Barycenter: average position of neighbors in the given position map.
881fn barycenter(neighbors: &[usize], positions: &[(usize, usize)]) -> f64 {
882    if neighbors.is_empty() {
883        return f64::MAX;
884    }
885    let mut sum = 0.0;
886    let mut count = 0;
887    for &n in neighbors {
888        if let Some(&(_, pos)) = positions.iter().find(|&&(id, _)| id == n) {
889            sum += pos as f64;
890            count += 1;
891        }
892    }
893    if count == 0 {
894        f64::MAX
895    } else {
896        sum / count as f64
897    }
898}
899
900/// Check if two line segments intersect (for crossing count).
901fn segments_intersect(a1: (f64, f64), a2: (f64, f64), b1: (f64, f64), b2: (f64, f64)) -> bool {
902    // Shared endpoints don't count as crossings
903    if a1 == b1 || a1 == b2 || a2 == b1 || a2 == b2 {
904        return false;
905    }
906
907    let d1 = cross_product(b1, b2, a1);
908    let d2 = cross_product(b1, b2, a2);
909    let d3 = cross_product(a1, a2, b1);
910    let d4 = cross_product(a1, a2, b2);
911
912    if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0))
913        && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))
914    {
915        return true;
916    }
917
918    false
919}
920
921fn cross_product(o: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
922    (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0)
923}
924
925// ---------------------------------------------------------------------------
926// Tests
927// ---------------------------------------------------------------------------
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932
933    fn make_node(id: usize, node_type: NodeType, depends_on: Vec<usize>) -> DagNode {
934        DagNode {
935            id,
936            node_type,
937            status: NodeStatus::Valid,
938            depends_on,
939            tags: Vec::new(),
940        }
941    }
942
943    fn make_linear_dag() -> Dag {
944        // 0 → 1 → 2 → 3
945        Dag::new(vec![
946            make_node(0, NodeType::DataSource, vec![]),
947            make_node(1, NodeType::Parameter, vec![0]),
948            make_node(2, NodeType::Analysis, vec![1]),
949            make_node(3, NodeType::Report, vec![2]),
950        ])
951    }
952
953    fn make_diamond_dag() -> Dag {
954        //     0
955        //    / \
956        //   1   2
957        //    \ /
958        //     3
959        Dag::new(vec![
960            make_node(0, NodeType::DataSource, vec![]),
961            make_node(1, NodeType::Analysis, vec![0]),
962            make_node(2, NodeType::Analysis, vec![0]),
963            make_node(3, NodeType::Report, vec![1, 2]),
964        ])
965    }
966
967    fn make_complex_dag() -> Dag {
968        // Mirrors a simplified version of the real project DAG:
969        //   src0  src1  param0
970        //    |  \ / |     |
971        //   a0   a1      a2
972        //    |   / \     /
973        //   r0   r1----/
974        Dag::new(vec![
975            make_node(0, NodeType::DataSource, vec![]),   // src0
976            make_node(1, NodeType::DataSource, vec![]),   // src1
977            make_node(2, NodeType::Parameter, vec![]),    // param0
978            make_node(3, NodeType::Analysis, vec![0]),    // a0
979            make_node(4, NodeType::Analysis, vec![0, 1]), // a1
980            make_node(5, NodeType::Analysis, vec![2]),    // a2
981            make_node(6, NodeType::Report, vec![3, 4]),   // r0
982            make_node(7, NodeType::Report, vec![4, 5]),   // r1
983        ])
984    }
985
986    // -- Construction --
987
988    #[test]
989    fn test_new_empty() {
990        let dag = Dag::new(vec![]);
991        assert_eq!(dag.node_count(), 0);
992        assert_eq!(dag.edge_count(), 0);
993    }
994
995    #[test]
996    fn test_new_counts() {
997        let dag = make_linear_dag();
998        assert_eq!(dag.node_count(), 4);
999        assert_eq!(dag.edge_count(), 3);
1000    }
1001
1002    #[test]
1003    fn test_forward_index() {
1004        let dag = make_diamond_dag();
1005        assert_eq!(dag.downstream(0), &[1, 2]);
1006        assert!(dag.downstream(3).is_empty());
1007    }
1008
1009    // -- Upstream / Downstream --
1010
1011    #[test]
1012    fn test_all_upstream_leaf() {
1013        let dag = make_linear_dag();
1014        let mut up = dag.all_upstream(3);
1015        up.sort();
1016        assert_eq!(up, vec![0, 1, 2]);
1017    }
1018
1019    #[test]
1020    fn test_all_upstream_root() {
1021        let dag = make_linear_dag();
1022        assert!(dag.all_upstream(0).is_empty());
1023    }
1024
1025    #[test]
1026    fn test_all_downstream_root() {
1027        let dag = make_linear_dag();
1028        let mut down = dag.all_downstream(0);
1029        down.sort();
1030        assert_eq!(down, vec![1, 2, 3]);
1031    }
1032
1033    #[test]
1034    fn test_all_downstream_leaf() {
1035        let dag = make_linear_dag();
1036        assert!(dag.all_downstream(3).is_empty());
1037    }
1038
1039    #[test]
1040    fn test_diamond_upstream() {
1041        let dag = make_diamond_dag();
1042        let mut up = dag.all_upstream(3);
1043        up.sort();
1044        assert_eq!(up, vec![0, 1, 2]);
1045    }
1046
1047    #[test]
1048    fn test_diamond_downstream() {
1049        let dag = make_diamond_dag();
1050        let mut down = dag.all_downstream(0);
1051        down.sort();
1052        assert_eq!(down, vec![1, 2, 3]);
1053    }
1054
1055    // -- Topological sort --
1056
1057    #[test]
1058    fn test_topo_sort_linear() {
1059        let dag = make_linear_dag();
1060        let order = dag.topological_sort().unwrap();
1061        // Must respect: 0 before 1 before 2 before 3
1062        for i in 0..order.len() - 1 {
1063            assert!(
1064                order.iter().position(|&x| x == i).unwrap()
1065                    < order.iter().position(|&x| x == i + 1).unwrap()
1066            );
1067        }
1068    }
1069
1070    #[test]
1071    fn test_topo_sort_diamond() {
1072        let dag = make_diamond_dag();
1073        let order = dag.topological_sort().unwrap();
1074        assert_eq!(order[0], 0); // root first
1075        assert_eq!(*order.last().unwrap(), 3); // leaf last
1076    }
1077
1078    // -- Cycle detection --
1079
1080    #[test]
1081    fn test_no_cycle() {
1082        let dag = make_linear_dag();
1083        assert!(dag.detect_cycle().is_none());
1084    }
1085
1086    #[test]
1087    fn test_detect_cycle() {
1088        // Create a cycle: 0 → 1 → 2 → 0
1089        let dag = Dag::new(vec![
1090            make_node(0, NodeType::Analysis, vec![2]),
1091            make_node(1, NodeType::Analysis, vec![0]),
1092            make_node(2, NodeType::Analysis, vec![1]),
1093        ]);
1094        let cycle = dag.detect_cycle();
1095        assert!(cycle.is_some());
1096    }
1097
1098    // -- Depth computation --
1099
1100    #[test]
1101    fn test_depths_linear() {
1102        let dag = make_linear_dag();
1103        assert_eq!(dag.compute_depths(), vec![0, 1, 2, 3]);
1104    }
1105
1106    #[test]
1107    fn test_depths_diamond() {
1108        let dag = make_diamond_dag();
1109        let depths = dag.compute_depths();
1110        assert_eq!(depths[0], 0);
1111        assert_eq!(depths[1], 1);
1112        assert_eq!(depths[2], 1);
1113        assert_eq!(depths[3], 2);
1114    }
1115
1116    // -- Critical path --
1117
1118    #[test]
1119    fn test_critical_path_linear() {
1120        let dag = make_linear_dag();
1121        assert_eq!(dag.critical_path(), vec![0, 1, 2, 3]);
1122    }
1123
1124    #[test]
1125    fn test_critical_path_diamond() {
1126        let dag = make_diamond_dag();
1127        let path = dag.critical_path();
1128        assert_eq!(path.len(), 3); // 0 → 1or2 → 3
1129        assert_eq!(path[0], 0);
1130        assert_eq!(*path.last().unwrap(), 3);
1131    }
1132
1133    // -- Layout --
1134
1135    #[test]
1136    fn test_layout_linear() {
1137        let dag = make_linear_dag();
1138        let pos = dag.layout(1000.0, 600.0);
1139        assert_eq!(pos.len(), 4);
1140        // x should increase along the chain
1141        for i in 0..3 {
1142            assert!(pos[i].0 < pos[i + 1].0, "x should increase: {:?}", pos);
1143        }
1144    }
1145
1146    #[test]
1147    fn test_layout_diamond() {
1148        let dag = make_diamond_dag();
1149        let pos = dag.layout(1000.0, 600.0);
1150        // Root (0) leftmost, leaf (3) rightmost
1151        assert!(pos[0].0 < pos[3].0);
1152        // 1 and 2 at same x (same layer)
1153        assert!((pos[1].0 - pos[2].0).abs() < 1.0);
1154        // 1 and 2 at different y
1155        assert!((pos[1].1 - pos[2].1).abs() > 1.0);
1156    }
1157
1158    #[test]
1159    fn test_layout_empty() {
1160        let dag = Dag::new(vec![]);
1161        assert!(dag.layout(100.0, 100.0).is_empty());
1162    }
1163
1164    // -- Subgraph --
1165
1166    #[test]
1167    fn test_subgraph_single() {
1168        let dag = make_complex_dag();
1169        let sub = dag.subgraph(|n| n.id == 4, 0);
1170        assert_eq!(sub, vec![4]);
1171    }
1172
1173    #[test]
1174    fn test_subgraph_with_depth() {
1175        let dag = make_complex_dag();
1176        let mut sub = dag.subgraph(|n| n.id == 4, 1);
1177        sub.sort();
1178        // id=4 deps: [0, 1], downstream: [6, 7]
1179        assert!(sub.contains(&4));
1180        assert!(sub.contains(&0));
1181        assert!(sub.contains(&1));
1182        assert!(sub.contains(&6));
1183        assert!(sub.contains(&7));
1184    }
1185
1186    // -- Impact analysis --
1187
1188    #[test]
1189    fn test_impact_root() {
1190        let dag = make_linear_dag();
1191        let impact = dag.impact_analysis(0);
1192        assert_eq!(impact.cascade_count, 3);
1193        assert_eq!(impact.by_type[NodeType::Parameter.layer_order()], 1);
1194        assert_eq!(impact.by_type[NodeType::Analysis.layer_order()], 1);
1195        assert_eq!(impact.by_type[NodeType::Report.layer_order()], 1);
1196    }
1197
1198    #[test]
1199    fn test_impact_leaf() {
1200        let dag = make_linear_dag();
1201        let impact = dag.impact_analysis(3);
1202        assert_eq!(impact.cascade_count, 0);
1203    }
1204
1205    #[test]
1206    fn test_impact_complex_param() {
1207        let dag = make_complex_dag();
1208        let impact = dag.impact_analysis(2); // param0
1209                                             // param0 → a2 → r1
1210        assert_eq!(impact.cascade_count, 2);
1211    }
1212
1213    // -- Orphans --
1214
1215    #[test]
1216    fn test_orphans_none() {
1217        let dag = make_linear_dag();
1218        assert!(dag.orphans().is_empty());
1219    }
1220
1221    #[test]
1222    fn test_orphans_found() {
1223        let mut nodes = vec![
1224            make_node(0, NodeType::DataSource, vec![]),
1225            make_node(1, NodeType::Analysis, vec![0]),
1226            make_node(2, NodeType::Report, vec![]), // orphan
1227        ];
1228        nodes[2].depends_on = vec![];
1229        let dag = Dag::new(nodes);
1230        assert_eq!(dag.orphans(), vec![2]);
1231    }
1232
1233    // -- Path finding --
1234
1235    #[test]
1236    fn test_find_paths_linear() {
1237        let dag = make_linear_dag();
1238        let paths = dag.find_paths(0, 3, 10);
1239        assert_eq!(paths.len(), 1);
1240        assert_eq!(paths[0], vec![0, 1, 2, 3]);
1241    }
1242
1243    #[test]
1244    fn test_find_paths_diamond() {
1245        let dag = make_diamond_dag();
1246        let paths = dag.find_paths(0, 3, 10);
1247        assert_eq!(paths.len(), 2); // via 1 and via 2
1248    }
1249
1250    #[test]
1251    fn test_find_paths_no_path() {
1252        let dag = make_linear_dag();
1253        let paths = dag.find_paths(3, 0, 10); // reverse direction
1254        assert!(paths.is_empty());
1255    }
1256
1257    // -- Edge crossings --
1258
1259    #[test]
1260    fn test_crossing_count_no_crossings() {
1261        let dag = make_linear_dag();
1262        let pos = dag.layout(1000.0, 600.0);
1263        assert_eq!(dag.count_crossings(&pos), 0);
1264    }
1265
1266    // -- NodeType / NodeStatus --
1267
1268    #[test]
1269    fn test_node_type_roundtrip() {
1270        for t in &[
1271            NodeType::DataSource,
1272            NodeType::Parameter,
1273            NodeType::Analysis,
1274            NodeType::Report,
1275            NodeType::Task,
1276        ] {
1277            assert_eq!(NodeType::parse(t.as_str()), Some(*t));
1278        }
1279    }
1280
1281    #[test]
1282    fn test_node_status_roundtrip() {
1283        for s in &[
1284            NodeStatus::Valid,
1285            NodeStatus::Stale,
1286            NodeStatus::Pending,
1287            NodeStatus::Active,
1288            NodeStatus::Blocked,
1289        ] {
1290            assert_eq!(NodeStatus::parse(s.as_str()), Some(*s));
1291        }
1292    }
1293
1294    // -- Validation --
1295
1296    #[test]
1297    fn test_validate_clean_dag() {
1298        let dag = make_linear_dag();
1299        let issues = dag.validate();
1300        assert!(issues.is_empty(), "linear DAG should have no issues");
1301    }
1302
1303    #[test]
1304    fn test_validate_orphan_warning() {
1305        let dag = Dag::new(vec![
1306            make_node(0, NodeType::DataSource, vec![]),
1307            make_node(1, NodeType::Analysis, vec![0]),
1308            make_node(2, NodeType::Report, vec![]),
1309        ]);
1310        let issues = dag.validate();
1311        assert!(issues.iter().any(|i| i.contains("Orphan")));
1312    }
1313
1314    #[test]
1315    fn test_validate_data_source_with_deps() {
1316        let dag = Dag::new(vec![
1317            make_node(0, NodeType::Analysis, vec![]),
1318            make_node(1, NodeType::DataSource, vec![0]),
1319        ]);
1320        let issues = dag.validate();
1321        assert!(issues
1322            .iter()
1323            .any(|i| i.contains("Data source") && i.contains("dependencies")));
1324    }
1325
1326    #[test]
1327    fn test_validate_param_depends_on_report() {
1328        let dag = Dag::new(vec![
1329            make_node(0, NodeType::Report, vec![]),
1330            make_node(1, NodeType::Parameter, vec![0]),
1331        ]);
1332        let issues = dag.validate();
1333        assert!(issues
1334            .iter()
1335            .any(|i| i.contains("Parameter") && i.contains("report")));
1336    }
1337
1338    // -- Stale nodes --
1339
1340    #[test]
1341    fn test_stale_nodes_empty() {
1342        let dag = make_linear_dag();
1343        assert!(dag.stale_nodes().is_empty());
1344    }
1345
1346    #[test]
1347    fn test_stale_nodes_sorted_by_depth() {
1348        let dag = Dag::new(vec![
1349            make_node_with_status(0, NodeType::DataSource, vec![], NodeStatus::Stale),
1350            make_node_with_status(1, NodeType::Parameter, vec![0], NodeStatus::Valid),
1351            make_node_with_status(2, NodeType::Analysis, vec![1], NodeStatus::Stale),
1352            make_node_with_status(3, NodeType::Report, vec![2], NodeStatus::Stale),
1353        ]);
1354        let stale = dag.stale_nodes();
1355        assert_eq!(stale, vec![0, 2, 3], "shallowest stale node first");
1356    }
1357
1358    // -- Task planning --
1359
1360    fn make_task_dag() -> Dag {
1361        //   data0 → analysis0 → task0 (pending)
1362        //   data1 → analysis1 → task1 (pending)
1363        //                      task2 (pending, depends on task0 and task1)
1364        Dag::new(vec![
1365            make_node_with_status(0, NodeType::DataSource, vec![], NodeStatus::Valid),
1366            make_node_with_status(1, NodeType::DataSource, vec![], NodeStatus::Valid),
1367            make_node_with_status(2, NodeType::Analysis, vec![0], NodeStatus::Valid),
1368            make_node_with_status(3, NodeType::Analysis, vec![1], NodeStatus::Valid),
1369            make_node_with_status(4, NodeType::Task, vec![2], NodeStatus::Pending), // task0
1370            make_node_with_status(5, NodeType::Task, vec![3], NodeStatus::Pending), // task1
1371            make_node_with_status(6, NodeType::Task, vec![4, 5], NodeStatus::Pending), // task2
1372        ])
1373    }
1374
1375    fn make_node_with_status(
1376        id: usize,
1377        node_type: NodeType,
1378        depends_on: Vec<usize>,
1379        status: NodeStatus,
1380    ) -> DagNode {
1381        DagNode {
1382            id,
1383            node_type,
1384            status,
1385            depends_on,
1386            tags: Vec::new(),
1387        }
1388    }
1389
1390    #[test]
1391    fn test_plannable_tasks() {
1392        let dag = make_task_dag();
1393        let plannable = dag.plannable_tasks();
1394        // task0 (4) and task1 (5) have all deps valid; task2 (6) is blocked
1395        assert_eq!(plannable, vec![4, 5]);
1396    }
1397
1398    #[test]
1399    fn test_blocked_tasks() {
1400        let dag = make_task_dag();
1401        let blocked = dag.blocked_tasks();
1402        // task2 (6) depends on pending tasks
1403        assert_eq!(blocked, vec![6]);
1404    }
1405
1406    #[test]
1407    fn test_active_tasks() {
1408        let dag = Dag::new(vec![
1409            make_node_with_status(0, NodeType::DataSource, vec![], NodeStatus::Valid),
1410            make_node_with_status(1, NodeType::Task, vec![0], NodeStatus::Active),
1411            make_node_with_status(2, NodeType::Task, vec![0], NodeStatus::Pending),
1412        ]);
1413        assert_eq!(dag.active_tasks(), vec![1]);
1414    }
1415
1416    #[test]
1417    fn test_parallel_groups() {
1418        let dag = make_task_dag();
1419        let groups = dag.parallel_groups();
1420        // task0 and task1 have no mutual deps → in same group
1421        assert_eq!(groups.len(), 1);
1422        let mut group = groups[0].clone();
1423        group.sort();
1424        assert_eq!(group, vec![4, 5]);
1425    }
1426
1427    #[test]
1428    fn test_parallel_groups_serial() {
1429        // task1 depends on task0 → must be in separate groups
1430        let dag = Dag::new(vec![
1431            make_node_with_status(0, NodeType::DataSource, vec![], NodeStatus::Valid),
1432            make_node_with_status(1, NodeType::Task, vec![0], NodeStatus::Pending), // task0
1433            make_node_with_status(2, NodeType::Task, vec![0, 1], NodeStatus::Pending), // task1 depends on task0
1434        ]);
1435        let plannable = dag.plannable_tasks();
1436        // Only task0 (1) is plannable; task1 (2) is blocked
1437        assert_eq!(plannable, vec![1]);
1438    }
1439
1440    // -- Summarize --
1441
1442    #[test]
1443    fn test_summarize_basic() {
1444        let dag = make_linear_dag();
1445        let summary = dag.summarize();
1446        assert!(summary.contains("DAG: 4 nodes, 3 edges"));
1447        assert!(summary.contains("data_source=1"));
1448        assert!(summary.contains("valid=4"));
1449    }
1450
1451    #[test]
1452    fn test_summarize_with_warnings() {
1453        let dag = Dag::new(vec![
1454            make_node(0, NodeType::DataSource, vec![]),
1455            make_node(1, NodeType::Report, vec![]),
1456        ]);
1457        let summary = dag.summarize();
1458        assert!(summary.contains("Warnings:"));
1459    }
1460
1461    #[test]
1462    fn test_find_paths_complex_multiple() {
1463        // In complex DAG: src0(0)→a0(3)→r0(6) and src0(0)→a1(4)→r0(6)
1464        let dag = make_complex_dag();
1465        let mut paths = dag.find_paths(0, 6, 10);
1466        paths.sort_by_key(|p| p.len());
1467        assert_eq!(paths.len(), 2, "two paths from src0 to r0");
1468        assert_eq!(paths[0].len(), 3, "shortest path has 3 nodes");
1469        // Both paths should start at 0 and end at 6
1470        for p in &paths {
1471            assert_eq!(*p.first().unwrap(), 0);
1472            assert_eq!(*p.last().unwrap(), 6);
1473        }
1474    }
1475
1476    #[test]
1477    fn test_impact_complex_data_source() {
1478        // Changing src0 affects a0, a1, r0, r1 (4 downstream nodes)
1479        // Changing src1 affects only a1, r0, r1 (3 downstream)
1480        let dag = make_complex_dag();
1481        let impact0 = dag.impact_analysis(0);
1482        assert_eq!(impact0.source, 0);
1483        assert_eq!(
1484            impact0.cascade_count, 4,
1485            "src0 affects 4 nodes: a0, a1, r0, r1"
1486        );
1487
1488        let impact1 = dag.impact_analysis(1);
1489        assert_eq!(impact1.cascade_count, 3, "src1 affects 3 nodes: a1, r0, r1");
1490
1491        // param0(2) affects only a2, r1
1492        let impact2 = dag.impact_analysis(2);
1493        assert_eq!(impact2.cascade_count, 2, "param0 affects 2 nodes: a2, r1");
1494    }
1495
1496    #[test]
1497    fn test_layout_complex_layered() {
1498        // Complex DAG should produce a valid layout with layers matching node types
1499        let dag = make_complex_dag();
1500        let layout = dag.layout(800.0, 600.0);
1501        assert_eq!(layout.len(), 8, "all 8 nodes should have positions");
1502        // Data sources (0, 1) and param (2) should be in earlier layers (lower x)
1503        // Reports (6, 7) should be in later layers (higher x)
1504        let x_src0 = layout[0].0;
1505        let x_r0 = layout[6].0;
1506        assert!(
1507            x_src0 < x_r0,
1508            "data source should be left of report: x_src={}, x_report={}",
1509            x_src0,
1510            x_r0
1511        );
1512    }
1513}