Skip to main content

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;