use crate::{EngineError, Operation};
use chrono::{DateTime, Utc};
use dpm_model::{ActorKind, Key, Plan};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
mod forecast;
mod history;
mod samples;
mod waits;
pub(crate) use forecast::calibrated_forecast;
pub use forecast::{AppliedCalibration, AppliedFactor, AppliedReviewDelay};
pub(crate) use history::HistoryIndex;
pub const MIN_SAMPLES: usize = 5;
pub const BULK_WINDOW_SECONDS: i64 = 180;
pub const MIN_ACTUAL_SECONDS: i64 = 60;
pub const MIN_APPLIED_RATIO: f64 = 0.05;
pub const MIN_AGENT_APPLIED_RATIO: f64 = 0.005;
#[must_use]
pub fn min_applied_ratio(executor: dpm_model::ActorKind) -> f64 {
match executor {
dpm_model::ActorKind::Agent => MIN_AGENT_APPLIED_RATIO,
dpm_model::ActorKind::Human | dpm_model::ActorKind::Service => MIN_APPLIED_RATIO,
}
}
pub const MAX_APPLIED_RATIO: f64 = 50.0;
pub const REVIEW_FLOOR_SECONDS: i64 = 10;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CalibrationReport {
pub generated_at: DateTime<Utc>,
pub rules: CalibrationRules,
pub history: HistoryCoverage,
pub estimates: EstimateCalibration,
pub reviews: WaitReport,
pub decisions: WaitReport,
pub flow: crate::FlowReport,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct CalibrationRules {
pub min_samples: usize,
pub bulk_window_seconds: i64,
pub min_actual_seconds: i64,
pub min_applied_ratio: f64,
pub min_agent_applied_ratio: f64,
pub max_applied_ratio: f64,
pub review_floor_seconds: i64,
pub actual_hours: HourBasis,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HourBasis {
Working,
Elapsed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HistoryCoverage {
pub operations: usize,
pub first_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EstimateCalibration {
pub samples: Vec<EstimateSample>,
pub excluded: Vec<Exclusion>,
pub by_executor: Vec<RatioGroup>,
pub by_capability: Vec<RatioGroup>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EstimateSample {
pub key: Key,
pub executor: ActorKind,
pub capabilities: BTreeSet<String>,
pub estimated_hours: f64,
pub actual_hours: f64,
pub ratio: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RatioGroup {
pub executor: ActorKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capability: Option<String>,
pub samples: usize,
pub median: Option<f64>,
pub p25: Option<f64>,
pub p75: Option<f64>,
pub sufficient: bool,
pub excluded: Vec<ReasonCount>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReasonCount {
pub reason: ExclusionReason,
pub count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExclusionReason {
Unestimated,
StartUnrecorded,
SubmitUnrecorded,
NotInHistory,
BulkRecorded,
MixedExecutors,
CalendarOutOfRange,
NoWorkingTime,
AttemptUnrecorded,
ReviewedOnSubmission,
OpenedBeforeHistory,
Replacement,
ResolutionUnrecorded,
VerificationUnrecorded,
ClaimedBeforeHistory,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Exclusion {
pub reason: ExclusionReason,
pub count: usize,
pub keys: Vec<Key>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WaitReport {
pub by_kind: Vec<WaitGroup>,
pub excluded: Vec<Exclusion>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct WaitGroup {
pub kind: ActorKind,
pub count: usize,
pub median_hours: f64,
pub p80_hours: f64,
pub sufficient: bool,
}
pub fn calibration(
plan: &Plan,
history: &[Operation],
now: DateTime<Utc>,
) -> Result<CalibrationReport, EngineError> {
plan.validate()?;
let index = HistoryIndex::new(history);
let measured = samples::measure(plan, &index);
Ok(CalibrationReport {
generated_at: now,
rules: CalibrationRules {
min_samples: MIN_SAMPLES,
bulk_window_seconds: BULK_WINDOW_SECONDS,
min_actual_seconds: MIN_ACTUAL_SECONDS,
min_applied_ratio: MIN_APPLIED_RATIO,
min_agent_applied_ratio: MIN_AGENT_APPLIED_RATIO,
max_applied_ratio: MAX_APPLIED_RATIO,
review_floor_seconds: REVIEW_FLOOR_SECONDS,
actual_hours: if plan.calendars.is_some() {
HourBasis::Working
} else {
HourBasis::Elapsed
},
},
history: HistoryCoverage {
operations: history.len(),
first_at: history.first().map(|op| op.timestamp),
},
reviews: waits::reviews(plan, &measured.bulk),
decisions: waits::decisions(plan, &index),
flow: crate::query::flow::flow(plan, &index, &measured.bulk, now),
estimates: measured.calibration,
})
}
pub(crate) fn exclusions(mut items: Vec<(ExclusionReason, Key)>) -> Vec<Exclusion> {
items.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.natural_cmp(&b.1)));
let mut grouped: Vec<Exclusion> = Vec::new();
for (reason, key) in items {
match grouped.last_mut() {
Some(last) if last.reason == reason => {
last.count = last.count.wrapping_add(1);
if last.keys.last() != Some(&key) {
last.keys.push(key);
}
}
_ => grouped.push(Exclusion {
reason,
count: 1,
keys: vec![key],
}),
}
}
grouped
}
pub(crate) fn ascending(mut values: Vec<f64>) -> Vec<f64> {
values.sort_by(f64::total_cmp);
values
}
pub(crate) fn elapsed_hours(from: DateTime<Utc>, to: DateTime<Utc>) -> f64 {
(to - from).num_milliseconds() as f64 / 3_600_000.0
}
#[cfg(test)]
pub(crate) mod tests;