Skip to main content

cpm_planner/
task.rs

1//! CPM Task data structures
2//!
3//! Defines the core types for Critical Path Management:
4//! - [`Task`]: A unit of work to be scheduled
5//! - [`TaskKind`]: Domain-neutral classification of work
6//! - [`TaskBatch`]: A group of tasks that can run in parallel
7//! - [`Bottleneck`]: A task that blocks significant downstream work
8//! - [`CriticalPathResult`]: The full CPM analysis output
9//!
10//! These types are the *algorithm's internal* data model. The wire model
11//! that crosses the [`crate::ports::Planner`] boundary lives in
12//! `crate::plan`. PA3 will provide the bridge.
13
14use serde::{Deserialize, Serialize};
15
16/// Kind of task that can be planned.
17///
18/// Variants are deliberately domain-neutral. Specialised variants for any
19/// given problem domain belong outside this crate.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type", rename_all = "snake_case")]
22pub enum TaskKind {
23    /// Break a circular dependency cycle in the task graph.
24    ///
25    /// This is a generic graph operation, not coupled to any particular
26    /// problem domain.
27    BreakCycle {
28        /// Module identifiers (or generally, node ids) forming the cycle.
29        cycle: Vec<String>,
30    },
31    /// Implement a spec requirement.
32    ImplementSpec {
33        /// Opaque identifier of the specification to implement.
34        spec_id: String,
35    },
36    /// Custom user-defined task.
37    Custom {
38        /// Free-form description of the work.
39        description: String,
40    },
41}
42
43impl std::fmt::Display for TaskKind {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::BreakCycle { .. } => write!(f, "CYCLE"),
47            Self::ImplementSpec { spec_id, .. } => write!(f, "SPEC-{spec_id}"),
48            Self::Custom { .. } => write!(f, "CUSTOM"),
49        }
50    }
51}
52
53/// Status of a task in the execution plan.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum TaskStatus {
57    /// Not started, dependencies may not be met.
58    #[default]
59    Pending,
60    /// Dependencies met, can start.
61    Ready,
62    /// Currently being worked on.
63    InProgress,
64    /// Successfully completed.
65    Completed,
66    /// Has unmet dependencies.
67    Blocked,
68}
69
70impl std::fmt::Display for TaskStatus {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Pending => write!(f, "pending"),
74            Self::Ready => write!(f, "ready"),
75            Self::InProgress => write!(f, "in_progress"),
76            Self::Completed => write!(f, "completed"),
77            Self::Blocked => write!(f, "blocked"),
78        }
79    }
80}
81
82/// A single task in the CPM plan.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct Task {
85    /// Unique identifier.
86    pub id: String,
87    /// Human-readable name.
88    pub name: String,
89    /// Type of task.
90    pub kind: TaskKind,
91    /// Scheduled length in hours. For tasks built from a deliverable this is
92    /// its calendar `duration_hours` when set, else its effort estimate;
93    /// earned value reads effort from the graph, not from here.
94    pub effort_hours: f32,
95    /// Task IDs this depends on (must complete before this can start).
96    pub dependencies: Vec<String>,
97    /// Minimum wait in hours between a dependency's finish and this task's
98    /// start, keyed by dependency id. Absent means 0.
99    #[serde(default)]
100    pub lag_by_dependency: std::collections::HashMap<String, f32>,
101    /// Current status.
102    pub status: TaskStatus,
103    /// Earliest start time (calculated by forward pass).
104    pub earliest_start: f32,
105    /// Earliest finish time (ES + effort).
106    pub earliest_finish: f32,
107    /// Latest start time (calculated by backward pass).
108    pub latest_start: f32,
109    /// Latest finish time.
110    pub latest_finish: f32,
111    /// Float/slack time (LS - ES).
112    pub float: f32,
113    /// Is this task on the critical path?
114    pub is_critical: bool,
115    /// Files affected by this task (for context building, domain-neutral).
116    pub affected_files: Vec<String>,
117}
118
119impl Default for Task {
120    fn default() -> Self {
121        Self {
122            id: String::new(),
123            name: String::new(),
124            kind: TaskKind::Custom {
125                description: String::new(),
126            },
127            effort_hours: 0.0,
128            dependencies: Vec::new(),
129            lag_by_dependency: std::collections::HashMap::new(),
130            status: TaskStatus::Pending,
131            earliest_start: 0.0,
132            earliest_finish: 0.0,
133            latest_start: 0.0,
134            latest_finish: 0.0,
135            float: 0.0,
136            is_critical: false,
137            affected_files: Vec::new(),
138        }
139    }
140}
141
142impl Task {
143    /// Create a new task with the given ID, name, kind, and effort.
144    pub fn new(
145        id: impl Into<String>,
146        name: impl Into<String>,
147        kind: TaskKind,
148        effort_hours: f32,
149    ) -> Self {
150        Self {
151            id: id.into(),
152            name: name.into(),
153            kind,
154            effort_hours,
155            ..Default::default()
156        }
157    }
158
159    /// Add a dependency to this task.
160    #[must_use]
161    pub fn depends_on(mut self, task_id: impl Into<String>) -> Self {
162        self.dependencies.push(task_id.into());
163        self
164    }
165
166    /// Add an affected file.
167    #[must_use]
168    pub fn affects_file(mut self, file: impl Into<String>) -> Self {
169        self.affected_files.push(file.into());
170        self
171    }
172}
173
174/// A batch of tasks that can run in parallel.
175#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct TaskBatch {
177    /// Batch identifier (e.g., "Batch-1").
178    pub id: String,
179    /// Tasks in this batch (all have same ES).
180    pub tasks: Vec<String>,
181    /// Total effort if done sequentially.
182    pub total_effort_hours: f32,
183    /// Actual duration (max of task efforts in batch).
184    pub duration_hours: f32,
185    /// Earliest start time for this batch.
186    pub start_time: f32,
187}
188
189impl Default for TaskBatch {
190    fn default() -> Self {
191        Self {
192            id: String::new(),
193            tasks: Vec::new(),
194            total_effort_hours: 0.0,
195            duration_hours: 0.0,
196            start_time: 0.0,
197        }
198    }
199}
200
201/// A bottleneck task that blocks significant downstream work.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct Bottleneck {
204    /// The bottleneck task ID.
205    pub task_id: String,
206    /// Task name for display.
207    pub task_name: String,
208    /// Number of tasks blocked by this one (directly or transitively).
209    pub blocks_count: usize,
210    /// Total scheduled hours of work blocked. Uses the scheduled length (`duration_hours` when set, else effort); earned value reads effort from the graph.
211    pub blocked_hours: f32,
212    /// ROI: `blocked_hours / effort_hours` (higher = higher priority). Both
213    /// are scheduled lengths; earned value reads effort from the graph.
214    pub roi: f32,
215    /// Scheduled length of this task (`duration_hours` when set, else effort).
216    pub effort_hours: f32,
217}
218
219/// Critical Path Analysis Results.
220#[derive(Debug, Clone, Default, Serialize, Deserialize)]
221pub struct CriticalPathResult {
222    /// Total number of tasks in the plan.
223    pub total_tasks: usize,
224    /// One longest prerequisite chain, in execution order: each element is a
225    /// prerequisite of the next. Sink ties resolve to the later earliest start,
226    /// then the smallest id; predecessor ties resolve to the smallest id.
227    pub critical_path: Vec<String>,
228    /// Every zero-float (critical) task, sorted by earliest start then id.
229    /// Unlike `critical_path` this may contain several parallel chains.
230    #[serde(default)]
231    pub critical_ids: Vec<String>,
232    /// Project length: the maximum earliest finish (the scheduled lengths plus
233    /// edge lags along `critical_path`).
234    pub critical_path_duration: f32,
235    /// Total duration if done sequentially. Uses the scheduled length (`duration_hours` when set, else effort); earned value reads effort from the graph.
236    pub total_duration_sequential: f32,
237    /// Makespan with unlimited parallelism (maximum earliest finish). Uses the scheduled length (`duration_hours` when set, else effort); earned value reads effort from the graph.
238    pub optimal_duration_parallel: f32,
239    /// Speedup factor (sequential / parallel).
240    pub speedup_factor: f32,
241    /// Batches for parallel execution.
242    pub parallelizable_batches: Vec<TaskBatch>,
243    /// Bottleneck tasks (sorted by ROI descending).
244    pub bottlenecks: Vec<Bottleneck>,
245    /// All tasks with calculated times.
246    pub tasks: Vec<Task>,
247    /// Task ids the forward pass could not schedule. Non-empty iff the
248    /// dependency graph is cyclic or otherwise unschedulable; in that case
249    /// every other field is confidently-wrong and must not be trusted.
250    /// Callers that do not pre-validate (e.g. direct
251    /// [`CpmAlgorithm::calculate`][crate::algorithm::CpmAlgorithm::calculate]
252    /// users) MUST check this is empty before using the result.
253    pub unscheduled: Vec<String>,
254}
255
256impl CriticalPathResult {
257    /// Get a task by ID.
258    #[must_use]
259    pub fn get_task(&self, id: &str) -> Option<&Task> {
260        self.tasks.iter().find(|t| t.id == id)
261    }
262}
263
264#[cfg(test)]
265#[allow(clippy::float_cmp)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_task_creation() {
271        let task = Task::new(
272            "SPEC-001",
273            "Implement login flow",
274            TaskKind::ImplementSpec {
275                spec_id: "auth.login".to_string(),
276            },
277            2.0,
278        )
279        .depends_on("SPEC-000")
280        .affects_file("src/auth/login.rs")
281        .affects_file("src/auth/mod.rs");
282
283        assert_eq!(task.id, "SPEC-001");
284        assert_eq!(task.effort_hours, 2.0);
285        assert_eq!(task.dependencies, vec!["SPEC-000"]);
286        assert_eq!(
287            task.affected_files,
288            vec!["src/auth/login.rs", "src/auth/mod.rs"]
289        );
290    }
291
292    #[test]
293    fn test_task_kind_display() {
294        assert_eq!(
295            TaskKind::ImplementSpec {
296                spec_id: "auth.login".to_string()
297            }
298            .to_string(),
299            "SPEC-auth.login"
300        );
301
302        assert_eq!(
303            TaskKind::BreakCycle {
304                cycle: vec!["a".to_string(), "b".to_string()]
305            }
306            .to_string(),
307            "CYCLE"
308        );
309
310        assert_eq!(
311            TaskKind::Custom {
312                description: "anything".to_string()
313            }
314            .to_string(),
315            "CUSTOM"
316        );
317    }
318
319    #[test]
320    fn test_get_task() {
321        let result = CriticalPathResult {
322            total_tasks: 2,
323            tasks: vec![
324                Task {
325                    id: "T1".to_string(),
326                    effort_hours: 5.0,
327                    ..Default::default()
328                },
329                Task {
330                    id: "T2".to_string(),
331                    effort_hours: 3.0,
332                    ..Default::default()
333                },
334            ],
335            ..Default::default()
336        };
337
338        assert_eq!(result.get_task("T2").expect("T2 present").effort_hours, 3.0);
339        assert!(result.get_task("missing").is_none());
340    }
341}