Skip to main content

tix/analytics/
series.rs

1//! The daily count-mode series, its coverage markers and its one-pass snapshot
2//! (mission `burndown-scope-analytics-01M3C9J1`, WP02 T007–T012).
3//!
4//! # What this module is
5//!
6//! One pure fold from the tree the caller already loaded to an ordered
7//! [`BurndownSeries`]:
8//!
9//! ```text
10//! issues + config + schedule index + AnalyticsScope
11//!     ──ScopeSnapshot::build──▶ one classified, deduplicated event index
12//!     ──ScopeSnapshot::series──▶ BurndownSeries { DailyPoint[], diagnostics }
13//! ```
14//!
15//! Nothing here opens a store, reads a clock, or writes anything: the period is the
16//! caller's, the issues are the caller's slice, and the result is an owned value.
17//! That is C-001's "pure projection", and it is what lets CLI, TUI, MCP and the
18//! chart document share one answer (C-008).
19//!
20//! # The one pass (T007)
21//!
22//! [`ScopeSnapshot::build`] walks the issue slice **once**, and in that one
23//! iteration it decides membership, folds the member's History through WP01's
24//! [`EventIndex`] (which is where the C-002 idempotency and the period filter live),
25//! reads the shared [#183 completion answer](crate::workflow::is_completed), and
26//! notes which days carry recorded history. Nothing later re-walks the slice: the
27//! member list, the classified index, the observed days and the findings are all read
28//! back out of the snapshot — the shape
29//! [`crate::capacity::scope::IssueSnapshot`] established for the same reason. The
30//! tally [`ScopeSnapshot::issues_visited`] grows **inside that loop**, one step per
31//! issue record the walk reads, so a second walk of the slice doubles it and a test
32//! can anchor "one pass" structurally instead of by wall clock.
33//!
34//! Membership resolves through the crate's **one** cycle-reference resolver
35//! ([`crate::capacity::resolve_cycle`]), keyed on the identity that resolver answers
36//! with — so a generated cycle's id, its schedule name and a hand-written legacy name
37//! select exactly the same issues, and two spellings of one cycle are one scope, not
38//! two. The key rule is the crate's own (`capacity::calculate::scope_identity`):
39//! `cycle-id:<id>` for a generated cycle, `cycle-name:<name>` for a legacy name or a
40//! name the configuration declares, and `cycle-ref:<name>` for a reference neither
41//! knows — that last namespace so two unresolvable names are never folded into one
42//! scope. That is where this service deliberately parts with
43//! [`crate::capacity::scope::IssueSnapshot`]: capacity selects **nobody** for an
44//! unresolvable reference, because a capacity entry has to be validated against a
45//! cycle that exists, while analytics only *reads* and therefore reports the series
46//! the issues naming that very name support. The divergence is the membership policy,
47//! not the resolution: both answer one reference the same way.
48//!
49//! # The boundary of the classification
50//!
51//! An event the classification cannot place — a `parent_cleared` removal, a malformed
52//! transition record, a relationship or a time mutation — contributes nothing, and the
53//! day it falls on is not marked incomplete for it: the day *was* read, and the classes
54//! that can move a line were folded. The limit is stated rather than hidden, and it is
55//! visible as an
56//! [`UnclassifiedEvent`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::UnclassifiedEvent)
57//! finding. A recorded scope *removal* therefore shows up as that finding and not as a
58//! decrease: the closed four-name classification places additions and completions, and
59//! inventing a fifth answer for a removal is the guessing C-001 refuses.
60//!
61//! # What a day means (T008/T009/T010)
62//!
63//! A point's five numbers are **counts of recorded contributions**, and a `Some`
64//! always means "the record says this", never "no record means zero":
65//!
66//! | field | meaning |
67//! |---|---|
68//! | `scope_added` | the `scope_addition` contributions **recorded on** that day |
69//! | `completions` | the `completion` contributions recorded on that day |
70//! | `scope_total` | the planned scope **carried** to that day: the period's running sum of recorded additions |
71//! | `completed` | the completed work carried to that day: the running sum of recorded completions |
72//! | `remaining` | `scope_total - completed`, when that difference is answerable |
73//!
74//! The origin is the period **start**, which the caller supplies (`--from`); a level
75//! is carried forward across days without events, because the running sum is a fact
76//! about the recorded events and not about the day. When the caller supplies no
77//! start the origin is unknown, and then every level is `None` — the series reports
78//! only the per-day additions and completions it can place. A milestone declares a
79//! target date and no start, and is exactly that case; a cycle declares both.
80//!
81//! # Coverage, and why a missing day is never a zero (T010, C-005)
82//!
83//! A day is not "zero"; it is **known**, **coverage-limited** or **unknown**, and
84//! the marker is a function of what the point can actually say:
85//!
86//! * [`Coverage::Known`] — every number on the day is Some and nothing weakened it.
87//! * [`Coverage::CoverageLimited`] — some numbers are known and some are not: a day
88//!   with no recorded history still carries the level, but its `scope_added` and
89//!   `completions` are `None` because nothing was observed; a day whose recorded
90//!   events cannot be turned into counts is limited for the same reason.
91//! * [`Coverage::Unknown`] — the day can say nothing at all, which is what an
92//!   unanchored origin (no period start) produces for a day with no events.
93//!
94//! The three markers therefore describe the *point*, not the calendar, and the
95//! series summarizes them with [`BurndownSeries::coverage`] and with a
96//! [`MissingHistory`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::MissingHistory)
97//! diagnostic per unbroken gap. A missing day is never serialized as an ordinary
98//! zero, which is the one thing the mission escalates over.
99//!
100//! # Recorded history that does not reach the period stays visible
101//!
102//! The fold's levels count what the period *records*. If the tree records completed
103//! members the period holds no completion event for, the series says so with a
104//! diagnostic rather than by inventing a baseline or by reading current state back
105//! into a historical value. The sentence never asserts a direction the fold cannot
106//! see: a member completed after the period end is filtered as outside-period exactly
107//! like one completed before the start, so the finding reports whether the period
108//! excluded any recorded event at all and lets the two directions stay open. That
109//! check is the one place the shared #183 status-category service is consulted for the
110//! series, and it only ever produces a sentence, never a number.
111//!
112//! # Closed snapshots (T011, C-006)
113//!
114//! A scope bound to a closed snapshot ([`AnalyticsScope::snapshot_id`]) keeps that
115//! identity in the series it produces and reports it as a
116//! [`ClosedSnapshot`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::ClosedSnapshot)
117//! diagnostic. The exclusion is the **caller-supplied period's**:
118//! [`EventIndex::outside_period`] counts every recorded event the period kept out, and
119//! this fold reads no snapshot file — so nothing here resolves `snapshot_id` to a close
120//! date. A caller that wants the closed view bounded by the close passes the snapshot's
121//! own recorded period (its `start_date`/`due_date`, or the day of its `closed_at`) as
122//! the scope period, and only then can a later edit not contribute; the diagnostic
123//! names the period that was actually applied instead of claiming a bound the fold never
124//! established.
125//!
126//! # Determinism
127//!
128//! Days are enumerated in calendar order, each day's provenance is ordered by the
129//! crate's recorded key — issue number, then entry index, never the order the caller
130//! happened to load the issues in — the member list is sorted once the walk is over,
131//! and the diagnostics are sorted by their own `Ord`, with each finding's example event
132//! chosen as the smallest id rather than the first one seen. Two runs over one snapshot
133//! therefore produce the same bytes (C-007) *including* when the caller loads the issues
134//! in a different order, which is what lets a chart export be compared
135//! character-for-character.
136//!
137//! # Mode
138//!
139//! Count mode is issue counts and is what this package folds. Estimate mode is
140//! declared here so the DTO has one shape, and resolves the shared unit conversion
141//! in WP03's [`crate::analytics::units`]; until a conversion is available this
142//! service reports
143//! [`MissingConversion`](crate::analytics::diagnostics::AnalyticsDiagnosticKind::MissingConversion)
144//! and folds **no** units, because mixing or guessing them is exactly the
145//! arithmetic C-003 forbids.
146
147use std::collections::{BTreeMap, BTreeSet};
148
149use serde::{Deserialize, Serialize};
150
151use crate::capacity::resolve_cycle;
152use crate::config::Config;
153use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
154use crate::model::Issue;
155use crate::workflow;
156
157use super::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
158use super::events::{AnalyticsScope, EventClass, EventIndex, ScopeKind};
159
160/// The documented formula the count-mode fold implements.
161///
162/// A stable string rather than a number: a reader compares it for equality, and a
163/// version *sequence* invites arithmetic on it that means nothing.
164pub const COUNT_FORMULA_VERSION: &str = "burndown.count.v1";
165
166/// The documented formula the estimate-mode fold implements (WP03).
167pub const ESTIMATE_FORMULA_VERSION: &str = "burndown.estimate.v1";
168
169/// The longest span one series will enumerate.
170///
171/// A period is caller-supplied, so a mistyped year would otherwise ask for a
172/// millions-long vector. A span past this bound is reported as an
173/// [`InvalidPeriod`](AnalyticsDiagnosticKind::InvalidPeriod) diagnostic rather than
174/// allocated, and the bound is far above any real planning window.
175pub const MAX_SERIES_DAYS: usize = 100_000;
176
177/// How a series counts (`data-model.md` §4).
178#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum Mode {
181    /// Issue counts (`data-model.md` §3: "Count mode uses issue counts").
182    Count,
183    /// Explicitly converted estimate units, resolved by the shared unit service.
184    Estimate,
185}
186
187impl Mode {
188    /// Every mode, in the order a surface renders them.
189    pub const ALL: [Mode; 2] = [Mode::Count, Mode::Estimate];
190
191    /// The canonical spelling used in output and in payloads.
192    pub const fn as_str(self) -> &'static str {
193        match self {
194            Mode::Count => "count",
195            Mode::Estimate => "estimate",
196        }
197    }
198
199    /// Parse a canonical spelling; `None` for anything outside the vocabulary.
200    pub fn parse(raw: &str) -> Option<Self> {
201        match raw.trim().to_ascii_lowercase().as_str() {
202            "count" => Some(Mode::Count),
203            "estimate" => Some(Mode::Estimate),
204            _ => None,
205        }
206    }
207
208    /// The formula version this mode folds under.
209    pub const fn formula_version(self) -> &'static str {
210        match self {
211            Mode::Count => COUNT_FORMULA_VERSION,
212            Mode::Estimate => ESTIMATE_FORMULA_VERSION,
213        }
214    }
215}
216
217impl std::fmt::Display for Mode {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.write_str(self.as_str())
220    }
221}
222
223/// How much of one day the series can answer (`data-model.md` §3 `coverage`).
224#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum Coverage {
227    /// Every value on the day is known.
228    Known,
229    /// The day says nothing: no level could be carried and nothing was observed.
230    Unknown,
231    /// Some values are known and some are not — the day was not observed, or a
232    /// recorded change could not be turned into a count.
233    CoverageLimited,
234}
235
236impl Coverage {
237    /// Every state, in the order a surface renders them.
238    pub const ALL: [Coverage; 3] = [
239        Coverage::Known,
240        Coverage::Unknown,
241        Coverage::CoverageLimited,
242    ];
243
244    /// The canonical spelling used in output and in payloads.
245    pub const fn as_str(self) -> &'static str {
246        match self {
247            Coverage::Known => "known",
248            Coverage::Unknown => "unknown",
249            Coverage::CoverageLimited => "coverage_limited",
250        }
251    }
252
253    /// Parse a canonical spelling; `None` for anything outside the vocabulary.
254    pub fn parse(raw: &str) -> Option<Self> {
255        match raw.trim().to_ascii_lowercase().as_str() {
256            "known" => Some(Coverage::Known),
257            "unknown" => Some(Coverage::Unknown),
258            "coverage_limited" => Some(Coverage::CoverageLimited),
259            _ => None,
260        }
261    }
262
263    /// Whether the day answers everything it carries.
264    pub const fn is_known(self) -> bool {
265        matches!(self, Coverage::Known)
266    }
267}
268
269impl std::fmt::Display for Coverage {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        f.write_str(self.as_str())
272    }
273}
274
275/// One calendar-day series value (`data-model.md` §3).
276///
277/// Every number is an `Option`, and every `None` is a statement: the value is not
278/// known, and rendering it as `0` would be the assumption C-005 forbids. `event_ids`
279/// is the day's provenance in the crate's recorded order — issue number, then entry
280/// index — so a reader can walk from a point
281/// back to the recorded lines that produced it.
282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
283pub struct DailyPoint {
284    /// The canonical `YYYY-MM-DD` day.
285    pub date: String,
286    /// The planned scope carried to this day, when the origin is known.
287    pub scope_total: Option<i64>,
288    /// The remaining work carried to this day, when it is answerable.
289    pub remaining: Option<i64>,
290    /// The completed work carried to this day, when the origin is known.
291    pub completed: Option<i64>,
292    /// The scope additions **recorded on** this day, when the day was observed.
293    pub scope_added: Option<i64>,
294    /// The completions recorded on this day, when the day was observed.
295    pub completions: Option<i64>,
296    /// How much of the day the fold can answer.
297    pub coverage: Coverage,
298    /// The recorded contributions placed on this day, in the crate's recorded order
299    /// (issue number, then entry index) — never the order the caller listed the store.
300    pub event_ids: Vec<String>,
301}
302
303impl DailyPoint {
304    /// Whether every number on the day is known.
305    pub fn is_known(&self) -> bool {
306        self.coverage.is_known()
307    }
308
309    /// The day's stable provenance ids.
310    pub fn event_ids(&self) -> &[String] {
311        &self.event_ids
312    }
313
314    /// The five numbers the day carries, in the schema's order.
315    fn values(&self) -> [Option<i64>; 5] {
316        [
317            self.scope_total,
318            self.remaining,
319            self.completed,
320            self.scope_added,
321            self.completions,
322        ]
323    }
324}
325
326/// How many days of a series fall into each coverage state.
327///
328/// The count a surface reports and an export carries (NFR-002): "3 of 15 days are
329/// coverage-limited" is the metadata that keeps a reader from reading a gap as
330/// progress.
331#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
332pub struct CoverageSummary {
333    /// Days whose every value is known.
334    pub known: usize,
335    /// Days that say nothing.
336    pub unknown: usize,
337    /// Days that answer some of what they carry.
338    pub coverage_limited: usize,
339}
340
341impl CoverageSummary {
342    /// How many days the series holds.
343    pub const fn total(&self) -> usize {
344        self.known + self.unknown + self.coverage_limited
345    }
346
347    /// Count one day's state.
348    fn add(&mut self, coverage: Coverage) {
349        match coverage {
350            Coverage::Known => self.known += 1,
351            Coverage::Unknown => self.unknown += 1,
352            Coverage::CoverageLimited => self.coverage_limited += 1,
353        }
354    }
355}
356
357/// The ordered daily series for one scope and mode (`data-model.md` §4).
358///
359/// `unit` is `None` in count mode — an issue count is unitless — and is required in
360/// estimate mode. `formula_version` is [`Mode::formula_version`], so a consumer can
361/// tell two folds apart without inspecting the points.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct BurndownSeries {
364    /// The selected cycle/milestone, its period and its snapshot binding.
365    pub scope: AnalyticsScope,
366    /// How the series counts.
367    pub mode: Mode,
368    /// The unit the values are counted in, required in estimate mode.
369    pub unit: Option<String>,
370    /// The ordered daily points.
371    pub points: Vec<DailyPoint>,
372    /// The findings, sorted and deterministic.
373    pub diagnostics: Vec<AnalyticsDiagnostic>,
374    /// The documented formula this series was folded under.
375    pub formula_version: String,
376}
377
378impl BurndownSeries {
379    /// An empty series — what an unusable period produces, with its diagnostic.
380    fn empty(scope: &AnalyticsScope, mode: Mode, diagnostics: Vec<AnalyticsDiagnostic>) -> Self {
381        Self {
382            scope: scope.clone(),
383            mode,
384            unit: None,
385            points: Vec::new(),
386            diagnostics,
387            formula_version: mode.formula_version().to_string(),
388        }
389    }
390
391    /// Whether the series holds no day at all.
392    pub fn is_empty(&self) -> bool {
393        self.points.is_empty()
394    }
395
396    /// How many days the series holds.
397    pub fn len(&self) -> usize {
398        self.points.len()
399    }
400
401    /// The coverage tally a surface reports and an export carries (NFR-002).
402    pub fn coverage(&self) -> CoverageSummary {
403        let mut summary = CoverageSummary::default();
404        for point in &self.points {
405            summary.add(point.coverage);
406        }
407        summary
408    }
409
410    /// The point for one day, when the series covers it.
411    pub fn point_on(&self, date: &str) -> Option<&DailyPoint> {
412        self.points.iter().find(|point| point.date == date)
413    }
414
415    /// The findings of one kind, in the series' deterministic order.
416    pub fn diagnostics_of(
417        &self,
418        kind: AnalyticsDiagnosticKind,
419    ) -> impl Iterator<Item = &AnalyticsDiagnostic> {
420        self.diagnostics.iter().filter(move |d| d.kind == kind)
421    }
422}
423
424/// The one-pass scope snapshot the series is folded from (T007).
425///
426/// Built by [`ScopeSnapshot::build`] and immutable afterwards: the member list, the
427/// classified event index, the observed days and the findings are all read once, so a
428/// fold cannot re-read an issue or re-classify an event. The tally
429/// [`ScopeSnapshot::issues_visited`] grows inside the build's one walk, which is what
430/// makes "one pass" checkable rather than merely claimed.
431#[derive(Debug, Clone)]
432pub struct ScopeSnapshot {
433    scope: AnalyticsScope,
434    index: EventIndex,
435    members: Vec<u32>,
436    completed_members: usize,
437    observed_days: BTreeSet<String>,
438    unplaced_events: usize,
439    visited: usize,
440    diagnostics: Vec<AnalyticsDiagnostic>,
441}
442
443impl ScopeSnapshot {
444    /// Index `issues` for `scope` in **one pass** (T007).
445    ///
446    /// `config` supplies the board whose status-category resolution the shared
447    /// [#183 service](crate::workflow) is built for, and `index` is the caller's
448    /// [`ScheduleIndex`] — the same index [`crate::capacity`] resolves cycle
449    /// references through, so a scope naming a generated cycle and one naming its
450    /// schedule select the same issues. Nothing here touches a store.
451    ///
452    /// The tally [`ScopeSnapshot::issues_visited`] is advanced in the loop below, so it
453    /// measures the walk this function performs and not the length of the slice it was
454    /// handed.
455    #[must_use]
456    pub fn build(
457        config: &Config,
458        index: &ScheduleIndex,
459        issues: &[Issue],
460        scope: &AnalyticsScope,
461    ) -> Self {
462        let resolution = workflow::resolution(config, config.default_config(), "default");
463        // The scope's identity is resolved **once**, not once per issue.
464        let scope_key = match scope.kind {
465            ScopeKind::Cycle => Some(cycle_key(config, index, &scope.id)),
466            ScopeKind::Milestone => None,
467        };
468
469        let mut index_events = EventIndex::new(scope.clone());
470        let mut members: Vec<u32> = Vec::new();
471        let mut completed_members = 0usize;
472        let mut visited = 0usize;
473        // The pass: one iteration per issue record, in which everything the issue
474        // contributes is read — its membership, the shared completion answer and its
475        // History. The tally advances **here**, in the same iteration, so duplicating
476        // this walk doubles it instead of leaving it reporting the input's length.
477        for issue in issues {
478            visited += 1;
479            if !is_member(issue, scope, config, index, scope_key.as_deref()) {
480                continue;
481            }
482            members.push(issue.number);
483            if workflow::is_completed(issue, &resolution) {
484                completed_members += 1;
485            }
486            index_events.contribute_history(&issue.number.to_string(), &issue.history);
487        }
488        // The member list is a summary of the walk, not an input to it: sorting it
489        // afterwards reads no record and cannot change what the walk contributed.
490        members.sort_unstable();
491        members.dedup();
492
493        let mut observed_days = BTreeSet::new();
494        let mut unplaced_events = 0usize;
495        for event in index_events.events() {
496            match event.date.as_deref() {
497                Some(date) => {
498                    observed_days.insert(date.to_string());
499                }
500                None => unplaced_events += 1,
501            }
502        }
503
504        let mut diagnostics = Vec::new();
505        if let Some(snapshot_id) = scope.snapshot_id.as_deref() {
506            let start = scope.period_start().unwrap_or("open");
507            let end = scope.period_end().unwrap_or("open");
508            diagnostics.push(
509                AnalyticsDiagnostic::new(
510                    AnalyticsDiagnosticKind::ClosedSnapshot,
511                    scope.id.clone(),
512                    format!(
513                        "the series is bound to closed snapshot `{snapshot_id}`; the period the fold \
514                         applied is the caller's `{start}..{end}`, and events recorded after it are \
515                         not contributions"
516                    ),
517                )
518                .with_remedy(
519                    "pass the snapshot's own recorded period — its `start_date`/`due_date`, or the \
520                     day of its `closed_at` — so the exclusion ends where the cycle closed",
521                ),
522            );
523        }
524        if unplaced_events > 0 {
525            diagnostics.push(AnalyticsDiagnostic::new(
526                AnalyticsDiagnosticKind::MissingHistory,
527                scope.id.clone(),
528                format!(
529                    "{unplaced_events} recorded event(s) carry no readable calendar date and are \
530                     placed on no day, so every day's change set is incomplete"
531                ),
532            ));
533        }
534
535        diagnostics.sort();
536        Self {
537            scope: scope.clone(),
538            index: index_events,
539            members,
540            completed_members,
541            observed_days,
542            unplaced_events,
543            visited,
544            diagnostics,
545        }
546    }
547
548    /// The scope every contribution was classified against.
549    #[must_use]
550    pub fn scope(&self) -> &AnalyticsScope {
551        &self.scope
552    }
553
554    /// The scope's member issue numbers, ascending and deduplicated.
555    ///
556    /// The walk reads the caller's slice in its own order; this list is the summary it
557    /// produced, sorted after the walk, so two runs agree however the store was listed.
558    #[must_use]
559    pub fn members(&self) -> &[u32] {
560        &self.members
561    }
562
563    /// The classified, deduplicated, period-filtered contributions.
564    #[must_use]
565    pub fn event_index(&self) -> &EventIndex {
566        &self.index
567    }
568
569    /// Every day that carries a placed recorded event, in calendar order.
570    #[must_use]
571    pub fn observed_days(&self) -> &BTreeSet<String> {
572        &self.observed_days
573    }
574
575    /// How many recorded events carried no readable date.
576    #[must_use]
577    pub fn unplaced_events(&self) -> usize {
578        self.unplaced_events
579    }
580
581    /// How many issue records the pass read — its own "one pass" record.
582    ///
583    /// The counter is advanced inside [`ScopeSnapshot::build`]'s single loop over the
584    /// caller's slice, once per issue record, so it is a measurement of the walk and not
585    /// a restatement of `issues.len()`: a second walk doubles it, and the NFR-001 case
586    /// anchors "one pass" on it structurally rather than on a wall-clock time. What it
587    /// does **not** observe is stated by omission — it counts reads of the slice walk,
588    /// not the cost of the work performed inside the iteration.
589    #[must_use]
590    pub fn issues_visited(&self) -> usize {
591        self.visited
592    }
593
594    /// The findings the snapshot itself produced, sorted.
595    #[must_use]
596    pub fn diagnostics(&self) -> &[AnalyticsDiagnostic] {
597        &self.diagnostics
598    }
599
600    /// Fold the snapshot into a series for `mode`.
601    ///
602    /// Count mode is folded here; estimate mode resolves the shared unit conversion
603    /// in WP03's [`crate::analytics::units`], and until one is available reports
604    /// [`MissingConversion`](AnalyticsDiagnosticKind::MissingConversion) and folds no
605    /// units rather than guessing them.
606    #[must_use]
607    pub fn series(&self, mode: Mode) -> BurndownSeries {
608        match mode {
609            Mode::Count => self.count_series(),
610            Mode::Estimate => {
611                let mut diagnostics = self.diagnostics.clone();
612                diagnostics.push(AnalyticsDiagnostic::new(
613                    AnalyticsDiagnosticKind::MissingConversion,
614                    self.scope.id.clone(),
615                    "estimate mode needs an explicit unit conversion, and no compatible unit is \
616                     declared for this scope yet",
617                ));
618                diagnostics.sort();
619                BurndownSeries::empty(&self.scope, mode, diagnostics)
620            }
621        }
622    }
623
624    /// The count-mode fold (T008/T009/T010).
625    #[allow(clippy::too_many_lines)]
626    fn count_series(&self) -> BurndownSeries {
627        let scope = &self.scope;
628        let mut diagnostics = self.diagnostics.clone();
629        let origin_known = scope.period.start.is_some();
630
631        let days = match self.series_days() {
632            Ok(days) => days,
633            Err(problem) => {
634                diagnostics.push(problem.into_diagnostic(&scope.id));
635                diagnostics.sort();
636                return BurndownSeries::empty(scope, Mode::Count, diagnostics);
637            }
638        };
639
640        // One walk of the index, grouping by day. The sums and the counts below do not
641        // depend on the index's order; each day's provenance does, so it is ordered by
642        // the recorded key once the walk is over.
643        let mut additions: BTreeMap<String, i64> = BTreeMap::new();
644        let mut completions: BTreeMap<String, i64> = BTreeMap::new();
645        let mut event_ids: BTreeMap<String, Vec<String>> = BTreeMap::new();
646        let mut weak_days: BTreeSet<String> = BTreeSet::new();
647        let mut unclassified = 0usize;
648        let mut unclassified_example: Option<String> = None;
649        let mut unquantified = 0usize;
650        let mut unquantified_example: Option<String> = None;
651        for event in self.index.events() {
652            let Some(date) = event.date.as_deref() else {
653                continue;
654            };
655            event_ids
656                .entry(date.to_string())
657                .or_default()
658                .push(event.event_id.clone());
659            match event.class {
660                EventClass::ScopeAddition | EventClass::Completion => match event.delta {
661                    Some(delta) => {
662                        let bucket = match event.class {
663                            EventClass::ScopeAddition => &mut additions,
664                            _ => &mut completions,
665                        };
666                        *bucket.entry(date.to_string()).or_default() += delta;
667                    }
668                    // A classified change the record cannot turn into a count: the
669                    // day's change set is observed but not complete.
670                    None => {
671                        weak_days.insert(date.to_string());
672                        unquantified += 1;
673                        keep_smallest(&mut unquantified_example, &event.event_id);
674                    }
675                },
676                EventClass::EstimateChange => {}
677                EventClass::Unclassified => {
678                    unclassified += 1;
679                    keep_smallest(&mut unclassified_example, &event.event_id);
680                }
681            }
682        }
683
684        // An event that cannot be placed could belong to any day, so no day's change
685        // set is complete until it is placed.
686        let global_weak = self.unplaced_events > 0;
687
688        let mut points = Vec::with_capacity(days.len());
689        let mut scope_total = 0i64;
690        let mut completed = 0i64;
691        let mut first_unanswerable: Option<(&str, i64, i64)> = None;
692        let mut level_overflow: Option<&str> = None;
693        for day in &days {
694            let observed = self.observed_days.contains(day);
695            let added = additions.get(day).copied();
696            let done = completions.get(day).copied();
697            if observed
698                && level_overflow.is_none()
699                && (!carry_level(&mut scope_total, added.unwrap_or(0))
700                    || !carry_level(&mut completed, done.unwrap_or(0)))
701            {
702                level_overflow = Some(day);
703            }
704
705            let (level_total, level_remaining, level_completed) = if level_overflow.is_some() {
706                (None, None, None)
707            } else if origin_known {
708                let remaining = scope_total - completed;
709                if remaining < 0 {
710                    // Recorded completions outran recorded additions: the level's
711                    // origin is not visible in this period, so the answer is unknown
712                    // rather than a negative (or a clamped) invention.
713                    first_unanswerable.get_or_insert((day, scope_total, completed));
714                    (Some(scope_total), None, Some(completed))
715                } else {
716                    (Some(scope_total), Some(remaining), Some(completed))
717                }
718            } else {
719                (None, None, None)
720            };
721
722            let point = DailyPoint {
723                date: day.clone(),
724                scope_total: level_total,
725                remaining: level_remaining,
726                completed: level_completed,
727                scope_added: observed.then(|| added.unwrap_or(0)),
728                completions: observed.then(|| done.unwrap_or(0)),
729                coverage: Coverage::Known,
730                event_ids: event_ids.get(day).cloned().unwrap_or_default(),
731            };
732            let weak = global_weak || weak_days.contains(day);
733            let coverage = coverage_of(&point, weak);
734            points.push(DailyPoint { coverage, ..point });
735        }
736
737        // Each day's provenance is ordered by the crate's recorded key, never by the
738        // order the caller loaded the issues in, so the point does not depend on it.
739        for point in &mut points {
740            point
741                .event_ids
742                .sort_by(|left, right| event_id_key(left).cmp(&event_id_key(right)));
743        }
744
745        if let Some((day, total, done)) = first_unanswerable {
746            diagnostics.push(
747                AnalyticsDiagnostic::new(
748                    AnalyticsDiagnosticKind::MissingHistory,
749                    scope.id.clone(),
750                    format!(
751                        "on {day} the period records {done} completion(s) but only {total} scope \
752                         addition(s), so remaining work cannot be answered from the period alone"
753                    ),
754                )
755                .on(day),
756            );
757        }
758        if let Some(day) = level_overflow {
759            diagnostics.push(
760                AnalyticsDiagnostic::new(
761                    AnalyticsDiagnosticKind::MissingHistory,
762                    scope.id.clone(),
763                    format!(
764                        "on {day} the carried level exceeded the i64 range, so remaining work \
765                         cannot be answered without wrapping or inventing a value"
766                    ),
767                )
768                .on(day)
769                .with_remedy(
770                    "reduce the synthetic fixture: persisted issues use u32 counts and u32 \
771                     estimates, so a realistic project is orders of magnitude below this bound",
772                ),
773            );
774        }
775
776        if unclassified > 0 {
777            let mut diagnostic = AnalyticsDiagnostic::new(
778                AnalyticsDiagnosticKind::UnclassifiedEvent,
779                scope.id.clone(),
780                format!(
781                    "{unclassified} recorded event(s) in the period carry no scope or completion \
782                     meaning and move no line"
783                ),
784            );
785            if let Some(event_id) = unclassified_example {
786                diagnostic = diagnostic.about_event(event_id);
787            }
788            diagnostics.push(diagnostic);
789        }
790
791        if unquantified > 0 {
792            let mut diagnostic = AnalyticsDiagnostic::new(
793                AnalyticsDiagnosticKind::MissingHistory,
794                scope.id.clone(),
795                format!(
796                    "{unquantified} recorded scope/completion event(s) carry no count-level value, \
797                     so the days they fall on are coverage-limited"
798                ),
799            );
800            if let Some(event_id) = unquantified_example {
801                diagnostic = diagnostic.about_event(event_id);
802            }
803            diagnostics.push(diagnostic);
804        }
805
806        diagnostics.extend(self.gap_diagnostics(&points));
807        diagnostics.extend(self.baseline_diagnostic(origin_known, completed));
808        diagnostics.sort();
809
810        BurndownSeries {
811            scope: scope.clone(),
812            mode: Mode::Count,
813            unit: None,
814            points,
815            diagnostics,
816            formula_version: COUNT_FORMULA_VERSION.to_string(),
817        }
818    }
819
820    /// The days the series covers, in calendar order.
821    ///
822    /// The caller's bounds are used verbatim. A bound the caller left absent is
823    /// resolved to the observed extent of the recorded events — which is coverage,
824    /// not a guess, and is reported as an unanchored origin by the caller of this
825    /// helper. When neither a bound nor an observation exists, there is nothing to
826    /// enumerate and the period is reported instead.
827    fn series_days(&self) -> Result<Vec<String>, PeriodProblem> {
828        let start = self
829            .scope
830            .period
831            .start
832            .clone()
833            .or_else(|| self.observed_days.iter().next().cloned());
834        let end = self
835            .scope
836            .period
837            .end
838            .clone()
839            .or_else(|| self.observed_days.iter().next_back().cloned());
840        let (Some(start), Some(end)) = (start, end) else {
841            return Err(PeriodProblem::Unbounded);
842        };
843        if start > end {
844            return Err(PeriodProblem::Reversed { start, end });
845        }
846
847        let mut days = Vec::new();
848        let mut cursor = start;
849        loop {
850            if days.len() >= MAX_SERIES_DAYS {
851                return Err(PeriodProblem::TooLong { from: cursor });
852            }
853            days.push(cursor.clone());
854            if cursor == end {
855                return Ok(days);
856            }
857            let Some(next) = next_day(&cursor) else {
858                return Err(PeriodProblem::Unsteppable { from: cursor });
859            };
860            cursor = next;
861        }
862    }
863
864    /// One missing-history finding per unbroken run of days with no recorded event.
865    ///
866    /// A run, not a day: a series with one quiet week says "7 days" once rather than
867    /// seven times, which keeps the diagnostics to the number of *gaps* instead of
868    /// the number of days.
869    fn gap_diagnostics(&self, points: &[DailyPoint]) -> Vec<AnalyticsDiagnostic> {
870        let mut diagnostics = Vec::new();
871        let mut run: Option<(String, String, usize)> = None;
872        for point in points {
873            if self.observed_days.contains(&point.date) {
874                if let Some((first, last, days)) = run.take() {
875                    diagnostics.push(self.gap_diagnostic(&first, &last, days));
876                }
877            } else {
878                run = match run {
879                    Some((first, _, days)) => Some((first, point.date.clone(), days + 1)),
880                    None => Some((point.date.clone(), point.date.clone(), 1)),
881                };
882            }
883        }
884        if let Some((first, last, days)) = run {
885            diagnostics.push(self.gap_diagnostic(&first, &last, days));
886        }
887        diagnostics
888    }
889
890    /// One gap's finding.
891    fn gap_diagnostic(&self, first: &str, last: &str, days: usize) -> AnalyticsDiagnostic {
892        let span = if first == last {
893            first.to_string()
894        } else {
895            format!("{first}..{last}")
896        };
897        AnalyticsDiagnostic::new(
898            AnalyticsDiagnosticKind::MissingHistory,
899            self.scope.id.clone(),
900            format!("{days} day(s) in {span} carry no recorded history"),
901        )
902        .on(first)
903    }
904
905    /// Say when the period holds no completion event for members the tree records as
906    /// complete.
907    ///
908    /// The single place the shared status-category service informs a series, and it
909    /// informs it with a sentence only: the count of completed members comes from
910    /// [`crate::workflow::is_completed`], and a shortfall against the period's folded
911    /// completions means those members' completions are not in the period. No level is
912    /// filled in from it.
913    ///
914    /// The sentence names only what the fold can see. A member completed **after** the
915    /// period end is filtered as outside-period exactly like one completed before the
916    /// start, so the difference alone cannot say which side it is on; the message
917    /// therefore reports whether the period excluded any recorded event at all
918    /// ([`EventIndex::outside_period`]) and never asserts a direction it cannot know.
919    /// The remedy is the finding's own for the same reason — the kind's default tells a
920    /// reader to widen the period backwards, which a post-period completion cannot use.
921    fn baseline_diagnostic(
922        &self,
923        origin_known: bool,
924        folded_completions: i64,
925    ) -> Option<AnalyticsDiagnostic> {
926        if !origin_known || self.completed_members as i64 <= folded_completions {
927            return None;
928        }
929        let missing = self.completed_members as i64 - folded_completions;
930        let excluded = self.index.outside_period();
931        let (because, remedy) = if excluded > 0 {
932            (
933                format!(
934                    "the period excluded {excluded} recorded event(s), so those {missing} \
935                     completion(s) may lie before its start or after its end"
936                ),
937                "pass a period that contains the members' completion events — for a closed cycle, \
938                 the snapshot's own recorded period — or read each member's History to place them",
939            )
940        } else {
941            (
942                "the period excluded no recorded event at all, so those completions carry no \
943                 transition inside the series' period"
944                    .to_string(),
945                "record the completion through `tix mv` so the transition lands in the History this \
946                 series folds",
947            )
948        };
949        Some(
950            AnalyticsDiagnostic::new(
951                AnalyticsDiagnosticKind::MissingHistory,
952                self.scope.id.clone(),
953                format!(
954                    "the tree records {} of the scope's {} member(s) as complete, but the period \
955                     holds only {folded_completions} completion event(s); {because}",
956                    self.completed_members,
957                    self.members.len()
958                ),
959            )
960            .with_remedy(remedy),
961        )
962    }
963}
964
965/// Why a period could not be enumerated.
966///
967/// The small half of an
968/// [`InvalidPeriod`](AnalyticsDiagnosticKind::InvalidPeriod) finding, kept separate
969/// from [`AnalyticsDiagnostic`] on purpose: the diagnostic is a display value of
970/// well over a hundred bytes, and returning it as a `Result` error would put that
971/// weight on the `Ok` path of every call (clippy's `result_large_err`). The reason
972/// travels as a problem and becomes a diagnostic only where it is reported.
973enum PeriodProblem {
974    /// Neither bound is present and nothing was observed to stand in for one.
975    Unbounded,
976    /// The resolved start falls after the resolved end.
977    Reversed {
978        /// The resolved inclusive start.
979        start: String,
980        /// The resolved inclusive end.
981        end: String,
982    },
983    /// The span is past [`MAX_SERIES_DAYS`].
984    TooLong {
985        /// The day the enumeration stopped on.
986        from: String,
987    },
988    /// A day the calendar stepper cannot advance from.
989    Unsteppable {
990        /// The day that could not be stepped.
991        from: String,
992    },
993}
994
995impl PeriodProblem {
996    /// The `invalid_period` finding this problem produces.
997    fn into_diagnostic(self, scope: &str) -> AnalyticsDiagnostic {
998        let (message, date) = match self {
999            PeriodProblem::Unbounded => (
1000                "the period has no bound and the scope records no dated event, so there is no day \
1001                 to enumerate"
1002                    .to_string(),
1003                None,
1004            ),
1005            PeriodProblem::Reversed { start, end } => (
1006                format!("the period starts at {start}, after its end {end}"),
1007                None,
1008            ),
1009            PeriodProblem::TooLong { from } => (
1010                format!(
1011                    "the period spans more than {MAX_SERIES_DAYS} days, which is past any planning \
1012                     window and is refused rather than enumerated"
1013                ),
1014                Some(from),
1015            ),
1016            PeriodProblem::Unsteppable { from } => (
1017                format!("`{from}` is not a calendar day this build can step from"),
1018                Some(from),
1019            ),
1020        };
1021        let diagnostic =
1022            AnalyticsDiagnostic::new(AnalyticsDiagnosticKind::InvalidPeriod, scope, message);
1023        match date {
1024            Some(date) => diagnostic.on(date),
1025            None => diagnostic,
1026        }
1027    }
1028}
1029
1030/// Carry one recorded contribution onto an `i64` level.
1031///
1032/// The DTO records carried levels as `i64`. The fold refuses an overflowing
1033/// fixture instead of wrapping or panicking; callers turn the `false` result
1034/// into the existing unknown level. This boundary is not reachable from a
1035/// persisted project of realistic size, but it is constructible in memory.
1036pub(super) fn carry_level(level: &mut i64, delta: i64) -> bool {
1037    let Some(next) = level.checked_add(delta) else {
1038        return false;
1039    };
1040    *level = next;
1041    true
1042}
1043
1044/// Classify a point's coverage from what it can actually say.
1045///
1046/// `weak` marks a day whose recorded changes include one the fold could not turn
1047/// into a count, or a period with an unplaceable event; it downgrades an otherwise
1048/// fully-known day to coverage-limited, never to unknown.
1049fn coverage_of(point: &DailyPoint, weak: bool) -> Coverage {
1050    let values = point.values();
1051    let known = values.iter().filter(|value| value.is_some()).count();
1052    match known {
1053        0 => Coverage::Unknown,
1054        5 if !weak => Coverage::Known,
1055        _ => Coverage::CoverageLimited,
1056    }
1057}
1058
1059/// The identity key a cycle reference resolves to.
1060///
1061/// The rule is the crate's own — `capacity::calculate::scope_identity` answers a cycle
1062/// reference identically — so a scope and an issue that name one cycle are one identity
1063/// here for the same reason they are there:
1064///
1065/// * a generated cycle is `cycle-id:<id>`, whatever name spelled it;
1066/// * a legacy name the identity index carries is `cycle-name:<name>`;
1067/// * a name the *configuration* declares but no record carries is a legacy name too,
1068///   and keeps `cycle-name:<name>`;
1069/// * a reference neither of them knows keeps its own text in its own namespace,
1070///   `cycle-ref:<name>`, so two unresolvable names are not folded into one scope.
1071///
1072/// What the identity is *used for* is where this module's policy parts with capacity's:
1073/// the same unresolvable reference selects **nobody** in
1074/// [`crate::capacity::scope::IssueSnapshot`] (a capacity entry must be validated against
1075/// a cycle that exists), while this fold selects the issues that spell that name. See
1076/// the module documentation; the resolution itself is shared.
1077fn cycle_key(config: &Config, index: &ScheduleIndex, reference: &str) -> String {
1078    match resolve_cycle(index, reference) {
1079        CycleReference::Generated { id, .. } => format!("cycle-id:{id}"),
1080        CycleReference::Legacy { name } => format!("cycle-name:{name}"),
1081        CycleReference::Unknown { .. } if config.cycles.contains_key(reference) => {
1082            format!("cycle-name:{reference}")
1083        }
1084        CycleReference::Unknown { name } => format!("cycle-ref:{name}"),
1085    }
1086}
1087
1088/// Whether an issue is recorded as a member of `scope`.
1089///
1090/// A milestone matches its recorded name. A cycle matches the identity key `scope_key`
1091/// carries — computed once per build, so the resolver runs once per series and not once
1092/// per issue.
1093fn is_member(
1094    issue: &Issue,
1095    scope: &AnalyticsScope,
1096    config: &Config,
1097    index: &ScheduleIndex,
1098    scope_key: Option<&str>,
1099) -> bool {
1100    match scope.kind {
1101        ScopeKind::Milestone => issue
1102            .milestones
1103            .iter()
1104            .flatten()
1105            .any(|milestone| milestone == &scope.id),
1106        // `scope_key` is `Some` for every cycle scope — the caller derives it from the
1107        // scope kind — so an absent key means the scope is not a cycle and this arm is
1108        // never reached. It answers `false` rather than panicking: a read that cannot
1109        // place a member reports no membership, and the empty series follows.
1110        ScopeKind::Cycle => scope_key.is_some_and(|key| {
1111            issue
1112                .cycles
1113                .iter()
1114                .flatten()
1115                .any(|cycle| cycle_key(config, index, cycle) == key)
1116        }),
1117    }
1118}
1119
1120/// The recorded order of a contribution id.
1121///
1122/// A History contribution's id is `<issue number>#<entry index>`, so ordering on the
1123/// parsed pair puts `2#0` before `10#0`, which sorting the text would not. Anything of
1124/// another shape (a snapshot id, say) orders after every such id, by its own text, so
1125/// every id has exactly one place in the order.
1126pub(super) fn event_id_key(id: &str) -> (u8, u64, u64, &str) {
1127    if let Some((source, index)) = id.split_once('#')
1128        && let Ok(number) = source.parse::<u64>()
1129    {
1130        return (0, number, index.parse().unwrap_or(u64::MAX), "");
1131    }
1132    (1, 0, 0, id)
1133}
1134
1135/// Keep the smallest id in an optional slot, so a finding's example event does not
1136/// depend on the order the caller loaded the issues in.
1137fn keep_smallest(slot: &mut Option<String>, candidate: &str) {
1138    let smaller = match slot.as_deref() {
1139        Some(current) => event_id_key(candidate) < event_id_key(current),
1140        None => true,
1141    };
1142    if smaller {
1143        *slot = Some(candidate.to_string());
1144    }
1145}
1146
1147/// The next calendar day, or `None` for a string this build cannot step from.
1148///
1149/// The crate validates `YYYY-MM-DD` ([`crate::model::validate_date`]) but has no
1150/// public calendar-step helper — [`super::events::Period`] deliberately compares
1151/// dates as strings — so a series that must enumerate days steps them here, with the
1152/// leap-year rule the validator already uses.
1153fn next_day(date: &str) -> Option<String> {
1154    if !crate::model::validate_date(date) {
1155        return None;
1156    }
1157    let year: i32 = date.get(0..4)?.parse().ok()?;
1158    let month: u32 = date.get(5..7)?.parse().ok()?;
1159    let mut day: u32 = date.get(8..10)?.parse::<u32>().ok()? + 1;
1160    let mut month = month;
1161    let mut year = year;
1162    if day > days_in_month(year, month) {
1163        day = 1;
1164        month += 1;
1165        if month > 12 {
1166            month = 1;
1167            year += 1;
1168        }
1169    }
1170    Some(format!("{year:04}-{month:02}-{day:02}"))
1171}
1172
1173/// How many days a month has, with the proleptic Gregorian leap rule
1174/// [`crate::model::validate_date`] applies.
1175fn days_in_month(year: i32, month: u32) -> u32 {
1176    match month {
1177        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
1178        4 | 6 | 9 | 11 => 30,
1179        2 if is_leap_year(year) => 29,
1180        2 => 28,
1181        _ => 0,
1182    }
1183}
1184
1185/// Whether `year` is a leap year in the proleptic Gregorian calendar.
1186fn is_leap_year(year: i32) -> bool {
1187    year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
1188}
1189
1190#[cfg(test)]
1191mod tests {
1192    #[allow(clippy::wildcard_imports)]
1193    use super::*;
1194
1195    #[test]
1196    fn a_series_steps_days_across_month_year_and_leap_boundaries() {
1197        assert_eq!(next_day("2026-01-05").as_deref(), Some("2026-01-06"));
1198        assert_eq!(next_day("2026-01-31").as_deref(), Some("2026-02-01"));
1199        assert_eq!(next_day("2026-02-28").as_deref(), Some("2026-03-01"));
1200        assert_eq!(next_day("2028-02-28").as_deref(), Some("2028-02-29"));
1201        assert_eq!(next_day("2028-02-29").as_deref(), Some("2028-03-01"));
1202        assert_eq!(next_day("2026-12-31").as_deref(), Some("2027-01-01"));
1203        // The century rule both ways: 2000 is a leap year (divisible by 400),
1204        // 2100 is not (divisible by 100 but not 400). `validate_date` starts at
1205        // 1970, so the cases stay inside the years the crate accepts.
1206        assert_eq!(next_day("2000-02-28").as_deref(), Some("2000-02-29"));
1207        assert_eq!(next_day("2100-02-28").as_deref(), Some("2100-03-01"));
1208    }
1209
1210    #[test]
1211    fn a_string_that_is_not_a_calendar_day_does_not_step() {
1212        assert_eq!(next_day("2026-02-30"), None);
1213        assert_eq!(next_day("not-a-day"), None);
1214        assert_eq!(next_day("2026-2-3"), None);
1215    }
1216
1217    #[test]
1218    fn carrying_a_level_refuses_the_i64_boundary_instead_of_wrapping() {
1219        let mut level = i64::MAX - 1;
1220        assert!(carry_level(&mut level, 1));
1221        assert_eq!(level, i64::MAX);
1222        assert!(
1223            !carry_level(&mut level, 1),
1224            "the boundary is a refusal, not a wrap"
1225        );
1226        assert_eq!(
1227            level,
1228            i64::MAX,
1229            "a refused carry leaves the prior level intact"
1230        );
1231
1232        let mut lowest = i64::MIN + 1;
1233        assert!(carry_level(&mut lowest, -1));
1234        assert!(!carry_level(&mut lowest, -1));
1235        assert_eq!(lowest, i64::MIN);
1236    }
1237
1238    #[test]
1239    fn the_coverage_marker_describes_the_point() {
1240        let base = DailyPoint {
1241            date: "2026-01-05".to_string(),
1242            scope_total: Some(3),
1243            remaining: Some(2),
1244            completed: Some(1),
1245            scope_added: Some(1),
1246            completions: Some(1),
1247            coverage: Coverage::Known,
1248            event_ids: vec!["1#0".to_string()],
1249        };
1250        assert_eq!(coverage_of(&base, false), Coverage::Known);
1251        assert_eq!(
1252            coverage_of(&base, true),
1253            Coverage::CoverageLimited,
1254            "a day the fold could not fully quantify is limited, not known"
1255        );
1256
1257        let gap = DailyPoint {
1258            scope_added: None,
1259            completions: None,
1260            ..base.clone()
1261        };
1262        assert_eq!(coverage_of(&gap, false), Coverage::CoverageLimited);
1263
1264        let unanchored = DailyPoint {
1265            scope_total: None,
1266            remaining: None,
1267            completed: None,
1268            scope_added: None,
1269            completions: None,
1270            ..base.clone()
1271        };
1272        assert_eq!(coverage_of(&unanchored, false), Coverage::Unknown);
1273    }
1274}