Skip to main content

dpm_engine/query/calibration/
forecast.rs

1//! A forecast that applies measured factors to a copy of the plan, on request only.
2
3use super::{CalibrationReport, MAX_APPLIED_RATIO, MIN_SAMPLES, min_applied_ratio};
4use dpm_model::{ActorKind, MAX_CALENDAR_HOURS, Plan, ThreePointEstimate, WorkItem};
5use dpm_schedule::RemainingOptions;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// The factors a calibrated `status` applied to a copy of the plan; the stored plan and its
10/// estimates are unchanged.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct AppliedCalibration {
13    /// Samples a group needed before its measurement was applied.
14    pub min_samples: usize,
15    /// One entry per executor kind that unfinished estimated work resolves to.
16    pub factors: Vec<AppliedFactor>,
17    /// Review wait added after the work of every task still awaiting verification.
18    pub review_delay: AppliedReviewDelay,
19    /// Tasks whose scaled estimate was capped at the largest estimate a plan with calendars may
20    /// state.
21    pub capped: usize,
22}
23
24/// The estimate factor for one executor kind.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct AppliedFactor {
27    /// Kind the work resolves to: its owner's, else its planned executor, else the calendars'
28    /// default executor, else human.
29    pub executor: ActorKind,
30    /// Unfinished estimated tasks of that kind.
31    pub tasks: usize,
32    /// Samples of that kind in the calibration.
33    pub samples: usize,
34    /// Median actual/estimated ratio measured, if any sample exists.
35    pub measured: Option<f64>,
36    /// Factor multiplied into optimistic, most-likely and pessimistic hours; 1 when not applied.
37    pub factor: f64,
38    /// Whether the measured median was applied.
39    pub applied: bool,
40    /// Why the factor is what it is.
41    pub reason: String,
42}
43
44/// The review wait added for tasks still awaiting verification.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct AppliedReviewDelay {
47    /// Kind that reviews: the calendars' verifier, else human.
48    pub verifier: ActorKind,
49    /// Reviews by that kind in the calibration.
50    pub samples: usize,
51    /// Median elapsed review wait measured, if any review exists.
52    pub measured_hours: Option<f64>,
53    /// Elapsed hours added after each such task's work; 0 when not applied.
54    pub hours: f64,
55    /// Whether the measured median was applied.
56    pub applied: bool,
57    /// Why the delay is what it is.
58    pub reason: String,
59}
60
61/// Executor kind the forecast attributes to work.
62fn executor(plan: &Plan, work: &WorkItem) -> ActorKind {
63    work.execution
64        .owner
65        .as_ref()
66        .map(|o| o.kind)
67        .or(work.schedule.executor)
68        .or(plan.calendars.as_ref().map(|c| c.default_executor))
69        .unwrap_or(ActorKind::Human)
70}
71
72/// A copy of the plan with each unfinished task's estimate scaled by its executor kind's measured
73/// median, the review delay to project with, and what was applied.
74pub(crate) fn calibrated_forecast(
75    plan: &Plan,
76    report: &CalibrationReport,
77) -> (Plan, RemainingOptions, AppliedCalibration) {
78    let mut scaled = plan.clone();
79    let mut tasks: BTreeMap<ActorKind, usize> = BTreeMap::new();
80    for work in plan.work_items.values().filter(|w| unfinished_estimate(w)) {
81        let count = tasks.entry(executor(plan, work)).or_default();
82        *count = count.wrapping_add(1);
83    }
84    let factors: Vec<AppliedFactor> = tasks
85        .into_iter()
86        .map(|(kind, tasks)| factor(report, kind, tasks))
87        .collect();
88    let limit = plan.calendars.as_ref().map(|_| MAX_CALENDAR_HOURS);
89    let mut capped = 0_usize;
90    for work in scaled
91        .work_items
92        .values_mut()
93        .filter(|w| unfinished_estimate(w))
94    {
95        let kind = executor(plan, work);
96        let applied = factors
97            .iter()
98            .find(|f| f.executor == kind)
99            .map(|f| f.factor);
100        if let (Some(estimate), Some(factor)) = (work.schedule.estimate.as_mut(), applied)
101            && scale(estimate, factor, limit)
102        {
103            capped = capped.wrapping_add(1);
104        }
105    }
106    let review_delay = review_delay(plan, report);
107    let options = RemainingOptions {
108        review_delay_hours: review_delay.hours,
109    };
110    let applied = AppliedCalibration {
111        min_samples: MIN_SAMPLES,
112        factors,
113        review_delay,
114        capped,
115    };
116    (scaled, options, applied)
117}
118
119fn unfinished_estimate(work: &WorkItem) -> bool {
120    work.is_executable()
121        && work.schedule.estimate.is_some()
122        && !work.execution.status.satisfies_dependency()
123}
124
125/// Scale an estimate; `true` when a bound capped it.
126fn scale(estimate: &mut ThreePointEstimate, factor: f64, limit: Option<f64>) -> bool {
127    let mut capped = false;
128    for hours in [
129        &mut estimate.optimistic_hours,
130        &mut estimate.likely_hours,
131        &mut estimate.pessimistic_hours,
132    ] {
133        *hours *= factor;
134        if let Some(limit) = limit.filter(|limit| *hours > *limit) {
135            *hours = limit;
136            capped = true;
137        }
138    }
139    capped
140}
141
142fn factor(report: &CalibrationReport, executor: ActorKind, tasks: usize) -> AppliedFactor {
143    let group = report
144        .estimates
145        .by_executor
146        .iter()
147        .find(|g| g.executor == executor);
148    let samples = group.map_or(0, |g| g.samples);
149    let measured = group.and_then(|g| g.median);
150    let sufficient = group.is_some_and(|g| g.sufficient);
151    let (factor, applied, reason) = match measured {
152        Some(median) if sufficient && in_band(median, executor) => (
153            median,
154            true,
155            format!(
156                "median actual/estimated ratio of {samples} verified {executor} task(s) applied"
157            ),
158        ),
159        Some(median) if sufficient => (
160            1.0,
161            false,
162            format!(
163                "median actual/estimated ratio {median:.3} of {samples} verified {executor} \
164                 task(s) lies outside {}-{MAX_APPLIED_RATIO} for {executor} work, which points at \
165                 the records rather than the estimates; estimates kept",
166                min_applied_ratio(executor)
167            ),
168        ),
169        _ => (
170            1.0,
171            false,
172            format!(
173                "{samples} verified {executor} sample(s), fewer than the {MIN_SAMPLES} needed; \
174                 estimates kept"
175            ),
176        ),
177    };
178    AppliedFactor {
179        executor,
180        tasks,
181        samples,
182        measured,
183        factor,
184        applied,
185        reason,
186    }
187}
188
189/// Whether a measured median is plausible enough to scale an executor kind's estimates by; a
190/// median near 0 would collapse the forecast to nothing, which no honest record supports.
191fn in_band(median: f64, executor: ActorKind) -> bool {
192    median.is_finite() && (min_applied_ratio(executor)..=MAX_APPLIED_RATIO).contains(&median)
193}
194
195fn review_delay(plan: &Plan, report: &CalibrationReport) -> AppliedReviewDelay {
196    let verifier = plan
197        .calendars
198        .as_ref()
199        .map_or(ActorKind::Human, |c| c.verifier);
200    let group = report.reviews.by_kind.iter().find(|g| g.kind == verifier);
201    let samples = group.map_or(0, |g| g.count);
202    let measured_hours = group.map(|g| g.median_hours);
203    let usable = measured_hours.filter(|h| h.is_finite() && *h >= 0.0);
204    let (hours, applied, reason) = match (group, usable) {
205        (Some(g), Some(median)) if g.sufficient => (
206            median,
207            true,
208            format!(
209                "median wait of {samples} {verifier} review(s) added after each unverified \
210                 task's work"
211            ),
212        ),
213        _ => (
214            0.0,
215            false,
216            format!(
217                "{samples} {verifier} review(s) measured, fewer than the {MIN_SAMPLES} needed; \
218                 no review delay added"
219            ),
220        ),
221    };
222    AppliedReviewDelay {
223        verifier,
224        samples,
225        measured_hours,
226        hours,
227        applied,
228        reason,
229    }
230}