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 >= MIN_MEASURABLE_PV_HOURS).then(|| ev / pv),
607        if pv == 0.0 {
608            "PV is 0 before any work was planned"
609        } else {
610            "PV below 0.01 h, too little planned work to measure (for example right after a \
611             baseline)"
612        },
613    );
614    let cpi = ratio(
615        "cpi",
616        (ac != 0.0).then(|| ev / ac),
617        "AC is 0, no cost recorded yet",
618    );
619    let eac_reason = if cpi.is_none() {
620        "CPI is undefined"
621    } else {
622        "CPI is 0, nothing earned for the cost spent"
623    };
624    let eac = ratio(
625        "eac",
626        cpi.filter(|c| *c != 0.0).map(|c| bac / c),
627        eac_reason,
628    );
629    let etc = ratio("etc", eac.map(|e| e - ac), "EAC is undefined");
630    let vac = ratio("vac", eac.map(|e| bac - e), "EAC is undefined");
631    let tcpi = ratio(
632        "tcpi",
633        ((bac - ac).abs() > 1e-9 * bac.max(1.0)).then(|| (bac - ev) / (bac - ac)),
634        "BAC - AC is 0, no budget remains",
635    );
636    let out = |v: Option<f64>| v.map(|x| x as f32);
637
638    let cpm = compute_cpm(graph)?;
639    let critical_float = (finish_of(&cpm) - baseline.finish_hours).max(0.0);
640
641    let alerts = trend_alerts(previous_snapshots, baseline.number);
642    for (field, v) in [
643        ("pv", pv),
644        ("ev", ev),
645        ("ac", ac),
646        ("sv", ev - pv),
647        ("cv", ev - ac),
648    ] {
649        if !(v as f32).is_finite() {
650            return Err(PlannerError::InvalidGraph {
651                reason: format!(
652                    "earned-value total {field} exceeds the f32 range (check cost_rate and \
653                     reported hours)"
654                ),
655            });
656        }
657    }
658
659    let report = EvReport {
660        as_of,
661        baseline_number: baseline.number,
662        backfilled: baseline.backfilled,
663        bac: baseline.bac,
664        pv: pv as f32,
665        ev: ev as f32,
666        ac: ac as f32,
667        sv: (ev - pv) as f32,
668        cv: (ev - ac) as f32,
669        spi: out(spi),
670        cpi: out(cpi),
671        eac: out(eac),
672        etc: out(etc),
673        vac: out(vac),
674        tcpi: out(tcpi),
675        undefined,
676        rows,
677        critical_float_consumed_hours: critical_float,
678        alerts,
679        excluded_unbaselined: {
680            let known: std::collections::HashSet<&str> =
681                baseline.rows.iter().map(|r| r.id.as_str()).collect();
682            let mut v: Vec<String> = graph
683                .deliverables
684                .iter()
685                .filter(|d| d.id != START_ID && d.id != FINISH_ID && !known.contains(d.id.as_str()))
686                .map(|d| d.id.clone())
687                .collect();
688            v.sort();
689            v
690        },
691        archived: false,
692        active_plan: None,
693    };
694    Ok(report)
695}
696
697/// Longest accepted `plan.baseline` reason, in characters (the evidence
698/// limit, [`crate::plan::MAX_EVIDENCE_CHARS`]).
699pub const MAX_BASELINE_REASON_CHARS: usize = crate::plan::MAX_EVIDENCE_CHARS;
700
701/// Most snapshots a `plan.snapshot` export lists (the newest ones).
702pub const SNAPSHOT_EXPORT_LIMIT: usize = 100;
703
704/// Planned value below this many hours is not yet measurable: an SPI computed
705/// from it would only amplify rounding noise (for example right after a
706/// baseline), so it is reported as undefined instead.
707pub const MIN_MEASURABLE_PV_HOURS: f64 = 0.01;
708
709/// Take a baseline of a plan (`plan.baseline`).
710#[derive(Debug, Clone, PartialEq)]
711pub struct BaselineRequest {
712    pub plan_id: PlanId,
713    /// Baseline start; defaults to the planner clock's now.
714    pub start: Option<DateTime<Utc>>,
715    /// Working-time calendar; `None` counts wall-clock hours.
716    pub calendar: Option<Calendar>,
717    /// Why the plan is re-baselined. Required (non-blank) when the plan
718    /// already has a baseline; at most [`MAX_BASELINE_REASON_CHARS`].
719    pub reason: Option<String>,
720    /// Retroactive baseline start: the baseline's `start` is this instant
721    /// instead of now and the baseline is marked `backfilled`. Must not be
722    /// in the future or before the plan's creation time.
723    pub backfill_from: Option<DateTime<Utc>>,
724}
725
726impl BaselineRequest {
727    pub fn new(plan_id: PlanId) -> Self {
728        Self {
729            plan_id,
730            start: None,
731            calendar: None,
732            reason: None,
733            backfill_from: None,
734        }
735    }
736
737    pub fn with_start(mut self, start: DateTime<Utc>) -> Self {
738        self.start = Some(start);
739        self
740    }
741
742    pub fn with_backfill_from(mut self, backfill_from: DateTime<Utc>) -> Self {
743        self.backfill_from = Some(backfill_from);
744        self
745    }
746
747    pub fn with_calendar(mut self, calendar: Calendar) -> Self {
748        self.calendar = Some(calendar);
749        self
750    }
751
752    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
753        self.reason = Some(reason.into());
754        self
755    }
756}
757
758/// A stored baseline, as `plan.baseline` reports it.
759#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
760pub struct BaselineOutcome {
761    pub plan_id: String,
762    pub baseline_number: u32,
763    pub start: DateTime<Utc>,
764    pub calendar: Option<Calendar>,
765    pub bac: f32,
766    pub finish_hours: f32,
767    pub deliverable_count: usize,
768    pub reason: Option<String>,
769    /// True when `start` came from `backfill_from`.
770    #[serde(default)]
771    pub backfilled: bool,
772}
773
774/// The reason recorded on a baseline an execution-starting call took
775/// automatically because the plan had none.
776pub const FIRST_EXECUTION_REASON: &str = "first_execution";
777
778/// How execution treats a plan that has no earned-value baseline yet.
779/// Server-configured once at startup with `CPM_BASELINE_MODE`.
780#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
781pub enum BaselineMode {
782    /// Execution takes the first baseline itself, from the current graph as
783    /// of the call, in the same write transaction (the default).
784    #[default]
785    Auto,
786    /// Execution is refused with `BASELINE_REQUIRED` until the operator
787    /// calls `plan.baseline`.
788    Refuse,
789}
790
791/// A baseline an execution-starting call took automatically. Reported in
792/// `baseline_taken` on the call's response; absent when one already existed.
793#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
794pub struct BaselineTaken {
795    /// Number within the plan; `1` for the first baseline.
796    pub baseline_number: u32,
797    /// The baseline's PV start (the call time).
798    pub as_of: DateTime<Utc>,
799    /// Always [`FIRST_EXECUTION_REASON`].
800    pub reason: String,
801}
802
803/// The latest baseline summary `plan.status` reports; `null` before the
804/// first baseline.
805#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
806pub struct PlanBaseline {
807    pub baseline_number: u32,
808    pub as_of: DateTime<Utc>,
809}
810
811/// Export format of `plan.snapshot`.
812#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
813#[serde(rename_all = "snake_case")]
814pub enum SnapshotFormat {
815    /// `export` is the list of snapshot summaries, oldest first.
816    #[default]
817    Json,
818    /// `export` is a Markdown table ([`render_snapshots_markdown`]).
819    Markdown,
820}
821
822/// Append an EV snapshot (`plan.snapshot`).
823#[derive(Debug, Clone, PartialEq)]
824pub struct SnapshotRequest {
825    pub plan_id: PlanId,
826    /// Status date of the reading's PV; defaults to the planner clock's now.
827    /// EV and AC are always the progress recorded when the snapshot is
828    /// taken, so a past `as_of` (over an hour back) marks it
829    /// [`SnapshotSummary::backfilled`].
830    pub as_of: Option<DateTime<Utc>>,
831    pub format: SnapshotFormat,
832}
833
834impl SnapshotRequest {
835    pub fn new(plan_id: PlanId) -> Self {
836        Self {
837            plan_id,
838            as_of: None,
839            format: SnapshotFormat::default(),
840        }
841    }
842
843    pub fn with_as_of(mut self, as_of: DateTime<Utc>) -> Self {
844        self.as_of = Some(as_of);
845        self
846    }
847
848    pub fn with_format(mut self, format: SnapshotFormat) -> Self {
849        self.format = format;
850        self
851    }
852}
853
854/// The snapshot just taken plus the rendered history.
855#[derive(Debug, Clone, PartialEq, Serialize)]
856pub struct SnapshotOutcome {
857    pub summary: SnapshotSummary,
858    pub format: SnapshotFormat,
859    /// The newest [`SNAPSHOT_EXPORT_LIMIT`] snapshots by `as_of` (ties by
860    /// `taken_at`), oldest first: a list of summaries for `json`, a Markdown
861    /// table string for `markdown`. A backfilled snapshot sits at its
862    /// `as_of` position, and one older than all of those is stored and
863    /// counted but not listed.
864    pub export: serde_json::Value,
865    /// Snapshots stored for the plan, this one included.
866    pub snapshot_count: usize,
867}
868
869/// Trend alerts over snapshot ratios in `as_of` order: `SPI_BELOW_0_9` /
870/// `CPI_BELOW_0_9` when the metric is defined and below 0.9 on both of the
871/// two latest snapshots taken against `baseline_number` (snapshots of
872/// earlier baselines are ignored). Backfilled snapshots
873/// ([`SnapshotSummary::backfilled`]) are ignored too, as the new reading
874/// and as the previous one: their EV and AC are today's, not the past
875/// date's. Fewer than two remaining snapshots raise nothing.
876pub fn trend_alerts(snapshots: &[EvSummary], baseline_number: u32) -> Vec<String> {
877    let mut alerts = Vec::new();
878    let current: Vec<&EvSummary> = snapshots
879        .iter()
880        .filter(|s| s.baseline_number == baseline_number && !s.backfilled)
881        .collect();
882    if let [.., a, b] = current.as_slice() {
883        let low = |v: Option<f32>| v.is_some_and(|x| x < 0.9);
884        if low(a.spi) && low(b.spi) {
885            alerts.push("SPI_BELOW_0_9".to_string());
886        }
887        if low(a.cpi) && low(b.cpi) {
888            alerts.push("CPI_BELOW_0_9".to_string());
889        }
890    }
891    alerts
892}
893
894/// The stored summary of one EV snapshot: the report's totals and ratios
895/// (an undefined ratio stays `None`, serialised as `null`) and the alerts
896/// raised when it was taken.
897#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
898pub struct SnapshotSummary {
899    pub taken_at: DateTime<Utc>,
900    pub as_of: DateTime<Utc>,
901    pub baseline_number: u32,
902    pub bac: f32,
903    pub pv: f32,
904    pub ev: f32,
905    pub ac: f32,
906    pub sv: f32,
907    pub cv: f32,
908    pub spi: Option<f32>,
909    pub cpi: Option<f32>,
910    pub eac: Option<f32>,
911    pub etc: Option<f32>,
912    pub vac: Option<f32>,
913    pub tcpi: Option<f32>,
914    /// Why each `null` ratio is undefined (as in [`EvReport::undefined`]).
915    /// Absent in summaries stored before this field existed.
916    #[serde(default)]
917    pub undefined: Vec<Undefined>,
918    pub alerts: Vec<String>,
919    /// `as_of` is more than [`BACKFILL_TOLERANCE_HOURS`] before `taken_at`.
920    /// EV and AC are measured when the snapshot is taken, so a backfilled
921    /// snapshot pairs a past PV with present progress; trend alerts ignore
922    /// it. Absent (false) in summaries stored before this field existed.
923    #[serde(default)]
924    pub backfilled: bool,
925}
926
927/// How far `as_of` may lag `taken_at` before a snapshot counts as
928/// backfilled ([`SnapshotSummary::backfilled`]).
929pub const BACKFILL_TOLERANCE_HOURS: i64 = 1;
930
931/// Whether a reading at `as_of`, taken at `taken_at`, is backfilled.
932pub fn is_backfilled(as_of: DateTime<Utc>, taken_at: DateTime<Utc>) -> bool {
933    as_of < taken_at - Duration::hours(BACKFILL_TOLERANCE_HOURS)
934}
935
936impl SnapshotSummary {
937    /// Summarise `report`, taken at `taken_at`.
938    pub fn from_report(report: &EvReport, taken_at: DateTime<Utc>) -> Self {
939        Self {
940            taken_at,
941            as_of: report.as_of,
942            baseline_number: report.baseline_number,
943            bac: report.bac,
944            pv: report.pv,
945            ev: report.ev,
946            ac: report.ac,
947            sv: report.sv,
948            cv: report.cv,
949            spi: report.spi,
950            cpi: report.cpi,
951            eac: report.eac,
952            etc: report.etc,
953            vac: report.vac,
954            tcpi: report.tcpi,
955            undefined: report.undefined.clone(),
956            alerts: report.alerts.clone(),
957            backfilled: is_backfilled(report.as_of, taken_at),
958        }
959    }
960
961    /// The ratios trend alerts look at.
962    pub fn ratios(&self) -> EvSummary {
963        EvSummary {
964            baseline_number: self.baseline_number,
965            spi: self.spi,
966            cpi: self.cpi,
967            backfilled: self.backfilled,
968        }
969    }
970}
971
972/// Render snapshots (in the given order) as a Markdown table with columns
973/// date (the `as_of` instant, UTC, to the minute), PV, EV, AC, SPI, CPI
974/// and EAC. Values have two decimals; an undefined ratio is `n/a`.
975pub fn render_snapshots_markdown(snapshots: &[SnapshotSummary]) -> String {
976    let ratio = |v: Option<f32>| v.map_or_else(|| "n/a".to_string(), |x| format!("{x:.2}"));
977    let mut out = String::from(
978        "| date | PV | EV | AC | SPI | CPI | EAC |\n|---|---:|---:|---:|---:|---:|---:|\n",
979    );
980    for s in snapshots {
981        out.push_str(&format!(
982            "| {} | {:.2} | {:.2} | {:.2} | {} | {} | {} |\n",
983            s.as_of.format("%Y-%m-%dT%H:%MZ"),
984            s.pv,
985            s.ev,
986            s.ac,
987            ratio(s.spi),
988            ratio(s.cpi),
989            ratio(s.eac),
990        ));
991    }
992    out
993}
994
995#[cfg(test)]
996mod tests {
997    use super::*;
998    use chrono::{TimeZone, Timelike};
999
1000    const EPS: f32 = 1e-3;
1001
1002    fn at(y: i32, m: u32, d: u32, h: u32) -> DateTime<Utc> {
1003        Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap()
1004    }
1005
1006    fn near(a: f32, b: f32) -> bool {
1007        (a - b).abs() < EPS
1008    }
1009
1010    fn deliverable(id: &str, effort: f32, prereqs: &[&str]) -> Deliverable {
1011        Deliverable {
1012            id: id.to_string(),
1013            owned_files: vec![],
1014            prerequisites: prereqs
1015                .iter()
1016                .map(|p| crate::plan::Prerequisite::Id((*p).to_string()))
1017                .collect(),
1018            estimated_effort_hours: Some(effort),
1019            duration_hours: None,
1020            estimate: None,
1021            metadata: serde_json::Value::Null,
1022            milestone: false,
1023            earning_rule: None,
1024        }
1025    }
1026
1027    fn graph(ds: Vec<Deliverable>) -> PlanGraph {
1028        PlanGraph {
1029            deliverables: ds,
1030            max_chained_dispatch: None,
1031        }
1032    }
1033
1034    fn row(id: &str, es: f32, ef: f32, budget: f32) -> BaselineRow {
1035        BaselineRow {
1036            id: id.to_string(),
1037            es,
1038            ef,
1039            budget,
1040            cost_rate: 1.0,
1041        }
1042    }
1043
1044    fn baseline(rows: Vec<BaselineRow>) -> Baseline {
1045        let bac = rows.iter().map(|r| r.budget).sum();
1046        let finish_hours = rows.iter().map(|r| r.ef).fold(0.0, f32::max);
1047        Baseline {
1048            number: 1,
1049            start: at(2026, 1, 5, 0),
1050            calendar: None,
1051            rows,
1052            bac,
1053            finish_hours,
1054            backfilled: false,
1055        }
1056    }
1057
1058    fn statuses(pairs: &[(&str, DeliverableStatus)]) -> HashMap<String, DeliverableStatus> {
1059        pairs
1060            .iter()
1061            .map(|(k, v)| ((*k).to_string(), v.clone()))
1062            .collect()
1063    }
1064
1065    fn report(
1066        b: &Baseline,
1067        g: &PlanGraph,
1068        st: &HashMap<String, DeliverableStatus>,
1069        rules: &HashMap<String, EarningRule>,
1070        actuals: &HashMap<String, Actuals>,
1071        hours: i64,
1072    ) -> EvReport {
1073        let as_of = b.start + Duration::hours(hours);
1074        compute_ev(b, g, st, rules, actuals, as_of, &[]).unwrap()
1075    }
1076
1077    fn one(rule: EarningRule, status: DeliverableStatus, pct: Option<u8>) -> EvReport {
1078        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1079        let g = graph(vec![deliverable("a", 100.0, &[])]);
1080        let st = statuses(&[("a", status)]);
1081        let rules = HashMap::from([("a".to_string(), rule)]);
1082        let actuals = HashMap::from([(
1083            "a".to_string(),
1084            Actuals {
1085                earned_pct: pct,
1086                ..Actuals::default()
1087            },
1088        )]);
1089        report(&b, &g, &st, &rules, &actuals, 5)
1090    }
1091
1092    /// BAC 100 (two rows), PV 50, EV 40, AC 48.
1093    fn textbook() -> EvReport {
1094        let b = baseline(vec![row("a", 0.0, 10.0, 50.0), row("b", 10.0, 20.0, 50.0)]);
1095        let g = graph(vec![
1096            deliverable("a", 50.0, &[]),
1097            deliverable("b", 50.0, &["a"]),
1098        ]);
1099        let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1100        let rules = HashMap::from([("a".to_string(), EarningRule::Weighted)]);
1101        let actuals = HashMap::from([(
1102            "a".to_string(),
1103            Actuals {
1104                earned_pct: Some(80),
1105                actual_hours: Some(48.0),
1106                ..Actuals::default()
1107            },
1108        )]);
1109        report(&b, &g, &st, &rules, &actuals, 10)
1110    }
1111
1112    #[test]
1113    fn wall_clock_elapsed_is_plain_hours() {
1114        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 6, 6), None).unwrap();
1115        assert!(near(h, 30.0));
1116    }
1117
1118    #[test]
1119    fn elapsed_before_start_is_zero() {
1120        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 4, 0), None).unwrap();
1121        assert!(near(h, 0.0));
1122    }
1123
1124    #[test]
1125    fn calendar_skips_weekends() {
1126        // Fri 2026-01-09 00:00 to Mon 2026-01-12 00:00: only Friday's 8h.
1127        let c = Calendar::default();
1128        let h = elapsed_hours(at(2026, 1, 9, 0), at(2026, 1, 12, 0), Some(&c)).unwrap();
1129        assert!(near(h, 8.0));
1130    }
1131
1132    #[test]
1133    fn calendar_counts_partial_day() {
1134        // Monday 03:00 to 05:30 is 2.5 working hours.
1135        let c = Calendar::default();
1136        let start = at(2026, 1, 5, 3);
1137        let h = elapsed_hours(start, start + Duration::minutes(150), Some(&c)).unwrap();
1138        assert!(near(h, 2.5));
1139    }
1140
1141    #[test]
1142    fn calendar_ignores_hours_after_the_working_window() {
1143        let c = Calendar::default();
1144        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 5, 20), Some(&c)).unwrap();
1145        assert!(near(h, 8.0));
1146    }
1147
1148    #[test]
1149    fn calendar_over_many_weeks_counts_whole_weeks() {
1150        // 10 weeks from a Monday: 10 * 5 * 8 working hours.
1151        let c = Calendar::default();
1152        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 3, 16, 0), Some(&c)).unwrap();
1153        assert!(near(h, 400.0));
1154    }
1155
1156    #[test]
1157    fn calendar_utc_offset_shifts_the_local_day() {
1158        // +120 min: Mon 2026-01-05 22:00 UTC is Tue 00:00 local, so 2h later is 2 work hours.
1159        let c = Calendar {
1160            utc_offset_minutes: 120,
1161            ..Calendar::default()
1162        };
1163        let h = elapsed_hours(at(2026, 1, 5, 22), at(2026, 1, 6, 0), Some(&c)).unwrap();
1164        assert!(near(h, 2.0));
1165    }
1166
1167    #[test]
1168    fn calendar_custom_workdays_count_only_listed_days() {
1169        let c = Calendar {
1170            workdays: vec!["sat".into()],
1171            ..Calendar::default()
1172        };
1173        let h = elapsed_hours(at(2026, 1, 5, 0), at(2026, 1, 12, 0), Some(&c)).unwrap();
1174        assert!(near(h, 8.0));
1175    }
1176
1177    fn invalid(c: Calendar) -> bool {
1178        matches!(c.validate(), Err(PlannerError::InvalidGraph { .. }))
1179    }
1180
1181    #[test]
1182    fn calendar_rejects_zero_hours_per_day() {
1183        assert!(invalid(Calendar {
1184            hours_per_day: 0.0,
1185            ..Calendar::default()
1186        }));
1187    }
1188
1189    #[test]
1190    fn calendar_rejects_more_than_24_hours_per_day() {
1191        assert!(invalid(Calendar {
1192            hours_per_day: 24.5,
1193            ..Calendar::default()
1194        }));
1195    }
1196
1197    #[test]
1198    fn calendar_rejects_nan_hours_per_day() {
1199        assert!(invalid(Calendar {
1200            hours_per_day: f32::NAN,
1201            ..Calendar::default()
1202        }));
1203    }
1204
1205    #[test]
1206    fn calendar_rejects_out_of_range_offset() {
1207        assert!(invalid(Calendar {
1208            utc_offset_minutes: 1441,
1209            ..Calendar::default()
1210        }));
1211    }
1212
1213    #[test]
1214    fn calendar_rejects_empty_workdays() {
1215        assert!(invalid(Calendar {
1216            workdays: vec![],
1217            ..Calendar::default()
1218        }));
1219    }
1220
1221    #[test]
1222    fn calendar_rejects_unknown_weekday_name() {
1223        assert!(invalid(Calendar {
1224            workdays: vec!["monday".into()],
1225            ..Calendar::default()
1226        }));
1227    }
1228
1229    #[test]
1230    fn calendar_accepts_a_24_hour_day() {
1231        assert!(
1232            Calendar {
1233                hours_per_day: 24.0,
1234                ..Calendar::default()
1235            }
1236            .validate()
1237            .is_ok()
1238        );
1239    }
1240
1241    #[test]
1242    fn pv_interpolates_linearly_inside_a_task() {
1243        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1244        let g = graph(vec![deliverable("a", 100.0, &[])]);
1245        let r = report(
1246            &b,
1247            &g,
1248            &HashMap::new(),
1249            &HashMap::new(),
1250            &HashMap::new(),
1251            15,
1252        );
1253        assert!(near(r.pv, 50.0));
1254    }
1255
1256    #[test]
1257    fn pv_is_zero_before_task_start() {
1258        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1259        let g = graph(vec![deliverable("a", 100.0, &[])]);
1260        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1261        assert!(near(r.pv, 0.0));
1262    }
1263
1264    #[test]
1265    fn pv_caps_at_budget_after_task_finish() {
1266        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1267        let g = graph(vec![deliverable("a", 100.0, &[])]);
1268        let r = report(
1269            &b,
1270            &g,
1271            &HashMap::new(),
1272            &HashMap::new(),
1273            &HashMap::new(),
1274            99,
1275        );
1276        assert!(near(r.pv, 100.0));
1277    }
1278
1279    #[test]
1280    fn pv_of_zero_length_task_is_zero_before_its_start() {
1281        let b = baseline(vec![row("m", 10.0, 10.0, 5.0)]);
1282        let g = graph(vec![deliverable("m", 5.0, &[])]);
1283        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 9);
1284        assert!(near(r.pv, 0.0));
1285    }
1286
1287    #[test]
1288    fn pv_of_zero_length_task_steps_to_budget_at_its_start() {
1289        let b = baseline(vec![row("m", 10.0, 10.0, 5.0)]);
1290        let g = graph(vec![deliverable("m", 5.0, &[])]);
1291        let r = report(
1292            &b,
1293            &g,
1294            &HashMap::new(),
1295            &HashMap::new(),
1296            &HashMap::new(),
1297            10,
1298        );
1299        assert!(near(r.pv, 5.0));
1300    }
1301
1302    #[test]
1303    fn zero_hundred_earns_nothing_while_in_progress() {
1304        let r = one(
1305            EarningRule::ZeroHundred,
1306            DeliverableStatus::InProgress,
1307            Some(90),
1308        );
1309        assert!(near(r.ev, 0.0));
1310    }
1311
1312    #[test]
1313    fn zero_hundred_earns_full_budget_when_complete() {
1314        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1315        assert!(near(r.ev, 100.0));
1316    }
1317
1318    #[test]
1319    fn default_rule_is_zero_hundred_when_unmapped() {
1320        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1321        let g = graph(vec![deliverable("a", 100.0, &[])]);
1322        let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1323        let r = report(&b, &g, &st, &HashMap::new(), &HashMap::new(), 5);
1324        assert!(near(r.ev, 0.0));
1325    }
1326
1327    #[test]
1328    fn fifty_fifty_earns_half_when_in_progress() {
1329        let r = one(EarningRule::FiftyFifty, DeliverableStatus::InProgress, None);
1330        assert!(near(r.ev, 50.0));
1331    }
1332
1333    #[test]
1334    fn fifty_fifty_earns_half_when_any_percent_reported() {
1335        let r = one(EarningRule::FiftyFifty, DeliverableStatus::Ready, Some(10));
1336        assert!(near(r.ev, 50.0));
1337    }
1338
1339    #[test]
1340    fn fifty_fifty_earns_full_when_complete() {
1341        let r = one(EarningRule::FiftyFifty, DeliverableStatus::Complete, None);
1342        assert!(near(r.ev, 100.0));
1343    }
1344
1345    #[test]
1346    fn weighted_earns_the_reported_percent() {
1347        let r = one(
1348            EarningRule::Weighted,
1349            DeliverableStatus::InProgress,
1350            Some(30),
1351        );
1352        assert!(near(r.ev, 30.0));
1353    }
1354
1355    #[test]
1356    fn weighted_earns_nothing_without_a_report() {
1357        let r = one(EarningRule::Weighted, DeliverableStatus::InProgress, None);
1358        assert!(near(r.ev, 0.0));
1359    }
1360
1361    #[test]
1362    fn weighted_ignores_reported_percent_once_complete() {
1363        let r = one(EarningRule::Weighted, DeliverableStatus::Complete, Some(40));
1364        assert!(near(r.ev, 100.0));
1365    }
1366
1367    #[test]
1368    fn failed_deliverable_earns_nothing_under_zero_hundred() {
1369        let r = one(
1370            EarningRule::ZeroHundred,
1371            DeliverableStatus::Failed { reason: "x".into() },
1372            None,
1373        );
1374        assert!(near(r.ev, 0.0));
1375    }
1376
1377    fn ac_for(actual: Option<f32>, leased: f32, rate: Option<f64>) -> f32 {
1378        let mut br = row("a", 0.0, 10.0, 100.0);
1379        br.cost_rate = rate.unwrap_or(1.0) as f32;
1380        let b = baseline(vec![br]);
1381        let g = graph(vec![deliverable("a", 100.0, &[])]);
1382        let actuals = HashMap::from([(
1383            "a".to_string(),
1384            Actuals {
1385                actual_hours: actual,
1386                leased_hours: leased,
1387                ..Actuals::default()
1388            },
1389        )]);
1390        report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5).ac
1391    }
1392
1393    #[test]
1394    fn ac_prefers_reported_hours_over_leased() {
1395        assert!(near(ac_for(Some(7.0), 3.0, None), 7.0));
1396    }
1397
1398    #[test]
1399    fn ac_falls_back_to_leased_hours() {
1400        assert!(near(ac_for(None, 3.0, None), 3.0));
1401    }
1402
1403    #[test]
1404    fn ac_is_scaled_by_cost_rate() {
1405        assert!(near(ac_for(Some(4.0), 0.0, Some(2.5)), 10.0));
1406    }
1407
1408    #[test]
1409    fn baseline_budget_is_effort_times_cost_rate() {
1410        let mut d = deliverable("a", 10.0, &[]);
1411        d.metadata = serde_json::json!({ "cost_rate": 3.0 });
1412        let g = graph(vec![d]);
1413        let cpm = compute_cpm(&g).unwrap();
1414        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1415        assert!(near(b.bac, 30.0));
1416    }
1417
1418    #[test]
1419    fn baseline_defaults_cost_rate_to_one() {
1420        let g = graph(vec![deliverable("a", 10.0, &[])]);
1421        let cpm = compute_cpm(&g).unwrap();
1422        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1423        assert!(near(b.bac, 10.0));
1424    }
1425
1426    #[test]
1427    fn baseline_excludes_synthetic_endpoints() {
1428        let g = graph(vec![deliverable("a", 10.0, &[])]);
1429        let cpm = compute_cpm(&g).unwrap();
1430        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1431        assert_eq!(b.rows.len(), 1);
1432    }
1433
1434    #[test]
1435    fn baseline_freezes_cpm_start_and_finish() {
1436        let g = graph(vec![
1437            deliverable("a", 10.0, &[]),
1438            deliverable("b", 5.0, &["a"]),
1439        ]);
1440        let cpm = compute_cpm(&g).unwrap();
1441        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1442        let rb = b.rows.iter().find(|r| r.id == "b").unwrap();
1443        assert!(near(rb.es, 10.0) && near(rb.ef, 15.0));
1444    }
1445
1446    #[test]
1447    fn baseline_records_finish_hours() {
1448        let g = graph(vec![
1449            deliverable("a", 10.0, &[]),
1450            deliverable("b", 5.0, &["a"]),
1451        ]);
1452        let cpm = compute_cpm(&g).unwrap();
1453        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1454        assert!(near(b.finish_hours, 15.0));
1455    }
1456
1457    #[test]
1458    fn baseline_rejects_negative_cost_rate() {
1459        let mut d = deliverable("a", 10.0, &[]);
1460        d.metadata = serde_json::json!({ "cost_rate": -1.0 });
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_non_numeric_cost_rate() {
1469        let mut d = deliverable("a", 10.0, &[]);
1470        d.metadata = serde_json::json!({ "cost_rate": "free" });
1471        let g = graph(vec![d]);
1472        let cpm = compute_cpm(&g).unwrap();
1473        let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
1474        assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1475    }
1476
1477    #[test]
1478    fn baseline_rejects_invalid_calendar() {
1479        let g = graph(vec![deliverable("a", 10.0, &[])]);
1480        let cpm = compute_cpm(&g).unwrap();
1481        let c = Calendar {
1482            hours_per_day: 0.0,
1483            ..Calendar::default()
1484        };
1485        let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), Some(c), 1);
1486        assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
1487    }
1488
1489    #[test]
1490    fn budget_in_ev_report_is_baseline_budget() {
1491        let r = textbook();
1492        assert!(near(r.bac, 100.0));
1493    }
1494
1495    #[test]
1496    fn textbook_pv() {
1497        assert!(near(textbook().pv, 50.0));
1498    }
1499
1500    #[test]
1501    fn textbook_ev() {
1502        assert!(near(textbook().ev, 40.0));
1503    }
1504
1505    #[test]
1506    fn textbook_ac() {
1507        assert!(near(textbook().ac, 48.0));
1508    }
1509
1510    #[test]
1511    fn textbook_sv() {
1512        assert!(near(textbook().sv, -10.0));
1513    }
1514
1515    #[test]
1516    fn textbook_cv() {
1517        assert!(near(textbook().cv, -8.0));
1518    }
1519
1520    #[test]
1521    fn textbook_spi() {
1522        assert!(near(textbook().spi.unwrap(), 0.8));
1523    }
1524
1525    #[test]
1526    fn textbook_cpi() {
1527        assert!(near(textbook().cpi.unwrap(), 0.8333));
1528    }
1529
1530    #[test]
1531    fn textbook_eac() {
1532        assert!(near(textbook().eac.unwrap(), 120.0));
1533    }
1534
1535    #[test]
1536    fn textbook_etc() {
1537        assert!(near(textbook().etc.unwrap(), 72.0));
1538    }
1539
1540    #[test]
1541    fn textbook_vac() {
1542        assert!(near(textbook().vac.unwrap(), -20.0));
1543    }
1544
1545    #[test]
1546    fn textbook_tcpi() {
1547        assert!(near(textbook().tcpi.unwrap(), 60.0 / 52.0));
1548    }
1549
1550    #[test]
1551    fn textbook_has_no_undefined_fields() {
1552        assert!(textbook().undefined.is_empty());
1553    }
1554
1555    fn undefined_reason(r: &EvReport, field: &str) -> Option<String> {
1556        r.undefined
1557            .iter()
1558            .find(|u| u.field == field)
1559            .map(|u| u.reason.clone())
1560    }
1561
1562    #[test]
1563    fn spi_is_null_when_pv_is_zero() {
1564        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1565        let g = graph(vec![deliverable("a", 100.0, &[])]);
1566        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 0);
1567        assert!(r.spi.is_none());
1568    }
1569
1570    #[test]
1571    fn spi_zero_pv_reason_is_explained() {
1572        let b = baseline(vec![row("a", 10.0, 20.0, 100.0)]);
1573        let g = graph(vec![deliverable("a", 100.0, &[])]);
1574        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 0);
1575        assert_eq!(
1576            undefined_reason(&r, "spi").as_deref(),
1577            Some("PV is 0 before any work was planned")
1578        );
1579    }
1580
1581    /// PV 0.000249, EV 72: below [`MIN_MEASURABLE_PV_HOURS`].
1582    fn tiny_pv_report() -> EvReport {
1583        let b = baseline(vec![
1584            row("a", 0.0, 1.0, 0.000249),
1585            row("b", 100.0, 200.0, 72.0),
1586        ]);
1587        let g = graph(vec![
1588            deliverable("a", 0.000249, &[]),
1589            deliverable("b", 72.0, &[]),
1590        ]);
1591        let st = statuses(&[
1592            ("a", DeliverableStatus::Pending),
1593            ("b", DeliverableStatus::Complete),
1594        ]);
1595        let rules = HashMap::from([
1596            ("a".to_string(), EarningRule::Weighted),
1597            ("b".to_string(), EarningRule::Weighted),
1598        ]);
1599        report(&b, &g, &st, &rules, &HashMap::new(), 1)
1600    }
1601
1602    #[test]
1603    fn spi_is_null_when_pv_is_below_min_measurable() {
1604        assert!(tiny_pv_report().spi.is_none());
1605    }
1606
1607    #[test]
1608    fn spi_below_min_measurable_reason_is_explained() {
1609        assert_eq!(
1610            undefined_reason(&tiny_pv_report(), "spi").as_deref(),
1611            Some(
1612                "PV below 0.01 h, too little planned work to measure (for example right after a \
1613                 baseline)"
1614            )
1615        );
1616    }
1617
1618    #[test]
1619    fn spi_is_computed_when_pv_meets_min_measurable() {
1620        let b = baseline(vec![row("a", 0.0, 16.0, 8.0)]);
1621        let g = graph(vec![deliverable("a", 8.0, &[])]);
1622        let st = statuses(&[("a", DeliverableStatus::InProgress)]);
1623        let rules = HashMap::from([("a".to_string(), EarningRule::Weighted)]);
1624        let actuals = HashMap::from([(
1625            "a".to_string(),
1626            Actuals {
1627                earned_pct: Some(50),
1628                ..Actuals::default()
1629            },
1630        )]);
1631        let r = report(&b, &g, &st, &rules, &actuals, 16);
1632        assert_eq!(r.spi, Some(0.5));
1633    }
1634
1635    #[test]
1636    fn cpi_is_null_when_ac_is_zero() {
1637        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1638        assert!(r.cpi.is_none());
1639    }
1640
1641    #[test]
1642    fn cpi_zero_ac_reason_is_explained() {
1643        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1644        assert_eq!(
1645            undefined_reason(&r, "cpi").as_deref(),
1646            Some("AC is 0, no cost recorded yet")
1647        );
1648    }
1649
1650    #[test]
1651    fn eac_is_null_when_cpi_is_null() {
1652        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1653        assert!(r.eac.is_none());
1654    }
1655
1656    #[test]
1657    fn dependents_of_eac_are_null_and_explained() {
1658        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Complete, None);
1659        assert!(r.etc.is_none() && r.vac.is_none());
1660        assert!(undefined_reason(&r, "etc").is_some() && undefined_reason(&r, "vac").is_some());
1661    }
1662
1663    #[test]
1664    fn eac_is_null_when_cpi_is_zero() {
1665        // Spent 10h, earned nothing: CPI = 0, EAC would divide by zero.
1666        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1667        let g = graph(vec![deliverable("a", 100.0, &[])]);
1668        let actuals = HashMap::from([(
1669            "a".to_string(),
1670            Actuals {
1671                actual_hours: Some(10.0),
1672                ..Actuals::default()
1673            },
1674        )]);
1675        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1676        assert!(r.eac.is_none());
1677    }
1678
1679    #[test]
1680    fn tcpi_is_null_when_bac_equals_ac() {
1681        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1682        let g = graph(vec![deliverable("a", 100.0, &[])]);
1683        let actuals = HashMap::from([(
1684            "a".to_string(),
1685            Actuals {
1686                actual_hours: Some(100.0),
1687                ..Actuals::default()
1688            },
1689        )]);
1690        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1691        assert!(r.tcpi.is_none());
1692    }
1693
1694    #[test]
1695    fn tcpi_zero_denominator_reason_is_explained() {
1696        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1697        let g = graph(vec![deliverable("a", 100.0, &[])]);
1698        let actuals = HashMap::from([(
1699            "a".to_string(),
1700            Actuals {
1701                actual_hours: Some(100.0),
1702                ..Actuals::default()
1703            },
1704        )]);
1705        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1706        assert_eq!(
1707            undefined_reason(&r, "tcpi").as_deref(),
1708            Some("BAC - AC is 0, no budget remains")
1709        );
1710    }
1711
1712    #[test]
1713    fn empty_baseline_yields_all_finite_values() {
1714        let b = baseline(vec![]);
1715        let g = graph(vec![]);
1716        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1717        assert!(r.pv.is_finite() && r.ev.is_finite() && r.ac.is_finite());
1718    }
1719
1720    #[test]
1721    fn rows_report_per_deliverable_ev() {
1722        let r = textbook();
1723        let a = r.rows.iter().find(|x| x.id == "a").unwrap();
1724        assert!(near(a.ev, 40.0));
1725    }
1726
1727    #[test]
1728    fn rows_report_status() {
1729        let r = textbook();
1730        let a = r.rows.iter().find(|x| x.id == "a").unwrap();
1731        assert_eq!(a.status, DeliverableStatus::InProgress);
1732    }
1733
1734    #[test]
1735    fn missing_status_defaults_to_pending() {
1736        let r = textbook();
1737        let b = r.rows.iter().find(|x| x.id == "b").unwrap();
1738        assert_eq!(b.status, DeliverableStatus::Pending);
1739    }
1740
1741    #[test]
1742    fn critical_float_consumed_is_forecast_overrun() {
1743        // Baseline finish 5h, current CPM finish 12h.
1744        let mut b = baseline(vec![row("a", 0.0, 5.0, 12.0)]);
1745        b.finish_hours = 5.0;
1746        let g = graph(vec![deliverable("a", 12.0, &[])]);
1747        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 1);
1748        assert!(near(r.critical_float_consumed_hours, 7.0));
1749    }
1750
1751    #[test]
1752    fn critical_float_consumed_never_negative() {
1753        let mut b = baseline(vec![row("a", 0.0, 20.0, 12.0)]);
1754        b.finish_hours = 20.0;
1755        let g = graph(vec![deliverable("a", 12.0, &[])]);
1756        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 1);
1757        assert!(near(r.critical_float_consumed_hours, 0.0));
1758    }
1759
1760    fn snap(spi: f32, cpi: f32) -> EvSummary {
1761        EvSummary {
1762            baseline_number: 1,
1763            spi: Some(spi),
1764            cpi: Some(cpi),
1765            backfilled: false,
1766        }
1767    }
1768
1769    fn alerts_with(prev: &[EvSummary]) -> Vec<String> {
1770        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1771        let g = graph(vec![deliverable("a", 100.0, &[])]);
1772        let as_of = b.start + Duration::hours(5);
1773        compute_ev(
1774            &b,
1775            &g,
1776            &HashMap::new(),
1777            &HashMap::new(),
1778            &HashMap::new(),
1779            as_of,
1780            prev,
1781        )
1782        .unwrap()
1783        .alerts
1784    }
1785
1786    #[test]
1787    fn spi_alert_fires_when_two_latest_snapshots_are_below_threshold() {
1788        let a = alerts_with(&[snap(0.95, 1.0), snap(0.8, 1.0), snap(0.85, 1.0)]);
1789        assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
1790    }
1791
1792    #[test]
1793    fn alert_does_not_fire_on_a_single_low_snapshot() {
1794        assert!(alerts_with(&[snap(0.95, 1.0), snap(0.8, 1.0)]).is_empty());
1795    }
1796
1797    #[test]
1798    fn alert_does_not_fire_with_one_snapshot() {
1799        assert!(alerts_with(&[snap(0.5, 0.5)]).is_empty());
1800    }
1801
1802    #[test]
1803    fn cpi_alert_fires_when_two_latest_snapshots_are_below_threshold() {
1804        let a = alerts_with(&[snap(1.0, 0.7), snap(1.0, 0.89)]);
1805        assert_eq!(a, vec!["CPI_BELOW_0_9".to_string()]);
1806    }
1807
1808    #[test]
1809    fn alert_ignores_snapshots_with_undefined_metric() {
1810        let none = EvSummary {
1811            baseline_number: 1,
1812            spi: None,
1813            cpi: None,
1814            backfilled: false,
1815        };
1816        assert!(alerts_with(&[snap(0.5, 0.5), none]).is_empty());
1817    }
1818
1819    #[test]
1820    fn report_is_independent_of_map_insertion_order() {
1821        let b = baseline(vec![row("a", 0.0, 10.0, 50.0), row("b", 0.0, 10.0, 50.0)]);
1822        let g = graph(vec![
1823            deliverable("a", 50.0, &[]),
1824            deliverable("b", 50.0, &[]),
1825        ]);
1826        let act = |h: f32| Actuals {
1827            actual_hours: Some(h),
1828            ..Actuals::default()
1829        };
1830        let fwd = HashMap::from([("a".to_string(), act(3.0)), ("b".to_string(), act(4.0))]);
1831        let mut rev = HashMap::new();
1832        rev.insert("b".to_string(), act(4.0));
1833        rev.insert("a".to_string(), act(3.0));
1834        let e = HashMap::new();
1835        assert_eq!(
1836            report(&b, &g, &e, &e2(), &fwd, 5),
1837            report(&b, &g, &e, &e2(), &rev, 5)
1838        );
1839    }
1840
1841    fn e2() -> HashMap<String, EarningRule> {
1842        HashMap::new()
1843    }
1844
1845    #[test]
1846    fn removed_deliverable_keeps_baselined_cost_rate() {
1847        let mut br = row("a", 0.0, 10.0, 100.0);
1848        br.cost_rate = 2.0;
1849        let b = baseline(vec![br]);
1850        let g = graph(vec![]);
1851        let actuals = HashMap::from([(
1852            "a".to_string(),
1853            Actuals {
1854                actual_hours: Some(5.0),
1855                ..Actuals::default()
1856            },
1857        )]);
1858        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
1859        assert!(near(r.ac, 10.0));
1860    }
1861
1862    #[test]
1863    fn rate_edit_after_baseline_does_not_change_ac() {
1864        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1865        let mut d = deliverable("a", 100.0, &[]);
1866        d.metadata = serde_json::json!({ "cost_rate": 9.0 });
1867        let actuals = HashMap::from([(
1868            "a".to_string(),
1869            Actuals {
1870                actual_hours: Some(5.0),
1871                ..Actuals::default()
1872            },
1873        )]);
1874        let r = report(
1875            &b,
1876            &graph(vec![d]),
1877            &HashMap::new(),
1878            &HashMap::new(),
1879            &actuals,
1880            5,
1881        );
1882        assert!(near(r.ac, 5.0));
1883    }
1884
1885    #[test]
1886    fn invalid_rate_added_after_baseline_does_not_fail_compute() {
1887        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1888        let mut d = deliverable("a", 100.0, &[]);
1889        d.metadata = serde_json::json!({ "cost_rate": -4.0 });
1890        let r = compute_ev(
1891            &b,
1892            &graph(vec![d]),
1893            &HashMap::new(),
1894            &HashMap::new(),
1895            &HashMap::new(),
1896            b.start,
1897            &[],
1898        );
1899        assert!(r.is_ok());
1900    }
1901
1902    #[test]
1903    fn baseline_captures_cost_rate_per_row() {
1904        let mut d = deliverable("a", 10.0, &[]);
1905        d.metadata = serde_json::json!({ "cost_rate": 3.0 });
1906        let g = graph(vec![d]);
1907        let cpm = compute_cpm(&g).unwrap();
1908        let b = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1).unwrap();
1909        assert!(near(b.rows[0].cost_rate, 3.0));
1910    }
1911
1912    #[test]
1913    fn deliverable_added_after_baseline_is_excluded_and_listed() {
1914        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1915        let g = graph(vec![
1916            deliverable("z", 40.0, &[]),
1917            deliverable("a", 100.0, &[]),
1918            deliverable("m", 40.0, &[]),
1919        ]);
1920        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1921        assert_eq!(
1922            r.excluded_unbaselined,
1923            vec!["m".to_string(), "z".to_string()]
1924        );
1925    }
1926
1927    #[test]
1928    fn unbaselined_deliverable_adds_nothing_to_bac() {
1929        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1930        let g = graph(vec![
1931            deliverable("a", 100.0, &[]),
1932            deliverable("z", 40.0, &[]),
1933        ]);
1934        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &HashMap::new(), 5);
1935        assert!(near(r.bac, 100.0));
1936    }
1937
1938    #[test]
1939    fn negative_reported_hours_fall_back_to_leased() {
1940        assert!(near(ac_for(Some(-2.0), 3.0, None), 3.0));
1941    }
1942
1943    #[test]
1944    fn non_finite_reported_hours_fall_back_to_leased() {
1945        assert!(near(ac_for(Some(f32::NAN), 3.0, None), 3.0));
1946    }
1947
1948    #[test]
1949    fn as_of_before_start_gives_zero_pv() {
1950        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1951        let g = graph(vec![deliverable("a", 100.0, &[])]);
1952        let r = report(
1953            &b,
1954            &g,
1955            &HashMap::new(),
1956            &HashMap::new(),
1957            &HashMap::new(),
1958            -5,
1959        );
1960        assert!(near(r.pv, 0.0));
1961    }
1962
1963    #[test]
1964    fn as_of_before_start_leaves_spi_null_with_reason() {
1965        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
1966        let g = graph(vec![deliverable("a", 100.0, &[])]);
1967        let r = report(
1968            &b,
1969            &g,
1970            &HashMap::new(),
1971            &HashMap::new(),
1972            &HashMap::new(),
1973            -5,
1974        );
1975        assert!(r.spi.is_none() && undefined_reason(&r, "spi").is_some());
1976    }
1977
1978    #[test]
1979    fn pv_is_flat_over_a_weekend_on_a_working_calendar() {
1980        // Task spans 40 working hours (a full work week); Fri 2026-01-09 00:00 + 8h = 40h elapsed.
1981        let mut b = baseline(vec![row("a", 0.0, 80.0, 100.0)]);
1982        b.calendar = Some(Calendar::default());
1983        let g = graph(vec![deliverable("a", 100.0, &[])]);
1984        let at_fri_end = compute_ev(
1985            &b,
1986            &g,
1987            &HashMap::new(),
1988            &HashMap::new(),
1989            &HashMap::new(),
1990            at(2026, 1, 9, 12),
1991            &[],
1992        )
1993        .unwrap();
1994        let mon = compute_ev(
1995            &b,
1996            &g,
1997            &HashMap::new(),
1998            &HashMap::new(),
1999            &HashMap::new(),
2000            at(2026, 1, 12, 0),
2001            &[],
2002        )
2003        .unwrap();
2004        assert!(near(at_fri_end.pv, mon.pv));
2005    }
2006
2007    #[test]
2008    fn eac_reason_when_cpi_is_zero_is_explained() {
2009        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2010        let g = graph(vec![deliverable("a", 100.0, &[])]);
2011        let actuals = HashMap::from([(
2012            "a".to_string(),
2013            Actuals {
2014                actual_hours: Some(10.0),
2015                ..Actuals::default()
2016            },
2017        )]);
2018        let r = report(&b, &g, &HashMap::new(), &HashMap::new(), &actuals, 5);
2019        assert_eq!(
2020            undefined_reason(&r, "eac").as_deref(),
2021            Some("CPI is 0, nothing earned for the cost spent")
2022        );
2023    }
2024
2025    #[test]
2026    fn tcpi_is_negative_when_ac_exceeds_bac() {
2027        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2028        let g = graph(vec![deliverable("a", 100.0, &[])]);
2029        let st = statuses(&[("a", DeliverableStatus::InProgress)]);
2030        let rules = HashMap::from([("a".to_string(), EarningRule::Weighted)]);
2031        let actuals = HashMap::from([(
2032            "a".to_string(),
2033            Actuals {
2034                earned_pct: Some(50),
2035                actual_hours: Some(150.0),
2036                ..Actuals::default()
2037            },
2038        )]);
2039        let r = report(&b, &g, &st, &rules, &actuals, 5);
2040        assert!(near(r.tcpi.unwrap(), -1.0));
2041    }
2042
2043    #[test]
2044    fn f32_overflow_in_a_ratio_becomes_undefined() {
2045        let b = baseline(vec![
2046            row("a", 0.0, 10.0, 3.0e38),
2047            row("b", 0.0, 10.0, 3.0e38),
2048        ]);
2049        let g = graph(vec![deliverable("a", 1.0, &[]), deliverable("b", 1.0, &[])]);
2050        let st = statuses(&[("a", DeliverableStatus::Complete)]);
2051        let actuals = HashMap::from([(
2052            "a".to_string(),
2053            Actuals {
2054                actual_hours: Some(1.0e-30),
2055                ..Actuals::default()
2056            },
2057        )]);
2058        let r = report(&b, &g, &st, &HashMap::new(), &actuals, 5);
2059        assert!(r.cpi.is_none() && undefined_reason(&r, "cpi").is_some());
2060    }
2061
2062    #[test]
2063    fn alert_needs_both_of_the_two_previous_snapshots() {
2064        // Oldest is low, but the latest two are (low, healthy): no alert.
2065        assert!(alerts_with(&[snap(0.5, 1.0), snap(0.5, 1.0), snap(1.0, 1.0)]).is_empty());
2066    }
2067
2068    #[test]
2069    fn elapsed_matches_naive_minute_loop_over_random_spans() {
2070        let mut seed: u64 = 0x5eed_1234_abcd_ef01;
2071        let mut next = move |n: u64| {
2072            seed = seed
2073                .wrapping_mul(6364136223846793005)
2074                .wrapping_add(1442695040888963407);
2075            (seed >> 33) % n
2076        };
2077        let names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
2078        let mut mismatches = Vec::new();
2079        for case in 0..60 {
2080            let start = at(2026, 1, 3, 0) + Duration::minutes(next(60 * 24 * 14) as i64);
2081            let span = Duration::minutes(next(60 * 24 * 40) as i64 + 1);
2082            let off = next(2881) as i32 - 1440;
2083            let n_days = 1 + next(7) as usize;
2084            let first = next(7) as usize;
2085            let workdays: Vec<String> = (0..n_days)
2086                .map(|i| names[(first + i) % 7].to_string())
2087                .collect();
2088            let hpd = [8.0_f32, 6.5, 24.0][next(3) as usize];
2089            let c = Calendar {
2090                hours_per_day: hpd,
2091                workdays: workdays.clone(),
2092                utc_offset_minutes: off,
2093            };
2094            let got = elapsed_hours(start, start + span, Some(&c)).unwrap();
2095            let mut naive = 0u64;
2096            let window = (f64::from(hpd) * 60.0) as i64;
2097            let mut m = start;
2098            let end = start + span;
2099            while m < end {
2100                let local = m + Duration::minutes(i64::from(off));
2101                let name = names[local.weekday().num_days_from_monday() as usize];
2102                let mins = i64::from(local.hour()) * 60 + i64::from(local.minute());
2103                if workdays.iter().any(|w| w == name) && mins < window {
2104                    naive += 1;
2105                }
2106                m += Duration::minutes(1);
2107            }
2108            let want = naive as f32 / 60.0;
2109            if (got - want).abs() > 1e-2 {
2110                mismatches.push((case, got, want));
2111            }
2112        }
2113        assert!(mismatches.is_empty(), "{mismatches:?}");
2114    }
2115
2116    // ── P5 Task 3: snapshot support ─────────────────────────────────────
2117
2118    #[test]
2119    fn trend_alerts_fire_spi_when_two_latest_are_low() {
2120        let a = trend_alerts(&[snap(0.8, 1.0), snap(0.85, 1.0)], 1);
2121        assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
2122    }
2123
2124    #[test]
2125    fn baseline_rejects_budget_total_beyond_f32() {
2126        let heavy = |id: &str| {
2127            let mut d = deliverable(id, 1.0, &[]);
2128            d.metadata = serde_json::json!({ "cost_rate": 3.0e38 });
2129            d
2130        };
2131        let g = graph(vec![heavy("a"), heavy("b")]);
2132        let cpm = compute_cpm(&g).unwrap();
2133        let e = build_baseline(&g, &cpm, at(2026, 1, 5, 0), None, 1);
2134        assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
2135    }
2136
2137    #[test]
2138    fn ev_rejects_actual_cost_beyond_f32() {
2139        let mut br = row("a", 0.0, 10.0, 100.0);
2140        br.cost_rate = 3.0e38;
2141        let b = baseline(vec![br]);
2142        let g = graph(vec![deliverable("a", 100.0, &[])]);
2143        let actuals = HashMap::from([(
2144            "a".to_string(),
2145            Actuals {
2146                actual_hours: Some(1.0e6),
2147                ..Actuals::default()
2148            },
2149        )]);
2150        let as_of = b.start + Duration::hours(5);
2151        let e = compute_ev(
2152            &b,
2153            &g,
2154            &HashMap::new(),
2155            &HashMap::new(),
2156            &actuals,
2157            as_of,
2158            &[],
2159        );
2160        assert!(matches!(e, Err(PlannerError::InvalidGraph { .. })));
2161    }
2162
2163    #[test]
2164    fn calendar_rejects_unknown_fields() {
2165        let c = serde_json::from_value::<Calendar>(serde_json::json!({ "hours": 8 }));
2166        assert!(c.is_err());
2167    }
2168
2169    fn summary_of(r: &EvReport) -> SnapshotSummary {
2170        SnapshotSummary::from_report(r, r.as_of)
2171    }
2172
2173    #[test]
2174    fn snapshot_summary_carries_report_spi() {
2175        let r = textbook();
2176        assert_eq!(summary_of(&r).spi, r.spi);
2177    }
2178
2179    #[test]
2180    fn snapshot_markdown_starts_with_header_row() {
2181        let md = render_snapshots_markdown(&[summary_of(&textbook())]);
2182        assert!(md.starts_with("| date | PV | EV | AC | SPI | CPI | EAC |\n"));
2183    }
2184
2185    #[test]
2186    fn snapshot_markdown_renders_one_row_per_snapshot() {
2187        let s = summary_of(&textbook());
2188        let md = render_snapshots_markdown(&[s.clone(), s]);
2189        assert_eq!(md.lines().count(), 4);
2190    }
2191
2192    #[test]
2193    fn snapshot_markdown_row_shows_textbook_values() {
2194        let md = render_snapshots_markdown(&[summary_of(&textbook())]);
2195        assert_eq!(
2196            md.lines().nth(2),
2197            Some("| 2026-01-05T10:00Z | 50.00 | 40.00 | 48.00 | 0.80 | 0.83 | 120.00 |")
2198        );
2199    }
2200
2201    #[test]
2202    fn snapshot_markdown_renders_undefined_ratio_as_na() {
2203        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Pending, None);
2204        let md = render_snapshots_markdown(&[summary_of(&r)]);
2205        assert!(
2206            md.lines()
2207                .nth(2)
2208                .is_some_and(|l| l.ends_with("| n/a | n/a |"))
2209        );
2210    }
2211
2212    #[test]
2213    fn alerts_ignore_a_backfilled_new_reading() {
2214        let mut backfilled = snap(0.5, 1.0);
2215        backfilled.backfilled = true;
2216        assert!(trend_alerts(&[snap(0.5, 1.0), backfilled], 1).is_empty());
2217    }
2218
2219    #[test]
2220    fn snapshot_more_than_an_hour_behind_its_taken_at_is_backfilled() {
2221        let r = textbook();
2222        assert!(SnapshotSummary::from_report(&r, r.as_of + Duration::minutes(61)).backfilled);
2223    }
2224
2225    #[test]
2226    fn snapshot_within_an_hour_of_its_taken_at_is_not_backfilled() {
2227        let r = textbook();
2228        assert!(!SnapshotSummary::from_report(&r, r.as_of + Duration::minutes(60)).backfilled);
2229    }
2230
2231    #[test]
2232    fn removed_row_earns_its_frozen_percent() {
2233        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2234        let actuals = HashMap::from([(
2235            "a".to_string(),
2236            Actuals {
2237                frozen_pct: Some(40),
2238                ..Actuals::default()
2239            },
2240        )]);
2241        let r = report(&b, &graph(vec![]), &HashMap::new(), &e2(), &actuals, 5);
2242        assert!(near(r.ev, 40.0));
2243    }
2244
2245    #[test]
2246    fn removed_row_without_frozen_percent_earns_nothing() {
2247        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2248        let st = statuses(&[("a", DeliverableStatus::Complete)]);
2249        let r = report(&b, &graph(vec![]), &st, &e2(), &HashMap::new(), 5);
2250        assert!(near(r.ev, 0.0));
2251    }
2252
2253    #[test]
2254    fn unbaselined_actuals_count_in_ac_at_rate_one() {
2255        let b = baseline(vec![row("a", 0.0, 10.0, 100.0)]);
2256        let g = graph(vec![
2257            deliverable("a", 100.0, &[]),
2258            deliverable("n", 5.0, &[]),
2259        ]);
2260        let actuals = HashMap::from([(
2261            "n".to_string(),
2262            Actuals {
2263                actual_hours: Some(3.0),
2264                ..Actuals::default()
2265            },
2266        )]);
2267        let r = report(&b, &g, &HashMap::new(), &e2(), &actuals, 5);
2268        assert!(near(r.ac, 3.0));
2269    }
2270
2271    #[test]
2272    fn frozen_pct_of_a_complete_deliverable_is_100() {
2273        let pct = frozen_pct(EarningRule::ZeroHundred, &DeliverableStatus::Complete, None);
2274        assert_eq!(pct, 100);
2275    }
2276
2277    #[test]
2278    fn alerts_ignore_snapshots_from_previous_baseline() {
2279        assert!(trend_alerts(&[snap(0.5, 0.5), snap(0.5, 0.5)], 2).is_empty());
2280    }
2281
2282    #[test]
2283    fn alerts_use_the_two_latest_snapshots_of_the_current_baseline() {
2284        let mut current = snap(0.5, 1.0);
2285        current.baseline_number = 2;
2286        let a = trend_alerts(&[current.clone(), snap(1.0, 1.0), current], 2);
2287        assert_eq!(a, vec!["SPI_BELOW_0_9".to_string()]);
2288    }
2289
2290    #[test]
2291    fn snapshot_summary_explains_undefined_ratios() {
2292        let r = one(EarningRule::ZeroHundred, DeliverableStatus::Pending, None);
2293        assert_eq!(summary_of(&r).undefined, r.undefined);
2294    }
2295
2296    #[test]
2297    fn summary_without_undefined_field_decodes() {
2298        let mut v = serde_json::to_value(summary_of(&textbook())).unwrap();
2299        v.as_object_mut().unwrap().remove("undefined");
2300        assert!(serde_json::from_value::<SnapshotSummary>(v).is_ok());
2301    }
2302}