Skip to main content

cpm_planner/
earned_value.rs

1//! Pure earned-value engine: PV / EV / AC and the derived SPI / CPI / EAC
2//! family, computed from a frozen baseline, the current graph, statuses and
3//! actuals. No I/O, no clocks: the caller supplies `as_of`.
4//!
5//! Costs are in hours multiplied by each deliverable's `metadata.cost_rate`
6//! (finite, >= 0, default 1.0). Every division is guarded; an undefined ratio
7//! becomes `None` plus an [`Undefined`] entry, never NaN or infinity.
8//!
9//! `as_of` is the status date of the planned value only: PV is the baseline
10//! curve at `as_of`, while EV and AC reflect the progress and actuals
11//! recorded up to the moment the call runs (actuals are not versioned in
12//! time). A reading with a past `as_of` therefore pairs an old PV with
13//! today's EV and AC; a snapshot more than an hour behind its `taken_at` is
14//! flagged [`SnapshotSummary::backfilled`] and ignored by [`trend_alerts`].
15//!
16//! Rows are taken from the baseline only. Deliverables added after the
17//! baseline contribute nothing to BAC, PV or EV until re-baseline; they are
18//! listed in [`EvReport::excluded_unbaselined`]. Cost rates are frozen in
19//! the baseline, so later rate edits do not change budget or AC.
20//!
21//! Actual cost never disappears: AC sums every actuals row of the plan
22//! (reported hours, else leased hours), at the row's baseline cost rate, or
23//! 1.0 for an id the baseline does not have (added later, or removed before
24//! a re-baseline). So per-row `ac` can sum to less than the total.
25//!
26//! Removed scope: when a revise removes a baselined deliverable, its earned
27//! percent is frozen ([`Actuals::frozen_pct`]: 100 if it was `Complete`,
28//! else its rule's percent then). Its row then reports
29//! [`EvRowStatus::Removed`] and earns that share of its budget (0 without a
30//! frozen percent). Adding the id back clears the freeze and the reported
31//! percent, so earning restarts; its hours keep accumulating.
32//!
33//! Known limitation: selecting variant A, then B, then A again after a
34//! revise reopened a carried deliverable can count copied leased hours
35//! twice (each select carries the source's leased hours onto the target).
36
37use crate::estimator::EffortEstimator;
38pub use crate::plan::EarningRule;
39use crate::plan::{
40    Deliverable, DeliverableStatus, FINISH_ID, PlanGraph, PlanId, PlannerError, START_ID,
41};
42use crate::schedule::{compute_cpm, effort_basis};
43use crate::task::CriticalPathResult;
44use chrono::{DateTime, Datelike, Duration, NaiveDate, Utc, Weekday};
45use serde::{Deserialize, Serialize};
46use std::collections::HashMap;
47
48/// Working-time calendar for the EV clock. Unknown fields are rejected.
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct Calendar {
52    /// Working hours per workday, in (0, 24]. A workday contributes these
53    /// hours starting at 00:00 local time.
54    #[serde(default = "default_hours_per_day")]
55    pub hours_per_day: f32,
56    /// Lowercase 3-letter weekday names (`"mon"`..`"sun"`).
57    #[serde(default = "default_workdays")]
58    pub workdays: Vec<String>,
59    /// Local offset from UTC in minutes, in [-1440, 1440].
60    #[serde(default)]
61    pub utc_offset_minutes: i32,
62}
63
64fn default_hours_per_day() -> f32 {
65    8.0
66}
67
68fn default_workdays() -> Vec<String> {
69    ["mon", "tue", "wed", "thu", "fri"]
70        .map(String::from)
71        .to_vec()
72}
73
74impl Default for Calendar {
75    fn default() -> Self {
76        Self {
77            hours_per_day: default_hours_per_day(),
78            workdays: default_workdays(),
79            utc_offset_minutes: 0,
80        }
81    }
82}
83
84/// One frozen baseline row.
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct BaselineRow {
87    pub id: String,
88    /// Baseline earliest start, hours from plan start.
89    pub es: f32,
90    /// Baseline earliest finish, hours from plan start.
91    pub ef: f32,
92    /// Budget = effort basis x cost rate.
93    pub budget: f32,
94    /// Cost rate captured at baseline time; AC uses this, not the live graph.
95    #[serde(default = "one")]
96    pub cost_rate: f32,
97}
98
99fn one() -> f32 {
100    1.0
101}
102
103/// A frozen schedule/budget baseline.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct Baseline {
106    pub number: u32,
107    pub start: DateTime<Utc>,
108    /// `None` means wall-clock hours.
109    pub calendar: Option<Calendar>,
110    pub rows: Vec<BaselineRow>,
111    pub bac: f32,
112    /// Baseline project finish (`__finish__` earliest finish), in hours.
113    pub finish_hours: f32,
114}
115
116/// Reported progress for one deliverable.
117#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118pub struct Actuals {
119    pub earned_pct: Option<u8>,
120    pub actual_hours: Option<f32>,
121    pub leased_hours: f32,
122    pub evidence: Vec<String>,
123    /// When a revise removed this deliverable from the graph; cleared when a
124    /// later revise adds the id back.
125    #[serde(default)]
126    pub removed_at: Option<DateTime<Utc>>,
127    /// Earned percent frozen at removal (100 if it was `Complete`, else its
128    /// earned percent under its rule then). A removed baseline row earns
129    /// this share of its budget.
130    #[serde(default)]
131    pub frozen_pct: Option<u8>,
132}
133
134/// Status of one EV row: the deliverable's live status, or `removed` for a
135/// baseline row whose id is no longer in the graph. Serialised like
136/// [`DeliverableStatus`] (`{"status": "..."}`), with the extra value
137/// `"removed"`.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(tag = "status", rename_all = "snake_case")]
140pub enum EvRowStatus {
141    Pending,
142    Ready,
143    InProgress,
144    Complete,
145    Failed {
146        reason: String,
147    },
148    /// A revise removed the deliverable after it was baselined; it earns its
149    /// frozen percent ([`Actuals::frozen_pct`], 0 if none).
150    Removed,
151}
152
153impl From<DeliverableStatus> for EvRowStatus {
154    fn from(s: DeliverableStatus) -> Self {
155        match s {
156            DeliverableStatus::Pending => Self::Pending,
157            DeliverableStatus::Ready => Self::Ready,
158            DeliverableStatus::InProgress => Self::InProgress,
159            DeliverableStatus::Complete => Self::Complete,
160            DeliverableStatus::Failed { reason } => Self::Failed { reason },
161        }
162    }
163}
164
165impl PartialEq<DeliverableStatus> for EvRowStatus {
166    fn eq(&self, other: &DeliverableStatus) -> bool {
167        match (self, other) {
168            (Self::Pending, DeliverableStatus::Pending)
169            | (Self::Ready, DeliverableStatus::Ready)
170            | (Self::InProgress, DeliverableStatus::InProgress)
171            | (Self::Complete, DeliverableStatus::Complete) => true,
172            (Self::Failed { reason: a }, DeliverableStatus::Failed { reason: b }) => a == b,
173            _ => false,
174        }
175    }
176}
177
178/// An EV field that could not be computed.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct Undefined {
181    pub field: String,
182    pub reason: String,
183}
184
185/// Per-deliverable EV row.
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct EvRow {
188    pub id: String,
189    pub budget: f32,
190    pub pv: f32,
191    pub earned_pct: f32,
192    pub ev: f32,
193    /// This row's spend at its baseline cost rate.
194    pub ac: f32,
195    /// Live status, or `removed` when the id left the graph after baselining.
196    pub status: EvRowStatus,
197}
198
199/// The ratios of an earlier snapshot, used for trend alerts.
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct EvSummary {
202    /// The baseline the snapshot was measured against; alerts only compare
203    /// snapshots of the current baseline.
204    pub baseline_number: u32,
205    pub spi: Option<f32>,
206    pub cpi: Option<f32>,
207    /// The snapshot was backfilled ([`SnapshotSummary::backfilled`]); alerts
208    /// ignore it.
209    #[serde(default)]
210    pub backfilled: bool,
211}
212
213/// Full earned-value report.
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215pub struct EvReport {
216    /// PV status date. EV and AC reflect progress and actuals recorded up
217    /// to the moment the report was computed.
218    pub as_of: DateTime<Utc>,
219    pub baseline_number: u32,
220    pub bac: f32,
221    pub pv: f32,
222    pub ev: f32,
223    pub ac: f32,
224    pub sv: f32,
225    pub cv: f32,
226    pub spi: Option<f32>,
227    pub cpi: Option<f32>,
228    pub eac: Option<f32>,
229    pub etc: Option<f32>,
230    pub vac: Option<f32>,
231    pub tcpi: Option<f32>,
232    pub undefined: Vec<Undefined>,
233    pub rows: Vec<EvRow>,
234    pub critical_float_consumed_hours: f32,
235    pub alerts: Vec<String>,
236    /// Ids in the live graph but absent from the baseline (sorted, synthetic
237    /// endpoints excluded). They earn no value and carry no budget until a
238    /// re-baseline, but their spend counts in AC at rate 1.0 (their live
239    /// `cost_rate` applies only once a baseline row exists).
240    pub excluded_unbaselined: Vec<String>,
241}
242
243fn weekday_from_name(name: &str) -> Option<Weekday> {
244    Some(match name {
245        "mon" => Weekday::Mon,
246        "tue" => Weekday::Tue,
247        "wed" => Weekday::Wed,
248        "thu" => Weekday::Thu,
249        "fri" => Weekday::Fri,
250        "sat" => Weekday::Sat,
251        "sun" => Weekday::Sun,
252        _ => return None,
253    })
254}
255
256fn workdays_per_week(days: &[Weekday]) -> usize {
257    let mut uniq = days.to_vec();
258    uniq.sort_by_key(|d| d.num_days_from_monday());
259    uniq.dedup();
260    uniq.len()
261}
262
263/// `metadata.cost_rate`: finite and >= 0, default 1.0.
264fn cost_rate(d: &Deliverable) -> Result<f32, PlannerError> {
265    match d.metadata.get("cost_rate") {
266        None | Some(serde_json::Value::Null) => Ok(1.0),
267        Some(v) => match v.as_f64() {
268            Some(x) if x.is_finite() && x >= 0.0 && x <= f64::from(f32::MAX) => Ok(x as f32),
269            _ => Err(PlannerError::InvalidGraph {
270                reason: format!(
271                    "deliverable {:?}: metadata.cost_rate must be a finite number >= 0, got {v}",
272                    d.id
273                ),
274            }),
275        },
276    }
277}
278
279fn budget_of(d: &Deliverable, estimator: &EffortEstimator) -> Result<f32, PlannerError> {
280    let budget = effort_basis(d, estimator) * cost_rate(d)?;
281    if budget.is_finite() {
282        Ok(budget)
283    } else {
284        Err(PlannerError::InvalidGraph {
285            reason: format!("deliverable {:?}: budget is not finite", d.id),
286        })
287    }
288}
289
290/// Project finish in hours: the `__finish__` earliest finish, else the maximum.
291fn finish_of(cpm: &CriticalPathResult) -> f32 {
292    cpm.tasks
293        .iter()
294        .find(|t| t.id == FINISH_ID)
295        .map(|t| t.earliest_finish)
296        .unwrap_or_else(|| {
297            cpm.tasks
298                .iter()
299                .map(|t| t.earliest_finish)
300                .fold(0.0, f32::max)
301        })
302}
303
304/// The percent a deliverable removed from the graph keeps
305/// ([`Actuals::frozen_pct`]): 100 if it was `Complete`, else its earned
306/// percent under `rule` with the `reported` percent (0 if none).
307pub fn frozen_pct(rule: EarningRule, status: &DeliverableStatus, reported: Option<u8>) -> u8 {
308    earned_pct(rule, status, reported.map(|p| p.min(100))).clamp(0.0, 100.0) as u8
309}
310
311fn earned_pct(rule: EarningRule, status: &DeliverableStatus, reported: Option<u8>) -> f64 {
312    if *status == DeliverableStatus::Complete {
313        return 100.0;
314    }
315    match rule {
316        EarningRule::ZeroHundred => 0.0,
317        EarningRule::FiftyFifty => {
318            if *status == DeliverableStatus::InProgress || reported.is_some() {
319                50.0
320            } else {
321                0.0
322            }
323        }
324        EarningRule::Weighted => f64::from(reported.unwrap_or(0)),
325    }
326}
327
328impl Calendar {
329    /// Reject an unusable calendar with a clear reason.
330    pub fn validate(&self) -> Result<(), PlannerError> {
331        let bad = |reason: String| Err(PlannerError::InvalidGraph { reason });
332        if !(self.hours_per_day.is_finite()
333            && self.hours_per_day > 0.0
334            && self.hours_per_day <= 24.0)
335        {
336            return bad(format!(
337                "calendar.hours_per_day must be in (0, 24], got {}",
338                self.hours_per_day
339            ));
340        }
341        if !(-1440..=1440).contains(&self.utc_offset_minutes) {
342            return bad(format!(
343                "calendar.utc_offset_minutes must be in [-1440, 1440], got {}",
344                self.utc_offset_minutes
345            ));
346        }
347        if self.workdays.is_empty() {
348            return bad("calendar.workdays must not be empty".to_string());
349        }
350        for name in &self.workdays {
351            if weekday_from_name(name).is_none() {
352                return bad(format!(
353                    "calendar.workdays entry {name:?} is not a lowercase 3-letter weekday (mon..sun)"
354                ));
355            }
356        }
357        Ok(())
358    }
359}
360
361/// Elapsed EV-clock hours between `start` and `as_of`.
362pub fn elapsed_hours(
363    start: DateTime<Utc>,
364    as_of: DateTime<Utc>,
365    calendar: Option<&Calendar>,
366) -> Result<f32, PlannerError> {
367    if as_of <= start {
368        return Ok(0.0);
369    }
370    let Some(calendar) = calendar else {
371        return Ok((as_of - start).num_milliseconds() as f32 / 3_600_000.0);
372    };
373    calendar.validate()?;
374    let off = Duration::minutes(i64::from(calendar.utc_offset_minutes));
375    let (ls, le) = (start.naive_utc() + off, as_of.naive_utc() + off);
376    let workdays: Vec<Weekday> = calendar
377        .workdays
378        .iter()
379        .filter_map(|n| weekday_from_name(n))
380        .collect();
381    let hpd = f64::from(calendar.hours_per_day);
382    let is_work = |d: NaiveDate| workdays.contains(&d.weekday());
383    // Working hours inside [from, to] on local day `d` (window is [00:00, 00:00 + hpd)).
384    let partial = |d: NaiveDate, from: chrono::NaiveDateTime, to: chrono::NaiveDateTime| -> f64 {
385        if !is_work(d) {
386            return 0.0;
387        }
388        let day0 = d.and_hms_opt(0, 0, 0).unwrap_or(from);
389        let lo = (from - day0).num_milliseconds() as f64 / 3_600_000.0;
390        let hi = (to - day0).num_milliseconds() as f64 / 3_600_000.0;
391        (hi.min(hpd) - lo.max(0.0)).max(0.0)
392    };
393    let (sd, ed) = (ls.date(), le.date());
394    let total = if sd == ed {
395        partial(sd, ls, le)
396    } else {
397        let mut t = partial(sd, ls, le) + partial(ed, ls, le);
398        // Interior days (strictly between) are full days; skip whole weeks arithmetically.
399        let interior = (ed - sd).num_days() - 1;
400        let weeks = interior / 7;
401        let per_week = workdays_per_week(&workdays) as f64;
402        t += weeks as f64 * per_week * hpd;
403        let mut d = sd + Duration::days(1 + weeks * 7);
404        while d < ed {
405            if is_work(d) {
406                t += hpd;
407            }
408            d += Duration::days(1);
409        }
410        t
411    };
412    Ok(total as f32)
413}
414
415/// Freeze the current CPM schedule and budgets into a baseline.
416pub fn build_baseline(
417    graph: &PlanGraph,
418    cpm: &CriticalPathResult,
419    start: DateTime<Utc>,
420    calendar: Option<Calendar>,
421    number: u32,
422) -> Result<Baseline, PlannerError> {
423    if let Some(c) = &calendar {
424        c.validate()?;
425    }
426    let by_id: HashMap<&str, &crate::task::Task> =
427        cpm.tasks.iter().map(|t| (t.id.as_str(), t)).collect();
428    let estimator = EffortEstimator::new();
429    let mut rows = Vec::new();
430    for d in &graph.deliverables {
431        if d.id == START_ID || d.id == FINISH_ID {
432            continue;
433        }
434        let Some(t) = by_id.get(d.id.as_str()) else {
435            return Err(PlannerError::InvalidGraph {
436                reason: format!("deliverable {:?} missing from CPM result", d.id),
437            });
438        };
439        rows.push(BaselineRow {
440            id: d.id.clone(),
441            es: t.earliest_start,
442            ef: t.earliest_finish,
443            budget: budget_of(d, &estimator)?,
444            cost_rate: cost_rate(d)?,
445        });
446    }
447    let bac = rows.iter().map(|r| f64::from(r.budget)).sum::<f64>() as f32;
448    if !bac.is_finite() {
449        return Err(PlannerError::InvalidGraph {
450            reason: "budget at completion (sum of effort x cost_rate) exceeds the f32 range"
451                .to_string(),
452        });
453    }
454    Ok(Baseline {
455        number,
456        start,
457        calendar,
458        rows,
459        bac,
460        finish_hours: finish_of(cpm),
461    })
462}
463
464/// Compute the earned-value report.
465///
466/// Critical-float consumption is approximated as
467/// `max(0, current __finish__ EF - baseline finish)`.
468/// Rows are taken from the baseline only. Deliverables added after the baseline
469/// contribute nothing to BAC, PV or EV until re-baseline; AC sums every
470/// entry of `actuals` (see the module docs). A baseline row whose id is not
471/// in `graph` is `removed` and earns its frozen percent.
472///
473/// `as_of` sets PV only; EV and AC come from `statuses` and `actuals` as
474/// given (the progress recorded when the call runs).
475///
476/// `critical_float_consumed_hours` reflects only graph/estimate changes: it
477/// ignores statuses, actuals and `as_of`, so it does not show execution
478/// slippage, and it includes scope added after the baseline.
479///
480/// Reported `actual_hours` that is non-finite or negative is treated as
481/// absent (AC falls back to leased hours). TCPI can be negative when AC > BAC.
482///
483/// Alerts need the two latest entries of `previous_snapshots` (in `as_of`
484/// order) taken against this baseline to both have the metric below 0.9
485/// (see [`trend_alerts`]).
486pub fn compute_ev(
487    baseline: &Baseline,
488    graph: &PlanGraph,
489    statuses: &HashMap<String, DeliverableStatus>,
490    rules: &HashMap<String, EarningRule>,
491    actuals: &HashMap<String, Actuals>,
492    as_of: DateTime<Utc>,
493    previous_snapshots: &[EvSummary],
494) -> Result<EvReport, PlannerError> {
495    let t = f64::from(elapsed_hours(
496        baseline.start,
497        as_of,
498        baseline.calendar.as_ref(),
499    )?);
500    let (mut pv, mut ev, mut ac) = (0.0_f64, 0.0_f64, 0.0_f64);
501    let mut rows = Vec::with_capacity(baseline.rows.len());
502    let live: std::collections::HashSet<&str> =
503        graph.deliverables.iter().map(|d| d.id.as_str()).collect();
504    let valid = |h: f32| h.is_finite() && h >= 0.0;
505    // Actual hours, else leased hours; invalid values count as 0.
506    let hours_of = |a: &Actuals| -> f32 {
507        let h = match a.actual_hours {
508            Some(h) if valid(h) => h,
509            _ => a.leased_hours,
510        };
511        if valid(h) { h } else { 0.0 }
512    };
513    for r in &baseline.rows {
514        let budget = f64::from(r.budget);
515        let (es, ef) = (f64::from(r.es), f64::from(r.ef));
516        let row_pv = if ef <= es {
517            if t >= es { budget } else { 0.0 }
518        } else {
519            budget * ((t - es) / (ef - es)).clamp(0.0, 1.0)
520        };
521        let act = actuals.get(&r.id);
522        let (status, pct) = if live.contains(r.id.as_str()) {
523            let status = statuses
524                .get(&r.id)
525                .cloned()
526                .unwrap_or(DeliverableStatus::Pending);
527            let rule = rules.get(&r.id).copied().unwrap_or_default();
528            let reported = act.and_then(|a| a.earned_pct).map(|p| p.min(100));
529            let pct = earned_pct(rule, &status, reported);
530            (EvRowStatus::from(status), pct)
531        } else {
532            let frozen = act.and_then(|a| a.frozen_pct).map_or(0, |p| p.min(100));
533            (EvRowStatus::Removed, f64::from(frozen))
534        };
535        let hours = act.map_or(0.0, hours_of);
536        let row_ev = budget * pct / 100.0;
537        let row_ac = f64::from(r.cost_rate) * f64::from(hours);
538        pv += row_pv;
539        ev += row_ev;
540        rows.push(EvRow {
541            id: r.id.clone(),
542            budget: r.budget,
543            pv: row_pv as f32,
544            earned_pct: pct as f32,
545            ev: row_ev as f32,
546            ac: row_ac as f32,
547            status,
548        });
549    }
550    // AC is every actuals row of the plan (spend never disappears): baseline
551    // rows at their frozen cost rate, any other id at rate 1.0.
552    let rates: HashMap<&str, f32> = baseline
553        .rows
554        .iter()
555        .map(|r| (r.id.as_str(), r.cost_rate))
556        .collect();
557    let mut spend: Vec<(&String, &Actuals)> = actuals.iter().collect();
558    spend.sort_by(|a, b| a.0.cmp(b.0));
559    for (id, a) in spend {
560        // Spend with no baseline row (added after the baseline, or descoped by
561        // a re-baseline) counts at rate 1.0 until a baseline records its rate.
562        let rate = rates.get(id.as_str()).copied().unwrap_or(1.0);
563        ac += f64::from(rate) * f64::from(hours_of(a));
564    }
565    let bac: f64 = baseline.rows.iter().map(|r| f64::from(r.budget)).sum();
566    let mut undefined = Vec::new();
567    // `None` input = guarded division; a value that overflows f32 is also undefined.
568    let mut ratio = |field: &str, v: Option<f64>, reason: &str| -> Option<f64> {
569        let out = v.filter(|x| (*x as f32).is_finite());
570        if out.is_none() {
571            let reason = if v.is_some() {
572                "result is not finite as f32"
573            } else {
574                reason
575            };
576            undefined.push(Undefined {
577                field: field.to_string(),
578                reason: reason.to_string(),
579            });
580        }
581        out
582    };
583    let spi = ratio(
584        "spi",
585        (pv != 0.0).then(|| ev / pv),
586        "PV is 0 before any work was planned",
587    );
588    let cpi = ratio(
589        "cpi",
590        (ac != 0.0).then(|| ev / ac),
591        "AC is 0, no cost recorded yet",
592    );
593    let eac_reason = if cpi.is_none() {
594        "CPI is undefined"
595    } else {
596        "CPI is 0, nothing earned for the cost spent"
597    };
598    let eac = ratio(
599        "eac",
600        cpi.filter(|c| *c != 0.0).map(|c| bac / c),
601        eac_reason,
602    );
603    let etc = ratio("etc", eac.map(|e| e - ac), "EAC is undefined");
604    let vac = ratio("vac", eac.map(|e| bac - e), "EAC is undefined");
605    let tcpi = ratio(
606        "tcpi",
607        ((bac - ac).abs() > 1e-9 * bac.max(1.0)).then(|| (bac - ev) / (bac - ac)),
608        "BAC - AC is 0, no budget remains",
609    );
610    let out = |v: Option<f64>| v.map(|x| x as f32);
611
612    let cpm = compute_cpm(graph)?;
613    let critical_float = (finish_of(&cpm) - baseline.finish_hours).max(0.0);
614
615    let alerts = trend_alerts(previous_snapshots, baseline.number);
616    for (field, v) in [
617        ("pv", pv),
618        ("ev", ev),
619        ("ac", ac),
620        ("sv", ev - pv),
621        ("cv", ev - ac),
622    ] {
623        if !(v as f32).is_finite() {
624            return Err(PlannerError::InvalidGraph {
625                reason: format!(
626                    "earned-value total {field} exceeds the f32 range (check cost_rate and \
627                     reported hours)"
628                ),
629            });
630        }
631    }
632
633    let report = EvReport {
634        as_of,
635        baseline_number: baseline.number,
636        bac: baseline.bac,
637        pv: pv as f32,
638        ev: ev as f32,
639        ac: ac as f32,
640        sv: (ev - pv) as f32,
641        cv: (ev - ac) as f32,
642        spi: out(spi),
643        cpi: out(cpi),
644        eac: out(eac),
645        etc: out(etc),
646        vac: out(vac),
647        tcpi: out(tcpi),
648        undefined,
649        rows,
650        critical_float_consumed_hours: critical_float,
651        alerts,
652        excluded_unbaselined: {
653            let known: std::collections::HashSet<&str> =
654                baseline.rows.iter().map(|r| r.id.as_str()).collect();
655            let mut v: Vec<String> = graph
656                .deliverables
657                .iter()
658                .filter(|d| d.id != START_ID && d.id != FINISH_ID && !known.contains(d.id.as_str()))
659                .map(|d| d.id.clone())
660                .collect();
661            v.sort();
662            v
663        },
664    };
665    Ok(report)
666}
667
668/// Longest accepted `plan.baseline` reason, in characters (the evidence
669/// limit, [`crate::plan::MAX_EVIDENCE_CHARS`]).
670pub const MAX_BASELINE_REASON_CHARS: usize = crate::plan::MAX_EVIDENCE_CHARS;
671
672/// Most snapshots a `plan.snapshot` export lists (the newest ones).
673pub const SNAPSHOT_EXPORT_LIMIT: usize = 100;
674
675/// Take a baseline of a plan (`plan.baseline`).
676#[derive(Debug, Clone, PartialEq)]
677pub struct BaselineRequest {
678    pub plan_id: PlanId,
679    /// Baseline start; defaults to the planner clock's now.
680    pub start: Option<DateTime<Utc>>,
681    /// Working-time calendar; `None` counts wall-clock hours.
682    pub calendar: Option<Calendar>,
683    /// Why the plan is re-baselined. Required (non-blank) when the plan
684    /// already has a baseline; at most [`MAX_BASELINE_REASON_CHARS`].
685    pub reason: Option<String>,
686}
687
688impl BaselineRequest {
689    pub fn new(plan_id: PlanId) -> Self {
690        Self {
691            plan_id,
692            start: None,
693            calendar: None,
694            reason: None,
695        }
696    }
697
698    pub fn with_start(mut self, start: DateTime<Utc>) -> Self {
699        self.start = Some(start);
700        self
701    }
702
703    pub fn with_calendar(mut self, calendar: Calendar) -> Self {
704        self.calendar = Some(calendar);
705        self
706    }
707
708    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
709        self.reason = Some(reason.into());
710        self
711    }
712}
713
714/// A stored baseline, as `plan.baseline` reports it.
715#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
716pub struct BaselineOutcome {
717    pub plan_id: String,
718    pub baseline_number: u32,
719    pub start: DateTime<Utc>,
720    pub calendar: Option<Calendar>,
721    pub bac: f32,
722    pub finish_hours: f32,
723    pub deliverable_count: usize,
724    pub reason: Option<String>,
725}
726
727/// Export format of `plan.snapshot`.
728#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
729#[serde(rename_all = "snake_case")]
730pub enum SnapshotFormat {
731    /// `export` is the list of snapshot summaries, oldest first.
732    #[default]
733    Json,
734    /// `export` is a Markdown table ([`render_snapshots_markdown`]).
735    Markdown,
736}
737
738/// Append an EV snapshot (`plan.snapshot`).
739#[derive(Debug, Clone, PartialEq)]
740pub struct SnapshotRequest {
741    pub plan_id: PlanId,
742    /// Status date of the reading's PV; defaults to the planner clock's now.
743    /// EV and AC are always the progress recorded when the snapshot is
744    /// taken, so a past `as_of` (over an hour back) marks it
745    /// [`SnapshotSummary::backfilled`].
746    pub as_of: Option<DateTime<Utc>>,
747    pub format: SnapshotFormat,
748}
749
750impl SnapshotRequest {
751    pub fn new(plan_id: PlanId) -> Self {
752        Self {
753            plan_id,
754            as_of: None,
755            format: SnapshotFormat::default(),
756        }
757    }
758
759    pub fn with_as_of(mut self, as_of: DateTime<Utc>) -> Self {
760        self.as_of = Some(as_of);
761        self
762    }
763
764    pub fn with_format(mut self, format: SnapshotFormat) -> Self {
765        self.format = format;
766        self
767    }
768}
769
770/// The snapshot just taken plus the rendered history.
771#[derive(Debug, Clone, PartialEq, Serialize)]
772pub struct SnapshotOutcome {
773    pub summary: SnapshotSummary,
774    pub format: SnapshotFormat,
775    /// The newest [`SNAPSHOT_EXPORT_LIMIT`] snapshots by `as_of` (ties by
776    /// `taken_at`), oldest first: a list of summaries for `json`, a Markdown
777    /// table string for `markdown`. A backfilled snapshot sits at its
778    /// `as_of` position, and one older than all of those is stored and
779    /// counted but not listed.
780    pub export: serde_json::Value,
781    /// Snapshots stored for the plan, this one included.
782    pub snapshot_count: usize,
783}
784
785/// Trend alerts over snapshot ratios in `as_of` order: `SPI_BELOW_0_9` /
786/// `CPI_BELOW_0_9` when the metric is defined and below 0.9 on both of the
787/// two latest snapshots taken against `baseline_number` (snapshots of
788/// earlier baselines are ignored). Backfilled snapshots
789/// ([`SnapshotSummary::backfilled`]) are ignored too, as the new reading
790/// and as the previous one: their EV and AC are today's, not the past
791/// date's. Fewer than two remaining snapshots raise nothing.
792pub fn trend_alerts(snapshots: &[EvSummary], baseline_number: u32) -> Vec<String> {
793    let mut alerts = Vec::new();
794    let current: Vec<&EvSummary> = snapshots
795        .iter()
796        .filter(|s| s.baseline_number == baseline_number && !s.backfilled)
797        .collect();
798    if let [.., a, b] = current.as_slice() {
799        let low = |v: Option<f32>| v.is_some_and(|x| x < 0.9);
800        if low(a.spi) && low(b.spi) {
801            alerts.push("SPI_BELOW_0_9".to_string());
802        }
803        if low(a.cpi) && low(b.cpi) {
804            alerts.push("CPI_BELOW_0_9".to_string());
805        }
806    }
807    alerts
808}
809
810/// The stored summary of one EV snapshot: the report's totals and ratios
811/// (an undefined ratio stays `None`, serialised as `null`) and the alerts
812/// raised when it was taken.
813#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
814pub struct SnapshotSummary {
815    pub taken_at: DateTime<Utc>,
816    pub as_of: DateTime<Utc>,
817    pub baseline_number: u32,
818    pub bac: f32,
819    pub pv: f32,
820    pub ev: f32,
821    pub ac: f32,
822    pub sv: f32,
823    pub cv: f32,
824    pub spi: Option<f32>,
825    pub cpi: Option<f32>,
826    pub eac: Option<f32>,
827    pub etc: Option<f32>,
828    pub vac: Option<f32>,
829    pub tcpi: Option<f32>,
830    /// Why each `null` ratio is undefined (as in [`EvReport::undefined`]).
831    /// Absent in summaries stored before this field existed.
832    #[serde(default)]
833    pub undefined: Vec<Undefined>,
834    pub alerts: Vec<String>,
835    /// `as_of` is more than [`BACKFILL_TOLERANCE_HOURS`] before `taken_at`.
836    /// EV and AC are measured when the snapshot is taken, so a backfilled
837    /// snapshot pairs a past PV with present progress; trend alerts ignore
838    /// it. Absent (false) in summaries stored before this field existed.
839    #[serde(default)]
840    pub backfilled: bool,
841}
842
843/// How far `as_of` may lag `taken_at` before a snapshot counts as
844/// backfilled ([`SnapshotSummary::backfilled`]).
845pub const BACKFILL_TOLERANCE_HOURS: i64 = 1;
846
847/// Whether a reading at `as_of`, taken at `taken_at`, is backfilled.
848pub fn is_backfilled(as_of: DateTime<Utc>, taken_at: DateTime<Utc>) -> bool {
849    as_of < taken_at - Duration::hours(BACKFILL_TOLERANCE_HOURS)
850}
851
852impl SnapshotSummary {
853    /// Summarise `report`, taken at `taken_at`.
854    pub fn from_report(report: &EvReport, taken_at: DateTime<Utc>) -> Self {
855        Self {
856            taken_at,
857            as_of: report.as_of,
858            baseline_number: report.baseline_number,
859            bac: report.bac,
860            pv: report.pv,
861            ev: report.ev,
862            ac: report.ac,
863            sv: report.sv,
864            cv: report.cv,
865            spi: report.spi,
866            cpi: report.cpi,
867            eac: report.eac,
868            etc: report.etc,
869            vac: report.vac,
870            tcpi: report.tcpi,
871            undefined: report.undefined.clone(),
872            alerts: report.alerts.clone(),
873            backfilled: is_backfilled(report.as_of, taken_at),
874        }
875    }
876
877    /// The ratios trend alerts look at.
878    pub fn ratios(&self) -> EvSummary {
879        EvSummary {
880            baseline_number: self.baseline_number,
881            spi: self.spi,
882            cpi: self.cpi,
883            backfilled: self.backfilled,
884        }
885    }
886}
887
888/// Render snapshots (in the given order) as a Markdown table with columns
889/// date (the `as_of` instant, UTC, to the minute), PV, EV, AC, SPI, CPI
890/// and EAC. Values have two decimals; an undefined ratio is `n/a`.
891pub fn render_snapshots_markdown(snapshots: &[SnapshotSummary]) -> String {
892    let ratio = |v: Option<f32>| v.map_or_else(|| "n/a".to_string(), |x| format!("{x:.2}"));
893    let mut out = String::from(
894        "| date | PV | EV | AC | SPI | CPI | EAC |\n|---|---:|---:|---:|---:|---:|---:|\n",
895    );
896    for s in snapshots {
897        out.push_str(&format!(
898            "| {} | {:.2} | {:.2} | {:.2} | {} | {} | {} |\n",
899            s.as_of.format("%Y-%m-%dT%H:%MZ"),
900            s.pv,
901            s.ev,
902            s.ac,
903            ratio(s.spi),
904            ratio(s.cpi),
905            ratio(s.eac),
906        ));
907    }
908    out
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914    use chrono::{TimeZone, Timelike};
915
916    const EPS: f32 = 1e-3;
917
918    fn at(y: i32, m: u32, d: u32, h: u32) -> DateTime<Utc> {
919        Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap()
920    }
921
922    fn near(a: f32, b: f32) -> bool {
923        (a - b).abs() < EPS
924    }
925
926    fn deliverable(id: &str, effort: f32, prereqs: &[&str]) -> Deliverable {
927        Deliverable {
928            id: id.to_string(),
929            owned_files: vec![],
930            prerequisites: prereqs
931                .iter()
932                .map(|p| crate::plan::Prerequisite::Id((*p).to_string()))
933                .collect(),
934            estimated_effort_hours: Some(effort),
935            duration_hours: None,
936            estimate: None,
937            metadata: serde_json::Value::Null,
938            milestone: false,
939            earning_rule: None,
940        }
941    }
942
943    fn graph(ds: Vec<Deliverable>) -> PlanGraph {
944        PlanGraph {
945            deliverables: ds,
946            max_chained_dispatch: None,
947        }
948    }
949
950    fn row(id: &str, es: f32, ef: f32, budget: f32) -> BaselineRow {
951        BaselineRow {
952            id: id.to_string(),
953            es,
954            ef,
955            budget,
956            cost_rate: 1.0,
957        }
958    }
959
960    fn baseline(rows: Vec<BaselineRow>) -> Baseline {
961        let bac = rows.iter().map(|r| r.budget).sum();
962        let finish_hours = rows.iter().map(|r| r.ef).fold(0.0, f32::max);
963        Baseline {
964            number: 1,
965            start: at(2026, 1, 5, 0),
966            calendar: None,
967            rows,
968            bac,
969            finish_hours,
970        }
971    }
972
973    fn statuses(pairs: &[(&str, DeliverableStatus)]) -> HashMap<String, DeliverableStatus> {
974        pairs
975            .iter()
976            .map(|(k, v)| ((*k).to_string(), v.clone()))
977            .collect()
978    }
979
980    fn report(
981        b: &Baseline,
982        g: &PlanGraph,
983        st: &HashMap<String, DeliverableStatus>,
984        rules: &HashMap<String, EarningRule>,
985        actuals: &HashMap<String, Actuals>,
986        hours: i64,
987    ) -> EvReport {
988        let as_of = b.start + Duration::hours(hours);
989        compute_ev(b, g, st, rules, actuals, as_of, &[]).unwrap()
990    }
991
992    fn one(rule: EarningRule, status: DeliverableStatus, pct: Option<u8>) -> EvReport {
993        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
994        let g = graph(vec![deliverable("a", 100.0, &[])]);
995        let st = statuses(&[("a", status)]);
996        let rules = HashMap::from([("a".to_string(), rule)]);
997        let actuals = HashMap::from([(
998            "a".to_string(),
999            Actuals {
1000                earned_pct: pct,
1001                ..Actuals::default()
1002            },
1003        )]);
1004        report(&b, &g, &st, &rules, &actuals, 5)
1005    }
1006
1007    /// BAC 100 (two rows), PV 50, EV 40, AC 48.
1008    fn textbook() -> EvReport {
1009        let b = baseline(vec![row("a", 0.0, 10.0, 50.0), row("b", 10.0, 20.0, 50.0)]);
1010        let g = graph(vec![
1011            deliverable("a", 50.0, &[]),
1012            deliverable("b", 50.0, &["a"]),
1013        ]);
1014        let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1015        let rules = HashMap::from([("a".to_string(), EarningRule::Weighted)]);
1016        let actuals = HashMap::from([(
1017            "a".to_string(),
1018            Actuals {
1019                earned_pct: Some(80),
1020                actual_hours: Some(48.0),
1021                ..Actuals::default()
1022            },
1023        )]);
1024        report(&b, &g, &st, &rules, &actuals, 10)
1025    }
1026
1027    #[test]
1028    fn wall_clock_elapsed_is_plain_hours() {
1029        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 6, 6), None).unwrap();
1030        assert!(near(h, 30.0));
1031    }
1032
1033    #[test]
1034    fn elapsed_before_start_is_zero() {
1035        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 4, 0), None).unwrap();
1036        assert!(near(h, 0.0));
1037    }
1038
1039    #[test]
1040    fn calendar_skips_weekends() {
1041        // Fri 2026-01-09 00:00 to Mon 2026-01-12 00:00: only Friday's 8h.
1042        let c = Calendar::default();
1043        let h = elapsed_hours(at(2026, 1, 9, 0), at(2026, 1, 12, 0), Some(&c)).unwrap();
1044        assert!(near(h, 8.0));
1045    }
1046
1047    #[test]
1048    fn calendar_counts_partial_day() {
1049        // Monday 03:00 to 05:30 is 2.5 working hours.
1050        let c = Calendar::default();
1051        let start = at(2026, 1, 5, 3);
1052        let h = elapsed_hours(start, start + Duration::minutes(150), Some(&c)).unwrap();
1053        assert!(near(h, 2.5));
1054    }
1055
1056    #[test]
1057    fn calendar_ignores_hours_after_the_working_window() {
1058        let c = Calendar::default();
1059        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 5, 20), Some(&c)).unwrap();
1060        assert!(near(h, 8.0));
1061    }
1062
1063    #[test]
1064    fn calendar_over_many_weeks_counts_whole_weeks() {
1065        // 10 weeks from a Monday: 10 * 5 * 8 working hours.
1066        let c = Calendar::default();
1067        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 3, 16, 0), Some(&c)).unwrap();
1068        assert!(near(h, 400.0));
1069    }
1070
1071    #[test]
1072    fn calendar_utc_offset_shifts_the_local_day() {
1073        // +120 min: Mon 2026-01-05 22:00 UTC is Tue 00:00 local, so 2h later is 2 work hours.
1074        let c = Calendar {
1075            utc_offset_minutes: 120,
1076            ..Calendar::default()
1077        };
1078        let h = elapsed_hours(at(2026, 1, 5, 22), at(2026, 1, 6, 0), Some(&c)).unwrap();
1079        assert!(near(h, 2.0));
1080    }
1081
1082    #[test]
1083    fn calendar_custom_workdays_count_only_listed_days() {
1084        let c = Calendar {
1085            workdays: vec!["sat".into()],
1086            ..Calendar::default()
1087        };
1088        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 12, 0), Some(&c)).unwrap();
1089        assert!(near(h, 8.0));
1090    }
1091
1092    fn invalid(c: Calendar) -> bool {
1093        matches!(c.validate(), Err(PlannerError::InvalidGraph { .. }))
1094    }
1095
1096    #[test]
1097    fn calendar_rejects_zero_hours_per_day() {
1098        assert!(invalid(Calendar {
1099            hours_per_day: 0.0,
1100            ..Calendar::default()
1101        }));
1102    }
1103
1104    #[test]
1105    fn calendar_rejects_more_than_24_hours_per_day() {
1106        assert!(invalid(Calendar {
1107            hours_per_day: 24.5,
1108            ..Calendar::default()
1109        }));
1110    }
1111
1112    #[test]
1113    fn calendar_rejects_nan_hours_per_day() {
1114        assert!(invalid(Calendar {
1115            hours_per_day: f32::NAN,
1116            ..Calendar::default()
1117        }));
1118    }
1119
1120    #[test]
1121    fn calendar_rejects_out_of_range_offset() {
1122        assert!(invalid(Calendar {
1123            utc_offset_minutes: 1441,
1124            ..Calendar::default()
1125        }));
1126    }
1127
1128    #[test]
1129    fn calendar_rejects_empty_workdays() {
1130        assert!(invalid(Calendar {
1131            workdays: vec![],
1132            ..Calendar::default()
1133        }));
1134    }
1135
1136    #[test]
1137    fn calendar_rejects_unknown_weekday_name() {
1138        assert!(invalid(Calendar {
1139            workdays: vec!["monday".into()],
1140            ..Calendar::default()
1141        }));
1142    }
1143
1144    #[test]
1145    fn calendar_accepts_a_24_hour_day() {
1146        assert!(
1147            Calendar {
1148                hours_per_day: 24.0,
1149                ..Calendar::default()
1150            }
1151            .validate()
1152            .is_ok()
1153        );
1154    }
1155
1156    #[test]
1157    fn pv_interpolates_linearly_inside_a_task() {
1158        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1159        let g = graph(vec![deliverable("a", 100.0, &[])]);
1160        let r = report(
1161            &b,
1162            &g,
1163            &HashMap::new(),
1164            &HashMap::new(),
1165            &HashMap::new(),
1166            15,
1167        );
1168        assert!(near(r.pv, 50.0));
1169    }
1170
1171    #[test]
1172    fn pv_is_zero_before_task_start() {
1173        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1174        let g = graph(vec![deliverable("a", 100.0, &[])]);
1175        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1176        assert!(near(r.pv, 0.0));
1177    }
1178
1179    #[test]
1180    fn pv_caps_at_budget_after_task_finish() {
1181        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1182        let g = graph(vec![deliverable("a", 100.0, &[])]);
1183        let r = report(
1184            &b,
1185            &g,
1186            &HashMap::new(),
1187            &HashMap::new(),
1188            &HashMap::new(),
1189            99,
1190        );
1191        assert!(near(r.pv, 100.0));
1192    }
1193
1194    #[test]
1195    fn pv_of_zero_length_task_is_zero_before_its_start() {
1196        let b = baseline(vec![row("m", 10.0, 10.0, 5.0)]);
1197        let g = graph(vec![deliverable("m", 5.0, &[])]);
1198        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 9);
1199        assert!(near(r.pv, 0.0));
1200    }
1201
1202    #[test]
1203    fn pv_of_zero_length_task_steps_to_budget_at_its_start() {
1204        let b = baseline(vec![row("m", 10.0, 10.0, 5.0)]);
1205        let g = graph(vec![deliverable("m", 5.0, &[])]);
1206        let r = report(
1207            &b,
1208            &g,
1209            &HashMap::new(),
1210            &HashMap::new(),
1211            &HashMap::new(),
1212            10,
1213        );
1214        assert!(near(r.pv, 5.0));
1215    }
1216
1217    #[test]
1218    fn zero_hundred_earns_nothing_while_in_progress() {
1219        let r = one(
1220            EarningRule::ZeroHundred,
1221            DeliverableStatus::InProgress,
1222            Some(90),
1223        );
1224        assert!(near(r.ev, 0.0));
1225    }
1226
1227    #[test]
1228    fn zero_hundred_earns_full_budget_when_complete() {
1229        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1230        assert!(near(r.ev, 100.0));
1231    }
1232
1233    #[test]
1234    fn default_rule_is_zero_hundred_when_unmapped() {
1235        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1236        let g = graph(vec![deliverable("a", 100.0, &[])]);
1237        let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1238        let r = report(&b, &g, &st, &HashMap::new(), &HashMap::new(), 5);
1239        assert!(near(r.ev, 0.0));
1240    }
1241
1242    #[test]
1243    fn fifty_fifty_earns_half_when_in_progress() {
1244        let r = one(EarningRule::FiftyFifty, DeliverableStatus::InProgress, None);
1245        assert!(near(r.ev, 50.0));
1246    }
1247
1248    #[test]
1249    fn fifty_fifty_earns_half_when_any_percent_reported() {
1250        let r = one(EarningRule::FiftyFifty, DeliverableStatus::Ready, Some(10));
1251        assert!(near(r.ev, 50.0));
1252    }
1253
1254    #[test]
1255    fn fifty_fifty_earns_full_when_complete() {
1256        let r = one(EarningRule::FiftyFifty, DeliverableStatus::Complete, None);
1257        assert!(near(r.ev, 100.0));
1258    }
1259
1260    #[test]
1261    fn weighted_earns_the_reported_percent() {
1262        let r = one(
1263            EarningRule::Weighted,
1264            DeliverableStatus::InProgress,
1265            Some(30),
1266        );
1267        assert!(near(r.ev, 30.0));
1268    }
1269
1270    #[test]
1271    fn weighted_earns_nothing_without_a_report() {
1272        let r = one(EarningRule::Weighted, DeliverableStatus::InProgress, None);
1273        assert!(near(r.ev, 0.0));
1274    }
1275
1276    #[test]
1277    fn weighted_ignores_reported_percent_once_complete() {
1278        let r = one(EarningRule::Weighted, DeliverableStatus::Complete, Some(40));
1279        assert!(near(r.ev, 100.0));
1280    }
1281
1282    #[test]
1283    fn failed_deliverable_earns_nothing_under_zero_hundred() {
1284        let r = one(
1285            EarningRule::ZeroHundred,
1286            DeliverableStatus::Failed { reason: "x".into() },
1287            None,
1288        );
1289        assert!(near(r.ev, 0.0));
1290    }
1291
1292    fn ac_for(actual: Option<f32>, leased: f32, rate: Option<f64>) -> f32 {
1293        let mut br = row("a", 0.0, 10.0, 100.0);
1294        br.cost_rate = rate.unwrap_or(1.0) as f32;
1295        let b = baseline(vec![br]);
1296        let g = graph(vec![deliverable("a", 100.0, &[])]);
1297        let actuals = HashMap::from([(
1298            "a".to_string(),
1299            Actuals {
1300                actual_hours: actual,
1301                leased_hours: leased,
1302                ..Actuals::default()
1303            },
1304        )]);
1305        report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5).ac
1306    }
1307
1308    #[test]
1309    fn ac_prefers_reported_hours_over_leased() {
1310        assert!(near(ac_for(Some(7.0), 3.0, None), 7.0));
1311    }
1312
1313    #[test]
1314    fn ac_falls_back_to_leased_hours() {
1315        assert!(near(ac_for(None, 3.0, None), 3.0));
1316    }
1317
1318    #[test]
1319    fn ac_is_scaled_by_cost_rate() {
1320        assert!(near(ac_for(Some(4.0), 0.0, Some(2.5)), 10.0));
1321    }
1322
1323    #[test]
1324    fn baseline_budget_is_effort_times_cost_rate() {
1325        let mut d = deliverable("a", 10.0, &[]);
1326        d.metadata = serde_json::json!({ "cost_rate": 3.0 });
1327        let g = graph(vec![d]);
1328        let cpm = compute_cpm(&g).unwrap();
1329        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1330        assert!(near(b.bac, 30.0));
1331    }
1332
1333    #[test]
1334    fn baseline_defaults_cost_rate_to_one() {
1335        let g = graph(vec![deliverable("a", 10.0, &[])]);
1336        let cpm = compute_cpm(&g).unwrap();
1337        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1338        assert!(near(b.bac, 10.0));
1339    }
1340
1341    #[test]
1342    fn baseline_excludes_synthetic_endpoints() {
1343        let g = graph(vec![deliverable("a", 10.0, &[])]);
1344        let cpm = compute_cpm(&g).unwrap();
1345        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1346        assert_eq!(b.rows.len(), 1);
1347    }
1348
1349    #[test]
1350    fn baseline_freezes_cpm_start_and_finish() {
1351        let g = graph(vec![
1352            deliverable("a", 10.0, &[]),
1353            deliverable("b", 5.0, &["a"]),
1354        ]);
1355        let cpm = compute_cpm(&g).unwrap();
1356        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1357        let rb = b.rows.iter().find(|r| r.id == "b").unwrap();
1358        assert!(near(rb.es, 10.0) && near(rb.ef, 15.0));
1359    }
1360
1361    #[test]
1362    fn baseline_records_finish_hours() {
1363        let g = graph(vec![
1364            deliverable("a", 10.0, &[]),
1365            deliverable("b", 5.0, &["a"]),
1366        ]);
1367        let cpm = compute_cpm(&g).unwrap();
1368        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1369        assert!(near(b.finish_hours, 15.0));
1370    }
1371
1372    #[test]
1373    fn baseline_rejects_negative_cost_rate() {
1374        let mut d = deliverable("a", 10.0, &[]);
1375        d.metadata = serde_json::json!({ "cost_rate": -1.0 });
1376        let g = graph(vec![d]);
1377        let cpm = compute_cpm(&g).unwrap();
1378        let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
1379        assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1380    }
1381
1382    #[test]
1383    fn baseline_rejects_non_numeric_cost_rate() {
1384        let mut d = deliverable("a", 10.0, &[]);
1385        d.metadata = serde_json::json!({ "cost_rate": "free" });
1386        let g = graph(vec![d]);
1387        let cpm = compute_cpm(&g).unwrap();
1388        let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
1389        assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1390    }
1391
1392    #[test]
1393    fn baseline_rejects_invalid_calendar() {
1394        let g = graph(vec![deliverable("a", 10.0, &[])]);
1395        let cpm = compute_cpm(&g).unwrap();
1396        let c = Calendar {
1397            hours_per_day: 0.0,
1398            ..Calendar::default()
1399        };
1400        let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), Some(c), 1);
1401        assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1402    }
1403
1404    #[test]
1405    fn budget_in_ev_report_is_baseline_budget() {
1406        let r = textbook();
1407        assert!(near(r.bac, 100.0));
1408    }
1409
1410    #[test]
1411    fn textbook_pv() {
1412        assert!(near(textbook().pv, 50.0));
1413    }
1414
1415    #[test]
1416    fn textbook_ev() {
1417        assert!(near(textbook().ev, 40.0));
1418    }
1419
1420    #[test]
1421    fn textbook_ac() {
1422        assert!(near(textbook().ac, 48.0));
1423    }
1424
1425    #[test]
1426    fn textbook_sv() {
1427        assert!(near(textbook().sv, -10.0));
1428    }
1429
1430    #[test]
1431    fn textbook_cv() {
1432        assert!(near(textbook().cv, -8.0));
1433    }
1434
1435    #[test]
1436    fn textbook_spi() {
1437        assert!(near(textbook().spi.unwrap(), 0.8));
1438    }
1439
1440    #[test]
1441    fn textbook_cpi() {
1442        assert!(near(textbook().cpi.unwrap(), 0.8333));
1443    }
1444
1445    #[test]
1446    fn textbook_eac() {
1447        assert!(near(textbook().eac.unwrap(), 120.0));
1448    }
1449
1450    #[test]
1451    fn textbook_etc() {
1452        assert!(near(textbook().etc.unwrap(), 72.0));
1453    }
1454
1455    #[test]
1456    fn textbook_vac() {
1457        assert!(near(textbook().vac.unwrap(), -20.0));
1458    }
1459
1460    #[test]
1461    fn textbook_tcpi() {
1462        assert!(near(textbook().tcpi.unwrap(), 60.0 / 52.0));
1463    }
1464
1465    #[test]
1466    fn textbook_has_no_undefined_fields() {
1467        assert!(textbook().undefined.is_empty());
1468    }
1469
1470    fn undefined_reason(r: &EvReport, field: &str) -> Option<String> {
1471        r.undefined
1472            .iter()
1473            .find(|u| u.field == field)
1474            .map(|u| u.reason.clone())
1475    }
1476
1477    #[test]
1478    fn spi_is_null_when_pv_is_zero() {
1479        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1480        let g = graph(vec![deliverable("a", 100.0, &[])]);
1481        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 0);
1482        assert!(r.spi.is_none());
1483    }
1484
1485    #[test]
1486    fn spi_zero_pv_reason_is_explained() {
1487        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1488        let g = graph(vec![deliverable("a", 100.0, &[])]);
1489        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 0);
1490        assert_eq!(
1491            undefined_reason(&r, "spi").as_deref(),
1492            Some("PV is 0 before any work was planned")
1493        );
1494    }
1495
1496    #[test]
1497    fn cpi_is_null_when_ac_is_zero() {
1498        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1499        assert!(r.cpi.is_none());
1500    }
1501
1502    #[test]
1503    fn cpi_zero_ac_reason_is_explained() {
1504        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1505        assert_eq!(
1506            undefined_reason(&r, "cpi").as_deref(),
1507            Some("AC is 0, no cost recorded yet")
1508        );
1509    }
1510
1511    #[test]
1512    fn eac_is_null_when_cpi_is_null() {
1513        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1514        assert!(r.eac.is_none());
1515    }
1516
1517    #[test]
1518    fn dependents_of_eac_are_null_and_explained() {
1519        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1520        assert!(r.etc.is_none() && r.vac.is_none());
1521        assert!(undefined_reason(&r, "etc").is_some() && undefined_reason(&r, "vac").is_some());
1522    }
1523
1524    #[test]
1525    fn eac_is_null_when_cpi_is_zero() {
1526        // Spent 10h, earned nothing: CPI = 0, EAC would divide by zero.
1527        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1528        let g = graph(vec![deliverable("a", 100.0, &[])]);
1529        let actuals = HashMap::from([(
1530            "a".to_string(),
1531            Actuals {
1532                actual_hours: Some(10.0),
1533                ..Actuals::default()
1534            },
1535        )]);
1536        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1537        assert!(r.eac.is_none());
1538    }
1539
1540    #[test]
1541    fn tcpi_is_null_when_bac_equals_ac() {
1542        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1543        let g = graph(vec![deliverable("a", 100.0, &[])]);
1544        let actuals = HashMap::from([(
1545            "a".to_string(),
1546            Actuals {
1547                actual_hours: Some(100.0),
1548                ..Actuals::default()
1549            },
1550        )]);
1551        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1552        assert!(r.tcpi.is_none());
1553    }
1554
1555    #[test]
1556    fn tcpi_zero_denominator_reason_is_explained() {
1557        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1558        let g = graph(vec![deliverable("a", 100.0, &[])]);
1559        let actuals = HashMap::from([(
1560            "a".to_string(),
1561            Actuals {
1562                actual_hours: Some(100.0),
1563                ..Actuals::default()
1564            },
1565        )]);
1566        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1567        assert_eq!(
1568            undefined_reason(&r, "tcpi").as_deref(),
1569            Some("BAC - AC is 0, no budget remains")
1570        );
1571    }
1572
1573    #[test]
1574    fn empty_baseline_yields_all_finite_values() {
1575        let b = baseline(vec![]);
1576        let g = graph(vec![]);
1577        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1578        assert!(r.pv.is_finite() && r.ev.is_finite() && r.ac.is_finite());
1579    }
1580
1581    #[test]
1582    fn rows_report_per_deliverable_ev() {
1583        let r = textbook();
1584        let a = r.rows.iter().find(|x| x.id == "a").unwrap();
1585        assert!(near(a.ev, 40.0));
1586    }
1587
1588    #[test]
1589    fn rows_report_status() {
1590        let r = textbook();
1591        let a = r.rows.iter().find(|x| x.id == "a").unwrap();
1592        assert_eq!(a.status, DeliverableStatus::InProgress);
1593    }
1594
1595    #[test]
1596    fn missing_status_defaults_to_pending() {
1597        let r = textbook();
1598        let b = r.rows.iter().find(|x| x.id == "b").unwrap();
1599        assert_eq!(b.status, DeliverableStatus::Pending);
1600    }
1601
1602    #[test]
1603    fn critical_float_consumed_is_forecast_overrun() {
1604        // Baseline finish 5h, current CPM finish 12h.
1605        let mut b = baseline(vec![row("a", 0.0, 5.0, 12.0)]);
1606        b.finish_hours = 5.0;
1607        let g = graph(vec![deliverable("a", 12.0, &[])]);
1608        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 1);
1609        assert!(near(r.critical_float_consumed_hours, 7.0));
1610    }
1611
1612    #[test]
1613    fn critical_float_consumed_never_negative() {
1614        let mut b = baseline(vec![row("a", 0.0, 20.0, 12.0)]);
1615        b.finish_hours = 20.0;
1616        let g = graph(vec![deliverable("a", 12.0, &[])]);
1617        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 1);
1618        assert!(near(r.critical_float_consumed_hours, 0.0));
1619    }
1620
1621    fn snap(spi: f32, cpi: f32) -> EvSummary {
1622        EvSummary {
1623            baseline_number: 1,
1624            spi: Some(spi),
1625            cpi: Some(cpi),
1626            backfilled: false,
1627        }
1628    }
1629
1630    fn alerts_with(prev: &[EvSummary]) -> Vec<String> {
1631        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1632        let g = graph(vec![deliverable("a", 100.0, &[])]);
1633        let as_of = b.start + Duration::hours(5);
1634        compute_ev(
1635            &b,
1636            &g,
1637            &HashMap::new(),
1638            &HashMap::new(),
1639            &HashMap::new(),
1640            as_of,
1641            prev,
1642        )
1643        .unwrap()
1644        .alerts
1645    }
1646
1647    #[test]
1648    fn spi_alert_fires_when_two_latest_snapshots_are_below_threshold() {
1649        let a = alerts_with(&[snap(0.95, 1.0), snap(0.8, 1.0), snap(0.85, 1.0)]);
1650        assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
1651    }
1652
1653    #[test]
1654    fn alert_does_not_fire_on_a_single_low_snapshot() {
1655        assert!(alerts_with(&[snap(0.95, 1.0), snap(0.8, 1.0)]).is_empty());
1656    }
1657
1658    #[test]
1659    fn alert_does_not_fire_with_one_snapshot() {
1660        assert!(alerts_with(&[snap(0.5, 0.5)]).is_empty());
1661    }
1662
1663    #[test]
1664    fn cpi_alert_fires_when_two_latest_snapshots_are_below_threshold() {
1665        let a = alerts_with(&[snap(1.0, 0.7), snap(1.0, 0.89)]);
1666        assert_eq!(a, vec!["CPI_BELOW_0_9".to_string()]);
1667    }
1668
1669    #[test]
1670    fn alert_ignores_snapshots_with_undefined_metric() {
1671        let none = EvSummary {
1672            baseline_number: 1,
1673            spi: None,
1674            cpi: None,
1675            backfilled: false,
1676        };
1677        assert!(alerts_with(&[snap(0.5, 0.5), none]).is_empty());
1678    }
1679
1680    #[test]
1681    fn report_is_independent_of_map_insertion_order() {
1682        let b = baseline(vec![row("a", 0.0, 10.0, 50.0), row("b", 0.0, 10.0, 50.0)]);
1683        let g = graph(vec![
1684            deliverable("a", 50.0, &[]),
1685            deliverable("b", 50.0, &[]),
1686        ]);
1687        let act = |h: f32| Actuals {
1688            actual_hours: Some(h),
1689            ..Actuals::default()
1690        };
1691        let fwd = HashMap::from([("a".to_string(), act(3.0)), ("b".to_string(), act(4.0))]);
1692        let mut rev = HashMap::new();
1693        rev.insert("b".to_string(), act(4.0));
1694        rev.insert("a".to_string(), act(3.0));
1695        let e = HashMap::new();
1696        assert_eq!(
1697            report(&b, &g, &e, &e2(), &fwd, 5),
1698            report(&b, &g, &e, &e2(), &rev, 5)
1699        );
1700    }
1701
1702    fn e2() -> HashMap<String, EarningRule> {
1703        HashMap::new()
1704    }
1705
1706    #[test]
1707    fn removed_deliverable_keeps_baselined_cost_rate() {
1708        let mut br = row("a", 0.0, 10.0, 100.0);
1709        br.cost_rate = 2.0;
1710        let b = baseline(vec![br]);
1711        let g = graph(vec![]);
1712        let actuals = HashMap::from([(
1713            "a".to_string(),
1714            Actuals {
1715                actual_hours: Some(5.0),
1716                ..Actuals::default()
1717            },
1718        )]);
1719        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1720        assert!(near(r.ac, 10.0));
1721    }
1722
1723    #[test]
1724    fn rate_edit_after_baseline_does_not_change_ac() {
1725        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1726        let mut d = deliverable("a", 100.0, &[]);
1727        d.metadata = serde_json::json!({ "cost_rate": 9.0 });
1728        let actuals = HashMap::from([(
1729            "a".to_string(),
1730            Actuals {
1731                actual_hours: Some(5.0),
1732                ..Actuals::default()
1733            },
1734        )]);
1735        let r = report(
1736            &b,
1737            &graph(vec![d]),
1738            &HashMap::new(),
1739            &HashMap::new(),
1740            &actuals,
1741            5,
1742        );
1743        assert!(near(r.ac, 5.0));
1744    }
1745
1746    #[test]
1747    fn invalid_rate_added_after_baseline_does_not_fail_compute() {
1748        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1749        let mut d = deliverable("a", 100.0, &[]);
1750        d.metadata = serde_json::json!({ "cost_rate": -4.0 });
1751        let r = compute_ev(
1752            &b,
1753            &graph(vec![d]),
1754            &HashMap::new(),
1755            &HashMap::new(),
1756            &HashMap::new(),
1757            b.start,
1758            &[],
1759        );
1760        assert!(r.is_ok());
1761    }
1762
1763    #[test]
1764    fn baseline_captures_cost_rate_per_row() {
1765        let mut d = deliverable("a", 10.0, &[]);
1766        d.metadata = serde_json::json!({ "cost_rate": 3.0 });
1767        let g = graph(vec![d]);
1768        let cpm = compute_cpm(&g).unwrap();
1769        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1770        assert!(near(b.rows[0].cost_rate, 3.0));
1771    }
1772
1773    #[test]
1774    fn deliverable_added_after_baseline_is_excluded_and_listed() {
1775        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1776        let g = graph(vec![
1777            deliverable("z", 40.0, &[]),
1778            deliverable("a", 100.0, &[]),
1779            deliverable("m", 40.0, &[]),
1780        ]);
1781        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1782        assert_eq!(
1783            r.excluded_unbaselined,
1784            vec!["m".to_string(), "z".to_string()]
1785        );
1786    }
1787
1788    #[test]
1789    fn unbaselined_deliverable_adds_nothing_to_bac() {
1790        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1791        let g = graph(vec![
1792            deliverable("a", 100.0, &[]),
1793            deliverable("z", 40.0, &[]),
1794        ]);
1795        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1796        assert!(near(r.bac, 100.0));
1797    }
1798
1799    #[test]
1800    fn negative_reported_hours_fall_back_to_leased() {
1801        assert!(near(ac_for(Some(-2.0), 3.0, None), 3.0));
1802    }
1803
1804    #[test]
1805    fn non_finite_reported_hours_fall_back_to_leased() {
1806        assert!(near(ac_for(Some(f32::NAN), 3.0, None), 3.0));
1807    }
1808
1809    #[test]
1810    fn as_of_before_start_gives_zero_pv() {
1811        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1812        let g = graph(vec![deliverable("a", 100.0, &[])]);
1813        let r = report(
1814            &b,
1815            &g,
1816            &HashMap::new(),
1817            &HashMap::new(),
1818            &HashMap::new(),
1819            -5,
1820        );
1821        assert!(near(r.pv, 0.0));
1822    }
1823
1824    #[test]
1825    fn as_of_before_start_leaves_spi_null_with_reason() {
1826        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1827        let g = graph(vec![deliverable("a", 100.0, &[])]);
1828        let r = report(
1829            &b,
1830            &g,
1831            &HashMap::new(),
1832            &HashMap::new(),
1833            &HashMap::new(),
1834            -5,
1835        );
1836        assert!(r.spi.is_none() && undefined_reason(&r, "spi").is_some());
1837    }
1838
1839    #[test]
1840    fn pv_is_flat_over_a_weekend_on_a_working_calendar() {
1841        // Task spans 40 working hours (a full work week); Fri 2026-01-09 00:00 + 8h = 40h elapsed.
1842        let mut b = baseline(vec![row("a", 0.0, 80.0, 100.0)]);
1843        b.calendar = Some(Calendar::default());
1844        let g = graph(vec![deliverable("a", 100.0, &[])]);
1845        let at_fri_end = compute_ev(
1846            &b,
1847            &g,
1848            &HashMap::new(),
1849            &HashMap::new(),
1850            &HashMap::new(),
1851            at(2026, 1, 9, 12),
1852            &[],
1853        )
1854        .unwrap();
1855        let mon = compute_ev(
1856            &b,
1857            &g,
1858            &HashMap::new(),
1859            &HashMap::new(),
1860            &HashMap::new(),
1861            at(2026, 1, 12, 0),
1862            &[],
1863        )
1864        .unwrap();
1865        assert!(near(at_fri_end.pv, mon.pv));
1866    }
1867
1868    #[test]
1869    fn eac_reason_when_cpi_is_zero_is_explained() {
1870        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1871        let g = graph(vec![deliverable("a", 100.0, &[])]);
1872        let actuals = HashMap::from([(
1873            "a".to_string(),
1874            Actuals {
1875                actual_hours: Some(10.0),
1876                ..Actuals::default()
1877            },
1878        )]);
1879        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1880        assert_eq!(
1881            undefined_reason(&r, "eac").as_deref(),
1882            Some("CPI is 0, nothing earned for the cost spent")
1883        );
1884    }
1885
1886    #[test]
1887    fn tcpi_is_negative_when_ac_exceeds_bac() {
1888        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1889        let g = graph(vec![deliverable("a", 100.0, &[])]);
1890        let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1891        let rules = HashMap::from([("a".to_string(), EarningRule::Weighted)]);
1892        let actuals = HashMap::from([(
1893            "a".to_string(),
1894            Actuals {
1895                earned_pct: Some(50),
1896                actual_hours: Some(150.0),
1897                ..Actuals::default()
1898            },
1899        )]);
1900        let r = report(&b, &g, &st, &rules, &actuals, 5);
1901        assert!(near(r.tcpi.unwrap(), -1.0));
1902    }
1903
1904    #[test]
1905    fn f32_overflow_in_a_ratio_becomes_undefined() {
1906        let b = baseline(vec![
1907            row("a", 0.0, 10.0, 3.0e38),
1908            row("b", 0.0, 10.0, 3.0e38),
1909        ]);
1910        let g = graph(vec![deliverable("a", 1.0, &[]), deliverable("b", 1.0, &[])]);
1911        let st = statuses(&[("a", DeliverableStatus::Complete)]);
1912        let actuals = HashMap::from([(
1913            "a".to_string(),
1914            Actuals {
1915                actual_hours: Some(1.0e-30),
1916                ..Actuals::default()
1917            },
1918        )]);
1919        let r = report(&b, &g, &st, &HashMap::new(), &actuals, 5);
1920        assert!(r.cpi.is_none() && undefined_reason(&r, "cpi").is_some());
1921    }
1922
1923    #[test]
1924    fn alert_needs_both_of_the_two_previous_snapshots() {
1925        // Oldest is low, but the latest two are (low, healthy): no alert.
1926        assert!(alerts_with(&[snap(0.5, 1.0), snap(0.5, 1.0), snap(1.0, 1.0)]).is_empty());
1927    }
1928
1929    #[test]
1930    fn elapsed_matches_naive_minute_loop_over_random_spans() {
1931        let mut seed: u64 = 0x5eed_1234_abcd_ef01;
1932        let mut next = move |n: u64| {
1933            seed = seed
1934                .wrapping_mul(6364136223846793005)
1935                .wrapping_add(1442695040888963407);
1936            (seed >> 33) % n
1937        };
1938        let names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
1939        let mut mismatches = Vec::new();
1940        for case in 0..60 {
1941            let start = at(2026, 1, 3, 0) + Duration::minutes(next(60 * 24 * 14) as i64);
1942            let span = Duration::minutes(next(60 * 24 * 40) as i64 + 1);
1943            let off = next(2881) as i32 - 1440;
1944            let n_days = 1 + next(7) as usize;
1945            let first = next(7) as usize;
1946            let workdays: Vec<String> = (0..n_days)
1947                .map(|i| names[(first + i) % 7].to_string())
1948                .collect();
1949            let hpd = [8.0_f32, 6.5, 24.0][next(3) as usize];
1950            let c = Calendar {
1951                hours_per_day: hpd,
1952                workdays: workdays.clone(),
1953                utc_offset_minutes: off,
1954            };
1955            let got = elapsed_hours(start, start + span, Some(&c)).unwrap();
1956            let mut naive = 0u64;
1957            let window = (f64::from(hpd) * 60.0) as i64;
1958            let mut m = start;
1959            let end = start + span;
1960            while m < end {
1961                let local = m + Duration::minutes(i64::from(off));
1962                let name = names[local.weekday().num_days_from_monday() as usize];
1963                let mins = i64::from(local.hour()) * 60 + i64::from(local.minute());
1964                if workdays.iter().any(|w| w == name) && mins < window {
1965                    naive += 1;
1966                }
1967                m += Duration::minutes(1);
1968            }
1969            let want = naive as f32 / 60.0;
1970            if (got - want).abs() > 1e-2 {
1971                mismatches.push((case, got, want));
1972            }
1973        }
1974        assert!(mismatches.is_empty(), "{mismatches:?}");
1975    }
1976
1977    // ── P5 Task 3: snapshot support ─────────────────────────────────────
1978
1979    #[test]
1980    fn trend_alerts_fire_spi_when_two_latest_are_low() {
1981        let a = trend_alerts(&[snap(0.8, 1.0), snap(0.85, 1.0)], 1);
1982        assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
1983    }
1984
1985    #[test]
1986    fn baseline_rejects_budget_total_beyond_f32() {
1987        let heavy = |id: &str| {
1988            let mut d = deliverable(id, 1.0, &[]);
1989            d.metadata = serde_json::json!({ "cost_rate": 3.0e38 });
1990            d
1991        };
1992        let g = graph(vec![heavy("a"), heavy("b")]);
1993        let cpm = compute_cpm(&g).unwrap();
1994        let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
1995        assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1996    }
1997
1998    #[test]
1999    fn ev_rejects_actual_cost_beyond_f32() {
2000        let mut br = row("a", 0.0, 10.0, 100.0);
2001        br.cost_rate = 3.0e38;
2002        let b = baseline(vec![br]);
2003        let g = graph(vec![deliverable("a", 100.0, &[])]);
2004        let actuals = HashMap::from([(
2005            "a".to_string(),
2006            Actuals {
2007                actual_hours: Some(1.0e6),
2008                ..Actuals::default()
2009            },
2010        )]);
2011        let as_of = b.start + Duration::hours(5);
2012        let e = compute_ev(
2013            &b,
2014            &g,
2015            &HashMap::new(),
2016            &HashMap::new(),
2017            &actuals,
2018            as_of,
2019            &[],
2020        );
2021        assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
2022    }
2023
2024    #[test]
2025    fn calendar_rejects_unknown_fields() {
2026        let c = serde_json::from_value::<Calendar>(serde_json::json!({ "hours": 8 }));
2027        assert!(c.is_err());
2028    }
2029
2030    fn summary_of(r: &EvReport) -> SnapshotSummary {
2031        SnapshotSummary::from_report(r, r.as_of)
2032    }
2033
2034    #[test]
2035    fn snapshot_summary_carries_report_spi() {
2036        let r = textbook();
2037        assert_eq!(summary_of(&r).spi, r.spi);
2038    }
2039
2040    #[test]
2041    fn snapshot_markdown_starts_with_header_row() {
2042        let md = render_snapshots_markdown(&[summary_of(&textbook())]);
2043        assert!(md.starts_with("| date | PV | EV | AC | SPI | CPI | EAC |\n"));
2044    }
2045
2046    #[test]
2047    fn snapshot_markdown_renders_one_row_per_snapshot() {
2048        let s = summary_of(&textbook());
2049        let md = render_snapshots_markdown(&[s.clone(), s]);
2050        assert_eq!(md.lines().count(), 4);
2051    }
2052
2053    #[test]
2054    fn snapshot_markdown_row_shows_textbook_values() {
2055        let md = render_snapshots_markdown(&[summary_of(&textbook())]);
2056        assert_eq!(
2057            md.lines().nth(2),
2058            Some("| 2026-01-05T10:00Z | 50.00 | 40.00 | 48.00 | 0.80 | 0.83 | 120.00 |")
2059        );
2060    }
2061
2062    #[test]
2063    fn snapshot_markdown_renders_undefined_ratio_as_na() {
2064        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Pending, None);
2065        let md = render_snapshots_markdown(&[summary_of(&r)]);
2066        assert!(
2067            md.lines()
2068                .nth(2)
2069                .is_some_and(|l| l.ends_with("| n/a | n/a |"))
2070        );
2071    }
2072
2073    #[test]
2074    fn alerts_ignore_a_backfilled_new_reading() {
2075        let mut backfilled = snap(0.5, 1.0);
2076        backfilled.backfilled = true;
2077        assert!(trend_alerts(&[snap(0.5, 1.0), backfilled], 1).is_empty());
2078    }
2079
2080    #[test]
2081    fn snapshot_more_than_an_hour_behind_its_taken_at_is_backfilled() {
2082        let r = textbook();
2083        assert!(SnapshotSummary::from_report(&r, r.as_of + Duration::minutes(61)).backfilled);
2084    }
2085
2086    #[test]
2087    fn snapshot_within_an_hour_of_its_taken_at_is_not_backfilled() {
2088        let r = textbook();
2089        assert!(!SnapshotSummary::from_report(&r, r.as_of + Duration::minutes(60)).backfilled);
2090    }
2091
2092    #[test]
2093    fn removed_row_earns_its_frozen_percent() {
2094        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2095        let actuals = HashMap::from([(
2096            "a".to_string(),
2097            Actuals {
2098                frozen_pct: Some(40),
2099                ..Actuals::default()
2100            },
2101        )]);
2102        let r = report(&b, &graph(vec![]), &HashMap::new(), &e2(), &actuals, 5);
2103        assert!(near(r.ev, 40.0));
2104    }
2105
2106    #[test]
2107    fn removed_row_without_frozen_percent_earns_nothing() {
2108        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2109        let st = statuses(&[("a", DeliverableStatus::Complete)]);
2110        let r = report(&b, &graph(vec![]), &st, &e2(), &HashMap::new(), 5);
2111        assert!(near(r.ev, 0.0));
2112    }
2113
2114    #[test]
2115    fn unbaselined_actuals_count_in_ac_at_rate_one() {
2116        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2117        let g = graph(vec![
2118            deliverable("a", 100.0, &[]),
2119            deliverable("n", 5.0, &[]),
2120        ]);
2121        let actuals = HashMap::from([(
2122            "n".to_string(),
2123            Actuals {
2124                actual_hours: Some(3.0),
2125                ..Actuals::default()
2126            },
2127        )]);
2128        let r = report(&b, &g, &HashMap::new(), &e2(), &actuals, 5);
2129        assert!(near(r.ac, 3.0));
2130    }
2131
2132    #[test]
2133    fn frozen_pct_of_a_complete_deliverable_is_100() {
2134        let pct = frozen_pct(EarningRule::ZeroHundred, &DeliverableStatus::Complete, None);
2135        assert_eq!(pct, 100);
2136    }
2137
2138    #[test]
2139    fn alerts_ignore_snapshots_from_previous_baseline() {
2140        assert!(trend_alerts(&[snap(0.5, 0.5), snap(0.5, 0.5)], 2).is_empty());
2141    }
2142
2143    #[test]
2144    fn alerts_use_the_two_latest_snapshots_of_the_current_baseline() {
2145        let mut current = snap(0.5, 1.0);
2146        current.baseline_number = 2;
2147        let a = trend_alerts(&[current.clone(), snap(1.0, 1.0), current], 2);
2148        assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
2149    }
2150
2151    #[test]
2152    fn snapshot_summary_explains_undefined_ratios() {
2153        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Pending, None);
2154        assert_eq!(summary_of(&r).undefined, r.undefined);
2155    }
2156
2157    #[test]
2158    fn summary_without_undefined_field_decodes() {
2159        let mut v = serde_json::to_value(summary_of(&textbook())).unwrap();
2160        v.as_object_mut().unwrap().remove("undefined");
2161        assert!(serde_json::from_value::<SnapshotSummary>(v).is_ok());
2162    }
2163}