use super::{CalibrationReport, MAX_APPLIED_RATIO, MIN_SAMPLES, min_applied_ratio};
use dpm_model::{ActorKind, MAX_CALENDAR_HOURS, Plan, ThreePointEstimate, WorkItem};
use dpm_schedule::RemainingOptions;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AppliedCalibration {
pub min_samples: usize,
pub factors: Vec<AppliedFactor>,
pub review_delay: AppliedReviewDelay,
pub capped: usize,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AppliedFactor {
pub executor: ActorKind,
pub tasks: usize,
pub samples: usize,
pub measured: Option<f64>,
pub factor: f64,
pub applied: bool,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AppliedReviewDelay {
pub verifier: ActorKind,
pub samples: usize,
pub measured_hours: Option<f64>,
pub hours: f64,
pub applied: bool,
pub reason: String,
}
fn executor(plan: &Plan, work: &WorkItem) -> ActorKind {
work.execution
.owner
.as_ref()
.map(|o| o.kind)
.or(work.schedule.executor)
.or(plan.calendars.as_ref().map(|c| c.default_executor))
.unwrap_or(ActorKind::Human)
}
pub(crate) fn calibrated_forecast(
plan: &Plan,
report: &CalibrationReport,
) -> (Plan, RemainingOptions, AppliedCalibration) {
let mut scaled = plan.clone();
let mut tasks: BTreeMap<ActorKind, usize> = BTreeMap::new();
for work in plan.work_items.values().filter(|w| unfinished_estimate(w)) {
let count = tasks.entry(executor(plan, work)).or_default();
*count = count.wrapping_add(1);
}
let factors: Vec<AppliedFactor> = tasks
.into_iter()
.map(|(kind, tasks)| factor(report, kind, tasks))
.collect();
let limit = plan.calendars.as_ref().map(|_| MAX_CALENDAR_HOURS);
let mut capped = 0_usize;
for work in scaled
.work_items
.values_mut()
.filter(|w| unfinished_estimate(w))
{
let kind = executor(plan, work);
let applied = factors
.iter()
.find(|f| f.executor == kind)
.map(|f| f.factor);
if let (Some(estimate), Some(factor)) = (work.schedule.estimate.as_mut(), applied)
&& scale(estimate, factor, limit)
{
capped = capped.wrapping_add(1);
}
}
let review_delay = review_delay(plan, report);
let options = RemainingOptions {
review_delay_hours: review_delay.hours,
};
let applied = AppliedCalibration {
min_samples: MIN_SAMPLES,
factors,
review_delay,
capped,
};
(scaled, options, applied)
}
fn unfinished_estimate(work: &WorkItem) -> bool {
work.is_executable()
&& work.schedule.estimate.is_some()
&& !work.execution.status.satisfies_dependency()
}
fn scale(estimate: &mut ThreePointEstimate, factor: f64, limit: Option<f64>) -> bool {
let mut capped = false;
for hours in [
&mut estimate.optimistic_hours,
&mut estimate.likely_hours,
&mut estimate.pessimistic_hours,
] {
*hours *= factor;
if let Some(limit) = limit.filter(|limit| *hours > *limit) {
*hours = limit;
capped = true;
}
}
capped
}
fn factor(report: &CalibrationReport, executor: ActorKind, tasks: usize) -> AppliedFactor {
let group = report
.estimates
.by_executor
.iter()
.find(|g| g.executor == executor);
let samples = group.map_or(0, |g| g.samples);
let measured = group.and_then(|g| g.median);
let sufficient = group.is_some_and(|g| g.sufficient);
let (factor, applied, reason) = match measured {
Some(median) if sufficient && in_band(median, executor) => (
median,
true,
format!(
"median actual/estimated ratio of {samples} verified {executor} task(s) applied"
),
),
Some(median) if sufficient => (
1.0,
false,
format!(
"median actual/estimated ratio {median:.3} of {samples} verified {executor} \
task(s) lies outside {}-{MAX_APPLIED_RATIO} for {executor} work, which points at \
the records rather than the estimates; estimates kept",
min_applied_ratio(executor)
),
),
_ => (
1.0,
false,
format!(
"{samples} verified {executor} sample(s), fewer than the {MIN_SAMPLES} needed; \
estimates kept"
),
),
};
AppliedFactor {
executor,
tasks,
samples,
measured,
factor,
applied,
reason,
}
}
fn in_band(median: f64, executor: ActorKind) -> bool {
median.is_finite() && (min_applied_ratio(executor)..=MAX_APPLIED_RATIO).contains(&median)
}
fn review_delay(plan: &Plan, report: &CalibrationReport) -> AppliedReviewDelay {
let verifier = plan
.calendars
.as_ref()
.map_or(ActorKind::Human, |c| c.verifier);
let group = report.reviews.by_kind.iter().find(|g| g.kind == verifier);
let samples = group.map_or(0, |g| g.count);
let measured_hours = group.map(|g| g.median_hours);
let usable = measured_hours.filter(|h| h.is_finite() && *h >= 0.0);
let (hours, applied, reason) = match (group, usable) {
(Some(g), Some(median)) if g.sufficient => (
median,
true,
format!(
"median wait of {samples} {verifier} review(s) added after each unverified \
task's work"
),
),
_ => (
0.0,
false,
format!(
"{samples} {verifier} review(s) measured, fewer than the {MIN_SAMPLES} needed; \
no review delay added"
),
),
};
AppliedReviewDelay {
verifier,
samples,
measured_hours,
hours,
applied,
reason,
}
}