Skip to main content

cpm_planner/
risk.rs

1//! iDesign risk engine for CPM Planner.
2//!
3//! Provides activity-risk and criticality-risk calculations following the
4//! iDesign methodology, with const weights and a 0.4-0.75 band flag.
5//!
6//! # Float units
7//! All `float` parameters here are **token-unit integers** (i.e. the integer
8//! version of `Task::float` after rounding or as supplied by the caller).
9//! Using integers eliminates floating-point comparison edge cases in the
10//! `CriticalityBand` classifier.
11
12// ── Criticality weights ──────────────────────────────────────────────────────
13
14/// Weight for a Critical-band task.
15pub const W_CRITICAL: u64 = 4;
16/// Weight for a High-band task.
17pub const W_HIGH: u64 = 3;
18/// Weight for a Medium-band task.
19pub const W_MEDIUM: u64 = 2;
20/// Weight for a Low-band task.
21pub const W_LOW: u64 = 1;
22
23// ── CriticalityBand ──────────────────────────────────────────────────────────
24
25/// Band that a task's float value falls into relative to the project maximum.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum CriticalityBand {
28    /// Float == 0: on the critical path.
29    Critical,
30    /// Float in the lower third of the max range.
31    High,
32    /// Float in the middle third of the max range.
33    Medium,
34    /// Float in the upper third of the max range.
35    Low,
36}
37
38// ── ValidatedThresholdSet ────────────────────────────────────────────────────
39
40/// A strictly-ordered pair of thresholds (high_end < medium_end) used by
41/// [`classify`].
42///
43/// The smart constructor panics at construction time if the invariant is
44/// violated, so callers that hold a `ValidatedThresholdSet` are guaranteed
45/// strict ordering.
46#[derive(Debug, Clone, Copy)]
47pub struct ValidatedThresholdSet {
48    /// Upper bound (inclusive) of the **High** band (as a ratio in `[0,1)`).
49    high_end: f64,
50    /// Upper bound (inclusive) of the **Medium** band (as a ratio in `[0,1)`).
51    medium_end: f64,
52}
53
54impl ValidatedThresholdSet {
55    /// Create a new threshold set.
56    ///
57    /// # Panics
58    /// Panics if `high_end >= medium_end` or either value is outside `(0, 1)`.
59    #[must_use]
60    pub fn new(high_end: f64, medium_end: f64) -> Self {
61        assert!(
62            high_end > 0.0 && high_end < 1.0,
63            "high_end must be in (0, 1), got {high_end}"
64        );
65        assert!(
66            medium_end > 0.0 && medium_end < 1.0,
67            "medium_end must be in (0, 1), got {medium_end}"
68        );
69        assert!(
70            high_end < medium_end,
71            "high_end ({high_end}) must be strictly less than medium_end ({medium_end})"
72        );
73        Self {
74            high_end,
75            medium_end,
76        }
77    }
78
79    /// The default 1/3-2/3 thresholds described in the iDesign methodology.
80    #[must_use]
81    pub fn default_thirds() -> Self {
82        Self {
83            high_end: 1.0 / 3.0,
84            medium_end: 2.0 / 3.0,
85        }
86    }
87}
88
89// ── classify ─────────────────────────────────────────────────────────────────
90
91/// Classify a task's float value into a [`CriticalityBand`].
92///
93/// - `float == 0`          => [`CriticalityBand::Critical`]
94/// - `max_float == 0`      => every non-zero float would be impossible;
95///   caller should guard, but we return `Critical` for safety.
96/// - ratio `<= thresholds.high_end`   => [`CriticalityBand::High`]
97/// - ratio `<= thresholds.medium_end` => [`CriticalityBand::Medium`]
98/// - otherwise             => [`CriticalityBand::Low`]
99#[must_use]
100pub fn classify(float: u64, max_float: u64) -> CriticalityBand {
101    let thresholds = ValidatedThresholdSet::default_thirds();
102    classify_with(float, max_float, &thresholds)
103}
104
105/// Like [`classify`] but with an explicit threshold set.
106#[must_use]
107pub fn classify_with(
108    float: u64,
109    max_float: u64,
110    thresholds: &ValidatedThresholdSet,
111) -> CriticalityBand {
112    if float == 0 || max_float == 0 {
113        return CriticalityBand::Critical;
114    }
115    let ratio = float as f64 / max_float as f64;
116    if ratio <= thresholds.high_end {
117        CriticalityBand::High
118    } else if ratio <= thresholds.medium_end {
119        CriticalityBand::Medium
120    } else {
121        CriticalityBand::Low
122    }
123}
124
125// ── ActivityRiskResult ───────────────────────────────────────────────────────
126
127/// Result of [`activity_risk`].
128#[derive(Debug, Clone, PartialEq)]
129pub enum ActivityRiskResult {
130    /// Computed risk score in `[0, 1]`.
131    Risk(f64),
132    /// Returned when `max_float == 0` (all tasks are on the critical path).
133    /// No division is performed.
134    AllCritical,
135}
136
137// ── activity_risk ─────────────────────────────────────────────────────────────
138
139/// Compute the iDesign **activity risk** for a set of task float values.
140///
141/// Formula (when `max_float > 0`):
142/// ```text
143/// activity_risk = 1.0 - sum(floats) / (max_float * len(floats))
144/// ```
145///
146/// Returns [`ActivityRiskResult::AllCritical`] when `max_float == 0` to
147/// prevent division by zero.
148///
149/// # Edge cases
150/// - Empty slice with `max_float > 0`: `sum == 0`, `len == 0` would
151///   produce NaN (0/0). We treat an empty schedule as fully critical and
152///   return `AllCritical` regardless of `max_float`.
153#[must_use]
154pub fn activity_risk(floats: &[u64], max_float: u64) -> ActivityRiskResult {
155    if max_float == 0 || floats.is_empty() {
156        return ActivityRiskResult::AllCritical;
157    }
158    let sum: u64 = floats.iter().sum();
159    let denominator = max_float * floats.len() as u64;
160    ActivityRiskResult::Risk(1.0 - (sum as f64) / (denominator as f64))
161}
162
163// ── criticality_risk ──────────────────────────────────────────────────────────
164
165/// Compute the iDesign **criticality risk** from a set of [`CriticalityBand`]s.
166///
167/// Formula:
168/// ```text
169/// criticality_risk = sum(W_band_i) / (W_CRITICAL * len(bands))
170/// ```
171///
172/// Returns `1.0` for an empty slice (maximally critical; no information to
173/// suggest otherwise).
174#[must_use]
175pub fn criticality_risk(bands: &[CriticalityBand]) -> f64 {
176    if bands.is_empty() {
177        return 1.0;
178    }
179    let sum: u64 = bands
180        .iter()
181        .map(|b| match b {
182            CriticalityBand::Critical => W_CRITICAL,
183            CriticalityBand::High => W_HIGH,
184            CriticalityBand::Medium => W_MEDIUM,
185            CriticalityBand::Low => W_LOW,
186        })
187        .sum();
188    let max_possible = W_CRITICAL * bands.len() as u64;
189    sum as f64 / max_possible as f64
190}
191
192// ── RiskBandFlag ─────────────────────────────────────────────────────────────
193
194/// Categorical flag for a risk score.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum RiskBandFlag {
197    /// Score in `[0.4, 0.75]`: healthy range.
198    InTarget,
199    /// Score `> 0.75`: too many high-criticality tasks.
200    HighRisk,
201    /// Score `< 0.4`: schedule is over-decomposed / too much slack.
202    OverDecompressed,
203}
204
205// ── band_flag ─────────────────────────────────────────────────────────────────
206
207/// Map a risk score to a [`RiskBandFlag`].
208///
209/// | Range        | Flag               |
210/// |--------------|--------------------|
211/// | `< 0.4`      | `OverDecompressed` |
212/// | `0.4 - 0.75` | `InTarget`         |
213/// | `> 0.75`     | `HighRisk`         |
214#[must_use]
215pub fn band_flag(risk: f64) -> RiskBandFlag {
216    if risk < 0.4 {
217        RiskBandFlag::OverDecompressed
218    } else if risk > 0.75 {
219        RiskBandFlag::HighRisk
220    } else {
221        RiskBandFlag::InTarget
222    }
223}
224
225// ── Tests ─────────────────────────────────────────────────────────────────────
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    // ── Weight constants ──────────────────────────────────────────────────────
232
233    #[test]
234    fn test_weights_are_exactly_4_3_2_1() {
235        assert_eq!(W_CRITICAL, 4, "W_CRITICAL must be exactly 4");
236        assert_eq!(W_HIGH, 3, "W_HIGH must be exactly 3");
237        assert_eq!(W_MEDIUM, 2, "W_MEDIUM must be exactly 2");
238        assert_eq!(W_LOW, 1, "W_LOW must be exactly 1");
239    }
240
241    // ── classify ──────────────────────────────────────────────────────────────
242
243    #[test]
244    fn test_classify_zero_float_is_critical() {
245        assert_eq!(classify(0, 12), CriticalityBand::Critical);
246    }
247
248    #[test]
249    fn test_classify_high_band() {
250        // 3 / 12 = 0.25 <= 1/3
251        assert_eq!(classify(3, 12), CriticalityBand::High);
252        // 4 / 12 = 0.333... <= 1/3 (boundary inclusive)
253        assert_eq!(classify(4, 12), CriticalityBand::High);
254    }
255
256    #[test]
257    fn test_classify_medium_band() {
258        // 5 / 12 = 0.4166 -- just above 1/3
259        assert_eq!(classify(5, 12), CriticalityBand::Medium);
260        // 8 / 12 = 0.6666... <= 2/3 (boundary inclusive)
261        assert_eq!(classify(8, 12), CriticalityBand::Medium);
262    }
263
264    #[test]
265    fn test_classify_low_band() {
266        // 9 / 12 = 0.75 > 2/3
267        assert_eq!(classify(9, 12), CriticalityBand::Low);
268        // 12 / 12 = 1.0
269        assert_eq!(classify(12, 12), CriticalityBand::Low);
270    }
271
272    #[test]
273    fn test_classify_max_float_zero_returns_critical() {
274        assert_eq!(classify(5, 0), CriticalityBand::Critical);
275    }
276
277    // ── ValidatedThresholdSet ─────────────────────────────────────────────────
278
279    #[test]
280    #[should_panic]
281    fn test_threshold_inverted_panics() {
282        let _ = ValidatedThresholdSet::new(0.7, 0.3);
283    }
284
285    #[test]
286    #[should_panic]
287    fn test_threshold_equal_panics() {
288        let _ = ValidatedThresholdSet::new(0.5, 0.5);
289    }
290
291    // ── activity_risk ─────────────────────────────────────────────────────────
292
293    #[test]
294    fn test_activity_risk_all_critical_when_max_float_zero() {
295        let floats = [0u64, 0, 0];
296        assert_eq!(activity_risk(&floats, 0), ActivityRiskResult::AllCritical);
297    }
298
299    #[test]
300    fn test_activity_risk_all_zero_floats_with_positive_max() {
301        // sum = 0, denom = 12 * 3 = 36  => risk = 1 - 0/36 = 1.0
302        let floats = [0u64, 0, 0];
303        assert_eq!(activity_risk(&floats, 12), ActivityRiskResult::Risk(1.0));
304    }
305
306    #[test]
307    fn test_activity_risk_known_distribution() {
308        // floats: [0, 4, 8], max_float = 12
309        // sum = 12, denom = 12 * 3 = 36
310        // risk = 1 - 12/36 = 1 - 0.333... = 0.666...
311        let floats = [0u64, 4, 8];
312        let max_float = 12u64;
313        let expected = 1.0 - 12.0_f64 / 36.0_f64;
314        match activity_risk(&floats, max_float) {
315            ActivityRiskResult::Risk(r) => {
316                assert!((r - expected).abs() < 1e-10, "expected {expected}, got {r}");
317            }
318            ActivityRiskResult::AllCritical => panic!("expected Risk, got AllCritical"),
319        }
320    }
321
322    #[test]
323    fn test_activity_risk_empty_returns_all_critical() {
324        assert_eq!(activity_risk(&[], 10), ActivityRiskResult::AllCritical);
325    }
326
327    // ── criticality_risk ──────────────────────────────────────────────────────
328
329    #[test]
330    fn test_criticality_risk_all_critical_is_one() {
331        // All tasks on critical path => all bands == Critical
332        // sum = 4*3 = 12, max = 4*3 = 12  => 1.0
333        let bands = [
334            CriticalityBand::Critical,
335            CriticalityBand::Critical,
336            CriticalityBand::Critical,
337        ];
338        let r = criticality_risk(&bands);
339        assert!(
340            (r - 1.0).abs() < 1e-10,
341            "all-critical criticality_risk must be 1.0, got {r}"
342        );
343    }
344
345    #[test]
346    fn test_criticality_risk_empty_is_one() {
347        let r = criticality_risk(&[]);
348        assert!(
349            (r - 1.0).abs() < 1e-10,
350            "empty criticality_risk must be 1.0, got {r}"
351        );
352    }
353
354    #[test]
355    fn test_criticality_risk_known_distribution() {
356        // bands: [Critical(4), High(3), Medium(2), Low(1)]
357        // sum = 10, max = 4*4 = 16  => 10/16 = 0.625
358        let bands = [
359            CriticalityBand::Critical,
360            CriticalityBand::High,
361            CriticalityBand::Medium,
362            CriticalityBand::Low,
363        ];
364        let expected = 10.0_f64 / 16.0_f64;
365        let r = criticality_risk(&bands);
366        assert!((r - expected).abs() < 1e-10, "expected {expected}, got {r}");
367    }
368
369    #[test]
370    fn test_criticality_risk_all_low_is_one_quarter() {
371        // sum = 1*4 = 4, max = 4*4 = 16  => 4/16 = 0.25
372        let bands = [CriticalityBand::Low; 4];
373        let r = criticality_risk(&bands);
374        assert!(
375            (r - 0.25).abs() < 1e-10,
376            "all-low criticality_risk must be 0.25, got {r}"
377        );
378    }
379
380    // ── band_flag ─────────────────────────────────────────────────────────────
381
382    #[test]
383    fn test_band_flag_below_0_4_is_over_decompressed() {
384        assert_eq!(band_flag(0.0), RiskBandFlag::OverDecompressed);
385        assert_eq!(band_flag(0.39), RiskBandFlag::OverDecompressed);
386        // 0.4 is boundary -- should be InTarget
387        assert_eq!(band_flag(0.4), RiskBandFlag::InTarget);
388    }
389
390    #[test]
391    fn test_band_flag_in_target_range() {
392        assert_eq!(band_flag(0.4), RiskBandFlag::InTarget);
393        assert_eq!(band_flag(0.57), RiskBandFlag::InTarget);
394        assert_eq!(band_flag(0.75), RiskBandFlag::InTarget);
395    }
396
397    #[test]
398    fn test_band_flag_above_0_75_is_high_risk() {
399        assert_eq!(band_flag(0.751), RiskBandFlag::HighRisk);
400        assert_eq!(band_flag(1.0), RiskBandFlag::HighRisk);
401        // 0.75 is boundary -- should be InTarget
402        assert_eq!(band_flag(0.75), RiskBandFlag::InTarget);
403    }
404
405    // ── Integration: all-critical schedule ───────────────────────────────────
406
407    #[test]
408    fn test_all_critical_schedule_end_to_end() {
409        // All floats == 0, max_float == 0 (pure critical path)
410        let floats = [0u64, 0, 0, 0];
411        let max_float = 0u64;
412
413        // activity_risk guard
414        assert_eq!(
415            activity_risk(&floats, max_float),
416            ActivityRiskResult::AllCritical
417        );
418
419        // classify all zeros
420        for &f in &floats {
421            assert_eq!(classify(f, max_float), CriticalityBand::Critical);
422        }
423
424        // criticality_risk of all-critical bands
425        let bands: Vec<_> = floats.iter().map(|&f| classify(f, max_float)).collect();
426        let cr = criticality_risk(&bands);
427        assert!(
428            (cr - 1.0).abs() < 1e-10,
429            "all-critical criticality_risk must be 1.0, got {cr}"
430        );
431    }
432}