Skip to main content

tix/analytics/
projection.rs

1//! The read-only planning-end projection (mission `burndown-scope-analytics-01M3C9J1`,
2//! WP03 T016/T017).
3//!
4//! # What it answers, and what it never does
5//!
6//! Given one [`BurndownSeries`], the day the caller asks **as of**, and the planning
7//! **end date** the caller supplies, [`project`] answers one question: at the
8//! completion rate the recorded series actually shows, when does the recorded
9//! remaining work reach zero — and does that fall on or before the planning end?
10//!
11//! Nothing here reads a clock ([`Projection::as_of`] is the caller's day), opens a
12//! store, or writes anything: it is a forecast over an already-folded series, and
13//! every number it publishes is one the series already carries or one the documented
14//! formula derives from those numbers.
15//!
16//! # The documented formula (version [`PROJECTION_FORMULA_VERSION`])
17//!
18//! ```text
19//! first            = the series' first covered day
20//! elapsed          = calendar days from `first` to `as_of`
21//! completed_delta  = completed(as_of) − completed(first)
22//! observed_rate    = completed_delta / elapsed                 [work per day, in the series' unit]
23//! days_needed      = ceil(remaining(as_of) / observed_rate)    [remaining > 0, observed_rate > 0]
24//! projected        = as_of + days_needed
25//! ```
26//!
27//! `days_needed` is evaluated as `ceil(remaining · elapsed / completed_delta)` in
28//! integers, so the projected **date** never depends on a float rounding; the float
29//! `observed_rate` is the same quotient, published for a reader.
30//!
31//! # The three states, and why a date is never invented (T017)
32//!
33//! | state | when |
34//! |---|---|
35//! | [`Projected`](ProjectionStatus::Projected) | the series answers for `as_of` and a completion date follows: the completions the record shows consumed the whole scope (already complete), or the observed rate is positive |
36//! | [`Unknown`](ProjectionStatus::Unknown) | the series answers, but no rate that can reach zero appears: no completion was recorded inside the window, or the derived date is outside the calendar this build can express |
37//! | [`InsufficientData`](ProjectionStatus::InsufficientData) | the record cannot answer at all: the series holds no point for `as_of`, the levels on it are unknown, the window holds no elapsed day, or the period recorded nothing at all — an empty level is not a finished one |
38//!
39//! Nothing is reconstructed to fill a gap: the levels, the rate and the remaining work
40//! are all read off the series, and a state that cannot answer says so with one
41//! [`ProjectionUnknown`](AnalyticsDiagnosticKind::ProjectionUnknown) finding and no
42//! date. A closed series ([`crate::analytics::events::AnalyticsScope::snapshot_id`])
43//! projects from the snapshot's own immutable points like any other — the projection
44//! never rewrites them.
45//!
46//! # Determinism (C-007)
47//!
48//! The whole projection is a pure function of the series and the two dates, and the
49//! only comparisons are over canonical `YYYY-MM-DD` strings (chronological for that
50//! shape) — so two runs produce the same value, and a chart document carrying it is
51//! byte-stable.
52
53use time::{Date, Month};
54
55use crate::error::{Result, TixError};
56use crate::model::validate_date;
57
58use super::diagnostics::{AnalyticsDiagnostic, AnalyticsDiagnosticKind};
59use super::events::AnalyticsScope;
60use super::series::{BurndownSeries, Mode};
61
62/// The documented formula this projection implements.
63///
64/// A stable string rather than a number, for the same reason
65/// [`crate::analytics::series::COUNT_FORMULA_VERSION`] is: a reader compares it for
66/// equality, and a version *sequence* invites arithmetic on it that means nothing.
67pub const PROJECTION_FORMULA_VERSION: &str = "burndown.projection.v1";
68
69/// Whether the projection could answer, and with what (data-model §5 `status`).
70#[derive(
71    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
72)]
73#[serde(rename_all = "snake_case")]
74pub enum ProjectionStatus {
75    /// A completion date follows from the recorded series.
76    Projected,
77    /// The series answers, but no rate that can reach zero appears, so no date is
78    /// invented.
79    Unknown,
80    /// The record cannot answer: no level, or no elapsed observation, at `as_of`.
81    InsufficientData,
82}
83
84impl ProjectionStatus {
85    /// Every state, in the order a surface renders them.
86    pub const ALL: [ProjectionStatus; 3] = [
87        ProjectionStatus::Projected,
88        ProjectionStatus::Unknown,
89        ProjectionStatus::InsufficientData,
90    ];
91
92    /// The canonical spelling used in output and in payloads.
93    pub const fn as_str(self) -> &'static str {
94        match self {
95            ProjectionStatus::Projected => "projected",
96            ProjectionStatus::Unknown => "unknown",
97            ProjectionStatus::InsufficientData => "insufficient_data",
98        }
99    }
100
101    /// Parse a canonical spelling; `None` for anything outside the vocabulary.
102    pub fn parse(raw: &str) -> Option<Self> {
103        match raw.trim().to_ascii_lowercase().as_str() {
104            "projected" => Some(ProjectionStatus::Projected),
105            "unknown" => Some(ProjectionStatus::Unknown),
106            "insufficient_data" => Some(ProjectionStatus::InsufficientData),
107            _ => None,
108        }
109    }
110
111    /// Whether a completion date was derived.
112    pub const fn is_projected(self) -> bool {
113        matches!(self, ProjectionStatus::Projected)
114    }
115}
116
117impl std::fmt::Display for ProjectionStatus {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.write_str(self.as_str())
120    }
121}
122
123/// The read-only end-date forecast over one series (data-model §5).
124///
125/// `observed_rate` and `remaining` are in the **series' own unit** — the counts of
126/// count mode, or the converted units of estimate mode — which `mode`/`unit` carry
127/// so a reader never sees a rate without the unit it is expressed in.
128///
129/// The type holds an [`AnalyticsScope`] (and therefore no serde derive, like
130/// [`BurndownSeries`]): the chart document either derives these types at the
131/// export layer or mirrors them there.
132#[derive(Debug, Clone, PartialEq)]
133pub struct Projection {
134    /// The selected cycle/milestone, period and snapshot binding.
135    pub scope: AnalyticsScope,
136    /// How the series the projection was drawn from counts.
137    pub mode: Mode,
138    /// The unit the remaining work and the rate are expressed in, in estimate mode.
139    pub unit: Option<String>,
140    /// The forecast origin — the caller's day, never a clock.
141    pub as_of: String,
142    /// The planning end date the forecast is drawn against, as the caller supplied it.
143    pub end_date: String,
144    /// The remaining work the series carried to `as_of`, in the series' unit.
145    pub remaining: Option<i64>,
146    /// The observed completion rate, in the series' unit per calendar day.
147    pub observed_rate: Option<f64>,
148    /// The forecast completion date, when one follows.
149    pub projected_completion: Option<String>,
150    /// Whether the projected completion falls on or before `end_date`; `None` when
151    /// no date was projected.
152    pub meets_end_date: Option<bool>,
153    /// Whether the projection could answer, and with what.
154    pub status: ProjectionStatus,
155    /// The documented formula this projection was drawn under.
156    pub formula_version: String,
157    /// Why no date was projected, when one was not.
158    pub diagnostics: Vec<AnalyticsDiagnostic>,
159}
160
161impl Projection {
162    /// Whether a completion date was derived.
163    #[must_use]
164    pub fn is_projected(&self) -> bool {
165        self.status.is_projected()
166    }
167
168    /// The projected completion date, when one was derived.
169    #[must_use]
170    pub fn projected_completion(&self) -> Option<&str> {
171        self.projected_completion.as_deref()
172    }
173
174    /// Settle on `status` and record why no date follows.
175    fn unanswered(mut self, status: ProjectionStatus, message: String, remedy: &str) -> Self {
176        self.status = status;
177        self.diagnostics.push(
178            AnalyticsDiagnostic::new(
179                AnalyticsDiagnosticKind::ProjectionUnknown,
180                self.scope.id.clone(),
181                message,
182            )
183            .on(self.as_of.clone())
184            .with_remedy(remedy),
185        );
186        self
187    }
188
189    /// Settle the projection on a derived completion date.
190    fn project_on(mut self, completion: String) -> Self {
191        self.meets_end_date = Some(completion <= self.end_date);
192        self.projected_completion = Some(completion);
193        self.status = ProjectionStatus::Projected;
194        self
195    }
196}
197
198/// Project `series` against the planning end date `end_date`, as of `as_of`.
199///
200/// Both dates are the **caller's** — this function reads no clock — and each must be
201/// a canonical `YYYY-MM-DD` calendar date; anything else is refused here, in the same
202/// words [`crate::analytics::events::Period::new`] refuses a malformed bound, rather
203/// than silently dropped into a forecast.
204///
205/// The end date is not required to be after `as_of`: "we are past the planning end,
206/// when will this finish?" is a question the projection answers like any other (with
207/// `meets_end_date: Some(false)`).
208pub fn project(series: &BurndownSeries, as_of: &str, end_date: &str) -> Result<Projection> {
209    let as_of = canonical("as_of", as_of)?;
210    let end_date = canonical("end_date", end_date)?;
211    let mut projection = Projection {
212        scope: series.scope.clone(),
213        mode: series.mode,
214        unit: series.unit.clone(),
215        as_of: as_of.clone(),
216        end_date,
217        remaining: None,
218        observed_rate: None,
219        projected_completion: None,
220        meets_end_date: None,
221        status: ProjectionStatus::InsufficientData,
222        formula_version: PROJECTION_FORMULA_VERSION.to_string(),
223        diagnostics: Vec::new(),
224    };
225
226    let covered = series.points.first().zip(series.points.last()).map_or_else(
227        || "no day at all".to_string(),
228        |(first, last)| format!("{}..{}", first.date, last.date),
229    );
230
231    // (1) The origin must be a day the series answers for.
232    let Some(index) = series.points.iter().position(|point| point.date == as_of) else {
233        return Ok(projection.unanswered(
234            ProjectionStatus::InsufficientData,
235            format!(
236                "the series covers {covered} and holds no point for {as_of}, so the record cannot \
237                 answer a forecast for that day"
238            ),
239            "pass an `as-of` day the series covers, or widen the series' period",
240        ));
241    };
242    let origin = &series.points[index];
243    projection.remaining = origin.remaining;
244
245    // (2) The levels the fold carried to the origin must be known.
246    let Some(remaining) = origin.remaining else {
247        return Ok(projection.unanswered(
248            ProjectionStatus::InsufficientData,
249            format!(
250                "the remaining work on {as_of} is not known from the record (the period's levels \
251                 are unanchored or a recorded completion outweighs its additions), so no rate can \
252                 be read from it"
253            ),
254            "widen the series' period with an earlier `--from` so its levels are anchored, then \
255             project again",
256        ));
257    };
258
259    // (3) The observed window: the series' first covered day to the origin.
260    let (Some(first), Some(completed_first), Some(completed_origin)) = (
261        series.points.first(),
262        series.points.first().and_then(|point| point.completed),
263        origin.completed,
264    ) else {
265        return Ok(projection.unanswered(
266            ProjectionStatus::InsufficientData,
267            format!(
268                "the recorded completions carried to {as_of} are not known, so no completion rate \
269                 can be observed over the period"
270            ),
271            "widen the series' period with an earlier `--from` so the level's origin is in it",
272        ));
273    };
274    if index == 0 {
275        return Ok(projection.unanswered(
276            ProjectionStatus::InsufficientData,
277            format!(
278                "{as_of} is the series' first covered day, so the period holds no elapsed day to \
279                 observe a completion rate over"
280            ),
281            "ask for an origin later than the series' first covered day",
282        ));
283    }
284
285    let delta = completed_origin - completed_first;
286    projection.observed_rate = Some(rate_per_day(delta, index));
287
288    if remaining == 0 {
289        // Nothing remained at the origin. When the record also shows the completions
290        // that consumed the scope, that is a fact and the projection states it — the
291        // origin itself, not a forecast standing in for one. When the period recorded
292        // nothing at all, its levels are *empty* rather than finished (`0 − 0`), and
293        // calling that complete would be the zero-by-assumption C-005 forbids.
294        if completed_origin > 0 {
295            return Ok(projection.project_on(as_of));
296        }
297        return Ok(projection.unanswered(
298            ProjectionStatus::InsufficientData,
299            "the period records no scope addition and no completion for this scope, so its levels \
300             are empty rather than finished and no completion date is drawn"
301                .to_string(),
302            "widen the series' period with an earlier `--from` so the scope's recorded work is in \
303             it",
304        ));
305    }
306
307    if delta <= 0 {
308        return Ok(projection.unanswered(
309            ProjectionStatus::Unknown,
310            format!(
311                "the period records no completion between {} and {as_of}, so there is no observed \
312                 rate to project {remaining} remaining `{}` from and no completion date is \
313                 invented",
314                first.date,
315                series.unit.as_deref().unwrap_or("unit")
316            ),
317            "record at least one completion inside the window before asking for a forecast",
318        ));
319    }
320
321    let Some(days) = days_needed(remaining, index, delta) else {
322        return Ok(projection.unanswered(
323            ProjectionStatus::Unknown,
324            format!(
325                "the observed rate projects {remaining} remaining `{}` past the calendar this \
326                 build can express, so no completion date is drawn",
327                series.unit.as_deref().unwrap_or("unit")
328            ),
329            "project over a shorter remaining scope, or from an origin the rate can reach zero from",
330        ));
331    };
332    let Some(completion) = shift(&as_of, days) else {
333        return Ok(projection.unanswered(
334            ProjectionStatus::Unknown,
335            format!(
336                "a completion {days} day(s) after {as_of} is outside the calendar this build can \
337                 express, so no completion date is drawn"
338            ),
339            "project from a different origin",
340        ));
341    };
342    Ok(projection.project_on(completion))
343}
344
345/// The observed completion rate: `delta` contributions over `elapsed` calendar days.
346///
347/// The projection's one division, and the only place a float appears — the data
348/// model types `observed_rate` as a numeric, and "0.5 items a day" is a rate a reader
349/// can act on. The **date** the rate produces is computed in integers ([`days_needed`])
350/// precisely so this quotient's rounding can never move it, and the values it divides
351/// are bounded by what a project's history records.
352#[allow(clippy::cast_precision_loss)]
353fn rate_per_day(delta: i64, elapsed: usize) -> f64 {
354    delta as f64 / elapsed as f64
355}
356
357/// The whole days `remaining` needs at `delta` completions per `elapsed` days.
358///
359/// The documented `ceil(remaining / observed_rate)`, evaluated in integers, so the
360/// projected date is exact rather than a float's neighbour. `None` when the quotient
361/// does not fit an `i64` day count; whether the shift from that count is a day the
362/// calendar can express is [`shift`]'s answer, and both resolve to the caller's
363/// "outside the calendar" branch.
364fn days_needed(remaining: i64, elapsed: usize, delta: i64) -> Option<i64> {
365    let denominator = i128::from(delta);
366    if denominator <= 0 {
367        return None;
368    }
369    let numerator = i128::from(remaining) * i128::try_from(elapsed).ok()?;
370    // Ceiling division in integers; both operands are positive here.
371    let days = (numerator + denominator - 1) / denominator;
372    i64::try_from(days).ok()
373}
374
375/// `date` shifted by `days` calendar days, or `None` past the expressible calendar.
376///
377/// The shift goes through Julian day numbers rather than a `time::Duration`: the
378/// duration's own day constructor multiplies by 86,400 and **panics** on overflow
379/// (`SignedDuration::days`'s `expect`), and it would be constructed before
380/// `Date::checked_add` could reject the magnitude. The days this function is handed are
381/// bounded only by the estimates a record can state over a window up to
382/// [`crate::analytics::series::MAX_SERIES_DAYS`], so an unshiftable magnitude must come
383/// back as `None` — the caller's "outside the calendar this build can express" answer —
384/// rather than as a panic. The `i32` bound is above what the calendar itself holds
385/// (`Date::MIN`..`Date::MAX` is ~±5.4e6 days around the anchor), so nothing a date
386/// could have answered is refused here.
387fn shift(date: &str, days: i64) -> Option<String> {
388    let start = calendar(date)?;
389    let julian = start
390        .to_julian_day()
391        .checked_add(i32::try_from(days).ok()?)?;
392    Date::from_julian_day(julian).ok().map(render)
393}
394
395/// A canonical `YYYY-MM-DD` day as a `time` date.
396fn calendar(date: &str) -> Option<Date> {
397    let year: i32 = date.get(0..4)?.parse().ok()?;
398    let month: u8 = date.get(5..7)?.parse().ok()?;
399    let day: u8 = date.get(8..10)?.parse().ok()?;
400    Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok()
401}
402
403/// A `time` date as the canonical `YYYY-MM-DD` string.
404fn render(date: Date) -> String {
405    format!(
406        "{:04}-{:02}-{:02}",
407        date.year(),
408        u8::from(date.month()),
409        date.day()
410    )
411}
412
413/// A required date input, refused in the crate's `invalid_period` words when it is
414/// not a canonical calendar day.
415fn canonical(field: &str, raw: &str) -> Result<String> {
416    let date = raw.trim();
417    if !validate_date(date) {
418        return Err(TixError::Config(format!(
419            "invalid_period: `{field}` is `{raw}`, not a `YYYY-MM-DD` calendar date"
420        )));
421    }
422    Ok(date.to_string())
423}