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