use crate::domain::model::issue::Issue;
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::usecases::issue::index_sampler::IndexSampler;
use crate::domain::usecases::issue::stats::helpers::{close_date, coeff_of_variation};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub enum StabilityLevel {
Stable,
Variable,
HighlyVariable,
}
impl StabilityLevel {
pub fn as_str(&self) -> &'static str {
match self {
StabilityLevel::Stable => "stable",
StabilityLevel::Variable => "variable",
StabilityLevel::HighlyVariable => "highly variable",
}
}
fn from_cv(cv: f64) -> Self {
if cv <= 25.0 {
StabilityLevel::Stable
} else if cv <= 50.0 {
StabilityLevel::Variable
} else {
StabilityLevel::HighlyVariable
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ForecastPercentiles {
pub p50: f64,
pub p70: f64,
pub p85: f64,
pub p95: f64,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ItemsForecast {
pub items: u32,
pub history_weeks: u32,
pub simulations: u32,
pub throughput_cv_pct: f64,
pub stability: StabilityLevel,
pub weeks_to_complete: ForecastPercentiles,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct WeeksForecast {
pub weeks: u32,
pub history_weeks: u32,
pub simulations: u32,
pub throughput_cv_pct: f64,
pub stability: StabilityLevel,
pub items_delivered: ForecastPercentiles,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ForecastError {
InsufficientData { non_zero_weeks: u32 },
}
impl std::fmt::Display for ForecastError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ForecastError::InsufficientData { non_zero_weeks } => write!(
f,
"insufficient data: need at least 4 weeks with closed issues in the \
history window, got {non_zero_weeks}"
),
}
}
}
fn build_throughput(
issues: &[Issue],
statuses: &StatusesConfig,
today: &IsoDate,
history_weeks: u32,
) -> Vec<u32> {
let mut week_labels: Vec<String> = (0..history_weeks)
.rev()
.map(|offset| today.minus_weeks(offset).iso_week_label())
.collect();
week_labels.dedup();
let mut counts: std::collections::HashMap<String, u32> =
week_labels.iter().map(|l| (l.clone(), 0)).collect();
for issue in issues.iter().filter(|i| i.status.terminal) {
if let Some(iso) = close_date(issue, statuses) {
let label = iso.iso_week_label();
if counts.contains_key(&label) {
*counts.entry(label).or_insert(0) += 1;
}
}
}
week_labels.iter().map(|w| counts[w]).collect()
}
fn percentiles_f64(mut values: Vec<f64>) -> ForecastPercentiles {
values.sort_by(|a, b| a.total_cmp(b));
let p = |pct: f64| -> f64 {
if values.is_empty() {
return 0.0;
}
let idx = (pct / 100.0 * (values.len() - 1) as f64).round() as usize;
values[idx.min(values.len() - 1)]
};
ForecastPercentiles {
p50: p(50.0),
p70: p(70.0),
p85: p(85.0),
p95: p(95.0),
}
}
pub fn forecast_items(
issues: &[Issue],
statuses: &StatusesConfig,
today: &IsoDate,
target_items: u32,
history_weeks: u32,
simulations: u32,
sampler: &mut dyn IndexSampler,
) -> Result<ItemsForecast, ForecastError> {
let throughput = build_throughput(issues, statuses, today, history_weeks);
let non_zero = throughput.iter().filter(|&&v| v > 0).count() as u32;
if non_zero < 4 {
return Err(ForecastError::InsufficientData {
non_zero_weeks: non_zero,
});
}
let throughput_f64: Vec<f64> = throughput.iter().map(|&v| v as f64).collect();
let cv = coeff_of_variation(&throughput_f64).unwrap_or(0.0);
let stability = StabilityLevel::from_cv(cv);
let n = throughput.len();
let mut sim_weeks: Vec<f64> = Vec::with_capacity(simulations as usize);
for _ in 0..simulations {
let mut delivered = 0u32;
let mut weeks_used = 0u32;
while delivered < target_items && weeks_used < 10_000 {
let idx = sampler.next_index(n);
delivered += throughput[idx];
weeks_used += 1;
}
sim_weeks.push(weeks_used as f64);
}
Ok(ItemsForecast {
items: target_items,
history_weeks,
simulations,
throughput_cv_pct: cv,
stability,
weeks_to_complete: percentiles_f64(sim_weeks),
})
}
pub fn forecast_weeks(
issues: &[Issue],
statuses: &StatusesConfig,
today: &IsoDate,
target_weeks: u32,
history_weeks: u32,
simulations: u32,
sampler: &mut dyn IndexSampler,
) -> Result<WeeksForecast, ForecastError> {
let throughput = build_throughput(issues, statuses, today, history_weeks);
let non_zero = throughput.iter().filter(|&&v| v > 0).count() as u32;
if non_zero < 4 {
return Err(ForecastError::InsufficientData {
non_zero_weeks: non_zero,
});
}
let throughput_f64: Vec<f64> = throughput.iter().map(|&v| v as f64).collect();
let cv = coeff_of_variation(&throughput_f64).unwrap_or(0.0);
let stability = StabilityLevel::from_cv(cv);
let n = throughput.len();
let mut sim_items: Vec<f64> = Vec::with_capacity(simulations as usize);
for _ in 0..simulations {
let total: u32 = (0..target_weeks)
.map(|_| throughput[sampler.next_index(n)])
.sum();
sim_items.push(total as f64);
}
Ok(WeeksForecast {
weeks: target_weeks,
history_weeks,
simulations,
throughput_cv_pct: cv,
stability,
items_delivered: percentiles_f64(sim_items),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::usecases::issue::tests::{enrich_issue, feature, ir};
#[test]
fn forecast_items_returns_error_when_fewer_than_4_weeks() {
scenario("2026-03-13")
.given_closed_on("2026-03-06")
.when_forecast_items(10, 8, 100)
.then_insufficient_data();
}
#[test]
fn forecast_weeks_returns_error_when_fewer_than_4_weeks() {
scenario("2026-03-13")
.given_closed_on("2026-03-06")
.when_forecast_weeks(4, 8, 100)
.then_insufficient_data();
}
#[test]
fn forecast_items_produces_percentiles_with_stable_throughput() {
scenario("2026-03-13")
.with_eight_issues_one_per_week()
.when_forecast_items(8, 8, 1000)
.then_items_forecast_ok()
.then_p50_in_range(4.0, 20.0)
.then_percentiles_ordered();
}
#[test]
fn forecast_weeks_produces_percentiles_with_stable_throughput() {
scenario("2026-03-13")
.with_eight_issues_one_per_week()
.when_forecast_weeks(4, 8, 1000)
.then_weeks_forecast_ok()
.then_items_delivered_at_least(1.0)
.then_weeks_percentiles_ordered();
}
#[test]
fn build_throughput_counts_closed_per_week() {
let issues = eight_issues_one_per_week();
let tp = build_throughput(
&issues,
&StatusesConfig::default_issue(),
&IsoDate::new("2026-03-13").unwrap(),
8,
);
assert_eq!(tp.len(), 8);
let total: u32 = tp.iter().sum();
assert!(
(7..=8).contains(&total),
"expected 7 or 8 closed in window, got {total}"
);
}
#[test]
fn build_throughput_includes_zero_weeks() {
let issues = vec![closed_issue(1, "2026-03-06"), closed_issue(2, "2026-02-27")];
let tp = build_throughput(
&issues,
&StatusesConfig::default_issue(),
&IsoDate::new("2026-03-13").unwrap(),
8,
);
assert_eq!(tp.len(), 8);
let zeros = tp.iter().filter(|&&v| v == 0).count();
assert_eq!(zeros, 6);
}
#[test]
fn stability_level_stable_below_25_pct() {
assert_eq!(StabilityLevel::from_cv(0.0), StabilityLevel::Stable);
assert_eq!(StabilityLevel::from_cv(25.0), StabilityLevel::Stable);
}
#[test]
fn stability_level_variable_between_25_and_50_pct() {
assert_eq!(StabilityLevel::from_cv(25.1), StabilityLevel::Variable);
assert_eq!(StabilityLevel::from_cv(50.0), StabilityLevel::Variable);
}
#[test]
fn stability_level_highly_variable_above_50_pct() {
assert_eq!(
StabilityLevel::from_cv(50.1),
StabilityLevel::HighlyVariable
);
assert_eq!(
StabilityLevel::from_cv(100.0),
StabilityLevel::HighlyVariable
);
}
#[test]
fn coeff_of_variation_uniform_throughput_is_zero() {
let values = vec![3.0f64, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0];
let cv = coeff_of_variation(&values).expect("should compute");
assert!(
cv.abs() < 0.01,
"expected CV≈0 for uniform throughput, got {cv}"
);
}
#[test]
fn coeff_of_variation_returns_none_for_single_value() {
assert!(coeff_of_variation(&[5.0]).is_none());
}
#[test]
fn coeff_of_variation_returns_none_for_all_zeros() {
assert!(coeff_of_variation(&[0.0, 0.0, 0.0, 0.0]).is_none());
}
struct Scenario {
issues: Vec<Issue>,
today: IsoDate,
}
fn scenario(today: &str) -> Scenario {
Scenario {
issues: vec![],
today: IsoDate::new(today).unwrap(),
}
}
impl Scenario {
fn given_closed_on(mut self, date: &str) -> Self {
let id = self.issues.len() as u64 + 1;
self.issues.push(closed_issue(id, date));
self
}
fn with_eight_issues_one_per_week(mut self) -> Self {
self.issues = eight_issues_one_per_week();
self
}
fn when_forecast_items(self, target: u32, history_weeks: u32, sims: u32) -> ItemsOutcome {
use crate::domain::usecases::issue::tests::CycleIndexSampler;
let mut sampler = CycleIndexSampler::default();
let result = forecast_items(
&self.issues,
&StatusesConfig::default_issue(),
&self.today,
target,
history_weeks,
sims,
&mut sampler,
);
ItemsOutcome { result }
}
fn when_forecast_weeks(self, target: u32, history_weeks: u32, sims: u32) -> WeeksOutcome {
use crate::domain::usecases::issue::tests::CycleIndexSampler;
let mut sampler = CycleIndexSampler::default();
let result = forecast_weeks(
&self.issues,
&StatusesConfig::default_issue(),
&self.today,
target,
history_weeks,
sims,
&mut sampler,
);
WeeksOutcome { result }
}
}
struct ItemsOutcome {
result: Result<ItemsForecast, ForecastError>,
}
impl ItemsOutcome {
fn then_insufficient_data(self) -> Self {
assert!(
matches!(self.result, Err(ForecastError::InsufficientData { .. })),
"expected InsufficientData error"
);
self
}
fn then_items_forecast_ok(self) -> ItemsForecastOutcome {
ItemsForecastOutcome {
forecast: self.result.expect("forecast should succeed"),
}
}
}
struct ItemsForecastOutcome {
forecast: ItemsForecast,
}
impl ItemsForecastOutcome {
fn then_p50_in_range(self, lo: f64, hi: f64) -> Self {
let p50 = self.forecast.weeks_to_complete.p50;
assert!(
p50 >= lo && p50 <= hi,
"expected p50 in [{lo}, {hi}], got {p50}"
);
self
}
fn then_percentiles_ordered(self) -> Self {
let w = &self.forecast.weeks_to_complete;
assert!(w.p95 >= w.p85, "p95 >= p85");
assert!(w.p85 >= w.p70, "p85 >= p70");
assert!(w.p70 >= w.p50, "p70 >= p50");
self
}
}
struct WeeksOutcome {
result: Result<WeeksForecast, ForecastError>,
}
impl WeeksOutcome {
fn then_insufficient_data(self) -> Self {
assert!(
matches!(self.result, Err(ForecastError::InsufficientData { .. })),
"expected InsufficientData error"
);
self
}
fn then_weeks_forecast_ok(self) -> WeeksForecastOutcome {
WeeksForecastOutcome {
forecast: self.result.expect("forecast should succeed"),
}
}
}
struct WeeksForecastOutcome {
forecast: WeeksForecast,
}
impl WeeksForecastOutcome {
fn then_items_delivered_at_least(self, min: f64) -> Self {
assert!(
self.forecast.items_delivered.p50 >= min,
"expected items_delivered.p50 >= {min}"
);
self
}
fn then_weeks_percentiles_ordered(self) -> Self {
let d = &self.forecast.items_delivered;
assert!(d.p95 >= d.p85, "p95 >= p85");
assert!(d.p85 >= d.p70, "p85 >= p70");
assert!(d.p70 >= d.p50, "p70 >= p50");
self
}
}
fn closed_issue(id: u64, closed_date: &str) -> Issue {
let statuses = StatusesConfig::default_issue();
let mut issue = feature(&format!("Issue {id}"))
.status("closed")
.date(closed_date)
.with_timestamped_event(&format!("{closed_date}T00:00:00Z"), "created", None, None)
.with_timestamped_event(
&format!("{closed_date}T12:00:00Z"),
"status_changed",
Some("open"),
Some("closed"),
)
.build(ir(id));
enrich_issue(&mut issue, &statuses);
issue
}
fn eight_issues_one_per_week() -> Vec<Issue> {
let dates = [
"2026-03-06",
"2026-02-27",
"2026-02-20",
"2026-02-13",
"2026-02-06",
"2026-01-30",
"2026-01-23",
"2026-01-16",
];
dates
.iter()
.enumerate()
.map(|(i, d)| closed_issue((i + 1) as u64, d))
.collect()
}
}