Skip to main content

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}