| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 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 | |
|
| 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 | |
|
| 22 | |
|
| 23 | |
|
| 24 | 5× | pub fn cumulative_flow(moves: &[Move], columns: &[i64], first_day: i64, days: usize) -> Vec<Vec<usize>> { |
| 25 | 5× | let mut at: HashMap<i64, Option<i64>> = HashMap::new(); |
| 26 | 5× | let mut i = 0; |
| 27 | 5× | let mut out = Vec::with_capacity(days); |
| 28 | 10× | for d in 0..days { |
| 29 | 10× | let end = first_day + (d as i64 + 1) * DAY_MS; |
| 30 | 32× | while i < moves.len() && moves[i].at < end { |
| 31 | 22× | at.insert(moves[i].card, moves[i].column); |
| 32 | 22× | i += 1; |
| 33 | 22× | } |
| 34 | 10× | let mut counts = vec![0; columns.len()]; |
| 35 | 28× | for col in at.values().flatten() { |
| 36 | 52× | if let Some(k) = columns.iter().position(|c| c == col) { |
| 37 | 28× | counts[k] += 1; |
| 38 | 28× | } |
| 39 | | } |
| 40 | 10× | out.push(counts); |
| 41 | | } |
| 42 | 5× | out |
| 43 | 5× | } |
| 44 | |
|
| 45 | |
|
| 46 | |
|
| 47 | |
|
| 48 | 5× | pub fn cycle_times(moves: &[Move], first_column: i64, done: i64) -> Vec<i64> { |
| 49 | 5× | let mut started: HashMap<i64, i64> = HashMap::new(); |
| 50 | 5× | let mut finished: BTreeMap<i64, i64> = BTreeMap::new(); |
| 51 | 22× | for m in moves { |
| 52 | 22× | let Some(col) = m.column else { continue }; |
| 53 | 21× | if col != first_column && !started.contains_key(&m.card) { |
| 54 | 9× | started.insert(m.card, m.at); |
| 55 | 12× | } |
| 56 | 21× | if col == done && !finished.contains_key(&m.card) { |
| 57 | 6× | if let Some(s) = started.get(&m.card) { |
| 58 | 6× | finished.insert(m.card, m.at - s); |
| 59 | 6× | } |
| 60 | 15× | } |
| 61 | | } |
| 62 | 5× | finished.into_values().collect() |
| 63 | 5× | } |
| 64 | |
|
| 65 | |
|
| 66 | 25× | pub fn percentile(values: &[i64], p: u32) -> Option<i64> { |
| 67 | 25× | if values.is_empty() { |
| 68 | 1× | return None; |
| 69 | 24× | } |
| 70 | 24× | let mut v = values.to_vec(); |
| 71 | 24× | v.sort_unstable(); |
| 72 | 24× | let rank = ((p as f64 / 100.0) * v.len() as f64).ceil().max(1.0) as usize; |
| 73 | 24× | Some(v[rank.min(v.len()) - 1]) |
| 74 | 25× | } |
| 75 | |
|
| 76 | |
|
| 77 | |
|
| 78 | 5× | pub fn weekly_throughput(moves: &[Move], done: i64, now: i64, weeks: usize) -> Vec<usize> { |
| 79 | 5× | let mut first: HashMap<i64, i64> = HashMap::new(); |
| 80 | 22× | for m in moves { |
| 81 | 22× | if m.column == Some(done) { |
| 82 | 6× | first.entry(m.card).or_insert(m.at); |
| 83 | 16× | } |
| 84 | | } |
| 85 | 5× | let start = now - weeks as i64 * WEEK_MS; |
| 86 | 5× | let mut out = vec![0; weeks]; |
| 87 | 6× | for at in first.values() { |
| 88 | 6× | if *at >= start && *at < now { |
| 89 | 6× | out[((at - start) / WEEK_MS) as usize] += 1; |
| 90 | 6× | } |
| 91 | | } |
| 92 | 5× | out |
| 93 | 5× | } |
| 94 | |
|
| 95 | |
|
| 96 | |
|
| 97 | |
|
| 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 | |
|
| 107 | |
|
| 108 | |
|
| 109 | | struct Rng(u64); |
| 110 | |
|
| 111 | | impl Rng { |
| 112 | 51144× | fn next(&mut self) -> u64 { |
| 113 | 51144× | let mut x = self.0; |
| 114 | 51144× | x ^= x >> 12; |
| 115 | 51144× | x ^= x << 25; |
| 116 | 51144× | x ^= x >> 27; |
| 117 | 51144× | self.0 = x; |
| 118 | 51144× | x.wrapping_mul(0x2545_F491_4F6C_DD1D) |
| 119 | 51144× | } |
| 120 | 51144× | fn below(&mut self, n: usize) -> usize { |
| 121 | 51144× | (self.next() % n as u64) as usize |
| 122 | 51144× | } |
| 123 | | } |
| 124 | |
|
| 125 | |
|
| 126 | | const HORIZON_WEEKS: usize = 520; |
| 127 | |
|
| 128 | |
|
| 129 | |
|
| 130 | |
|
| 131 | 11× | pub fn forecast(throughput: &[usize], remaining: usize, trials: usize, seed: u64) -> Forecast { |
| 132 | 11× | let weeks_of_history = throughput.len(); |
| 133 | 11× | if remaining == 0 { |
| 134 | 2× | return Forecast { remaining, weeks_of_history, p50_weeks: Some(0), p85_weeks: Some(0) }; |
| 135 | 9× | } |
| 136 | 11× | if throughput.iter().all(|t| *t == 0) || trials == 0 { |
| 137 | 2× | return Forecast { remaining, weeks_of_history, p50_weeks: None, p85_weeks: None }; |
| 138 | 7× | } |
| 139 | 7× | let mut rng = Rng(seed | 1); |
| 140 | 7× | let mut taken: Vec<i64> = Vec::with_capacity(trials); |
| 141 | 7× | for _ in 0..trials { |
| 142 | 21000× | let (mut left, mut weeks) = (remaining as i64, 0usize); |
| 143 | 72144× | while left > 0 && weeks < HORIZON_WEEKS { |
| 144 | 51144× | left -= throughput[rng.below(throughput.len())] as i64; |
| 145 | 51144× | weeks += 1; |
| 146 | 51144× | } |
| 147 | 21000× | taken.push(weeks as i64); |
| 148 | | } |
| 149 | 14× | let at = |p| percentile(&taken, p).map(|w| w as usize).filter(|w| *w < HORIZON_WEEKS); |
| 150 | 7× | Forecast { remaining, weeks_of_history, p50_weeks: at(50), p85_weeks: at(85) } |
| 151 | 11× | } |
| 152 | |
|
| 153 | |
|
| 154 | |
|
| 155 | |
|
| 156 | 1× | pub fn burn_down(moves: &[Move], done: i64, start: i64, days: usize) -> Vec<(usize, usize)> { |
| 157 | 1× | let mut at: HashMap<i64, Option<i64>> = HashMap::new(); |
| 158 | 1× | let mut done_since: std::collections::HashSet<i64> = Default::default(); |
| 159 | 1× | let mut i = 0; |
| 160 | 1× | let mut out = Vec::with_capacity(days); |
| 161 | 6× | for d in 0..days { |
| 162 | 6× | let end = start + (d as i64 + 1) * DAY_MS; |
| 163 | 15× | while i < moves.len() && moves[i].at < end { |
| 164 | 9× | let m = moves[i]; |
| 165 | 9× | at.insert(m.card, m.column); |
| 166 | 9× | if m.column == Some(done) && m.at >= start { |
| 167 | 2× | done_since.insert(m.card); |
| 168 | 7× | } |
| 169 | 9× | i += 1; |
| 170 | | } |
| 171 | 21× | let left = at.values().filter(|c| c.is_some() && **c != Some(done)).count(); |
| 172 | | |
| 173 | 6× | out.push((left, left + done_since.len())); |
| 174 | | } |
| 175 | 1× | out |
| 176 | 1× | } |
| 177 | |
|
| 178 | |
|
| 179 | |
|
| 180 | 1× | pub fn polyline(values: &[usize], max: usize, w: i64, h: i64) -> String { |
| 181 | 1× | let max = max.max(1) as f64; |
| 182 | 1× | let n = values.len().max(2) - 1; |
| 183 | 1× | values |
| 184 | 1× | .iter() |
| 185 | 1× | .enumerate() |
| 186 | 3× | .map(|(i, v)| format!("{},{}", (i as i64 * w) / n as i64, h - ((*v as f64 / max) * h as f64).round() as i64)) |
| 187 | 1× | .collect::<Vec<_>>() |
| 188 | 1× | .join(" ") |
| 189 | 1× | } |
| 190 | |
|
| 191 | |
|
| 192 | |
|
| 193 | |
|
| 194 | 3× | pub fn flow_bands(rows: &[Vec<usize>], w: i64, h: i64) -> Vec<String> { |
| 195 | 3× | let cols = rows.first().map(|r| r.len()).unwrap_or(0); |
| 196 | 8× | let max = rows.iter().map(|r| r.iter().sum::<usize>()).max().unwrap_or(0).max(1) as f64; |
| 197 | 3× | let n = rows.len().max(2) - 1; |
| 198 | 48× | let x = |i: usize| (i as i64 * w) / n as i64; |
| 199 | 48× | let y = |v: usize| h - ((v as f64 / max) * h as f64).round() as i64; |
| 200 | 3× | (0..cols) |
| 201 | 9× | .map(|k| { |
| 202 | 48× | let below = |r: &Vec<usize>| r[k + 1..].iter().sum::<usize>(); |
| 203 | 24× | let top: Vec<String> = rows.iter().enumerate().map(|(i, r)| format!("{},{}", x(i), y(below(r) + r[k]))).collect(); |
| 204 | 24× | let bottom: Vec<String> = rows.iter().enumerate().rev().map(|(i, r)| format!("{},{}", x(i), y(below(r)))).collect(); |
| 205 | 9× | [top, bottom].concat().join(" ") |
| 206 | 9× | }) |
| 207 | 3× | .collect() |
| 208 | 3× | } |
| 209 | |
|
| 210 | | #[cfg(test)] |
| 211 | | mod tests { |
| 212 | | use super::*; |
| 213 | |
|
| 214 | | const T0: i64 = 1_000 * DAY_MS; |
| 215 | |
|
| 216 | 36× | fn m(card: i64, column: Option<i64>, day: i64) -> Move { |
| 217 | 36× | Move { card, column, at: T0 + day * DAY_MS + 3_600_000 } |
| 218 | 36× | } |
| 219 | |
|
| 220 | | |
| 221 | | |
| 222 | | |
| 223 | 4× | fn board() -> Vec<Move> { |
| 224 | 4× | vec![ |
| 225 | 4× | m(1, Some(1), 0), |
| 226 | 4× | m(2, Some(1), 0), |
| 227 | 4× | m(4, Some(1), 0), |
| 228 | 4× | m(1, Some(2), 1), |
| 229 | 4× | m(2, Some(2), 2), |
| 230 | 4× | m(3, Some(2), 3), |
| 231 | 4× | m(4, None, 4), |
| 232 | 4× | m(1, Some(3), 5), |
| 233 | 4× | m(3, Some(3), 5), |
| 234 | | ] |
| 235 | 4× | } |
| 236 | |
|
| 237 | | #[test] |
| 238 | 1× | fn cumulative_flow_replays_the_moves() { |
| 239 | 1× | let rows = cumulative_flow(&board(), &[1, 2, 3], T0, 6); |
| 240 | 1× | 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]]); |
| 241 | 1× | let bands = flow_bands(&rows, 100, 30); |
| 242 | 1× | assert_eq!(bands.len(), 3); |
| 243 | 1× | assert!(bands[2].starts_with("0,30"), "Done is the bottom band: {}", bands[2]); |
| 244 | 1× | } |
| 245 | |
|
| 246 | | #[test] |
| 247 | 1× | fn cycle_time_and_throughput() { |
| 248 | 1× | let mut times = cycle_times(&board(), 1, 3); |
| 249 | 1× | times.sort(); |
| 250 | 1× | assert_eq!(times, vec![2 * DAY_MS, 4 * DAY_MS], "card 3: day 3 to 5; card 1: day 1 to 5"); |
| 251 | 1× | assert_eq!(percentile(×, 50), Some(2 * DAY_MS)); |
| 252 | 1× | assert_eq!(percentile(×, 85), Some(4 * DAY_MS)); |
| 253 | 1× | assert_eq!(percentile(&[], 50), None); |
| 254 | 1× | let now = T0 + 7 * DAY_MS; |
| 255 | 1× | assert_eq!(weekly_throughput(&board(), 3, now, 2), vec![0, 2]); |
| 256 | 1× | } |
| 257 | |
|
| 258 | | #[test] |
| 259 | 1× | fn the_forecast_is_seeded_and_honest() { |
| 260 | | |
| 261 | 1× | let f = forecast(&[2, 2, 2, 2], 10, 1000, 7); |
| 262 | 1× | assert_eq!((f.p50_weeks, f.p85_weeks, f.weeks_of_history), (Some(5), Some(5), 4)); |
| 263 | | |
| 264 | | |
| 265 | 1× | let a = forecast(&[0, 1, 4, 2, 3], 12, 2000, 42); |
| 266 | 1× | assert_eq!(a, forecast(&[0, 1, 4, 2, 3], 12, 2000, 42)); |
| 267 | 1× | let (p50, p85) = (a.p50_weeks.unwrap(), a.p85_weeks.unwrap()); |
| 268 | 1× | assert!((4..=7).contains(&p50) && p85 >= p50, "{a:?}"); |
| 269 | | |
| 270 | 1× | assert_eq!(forecast(&[0, 0], 5, 100, 1).p50_weeks, None); |
| 271 | 1× | assert_eq!(forecast(&[], 5, 100, 1).p50_weeks, None); |
| 272 | 1× | assert_eq!(forecast(&[1], 0, 100, 1).p85_weeks, Some(0)); |
| 273 | 1× | } |
| 274 | |
|
| 275 | | #[test] |
| 276 | 1× | fn burn_down_counts_left_and_scope() { |
| 277 | 1× | let bd = burn_down(&board(), 3, T0, 6); |
| 278 | 1× | assert_eq!(bd[0], (3, 3)); |
| 279 | 1× | assert_eq!(bd[3], (4, 4), "card 3 added on day 3 grows the scope"); |
| 280 | 1× | assert_eq!(bd[4], (3, 3), "card 4 archived on day 4 leaves it"); |
| 281 | 1× | assert_eq!(bd[5], (1, 3), "two done"); |
| 282 | 1× | assert_eq!(polyline(&[0, 2, 4], 4, 10, 10), "0,10 5,5 10,0"); |
| 283 | 1× | } |
| 284 | | } |