dpm_engine/query/calibration/
forecast.rs1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct AppliedCalibration {
13 pub min_samples: usize,
15 pub factors: Vec<AppliedFactor>,
17 pub review_delay: AppliedReviewDelay,
19 pub capped: usize,
22}
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26pub struct AppliedFactor {
27 pub executor: ActorKind,
30 pub tasks: usize,
32 pub samples: usize,
34 pub measured: Option<f64>,
36 pub factor: f64,
38 pub applied: bool,
40 pub reason: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46pub struct AppliedReviewDelay {
47 pub verifier: ActorKind,
49 pub samples: usize,
51 pub measured_hours: Option<f64>,
53 pub hours: f64,
55 pub applied: bool,
57 pub reason: String,
59}
60
61fn 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
72pub(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
125fn 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
189fn 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}