Skip to main content

rch_common/
admission_rejection.rs

1//! Admission rejection aggregation with a stable reason vocabulary
2//! (bd-session-history-remediation-ocv9i.6.2).
3//!
4//! When a command finds no admissible worker, an agent needs to know *why* in
5//! aggregate — not a single opaque "no admissible workers", but a breakdown that
6//! separates **global worker health** (the worker is unusable for anything right
7//! now) from **command-specific admissibility** (this worker can't run *this*
8//! command) from **project policy** (the worker was excluded by configuration).
9//!
10//! This module is that aggregation contract. [`AdmissionRejectionCategory`] is
11//! the stable, exhaustive vocabulary the session-history report enumerates;
12//! each maps to a [`RejectionClass`] and (where the registry has a 1:1 code) an
13//! [`IncidentReasonCode`], so the aggregation reuses the existing incident
14//! registry and selection/eligibility diagnostics rather than inventing a
15//! parallel path. [`aggregate_rejections`] folds per-candidate categories into
16//! an [`AdmissionRejectionSummary`] with per-category counts and per-class
17//! subtotals.
18
19use std::collections::BTreeMap;
20
21use serde::{Deserialize, Serialize};
22
23use crate::incident::IncidentReasonCode;
24
25/// Which broad concern a rejection belongs to. Separating these is the bead's
26/// core requirement: an agent must tell "the fleet is unhealthy" apart from
27/// "this command needs something the fleet lacks" apart from "policy excluded
28/// the worker".
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum RejectionClass {
32    /// The worker is unusable right now regardless of the command.
33    WorkerHealth,
34    /// The worker can't run *this* command (missing capability / classifier).
35    CommandAdmissibility,
36    /// Configuration/policy excluded the worker.
37    ProjectPolicy,
38}
39
40impl RejectionClass {
41    /// Stable lowercase token.
42    #[must_use]
43    pub fn as_str(self) -> &'static str {
44        match self {
45            RejectionClass::WorkerHealth => "worker_health",
46            RejectionClass::CommandAdmissibility => "command_admissibility",
47            RejectionClass::ProjectPolicy => "project_policy",
48        }
49    }
50}
51
52/// The stable, exhaustive vocabulary of admission-rejection reasons. Ordered for
53/// deterministic aggregation output. Distinguishes missing runtime / toolchain /
54/// Rust target (which the coarse [`IncidentReasonCode::MissingRuntimeToolchainTarget`]
55/// collapses) because operators count them separately.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58pub enum AdmissionRejectionCategory {
59    CriticalPressure,
60    InsufficientSlots,
61    CircuitOpen,
62    TelemetryStale,
63    HardPreflight,
64    MissingRuntime,
65    MissingToolchain,
66    MissingRustTarget,
67    OsArchMismatch,
68    ClassifierLocalDrift,
69    ActiveProjectExclusion,
70    ProjectExcluded,
71}
72
73impl AdmissionRejectionCategory {
74    /// Every category in stable declaration order (for coverage + iteration).
75    pub const ALL: &'static [AdmissionRejectionCategory] = &[
76        Self::CriticalPressure,
77        Self::InsufficientSlots,
78        Self::CircuitOpen,
79        Self::TelemetryStale,
80        Self::HardPreflight,
81        Self::MissingRuntime,
82        Self::MissingToolchain,
83        Self::MissingRustTarget,
84        Self::OsArchMismatch,
85        Self::ClassifierLocalDrift,
86        Self::ActiveProjectExclusion,
87        Self::ProjectExcluded,
88    ];
89
90    /// Stable lowercase token (matches the serde representation).
91    #[must_use]
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Self::CriticalPressure => "critical_pressure",
95            Self::InsufficientSlots => "insufficient_slots",
96            Self::CircuitOpen => "circuit_open",
97            Self::TelemetryStale => "telemetry_stale",
98            Self::HardPreflight => "hard_preflight",
99            Self::MissingRuntime => "missing_runtime",
100            Self::MissingToolchain => "missing_toolchain",
101            Self::MissingRustTarget => "missing_rust_target",
102            Self::OsArchMismatch => "os_arch_mismatch",
103            Self::ClassifierLocalDrift => "classifier_local_drift",
104            Self::ActiveProjectExclusion => "active_project_exclusion",
105            Self::ProjectExcluded => "project_excluded",
106        }
107    }
108
109    /// The broad class this category belongs to.
110    #[must_use]
111    pub fn class(self) -> RejectionClass {
112        match self {
113            Self::CriticalPressure
114            | Self::InsufficientSlots
115            | Self::CircuitOpen
116            | Self::TelemetryStale => RejectionClass::WorkerHealth,
117            Self::HardPreflight
118            | Self::MissingRuntime
119            | Self::MissingToolchain
120            | Self::MissingRustTarget
121            | Self::OsArchMismatch
122            | Self::ClassifierLocalDrift => RejectionClass::CommandAdmissibility,
123            Self::ActiveProjectExclusion | Self::ProjectExcluded => RejectionClass::ProjectPolicy,
124        }
125    }
126
127    /// The closest stable incident reason code, reusing the existing registry.
128    /// The three missing-* runtime categories all map to the coarse
129    /// [`IncidentReasonCode::MissingRuntimeToolchainTarget`]; `ProjectExcluded`
130    /// reuses the active-project-exclusion code (the registry has one exclusion
131    /// reason).
132    #[must_use]
133    pub fn incident_reason(self) -> IncidentReasonCode {
134        match self {
135            Self::CriticalPressure => IncidentReasonCode::CriticalPressure,
136            Self::InsufficientSlots => IncidentReasonCode::InsufficientSlots,
137            Self::CircuitOpen => IncidentReasonCode::CircuitOpen,
138            Self::TelemetryStale => IncidentReasonCode::TelemetryStale,
139            Self::HardPreflight => IncidentReasonCode::HardPreflight,
140            Self::MissingRuntime | Self::MissingToolchain | Self::MissingRustTarget => {
141                IncidentReasonCode::MissingRuntimeToolchainTarget
142            }
143            Self::OsArchMismatch => IncidentReasonCode::OsArchMismatch,
144            Self::ClassifierLocalDrift => IncidentReasonCode::QueueAmbiguity,
145            Self::ActiveProjectExclusion | Self::ProjectExcluded => {
146                IncidentReasonCode::ActiveProjectExclusion
147            }
148        }
149    }
150
151    /// Map an existing incident reason code to a category where it is
152    /// unambiguous. Returns `None` for
153    /// [`IncidentReasonCode::MissingRuntimeToolchainTarget`] (the caller must
154    /// pick `MissingRuntime`/`MissingToolchain`/`MissingRustTarget` from the
155    /// specific failure) and for codes with no admission-rejection meaning.
156    #[must_use]
157    pub fn from_incident_reason(code: IncidentReasonCode) -> Option<Self> {
158        Some(match code {
159            IncidentReasonCode::CriticalPressure => Self::CriticalPressure,
160            IncidentReasonCode::InsufficientSlots => Self::InsufficientSlots,
161            IncidentReasonCode::CircuitOpen => Self::CircuitOpen,
162            IncidentReasonCode::TelemetryStale => Self::TelemetryStale,
163            IncidentReasonCode::HardPreflight => Self::HardPreflight,
164            IncidentReasonCode::OsArchMismatch => Self::OsArchMismatch,
165            IncidentReasonCode::ActiveProjectExclusion => Self::ActiveProjectExclusion,
166            // Ambiguous (3-way split) or not an admission rejection.
167            _ => return None,
168        })
169    }
170}
171
172/// One candidate worker's rejection.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct CandidateRejection {
175    pub worker_id: String,
176    pub category: AdmissionRejectionCategory,
177}
178
179/// Aggregated admission rejection diagnostics for a command.
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct AdmissionRejectionSummary {
182    /// Total candidate workers considered.
183    pub total_candidates: usize,
184    /// How many were rejected.
185    pub rejected: usize,
186    /// Count of rejected workers per category (only non-zero entries).
187    pub by_category: BTreeMap<String, usize>,
188    /// Count of rejected workers per broad class (only non-zero entries).
189    pub by_class: BTreeMap<String, usize>,
190}
191
192impl AdmissionRejectionSummary {
193    /// Whether every candidate was rejected (no worker could run the command).
194    #[must_use]
195    pub fn all_rejected(&self) -> bool {
196        self.total_candidates > 0 && self.rejected == self.total_candidates
197    }
198
199    /// Rejections attributable to a class.
200    #[must_use]
201    pub fn class_count(&self, class: RejectionClass) -> usize {
202        self.by_class.get(class.as_str()).copied().unwrap_or(0)
203    }
204
205    /// Human-readable summary that separates worker health from command
206    /// admissibility from project policy.
207    #[must_use]
208    pub fn render(&self) -> String {
209        let mut parts: Vec<String> = self
210            .by_category
211            .iter()
212            .map(|(cat, n)| format!("{cat}={n}"))
213            .collect();
214        parts.sort();
215        format!(
216            "{}/{} candidates rejected (health={}, command={}, policy={}): {}",
217            self.rejected,
218            self.total_candidates,
219            self.class_count(RejectionClass::WorkerHealth),
220            self.class_count(RejectionClass::CommandAdmissibility),
221            self.class_count(RejectionClass::ProjectPolicy),
222            parts.join(", "),
223        )
224    }
225}
226
227/// Aggregate per-candidate rejections into a summary. `total_candidates` is the
228/// full candidate set (so the caller can express "3 of 5 rejected"); the
229/// rejections slice is one entry per rejected candidate.
230#[must_use]
231pub fn aggregate_rejections(
232    total_candidates: usize,
233    rejections: &[CandidateRejection],
234) -> AdmissionRejectionSummary {
235    let mut by_category: BTreeMap<String, usize> = BTreeMap::new();
236    let mut by_class: BTreeMap<String, usize> = BTreeMap::new();
237    for rejection in rejections {
238        *by_category
239            .entry(rejection.category.as_str().to_string())
240            .or_insert(0) += 1;
241        *by_class
242            .entry(rejection.category.class().as_str().to_string())
243            .or_insert(0) += 1;
244    }
245    AdmissionRejectionSummary {
246        total_candidates,
247        rejected: rejections.len(),
248        by_category,
249        by_class,
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn rej(worker: &str, category: AdmissionRejectionCategory) -> CandidateRejection {
258        CandidateRejection {
259            worker_id: worker.to_string(),
260            category,
261        }
262    }
263
264    #[test]
265    fn aggregates_counts_per_category() {
266        let rejections = vec![
267            rej("a", AdmissionRejectionCategory::CriticalPressure),
268            rej("b", AdmissionRejectionCategory::CriticalPressure),
269            rej("c", AdmissionRejectionCategory::MissingRustTarget),
270        ];
271        let summary = aggregate_rejections(4, &rejections);
272        assert_eq!(summary.total_candidates, 4);
273        assert_eq!(summary.rejected, 3);
274        assert_eq!(summary.by_category.get("critical_pressure"), Some(&2));
275        assert_eq!(summary.by_category.get("missing_rust_target"), Some(&1));
276        assert!(!summary.all_rejected());
277    }
278
279    #[test]
280    fn separates_global_health_from_command_admissibility_and_policy() {
281        let rejections = vec![
282            rej("a", AdmissionRejectionCategory::CriticalPressure), // health
283            rej("b", AdmissionRejectionCategory::CircuitOpen),      // health
284            rej("c", AdmissionRejectionCategory::MissingToolchain), // command
285            rej("d", AdmissionRejectionCategory::OsArchMismatch),   // command
286            rej("e", AdmissionRejectionCategory::ProjectExcluded),  // policy
287        ];
288        let summary = aggregate_rejections(5, &rejections);
289        assert!(summary.all_rejected());
290        assert_eq!(summary.class_count(RejectionClass::WorkerHealth), 2);
291        assert_eq!(summary.class_count(RejectionClass::CommandAdmissibility), 2);
292        assert_eq!(summary.class_count(RejectionClass::ProjectPolicy), 1);
293    }
294
295    #[test]
296    fn the_twelve_categories_are_distinct_and_classified() {
297        assert_eq!(AdmissionRejectionCategory::ALL.len(), 12);
298        let mut tokens: Vec<&str> = AdmissionRejectionCategory::ALL
299            .iter()
300            .map(|c| c.as_str())
301            .collect();
302        tokens.sort_unstable();
303        let before = tokens.len();
304        tokens.dedup();
305        assert_eq!(before, tokens.len(), "duplicate category tokens");
306        // Every category has a class and an incident reason.
307        for c in AdmissionRejectionCategory::ALL {
308            let _ = c.class();
309            let _ = c.incident_reason();
310        }
311    }
312
313    #[test]
314    fn missing_runtime_split_maps_to_coarse_incident_code() {
315        // The three runtime categories collapse to the registry's single code.
316        for c in [
317            AdmissionRejectionCategory::MissingRuntime,
318            AdmissionRejectionCategory::MissingToolchain,
319            AdmissionRejectionCategory::MissingRustTarget,
320        ] {
321            assert_eq!(
322                c.incident_reason(),
323                IncidentReasonCode::MissingRuntimeToolchainTarget
324            );
325        }
326    }
327
328    #[test]
329    fn from_incident_reason_is_unambiguous_or_none() {
330        // 1:1 codes round-trip.
331        assert_eq!(
332            AdmissionRejectionCategory::from_incident_reason(IncidentReasonCode::CircuitOpen),
333            Some(AdmissionRejectionCategory::CircuitOpen)
334        );
335        assert_eq!(
336            AdmissionRejectionCategory::from_incident_reason(IncidentReasonCode::OsArchMismatch),
337            Some(AdmissionRejectionCategory::OsArchMismatch)
338        );
339        // The collapsed runtime code is ambiguous => None (caller must refine).
340        assert_eq!(
341            AdmissionRejectionCategory::from_incident_reason(
342                IncidentReasonCode::MissingRuntimeToolchainTarget
343            ),
344            None
345        );
346        // A non-admission code => None.
347        assert_eq!(
348            AdmissionRejectionCategory::from_incident_reason(IncidentReasonCode::DiskFull),
349            None
350        );
351    }
352
353    #[test]
354    fn empty_rejections_is_no_rejection() {
355        let summary = aggregate_rejections(3, &[]);
356        assert_eq!(summary.rejected, 0);
357        assert!(!summary.all_rejected());
358        assert!(summary.by_category.is_empty());
359        assert_eq!(summary.class_count(RejectionClass::WorkerHealth), 0);
360    }
361
362    #[test]
363    fn summary_serializes_with_stable_tokens_and_round_trips() {
364        let rejections = vec![
365            rej("a", AdmissionRejectionCategory::TelemetryStale),
366            rej("b", AdmissionRejectionCategory::MissingRuntime),
367        ];
368        let summary = aggregate_rejections(2, &rejections);
369        let value = serde_json::to_value(&summary).unwrap();
370        assert_eq!(value["by_category"]["telemetry_stale"], 1);
371        assert_eq!(value["by_category"]["missing_runtime"], 1);
372        assert_eq!(value["by_class"]["worker_health"], 1);
373        assert_eq!(value["by_class"]["command_admissibility"], 1);
374        let back: AdmissionRejectionSummary = serde_json::from_value(value).unwrap();
375        assert_eq!(back, summary);
376    }
377
378    #[test]
379    fn render_separates_classes() {
380        let summary = aggregate_rejections(
381            2,
382            &[
383                rej("a", AdmissionRejectionCategory::CriticalPressure),
384                rej("b", AdmissionRejectionCategory::MissingRustTarget),
385            ],
386        );
387        let line = summary.render();
388        assert!(line.contains("health=1"));
389        assert!(line.contains("command=1"));
390        assert!(line.contains("2/2 candidates rejected"));
391    }
392}