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(§ion, &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}