1use super::calibration::{HistoryIndex, ascending, elapsed_hours, exclusions};
5use crate::{Command, Exclusion, ExclusionReason, Operation};
6use chrono::{DateTime, Datelike, TimeDelta, Utc};
7use dpm_model::{ActorId, ActorKind, Key, Plan, WorkItem, WorkItemId, WorkStatus};
8use dpm_schedule::percentile;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12pub const THROUGHPUT_WEEKS: usize = 8;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17pub struct FlowReport {
18 pub cycle_time: Durations,
20 pub lead_time: Durations,
22 pub throughput: Throughput,
24 pub aging: Vec<AgingWork>,
26 pub reliability: Reliability,
28}
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct Durations {
33 pub count: usize,
35 pub median_hours: Option<f64>,
37 pub p85_hours: Option<f64>,
39 pub excluded: Vec<Exclusion>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Throughput {
46 pub last_7_days: usize,
48 pub last_28_days: usize,
50 pub weeks: Vec<WeekCount>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct WeekCount {
57 pub week: String,
59 pub verified: usize,
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65pub struct AgingWork {
66 pub key: Key,
68 pub status: WorkStatus,
70 pub owner: Option<ActorId>,
72 pub hours_since_claim: Option<f64>,
74 pub hours_since_start: Option<f64>,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct Reliability {
81 pub total: ClaimOutcomes,
83 pub by_holder: Vec<HolderOutcomes>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct HolderOutcomes {
90 pub holder: ActorKind,
92 #[serde(flatten)]
94 pub outcomes: ClaimOutcomes,
95}
96
97#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
99pub struct ClaimOutcomes {
100 pub episodes: usize,
102 pub verified: usize,
104 pub verified_after_rejection: usize,
106 pub released: usize,
108 pub handed_off: usize,
110 pub open: usize,
112 pub verified_fraction: Option<f64>,
115}
116
117pub(crate) fn flow(
122 plan: &Plan,
123 index: &HistoryIndex<'_>,
124 bulk: &BTreeSet<WorkItemId>,
125 now: DateTime<Utc>,
126) -> FlowReport {
127 let done: Vec<&WorkItem> = plan
128 .work_items
129 .values()
130 .filter(|w| w.is_executable() && w.execution.status.satisfies_dependency())
131 .collect();
132 let mut cycle = Measured::default();
133 let mut lead = Measured::default();
134 let mut verified = Vec::new();
135 for work in done {
136 let Some(at) = work.execution.events.verified_at else {
137 cycle.skip(ExclusionReason::VerificationUnrecorded, work);
138 lead.skip(ExclusionReason::VerificationUnrecorded, work);
139 continue;
140 };
141 verified.push(at);
142 if bulk.contains(&work.id) {
143 cycle.skip(ExclusionReason::BulkRecorded, work);
144 lead.skip(ExclusionReason::BulkRecorded, work);
145 continue;
146 }
147 match work.execution.events.started_at {
148 Some(started) => cycle.hours.push(elapsed_hours(started, at)),
149 None => cycle.skip(ExclusionReason::StartUnrecorded, work),
150 }
151 let claim = index
152 .of(work.id)
153 .iter()
154 .find(|op| matches!(op.command, Command::Claim { .. }));
155 match claim {
156 Some(claim) => lead.hours.push(elapsed_hours(claim.timestamp, at)),
157 None => lead.skip(ExclusionReason::ClaimedBeforeHistory, work),
158 }
159 }
160 FlowReport {
161 cycle_time: cycle.durations(),
162 lead_time: lead.durations(),
163 throughput: throughput(verified.iter().copied(), now),
164 aging: aging(plan, index, now),
165 reliability: reliability(index.work_operations()),
166 }
167}
168
169#[derive(Default)]
171struct Measured {
172 hours: Vec<f64>,
173 skipped: Vec<(ExclusionReason, Key)>,
174}
175
176impl Measured {
177 fn skip(&mut self, reason: ExclusionReason, work: &WorkItem) {
178 self.skipped.push((reason, work.key.clone()));
179 }
180
181 fn durations(self) -> Durations {
182 let sorted = ascending(self.hours);
183 let known = !sorted.is_empty();
184 Durations {
185 count: sorted.len(),
186 median_hours: known.then(|| percentile(&sorted, 0.5)),
187 p85_hours: known.then(|| percentile(&sorted, 0.85)),
188 excluded: exclusions(self.skipped),
189 }
190 }
191}
192
193fn throughput(
194 verified: impl Iterator<Item = DateTime<Utc>> + Clone,
195 now: DateTime<Utc>,
196) -> Throughput {
197 let within = |days: i64| {
198 verified
199 .clone()
200 .filter(|at| *at <= now && now - *at < TimeDelta::days(days))
201 .count()
202 };
203 let week_of = |at: DateTime<Utc>| {
204 let week = at.iso_week();
205 (week.year(), week.week())
206 };
207 let weeks = (0..THROUGHPUT_WEEKS)
208 .rev()
209 .filter_map(|back| now.checked_sub_signed(TimeDelta::weeks(back as i64)))
210 .map(|at| {
211 let (year, week) = week_of(at);
212 WeekCount {
213 week: format!("{year}-W{week:02}"),
214 verified: verified
215 .clone()
216 .filter(|v| week_of(*v) == (year, week))
217 .count(),
218 }
219 })
220 .collect();
221 Throughput {
222 last_7_days: within(7),
223 last_28_days: within(28),
224 weeks,
225 }
226}
227
228fn aging(plan: &Plan, index: &HistoryIndex<'_>, now: DateTime<Utc>) -> Vec<AgingWork> {
229 const HELD: [WorkStatus; 4] = [
230 WorkStatus::Claimed,
231 WorkStatus::InProgress,
232 WorkStatus::Blocked,
233 WorkStatus::Submitted,
234 ];
235 let mut aging: Vec<AgingWork> = plan
236 .work_items
237 .values()
238 .filter(|w| w.is_executable() && HELD.contains(&w.execution.status))
239 .map(|w| {
240 let held_since = w.execution.events.claimed_at.or_else(|| {
241 index
242 .last(w.id, |c| {
243 matches!(c, Command::Claim { .. } | Command::Handoff { .. })
244 })
245 .map(|op| op.timestamp)
246 });
247 AgingWork {
248 key: w.key.clone(),
249 status: w.execution.status,
250 owner: w.execution.owner.clone(),
251 hours_since_claim: held_since.map(|at| elapsed_hours(at, now)),
252 hours_since_start: w
253 .execution
254 .events
255 .started_at
256 .map(|at| elapsed_hours(at, now)),
257 }
258 })
259 .collect();
260 aging.sort_by(|a, b| {
261 let age = |w: &AgingWork| w.hours_since_claim.unwrap_or(f64::NEG_INFINITY);
262 age(b)
263 .total_cmp(&age(a))
264 .then_with(|| a.key.natural_cmp(&b.key))
265 });
266 aging
267}
268
269#[derive(Clone, Copy)]
271enum Ending {
272 Verified { rejected: bool },
273 Released,
274 HandedOff,
275 Open,
276}
277
278fn reliability(operations: &[&Operation]) -> Reliability {
279 let mut open: BTreeMap<WorkItemId, (ActorKind, bool)> = BTreeMap::new();
280 let mut ended: Vec<(ActorKind, Ending)> = Vec::new();
281 for operation in operations {
282 match &operation.command {
283 Command::Claim { work } => {
284 open.insert(*work, (operation.actor.kind, false));
285 }
286 Command::Handoff { work, to, .. } => {
287 if let Some((holder, _)) = open.insert(*work, (to.kind, false)) {
288 ended.push((holder, Ending::HandedOff));
289 }
290 }
291 Command::Release { work, .. } => {
292 if let Some((holder, _)) = open.remove(work) {
293 ended.push((holder, Ending::Released));
294 }
295 }
296 Command::Reject { work, .. } => {
297 if let Some(episode) = open.get_mut(work) {
298 episode.1 = true;
299 }
300 }
301 Command::Verify { work, .. } => {
302 if let Some((holder, rejected)) = open.remove(work) {
303 ended.push((holder, Ending::Verified { rejected }));
304 }
305 }
306 _ => {}
307 }
308 }
309 ended.extend(open.into_values().map(|(holder, _)| (holder, Ending::Open)));
310 let mut by_holder: BTreeMap<ActorKind, ClaimOutcomes> = BTreeMap::new();
311 let mut total = ClaimOutcomes::default();
312 for (holder, ending) in ended {
313 count(&mut total, ending);
314 count(by_holder.entry(holder).or_default(), ending);
315 }
316 Reliability {
317 total: finish(total),
318 by_holder: by_holder
319 .into_iter()
320 .map(|(holder, outcomes)| HolderOutcomes {
321 holder,
322 outcomes: finish(outcomes),
323 })
324 .collect(),
325 }
326}
327
328fn count(outcomes: &mut ClaimOutcomes, ending: Ending) {
329 outcomes.episodes = outcomes.episodes.wrapping_add(1);
330 let slot = match ending {
331 Ending::Verified { rejected } => {
332 if rejected {
333 outcomes.verified_after_rejection =
334 outcomes.verified_after_rejection.wrapping_add(1);
335 }
336 &mut outcomes.verified
337 }
338 Ending::Released => &mut outcomes.released,
339 Ending::HandedOff => &mut outcomes.handed_off,
340 Ending::Open => &mut outcomes.open,
341 };
342 *slot = slot.wrapping_add(1);
343}
344
345fn finish(mut outcomes: ClaimOutcomes) -> ClaimOutcomes {
348 let closed = outcomes.verified + outcomes.released;
349 outcomes.verified_fraction = (closed > 0).then(|| outcomes.verified as f64 / closed as f64);
350 outcomes
351}
352
353#[cfg(test)]
354mod tests;