1use std::collections::BTreeMap;
20
21use serde::{Deserialize, Serialize};
22
23use crate::incident::IncidentReasonCode;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum RejectionClass {
32 WorkerHealth,
34 CommandAdmissibility,
36 ProjectPolicy,
38}
39
40impl RejectionClass {
41 #[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#[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 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 #[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 #[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 #[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 #[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 _ => return None,
168 })
169 }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct CandidateRejection {
175 pub worker_id: String,
176 pub category: AdmissionRejectionCategory,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct AdmissionRejectionSummary {
182 pub total_candidates: usize,
184 pub rejected: usize,
186 pub by_category: BTreeMap<String, usize>,
188 pub by_class: BTreeMap<String, usize>,
190}
191
192impl AdmissionRejectionSummary {
193 #[must_use]
195 pub fn all_rejected(&self) -> bool {
196 self.total_candidates > 0 && self.rejected == self.total_candidates
197 }
198
199 #[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 #[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#[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), rej("b", AdmissionRejectionCategory::CircuitOpen), rej("c", AdmissionRejectionCategory::MissingToolchain), rej("d", AdmissionRejectionCategory::OsArchMismatch), rej("e", AdmissionRejectionCategory::ProjectExcluded), ];
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 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 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 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 assert_eq!(
341 AdmissionRejectionCategory::from_incident_reason(
342 IncidentReasonCode::MissingRuntimeToolchainTarget
343 ),
344 None
345 );
346 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}