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}