1use crate::task::TaskKind;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct EstimationConfig {
31 #[serde(default = "default_cycle_base")]
33 pub cycle_base_hours: f32,
34
35 #[serde(default = "default_spec_base")]
37 pub spec_base_hours: f32,
38
39 #[serde(default = "default_custom_base")]
41 pub custom_base_hours: f32,
42
43 #[serde(default = "default_complexity_multiplier")]
46 pub complexity_multiplier: f32,
47
48 #[serde(default = "default_critical_multiplier")]
50 pub critical_multiplier: f32,
51
52 #[serde(default = "default_min_hours")]
54 pub min_hours: f32,
55
56 #[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
97pub struct EffortEstimator {
99 config: EstimationConfig,
100}
101
102impl EffortEstimator {
103 #[must_use]
105 pub fn new() -> Self {
106 Self {
107 config: EstimationConfig::default(),
108 }
109 }
110
111 #[must_use]
113 pub const fn with_config(config: EstimationConfig) -> Self {
114 Self { config }
115 }
116
117 fn clamp(&self, hours: f32) -> f32 {
119 hours.clamp(self.config.min_hours, self.config.max_hours)
120 }
121
122 #[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 #[must_use]
159 pub fn apply_critical_multiplier(&self, hours: f32) -> f32 {
160 self.clamp(hours * self.config.critical_multiplier)
161 }
162
163 #[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 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 assert_eq!(bumped, 3.0);
275 }
276}