tix/capacity/scope.rs
1//! The cycle/team/assignee scope indexes and the one issue snapshot every capacity
2//! calculation reads (mission `cycle-capacity-planning-01M3C85C`, WP02).
3//!
4//! # One pass, one snapshot, one reading of every estimate
5//!
6//! [`IssueSnapshot::build`] walks the issues **once**. In that single walk it indexes
7//! cycle membership, team membership, assignee membership, the shared #183 completion
8//! answer, and each issue's estimate — parsed once and converted once per unit the
9//! conversion table is resolved for. Every later question (`committed`, `completed`,
10//! `remaining`, utilization, a per-scope detail view) is then a lookup, not a scan:
11//! re-reading the store or re-parsing an estimate per scope is what NFR-001 exists to
12//! forbid, and it is also how two surfaces start disagreeing about one repository.
13//!
14//! # Cycle membership resolves through the crate's one adapter
15//!
16//! An issue's `cycles` value is a **name**, and a name may be a hand-written
17//! `[cycles.<name>]` cycle or one an issue-181 schedule generated. Both, and a
18//! generated cycle's stable `<schedule-id>:<sequence>` id, resolve through
19//! [`super::config::resolve_cycle`] — the crate's single cycle-reference resolver — so
20//! a capacity scope naming any of the three selects exactly the same issues. The
21//! snapshot keys membership on the **identity** that resolver answers with (a generated
22//! cycle's id, a legacy cycle's name) and never on the spelling, so two spellings of
23//! one cycle are one scope (data-model §2).
24//!
25//! # Team membership
26//!
27//! The only link the model offers between an issue and a team is the assignee: a team
28//! is `[teams.<label>]` and its members are assignee identities. An issue therefore
29//! belongs to a team when its `assignee`, canonicalised through
30//! [`crate::assignee::parse_assignee`] (so `ada` and `@ada` are one identity, exactly
31//! as [`super::config::validate`] folds them), is one of that team's canonical
32//! members. An issue may belong to several teams; it contributes to each once.
33//!
34//! # References that resolve to nothing are diagnostics, not guesses
35//!
36//! An issue naming a cycle nothing resolves to is reported
37//! ([`ScopeDiagnosticKind::UnknownScope`]) and contributes to **no** cycle scope: a
38//! capacity entry can only be validated against a cycle that exists, so an unresolved
39//! name could never be selected, and inventing a membership for it would be the silent
40//! guess C-003 refuses. Two spellings of one identity inside one issue are reported too
41//! ([`ScopeDiagnosticKind::AmbiguousScope`]) and deduplicated — the cycle counts once,
42//! never twice.
43
44use std::collections::btree_map::Entry;
45use std::collections::{BTreeMap, BTreeSet};
46
47use crate::assignee::parse_assignee;
48use crate::config::Config;
49use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
50use crate::error::render_diagnostics;
51use crate::model::Issue;
52use crate::wip::WipConfig;
53use crate::workflow;
54
55use super::config::{CapacityScopeKind, resolve_cycle};
56use super::estimate::EstimateValue;
57
58/// What a scope-index finding is about (data-model §5's vocabulary).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum ScopeDiagnosticKind {
61 /// An issue names a cycle that does not resolve to a record.
62 UnknownScope,
63 /// Two spellings in one issue resolve to one scope.
64 AmbiguousScope,
65}
66
67impl ScopeDiagnosticKind {
68 /// The stable spelling every surface prints.
69 #[must_use]
70 pub const fn as_str(self) -> &'static str {
71 match self {
72 Self::UnknownScope => "unknown_scope",
73 Self::AmbiguousScope => "ambiguous_scope",
74 }
75 }
76}
77
78/// One scope-index finding: what it is, which scope, and which issue raised it.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ScopeDiagnostic {
81 /// What is wrong.
82 pub kind: ScopeDiagnosticKind,
83 /// Which dimension the reference belongs to.
84 pub scope_kind: CapacityScopeKind,
85 /// The reference the issue wrote.
86 pub reference: String,
87 /// The issue that wrote it, when the finding came from an issue.
88 pub issue: Option<u32>,
89 /// One sentence saying what happened.
90 pub message: String,
91 /// A second sentence, when the first is not enough.
92 pub because: Option<String>,
93}
94
95impl ScopeDiagnostic {
96 /// A finding with its message.
97 #[must_use]
98 pub fn new(
99 kind: ScopeDiagnosticKind,
100 scope_kind: CapacityScopeKind,
101 reference: impl Into<String>,
102 message: impl Into<String>,
103 ) -> Self {
104 Self {
105 kind,
106 scope_kind,
107 reference: reference.into(),
108 issue: None,
109 message: message.into(),
110 because: None,
111 }
112 }
113
114 /// Attach the issue the finding came from.
115 #[must_use]
116 pub fn for_issue(mut self, number: u32) -> Self {
117 self.issue = Some(number);
118 self
119 }
120
121 /// Add the sentence that says what to do about it.
122 #[must_use]
123 pub fn because(mut self, because: impl Into<String>) -> Self {
124 self.because = Some(because.into());
125 self
126 }
127
128 /// Render through the crate's one diagnostic renderer.
129 #[must_use]
130 pub fn render(&self) -> String {
131 let mut pairs = vec![(self.kind.as_str(), self.message.as_str())];
132 if let Some(because) = self.because.as_deref() {
133 pairs.push((self.kind.as_str(), because));
134 }
135 render_diagnostics("E-tix-capacity", pairs)
136 }
137}
138
139/// The one indexed issue snapshot capacity calculations read (T011).
140///
141/// Built by [`IssueSnapshot::build`] and immutable afterwards: every lookup is a map
142/// read, so a calculation never touches a file, re-parses an estimate, or walks the
143/// issue list again — there is no seam a lookup could parse through, and no interior
144/// mutability to cache one in. The tally it exposes
145/// ([`issue_visits`](Self::issue_visits),
146/// [`estimates_parsed`](Self::estimates_parsed),
147/// [`conversions_resolved`](Self::conversions_resolved)) is the pass's own record of the
148/// parse and conversion work it performed, so a test can assert how much work the build
149/// did instead of only that it was fast. What those counters do **not** observe is stated
150/// on each of them.
151#[derive(Debug, Clone, Default, PartialEq, Eq)]
152pub struct IssueSnapshot {
153 /// Every issue number, in the order the pass saw them.
154 numbers: Vec<u32>,
155 /// A cycle identity -> its member issues, in issue order.
156 by_cycle: BTreeMap<String, Vec<u32>>,
157 /// A cycle identity -> the name it answers to, for a surface that lists scopes.
158 cycle_names: BTreeMap<String, String>,
159 /// A team label -> its member issues, in issue order.
160 by_team: BTreeMap<String, Vec<u32>>,
161 /// A canonical assignee identity -> its member issues, in issue order.
162 by_assignee: BTreeMap<String, Vec<u32>>,
163 /// An issue -> its estimate as stated, with the parse's diagnostics only.
164 stated: BTreeMap<u32, EstimateValue>,
165 /// An issue -> its estimate per target unit, conversion already applied.
166 converted: BTreeMap<u32, BTreeMap<String, EstimateValue>>,
167 /// The units every estimate was converted into, in a stable order.
168 targets: Vec<String>,
169 /// The issues #183's category service calls completed.
170 completed: BTreeSet<u32>,
171 /// The issues that belong to no cycle, team, or assignee scope.
172 unscoped: Vec<u32>,
173 /// The membership findings the pass produced, in the order it found them.
174 diagnostics: Vec<ScopeDiagnostic>,
175 /// How many issue records the pass's estimate seam walked.
176 visits: usize,
177 /// How many estimates that seam parsed.
178 parses: usize,
179 /// How many conversions that seam attempted: a readable estimate, per target unit.
180 conversions: usize,
181}
182
183impl IssueSnapshot {
184 /// Index `issues` in **one pass** against the repository's scopes and units.
185 ///
186 /// `index` is the caller's [`ScheduleIndex`], built from the cycle records
187 /// (`crate::capacity::schedule_index`, or [`crate::cycle_schedule::registry::load_index`]
188 /// — the same index either way). The units an estimate is converted into are the
189 /// board's declared units plus every unit a configured capacity entry names, read
190 /// from the config here: the conversion table is resolved **once**, at build time,
191 /// so no calculation repeats it (R10).
192 ///
193 /// Conversions use the **default board**'s registry (`config.default_config()`,
194 /// the board the CLI and other single-config surfaces run under, and the same board
195 /// [`crate::workflow::resolution`] is built for here). A capacity scope names a
196 /// cycle, a team, or an assignee and never a board, so there is no other board the
197 /// arithmetic could run under; a unit *that* board does not declare is a
198 /// `missing_conversion`/`unknown_unit` diagnostic per issue, never a guess.
199 ///
200 /// Completion is [`crate::workflow::is_completed`]'s answer (C-002): the snapshot
201 /// records what the one status-category service decides and never re-derives it.
202 #[must_use]
203 #[allow(clippy::too_many_lines)]
204 pub fn build(config: &Config, index: &ScheduleIndex, issues: &[Issue]) -> Self {
205 let board = config.default_config();
206 let resolution = workflow::resolution(config, board, "default");
207
208 let mut targets: BTreeSet<String> = board.wip.units.keys().cloned().collect();
209 for (_, entry) in config.capacity.iter() {
210 if let Some(unit) = entry
211 .unit
212 .as_deref()
213 .map(str::trim)
214 .filter(|unit| !unit.is_empty())
215 {
216 targets.insert(unit.to_string());
217 }
218 }
219 let targets: Vec<String> = targets.into_iter().collect();
220
221 // Which teams a canonical assignee identity belongs to. Read from the config
222 // once, so the issue pass stays a single pass.
223 let mut teams_of: BTreeMap<String, Vec<String>> = BTreeMap::new();
224 for (label, team) in &config.teams {
225 for member in &team.members {
226 let Some(handle) = parse_assignee(member) else {
227 continue;
228 };
229 let teams = teams_of.entry(handle).or_default();
230 if !teams.contains(label) {
231 teams.push(label.clone());
232 }
233 }
234 }
235
236 let mut snapshot = Self {
237 targets: targets.clone(),
238 ..Self::default()
239 };
240 snapshot.numbers.reserve(issues.len());
241 for issue in issues {
242 snapshot.numbers.push(issue.number);
243
244 let (stated, per_unit) = snapshot.read_issue(issue, &board.wip, &targets);
245 snapshot.converted.insert(issue.number, per_unit);
246 snapshot.stated.insert(issue.number, stated);
247
248 if workflow::is_completed(issue, &resolution) {
249 snapshot.completed.insert(issue.number);
250 }
251
252 // The cycle memberships, keyed on the identity `resolve_cycle` answers
253 // with rather than on the spelling the issue used.
254 let mut spellings: BTreeMap<String, String> = BTreeMap::new();
255 let mut scoped = false;
256 for reference in issue.cycles.iter().flatten() {
257 let cycle = resolve_cycle(index, reference);
258 let member = match &cycle {
259 CycleReference::Generated { name, id, .. } => {
260 Some((format!("cycle-id:{id}"), name.clone()))
261 }
262 CycleReference::Legacy { name } => {
263 Some((format!("cycle-name:{name}"), name.clone()))
264 }
265 CycleReference::Unknown { name } => {
266 snapshot.diagnostics.push(
267 ScopeDiagnostic::new(
268 ScopeDiagnosticKind::UnknownScope,
269 CapacityScopeKind::Cycle,
270 name.clone(),
271 format!(
272 "issue #{} `cycles` names `{name}`, which does not resolve \
273 to a cycle record",
274 issue.number
275 ),
276 )
277 .for_issue(issue.number)
278 .because(
279 "the issue counts in no cycle until that cycle exists; an \
280 unresolvable name is reported, never guessed (C-003)",
281 ),
282 );
283 None
284 }
285 };
286 let Some((key, name)) = member else {
287 continue;
288 };
289 snapshot.cycle_names.entry(key.clone()).or_insert(name);
290 scoped = true;
291 match spellings.entry(key.clone()) {
292 Entry::Vacant(slot) => {
293 slot.insert(reference.clone());
294 snapshot.by_cycle.entry(key).or_default().push(issue.number);
295 }
296 // A second spelling of one identity is one contribution, and the
297 // ambiguity is worth saying out loud.
298 Entry::Occupied(slot) => {
299 if slot.get() != reference {
300 snapshot.diagnostics.push(
301 ScopeDiagnostic::new(
302 ScopeDiagnosticKind::AmbiguousScope,
303 CapacityScopeKind::Cycle,
304 reference.clone(),
305 format!(
306 "issue #{} `cycles` names `{reference}` and `{}`, \
307 which resolve to one cycle",
308 issue.number,
309 slot.get()
310 ),
311 )
312 .for_issue(issue.number)
313 .because("the cycle counts once, not twice"),
314 );
315 }
316 }
317 }
318 }
319
320 if let Some(handle) = issue.assignee.as_deref().and_then(parse_assignee) {
321 scoped = true;
322 snapshot
323 .by_assignee
324 .entry(handle.clone())
325 .or_default()
326 .push(issue.number);
327 for label in teams_of.get(&handle).into_iter().flatten() {
328 snapshot
329 .by_team
330 .entry(label.clone())
331 .or_default()
332 .push(issue.number);
333 }
334 }
335
336 if !scoped {
337 snapshot.unscoped.push(issue.number);
338 }
339 }
340
341 for members in snapshot
342 .by_cycle
343 .values_mut()
344 .chain(snapshot.by_team.values_mut())
345 .chain(snapshot.by_assignee.values_mut())
346 {
347 members.sort_unstable();
348 members.dedup();
349 }
350 snapshot.unscoped.sort_unstable();
351 snapshot.unscoped.dedup();
352 snapshot
353 }
354
355 /// One issue record's worth of the pass: the estimate as the issue stated it, and
356 /// its reading in every target unit.
357 ///
358 /// This is the **one** place the snapshot parses an estimate or asks the registry
359 /// for a conversion, and the one place the pass's tally
360 /// ([`issue_visits`](Self::issue_visits),
361 /// [`estimates_parsed`](Self::estimates_parsed),
362 /// [`conversions_resolved`](Self::conversions_resolved)) grows. Keeping the work and
363 /// its record in the same method is what lets those counters say something: work
364 /// duplicated through this seam is counted twice and breaks the tests that anchor the
365 /// tally to the slice. Work that bypasses the seam is not counted — see the
366 /// accessors for exactly what the tally does and does not prove.
367 fn read_issue(
368 &mut self,
369 issue: &Issue,
370 registry: &WipConfig,
371 targets: &[String],
372 ) -> (EstimateValue, BTreeMap<String, EstimateValue>) {
373 self.visits += 1;
374 let stated = match issue.estimate.as_deref() {
375 // A blank estimate states nothing, so it is unestimated rather than
376 // invalid — the same rule the WIP aggregate follows.
377 Some(raw) if !raw.trim().is_empty() => {
378 self.parses += 1;
379 EstimateValue::parse(issue.number, raw)
380 }
381 _ => EstimateValue::unestimated(),
382 };
383 let mut per_unit: BTreeMap<String, EstimateValue> = BTreeMap::new();
384 for unit in targets {
385 per_unit.insert(
386 unit.clone(),
387 stated.converted_to(issue.number, registry, unit),
388 );
389 }
390 if stated.is_valid() {
391 self.conversions += targets.len();
392 }
393 (stated, per_unit)
394 }
395
396 /// Every issue number, in the order the pass saw them.
397 #[must_use]
398 pub fn issues(&self) -> &[u32] {
399 &self.numbers
400 }
401
402 /// How many issues the snapshot indexed.
403 #[must_use]
404 pub fn len(&self) -> usize {
405 self.numbers.len()
406 }
407
408 /// Whether the snapshot indexed no issues.
409 #[must_use]
410 pub fn is_empty(&self) -> bool {
411 self.numbers.is_empty()
412 }
413
414 /// The units every estimate was converted into, in a stable order (T007).
415 #[must_use]
416 pub fn targets(&self) -> &[String] {
417 &self.targets
418 }
419
420 /// An issue's estimate as stated: the raw text, the parsed unit and value, and the
421 /// parse diagnostics. No conversion has been applied.
422 #[must_use]
423 pub fn estimate(&self, number: u32) -> Option<&EstimateValue> {
424 self.stated.get(&number)
425 }
426
427 /// An issue's estimate converted into `unit`, conversion included.
428 ///
429 /// `None` only when the issue is not in the snapshot or `unit` was not in the
430 /// conversion table — the units [`targets`](Self::targets) lists. Every issue in the
431 /// snapshot otherwise answers, including an unestimated one (which stays
432 /// unestimated) and an invalid one (whose diagnostics say why).
433 #[must_use]
434 pub fn estimate_in(&self, number: u32, unit: &str) -> Option<&EstimateValue> {
435 self.converted.get(&number)?.get(unit)
436 }
437
438 /// Whether #183's category service calls this issue completed.
439 #[must_use]
440 pub fn is_completed(&self, number: u32) -> bool {
441 self.completed.contains(&number)
442 }
443
444 /// The issues #183's category service calls completed, in issue order.
445 #[must_use]
446 pub fn completed(&self) -> &BTreeSet<u32> {
447 &self.completed
448 }
449
450 /// The issues whose `cycles` resolve to `reference`'s identity.
451 ///
452 /// `reference` may be a hand-written cycle's name, a generated cycle's name, or a
453 /// generated cycle's stable id: all three go through [`resolve_cycle`], so all
454 /// three select the same set (T010). An unknown reference selects nobody.
455 #[must_use]
456 pub fn cycle_members(&self, index: &ScheduleIndex, reference: &str) -> &[u32] {
457 let key = match resolve_cycle(index, reference) {
458 CycleReference::Generated { id, .. } => format!("cycle-id:{id}"),
459 CycleReference::Legacy { name } => format!("cycle-name:{name}"),
460 CycleReference::Unknown { .. } => return &[],
461 };
462 self.by_cycle.get(&key).map_or(&[], Vec::as_slice)
463 }
464
465 /// The issues whose assignee is a member of team `label`.
466 #[must_use]
467 pub fn team_members(&self, label: &str) -> &[u32] {
468 self.by_team.get(label).map_or(&[], Vec::as_slice)
469 }
470
471 /// The issues assigned to `reference`, by the model's assignee identity.
472 ///
473 /// The reference folds the way [`crate::assignee::parse_assignee`] folds it, so
474 /// `ada` and `@ada` select the same issues and `Ada Lovelace <ada@example.com>` is
475 /// its own identity — the same folding [`super::config::validate`] applies.
476 #[must_use]
477 pub fn assignee_members(&self, reference: &str) -> &[u32] {
478 match parse_assignee(reference) {
479 Some(handle) => self.by_assignee.get(&handle).map_or(&[], Vec::as_slice),
480 None => &[],
481 }
482 }
483
484 /// The issues in one scope, whichever dimension it is.
485 ///
486 /// The one call a calculation needs: it dispatches on the entry's scope kind, so
487 /// no surface re-implements the per-kind lookup.
488 #[must_use]
489 pub fn members(
490 &self,
491 kind: CapacityScopeKind,
492 reference: &str,
493 index: &ScheduleIndex,
494 ) -> &[u32] {
495 match kind {
496 CapacityScopeKind::Cycle => self.cycle_members(index, reference),
497 CapacityScopeKind::Team => self.team_members(reference),
498 CapacityScopeKind::Assignee => self.assignee_members(reference),
499 }
500 }
501
502 /// The cycles with at least one member, by the name they answer to.
503 #[must_use]
504 pub fn cycle_scopes(&self) -> Vec<&str> {
505 self.by_cycle
506 .keys()
507 .map(|key| {
508 self.cycle_names
509 .get(key)
510 .map_or(key.as_str(), String::as_str)
511 })
512 .collect()
513 }
514
515 /// The teams with at least one member.
516 #[must_use]
517 pub fn team_scopes(&self) -> Vec<&str> {
518 self.by_team.keys().map(String::as_str).collect()
519 }
520
521 /// The assignee identities with at least one member.
522 #[must_use]
523 pub fn assignee_scopes(&self) -> Vec<&str> {
524 self.by_assignee.keys().map(String::as_str).collect()
525 }
526
527 /// The issues that belong to no cycle, team, or assignee scope, in issue order.
528 ///
529 /// An issue naming only a cycle that does not resolve is here too: it is in no
530 /// scope a capacity entry could select.
531 #[must_use]
532 pub fn unscoped(&self) -> &[u32] {
533 &self.unscoped
534 }
535
536 /// The membership findings the pass produced, in the order it found them.
537 #[must_use]
538 pub fn diagnostics(&self) -> &[ScopeDiagnostic] {
539 &self.diagnostics
540 }
541
542 /// How many issue records the pass's estimate seam walked — the cursor's record.
543 ///
544 /// One per issue by construction: the pass walks the slice once and this counts the
545 /// walk. It is a **discipline about this pass**, not evidence about code outside it —
546 /// a second scan elsewhere in the crate would not appear here, and neither would a
547 /// re-parse that bypassed the seam. The counters that can catch duplicated work are
548 /// [`estimates_parsed`](Self::estimates_parsed) and
549 /// [`conversions_resolved`](Self::conversions_resolved), which are incremented by the
550 /// work itself.
551 #[must_use]
552 pub fn issue_visits(&self) -> usize {
553 self.visits
554 }
555
556 /// How many estimates the pass parsed: one per issue that stated one.
557 ///
558 /// Counted where the parse happens, so a second read of the estimates through the
559 /// same seam counts twice and is visible; the counters measure the work performed,
560 /// not the absence of other code. A lookup adds nothing, because a lookup does not
561 /// parse (NFR-001).
562 #[must_use]
563 pub fn estimates_parsed(&self) -> usize {
564 self.parses
565 }
566
567 /// How many explicit conversions the pass attempted: one per issue with a readable
568 /// estimate, per unit in [`targets`](Self::targets).
569 ///
570 /// This counts the **attempts** — the conversion seam was consulted exactly that
571 /// many times, in this pass and nowhere else. Whether a particular attempt produced
572 /// a number is read from the value itself
573 /// ([`is_converted`](EstimateValue::is_converted)), because a refusal is a
574 /// diagnostic on the value, not the loss of one.
575 #[must_use]
576 pub fn conversions_resolved(&self) -> usize {
577 self.conversions
578 }
579}