Skip to main content

oximedia_workflow/
task_graph.rs

1// Copyright 2025 OxiMedia Contributors
2// Licensed under the Apache License, Version 2.0
3
4//! Task dependency graph for workflow execution planning.
5//!
6//! Models tasks as nodes and their dependencies as directed edges, and
7//! exposes helpers for finding root tasks, successors, predecessors, and
8//! the critical path through the graph.
9
10/// A single task node in the dependency graph.
11#[derive(Debug, Clone)]
12pub struct TaskNode {
13    /// Unique task identifier.
14    pub id: u64,
15    /// Human-readable task label.
16    pub name: String,
17    /// Estimated execution time in milliseconds.
18    pub estimated_ms: u64,
19    /// Symbolic resource requirements (e.g. `"gpu"`, `"network"`).
20    pub resource_requirements: Vec<String>,
21}
22
23impl TaskNode {
24    /// Create a new `TaskNode`.
25    #[must_use]
26    pub fn new(id: u64, name: impl Into<String>, estimated_ms: u64) -> Self {
27        Self {
28            id,
29            name: name.into(),
30            estimated_ms,
31            resource_requirements: Vec::new(),
32        }
33    }
34
35    /// Add a resource requirement.
36    #[must_use]
37    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
38        self.resource_requirements.push(resource.into());
39        self
40    }
41
42    /// Total number of resource requirements.
43    #[must_use]
44    pub fn total_resource_count(&self) -> usize {
45        self.resource_requirements.len()
46    }
47}
48
49/// Relationship type between two task nodes.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum EdgeType {
52    /// The successor may only start after the predecessor completes.
53    Sequential,
54    /// The successor may start at the same time as the predecessor.
55    Parallel,
56    /// The successor runs only if the predecessor succeeds.
57    ConditionalSuccess,
58    /// The successor runs only if the predecessor fails.
59    ConditionalFailure,
60}
61
62impl EdgeType {
63    /// Returns `true` for conditional edge types.
64    #[must_use]
65    pub const fn is_conditional(self) -> bool {
66        matches!(self, Self::ConditionalSuccess | Self::ConditionalFailure)
67    }
68}
69
70/// A directed edge from one [`TaskNode`] to another.
71#[derive(Debug, Clone)]
72pub struct TaskEdge {
73    /// Source node ID.
74    pub from_id: u64,
75    /// Destination node ID.
76    pub to_id: u64,
77    /// Relationship type.
78    pub edge_type: EdgeType,
79}
80
81impl TaskEdge {
82    /// Create a new `TaskEdge`.
83    #[must_use]
84    pub fn new(from_id: u64, to_id: u64, edge_type: EdgeType) -> Self {
85        Self {
86            from_id,
87            to_id,
88            edge_type,
89        }
90    }
91}
92
93/// A directed acyclic task dependency graph.
94#[derive(Debug, Clone, Default)]
95pub struct TaskGraph {
96    /// All registered task nodes.
97    pub nodes: Vec<TaskNode>,
98    /// All directed edges.
99    pub edges: Vec<TaskEdge>,
100}
101
102impl TaskGraph {
103    /// Create an empty `TaskGraph`.
104    #[must_use]
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    /// Add a task node.
110    pub fn add_node(&mut self, node: TaskNode) {
111        self.nodes.push(node);
112    }
113
114    /// Add a directed edge.
115    pub fn add_edge(&mut self, edge: TaskEdge) {
116        self.edges.push(edge);
117    }
118
119    /// Return the IDs of all nodes that are direct successors of `id`.
120    #[must_use]
121    pub fn successors(&self, id: u64) -> Vec<u64> {
122        self.edges
123            .iter()
124            .filter(|e| e.from_id == id)
125            .map(|e| e.to_id)
126            .collect()
127    }
128
129    /// Return the IDs of all nodes that are direct predecessors of `id`.
130    #[must_use]
131    pub fn predecessors(&self, id: u64) -> Vec<u64> {
132        self.edges
133            .iter()
134            .filter(|e| e.to_id == id)
135            .map(|e| e.from_id)
136            .collect()
137    }
138
139    /// Return IDs of nodes that have no incoming edges (roots of the DAG).
140    #[must_use]
141    pub fn root_nodes(&self) -> Vec<u64> {
142        self.nodes
143            .iter()
144            .filter(|n| self.predecessors(n.id).is_empty())
145            .map(|n| n.id)
146            .collect()
147    }
148
149    /// Compute the critical path length (ms) via dynamic programming.
150    ///
151    /// The critical path is the longest weighted path from any root node to
152    /// any leaf node, where each node contributes its `estimated_ms`.
153    ///
154    /// Returns 0 if the graph has no nodes.
155    #[must_use]
156    pub fn critical_path_ms(&self) -> u64 {
157        if self.nodes.is_empty() {
158            return 0;
159        }
160
161        // Build a map from id → estimated_ms for quick lookup.
162        let mut best: std::collections::HashMap<u64, u64> =
163            self.nodes.iter().map(|n| (n.id, 0u64)).collect();
164
165        // Topological sort (Kahn's algorithm).
166        let mut in_degree: std::collections::HashMap<u64, usize> =
167            self.nodes.iter().map(|n| (n.id, 0)).collect();
168
169        for e in &self.edges {
170            *in_degree.entry(e.to_id).or_insert(0) += 1;
171        }
172
173        let mut queue: std::collections::VecDeque<u64> = in_degree
174            .iter()
175            .filter(|(_, &deg)| deg == 0)
176            .map(|(&id, _)| id)
177            .collect();
178
179        // Initialise roots with their own cost.
180        for &id in &queue {
181            if let Some(node) = self.nodes.iter().find(|n| n.id == id) {
182                best.insert(id, node.estimated_ms);
183            }
184        }
185
186        while let Some(node_id) = queue.pop_front() {
187            let current_best = best[&node_id];
188            let node_cost = self
189                .nodes
190                .iter()
191                .find(|n| n.id == node_id)
192                .map_or(0, |n| n.estimated_ms);
193
194            for succ_id in self.successors(node_id) {
195                let candidate = current_best
196                    + node_cost.saturating_sub(
197                        // node cost already counted once in current_best
198                        // recompute: current_best is the path cost *up to and including* node_id
199                        // so for successor we add succ's own cost
200                        node_cost, // effectively: current_best (which includes node_id cost) + succ cost
201                    );
202                // Simpler: best[succ] = max(best[succ], best[node] + succ_cost)
203                let succ_cost = self
204                    .nodes
205                    .iter()
206                    .find(|n| n.id == succ_id)
207                    .map_or(0, |n| n.estimated_ms);
208
209                let new_val = current_best + succ_cost;
210                let entry = best.entry(succ_id).or_insert(0);
211                if new_val > *entry {
212                    *entry = new_val;
213                }
214                let _ = candidate; // suppress unused-variable lint
215
216                // Decrement in-degree; enqueue when it reaches 0.
217                if let Some(deg) = in_degree.get_mut(&succ_id) {
218                    if *deg > 0 {
219                        *deg -= 1;
220                    }
221                    if *deg == 0 {
222                        queue.push_back(succ_id);
223                    }
224                }
225            }
226        }
227
228        best.values().copied().max().unwrap_or(0)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    // --- TaskNode ---
237
238    #[test]
239    fn test_task_node_resource_count_empty() {
240        let n = TaskNode::new(1, "encode", 5000);
241        assert_eq!(n.total_resource_count(), 0);
242    }
243
244    #[test]
245    fn test_task_node_resource_count_with_resources() {
246        let n = TaskNode::new(1, "encode", 5000)
247            .with_resource("gpu")
248            .with_resource("network");
249        assert_eq!(n.total_resource_count(), 2);
250    }
251
252    // --- EdgeType ---
253
254    #[test]
255    fn test_edge_type_is_conditional_false() {
256        assert!(!EdgeType::Sequential.is_conditional());
257        assert!(!EdgeType::Parallel.is_conditional());
258    }
259
260    #[test]
261    fn test_edge_type_is_conditional_true() {
262        assert!(EdgeType::ConditionalSuccess.is_conditional());
263        assert!(EdgeType::ConditionalFailure.is_conditional());
264    }
265
266    // --- TaskGraph basic operations ---
267
268    #[test]
269    fn test_add_node_and_edge() {
270        let mut g = TaskGraph::new();
271        g.add_node(TaskNode::new(1, "a", 100));
272        g.add_node(TaskNode::new(2, "b", 200));
273        g.add_edge(TaskEdge::new(1, 2, EdgeType::Sequential));
274        assert_eq!(g.nodes.len(), 2);
275        assert_eq!(g.edges.len(), 1);
276    }
277
278    #[test]
279    fn test_successors() {
280        let mut g = TaskGraph::new();
281        g.add_node(TaskNode::new(1, "a", 100));
282        g.add_node(TaskNode::new(2, "b", 100));
283        g.add_node(TaskNode::new(3, "c", 100));
284        g.add_edge(TaskEdge::new(1, 2, EdgeType::Sequential));
285        g.add_edge(TaskEdge::new(1, 3, EdgeType::Parallel));
286        let mut succs = g.successors(1);
287        succs.sort_unstable();
288        assert_eq!(succs, vec![2, 3]);
289    }
290
291    #[test]
292    fn test_predecessors() {
293        let mut g = TaskGraph::new();
294        g.add_node(TaskNode::new(1, "a", 100));
295        g.add_node(TaskNode::new(2, "b", 100));
296        g.add_node(TaskNode::new(3, "c", 100));
297        g.add_edge(TaskEdge::new(1, 3, EdgeType::Sequential));
298        g.add_edge(TaskEdge::new(2, 3, EdgeType::Sequential));
299        let mut preds = g.predecessors(3);
300        preds.sort_unstable();
301        assert_eq!(preds, vec![1, 2]);
302    }
303
304    #[test]
305    fn test_root_nodes_single() {
306        let mut g = TaskGraph::new();
307        g.add_node(TaskNode::new(1, "root", 0));
308        g.add_node(TaskNode::new(2, "leaf", 0));
309        g.add_edge(TaskEdge::new(1, 2, EdgeType::Sequential));
310        assert_eq!(g.root_nodes(), vec![1]);
311    }
312
313    #[test]
314    fn test_root_nodes_multiple() {
315        let mut g = TaskGraph::new();
316        g.add_node(TaskNode::new(1, "a", 0));
317        g.add_node(TaskNode::new(2, "b", 0));
318        g.add_node(TaskNode::new(3, "c", 0));
319        g.add_edge(TaskEdge::new(1, 3, EdgeType::Sequential));
320        g.add_edge(TaskEdge::new(2, 3, EdgeType::Sequential));
321        let mut roots = g.root_nodes();
322        roots.sort_unstable();
323        assert_eq!(roots, vec![1, 2]);
324    }
325
326    #[test]
327    fn test_successors_empty() {
328        let g = TaskGraph::new();
329        assert!(g.successors(99).is_empty());
330    }
331
332    #[test]
333    fn test_predecessors_empty() {
334        let g = TaskGraph::new();
335        assert!(g.predecessors(99).is_empty());
336    }
337
338    // --- critical_path_ms ---
339
340    #[test]
341    fn test_critical_path_empty_graph() {
342        let g = TaskGraph::new();
343        assert_eq!(g.critical_path_ms(), 0);
344    }
345
346    #[test]
347    fn test_critical_path_single_node() {
348        let mut g = TaskGraph::new();
349        g.add_node(TaskNode::new(1, "only", 500));
350        assert_eq!(g.critical_path_ms(), 500);
351    }
352
353    #[test]
354    fn test_critical_path_linear_chain() {
355        // 1 → 2 → 3 with costs 100, 200, 300 → critical = 600
356        let mut g = TaskGraph::new();
357        g.add_node(TaskNode::new(1, "a", 100));
358        g.add_node(TaskNode::new(2, "b", 200));
359        g.add_node(TaskNode::new(3, "c", 300));
360        g.add_edge(TaskEdge::new(1, 2, EdgeType::Sequential));
361        g.add_edge(TaskEdge::new(2, 3, EdgeType::Sequential));
362        assert_eq!(g.critical_path_ms(), 600);
363    }
364
365    #[test]
366    fn test_critical_path_parallel_paths() {
367        // Root 1(100) → 2(200); Root 3(500) → 2.
368        // Critical path: 3(500) + 2(200) = 700
369        let mut g = TaskGraph::new();
370        g.add_node(TaskNode::new(1, "a", 100));
371        g.add_node(TaskNode::new(2, "b", 200));
372        g.add_node(TaskNode::new(3, "c", 500));
373        g.add_edge(TaskEdge::new(1, 2, EdgeType::Sequential));
374        g.add_edge(TaskEdge::new(3, 2, EdgeType::Sequential));
375        assert_eq!(g.critical_path_ms(), 700);
376    }
377}