Skip to main content

bones_triage/graph/
critical_path.rs

1//! Critical path analysis for the dependency graph.
2//!
3//! # Overview
4//!
5//! The critical path is the *longest* dependency chain in the project.  Items
6//! on the critical path have **zero slack** — any delay on them delays the
7//! earliest possible completion of the entire project.
8//!
9//! # Definitions
10//!
11//! All durations are measured in "steps" where each item contributes 1 step.
12//!
13//! | Term              | Definition |
14//! |-------------------|------------|
15//! | `earliest_start`  | Earliest step at which an item can begin (all predecessors done). |
16//! | `earliest_finish` | `earliest_start + 1`. |
17//! | `latest_start`    | Latest step at which the item can begin without delaying the project. |
18//! | `latest_finish`   | `latest_start + 1`. |
19//! | `slack`           | `latest_start - earliest_start` — zero on the critical path. |
20//!
21//! # Algorithm
22//!
23//! 1. Work on the **condensed DAG** so cycles are handled (each SCC becomes
24//!    one super-node; its members are all reported as critical together).
25//! 2. **Forward pass** in topological order: compute `earliest_start` /
26//!    `earliest_finish` for every condensed node.
27//! 3. **Backward pass** in reverse topological order: compute `latest_finish`
28//!    / `latest_start`.
29//! 4. **Slack** = `latest_start − earliest_start`.  Nodes with slack = 0 are
30//!    on the critical path.
31//! 5. **Path reconstruction**: walk forward through zero-slack nodes choosing
32//!    the zero-slack successor at each step.
33//!
34//! The result exposes both *per-item* timings (accounting for SCC membership)
35//! and the reconstructed critical path as an ordered list of item IDs.
36
37#![allow(clippy::module_name_repetitions)]
38
39use std::collections::{HashMap, HashSet};
40
41use petgraph::{Direction, algo::toposort, graph::NodeIndex, visit::EdgeRef};
42
43use crate::graph::normalize::NormalizedGraph;
44
45// ---------------------------------------------------------------------------
46// Public types
47// ---------------------------------------------------------------------------
48
49/// Per-item timing computed during critical path analysis.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ItemTiming {
52    /// Earliest step at which the item can start (0-based).
53    pub earliest_start: usize,
54    /// Earliest step at which the item finishes (`earliest_start + 1`).
55    pub earliest_finish: usize,
56    /// Latest step at which the item can start without delaying the project.
57    pub latest_start: usize,
58    /// Latest step at which the item finishes (`latest_start + 1`).
59    pub latest_finish: usize,
60    /// Total float (slack): `latest_start - earliest_start`.
61    ///
62    /// Zero for items on the critical path.
63    pub slack: usize,
64}
65
66/// Result of critical path analysis on a dependency graph.
67#[derive(Debug, Clone)]
68pub struct CriticalPathResult {
69    /// Item IDs on the critical path, in dependency order (sources first).
70    ///
71    /// Empty when the graph has no items.
72    pub critical_path: Vec<String>,
73    /// All item IDs with zero slack.
74    ///
75    /// May include items *not* on the reconstructed `critical_path` when
76    /// there are multiple parallel critical paths of equal length.
77    pub critical_items: HashSet<String>,
78    /// Per-item timing information.
79    pub item_timings: HashMap<String, ItemTiming>,
80    /// Length of the critical path (number of items).
81    pub total_length: usize,
82}
83
84impl CriticalPathResult {
85    /// Return an empty result for a graph with no items.
86    #[must_use]
87    pub fn empty() -> Self {
88        Self {
89            critical_path: Vec::new(),
90            critical_items: HashSet::new(),
91            item_timings: HashMap::new(),
92            total_length: 0,
93        }
94    }
95
96    /// Return `true` if the critical path is empty (no items in the graph).
97    #[must_use]
98    pub const fn is_empty(&self) -> bool {
99        self.critical_path.is_empty()
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Core computation
105// ---------------------------------------------------------------------------
106
107/// Compute the critical path for the dependency graph described by `ng`.
108///
109/// Uses the **condensed DAG** so the computation is correct even when the
110/// raw graph contains dependency cycles.  Members of cycle SCCs are assigned
111/// the timing of their super-node and all have zero slack (they are mutually
112/// blocking and therefore always on the critical path of their SCC).
113///
114/// # Returns
115///
116/// A [`CriticalPathResult`] containing:
117/// - The reconstructed critical path (one representative path).
118/// - The set of all zero-slack items.
119/// - Per-item timing data.
120/// - The total project length in steps.
121#[must_use]
122pub fn compute_critical_path(ng: &NormalizedGraph) -> CriticalPathResult {
123    let condensed = &ng.condensed;
124
125    if condensed.node_count() == 0 {
126        return CriticalPathResult::empty();
127    }
128
129    // --- Topological sort of the condensed DAG ---
130    // The condensed graph is a DAG by construction; toposort will not fail.
131    let topo: Vec<NodeIndex> = toposort(condensed, None).unwrap_or_else(|_| {
132        // Defensive fallback (should never happen on a condensed DAG).
133        condensed.node_indices().collect()
134    });
135
136    // --- Forward pass: earliest_start / earliest_finish ---
137    let mut earliest_finish: HashMap<NodeIndex, usize> = HashMap::with_capacity(topo.len());
138
139    for &v in &topo {
140        let max_pred_finish = condensed
141            .edges_directed(v, Direction::Incoming)
142            .map(|e| earliest_finish.get(&e.source()).copied().unwrap_or(0))
143            .max()
144            .unwrap_or(0);
145        earliest_finish.insert(v, max_pred_finish + 1);
146    }
147
148    // Project duration = max earliest_finish over all nodes.
149    let project_finish = earliest_finish.values().copied().max().unwrap_or(1);
150
151    // --- Backward pass: latest_finish / latest_start ---
152    let mut latest_finish: HashMap<NodeIndex, usize> = HashMap::with_capacity(topo.len());
153
154    for &v in topo.iter().rev() {
155        let min_succ_start = condensed
156            .edges_directed(v, Direction::Outgoing)
157            .map(|e| {
158                let lf = latest_finish
159                    .get(&e.target())
160                    .copied()
161                    .unwrap_or(project_finish);
162                lf - 1 // latest_start of successor = latest_finish[succ] - 1
163            })
164            .min()
165            .unwrap_or(project_finish);
166        // latest_finish[v] = min_succ_start (the successor's latest_start)
167        latest_finish.insert(v, min_succ_start);
168    }
169
170    // --- Build per-item timings and critical item set ---
171    let mut item_timings: HashMap<String, ItemTiming> = HashMap::new();
172    let mut critical_items: HashSet<String> = HashSet::new();
173
174    // Map NodeIndex → slack for path reconstruction.
175    let mut node_slack: HashMap<NodeIndex, usize> = HashMap::with_capacity(topo.len());
176
177    for &v in &topo {
178        let ef = earliest_finish[&v];
179        let es = ef.saturating_sub(1);
180        let lf = latest_finish[&v];
181        let ls = lf.saturating_sub(1);
182        let slack = ls.saturating_sub(es);
183
184        node_slack.insert(v, slack);
185
186        let timing = ItemTiming {
187            earliest_start: es,
188            earliest_finish: ef,
189            latest_start: ls,
190            latest_finish: lf,
191            slack,
192        };
193
194        // All members of this condensed node share the same timing.
195        if let Some(scc_node) = condensed.node_weight(v) {
196            for member in &scc_node.members {
197                item_timings.insert(member.clone(), timing.clone());
198                if slack == 0 {
199                    critical_items.insert(member.clone());
200                }
201            }
202        }
203    }
204
205    // --- Critical path reconstruction ---
206    // Find the source node(s) with zero slack and earliest_start == 0,
207    // then walk forward always choosing a zero-slack successor.
208    let critical_path = reconstruct_critical_path(condensed, &topo, &earliest_finish, &node_slack);
209
210    // Expand condensed path to item IDs (each SCC node → sorted members).
211    let critical_path_items: Vec<String> = critical_path
212        .iter()
213        .flat_map(|&idx| {
214            condensed
215                .node_weight(idx)
216                .map(|n| n.members.clone())
217                .unwrap_or_default()
218        })
219        .collect();
220
221    let total_length = critical_path_items.len();
222
223    CriticalPathResult {
224        critical_path: critical_path_items,
225        critical_items,
226        item_timings,
227        total_length,
228    }
229}
230
231// ---------------------------------------------------------------------------
232// Path reconstruction helper
233// ---------------------------------------------------------------------------
234
235/// Walk from a zero-slack source to a zero-slack sink along zero-slack edges.
236///
237/// Returns the sequence of condensed node indices that form one critical path.
238fn reconstruct_critical_path(
239    condensed: &petgraph::graph::DiGraph<crate::graph::normalize::SccNode, ()>,
240    topo: &[NodeIndex],
241    earliest_finish: &HashMap<NodeIndex, usize>,
242    node_slack: &HashMap<NodeIndex, usize>,
243) -> Vec<NodeIndex> {
244    // Find the zero-slack node with the greatest earliest_finish (the sink of
245    // the critical path).
246    let Some(&sink) = topo
247        .iter()
248        .filter(|&&v| node_slack.get(&v).copied().unwrap_or(1) == 0)
249        .max_by_key(|&&v| earliest_finish.get(&v).copied().unwrap_or(0))
250    else {
251        return Vec::new();
252    };
253
254    // Walk backwards from the sink: at each step pick the zero-slack
255    // predecessor with the largest earliest_finish (ties broken by ID sort
256    // via SccNode::representative for determinism).
257    let mut path: Vec<NodeIndex> = vec![sink];
258    let mut current = sink;
259
260    loop {
261        let prev = condensed
262            .edges_directed(current, Direction::Incoming)
263            .filter(|e| node_slack.get(&e.source()).copied().unwrap_or(1) == 0)
264            .max_by_key(|e| earliest_finish.get(&e.source()).copied().unwrap_or(0));
265
266        match prev {
267            Some(e) => {
268                current = e.source();
269                path.push(current);
270            }
271            None => break,
272        }
273    }
274
275    path.reverse();
276    path
277}
278
279// ---------------------------------------------------------------------------
280// Tests
281// ---------------------------------------------------------------------------
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::graph::build::RawGraph;
287    use crate::graph::normalize::NormalizedGraph;
288    use petgraph::graph::DiGraph;
289    use std::collections::HashMap;
290
291    // -----------------------------------------------------------------------
292    // Test helpers
293    // -----------------------------------------------------------------------
294
295    fn make_normalized(edges: &[(&str, &str)]) -> NormalizedGraph {
296        make_normalized_nodes(
297            &edges
298                .iter()
299                .flat_map(|(a, b)| [*a, *b])
300                .collect::<std::collections::BTreeSet<_>>()
301                .into_iter()
302                .collect::<Vec<_>>(),
303            edges,
304        )
305    }
306
307    fn make_normalized_nodes(nodes: &[&str], edges: &[(&str, &str)]) -> NormalizedGraph {
308        let mut graph = DiGraph::<String, ()>::new();
309        let mut node_map: HashMap<String, _> = HashMap::new();
310
311        for &id in nodes {
312            let idx = graph.add_node(id.to_string());
313            node_map.insert(id.to_string(), idx);
314        }
315
316        for (a, b) in edges {
317            let ia = node_map[*a];
318            let ib = node_map[*b];
319            graph.add_edge(ia, ib, ());
320        }
321
322        let raw = RawGraph {
323            graph,
324            node_map,
325            content_hash: "blake3:test".to_string(),
326        };
327
328        NormalizedGraph::from_raw(raw)
329    }
330
331    // -----------------------------------------------------------------------
332    // Empty / trivial graphs
333    // -----------------------------------------------------------------------
334
335    #[test]
336    fn empty_graph_returns_empty_result() {
337        let ng = make_normalized_nodes(&[], &[]);
338        let result = compute_critical_path(&ng);
339
340        assert!(result.is_empty());
341        assert!(result.critical_path.is_empty());
342        assert!(result.critical_items.is_empty());
343        assert_eq!(result.total_length, 0);
344    }
345
346    #[test]
347    fn single_node_is_critical() {
348        let ng = make_normalized_nodes(&["A"], &[]);
349        let result = compute_critical_path(&ng);
350
351        assert_eq!(result.total_length, 1);
352        assert!(result.critical_items.contains("A"));
353        assert_eq!(result.critical_path, vec!["A".to_string()]);
354
355        let timing = &result.item_timings["A"];
356        assert_eq!(timing.earliest_start, 0);
357        assert_eq!(timing.earliest_finish, 1);
358        assert_eq!(timing.slack, 0);
359    }
360
361    // -----------------------------------------------------------------------
362    // Linear chain
363    // -----------------------------------------------------------------------
364
365    #[test]
366    fn linear_chain_all_items_critical() {
367        // A → B → C (all on critical path, no parallel branches)
368        let ng = make_normalized(&[("A", "B"), ("B", "C")]);
369        let result = compute_critical_path(&ng);
370
371        assert_eq!(result.total_length, 3);
372        assert!(result.critical_items.contains("A"));
373        assert!(result.critical_items.contains("B"));
374        assert!(result.critical_items.contains("C"));
375
376        // All slack should be zero
377        for item in ["A", "B", "C"] {
378            assert_eq!(
379                result.item_timings[item].slack, 0,
380                "slack({item}) should be 0"
381            );
382        }
383
384        // Critical path should be in dependency order A→B→C
385        assert_eq!(
386            result.critical_path,
387            vec!["A".to_string(), "B".to_string(), "C".to_string()]
388        );
389    }
390
391    // -----------------------------------------------------------------------
392    // Diamond topology
393    // -----------------------------------------------------------------------
394
395    #[test]
396    fn diamond_top_and_bottom_are_critical() {
397        // Diamond: A → B → D, A → C → D
398        // A: es=0, B: es=1, C: es=1, D: es=2
399        // B and C have equal ES; both have slack 0 in a pure diamond... wait
400        // let me think: project length = 3 (A, B/C, D)
401        // ES[A]=0 EF[A]=1
402        // ES[B]=1 EF[B]=2
403        // ES[C]=1 EF[C]=2
404        // ES[D]=2 EF[D]=3
405        // LS[D]=2 LF[D]=3
406        // LS[B]: succ is D with LS=2, so LF[B]=2, LS[B]=1, slack=0
407        // LS[C]: succ is D with LS=2, so LF[C]=2, LS[C]=1, slack=0
408        // LS[A]: succs B,C with LS=1, so LF[A]=1, LS[A]=0, slack=0
409        // All have zero slack in a diamond with uniform weights!
410        let ng = make_normalized(&[("A", "B"), ("A", "C"), ("B", "D"), ("C", "D")]);
411        let result = compute_critical_path(&ng);
412
413        assert_eq!(result.total_length, 3, "one critical path A→B→D or A→C→D");
414        // A and D are always critical
415        assert!(result.critical_items.contains("A"), "A is critical");
416        assert!(result.critical_items.contains("D"), "D is critical");
417
418        // Timings
419        let ta = &result.item_timings["A"];
420        assert_eq!(ta.earliest_start, 0);
421        assert_eq!(ta.slack, 0);
422
423        let td = &result.item_timings["D"];
424        assert_eq!(td.earliest_start, 2);
425        assert_eq!(td.slack, 0);
426    }
427
428    // -----------------------------------------------------------------------
429    // Parallel branches (slack visible)
430    // -----------------------------------------------------------------------
431
432    #[test]
433    fn parallel_branches_shorter_branch_has_slack() {
434        // A → B → C → D (long branch, length 4)
435        // A → E → D      (short branch via E, length 3)
436        // E has slack 1 (can start 1 step later than earliest)
437        let ng = make_normalized(&[("A", "B"), ("B", "C"), ("C", "D"), ("A", "E"), ("E", "D")]);
438        let result = compute_critical_path(&ng);
439
440        // Project length should be 4 (A→B→C→D)
441        assert_eq!(result.total_length, 4, "critical path A→B→C→D");
442
443        // A, B, C, D have zero slack
444        for item in ["A", "B", "C", "D"] {
445            assert_eq!(
446                result.item_timings[item].slack, 0,
447                "{item} should have zero slack"
448            );
449        }
450
451        // E has slack 1
452        assert_eq!(
453            result.item_timings["E"].slack, 1,
454            "E on shorter branch should have slack 1"
455        );
456
457        // E should NOT be in critical_items
458        assert!(
459            !result.critical_items.contains("E"),
460            "E not on critical path"
461        );
462    }
463
464    // -----------------------------------------------------------------------
465    // Graph with cycle (condensed)
466    // -----------------------------------------------------------------------
467
468    #[test]
469    fn cycle_members_reported_as_critical() {
470        // A → B → A (cycle condensed to one super-node)
471        // The super-node {A,B} is the whole graph, so it's trivially critical.
472        let ng = make_normalized(&[("A", "B"), ("B", "A")]);
473        let result = compute_critical_path(&ng);
474
475        // Both members of the SCC should appear.
476        assert!(result.critical_items.contains("A"));
477        assert!(result.critical_items.contains("B"));
478        assert_eq!(result.total_length, 2, "SCC expands to both members");
479    }
480
481    // -----------------------------------------------------------------------
482    // Timing invariants
483    // -----------------------------------------------------------------------
484
485    #[test]
486    fn timing_invariants_hold_for_chain() {
487        // A → B → C
488        let ng = make_normalized(&[("A", "B"), ("B", "C")]);
489        let result = compute_critical_path(&ng);
490
491        for (id, t) in &result.item_timings {
492            assert_eq!(
493                t.earliest_finish,
494                t.earliest_start + 1,
495                "{id}: earliest_finish = earliest_start + 1"
496            );
497            assert_eq!(
498                t.latest_finish,
499                t.latest_start + 1,
500                "{id}: latest_finish = latest_start + 1"
501            );
502            assert_eq!(
503                t.slack,
504                t.latest_start.saturating_sub(t.earliest_start),
505                "{id}: slack = latest_start - earliest_start"
506            );
507        }
508    }
509
510    #[test]
511    fn timing_invariants_hold_for_parallel_branches() {
512        let ng = make_normalized(&[("A", "B"), ("B", "C"), ("C", "D"), ("A", "E"), ("E", "D")]);
513        let result = compute_critical_path(&ng);
514
515        for (id, t) in &result.item_timings {
516            assert_eq!(t.earliest_finish, t.earliest_start + 1, "{id}: ef = es + 1");
517            assert_eq!(t.latest_finish, t.latest_start + 1, "{id}: lf = ls + 1");
518            assert!(t.latest_start >= t.earliest_start, "{id}: ls >= es");
519        }
520    }
521
522    // -----------------------------------------------------------------------
523    // Critical path ordering
524    // -----------------------------------------------------------------------
525
526    #[test]
527    fn critical_path_is_in_dependency_order() {
528        // A → B → C → D
529        let ng = make_normalized(&[("A", "B"), ("B", "C"), ("C", "D")]);
530        let result = compute_critical_path(&ng);
531
532        let path = &result.critical_path;
533        assert_eq!(path.len(), 4);
534
535        // Verify ordering via timings
536        for window in path.windows(2) {
537            let (a, b) = (&window[0], &window[1]);
538            let ta = &result.item_timings[a];
539            let tb = &result.item_timings[b];
540            assert!(
541                ta.earliest_start < tb.earliest_start,
542                "{a} should come before {b}"
543            );
544        }
545    }
546
547    #[test]
548    fn disjoint_graphs_longest_path_selected() {
549        // Chain 1: A → B → C (length 3)
550        // Chain 2: X → Y     (length 2)
551        let ng = make_normalized(&[("A", "B"), ("B", "C"), ("X", "Y")]);
552        let result = compute_critical_path(&ng);
553
554        // The critical path should be the longer chain (3 items)
555        assert_eq!(result.total_length, 3, "longest chain selected");
556        assert!(result.critical_items.contains("A"));
557        assert!(result.critical_items.contains("C"));
558    }
559}