Skip to main content

tix/capacity/
calculate.rs

1//! The one shared capacity calculation (mission `cycle-capacity-planning-01M3C85C`, WP03,
2//! T013–T016).
3//!
4//! # The formulas (C-005)
5//!
6//! For one scope — a cycle, a team, or an assignee — in the unit a capacity entry
7//! declares:
8//!
9//! * **committed** — the sum of the **valid, converted** estimates of the issues in
10//!   scope. An issue that states no estimate, or whose estimate cannot be read in that
11//!   unit, contributes nothing and stays visible: it is counted in
12//!   [`CapacityCalculation::unestimated_issues`] or
13//!   [`CapacityCalculation::uncounted_issues`] and its own diagnostic names it. It is
14//!   never counted as zero, and the sum is never presented as complete without them.
15//! * **completed** — the same per-issue numbers, restricted to the issues
16//!   [`IssueSnapshot::is_completed`] answers for. That answer is the shared #183
17//!   status-category service, resolved with board context once, when the snapshot was
18//!   built (C-002). Neither this module nor any surface compares a status string.
19//! * **remaining** — `committed - completed`. `completed` is the same numbers over a
20//!   subset of the same issues, so the subtraction cannot underflow.
21//! * **overflow** — `max(committed - capacity, 0)`, and `Some(0)` at or under the
22//!   ceiling: a scope exactly at its ceiling is not over it.
23//! * **utilization** — `floor(committed * 100 / capacity)`: the crate's **floored
24//!   whole-percent** policy, implemented here in exact `u128` integer arithmetic. The
25//!   epics sibling implements the same floor policy independently with exact `i128`
26//!   arithmetic over its signed weights, so both stay correct at very large magnitudes.
27//!   This module's own policy — the rounding this package owns — is pinned by the table in
28//!   `tests/capacity_calculate.rs`. It is `None` when no ceiling is configured —
29//!   unconfigured is not `0%` — and when the ceiling is `0`, where the ratio has no value
30//!   at all.
31//!
32//! # One pass, and no second one
33//!
34//! The snapshot indexed the issues once (WP02): cycle/team/assignee membership, the
35//! completion answer, every estimate parse and every explicit conversion. A calculation
36//! walks the **membership list** the snapshot already holds and reads per-issue values out
37//! of its tables — it never opens a file, never re-parses an estimate and never re-derives
38//! completion (NFR-001). [`CapacityCalculation::for_scope`] therefore takes a
39//! `&IssueSnapshot`, the `&ScheduleIndex` that built it, and the project `&Config` that
40//! built it: there is no store, no project root and no writer in the signature at all,
41//! which is how the read-only guarantee is kept rather than promised (C-001/C-006).
42//!
43//! # An uncalculable result is not a zero
44//!
45//! Every derived field is `None` exactly when the calculation could not run, and
46//! [`CapacityCalculation::state`] says which of the two reasons applies: the scope has no
47//! ceiling in force ([`CapacityState::Unconfigured`], which is not a zero ceiling — a zero
48//! ceiling would report every commit as an overflow), or it has a ceiling whose unit the
49//! snapshot carries no conversion for ([`CapacityState::Uncountable`], with a diagnostic
50//! saying so). `Some(0)` always means "counted, and the total is zero".
51//!
52//! # One vocabulary
53//!
54//! Every finding is a [`CapacityWarning`] carrying a [`CapacityWarningKind`] borrowed from
55//! the registry that already owns the condition, so CLI, TUI, MCP and portable output
56//! consume one value rather than each re-deriving a formula or a rounding rule
57//! (C-007/NFR-003).
58
59use super::config::{CapacityEntry, CapacityScopeKind, resolve_cycle};
60use super::scope::IssueSnapshot;
61use super::warning::{CapacityWarning, CapacityWarningKind};
62use crate::assignee::parse_assignee;
63use crate::config::Config;
64use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
65use crate::wip::WipDiagnosticKind;
66
67/// Whether a calculation had a ceiling it could count against (T016).
68///
69/// Derived from the calculation's own fields rather than stored beside them, so the state
70/// and the numbers cannot drift apart.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum CapacityState {
73    /// No capacity entry is in force for the scope: there is no ceiling and no unit, so
74    /// every derived value is `None`. **Unconfigured is not zero capacity.**
75    Unconfigured,
76    /// A ceiling is in force, but the snapshot resolved no conversion for its unit, so
77    /// nothing was counted and every derived value is `None`. A diagnostic says why.
78    Uncountable,
79    /// A ceiling is in force and the values were derived.
80    Configured,
81}
82
83impl CapacityState {
84    /// The stable spelling every surface prints.
85    #[must_use]
86    pub const fn as_str(self) -> &'static str {
87        match self {
88            Self::Unconfigured => "unconfigured",
89            Self::Uncountable => "uncountable",
90            Self::Configured => "configured",
91        }
92    }
93}
94
95impl std::fmt::Display for CapacityState {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// The resolved scope one calculation runs for (data-model §2).
102///
103/// `reference` is what the caller wrote; `display` is what it resolves to. The two differ
104/// whenever a spelling is not the identity — a generated cycle's stable id versus its
105/// name, `@ada` versus `ada` — and keeping both is what lets a surface print the cycle a
106/// human named while comparing on the identity the records answer with.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct CapacityScope {
109    /// The dimension the scope names.
110    pub kind: CapacityScopeKind,
111    /// The reference as the caller wrote it: a cycle name or stable id, a team label, or
112    /// an assignee identity.
113    pub reference: String,
114    /// The human label the reference resolves to.
115    pub display: String,
116}
117
118impl Default for CapacityScope {
119    /// The empty scope: a cycle reference that resolves to nothing. A calculation over it
120    /// is unconfigured unless a section happens to declare a ceiling for it.
121    fn default() -> Self {
122        Self {
123            kind: CapacityScopeKind::Cycle,
124            reference: String::new(),
125            display: String::new(),
126        }
127    }
128}
129
130impl CapacityScope {
131    /// Resolve one reference into a scope, through the crate's own adapters.
132    ///
133    /// The index is what turns a reference into a display label and into the identity two
134    /// spellings share ([`resolve_cycle`], [`crate::assignee::parse_assignee`]) — the same
135    /// resolution [`super::config::validate`] applies to a capacity entry, so a ceiling
136    /// and the scope a caller asks about cannot disagree about which cycle they mean.
137    #[must_use]
138    pub fn new(
139        kind: CapacityScopeKind,
140        reference: impl Into<String>,
141        index: &ScheduleIndex,
142    ) -> Self {
143        let reference = reference.into();
144        let display = match kind {
145            CapacityScopeKind::Cycle => match resolve_cycle(index, &reference) {
146                CycleReference::Generated { name, .. } | CycleReference::Legacy { name } => name,
147                CycleReference::Unknown { name } => name,
148            },
149            CapacityScopeKind::Team => reference.clone(),
150            CapacityScopeKind::Assignee => {
151                parse_assignee(&reference).unwrap_or_else(|| reference.clone())
152            }
153        };
154        Self {
155            kind,
156            reference,
157            display,
158        }
159    }
160
161    /// The scope as one message reads it: `cycle `Cycle 1``.
162    #[must_use]
163    pub fn describe(&self) -> String {
164        format!("{} `{}`", self.kind, self.display)
165    }
166}
167
168/// Which snapshot a calculation read (T013's source reference).
169///
170/// A [`CapacityCalculation`] never owns the snapshot: every surface receives the DTO, and
171/// copying 10,000 issue records into each one would make the shared value the expensive
172/// part of the system. This is the reference a surface can compare and quote instead —
173/// how many issues the snapshot indexed and which units it resolved conversions for, i.e.
174/// the exact reading the numbers came from. A historical snapshot *identity* is WP04's
175/// (the close snapshot), not this: these numbers are always the current reading.
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub struct CapacitySource {
178    /// How many issues the snapshot indexed ([`IssueSnapshot::len`]).
179    pub issues: usize,
180    /// The units the snapshot resolved conversions for ([`IssueSnapshot::targets`]), in
181    /// the snapshot's stable order.
182    pub units: Vec<String>,
183}
184
185impl From<&IssueSnapshot> for CapacitySource {
186    fn from(snapshot: &IssueSnapshot) -> Self {
187        Self {
188            issues: snapshot.len(),
189            units: snapshot.targets().to_vec(),
190        }
191    }
192}
193
194/// The shared derived result for one scope (data-model §4, T013).
195///
196/// Every derived field is `None` exactly when the calculation could not run; see the
197/// module docs for the rule. The DTO is read-only by construction: it holds no store, no
198/// root and no writer, so the same value that a CLI prints is the one an MCP tool returns
199/// and the one portable output serializes (C-007).
200#[derive(Debug, Clone, Default, PartialEq, Eq)]
201pub struct CapacityCalculation {
202    /// The scope the numbers describe.
203    pub scope: CapacityScope,
204    /// The ceiling in force, or `None` when no capacity entry applies: **unconfigured is
205    /// not a zero ceiling.**
206    pub capacity: Option<u64>,
207    /// The unit every number is counted in, from the capacity entry. `None` when the
208    /// scope is unconfigured — a capacity with no unit cannot be calculated (C-004).
209    pub unit: Option<String>,
210    /// The sum of the valid, converted estimates in scope. `None` when nothing could be
211    /// counted; `Some(0)` only when the scope was counted and holds no counted work.
212    pub committed: Option<u64>,
213    /// The committed part whose issues the shared completion service calls complete
214    /// (T015). Always `<= committed`: it is the same per-issue numbers over a subset.
215    pub completed: Option<u64>,
216    /// `committed - completed`. `None` exactly when those are.
217    pub remaining: Option<u64>,
218    /// `max(committed - capacity, 0)`. `None` when either input is unknown; `Some(0)` at
219    /// or under the ceiling, because a scope exactly at its ceiling is not over it.
220    pub overflow: Option<u64>,
221    /// `committed` as a floored whole percentage of `capacity` — the one rounding policy
222    /// (C-007). `None` when no ceiling is configured or the ceiling is `0`, where the
223    /// ratio has no value; never `0` standing in for "unknown".
224    pub utilization: Option<u32>,
225    /// Issues whose estimate was counted into `committed`.
226    pub counted_issues: Option<u32>,
227    /// Issues that state an estimate which could not be counted in the unit; each has a
228    /// diagnostic naming it. They contribute nothing to the sums.
229    pub uncounted_issues: Option<u32>,
230    /// Issues that state no estimate at all. Visible, never zero, and never an error —
231    /// stating nothing is not a mistake (C-005, data-model §3).
232    pub unestimated_issues: Option<u32>,
233    /// The snapshot the numbers came from.
234    pub source: CapacitySource,
235    /// The findings, in derivation order: about the scope itself, then about its members
236    /// in issue order, then about the total derived from them (T017).
237    pub diagnostics: Vec<CapacityWarning>,
238}
239
240impl CapacityCalculation {
241    /// Calculate one scope from an already-built snapshot.
242    ///
243    /// `index` is the [`ScheduleIndex`] `snapshot` was built with (a cycle reference
244    /// resolves through it), and `config` is the project configuration that snapshot was
245    /// built under: the snapshot's conversion table includes every unit `config.capacity`
246    /// declares, and the ceiling and unit come from the entry that targets `scope`. A
247    /// reference is matched on the identity [`super::config::validate`] keys a ceiling by
248    /// — never on the text — so `@ada` for `ada` and a generated cycle's stable id for its
249    /// name are one scope.
250    ///
251    /// Nothing is mutated, and nothing outside the snapshot is read: the issues are
252    /// already indexed, and this walks the scope's membership list once, in issue order.
253    #[must_use]
254    pub fn for_scope(
255        snapshot: &IssueSnapshot,
256        index: &ScheduleIndex,
257        config: &Config,
258        scope: CapacityScope,
259    ) -> Self {
260        let mut calculation = Self {
261            scope: scope.clone(),
262            source: CapacitySource::from(snapshot),
263            ..Self::default()
264        };
265
266        // The scope reference itself. A cycle nothing resolves to selects no issues, and
267        // saying so is what keeps "no work" from being indistinguishable from "no such
268        // cycle" — data-model §2's rule that an unknown reference is a diagnostic and
269        // never a guess.
270        if scope.kind == CapacityScopeKind::Cycle
271            && matches!(
272                resolve_cycle(index, &scope.reference),
273                CycleReference::Unknown { .. }
274            )
275        {
276            calculation
277                .diagnostics
278                .push(CapacityWarning::unresolved_scope(scope.clone()));
279        }
280
281        // The membership findings that are about this scope, in the snapshot's own order.
282        for finding in snapshot.diagnostics() {
283            if finding.scope_kind == scope.kind
284                && scope_identity(config, index, scope.kind, &finding.reference)
285                    == scope_identity(config, index, scope.kind, &scope.reference)
286            {
287                calculation
288                    .diagnostics
289                    .push(CapacityWarning::from_scope_diagnostic(
290                        scope.clone(),
291                        finding,
292                    ));
293            }
294        }
295
296        // The one authority on whether a ceiling applies. An entry that is present but not
297        // in force leaves the scope unconfigured: a disabled ceiling keeps its numbers for
298        // later without changing today's calculation (config `CapacityEntry::effective`).
299        let Some(entry) = entry_for(config, index, &scope) else {
300            return calculation;
301        };
302        let Some(ceiling) = entry.capacity.and_then(|value| u64::try_from(value).ok()) else {
303            return calculation;
304        };
305        let Some(unit) = entry
306            .unit
307            .as_deref()
308            .map(str::trim)
309            .filter(|unit| !unit.is_empty())
310        else {
311            return calculation;
312        };
313
314        // A ceiling is in force, so it is reported even when nothing can be counted in its
315        // unit — the ceiling is configuration, not a derived value.
316        calculation.capacity = Some(ceiling);
317        calculation.unit = Some(unit.to_string());
318
319        let targets = snapshot.targets();
320        if !targets.iter().any(|target| target == unit) {
321            // The snapshot resolved its conversion table from the section it was built
322            // under; a section naming a unit outside it is a different configuration, and
323            // counting in a unit the table never resolved would be a guess.
324            calculation.diagnostics.push(
325                CapacityWarning::new(
326                    CapacityWarningKind::Conversion(WipDiagnosticKind::UnknownUnit),
327                    scope.clone(),
328                    format!(
329                        "the capacity unit `{unit}` is not among the units this snapshot \
330                         resolved ({}), so nothing was counted",
331                        targets.join(", ")
332                    ),
333                )
334                .with_remedy(
335                    "recalculate against the `[capacity]` section this snapshot was built \
336                     under: the unit is already declared, so this is a stale snapshot rather \
337                     than a missing unit",
338                ),
339            );
340            return calculation;
341        }
342
343        let members = snapshot.members(scope.kind, &scope.reference, index);
344        let mut committed = 0u64;
345        let mut completed = 0u64;
346        let mut counted = 0u32;
347        let mut uncounted = 0u32;
348        let mut unestimated = 0u32;
349        // The issues whose estimates make up `committed`, in membership order: the
350        // actionable half of an overflow warning (C-006).
351        let mut contributing: Vec<u32> = Vec::new();
352
353        for &number in members {
354            let Some(estimate) = snapshot.estimate_in(number, unit) else {
355                // A member is always in the snapshot, and `unit` is in the table above, so
356                // this arm is unreachable for a caller that passes the section the
357                // snapshot was built under. Counting it as uncounted keeps the partition
358                // total rather than dropping an issue silently.
359                uncounted += 1;
360                continue;
361            };
362            match estimate.converted_value {
363                Some(value) => {
364                    committed += u64::from(value);
365                    counted += 1;
366                    contributing.push(number);
367                    if snapshot.is_completed(number) {
368                        completed += u64::from(value);
369                    }
370                }
371                None if estimate.is_unestimated() => unestimated += 1,
372                None => {
373                    uncounted += 1;
374                    for finding in estimate.diagnostics() {
375                        calculation
376                            .diagnostics
377                            .push(CapacityWarning::from_estimate_diagnostic(
378                                scope.clone(),
379                                number,
380                                unit,
381                                finding,
382                            ));
383                    }
384                }
385            }
386        }
387
388        calculation.committed = Some(committed);
389        calculation.completed = Some(completed);
390        calculation.remaining = Some(committed - completed);
391        calculation.overflow = Some(committed.saturating_sub(ceiling));
392        calculation.utilization = utilization_percent(committed, ceiling);
393        calculation.counted_issues = Some(counted);
394        calculation.uncounted_issues = Some(uncounted);
395        calculation.unestimated_issues = Some(unestimated);
396
397        if committed > ceiling {
398            calculation.diagnostics.push(CapacityWarning::overflow(
399                scope.clone(),
400                committed,
401                ceiling,
402                unit,
403                contributing,
404            ));
405        }
406
407        calculation
408    }
409
410    /// Whether this calculation had a ceiling it could count against (T016).
411    ///
412    /// The explicit no-capacity state: a scope with no entry, or with an entry that is not
413    /// in force, is [`CapacityState::Unconfigured`] rather than a scope with zero capacity.
414    #[must_use]
415    pub fn state(&self) -> CapacityState {
416        match (self.capacity, self.committed) {
417            (None, _) => CapacityState::Unconfigured,
418            (Some(_), Some(_)) => CapacityState::Configured,
419            (Some(_), None) => CapacityState::Uncountable,
420        }
421    }
422
423    /// Whether the scope is over its ceiling.
424    ///
425    /// The one predicate a surface asks instead of comparing `overflow` itself, so "over
426    /// capacity" means one thing everywhere.
427    #[must_use]
428    pub fn is_over_capacity(&self) -> bool {
429        self.overflow.is_some_and(|overflow| overflow > 0)
430    }
431}
432
433/// `committed` as a floored whole percentage of `capacity` — the one rounding policy.
434///
435/// Floored, not rounded to nearest: a ceiling report that rounds up would claim a scope is
436/// at 100% while it is at 99.5% — the one reading that matters at a capacity boundary.
437/// The epics sibling follows the same policy with exact `i128` arithmetic over its
438/// `i64` weights, so very large weights cannot pin either surface below 100%.
439/// `None` when the denominator is zero, where the ratio has no value; `u32::MAX`
440/// in the impossible extreme.
441///
442/// The test table in `tests/capacity_calculate.rs` pins *this* module's policy — the
443/// rounding this package owns — rather than the sibling's implementation.
444fn utilization_percent(committed: u64, capacity: u64) -> Option<u32> {
445    (capacity > 0).then(|| {
446        let percent = u128::from(committed) * 100 / u128::from(capacity);
447        u32::try_from(percent).unwrap_or(u32::MAX)
448    })
449}
450
451/// The capacity entry that is in force for `scope`, if any.
452///
453/// Matched on the resolved identity rather than on the text, so an entry naming a
454/// generated cycle's stable id and a caller asking about its name are one scope; the
455/// section's declaration order breaks a tie, which a validated section cannot have (two
456/// entries for one scope are refused by [`super::config::validate`]).
457fn entry_for<'a>(
458    config: &'a Config,
459    index: &ScheduleIndex,
460    scope: &CapacityScope,
461) -> Option<&'a CapacityEntry> {
462    config
463        .capacity
464        .iter()
465        .map(|(_, entry)| entry)
466        .find(|entry| {
467            entry.effective()
468                && entry.scope_kind() == Some(scope.kind)
469                && entry.scope_id().is_some_and(|reference| {
470                    same_scope(config, index, scope.kind, reference, &scope.reference)
471                })
472        })
473}
474
475/// Whether two references name one scope, through the same folding
476/// [`super::config::validate`] applies.
477fn same_scope(
478    config: &Config,
479    index: &ScheduleIndex,
480    kind: CapacityScopeKind,
481    left: &str,
482    right: &str,
483) -> bool {
484    scope_identity(config, index, kind, left) == scope_identity(config, index, kind, right)
485}
486
487/// The identity a scope reference resolves to, in the spelling
488/// [`super::config::validate`] keys a ceiling by.
489///
490/// A cycle's stable id or resolved name, a canonical assignee handle, a team label — the
491/// same canonicalisation that makes `@ada` and `ada` one scope. The cycle arm mirrors
492/// validate's `scope_identity` (`src/capacity/config.rs:899-920`) **including its second
493/// half**: a reference the identity index answers nothing for but the configuration does
494/// name — a legacy `[cycles.<name>]` table, or a stored record whose generated identity is
495/// partial, which the loader keeps in `config.cycles` while `ScheduleIndex::add_record`
496/// drops the record — is `cycle-name:<name>` there, and the mission's one-ceiling rule is
497/// stated in that identity. A name neither the records nor the configuration knows keeps
498/// its own text, so two unresolvable names are not mistaken for one scope.
499fn scope_identity(
500    config: &Config,
501    index: &ScheduleIndex,
502    kind: CapacityScopeKind,
503    reference: &str,
504) -> String {
505    match kind {
506        CapacityScopeKind::Cycle => match resolve_cycle(index, reference) {
507            CycleReference::Generated { id, .. } => format!("cycle-id:{id}"),
508            CycleReference::Legacy { name } => format!("cycle-name:{name}"),
509            CycleReference::Unknown { .. } if config.cycles.contains_key(reference) => {
510                format!("cycle-name:{reference}")
511            }
512            CycleReference::Unknown { name } => format!("cycle-ref:{name}"),
513        },
514        CapacityScopeKind::Team => format!("team:{reference}"),
515        CapacityScopeKind::Assignee => match parse_assignee(reference) {
516            Some(handle) => format!("assignee:{handle}"),
517            None => format!("assignee-ref:{reference}"),
518        },
519    }
520}
521
522#[cfg(test)]
523mod tests {
524    #[allow(clippy::wildcard_imports)]
525    use super::*;
526    use crate::capacity::config::{CapacityConfig, validate};
527    use crate::config::{Config, PlanningCycle};
528
529    /// The identity this module compares on has to be the one
530    /// [`super::config::validate`] keys a ceiling by. A reference the identity index
531    /// answers nothing for but the configuration *does* name — a `[cycles.<name>]` table,
532    /// or a stored record whose generated identity is partial, which the loader keeps in
533    /// `config.cycles` while `ScheduleIndex::add_record` drops the record — is
534    /// `cycle-name:<name>` there (`src/capacity/config.rs:899-905`), not a spelling of
535    /// this module's own: the mission's one-ceiling rule is stated in that identity, so a
536    /// surface that keys a ceiling or a calculation by validate's canonical has to find
537    /// this scope.
538    ///
539    /// This test fails if the identity falls back to `cycle-ref:` for such a reference.
540    #[test]
541    fn the_scope_identity_is_the_one_validate_keys_a_ceiling_by() {
542        let mut config = Config::default();
543        config
544            .cycles
545            .insert("Partial 1".to_string(), PlanningCycle::default());
546        let index = ScheduleIndex::default();
547
548        // Validate accepts a ceiling naming that reference — which it can only do by
549        // resolving it through the configuration, because the index answers nothing for
550        // it. The two callers therefore have to agree on one spelling.
551        let mut section = CapacityConfig::new();
552        section.insert(
553            "partial",
554            CapacityEntry {
555                cycle: Some("Partial 1".to_string()),
556                enabled: false,
557                ..CapacityEntry::default()
558            },
559        );
560        validate(&section, &config).expect("a reference the configuration names is a valid scope");
561
562        assert_eq!(
563            scope_identity(&config, &index, CapacityScopeKind::Cycle, "Partial 1"),
564            "cycle-name:Partial 1"
565        );
566        // A name neither the records nor the configuration knows keeps its own text, so
567        // two unresolvable names are not mistaken for one scope.
568        assert_eq!(
569            scope_identity(&config, &index, CapacityScopeKind::Cycle, "Ghost"),
570            "cycle-ref:Ghost"
571        );
572    }
573}