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