tix/analytics/units.rs
1//! Estimate mode: the shared unit conversion, and the series estimate mode folds
2//! (mission `burndown-scope-analytics-01M3C9J1`, WP03 T013–T015).
3//!
4//! # What estimate mode is, and what it is not
5//!
6//! Count mode ([`crate::analytics::series`]) values every counted contribution at
7//! `1`. Estimate mode runs the **same fold over the same recorded contributions**
8//! and values each one at the estimated size of the issue it belongs to, in one
9//! explicitly selected unit:
10//!
11//! | the fold sees | count mode | estimate mode |
12//! |---|---|---|
13//! | a recorded scope addition | `+1` on the day | `+` the issue's estimate |
14//! | a recorded completion | `+1` on the day | `+` the issue's estimate |
15//! | a contribution the record cannot quantify (`delta: None`) | nothing; the day is limited | nothing; the day is limited |
16//! | a recorded estimate change | nothing | nothing; the day is limited, and it is reported |
17//! | any other recorded event | nothing; reported | nothing; reported |
18//!
19//! The two modes therefore agree on the **days**, on the provenance of every day and
20//! on what they can and cannot answer; they differ only in what a contribution is
21//! worth. On a project where every counted contribution is worth the same `n`, the
22//! estimate series is exactly `n` times the count series — the parity the package's
23//! tests anchor.
24//!
25//! # The conversion is the crate's, not a second rule (T013, C-003)
26//!
27//! An issue's size is read by [`EstimateValue::parse`] (the raw text kept verbatim;
28//! human duration words like `day` fold to the board symbol `d`, and each term is
29//! converted separately) and turned into the series' unit by
30//! [`EstimateValue::converted_to`], whose arithmetic is
31//! [`crate::wip::WipConfig::convert`] — the **same** board registry `tix capacity`
32//! converts through, resolved from [`Config::default_config`] once per series
33//! ([`EstimateConversion::resolve`]). Nothing here invents a unit, a factor or a
34//! rounding rule, and a unit the board does not declare is reported rather than
35//! guessed.
36//!
37//! # A missing or mixed unit is a finding, not a number (T015)
38//!
39//! * A series whose **unit the board does not declare** cannot count anything: the
40//! fold returns no point at all and says why (C-003's "never invent arithmetic").
41//! * A member whose stated estimate **cannot be converted** contributes nothing, is
42//! reported once per (reason, unit, field) group, and limits the coverage of every
43//! day its contributions fall on.
44//! * An **unestimated** member is not a zero either: its contributions have no size,
45//! which is a finding and a coverage marker, never a `0` in the sum.
46//!
47//! Every number the series publishes is a **lower bound over the contributions the
48//! shared conversion could value**, exactly as [`crate::capacity::calculate`]
49//! publishes `committed` beside its `uncounted_issues`/`unestimated_issues` counts —
50//! with the coverage marker and the finding standing in for the counts a
51//! [`DailyPoint`] cannot carry. A day with no recorded history keeps `None` for its
52//! day's changes and is `coverage_limited`, as in count mode; a day whose arithmetic
53//! was refused is `coverage_limited` with the finding beside it. Nothing
54//! unanswerable is rendered as a zero.
55//!
56//! # An estimate change is reported, never replayed
57//!
58//! `estimate_changed` records a before/after pair in its detail. Replaying those
59//! pairs into per-day sizes would be a **new formula** (`spec.md` §6: "Escalate any
60//! formula that treats missing events as known"), and it would double-count the
61//! change against the stated estimate that every other contribution is valued at.
62//! The fold therefore values every contribution at the estimate the issue states
63//! **now** and reports any re-sizing recorded inside the period, limiting the day it
64//! was recorded on: the caveat stays visible and no level is invented.
65//!
66//! # Determinism (C-007)
67//!
68//! Members are read in the snapshot's own `(number, filename)` order, the recorded
69//! contributions in the index's first-seen order, group tallies are `BTreeMap`s, and
70//! the findings are sorted by their own `Ord` — so two runs over one snapshot, in
71//! any input order, produce the same bytes.
72//!
73//! # Findings
74//!
75//! The estimate fold speaks the **one** vocabulary
76//! [`crate::analytics::diagnostics`] fixes — `missing_conversion` for a value the
77//! shared conversion could not state, `missing_history` for a contribution the
78//! record cannot size, `unclassified_event` for an event that moves no line — and
79//! invents no seventh name, so WP02's `diagnostics.rs` needs no edit for this
80//! package.
81//!
82//! # Why the day grid comes from the count fold
83//!
84//! Which days a series covers is not a property of a mode: it is the scope's period
85//! and the recorded days ([`ScopeSnapshot`]). The estimate fold therefore takes its
86//! day list from [`ScopeSnapshot::series`]'s count fold — the crate's one period
87//! enumeration, including its refusal of a period it cannot enumerate — instead of
88//! re-implementing the calendar rules beside it, and it reports a gap of days with
89//! no recorded history in the count fold's own sentence, because that fact is the
90//! same in both modes.
91//!
92//! An estimate series therefore carries **everything a count series carries** —
93//! the snapshot's own findings, the gap findings — and adds the findings only its
94//! valuation can produce. A reader that switches `--mode` on one scope keeps the
95//! context and gains the unit caveats.
96
97use std::collections::{BTreeMap, BTreeSet};
98
99use crate::capacity::estimate::EstimateValue;
100use crate::config::Config;
101use crate::model::Issue;
102use crate::wip::WipConfig;
103
104use super::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
105use super::events::{AnalyticsEvent, AnalyticsScope, EventClass};
106use super::series::{
107 BurndownSeries, Coverage, DailyPoint, ESTIMATE_FORMULA_VERSION, Mode, ScopeSnapshot,
108 event_id_key,
109};
110
111/// The board's declared estimate units, in the order the board carries them.
112///
113/// The board is [`Config::default_config`] — the one [`crate::capacity`]'s issue
114/// snapshot converts under — so a surface validating a `--unit` before folding asks
115/// the same table the fold will ask (C-008). A loaded configuration normalizes the
116/// order, so this is stable for one config rather than necessarily the order the file
117/// was written in; the finding below quotes it as the declared table.
118#[must_use]
119pub fn declared_units(config: &Config) -> Vec<String> {
120 config.default_config().wip.units.keys().cloned().collect()
121}
122
123/// One series' resolved estimate conversion (T013).
124///
125/// Resolved **once per series**: the board's registry is read at resolve time, and
126/// every member's estimate is then converted through [`EstimateConversion::value_of`],
127/// so a 10,000-issue series does not re-read the configuration per issue.
128#[derive(Debug, Clone)]
129pub struct EstimateConversion {
130 registry: WipConfig,
131 unit: String,
132}
133
134impl EstimateConversion {
135 /// Resolve the shared conversion for `unit`, or `None` when the board does not
136 /// declare that unit.
137 ///
138 /// `None` is not a fallback: the registry has no factor for the unit, so
139 /// [`crate::wip::WipConfig::convert`] would refuse every value anyway (C-003).
140 /// The caller reports it once instead of converting per issue.
141 #[must_use]
142 pub fn resolve(config: &Config, unit: &str) -> Option<Self> {
143 let registry = config.default_config().wip.clone();
144 let unit = unit.trim();
145 registry.factor(unit)?;
146 Some(Self {
147 registry,
148 unit: unit.to_string(),
149 })
150 }
151
152 /// The unit every value in the series counts in.
153 #[must_use]
154 pub fn unit(&self) -> &str {
155 &self.unit
156 }
157
158 /// One issue's stated estimate, read and converted in this unit.
159 ///
160 /// The crate's one estimate seam: [`EstimateValue::parse`] (raw text kept
161 /// verbatim; prose units fold to their board symbol and every term is converted
162 /// separately) followed by [`EstimateValue::converted_to`] (the registry's
163 /// arithmetic). A blank or absent statement is
164 /// [`EstimateValue::unestimated`] — "no estimate" is not an error, and it never
165 /// becomes a zero.
166 #[must_use]
167 pub fn value_of(&self, number: u32, stated: Option<&str>) -> EstimateValue {
168 match stated {
169 Some(raw) if !raw.trim().is_empty() => {
170 EstimateValue::parse(number, raw).converted_to(number, &self.registry, &self.unit)
171 }
172 _ => EstimateValue::unestimated(),
173 }
174 }
175
176 /// How one member's stated estimate reads in this unit.
177 fn size_of(&self, number: u32, stated: Option<&str>) -> MemberSize {
178 let value = self.value_of(number, stated);
179 if let Some(size) = value.converted_value {
180 return MemberSize::Sized(i64::from(size));
181 }
182 if value.is_unestimated() {
183 return MemberSize::Unestimated;
184 }
185 // A stated estimate that could not be read or converted. The registry's own
186 // finding is kept verbatim (it already names the issue), with its
187 // machine-readable kind and its own remedy.
188 let Some(finding) = value.diagnostics().first() else {
189 // Unreachable for the shared seam: a value that is neither unestimated
190 // nor converted carries the finding that refused it. Reported as the
191 // conversion problem it must have been rather than dropped.
192 return MemberSize::Refused(Refused {
193 key: RefusalKey {
194 kind: "missing_conversion",
195 spelled: value.unit().map(str::to_string),
196 },
197 reason: format!("issue #{number} states `{}`", value.render()),
198 remedy: None,
199 });
200 };
201 MemberSize::Refused(Refused {
202 key: RefusalKey {
203 kind: finding.kind.as_str(),
204 spelled: value.unit().map(str::to_string),
205 },
206 reason: finding.message.clone(),
207 remedy: finding.because.clone(),
208 })
209 }
210}
211
212/// Fold `snapshot` into an **estimate-mode** series counting in `unit` (T014/T015).
213///
214/// `issues` is the same slice [`ScopeSnapshot::build`] read, so a member's stated
215/// estimate is looked up in the record the snapshot was folded from and nothing is
216/// re-read from a store. `config` supplies the board whose unit table the conversion
217/// runs under (the same one `tix capacity` uses).
218///
219/// The result is a [`BurndownSeries`] — the crate's one series shape — with
220/// `mode: Estimate`, `unit: Some(unit)`, the documented
221/// [`ESTIMATE_FORMULA_VERSION`], the days and coverage the count fold would report,
222/// and this fold's own findings beside them.
223#[must_use]
224pub fn estimate_series(
225 snapshot: &ScopeSnapshot,
226 issues: &[Issue],
227 config: &Config,
228 unit: &str,
229) -> BurndownSeries {
230 let scope = snapshot.scope();
231 let Some(conversion) = EstimateConversion::resolve(config, unit) else {
232 let mut diagnostics = snapshot.diagnostics().to_vec();
233 diagnostics.push(undeclared_unit(scope, unit.trim(), config));
234 diagnostics.sort();
235 return empty(scope, unit.trim(), diagnostics);
236 };
237
238 // The days come from the count fold: one period enumeration for the whole
239 // module, and its refusal when the period cannot be enumerated at all.
240 let days = snapshot.series(Mode::Count);
241 if days.is_empty() {
242 return empty(scope, conversion.unit(), days.diagnostics);
243 }
244
245 let sizes = size_members(snapshot, issues, &conversion);
246 let valued = value_days(snapshot, &sizes);
247
248 let origin_known = scope.period_start().is_some();
249 let global_weak = snapshot.unplaced_events() > 0;
250 let mut points = Vec::with_capacity(days.points.len());
251 let mut scope_total = 0i64;
252 let mut completed = 0i64;
253 let mut first_unanswerable: Option<(&str, i64, i64)> = None;
254 let mut level_overflow: Option<&str> = None;
255 for covered in &days.points {
256 let date = covered.date.as_str();
257 let observed = snapshot.observed_days().contains(date);
258 let added = valued.added.get(date).copied();
259 let done = valued.completed.get(date).copied();
260 if observed
261 && level_overflow.is_none()
262 && (!super::series::carry_level(&mut scope_total, added.unwrap_or(0))
263 || !super::series::carry_level(&mut completed, done.unwrap_or(0)))
264 {
265 level_overflow = Some(date);
266 }
267
268 let (total, remaining, done_level) = if level_overflow.is_some() {
269 (None, None, None)
270 } else if origin_known {
271 let remaining = scope_total - completed;
272 if remaining < 0 {
273 first_unanswerable.get_or_insert((date, scope_total, completed));
274 (Some(scope_total), None, Some(completed))
275 } else {
276 (Some(scope_total), Some(remaining), Some(completed))
277 }
278 } else {
279 (None, None, None)
280 };
281
282 // The day's provenance is the crate's recorded order — issue number, then entry
283 // index — never the order the caller loaded the issues in, so a point, and the
284 // document built from it, does not depend on that order. `DailyPoint::event_ids`
285 // documents exactly this, and the count fold orders its ids through the same key;
286 // without it the estimate fold reported a day's ids first-seen, and two runs over
287 // one snapshot disagreed on a reversed input (C-007).
288 let mut event_ids = valued.event_ids.get(date).cloned().unwrap_or_default();
289 event_ids.sort_by(|left, right| event_id_key(left).cmp(&event_id_key(right)));
290
291 let point = DailyPoint {
292 date: date.to_string(),
293 scope_total: total,
294 remaining,
295 completed: done_level,
296 scope_added: observed.then(|| added.unwrap_or(0)),
297 completions: observed.then(|| done.unwrap_or(0)),
298 coverage: Coverage::Known,
299 event_ids,
300 };
301 let weak = global_weak || valued.weak.contains(date);
302 points.push(DailyPoint {
303 coverage: coverage_of(&point, weak),
304 ..point
305 });
306 }
307
308 let mut diagnostics = snapshot.diagnostics().to_vec();
309 diagnostics.extend(gap_findings(scope, &points, snapshot.observed_days()));
310 diagnostics.extend(valued.findings(scope, conversion.unit()));
311 if let Some((day, total, done)) = first_unanswerable {
312 diagnostics.push(level_finding(scope, conversion.unit(), day, total, done));
313 }
314 if let Some(day) = level_overflow {
315 diagnostics.push(
316 AnalyticsDiagnostic::new(
317 AnalyticsDiagnosticKind::MissingHistory,
318 scope.id.clone(),
319 format!(
320 "on {day} the carried estimate level exceeded the i64 range, so remaining \
321 work cannot be answered without wrapping or inventing a value"
322 ),
323 )
324 .on(day)
325 .with_remedy(
326 "reduce the synthetic fixture: persisted estimates use u32 values, so a \
327 realistic project is orders of magnitude below this bound",
328 ),
329 );
330 }
331 diagnostics.sort();
332
333 BurndownSeries {
334 scope: scope.clone(),
335 mode: Mode::Estimate,
336 unit: Some(conversion.unit().to_string()),
337 points,
338 diagnostics,
339 formula_version: ESTIMATE_FORMULA_VERSION.to_string(),
340 }
341}
342
343/// The series an estimate-mode request that cannot be answered returns.
344fn empty(
345 scope: &AnalyticsScope,
346 unit: &str,
347 diagnostics: Vec<AnalyticsDiagnostic>,
348) -> BurndownSeries {
349 BurndownSeries {
350 scope: scope.clone(),
351 mode: Mode::Estimate,
352 unit: Some(unit.to_string()),
353 points: Vec::new(),
354 diagnostics,
355 formula_version: ESTIMATE_FORMULA_VERSION.to_string(),
356 }
357}
358
359/// The finding for a unit the board does not declare: no point is folded at all.
360fn undeclared_unit(scope: &AnalyticsScope, unit: &str, config: &Config) -> AnalyticsDiagnostic {
361 let declared = declared_units(config);
362 let table = if declared.is_empty() {
363 "the board declares no estimate unit".to_string()
364 } else {
365 format!(
366 "the board declares {}",
367 declared
368 .iter()
369 .map(|unit| format!("`{unit}`"))
370 .collect::<Vec<_>>()
371 .join(", ")
372 )
373 };
374 AnalyticsDiagnostic::new(
375 AnalyticsDiagnosticKind::MissingConversion,
376 scope.id.clone(),
377 format!(
378 "estimate mode cannot count in `{unit}`: {table}, so no day is folded and no \
379 estimate total is fabricated"
380 ),
381 )
382 .with_remedy(
383 "choose `--mode count`, or declare the unit under `[kanban.<board>.wip.units]` and \
384 state the estimates in it",
385 )
386}
387
388/// One finding per unbroken run of days with no recorded contribution.
389///
390/// A run, not a day: one quiet week says "7 days" once rather than seven times. The
391/// sentence is the one [`crate::analytics::series`] uses for the count fold's own
392/// gaps — the fact is the same in both modes, and a reader comparing two series for
393/// one scope should not have to learn a second phrasing for one gap.
394fn gap_findings(
395 scope: &AnalyticsScope,
396 points: &[DailyPoint],
397 observed: &BTreeSet<String>,
398) -> Vec<AnalyticsDiagnostic> {
399 let mut findings = Vec::new();
400 let mut run: Option<(String, String, usize)> = None;
401 for point in points {
402 if observed.contains(&point.date) {
403 if let Some((first, last, days)) = run.take() {
404 findings.push(gap_finding(scope, &first, &last, days));
405 }
406 } else {
407 run = match run {
408 Some((first, _, days)) => Some((first, point.date.clone(), days + 1)),
409 None => Some((point.date.clone(), point.date.clone(), 1)),
410 };
411 }
412 }
413 if let Some((first, last, days)) = run {
414 findings.push(gap_finding(scope, &first, &last, days));
415 }
416 findings
417}
418
419/// One gap's finding.
420fn gap_finding(
421 scope: &AnalyticsScope,
422 first: &str,
423 last: &str,
424 days: usize,
425) -> AnalyticsDiagnostic {
426 let span = if first == last {
427 first.to_string()
428 } else {
429 format!("{first}..{last}")
430 };
431 AnalyticsDiagnostic::new(
432 AnalyticsDiagnosticKind::MissingHistory,
433 scope.id.clone(),
434 format!("{days} day(s) in {span} carry no recorded history"),
435 )
436 .on(first)
437}
438
439/// The finding for a level the period cannot answer: recorded completions outweigh
440/// recorded additions, so the remaining work has no origin inside the period.
441fn level_finding(
442 scope: &AnalyticsScope,
443 unit: &str,
444 day: &str,
445 total: i64,
446 done: i64,
447) -> AnalyticsDiagnostic {
448 AnalyticsDiagnostic::new(
449 AnalyticsDiagnosticKind::MissingHistory,
450 scope.id.clone(),
451 format!(
452 "on {day} the period records `{done}` `{unit}` of completion but only `{total}` \
453 `{unit}` of scope addition, so the remaining work cannot be answered from the \
454 period alone"
455 ),
456 )
457 .on(day)
458}
459
460/// Classify a point's coverage from what it can actually say.
461///
462/// The rule [`crate::analytics::series`] applies to a count point, kept identical
463/// here so a reader gets the same three states from either mode: every value known
464/// is known (unless the day is weak), no value known is unknown, and anything in
465/// between is coverage-limited.
466fn coverage_of(point: &DailyPoint, weak: bool) -> Coverage {
467 let values = [
468 point.scope_total,
469 point.remaining,
470 point.completed,
471 point.scope_added,
472 point.completions,
473 ];
474 let known = values.iter().filter(|value| value.is_some()).count();
475 match known {
476 0 => Coverage::Unknown,
477 5 if !weak => Coverage::Known,
478 _ => Coverage::CoverageLimited,
479 }
480}
481
482/// The scope's members, each with the size its stated estimate resolves to.
483fn size_members(
484 snapshot: &ScopeSnapshot,
485 issues: &[Issue],
486 conversion: &EstimateConversion,
487) -> BTreeMap<u32, MemberSize> {
488 let records: BTreeMap<u32, &Issue> = issues.iter().map(|issue| (issue.number, issue)).collect();
489 snapshot
490 .members()
491 .iter()
492 .map(|&number| match records.get(&number) {
493 Some(issue) => (
494 number,
495 conversion.size_of(number, issue.estimate.as_deref()),
496 ),
497 // The snapshot's members came from the same slice, so this is a caller
498 // that passed a narrower slice; the contribution is reported, not sized.
499 None => (number, MemberSize::Unrecorded),
500 })
501 .collect()
502}
503
504/// The valued contributions of one scope (T014), and everything the fold could not
505/// value.
506#[derive(Debug, Default)]
507struct ValuedDays {
508 /// The `scope_added` values carried per day, in the selected unit.
509 added: BTreeMap<String, i64>,
510 /// The `completions` values carried per day, in the selected unit.
511 completed: BTreeMap<String, i64>,
512 /// Every contribution's stable id per day, in first-seen order.
513 event_ids: BTreeMap<String, Vec<String>>,
514 /// The days whose change set the fold could not fully value.
515 weak: BTreeSet<String>,
516 /// Refused values, by (field, shared reason).
517 refusals: BTreeMap<(Field, RefusalKey), Tally>,
518 /// Contributions of an unestimated member, by field.
519 unestimated: BTreeMap<Field, Tally>,
520 /// Contributions no loaded issue can be sized from, by field.
521 unattributed: BTreeMap<Field, Tally>,
522 /// Contributions the record carries no count-level value for, by field.
523 unquantified: BTreeMap<Field, Tally>,
524 /// Recorded estimate changes inside the period.
525 rescoped: Tally,
526 /// Every other recorded event.
527 unclassified: Tally,
528}
529
530impl ValuedDays {
531 /// The findings this fold's own valuation produced, in a stable order.
532 fn findings(&self, scope: &AnalyticsScope, unit: &str) -> Vec<AnalyticsDiagnostic> {
533 let mut diagnostics = Vec::new();
534
535 for ((field, key), tally) in &self.refusals {
536 let reason = tally
537 .reason
538 .as_deref()
539 .unwrap_or("the shared conversion refused it");
540 let mut diagnostic = AnalyticsDiagnostic::new(
541 AnalyticsDiagnosticKind::MissingConversion,
542 scope.id.clone(),
543 format!(
544 "{} recorded `{}` contribution(s) are left out of the estimate series in \
545 `{unit}`: {reason}",
546 tally.count,
547 field.as_str()
548 ),
549 );
550 if let Some(event) = tally.first_event.as_deref() {
551 diagnostic = diagnostic.about_event(event);
552 }
553 // The registry's own "what to do about it" when it gave one, and a
554 // shape-naming remedy otherwise: a conversion the board cannot make is
555 // fixed where the unit or the estimate is declared, not here.
556 diagnostic = match tally.remedy.as_deref() {
557 Some(remedy) => diagnostic.with_remedy(remedy),
558 None if key.kind == "malformed_estimate" => diagnostic.with_remedy(
559 "rewrite the issue's `estimate:` as `<number><unit>`, or run `tix lint`",
560 ),
561 None => diagnostic,
562 };
563 diagnostics.push(diagnostic);
564 }
565
566 for (field, tally) in &self.unestimated {
567 diagnostics.push(sized_finding(
568 scope,
569 tally,
570 format!(
571 "{} recorded `{}` contribution(s) belong to an unestimated issue, so \
572 their size in `{unit}` is unknown and their days are coverage-limited",
573 tally.count,
574 field.as_str()
575 ),
576 "state the estimate on the issue (`tix edit <n> --estimate <number><unit>`) \
577 so its work can be sized",
578 ));
579 }
580
581 for (field, tally) in &self.unattributed {
582 diagnostics.push(sized_finding(
583 scope,
584 tally,
585 format!(
586 "{} recorded `{}` contribution(s) name no issue this series can size (a \
587 closed snapshot's contribution carries no issue), so they are not valued in \
588 `{unit}`",
589 tally.count,
590 field.as_str()
591 ),
592 "read the day as coverage-limited: only the recorded per-issue contributions can \
593 be sized",
594 ));
595 }
596
597 for (field, tally) in &self.unquantified {
598 diagnostics.push(sized_finding(
599 scope,
600 tally,
601 format!(
602 "{} recorded `{}` contribution(s) carry no count-level value, so their size in \
603 `{unit}` is unknown and their days are coverage-limited",
604 tally.count,
605 field.as_str()
606 ),
607 "read the day as coverage-limited: the recorded detail names the change but not \
608 its size in this unit",
609 ));
610 }
611
612 if self.rescoped.count > 0 {
613 let tally = &self.rescoped;
614 let mut diagnostic = AnalyticsDiagnostic::new(
615 AnalyticsDiagnosticKind::MissingHistory,
616 scope.id.clone(),
617 format!(
618 "{} recorded estimate change(s) inside the period re-sized an issue; the series \
619 values every contribution at the estimate the issue states now, so a level \
620 before such a change is not the level the record held then",
621 tally.count
622 ),
623 );
624 if let Some(event) = tally.first_event.as_deref() {
625 diagnostic = diagnostic.about_event(event);
626 }
627 diagnostics.push(diagnostic);
628 }
629
630 if self.unclassified.count > 0 {
631 let tally = &self.unclassified;
632 let mut diagnostic = AnalyticsDiagnostic::new(
633 AnalyticsDiagnosticKind::UnclassifiedEvent,
634 scope.id.clone(),
635 format!(
636 "{} recorded event(s) in the period carry no scope, completion or estimate \
637 meaning and move no line",
638 tally.count
639 ),
640 );
641 if let Some(event) = tally.first_event.as_deref() {
642 diagnostic = diagnostic.about_event(event);
643 }
644 diagnostics.push(diagnostic);
645 }
646
647 diagnostics
648 }
649}
650
651/// One contribution-group finding, with the first contributing event as provenance.
652///
653/// The message already names the field the value would have landed in, so the group
654/// is reported once per (reason, field) with a count rather than once per issue.
655fn sized_finding(
656 scope: &AnalyticsScope,
657 tally: &Tally,
658 message: String,
659 remedy: &str,
660) -> AnalyticsDiagnostic {
661 let mut diagnostic = AnalyticsDiagnostic::new(
662 AnalyticsDiagnosticKind::MissingHistory,
663 scope.id.clone(),
664 message,
665 )
666 .with_remedy(remedy);
667 if let Some(event) = tally.first_event.as_deref() {
668 diagnostic = diagnostic.about_event(event);
669 }
670 diagnostic
671}
672
673/// Value every recorded contribution of the scope on its day.
674fn value_days(snapshot: &ScopeSnapshot, sizes: &BTreeMap<u32, MemberSize>) -> ValuedDays {
675 let mut valued = ValuedDays::default();
676 for event in snapshot.event_index().events() {
677 // A contribution with no readable day is a service-level finding (the
678 // snapshot reports it) and weakens every day through `unplaced_events`.
679 let Some(date) = event.date.as_deref() else {
680 continue;
681 };
682 valued
683 .event_ids
684 .entry(date.to_string())
685 .or_default()
686 .push(event.event_id.clone());
687 match event.class {
688 EventClass::ScopeAddition | EventClass::Completion => {
689 let field = match event.class {
690 EventClass::ScopeAddition => Field::Added,
691 _ => Field::Completed,
692 };
693 if event.delta.is_none() {
694 valued.weak.insert(date.to_string());
695 valued.unquantified.entry(field).or_default().record(event);
696 continue;
697 }
698 match event.issue.and_then(|number| sizes.get(&number)) {
699 Some(MemberSize::Sized(size)) => {
700 let bucket = match field {
701 Field::Added => &mut valued.added,
702 Field::Completed => &mut valued.completed,
703 };
704 *bucket.entry(date.to_string()).or_default() += size;
705 }
706 Some(MemberSize::Unestimated) => {
707 valued.weak.insert(date.to_string());
708 valued.unestimated.entry(field).or_default().record(event);
709 }
710 Some(MemberSize::Refused(refusal)) => {
711 valued.weak.insert(date.to_string());
712 valued
713 .refusals
714 .entry((field, refusal.key.clone()))
715 .or_default()
716 .record_refusal(event, refusal);
717 }
718 Some(MemberSize::Unrecorded) | None => {
719 valued.weak.insert(date.to_string());
720 valued.unattributed.entry(field).or_default().record(event);
721 }
722 }
723 }
724 // A re-sizing recorded inside the period: reported, and the day limited.
725 // It is never replayed into the levels (see the module documentation).
726 EventClass::EstimateChange => {
727 valued.weak.insert(date.to_string());
728 valued.rescoped.record(event);
729 }
730 EventClass::Unclassified => valued.unclassified.record(event),
731 }
732 }
733 valued
734}
735
736/// How one member's stated estimate reads in the series' unit (T013).
737#[derive(Debug, Clone)]
738enum MemberSize {
739 /// Converted: the size every counted contribution of this member carries.
740 Sized(i64),
741 /// The member states no estimate at all.
742 Unestimated,
743 /// Stated but not countable in this unit, with the registry's own finding.
744 Refused(Refused),
745 /// The member's record was not among the issues this series was given.
746 Unrecorded,
747}
748
749/// Why a stated estimate could not be counted, in the shared vocabulary.
750#[derive(Debug, Clone)]
751struct Refused {
752 /// What refused it: the registry's kind and the unit spelling the issue used.
753 key: RefusalKey,
754 /// The registry's sentence, verbatim (it already names the issue).
755 reason: String,
756 /// The registry's own remedy, when it gave one.
757 remedy: Option<String>,
758}
759
760/// The grouping key of a refusal: the shared reason and the spelling behind it.
761///
762/// Two issues that wrote the same undeclared unit, or that both failed the same
763/// arithmetic, group into one finding — the count is the actionable part, and one
764/// finding per issue would bury it.
765#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
766struct RefusalKey {
767 /// The registry's machine-readable kind (`missing_conversion`, `unknown_unit`,
768 /// `ambiguous_conversion`, `malformed_estimate`).
769 kind: &'static str,
770 /// The unit the issue named, when it named one.
771 spelled: Option<String>,
772}
773
774/// The `DailyPoint` field a contribution's value lands in.
775#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
776enum Field {
777 /// `scope_added` (and, carried, `scope_total`).
778 Added,
779 /// `completions` (and, carried, `completed`).
780 Completed,
781}
782
783impl Field {
784 /// The stable field name the finding quotes.
785 const fn as_str(self) -> &'static str {
786 match self {
787 Field::Added => "scope_added",
788 Field::Completed => "completions",
789 }
790 }
791}
792
793/// One group of contributions the fold could not value: how many, and the first.
794#[derive(Debug, Clone, Default)]
795struct Tally {
796 /// How many contributions the group holds.
797 count: usize,
798 /// The stable id of the first contribution in the group.
799 first_event: Option<String>,
800 /// The registry's sentence for the first contribution, when it gave one.
801 reason: Option<String>,
802 /// The registry's own remedy for the first contribution, when it gave one.
803 remedy: Option<String>,
804}
805
806impl Tally {
807 /// Count one contribution, keeping the first as the group's provenance.
808 fn record(&mut self, event: &AnalyticsEvent) {
809 self.count += 1;
810 if self.first_event.is_none() {
811 self.first_event = Some(event.event_id.clone());
812 }
813 }
814
815 /// Count one refused contribution, keeping the first refusal's own words.
816 fn record_refusal(&mut self, event: &AnalyticsEvent, refusal: &Refused) {
817 let first = self.count == 0;
818 self.record(event);
819 if first {
820 self.reason = Some(refusal.reason.clone());
821 self.remedy = refusal.remedy.clone();
822 }
823 }
824}