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}