dpm_engine/query/calibration.rs
1//! Calibration of estimates, review and decision waits, and flow, measured from recorded history.
2//!
3//! Everything here is derived from the snapshot's recorded event times and the operation log the
4//! adapter passes in; nothing is stored and no estimate is ever rewritten. A forecast applies the
5//! measured factors only when a caller asks for it ([`crate::status_calibrated`]).
6
7use crate::{EngineError, Operation};
8use chrono::{DateTime, Utc};
9use dpm_model::{ActorKind, Key, Plan};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeSet;
12
13mod forecast;
14mod history;
15mod samples;
16mod waits;
17pub(crate) use forecast::calibrated_forecast;
18pub use forecast::{AppliedCalibration, AppliedFactor, AppliedReviewDelay};
19pub(crate) use history::HistoryIndex;
20
21/// Samples a group needs before a calibrated forecast applies its median.
22pub const MIN_SAMPLES: usize = 5;
23
24/// Start and submit operations committed this close together, neither naming an occurrence
25/// time, were recorded after the fact in one sitting, so their interval says nothing about the
26/// work.
27pub const BULK_WINDOW_SECONDS: i64 = 180;
28
29/// Actual working time below this is no measurement: start and submit were recorded together, or
30/// the whole interval fell outside the task's working hours.
31pub const MIN_ACTUAL_SECONDS: i64 = 60;
32
33/// Smallest group median a calibrated forecast applies to people and services; lower says the
34/// records, such as times typed in afterwards, not the estimates, are wrong.
35pub const MIN_APPLIED_RATIO: f64 = 0.05;
36
37/// Smallest group median a calibrated forecast applies to agents. Agents finishing human-scale
38/// estimates routinely measure 0.01-0.1, so their floor sits below that range; near-zero samples
39/// are already excluded by [`MIN_ACTUAL_SECONDS`].
40pub const MIN_AGENT_APPLIED_RATIO: f64 = 0.005;
41
42/// Smallest group median a calibrated forecast applies to an executor kind.
43#[must_use]
44pub fn min_applied_ratio(executor: dpm_model::ActorKind) -> f64 {
45 match executor {
46 dpm_model::ActorKind::Agent => MIN_AGENT_APPLIED_RATIO,
47 dpm_model::ActorKind::Human | dpm_model::ActorKind::Service => MIN_APPLIED_RATIO,
48 }
49}
50
51/// Largest group median a calibrated forecast applies.
52pub const MAX_APPLIED_RATIO: f64 = 50.0;
53
54/// A review recorded this soon after its submission was recorded together with it; no one reads
55/// and judges a result that fast, so it is no wait.
56pub const REVIEW_FLOOR_SECONDS: i64 = 10;
57
58/// Calibration of estimates and waits, and flow metrics, at one clock reading.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60pub struct CalibrationReport {
61 /// Clock reading the report was computed at.
62 pub generated_at: DateTime<Utc>,
63 /// Thresholds and units every figure in the report follows.
64 pub rules: CalibrationRules,
65 /// How much operation history the report could read.
66 pub history: HistoryCoverage,
67 /// Actual working time against estimates, per executor kind and capability.
68 pub estimates: EstimateCalibration,
69 /// Time from each submission to its review, per reviewer kind.
70 pub reviews: WaitReport,
71 /// Time from a decision's opening to its resolution, per deciding actor kind.
72 pub decisions: WaitReport,
73 /// Cycle time, lead time, throughput, aging work in progress and claim reliability.
74 pub flow: crate::FlowReport,
75}
76
77/// Thresholds and units of a calibration report.
78#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
79pub struct CalibrationRules {
80 /// Samples a group needs before a calibrated forecast applies it.
81 pub min_samples: usize,
82 /// Claim, start and submit of one attempt committed within this many seconds, start and
83 /// submit without an occurrence time, count as recorded in bulk.
84 pub bulk_window_seconds: i64,
85 /// Samples with less actual time than this are excluded as `no_working_time`.
86 pub min_actual_seconds: i64,
87 /// Smallest group median a calibrated forecast applies to people and services.
88 pub min_applied_ratio: f64,
89 /// Smallest group median a calibrated forecast applies to agents.
90 pub min_agent_applied_ratio: f64,
91 /// Largest group median a calibrated forecast applies.
92 pub max_applied_ratio: f64,
93 /// Reviews recorded within this many seconds of their submission are not waits.
94 pub review_floor_seconds: i64,
95 /// Unit of actual execution time.
96 pub actual_hours: HourBasis,
97}
98
99/// How actual execution time is counted.
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum HourBasis {
103 /// Working hours of each task's calendar, the unit its estimate is written in.
104 Working,
105 /// Elapsed hours, because the plan has no calendars.
106 Elapsed,
107}
108
109/// Extent of the operation log the report read.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111pub struct HistoryCoverage {
112 /// Operations read.
113 pub operations: usize,
114 /// Commit time of the first operation, if any.
115 pub first_at: Option<DateTime<Utc>>,
116}
117
118/// Actual against estimated execution time.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub struct EstimateCalibration {
121 /// Every verified task measured, in key order.
122 pub samples: Vec<EstimateSample>,
123 /// Verified tasks that were not measured, grouped by reason.
124 pub excluded: Vec<Exclusion>,
125 /// Ratio distribution per executor kind.
126 pub by_executor: Vec<RatioGroup>,
127 /// Ratio distribution per executor kind and required capability; a task counts once for each
128 /// capability it requires.
129 pub by_capability: Vec<RatioGroup>,
130}
131
132/// One verified task's actual execution time against its estimate.
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub struct EstimateSample {
135 /// Task key.
136 pub key: Key,
137 /// Kind of the actor who submitted the verified attempt.
138 pub executor: ActorKind,
139 /// Capabilities the task requires.
140 pub capabilities: BTreeSet<String>,
141 /// PERT expected hours of the estimate.
142 pub estimated_hours: f64,
143 /// Hours from the start to the verified submission, in the report's hour basis, less the
144 /// review waits of the task's earlier rejected attempts.
145 pub actual_hours: f64,
146 /// `actual_hours / estimated_hours`.
147 pub ratio: f64,
148}
149
150/// Distribution of actual/estimated ratios in one group.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct RatioGroup {
153 /// Executor kind of the samples.
154 pub executor: ActorKind,
155 /// Capability of the samples, for a per-capability group.
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub capability: Option<String>,
158 /// Number of samples.
159 pub samples: usize,
160 /// Median ratio, the factor a calibrated forecast applies; absent without samples.
161 pub median: Option<f64>,
162 /// 25th percentile ratio; absent without samples.
163 pub p25: Option<f64>,
164 /// 75th percentile ratio; absent without samples.
165 pub p75: Option<f64>,
166 /// Whether the group has at least [`MIN_SAMPLES`] samples.
167 pub sufficient: bool,
168 /// Verified tasks of this executor kind (and capability) left out, per reason; what they
169 /// would have said is missing from the distribution.
170 pub excluded: Vec<ReasonCount>,
171}
172
173/// How many items one reason left out.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175pub struct ReasonCount {
176 /// Why they were left out.
177 pub reason: ExclusionReason,
178 /// How many were left out.
179 pub count: usize,
180}
181
182/// Why recorded work or a decision was left out of a measurement.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
184#[serde(rename_all = "snake_case")]
185pub enum ExclusionReason {
186 /// The task has no estimate, or its expected hours are zero.
187 Unestimated,
188 /// The task's start time was never recorded.
189 StartUnrecorded,
190 /// The verified submission's time was never recorded.
191 SubmitUnrecorded,
192 /// The start or the verified submission is not in the operation log the store holds.
193 NotInHistory,
194 /// Claim, start and submit were committed within the bulk window, start and submit without an
195 /// occurrence time; with the claim before the log begins, start and submit alone.
196 BulkRecorded,
197 /// Actors of more than one kind held the attempt between its start and the verified
198 /// submission, so no one kind's ratio describes it.
199 MixedExecutors,
200 /// The task's calendar cannot count the working hours of its recorded interval.
201 CalendarOutOfRange,
202 /// Less than [`MIN_ACTUAL_SECONDS`] of actual time: the interval is empty or lies outside
203 /// the task's working hours.
204 NoWorkingTime,
205 /// Work verified before submission attempts were recorded has no reviewer or review time.
206 AttemptUnrecorded,
207 /// The review was recorded within [`REVIEW_FLOOR_SECONDS`] of its submission.
208 ReviewedOnSubmission,
209 /// The decision existed before the operation log begins, so its opening time is unknown.
210 OpenedBeforeHistory,
211 /// A reviewed replacement is created already decided; it never waited.
212 Replacement,
213 /// The decision's resolution time was never recorded.
214 ResolutionUnrecorded,
215 /// The task's verification time was never recorded.
216 VerificationUnrecorded,
217 /// The task's first claim is not in the operation log the store holds.
218 ClaimedBeforeHistory,
219}
220
221/// Items left out of one measurement for one reason.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223pub struct Exclusion {
224 /// Why they were left out.
225 pub reason: ExclusionReason,
226 /// How many were left out; for reviews, the number of reviews.
227 pub count: usize,
228 /// Their keys, in key order, each once.
229 pub keys: Vec<Key>,
230}
231
232/// Waits grouped by the kind of actor that ended them.
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234pub struct WaitReport {
235 /// Wait distribution per actor kind.
236 pub by_kind: Vec<WaitGroup>,
237 /// Items not measured, grouped by reason.
238 pub excluded: Vec<Exclusion>,
239}
240
241/// Distribution of elapsed waits for one actor kind.
242#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
243pub struct WaitGroup {
244 /// Kind of the reviewing or deciding actor.
245 pub kind: ActorKind,
246 /// Number of waits.
247 pub count: usize,
248 /// Median elapsed hours, the delay a calibrated forecast applies for reviews.
249 pub median_hours: f64,
250 /// 80th percentile elapsed hours.
251 pub p80_hours: f64,
252 /// Whether the group has at least [`MIN_SAMPLES`] waits.
253 pub sufficient: bool,
254}
255
256/// Measure estimates, waits and flow from a validated plan and its operation log in append order.
257///
258/// `history` is the log the plan's snapshot results from; items whose facts predate it are
259/// reported as excluded rather than guessed.
260pub fn calibration(
261 plan: &Plan,
262 history: &[Operation],
263 now: DateTime<Utc>,
264) -> Result<CalibrationReport, EngineError> {
265 plan.validate()?;
266 let index = HistoryIndex::new(history);
267 let measured = samples::measure(plan, &index);
268 Ok(CalibrationReport {
269 generated_at: now,
270 rules: CalibrationRules {
271 min_samples: MIN_SAMPLES,
272 bulk_window_seconds: BULK_WINDOW_SECONDS,
273 min_actual_seconds: MIN_ACTUAL_SECONDS,
274 min_applied_ratio: MIN_APPLIED_RATIO,
275 min_agent_applied_ratio: MIN_AGENT_APPLIED_RATIO,
276 max_applied_ratio: MAX_APPLIED_RATIO,
277 review_floor_seconds: REVIEW_FLOOR_SECONDS,
278 actual_hours: if plan.calendars.is_some() {
279 HourBasis::Working
280 } else {
281 HourBasis::Elapsed
282 },
283 },
284 history: HistoryCoverage {
285 operations: history.len(),
286 first_at: history.first().map(|op| op.timestamp),
287 },
288 reviews: waits::reviews(plan, &measured.bulk),
289 decisions: waits::decisions(plan, &index),
290 flow: crate::query::flow::flow(plan, &index, &measured.bulk, now),
291 estimates: measured.calibration,
292 })
293}
294
295/// Group excluded keys by reason, reasons and keys in order.
296pub(crate) fn exclusions(mut items: Vec<(ExclusionReason, Key)>) -> Vec<Exclusion> {
297 items.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.natural_cmp(&b.1)));
298 let mut grouped: Vec<Exclusion> = Vec::new();
299 for (reason, key) in items {
300 match grouped.last_mut() {
301 Some(last) if last.reason == reason => {
302 last.count = last.count.wrapping_add(1);
303 if last.keys.last() != Some(&key) {
304 last.keys.push(key);
305 }
306 }
307 _ => grouped.push(Exclusion {
308 reason,
309 count: 1,
310 keys: vec![key],
311 }),
312 }
313 }
314 grouped
315}
316
317/// Values in ascending order for [`dpm_schedule::percentile`].
318pub(crate) fn ascending(mut values: Vec<f64>) -> Vec<f64> {
319 values.sort_by(f64::total_cmp);
320 values
321}
322
323/// Elapsed hours from `from` to `to`.
324pub(crate) fn elapsed_hours(from: DateTime<Utc>, to: DateTime<Utc>) -> f64 {
325 (to - from).num_milliseconds() as f64 / 3_600_000.0
326}
327
328#[cfg(test)]
329pub(crate) mod tests;