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}