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}