1use crate::core::types::ValidationTier;
34use serde::{Deserialize, Serialize};
35use std::collections::HashMap;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TransitionError {
44 pub from: ValidationTier,
45 pub to: ValidationTier,
46 pub reason: String,
47}
48
49impl std::fmt::Display for TransitionError {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 write!(
52 f,
53 "illegal transition from {:?} to {:?}: {}",
54 self.from, self.to, self.reason
55 )
56 }
57}
58
59impl std::error::Error for TransitionError {}
60
61#[derive(Debug, Clone, Default)]
78pub struct ValidationStateMachine {
79 entries: HashMap<String, ValidationTier>,
81}
82
83impl ValidationStateMachine {
84 pub fn new() -> Self {
86 Self {
87 entries: HashMap::new(),
88 }
89 }
90
91 pub fn register(&mut self, component_id: impl Into<String>) {
95 let id = component_id.into();
96 self.entries.entry(id).or_insert(ValidationTier::Candidate);
97 }
98
99 pub fn register_with_tier(&mut self, component_id: impl Into<String>, tier: ValidationTier) {
103 let id = component_id.into();
104 self.entries.entry(id).or_insert(tier);
105 }
106
107 pub fn transition(
111 &mut self,
112 component_id: &str,
113 target: ValidationTier,
114 ) -> Result<(), TransitionError> {
115 let current = self
116 .entries
117 .get(component_id)
118 .copied()
119 .unwrap_or(ValidationTier::Candidate);
120
121 if Self::is_legal_transition(current, target) {
122 self.entries.insert(component_id.to_string(), target);
123 Ok(())
124 } else {
125 Err(TransitionError {
126 from: current,
127 to: target,
128 reason: Self::transition_reason(current, target),
129 })
130 }
131 }
132
133 pub fn tier(&self, component_id: &str) -> ValidationTier {
137 self.entries
138 .get(component_id)
139 .copied()
140 .unwrap_or(ValidationTier::Candidate)
141 }
142
143 pub fn can_transition(&self, component_id: &str, target: ValidationTier) -> bool {
145 let current = self
146 .entries
147 .get(component_id)
148 .copied()
149 .unwrap_or(ValidationTier::Candidate);
150 Self::is_legal_transition(current, target)
151 }
152
153 fn is_legal_transition(from: ValidationTier, to: ValidationTier) -> bool {
165 if from == to {
166 return true; }
168 match from {
169 ValidationTier::Validated => {
170 matches!(to, ValidationTier::Candidate | ValidationTier::Blocked)
171 }
172 ValidationTier::Candidate => matches!(
173 to,
174 ValidationTier::Validated | ValidationTier::Experimental | ValidationTier::Blocked
175 ),
176 ValidationTier::Experimental => {
177 matches!(to, ValidationTier::Candidate | ValidationTier::Blocked)
178 }
179 ValidationTier::Blocked => false, }
181 }
182
183 fn transition_reason(from: ValidationTier, to: ValidationTier) -> String {
185 match from {
186 ValidationTier::Blocked => {
187 "Blocked is a terminal state; no transitions out allowed".to_string()
188 }
189 ValidationTier::Experimental if to == ValidationTier::Validated => {
190 "Experimental components must pass through Candidate before reaching Validated"
191 .to_string()
192 }
193 _ => format!("transition from {:?} to {:?} is not allowed", from, to),
194 }
195 }
196
197 pub fn entries(&self) -> &HashMap<String, ValidationTier> {
199 &self.entries
200 }
201
202 pub fn force_set(&mut self, component_id: impl Into<String>, tier: ValidationTier) {
206 self.entries.insert(component_id.into(), tier);
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
218pub struct ValidationEntry {
219 pub component_id: String,
220 pub tier: ValidationTier,
221}
222
223impl ValidationEntry {
224 pub fn new(component_id: impl Into<String>) -> Self {
226 Self {
227 component_id: component_id.into(),
228 tier: ValidationTier::Candidate,
229 }
230 }
231
232 pub fn with_tier(component_id: impl Into<String>, tier: ValidationTier) -> Self {
234 Self {
235 component_id: component_id.into(),
236 tier,
237 }
238 }
239
240 pub fn is_visible(&self) -> bool {
242 self.tier.is_visible()
243 }
244
245 pub fn is_visible_with_experimental(&self) -> bool {
247 match self.tier {
248 ValidationTier::Validated
249 | ValidationTier::Candidate
250 | ValidationTier::Experimental => true,
251 ValidationTier::Blocked => false,
252 }
253 }
254
255 pub fn is_preselected(&self) -> bool {
257 self.tier.is_preselected()
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
267pub struct FilteredEntries {
268 pub visible: Vec<VisibleEntry>,
270 pub hidden: Vec<ValidationEntry>,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
276pub struct VisibleEntry {
277 pub component_id: String,
278 pub tier: ValidationTier,
279 pub preselected: bool,
280}
281
282pub fn filter_entries(entries: &[ValidationEntry]) -> FilteredEntries {
291 let mut visible = Vec::new();
292 let mut hidden = Vec::new();
293
294 for entry in entries {
295 match entry.tier {
296 ValidationTier::Validated => {
297 visible.push(VisibleEntry {
298 component_id: entry.component_id.clone(),
299 tier: entry.tier,
300 preselected: true,
301 });
302 }
303 ValidationTier::Candidate => {
304 visible.push(VisibleEntry {
305 component_id: entry.component_id.clone(),
306 tier: entry.tier,
307 preselected: false,
308 });
309 }
310 ValidationTier::Experimental => {
311 hidden.push(entry.clone());
312 }
313 ValidationTier::Blocked => {
314 }
316 }
317 }
318
319 FilteredEntries { visible, hidden }
320}
321
322pub fn filter_entries_with_experimental(
327 entries: &[ValidationEntry],
328 include_experimental: bool,
329) -> FilteredEntries {
330 if include_experimental {
331 let mut visible = Vec::new();
332
333 for entry in entries {
334 match entry.tier {
335 ValidationTier::Validated => {
336 visible.push(VisibleEntry {
337 component_id: entry.component_id.clone(),
338 tier: entry.tier,
339 preselected: true,
340 });
341 }
342 ValidationTier::Candidate => {
343 visible.push(VisibleEntry {
344 component_id: entry.component_id.clone(),
345 tier: entry.tier,
346 preselected: false,
347 });
348 }
349 ValidationTier::Experimental => {
350 visible.push(VisibleEntry {
351 component_id: entry.component_id.clone(),
352 tier: entry.tier,
353 preselected: false,
354 });
355 }
356 ValidationTier::Blocked => {
357 }
359 }
360 }
361
362 FilteredEntries {
363 visible,
364 hidden: vec![],
365 }
366 } else {
367 filter_entries(entries)
368 }
369}
370
371#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
384 fn test_validation_legal_transition_validated_to_candidate() {
385 let mut sm = ValidationStateMachine::new();
386 sm.register_with_tier("pytorch", ValidationTier::Validated);
387 assert!(sm.transition("pytorch", ValidationTier::Candidate).is_ok());
388 assert_eq!(sm.tier("pytorch"), ValidationTier::Candidate);
389 }
390
391 #[test]
392 fn test_validation_legal_transition_candidate_to_validated() {
393 let mut sm = ValidationStateMachine::new();
394 sm.register("pytorch"); assert!(sm.transition("pytorch", ValidationTier::Validated).is_ok());
396 assert_eq!(sm.tier("pytorch"), ValidationTier::Validated);
397 }
398
399 #[test]
400 fn test_validation_legal_transition_candidate_to_experimental() {
401 let mut sm = ValidationStateMachine::new();
402 sm.register("triton"); assert!(sm
404 .transition("triton", ValidationTier::Experimental)
405 .is_ok());
406 assert_eq!(sm.tier("triton"), ValidationTier::Experimental);
407 }
408
409 #[test]
410 fn test_validation_legal_transition_experimental_to_candidate() {
411 let mut sm = ValidationStateMachine::new();
412 sm.register_with_tier("triton", ValidationTier::Experimental);
413 assert!(sm.transition("triton", ValidationTier::Candidate).is_ok());
414 assert_eq!(sm.tier("triton"), ValidationTier::Candidate);
415 }
416
417 #[test]
418 fn test_validation_legal_transition_any_to_blocked() {
419 for from_tier in ValidationTier::all() {
420 let mut sm = ValidationStateMachine::new();
421 sm.register_with_tier("comp", *from_tier);
422 let result = sm.transition("comp", ValidationTier::Blocked);
423 assert!(
424 result.is_ok(),
425 "transition from {:?} to Blocked should be legal",
426 from_tier
427 );
428 assert_eq!(sm.tier("comp"), ValidationTier::Blocked);
429 }
430 }
431
432 #[test]
433 fn test_validation_self_transitions_are_legal() {
434 for tier in ValidationTier::all() {
435 let mut sm = ValidationStateMachine::new();
436 sm.register_with_tier("comp", *tier);
437 let result = sm.transition("comp", *tier);
438 assert!(
439 result.is_ok(),
440 "self-transition for {:?} should be legal",
441 tier
442 );
443 }
444 }
445
446 #[test]
451 fn test_validation_illegal_transition_experimental_to_validated() {
452 let mut sm = ValidationStateMachine::new();
453 sm.register_with_tier("triton", ValidationTier::Experimental);
454 let result = sm.transition("triton", ValidationTier::Validated);
455 assert!(
456 result.is_err(),
457 "Experimental → Validated should be illegal"
458 );
459 let err = result.unwrap_err();
460 assert_eq!(err.from, ValidationTier::Experimental);
461 assert_eq!(err.to, ValidationTier::Validated);
462 assert_eq!(sm.tier("triton"), ValidationTier::Experimental);
464 }
465
466 #[test]
467 fn test_validation_illegal_transition_blocked_to_validated() {
468 let mut sm = ValidationStateMachine::new();
469 sm.register_with_tier("comp", ValidationTier::Blocked);
470 let result = sm.transition("comp", ValidationTier::Validated);
471 assert!(result.is_err(), "Blocked → Validated should be illegal");
472 assert_eq!(result.unwrap_err().from, ValidationTier::Blocked);
473 assert_eq!(sm.tier("comp"), ValidationTier::Blocked);
474 }
475
476 #[test]
477 fn test_validation_illegal_transition_blocked_to_candidate() {
478 let mut sm = ValidationStateMachine::new();
479 sm.register_with_tier("comp", ValidationTier::Blocked);
480 let result = sm.transition("comp", ValidationTier::Candidate);
481 assert!(result.is_err(), "Blocked → Candidate should be illegal");
482 }
483
484 #[test]
485 fn test_validation_illegal_transition_blocked_to_experimental() {
486 let mut sm = ValidationStateMachine::new();
487 sm.register_with_tier("comp", ValidationTier::Blocked);
488 let result = sm.transition("comp", ValidationTier::Experimental);
489 assert!(result.is_err(), "Blocked → Experimental should be illegal");
490 }
491
492 #[test]
493 fn test_validation_blocked_is_terminal() {
494 let mut sm = ValidationStateMachine::new();
496 sm.register_with_tier("comp", ValidationTier::Blocked);
497 for target in ValidationTier::all() {
498 if *target == ValidationTier::Blocked {
499 continue; }
501 assert!(
502 !sm.can_transition("comp", *target),
503 "Blocked → {:?} should be illegal",
504 target
505 );
506 }
507 }
508
509 #[test]
510 fn test_validation_error_display() {
511 let err = TransitionError {
512 from: ValidationTier::Blocked,
513 to: ValidationTier::Validated,
514 reason: "Blocked is a terminal state".to_string(),
515 };
516 let msg = err.to_string();
517 assert!(msg.contains("Blocked"));
518 assert!(msg.contains("Validated"));
519 }
520
521 #[test]
526 fn test_validation_new_component_defaults_to_candidate() {
527 let mut sm = ValidationStateMachine::new();
528 sm.register("new-component");
529 assert_eq!(sm.tier("new-component"), ValidationTier::Candidate);
530 }
531
532 #[test]
533 fn test_validation_unregistered_component_returns_candidate() {
534 let sm = ValidationStateMachine::new();
535 assert_eq!(sm.tier("nonexistent"), ValidationTier::Candidate);
536 }
537
538 #[test]
539 fn test_validation_entry_new_defaults_to_candidate() {
540 let entry = ValidationEntry::new("test-comp");
541 assert_eq!(entry.tier, ValidationTier::Candidate);
542 assert_eq!(entry.component_id, "test-comp");
543 }
544
545 #[test]
546 fn test_validation_register_existing_is_noop() {
547 let mut sm = ValidationStateMachine::new();
548 sm.register_with_tier("comp", ValidationTier::Validated);
549 sm.register("comp"); assert_eq!(sm.tier("comp"), ValidationTier::Validated);
551 }
552
553 #[test]
558 fn test_validation_filter_validated_visible_and_preselected() {
559 let entries = vec![ValidationEntry::with_tier(
560 "pytorch",
561 ValidationTier::Validated,
562 )];
563 let filtered = filter_entries(&entries);
564 assert_eq!(filtered.visible.len(), 1);
565 assert_eq!(filtered.hidden.len(), 0);
566 assert_eq!(filtered.visible[0].component_id, "pytorch");
567 assert!(filtered.visible[0].preselected);
568 assert_eq!(filtered.visible[0].tier, ValidationTier::Validated);
569 }
570
571 #[test]
572 fn test_validation_filter_candidate_visible_not_preselected() {
573 let entries = vec![ValidationEntry::with_tier(
574 "triton",
575 ValidationTier::Candidate,
576 )];
577 let filtered = filter_entries(&entries);
578 assert_eq!(filtered.visible.len(), 1);
579 assert_eq!(filtered.hidden.len(), 0);
580 assert!(!filtered.visible[0].preselected);
581 }
582
583 #[test]
584 fn test_validation_filter_experimental_hidden_from_default_view() {
585 let entries = vec![ValidationEntry::with_tier(
586 "deepspeed",
587 ValidationTier::Experimental,
588 )];
589 let filtered = filter_entries(&entries);
590 assert_eq!(filtered.visible.len(), 0);
591 assert_eq!(filtered.hidden.len(), 1);
592 assert_eq!(filtered.hidden[0].component_id, "deepspeed");
593 }
594
595 #[test]
596 fn test_validation_filter_blocked_never_shown() {
597 let entries = vec![ValidationEntry::with_tier(
598 "bad-comp",
599 ValidationTier::Blocked,
600 )];
601 let filtered = filter_entries(&entries);
602 assert_eq!(filtered.visible.len(), 0);
603 assert_eq!(filtered.hidden.len(), 0);
604 }
605
606 #[test]
607 fn test_validation_filter_all_tiers_combined() {
608 let entries = vec![
609 ValidationEntry::with_tier("rocm", ValidationTier::Validated),
610 ValidationEntry::with_tier("triton", ValidationTier::Candidate),
611 ValidationEntry::with_tier("deepspeed", ValidationTier::Experimental),
612 ValidationEntry::with_tier("bad-comp", ValidationTier::Blocked),
613 ];
614 let filtered = filter_entries(&entries);
615
616 assert_eq!(filtered.visible.len(), 2);
618 assert_eq!(filtered.hidden.len(), 1);
619
620 let rocm = filtered
622 .visible
623 .iter()
624 .find(|e| e.component_id == "rocm")
625 .unwrap();
626 assert!(rocm.preselected);
627 let triton = filtered
628 .visible
629 .iter()
630 .find(|e| e.component_id == "triton")
631 .unwrap();
632 assert!(!triton.preselected);
633 }
634
635 #[test]
636 fn test_validation_filter_include_experimental_flag() {
637 let entries = vec![
638 ValidationEntry::with_tier("rocm", ValidationTier::Validated),
639 ValidationEntry::with_tier("deepspeed", ValidationTier::Experimental),
640 ];
641
642 let filtered = filter_entries_with_experimental(&entries, false);
644 assert_eq!(filtered.visible.len(), 1);
645 assert_eq!(filtered.hidden.len(), 1);
646
647 let filtered = filter_entries_with_experimental(&entries, true);
649 assert_eq!(filtered.visible.len(), 2);
650 assert_eq!(filtered.hidden.len(), 0);
651 let deepspeed = filtered
652 .visible
653 .iter()
654 .find(|e| e.component_id == "deepspeed")
655 .unwrap();
656 assert!(!deepspeed.preselected);
657 }
658
659 #[test]
664 fn test_validation_entry_visibility() {
665 let validated = ValidationEntry::with_tier("a", ValidationTier::Validated);
666 let candidate = ValidationEntry::with_tier("b", ValidationTier::Candidate);
667 let experimental = ValidationEntry::with_tier("c", ValidationTier::Experimental);
668 let blocked = ValidationEntry::with_tier("d", ValidationTier::Blocked);
669
670 assert!(validated.is_visible());
671 assert!(candidate.is_visible());
672 assert!(!experimental.is_visible());
673 assert!(!blocked.is_visible());
674
675 assert!(validated.is_visible_with_experimental());
677 assert!(candidate.is_visible_with_experimental());
678 assert!(experimental.is_visible_with_experimental());
679 assert!(!blocked.is_visible_with_experimental());
680 }
681
682 #[test]
683 fn test_validation_entry_preselection() {
684 let validated = ValidationEntry::with_tier("a", ValidationTier::Validated);
685 let candidate = ValidationEntry::with_tier("b", ValidationTier::Candidate);
686 let experimental = ValidationEntry::with_tier("c", ValidationTier::Experimental);
687 let blocked = ValidationEntry::with_tier("d", ValidationTier::Blocked);
688
689 assert!(validated.is_preselected());
690 assert!(!candidate.is_preselected());
691 assert!(!experimental.is_preselected());
692 assert!(!blocked.is_preselected());
693 }
694
695 #[test]
696 fn test_validation_entry_serde_roundtrip() {
697 let entry = ValidationEntry::with_tier("pytorch", ValidationTier::Validated);
698 let json = serde_json::to_string(&entry).unwrap();
699 let back: ValidationEntry = serde_json::from_str(&json).unwrap();
700 assert_eq!(entry, back);
701 }
702
703 #[test]
704 fn test_validation_can_transition_query() {
705 let mut sm = ValidationStateMachine::new();
706 sm.register_with_tier("comp", ValidationTier::Validated);
707 assert!(sm.can_transition("comp", ValidationTier::Candidate));
708 assert!(sm.can_transition("comp", ValidationTier::Blocked));
709 assert!(sm.can_transition("comp", ValidationTier::Validated)); assert!(!sm.can_transition("comp", ValidationTier::Experimental));
711 }
712
713 #[test]
714 fn test_validation_force_set() {
715 let mut sm = ValidationStateMachine::new();
716 sm.register("comp");
717 sm.force_set("comp", ValidationTier::Blocked);
719 assert_eq!(sm.tier("comp"), ValidationTier::Blocked);
720 }
721
722 #[test]
723 fn test_validation_entries_iterator() {
724 let mut sm = ValidationStateMachine::new();
725 sm.register("a");
726 sm.register_with_tier("b", ValidationTier::Validated);
727 assert_eq!(sm.entries().len(), 2);
728 assert_eq!(sm.entries().get("a"), Some(&ValidationTier::Candidate));
729 assert_eq!(sm.entries().get("b"), Some(&ValidationTier::Validated));
730 }
731
732 #[test]
733 fn test_validation_multi_step_transition() {
734 let mut sm = ValidationStateMachine::new();
736 sm.register_with_tier("comp", ValidationTier::Experimental);
737
738 assert!(sm.transition("comp", ValidationTier::Candidate).is_ok());
740 assert_eq!(sm.tier("comp"), ValidationTier::Candidate);
741
742 assert!(sm.transition("comp", ValidationTier::Validated).is_ok());
744 assert_eq!(sm.tier("comp"), ValidationTier::Validated);
745 }
746
747 #[test]
748 fn test_validation_filtered_entries_serde_roundtrip() {
749 let filtered = FilteredEntries {
750 visible: vec![VisibleEntry {
751 component_id: "rocm".to_string(),
752 tier: ValidationTier::Validated,
753 preselected: true,
754 }],
755 hidden: vec![ValidationEntry::with_tier(
756 "exp",
757 ValidationTier::Experimental,
758 )],
759 };
760 let json = serde_json::to_string(&filtered).unwrap();
761 let back: FilteredEntries = serde_json::from_str(&json).unwrap();
762 assert_eq!(filtered, back);
763 }
764
765 #[test]
766 fn test_validation_transition_error_is_std_error() {
767 let err = TransitionError {
768 from: ValidationTier::Blocked,
769 to: ValidationTier::Validated,
770 reason: "test".to_string(),
771 };
772 let _: &dyn std::error::Error = &err; }
774}