OXYGENHUB

Coverage / crates/hub/src/board_stats.rs 100.0%

196 of 196 lines run, 52 of 56 functions · run #545 on main · run missed

1
//! A board's charts and forecasts from its own history (oxygenhub#357):
2
//! cumulative flow, cycle time, throughput and a Monte Carlo of when the
3
//! remaining cards will be done, with no estimates to keep. Everything here
4
//! is a pure function of the board's card moves (`project_card_moves`), so
5
//! the series and the forecast's percentiles are testable from a fixture
6
//! board and a fixed seed.
7
8
use std::collections::{BTreeMap, HashMap};
9
10
pub const DAY_MS: i64 = 86_400_000;
11
pub const WEEK_MS: i64 = 7 * DAY_MS;
12
13
/// A card's move: to a column, or off the board (`None`), at a time.
14
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15
pub struct Move {
16
    pub card: i64,
17
    pub column: Option<i64>,
18
    pub at: i64,
19
}
20
21
/// Cards per column at the end of each day from `first_day` (midnight UTC,
22
/// ms) for `days` days: one row a day, one count per column of `columns`
23
/// (in board order). Moves must be oldest first.
245×
pub fn cumulative_flow(moves: &[Move], columns: &[i64], first_day: i64, days: usize) -> Vec<Vec<usize>> {
255×
    let mut at: HashMap<i64, Option<i64>> = HashMap::new();
265×
    let mut i = 0;
275×
    let mut out = Vec::with_capacity(days);
2810×
    for d in 0..days {
2910×
        let end = first_day + (d as i64 + 1) * DAY_MS;
3032×
        while i < moves.len() && moves[i].at < end {
3122×
            at.insert(moves[i].card, moves[i].column);
3222×
            i += 1;
3322×
        }
3410×
        let mut counts = vec![0; columns.len()];
3528×
        for col in at.values().flatten() {
3652×
            if let Some(k) = columns.iter().position(|c| c == col) {
3728×
                counts[k] += 1;
3828×
            }
39
        }
4010×
        out.push(counts);
41
    }
425×
    out
435×
}
44
45
/// Each finished card's cycle time in ms: from when it first left the
46
/// board's first column (work started) to when it first reached `done`
47
/// after that. A card added straight into a later column starts there.
485×
pub fn cycle_times(moves: &[Move], first_column: i64, done: i64) -> Vec<i64> {
495×
    let mut started: HashMap<i64, i64> = HashMap::new();
505×
    let mut finished: BTreeMap<i64, i64> = BTreeMap::new();
5122×
    for m in moves {
5222×
        let Some(col) = m.column else { continue };
5321×
        if col != first_column && !started.contains_key(&m.card) {
549×
            started.insert(m.card, m.at);
5512×
        }
5621×
        if col == done && !finished.contains_key(&m.card) {
576×
            if let Some(s) = started.get(&m.card) {
586×
                finished.insert(m.card, m.at - s);
596×
            }
6015×
        }
61
    }
625×
    finished.into_values().collect()
635×
}
64
65
/// The `p`th percentile (0 to 100) of `values`, nearest rank; `None` for none.
6625×
pub fn percentile(values: &[i64], p: u32) -> Option<i64> {
6725×
    if values.is_empty() {
681×
        return None;
6924×
    }
7024×
    let mut v = values.to_vec();
7124×
    v.sort_unstable();
7224×
    let rank = ((p as f64 / 100.0) * v.len() as f64).ceil().max(1.0) as usize;
7324×
    Some(v[rank.min(v.len()) - 1])
7425×
}
75
76
/// Cards that first reached `done` in each of the `weeks` whole weeks
77
/// before `now`, oldest first.
785×
pub fn weekly_throughput(moves: &[Move], done: i64, now: i64, weeks: usize) -> Vec<usize> {
795×
    let mut first: HashMap<i64, i64> = HashMap::new();
8022×
    for m in moves {
8122×
        if m.column == Some(done) {
826×
            first.entry(m.card).or_insert(m.at);
8316×
        }
84
    }
855×
    let start = now - weeks as i64 * WEEK_MS;
865×
    let mut out = vec![0; weeks];
876×
    for at in first.values() {
886×
        if *at >= start && *at < now {
896×
            out[((at - start) / WEEK_MS) as usize] += 1;
906×
        }
91
    }
925×
    out
935×
}
94
95
/// A forecast: in how many weeks the remaining cards are done at 50% and
96
/// 85% confidence, from how many weeks of history, and `None` when the
97
/// history holds no finished card to go on.
98
#[derive(Clone, Debug, PartialEq, Eq)]
99
pub struct Forecast {
100
    pub remaining: usize,
101
    pub weeks_of_history: usize,
102
    pub p50_weeks: Option<usize>,
103
    pub p85_weeks: Option<usize>,
104
}
105
106
/// A small, seeded generator (xorshift64*): the forecast is the same for
107
/// the same history and seed, so it is testable and does not flicker
108
/// between page loads.
109
struct Rng(u64);
110
111
impl Rng {
11251144×
    fn next(&mut self) -> u64 {
11351144×
        let mut x = self.0;
11451144×
        x ^= x >> 12;
11551144×
        x ^= x << 25;
11651144×
        x ^= x >> 27;
11751144×
        self.0 = x;
11851144×
        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
11951144×
    }
12051144×
    fn below(&mut self, n: usize) -> usize {
12151144×
        (self.next() % n as u64) as usize
12251144×
    }
123
}
124
125
/// Weeks a run of trials can take at most before it is counted as never.
126
const HORIZON_WEEKS: usize = 520;
127
128
/// A Monte Carlo of when `remaining` cards are done: each trial draws
129
/// weeks from `throughput` (cards finished in each past week) until the
130
/// remaining cards are used up, `trials` times, seeded with `seed`.
13111×
pub fn forecast(throughput: &[usize], remaining: usize, trials: usize, seed: u64) -> Forecast {
13211×
    let weeks_of_history = throughput.len();
13311×
    if remaining == 0 {
1342×
        return Forecast { remaining, weeks_of_history, p50_weeks: Some(0), p85_weeks: Some(0) };
1359×
    }
13611×
    if throughput.iter().all(|t| *t == 0) || trials == 0 {
1372×
        return Forecast { remaining, weeks_of_history, p50_weeks: None, p85_weeks: None };
1387×
    }
1397×
    let mut rng = Rng(seed | 1);
1407×
    let mut taken: Vec<i64> = Vec::with_capacity(trials);
1417×
    for _ in 0..trials {
14221000×
        let (mut left, mut weeks) = (remaining as i64, 0usize);
14372144×
        while left > 0 && weeks < HORIZON_WEEKS {
14451144×
            left -= throughput[rng.below(throughput.len())] as i64;
14551144×
            weeks += 1;
14651144×
        }
14721000×
        taken.push(weeks as i64);
148
    }
14914×
    let at = |p| percentile(&taken, p).map(|w| w as usize).filter(|w| *w < HORIZON_WEEKS);
1507×
    Forecast { remaining, weeks_of_history, p50_weeks: at(50), p85_weeks: at(85) }
15111×
}
152
153
/// The open iteration's burn-down and scope: at the end of each day so
154
/// far, cards on the board not in `done` (left) and those plus the ones
155
/// that reached `done` since the iteration began (scope).
1561×
pub fn burn_down(moves: &[Move], done: i64, start: i64, days: usize) -> Vec<(usize, usize)> {
1571×
    let mut at: HashMap<i64, Option<i64>> = HashMap::new();
1581×
    let mut done_since: std::collections::HashSet<i64> = Default::default();
1591×
    let mut i = 0;
1601×
    let mut out = Vec::with_capacity(days);
1616×
    for d in 0..days {
1626×
        let end = start + (d as i64 + 1) * DAY_MS;
16315×
        while i < moves.len() && moves[i].at < end {
1649×
            let m = moves[i];
1659×
            at.insert(m.card, m.column);
1669×
            if m.column == Some(done) && m.at >= start {
1672×
                done_since.insert(m.card);
1687×
            }
1699×
            i += 1;
170
        }
17121×
        let left = at.values().filter(|c| c.is_some() && **c != Some(done)).count();
172
        // Done stays done when it leaves the board (an iteration closing).
1736×
        out.push((left, left + done_since.len()));
174
    }
1751×
    out
1761×
}
177
178
/// Points for an SVG polyline of `values` in a `w` × `h` box scaled to
179
/// `max` (at least 1), one step per value.
1801×
pub fn polyline(values: &[usize], max: usize, w: i64, h: i64) -> String {
1811×
    let max = max.max(1) as f64;
1821×
    let n = values.len().max(2) - 1;
1831×
    values
1841×
        .iter()
1851×
        .enumerate()
1863×
        .map(|(i, v)| format!("{},{}", (i as i64 * w) / n as i64, h - ((*v as f64 / max) * h as f64).round() as i64))
1871×
        .collect::<Vec<_>>()
1881×
        .join(" ")
1891×
}
190
191
/// The bands of a cumulative flow chart as SVG polygon points, one per
192
/// column (bottom band last in board order, Done at the bottom): each band
193
/// spans from the sum of the columns after it to that sum plus its own.
1943×
pub fn flow_bands(rows: &[Vec<usize>], w: i64, h: i64) -> Vec<String> {
1953×
    let cols = rows.first().map(|r| r.len()).unwrap_or(0);
1968×
    let max = rows.iter().map(|r| r.iter().sum::<usize>()).max().unwrap_or(0).max(1) as f64;
1973×
    let n = rows.len().max(2) - 1;
19848×
    let x = |i: usize| (i as i64 * w) / n as i64;
19948×
    let y = |v: usize| h - ((v as f64 / max) * h as f64).round() as i64;
2003×
    (0..cols)
2019×
        .map(|k| {
20248×
            let below = |r: &Vec<usize>| r[k + 1..].iter().sum::<usize>();
20324×
            let top: Vec<String> = rows.iter().enumerate().map(|(i, r)| format!("{},{}", x(i), y(below(r) + r[k]))).collect();
20424×
            let bottom: Vec<String> = rows.iter().enumerate().rev().map(|(i, r)| format!("{},{}", x(i), y(below(r)))).collect();
2059×
            [top, bottom].concat().join(" ")
2069×
        })
2073×
        .collect()
2083×
}
209
210
#[cfg(test)]
211
mod tests {
212
    use super::*;
213
214
    const T0: i64 = 1_000 * DAY_MS;
215
21636×
    fn m(card: i64, column: Option<i64>, day: i64) -> Move {
21736×
        Move { card, column, at: T0 + day * DAY_MS + 3_600_000 }
21836×
    }
219
220
    /// Backlog 1, Doing 2, Done 3: card 1 goes the whole way in four days,
221
    /// card 2 reaches Doing on day 2 and stays, card 3 is added straight
222
    /// into Doing on day 3 and done on day 5, card 4 is archived on day 4.
2234×
    fn board() -> Vec<Move> {
2244×
        vec![
2254×
            m(1, Some(1), 0),
2264×
            m(2, Some(1), 0),
2274×
            m(4, Some(1), 0),
2284×
            m(1, Some(2), 1),
2294×
            m(2, Some(2), 2),
2304×
            m(3, Some(2), 3),
2314×
            m(4, None, 4),
2324×
            m(1, Some(3), 5),
2334×
            m(3, Some(3), 5),
234
        ]
2354×
    }
236
237
    #[test]
2381×
    fn cumulative_flow_replays_the_moves() {
2391×
        let rows = cumulative_flow(&board(), &[1, 2, 3], T0, 6);
2401×
        assert_eq!(rows, vec![vec![3, 0, 0], vec![2, 1, 0], vec![1, 2, 0], vec![1, 3, 0], vec![0, 3, 0], vec![0, 1, 2]]);
2411×
        let bands = flow_bands(&rows, 100, 30);
2421×
        assert_eq!(bands.len(), 3);
2431×
        assert!(bands[2].starts_with("0,30"), "Done is the bottom band: {}", bands[2]);
2441×
    }
245
246
    #[test]
2471×
    fn cycle_time_and_throughput() {
2481×
        let mut times = cycle_times(&board(), 1, 3);
2491×
        times.sort();
2501×
        assert_eq!(times, vec![2 * DAY_MS, 4 * DAY_MS], "card 3: day 3 to 5; card 1: day 1 to 5");
2511×
        assert_eq!(percentile(&times, 50), Some(2 * DAY_MS));
2521×
        assert_eq!(percentile(&times, 85), Some(4 * DAY_MS));
2531×
        assert_eq!(percentile(&[], 50), None);
2541×
        let now = T0 + 7 * DAY_MS;
2551×
        assert_eq!(weekly_throughput(&board(), 3, now, 2), vec![0, 2]);
2561×
    }
257
258
    #[test]
2591×
    fn the_forecast_is_seeded_and_honest() {
260
        // Two cards a week, every week: ten cards take five weeks, always.
2611×
        let f = forecast(&[2, 2, 2, 2], 10, 1000, 7);
2621×
        assert_eq!((f.p50_weeks, f.p85_weeks, f.weeks_of_history), (Some(5), Some(5), 4));
263
        // Uneven weeks: the 85% date is no earlier than the 50% one, and the
264
        // same seed gives the same answer.
2651×
        let a = forecast(&[0, 1, 4, 2, 3], 12, 2000, 42);
2661×
        assert_eq!(a, forecast(&[0, 1, 4, 2, 3], 12, 2000, 42));
2671×
        let (p50, p85) = (a.p50_weeks.unwrap(), a.p85_weeks.unwrap());
2681×
        assert!((4..=7).contains(&p50) && p85 >= p50, "{a:?}");
269
        // Nothing finished yet: no forecast, said plainly.
2701×
        assert_eq!(forecast(&[0, 0], 5, 100, 1).p50_weeks, None);
2711×
        assert_eq!(forecast(&[], 5, 100, 1).p50_weeks, None);
2721×
        assert_eq!(forecast(&[1], 0, 100, 1).p85_weeks, Some(0));
2731×
    }
274
275
    #[test]
2761×
    fn burn_down_counts_left_and_scope() {
2771×
        let bd = burn_down(&board(), 3, T0, 6);
2781×
        assert_eq!(bd[0], (3, 3));
2791×
        assert_eq!(bd[3], (4, 4), "card 3 added on day 3 grows the scope");
2801×
        assert_eq!(bd[4], (3, 3), "card 4 archived on day 4 leaves it");
2811×
        assert_eq!(bd[5], (1, 3), "two done");
2821×
        assert_eq!(polyline(&[0, 2, 4], 4, 10, 10), "0,10 5,5 10,0");
2831×
    }
284
}