Skip to main content

cpm_planner/
estimator.rs

1//! Effort Estimation for CPM Tasks
2//!
3//! Provides domain-neutral effort estimation. Given a [`TaskKind`] and an
4//! optional complexity hint, returns a base estimate clamped to the
5//! configured min/max range.
6//!
7//! # Scope
8//!
9//! This is intentionally a *minimal* estimator. It maps a [`TaskKind`] to
10//! a base hour count, optionally applies a complexity multiplier, and
11//! clamps to a configured range. A richer estimator can layer on top of
12//! [`EffortEstimator::estimate`] without changing this contract.
13
14use crate::task::TaskKind;
15use serde::{Deserialize, Serialize};
16
17/// Effort estimation configuration.
18///
19/// Can be loaded from a config file under `[cpm.effort]`:
20/// ```toml
21/// [cpm.effort]
22/// cycle_base_hours = 4.0
23/// spec_base_hours = 8.0
24/// custom_base_hours = 4.0
25/// complexity_multiplier = 1.5
26/// min_hours = 0.5
27/// max_hours = 40.0
28/// ```
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct EstimationConfig {
31    /// Base hours for breaking a dependency cycle.
32    #[serde(default = "default_cycle_base")]
33    pub cycle_base_hours: f32,
34
35    /// Base hours for implementing a spec requirement.
36    #[serde(default = "default_spec_base")]
37    pub spec_base_hours: f32,
38
39    /// Base hours for a custom task.
40    #[serde(default = "default_custom_base")]
41    pub custom_base_hours: f32,
42
43    /// Multiplier for high-complexity tasks (applied when caller signals
44    /// the work is "complex"; see [`EffortEstimator::estimate`]).
45    #[serde(default = "default_complexity_multiplier")]
46    pub complexity_multiplier: f32,
47
48    /// Multiplier applied to tasks on the critical path.
49    #[serde(default = "default_critical_multiplier")]
50    pub critical_multiplier: f32,
51
52    /// Minimum effort for any task (floor).
53    #[serde(default = "default_min_hours")]
54    pub min_hours: f32,
55
56    /// Maximum effort for any task (ceiling).
57    #[serde(default = "default_max_hours")]
58    pub max_hours: f32,
59}
60
61const fn default_cycle_base() -> f32 {
62    4.0
63}
64const fn default_spec_base() -> f32 {
65    8.0
66}
67const fn default_custom_base() -> f32 {
68    4.0
69}
70const fn default_complexity_multiplier() -> f32 {
71    1.5
72}
73const fn default_critical_multiplier() -> f32 {
74    1.0
75}
76const fn default_min_hours() -> f32 {
77    0.5
78}
79const fn default_max_hours() -> f32 {
80    40.0
81}
82
83impl Default for EstimationConfig {
84    fn default() -> Self {
85        Self {
86            cycle_base_hours: default_cycle_base(),
87            spec_base_hours: default_spec_base(),
88            custom_base_hours: default_custom_base(),
89            complexity_multiplier: default_complexity_multiplier(),
90            critical_multiplier: default_critical_multiplier(),
91            min_hours: default_min_hours(),
92            max_hours: default_max_hours(),
93        }
94    }
95}
96
97/// Effort estimator using simple `TaskKind` heuristics.
98pub struct EffortEstimator {
99    config: EstimationConfig,
100}
101
102impl EffortEstimator {
103    /// Create a new estimator with default configuration.
104    #[must_use]
105    pub fn new() -> Self {
106        Self {
107            config: EstimationConfig::default(),
108        }
109    }
110
111    /// Create an estimator with custom configuration.
112    #[must_use]
113    pub const fn with_config(config: EstimationConfig) -> Self {
114        Self { config }
115    }
116
117    /// Clamp estimate to configured min/max.
118    fn clamp(&self, hours: f32) -> f32 {
119        hours.clamp(self.config.min_hours, self.config.max_hours)
120    }
121
122    /// Estimate effort for a task by its kind.
123    ///
124    /// `is_complex` is a coarse caller-supplied hint: when `true`, the
125    /// configured [`EstimationConfig::complexity_multiplier`] is applied
126    /// before clamping.
127    ///
128    /// For [`TaskKind::BreakCycle`], the cycle length further scales the
129    /// estimate: cycles of length > 3 add `0.25 * (len - 3)` to the base
130    /// multiplier; length <= 2 reduces it slightly.
131    #[must_use]
132    pub fn estimate(&self, kind: &TaskKind, is_complex: bool) -> f32 {
133        let mut hours = match kind {
134            TaskKind::BreakCycle { cycle } => {
135                let mut h = self.config.cycle_base_hours;
136                let cycle_len = cycle.len();
137                if cycle_len > 3 {
138                    h *= (cycle_len as f32 - 3.0).mul_add(0.25, 1.0);
139                } else if cycle_len <= 2 {
140                    h *= 0.8;
141                }
142                h
143            }
144            TaskKind::ImplementSpec { .. } => self.config.spec_base_hours,
145            TaskKind::Custom { .. } => self.config.custom_base_hours,
146        };
147
148        if is_complex {
149            hours *= self.config.complexity_multiplier;
150        }
151
152        self.clamp(hours)
153    }
154
155    /// Apply the critical-path multiplier to a previously computed estimate
156    /// and re-clamp. Convenience for callers that decorate already-estimated
157    /// tasks once they know which ones lie on the critical path.
158    #[must_use]
159    pub fn apply_critical_multiplier(&self, hours: f32) -> f32 {
160        self.clamp(hours * self.config.critical_multiplier)
161    }
162
163    /// Get the configuration.
164    #[must_use]
165    pub const fn config(&self) -> &EstimationConfig {
166        &self.config
167    }
168}
169
170impl Default for EffortEstimator {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176#[cfg(test)]
177#[allow(clippy::float_cmp)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn test_default_estimates_within_bounds() {
183        let estimator = EffortEstimator::new();
184        let h = estimator.estimate(
185            &TaskKind::Custom {
186                description: "x".into(),
187            },
188            false,
189        );
190        assert!(h >= estimator.config().min_hours);
191        assert!(h <= estimator.config().max_hours);
192    }
193
194    #[test]
195    fn test_complexity_multiplier() {
196        let estimator = EffortEstimator::new();
197        let simple = estimator.estimate(
198            &TaskKind::ImplementSpec {
199                spec_id: "s".into(),
200            },
201            false,
202        );
203        let complex = estimator.estimate(
204            &TaskKind::ImplementSpec {
205                spec_id: "s".into(),
206            },
207            true,
208        );
209        assert!(complex > simple);
210    }
211
212    #[test]
213    fn test_cycle_length_scales_estimate() {
214        let estimator = EffortEstimator::new();
215        let short = estimator.estimate(
216            &TaskKind::BreakCycle {
217                cycle: vec!["a".into(), "b".into()],
218            },
219            false,
220        );
221        let long = estimator.estimate(
222            &TaskKind::BreakCycle {
223                cycle: vec!["a".into(), "b".into(), "c".into(), "d".into(), "e".into()],
224            },
225            false,
226        );
227        assert!(long > short);
228    }
229
230    #[test]
231    fn test_custom_config_applies() {
232        let config = EstimationConfig {
233            custom_base_hours: 10.0,
234            ..Default::default()
235        };
236        let estimator = EffortEstimator::with_config(config);
237        let h = estimator.estimate(
238            &TaskKind::Custom {
239                description: "x".into(),
240            },
241            false,
242        );
243        // Default custom_base is 4.0; bumped to 10.0 here.
244        assert!(h >= 9.0);
245    }
246
247    #[test]
248    fn test_clamp_to_max() {
249        let config = EstimationConfig {
250            min_hours: 1.0,
251            max_hours: 5.0,
252            spec_base_hours: 100.0,
253            ..Default::default()
254        };
255        let estimator = EffortEstimator::with_config(config);
256        let h = estimator.estimate(
257            &TaskKind::ImplementSpec {
258                spec_id: "s".into(),
259            },
260            true,
261        );
262        assert!(h <= 5.0);
263    }
264
265    #[test]
266    fn test_apply_critical_multiplier_uses_config() {
267        let config = EstimationConfig {
268            critical_multiplier: 1.5,
269            ..Default::default()
270        };
271        let estimator = EffortEstimator::with_config(config);
272        let bumped = estimator.apply_critical_multiplier(2.0);
273        // 2.0 * 1.5 = 3.0, within default clamp range
274        assert_eq!(bumped, 3.0);
275    }
276}