use super::*;
use dpm_model::{ActorId, ThreePointEstimate, WorkStatus};
mod agreement;
mod milestones;
fn fixture() -> Plan {
serde_json::from_str(include_str!(
"../../../../tests/support/execution-plan.json"
))
.expect("fixture")
}
#[test]
fn seeded_simulation_is_reproducible_ordered_and_bounded() {
let plan = fixture();
let config = SimulationConfig {
iterations: 500,
seed: 1234,
};
let a = simulate(&plan, config).expect("simulate");
let b = simulate(&plan, config).expect("repeat");
assert_eq!(a.p50_finish_hours, b.p50_finish_hours);
assert_eq!(a.p80_finish_hours, b.p80_finish_hours);
assert_eq!(a.p95_finish_hours, b.p95_finish_hours);
assert_eq!(a.criticality, b.criticality);
assert!(a.p50_finish_hours <= a.p80_finish_hours && a.p80_finish_hours <= a.p95_finish_hours);
assert!(a.criticality.values().all(|v| (0.0..=1.0).contains(v)));
assert_eq!(a.criticality.len(), plan.work_items.len());
assert!(
simulate(
&plan,
SimulationConfig {
iterations: 0,
seed: 0
}
)
.is_err()
);
}
#[test]
fn completed_plan_has_zero_remaining_duration_even_with_lag() {
let mut plan = fixture();
for work in plan.work_items.values_mut().filter(|w| w.is_executable()) {
work.execution.status = WorkStatus::Verified;
work.execution.owner = Some(ActorId::agent("owner"));
work.execution.events.verified_at = Some(chrono::DateTime::UNIX_EPOCH);
}
for dependency in &mut plan.dependencies {
dependency.lag_hours = 5.0;
}
let result = simulate_remaining(
&plan,
SimulationConfig {
iterations: 10,
seed: 0,
},
chrono::DateTime::UNIX_EPOCH + chrono::TimeDelta::hours(5),
)
.expect("remaining");
assert_eq!(result.p95_finish_hours, 0.0);
}
fn layered(count: usize) -> Plan {
let base = fixture();
let template = base
.work_items
.values()
.find(|w| w.is_executable())
.cloned()
.expect("task");
let mut plan = Plan::empty("layered");
let project = base.projects[&template.project].clone();
plan.projects.insert(project.id, project);
let mut ids = Vec::with_capacity(count);
for i in 0..count {
let hours = 1.0 + (i % 7) as f64;
let work = dpm_model::WorkItem {
id: dpm_model::WorkItemId::new(),
key: dpm_model::Key::new(format!("S-{i}")),
parent: None,
contract: dpm_model::WorkContract {
requirement_ids: Default::default(),
assets: Vec::new(),
..(template.clone()).contract
},
execution: dpm_model::ExecutionRecord {
status: WorkStatus::Planned,
owner: None,
artifact_ids: Default::default(),
..(template.clone()).execution
},
schedule: dpm_model::ScheduleInputs {
estimate: Some(ThreePointEstimate {
optimistic_hours: hours * 0.5,
likely_hours: hours,
pessimistic_hours: hours * 3.0,
}),
..(template.clone()).schedule
},
..template.clone()
};
ids.push(work.id);
plan.work_items.insert(work.id, work);
}
let kinds = [
dpm_model::DependencyKind::FinishStart,
dpm_model::DependencyKind::StartStart,
dpm_model::DependencyKind::FinishFinish,
];
for (i, successor) in ids.iter().enumerate().skip(1) {
for step in [1_usize, 17, 49].into_iter().filter(|step| *step <= i) {
plan.dependencies.push(dpm_model::Dependency::new(
ids[i - step],
*successor,
kinds[(i + step) % 3],
(step % 3) as f64,
));
}
}
plan
}
fn baseline(plan: &Plan) -> Vec<(WorkItemId, RemainingDuration)> {
plan.work_items
.iter()
.map(|(id, work)| {
(
*id,
RemainingDuration::of(*id, work, false, 0.0).expect("valid"),
)
})
.collect()
}
struct Sampled {
finishes: Vec<f64>,
counts: BTreeMap<WorkItemId, usize>,
milestones: BTreeMap<WorkItemId, Vec<f64>>,
}
fn sampled_projections(
plan: &Plan,
config: SimulationConfig,
sources: &[(WorkItemId, RemainingDuration)],
) -> Sampled {
let mut rng = XorShift64::new(config.seed);
let mut sampled = Sampled {
finishes: Vec::new(),
counts: plan.work_items.keys().map(|id| (*id, 0)).collect(),
milestones: plan
.work_items
.values()
.filter(|w| w.kind == dpm_model::WorkKind::Milestone)
.map(|w| (w.id, Vec::new()))
.collect(),
};
for _ in 0..config.iterations {
let durations = sources
.iter()
.map(|(id, source)| (*id, source.sample(&mut rng)))
.collect();
let schedule = crate::deterministic_with_durations(plan, &durations).expect("cpm");
sampled.finishes.push(schedule.project_finish_hours);
for id in schedule.critical_activities {
*sampled.counts.entry(id).or_default() += 1;
}
for (id, finishes) in &mut sampled.milestones {
finishes.push(schedule.activities[id].earliest_finish_hours);
}
}
sampled
}
fn reference(
plan: &Plan,
config: SimulationConfig,
sources: &[(WorkItemId, RemainingDuration)],
) -> SimulationSummary {
let sampled = sampled_projections(plan, config, sources);
let finishes = SortedSamples::new(sampled.finishes);
SimulationSummary {
iterations: config.iterations,
p50_finish_hours: finishes.percentile(0.50),
p80_finish_hours: finishes.percentile(0.80),
p95_finish_hours: finishes.percentile(0.95),
criticality: sampled
.counts
.into_iter()
.map(|(id, n)| (id, n as f64 / config.iterations as f64))
.collect(),
sampled_std_dev_hours: finishes.std_dev(),
milestones: sampled
.milestones
.into_iter()
.map(|(id, samples)| (id, MilestoneFinish::new(SortedSamples::new(samples))))
.collect(),
finishes,
}
}
fn assert_same(fast: &SimulationSummary, slow: &SimulationSummary) {
assert_eq!(fast.p50_finish_hours, slow.p50_finish_hours);
assert_eq!(fast.p80_finish_hours, slow.p80_finish_hours);
assert_eq!(fast.p95_finish_hours, slow.p95_finish_hours);
assert_eq!(fast.criticality, slow.criticality);
assert_eq!(fast.sampled_std_dev_hours, slow.sampled_std_dev_hours);
assert_eq!(fast.finishes, slow.finishes);
assert_eq!(
fast.milestones.keys().collect::<Vec<_>>(),
slow.milestones.keys().collect::<Vec<_>>()
);
for (id, milestone) in &fast.milestones {
let other = &slow.milestones[id];
assert_eq!(milestone.p50_finish_hours, other.p50_finish_hours);
assert_eq!(milestone.p95_finish_hours, other.p95_finish_hours);
assert_eq!(milestone.samples, other.samples);
}
}
#[test]
fn simulation_equals_one_deterministic_projection_per_sample() {
for plan in [fixture(), layered(120)] {
let config = SimulationConfig {
iterations: 300,
seed: 7,
};
let fast = simulate(&plan, config).expect("simulate");
let slow = reference(&plan, config, &baseline(&plan));
assert_same(&fast, &slow);
}
}
#[test]
fn simulation_cost_is_linear_in_the_graph_per_iteration() {
let plan = layered(3_000);
let config = SimulationConfig {
iterations: 200,
seed: 11,
};
let started = std::time::Instant::now();
let summary = simulate(&plan, config).expect("simulate");
let elapsed = started.elapsed();
assert!(summary.p50_finish_hours > 0.0);
assert!(
elapsed < std::time::Duration::from_secs(20),
"3000 activities x 200 samples took {elapsed:?}"
);
}
#[test]
fn remaining_simulation_equals_one_projection_of_the_remaining_graph_per_sample() {
let mut plan = layered(80);
let at = |hours| chrono::DateTime::UNIX_EPOCH + chrono::TimeDelta::hours(hours);
for n in 0..20 {
let key = dpm_model::Key::new(format!("S-{n}"));
let work = plan
.work_items
.values_mut()
.find(|w| w.key == key)
.expect("layered task");
work.execution.status = WorkStatus::Verified;
work.execution.owner = Some(ActorId::agent("owner"));
work.execution.events.started_at = Some(at(n));
work.execution.events.verified_at = Some(at(n + 1));
}
for (n, started) in [(20, 20), (21, 23)] {
let key = dpm_model::Key::new(format!("S-{n}"));
let work = plan
.work_items
.values_mut()
.find(|w| w.key == key)
.expect("layered task");
work.execution.status = WorkStatus::InProgress;
work.execution.owner = Some(ActorId::agent("owner"));
work.execution.events.started_at = Some(at(started));
}
let config = SimulationConfig {
iterations: 200,
seed: 3,
};
for now in [at(10), at(25), at(400)] {
let fast = simulate_remaining(&plan, config, now).expect("remaining");
let remaining = crate::cpm::remaining_plan_at(&plan, &dpm_model::Timeline::at(&plan, now))
.expect("remaining");
assert!(remaining.excluded.is_empty());
let slow = reference(
&remaining.plan,
config,
&remaining.durations().expect("durations"),
);
assert_same(&fast, &slow);
}
}