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