1use serde::{Deserialize, Serialize};
33use std::collections::VecDeque;
34
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37pub struct ModelVersion {
38 pub major: u32,
40 pub minor: u32,
42 pub patch: u32,
44}
45
46impl ModelVersion {
47 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
49 Self {
50 major,
51 minor,
52 patch,
53 }
54 }
55
56 pub fn bump_major(&self) -> Self {
58 Self::new(self.major + 1, 0, 0)
59 }
60
61 pub fn bump_minor(&self) -> Self {
63 Self::new(self.major, self.minor + 1, 0)
64 }
65
66 pub fn bump_patch(&self) -> Self {
68 Self::new(self.major, self.minor, self.patch + 1)
69 }
70
71 pub fn parse(s: &str) -> Option<Self> {
73 let parts: Vec<&str> = s.trim_start_matches('v').split('.').collect();
74 if parts.len() != 3 {
75 return None;
76 }
77
78 let major = parts[0].parse().ok()?;
79 let minor = parts[1].parse().ok()?;
80 let patch = parts[2].parse().ok()?;
81
82 Some(Self::new(major, minor, patch))
83 }
84}
85
86impl std::fmt::Display for ModelVersion {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 write!(f, "v{}.{}.{}", self.major, self.minor, self.patch)
89 }
90}
91
92impl Default for ModelVersion {
93 fn default() -> Self {
94 Self::new(1, 0, 0)
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ModelQualityMetrics {
101 pub accuracy: f64,
103 pub precision: f64,
105 pub recall: f64,
107 pub f1_score: f64,
109 pub avg_confidence: f64,
111 pub fallback_rate: f64,
113 pub sample_count: u64,
115}
116
117impl ModelQualityMetrics {
118 pub fn new(
120 accuracy: f64,
121 precision: f64,
122 recall: f64,
123 f1_score: f64,
124 avg_confidence: f64,
125 fallback_rate: f64,
126 sample_count: u64,
127 ) -> Self {
128 Self {
129 accuracy: accuracy.clamp(0.0, 1.0),
130 precision: precision.clamp(0.0, 1.0),
131 recall: recall.clamp(0.0, 1.0),
132 f1_score: f1_score.clamp(0.0, 1.0),
133 avg_confidence: avg_confidence.clamp(0.0, 1.0),
134 fallback_rate: fallback_rate.clamp(0.0, 1.0),
135 sample_count,
136 }
137 }
138
139 pub fn meets_thresholds(&self, thresholds: &QualityThresholds) -> bool {
141 self.accuracy >= thresholds.min_accuracy
142 && self.precision >= thresholds.min_precision
143 && self.recall >= thresholds.min_recall
144 && self.f1_score >= thresholds.min_f1
145 }
146
147 pub fn is_better_than(&self, other: &Self) -> bool {
149 if (self.accuracy - other.accuracy).abs() > 0.01 {
151 self.accuracy > other.accuracy
152 } else {
153 self.f1_score > other.f1_score
154 }
155 }
156}
157
158impl Default for ModelQualityMetrics {
159 fn default() -> Self {
160 Self::new(0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0)
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct QualityThresholds {
167 pub min_accuracy: f64,
169 pub min_precision: f64,
171 pub min_recall: f64,
173 pub min_f1: f64,
175}
176
177impl Default for QualityThresholds {
178 fn default() -> Self {
179 Self {
180 min_accuracy: 0.85,
181 min_precision: 0.80,
182 min_recall: 0.80,
183 min_f1: 0.80,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ModelEntry {
191 pub version: ModelVersion,
193 pub metrics: ModelQualityMetrics,
195 pub released_at: u64,
197 pub description: String,
199 pub artifact_path: String,
201 pub is_active: bool,
203 pub rolled_back: bool,
205 pub rollback_reason: Option<String>,
207}
208
209impl ModelEntry {
210 pub fn new(
212 version: ModelVersion,
213 metrics: ModelQualityMetrics,
214 description: impl Into<String>,
215 artifact_path: impl Into<String>,
216 ) -> Self {
217 let now = std::time::SystemTime::now()
218 .duration_since(std::time::UNIX_EPOCH)
219 .unwrap_or_default()
220 .as_millis() as u64;
221
222 Self {
223 version,
224 metrics,
225 released_at: now,
226 description: description.into(),
227 artifact_path: artifact_path.into(),
228 is_active: false,
229 rolled_back: false,
230 rollback_reason: None,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct RollbackResult {
238 pub success: bool,
240 pub from_version: ModelVersion,
242 pub to_version: ModelVersion,
244 pub reason: String,
246 pub timestamp: u64,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ModelVersionManager {
253 versions: VecDeque<ModelEntry>,
255 active_index: Option<usize>,
257 thresholds: QualityThresholds,
259 max_history: usize,
261 rollback_history: Vec<RollbackResult>,
263}
264
265impl Default for ModelVersionManager {
266 fn default() -> Self {
267 Self::new()
268 }
269}
270
271impl ModelVersionManager {
272 pub fn new() -> Self {
274 Self {
275 versions: VecDeque::new(),
276 active_index: None,
277 thresholds: QualityThresholds::default(),
278 max_history: 10,
279 rollback_history: Vec::new(),
280 }
281 }
282
283 pub fn with_thresholds(thresholds: QualityThresholds) -> Self {
285 Self {
286 thresholds,
287 ..Self::new()
288 }
289 }
290
291 pub fn with_max_history(mut self, max: usize) -> Self {
293 self.max_history = max.max(2); self
295 }
296
297 pub fn active_version(&self) -> Option<&ModelEntry> {
299 self.active_index.and_then(|i| self.versions.get(i))
300 }
301
302 pub fn versions(&self) -> impl Iterator<Item = &ModelEntry> {
304 self.versions.iter()
305 }
306
307 pub fn version_count(&self) -> usize {
309 self.versions.len()
310 }
311
312 pub fn thresholds(&self) -> &QualityThresholds {
314 &self.thresholds
315 }
316
317 pub fn register_version(&mut self, mut entry: ModelEntry) -> Result<bool, String> {
322 if let Some(latest) = self.versions.back() {
324 if entry.version <= latest.version {
325 return Err(format!(
326 "Version {} must be greater than current {}",
327 entry.version, latest.version
328 ));
329 }
330 }
331
332 let meets_quality = entry.metrics.meets_thresholds(&self.thresholds);
334
335 let is_better = self
337 .active_version()
338 .map(|active| entry.metrics.is_better_than(&active.metrics))
339 .unwrap_or(true);
340
341 let should_activate = meets_quality && is_better;
343
344 if should_activate {
345 if let Some(idx) = self.active_index {
347 if let Some(current) = self.versions.get_mut(idx) {
348 current.is_active = false;
349 }
350 }
351
352 entry.is_active = true;
354 self.versions.push_back(entry);
355 self.active_index = Some(self.versions.len() - 1);
356 } else {
357 entry.is_active = false;
359 self.versions.push_back(entry);
360 }
361
362 self.prune_history();
364
365 Ok(should_activate)
366 }
367
368 pub fn rollback(&mut self, reason: impl Into<String>) -> Result<RollbackResult, String> {
370 let reason = reason.into();
371
372 if self.versions.len() < 2 {
374 return Err("Not enough versions to rollback".to_string());
375 }
376
377 let current_idx = self.active_index.ok_or("No active version")?;
378 let current_version = self.versions[current_idx].version.clone();
379
380 let prev_idx = self
382 .versions
383 .iter()
384 .enumerate()
385 .rev()
386 .skip(1) .find(|(_, e)| !e.rolled_back)
388 .map(|(i, _)| i)
389 .ok_or("No previous version available for rollback")?;
390
391 let prev_version = self.versions[prev_idx].version.clone();
392
393 if let Some(current) = self.versions.get_mut(current_idx) {
395 current.is_active = false;
396 current.rolled_back = true;
397 current.rollback_reason = Some(reason.clone());
398 }
399
400 if let Some(prev) = self.versions.get_mut(prev_idx) {
402 prev.is_active = true;
403 }
404 self.active_index = Some(prev_idx);
405
406 let now = std::time::SystemTime::now()
407 .duration_since(std::time::UNIX_EPOCH)
408 .unwrap_or_default()
409 .as_millis() as u64;
410
411 let result = RollbackResult {
412 success: true,
413 from_version: current_version,
414 to_version: prev_version,
415 reason,
416 timestamp: now,
417 };
418
419 self.rollback_history.push(result.clone());
420
421 Ok(result)
422 }
423
424 pub fn rollback_to(
426 &mut self,
427 target: &ModelVersion,
428 reason: impl Into<String>,
429 ) -> Result<RollbackResult, String> {
430 let reason = reason.into();
431
432 let target_idx = self
433 .versions
434 .iter()
435 .position(|e| &e.version == target)
436 .ok_or_else(|| format!("Version {} not found", target))?;
437
438 let current_idx = self.active_index.ok_or("No active version")?;
439
440 if target_idx == current_idx {
441 return Err("Target is already the active version".to_string());
442 }
443
444 let current_version = self.versions[current_idx].version.clone();
445
446 if let Some(current) = self.versions.get_mut(current_idx) {
448 current.is_active = false;
449 current.rolled_back = true;
450 current.rollback_reason = Some(reason.clone());
451 }
452
453 if let Some(target_entry) = self.versions.get_mut(target_idx) {
455 target_entry.is_active = true;
456 target_entry.rolled_back = false; }
458 self.active_index = Some(target_idx);
459
460 let now = std::time::SystemTime::now()
461 .duration_since(std::time::UNIX_EPOCH)
462 .unwrap_or_default()
463 .as_millis() as u64;
464
465 let result = RollbackResult {
466 success: true,
467 from_version: current_version,
468 to_version: target.clone(),
469 reason,
470 timestamp: now,
471 };
472
473 self.rollback_history.push(result.clone());
474
475 Ok(result)
476 }
477
478 pub fn rollback_history(&self) -> &[RollbackResult] {
480 &self.rollback_history
481 }
482
483 pub fn check_quality(&self, current_metrics: &ModelQualityMetrics) -> Option<String> {
487 if !current_metrics.meets_thresholds(&self.thresholds) {
488 return Some(format!(
489 "Quality below thresholds: accuracy={:.2} (min={:.2})",
490 current_metrics.accuracy, self.thresholds.min_accuracy
491 ));
492 }
493
494 if let Some(active) = self.active_version() {
496 if current_metrics.accuracy < active.metrics.accuracy - 0.05 {
497 return Some(format!(
498 "Accuracy regression: {:.2} → {:.2} (>5% drop)",
499 active.metrics.accuracy, current_metrics.accuracy
500 ));
501 }
502 }
503
504 None
505 }
506
507 pub fn auto_rollback_if_needed(
509 &mut self,
510 current_metrics: &ModelQualityMetrics,
511 ) -> Option<RollbackResult> {
512 if let Some(reason) = self.check_quality(current_metrics) {
513 self.rollback(format!("Auto-rollback: {}", reason)).ok()
514 } else {
515 None
516 }
517 }
518
519 pub fn to_markdown(&self) -> String {
521 let mut report = String::from("## Model Version Report\n\n");
522
523 if let Some(active) = self.active_version() {
525 report.push_str(&format!(
526 "**Active Version**: {} | Accuracy: {:.1}% | F1: {:.3}\n\n",
527 active.version,
528 active.metrics.accuracy * 100.0,
529 active.metrics.f1_score
530 ));
531 } else {
532 report.push_str("**Active Version**: None\n\n");
533 }
534
535 report.push_str("### Version History\n\n");
537 report.push_str("| Version | Accuracy | F1 | Status | Released |\n");
538 report.push_str("|---------|----------|----|---------|---------|\n");
539
540 for entry in self.versions.iter().rev() {
541 let status = if entry.is_active {
542 "✅ Active"
543 } else if entry.rolled_back {
544 "🔙 Rolled Back"
545 } else {
546 "📦 Available"
547 };
548
549 let released = chrono_lite_format(entry.released_at);
551
552 report.push_str(&format!(
553 "| {} | {:.1}% | {:.3} | {} | {} |\n",
554 entry.version,
555 entry.metrics.accuracy * 100.0,
556 entry.metrics.f1_score,
557 status,
558 released
559 ));
560 }
561
562 if !self.rollback_history.is_empty() {
564 report.push_str("\n### Rollback History\n\n");
565 for rb in &self.rollback_history {
566 report.push_str(&format!(
567 "- {} → {}: {}\n",
568 rb.from_version, rb.to_version, rb.reason
569 ));
570 }
571 }
572
573 report
574 }
575
576 fn prune_history(&mut self) {
577 while self.versions.len() > self.max_history {
578 if self.active_index == Some(0) {
580 break;
581 }
582
583 self.versions.pop_front();
584
585 if let Some(idx) = self.active_index {
587 if idx > 0 {
588 self.active_index = Some(idx - 1);
589 }
590 }
591 }
592 }
593}
594
595fn chrono_lite_format(millis: u64) -> String {
597 let secs = millis / 1000;
599 let days = secs / 86400;
600 if days > 0 {
601 format!("{}d ago", days)
602 } else {
603 let hours = secs / 3600;
604 if hours > 0 {
605 format!("{}h ago", hours)
606 } else {
607 "recent".to_string()
608 }
609 }
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615
616 #[test]
621 fn model_version_new() {
622 let v = ModelVersion::new(1, 2, 3);
623 assert_eq!(v.major, 1);
624 assert_eq!(v.minor, 2);
625 assert_eq!(v.patch, 3);
626 }
627
628 #[test]
629 fn model_version_display() {
630 let v = ModelVersion::new(1, 2, 3);
631 assert_eq!(v.to_string(), "v1.2.3");
632 }
633
634 #[test]
635 fn model_version_parse() {
636 assert_eq!(
637 ModelVersion::parse("1.2.3"),
638 Some(ModelVersion::new(1, 2, 3))
639 );
640 assert_eq!(
641 ModelVersion::parse("v1.2.3"),
642 Some(ModelVersion::new(1, 2, 3))
643 );
644 assert_eq!(ModelVersion::parse("invalid"), None);
645 assert_eq!(ModelVersion::parse("1.2"), None);
646 }
647
648 #[test]
649 fn model_version_bump() {
650 let v = ModelVersion::new(1, 2, 3);
651
652 assert_eq!(v.bump_major(), ModelVersion::new(2, 0, 0));
653 assert_eq!(v.bump_minor(), ModelVersion::new(1, 3, 0));
654 assert_eq!(v.bump_patch(), ModelVersion::new(1, 2, 4));
655 }
656
657 #[test]
658 fn model_version_ordering() {
659 let v1 = ModelVersion::new(1, 0, 0);
660 let v2 = ModelVersion::new(1, 1, 0);
661 let v3 = ModelVersion::new(2, 0, 0);
662
663 assert!(v1 < v2);
664 assert!(v2 < v3);
665 assert!(v1 < v3);
666 }
667
668 #[test]
673 fn quality_metrics_new() {
674 let m = ModelQualityMetrics::new(0.9, 0.85, 0.88, 0.86, 0.75, 0.2, 1000);
675 assert!((m.accuracy - 0.9).abs() < 0.001);
676 assert_eq!(m.sample_count, 1000);
677 }
678
679 #[test]
680 fn quality_metrics_clamps() {
681 let m = ModelQualityMetrics::new(1.5, -0.1, 0.5, 0.5, 0.5, 0.5, 100);
682 assert!((m.accuracy - 1.0).abs() < 0.001);
683 assert!((m.precision - 0.0).abs() < 0.001);
684 }
685
686 #[test]
687 fn quality_metrics_meets_thresholds() {
688 let thresholds = QualityThresholds::default();
689
690 let good = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
692 assert!(good.meets_thresholds(&thresholds));
693
694 let bad = ModelQualityMetrics::new(0.70, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
696 assert!(!bad.meets_thresholds(&thresholds));
697 }
698
699 #[test]
700 fn quality_metrics_is_better_than() {
701 let m1 = ModelQualityMetrics::new(0.85, 0.80, 0.80, 0.80, 0.7, 0.3, 1000);
702 let m2 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
703
704 assert!(m2.is_better_than(&m1));
705 assert!(!m1.is_better_than(&m2));
706 }
707
708 #[test]
713 fn version_manager_new() {
714 let mgr = ModelVersionManager::new();
715 assert_eq!(mgr.version_count(), 0);
716 assert!(mgr.active_version().is_none());
717 }
718
719 #[test]
720 fn version_manager_register_first() {
721 let mut mgr = ModelVersionManager::new();
722
723 let metrics = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
724 let entry = ModelEntry::new(
725 ModelVersion::new(1, 0, 0),
726 metrics,
727 "Initial version",
728 "/models/v1.0.0.bin",
729 );
730
731 let result = mgr.register_version(entry);
732 assert!(result.is_ok());
733 assert!(result.unwrap()); assert_eq!(mgr.version_count(), 1);
736 assert!(mgr.active_version().is_some());
737 assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.0.0");
738 }
739
740 #[test]
741 fn version_manager_register_better_version() {
742 let mut mgr = ModelVersionManager::new();
743
744 let m1 = ModelQualityMetrics::new(0.85, 0.80, 0.80, 0.80, 0.7, 0.3, 1000);
746 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
747 mgr.register_version(e1).unwrap();
748
749 let m2 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
751 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
752 let activated = mgr.register_version(e2).unwrap();
753
754 assert!(activated); assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.1.0");
756 }
757
758 #[test]
759 fn version_manager_register_worse_version_not_activated() {
760 let mut mgr = ModelVersionManager::new();
761
762 let m1 = ModelQualityMetrics::new(0.92, 0.88, 0.88, 0.88, 0.85, 0.15, 1000);
764 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
765 mgr.register_version(e1).unwrap();
766
767 let m2 = ModelQualityMetrics::new(0.86, 0.82, 0.82, 0.82, 0.75, 0.25, 1000);
769 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
770 let activated = mgr.register_version(e2).unwrap();
771
772 assert!(!activated); assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.0.0");
774 assert_eq!(mgr.version_count(), 2); }
776
777 #[test]
778 fn version_manager_register_below_threshold() {
779 let mut mgr = ModelVersionManager::new();
780
781 let m1 = ModelQualityMetrics::new(0.70, 0.65, 0.65, 0.65, 0.5, 0.5, 1000);
783 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
784 let activated = mgr.register_version(e1).unwrap();
785
786 assert!(!activated); }
788
789 #[test]
790 fn version_manager_reject_older_version() {
791 let mut mgr = ModelVersionManager::new();
792
793 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
794 let e1 = ModelEntry::new(ModelVersion::new(1, 1, 0), m1.clone(), "v1.1", "/v1.1");
795 mgr.register_version(e1).unwrap();
796
797 let e2 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1.0", "/v1.0");
799 let result = mgr.register_version(e2);
800
801 assert!(result.is_err());
802 }
803
804 #[test]
805 fn version_manager_rollback() {
806 let mut mgr = ModelVersionManager::new();
807
808 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
810 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
811 mgr.register_version(e1).unwrap();
812
813 let m2 = ModelQualityMetrics::new(0.92, 0.87, 0.87, 0.87, 0.82, 0.18, 1000);
815 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
816 mgr.register_version(e2).unwrap();
817
818 assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.1.0");
819
820 let result = mgr.rollback("Quality regression detected");
822 assert!(result.is_ok());
823
824 let rb = result.unwrap();
825 assert!(rb.success);
826 assert_eq!(rb.from_version.to_string(), "v1.1.0");
827 assert_eq!(rb.to_version.to_string(), "v1.0.0");
828
829 assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.0.0");
830 }
831
832 #[test]
833 fn version_manager_rollback_not_enough_versions() {
834 let mut mgr = ModelVersionManager::new();
835
836 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
837 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
838 mgr.register_version(e1).unwrap();
839
840 let result = mgr.rollback("Test");
841 assert!(result.is_err());
842 }
843
844 #[test]
845 fn version_manager_rollback_to_specific() {
846 let mut mgr = ModelVersionManager::new();
847
848 for i in 0..3 {
850 let m = ModelQualityMetrics::new(
851 0.85 + (i as f64 * 0.02),
852 0.85,
853 0.85,
854 0.85,
855 0.8,
856 0.2,
857 1000,
858 );
859 let e = ModelEntry::new(
860 ModelVersion::new(1, i, 0),
861 m,
862 format!("v1.{}.0", i),
863 format!("/v1.{}.0", i),
864 );
865 mgr.register_version(e).unwrap();
866 }
867
868 assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.2.0");
869
870 let result = mgr.rollback_to(&ModelVersion::new(1, 0, 0), "Rollback to baseline");
872 assert!(result.is_ok());
873
874 assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.0.0");
875 }
876
877 #[test]
878 fn version_manager_check_quality() {
879 let mut mgr = ModelVersionManager::new();
880
881 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
882 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
883 mgr.register_version(e1).unwrap();
884
885 let good = ModelQualityMetrics::new(0.88, 0.83, 0.83, 0.83, 0.78, 0.22, 500);
887 assert!(mgr.check_quality(&good).is_none());
888
889 let bad = ModelQualityMetrics::new(0.70, 0.65, 0.65, 0.65, 0.5, 0.5, 500);
891 assert!(mgr.check_quality(&bad).is_some());
892
893 let regressed = ModelQualityMetrics::new(0.84, 0.80, 0.80, 0.80, 0.75, 0.25, 500);
895 assert!(mgr.check_quality(®ressed).is_some());
896 }
897
898 #[test]
899 fn version_manager_auto_rollback() {
900 let mut mgr = ModelVersionManager::new();
901
902 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
904 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
905 mgr.register_version(e1).unwrap();
906
907 let m2 = ModelQualityMetrics::new(0.92, 0.87, 0.87, 0.87, 0.82, 0.18, 1000);
908 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
909 mgr.register_version(e2).unwrap();
910
911 let degraded = ModelQualityMetrics::new(0.70, 0.65, 0.65, 0.65, 0.5, 0.5, 500);
913 let rollback = mgr.auto_rollback_if_needed(°raded);
914
915 assert!(rollback.is_some());
916 assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.0.0");
917 }
918
919 #[test]
920 fn version_manager_to_markdown() {
921 let mut mgr = ModelVersionManager::new();
922
923 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
924 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "Initial", "/v1");
925 mgr.register_version(e1).unwrap();
926
927 let md = mgr.to_markdown();
928 assert!(md.contains("Model Version Report"));
929 assert!(md.contains("v1.0.0"));
930 assert!(md.contains("Active"));
931 }
932
933 #[test]
934 fn version_manager_prune_history() {
935 let mut mgr = ModelVersionManager::new().with_max_history(3);
936
937 for i in 0..5 {
939 let m = ModelQualityMetrics::new(
940 0.85 + (i as f64 * 0.01),
941 0.85,
942 0.85,
943 0.85,
944 0.8,
945 0.2,
946 1000,
947 );
948 let e = ModelEntry::new(
949 ModelVersion::new(1, i, 0),
950 m,
951 format!("v1.{}.0", i),
952 format!("/v1.{}.0", i),
953 );
954 mgr.register_version(e).unwrap();
955 }
956
957 assert_eq!(mgr.version_count(), 3);
959 assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.4.0");
961 }
962
963 #[test]
968 fn rollback_history_recorded() {
969 let mut mgr = ModelVersionManager::new();
970
971 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
972 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
973 mgr.register_version(e1).unwrap();
974
975 let m2 = ModelQualityMetrics::new(0.92, 0.87, 0.87, 0.87, 0.82, 0.18, 1000);
976 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
977 mgr.register_version(e2).unwrap();
978
979 mgr.rollback("Test rollback").unwrap();
980
981 assert_eq!(mgr.rollback_history().len(), 1);
982 assert_eq!(mgr.rollback_history()[0].reason, "Test rollback");
983 }
984
985 #[test]
986 fn version_manager_default_trait() {
987 let mgr = ModelVersionManager::default();
988 assert!(mgr.active_version().is_none());
989 assert_eq!(mgr.version_count(), 0);
990 }
991
992 #[test]
997 fn model_version_default() {
998 let v = ModelVersion::default();
999 assert_eq!(v, ModelVersion::new(1, 0, 0));
1000 assert_eq!(v.to_string(), "v1.0.0");
1001 }
1002
1003 #[test]
1004 fn model_version_parse_non_numeric() {
1005 assert_eq!(ModelVersion::parse("a.b.c"), None);
1006 assert_eq!(ModelVersion::parse("1.2.x"), None);
1007 assert_eq!(ModelVersion::parse("1.x.3"), None);
1008 }
1009
1010 #[test]
1011 fn model_version_hash() {
1012 use std::collections::HashSet;
1013 let mut set = HashSet::new();
1014 set.insert(ModelVersion::new(1, 0, 0));
1015 set.insert(ModelVersion::new(1, 0, 0)); assert_eq!(set.len(), 1);
1017 set.insert(ModelVersion::new(1, 0, 1));
1018 assert_eq!(set.len(), 2);
1019 }
1020
1021 #[test]
1026 fn quality_metrics_default() {
1027 let m = ModelQualityMetrics::default();
1028 assert!((m.accuracy - 0.0).abs() < 0.001);
1029 assert!((m.precision - 0.0).abs() < 0.001);
1030 assert!((m.recall - 0.0).abs() < 0.001);
1031 assert!((m.f1_score - 0.0).abs() < 0.001);
1032 assert!((m.avg_confidence - 0.0).abs() < 0.001);
1033 assert!((m.fallback_rate - 1.0).abs() < 0.001);
1034 assert_eq!(m.sample_count, 0);
1035 }
1036
1037 #[test]
1038 fn quality_metrics_is_better_than_f1_tiebreaker() {
1039 let m1 = ModelQualityMetrics::new(0.900, 0.80, 0.80, 0.85, 0.7, 0.3, 1000);
1041 let m2 = ModelQualityMetrics::new(0.905, 0.85, 0.85, 0.90, 0.8, 0.2, 1000);
1042
1043 assert!(m2.is_better_than(&m1)); assert!(!m1.is_better_than(&m2)); }
1047
1048 #[test]
1049 fn quality_metrics_is_better_than_equal_accuracy_equal_f1() {
1050 let m1 = ModelQualityMetrics::new(0.90, 0.80, 0.80, 0.85, 0.7, 0.3, 1000);
1051 let m2 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
1052
1053 assert!(!m1.is_better_than(&m2));
1055 assert!(!m2.is_better_than(&m1));
1056 }
1057
1058 #[test]
1059 fn quality_metrics_meets_thresholds_all_boundaries() {
1060 let t = QualityThresholds {
1061 min_accuracy: 0.85,
1062 min_precision: 0.80,
1063 min_recall: 0.80,
1064 min_f1: 0.80,
1065 };
1066
1067 let exact = ModelQualityMetrics::new(0.85, 0.80, 0.80, 0.80, 0.5, 0.5, 100);
1069 assert!(exact.meets_thresholds(&t));
1070
1071 let bad_prec = ModelQualityMetrics::new(0.90, 0.79, 0.85, 0.85, 0.5, 0.5, 100);
1073 assert!(!bad_prec.meets_thresholds(&t));
1074
1075 let bad_recall = ModelQualityMetrics::new(0.90, 0.85, 0.79, 0.85, 0.5, 0.5, 100);
1077 assert!(!bad_recall.meets_thresholds(&t));
1078
1079 let bad_f1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.79, 0.5, 0.5, 100);
1081 assert!(!bad_f1.meets_thresholds(&t));
1082 }
1083
1084 #[test]
1089 fn rollback_no_active_version() {
1090 let mut mgr = ModelVersionManager::new();
1091
1092 let m1 = ModelQualityMetrics::new(0.70, 0.65, 0.65, 0.65, 0.5, 0.5, 100);
1094 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1095 mgr.register_version(e1).unwrap();
1096
1097 let m2 = ModelQualityMetrics::new(0.71, 0.66, 0.66, 0.66, 0.5, 0.5, 100);
1098 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
1099 mgr.register_version(e2).unwrap();
1100
1101 assert_eq!(mgr.version_count(), 2);
1102 assert!(mgr.active_version().is_none());
1103
1104 let result = mgr.rollback("No active version");
1105 assert!(result.is_err());
1106 assert!(result.unwrap_err().contains("No active version"));
1107 }
1108
1109 #[test]
1110 fn rollback_to_not_found() {
1111 let mut mgr = ModelVersionManager::new();
1112
1113 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
1114 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1115 mgr.register_version(e1).unwrap();
1116
1117 let result = mgr.rollback_to(&ModelVersion::new(9, 9, 9), "Not found");
1118 assert!(result.is_err());
1119 assert!(result.unwrap_err().contains("not found"));
1120 }
1121
1122 #[test]
1123 fn rollback_to_already_active() {
1124 let mut mgr = ModelVersionManager::new();
1125
1126 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
1127 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1128 mgr.register_version(e1).unwrap();
1129
1130 let result = mgr.rollback_to(&ModelVersion::new(1, 0, 0), "Already active");
1131 assert!(result.is_err());
1132 assert!(result.unwrap_err().contains("already the active version"));
1133 }
1134
1135 #[test]
1136 fn rollback_to_no_active_version() {
1137 let mut mgr = ModelVersionManager::new();
1138
1139 let m1 = ModelQualityMetrics::new(0.70, 0.65, 0.65, 0.65, 0.5, 0.5, 100);
1141 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1142 mgr.register_version(e1).unwrap();
1143
1144 let result = mgr.rollback_to(&ModelVersion::new(1, 0, 0), "No active");
1145 assert!(result.is_err());
1146 assert!(result.unwrap_err().contains("No active version"));
1147 }
1148
1149 #[test]
1154 fn to_markdown_no_active_version() {
1155 let mgr = ModelVersionManager::new();
1156 let md = mgr.to_markdown();
1157 assert!(md.contains("**Active Version**: None"));
1158 }
1159
1160 #[test]
1161 fn to_markdown_with_rolled_back_and_available_status() {
1162 let mut mgr = ModelVersionManager::new();
1163
1164 let m1 = ModelQualityMetrics::new(0.86, 0.82, 0.82, 0.82, 0.7, 0.3, 1000);
1166 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1167 mgr.register_version(e1).unwrap();
1168
1169 let m2 = ModelQualityMetrics::new(0.88, 0.84, 0.84, 0.84, 0.75, 0.25, 1000);
1170 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
1171 mgr.register_version(e2).unwrap();
1172
1173 let m3 = ModelQualityMetrics::new(0.90, 0.86, 0.86, 0.86, 0.8, 0.2, 1000);
1174 let e3 = ModelEntry::new(ModelVersion::new(1, 2, 0), m3, "v1.2", "/v1.2");
1175 mgr.register_version(e3).unwrap();
1176
1177 mgr.rollback_to(&ModelVersion::new(1, 0, 0), "Quality issue").unwrap();
1179
1180 let md = mgr.to_markdown();
1181 assert!(md.contains("Active"));
1183 assert!(md.contains("Rolled Back"));
1184 assert!(md.contains("Available"));
1185 assert!(md.contains("Rollback History"));
1187 assert!(md.contains("Quality issue"));
1188 }
1189
1190 #[test]
1195 fn prune_history_active_at_index_zero_breaks() {
1196 let mut mgr = ModelVersionManager::new().with_max_history(2);
1197
1198 let m1 = ModelQualityMetrics::new(0.92, 0.88, 0.88, 0.88, 0.85, 0.15, 1000);
1200 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1201 mgr.register_version(e1).unwrap();
1202
1203 let m2 = ModelQualityMetrics::new(0.94, 0.90, 0.90, 0.90, 0.87, 0.13, 1000);
1205 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
1206 mgr.register_version(e2).unwrap();
1207
1208 mgr.rollback("test").unwrap();
1210 assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.0.0");
1211
1212 let m3 = ModelQualityMetrics::new(0.86, 0.82, 0.82, 0.82, 0.75, 0.25, 1000);
1214 let e3 = ModelEntry::new(ModelVersion::new(1, 2, 0), m3, "v1.2", "/v1.2");
1215 mgr.register_version(e3).unwrap();
1216
1217 assert!(mgr.version_count() >= 2);
1220 assert!(mgr.active_version().is_some());
1222 }
1223
1224 #[test]
1229 fn chrono_lite_format_recent() {
1230 let result = chrono_lite_format(0);
1231 assert_eq!(result, "recent");
1232 }
1233
1234 #[test]
1235 fn chrono_lite_format_hours() {
1236 let two_hours_ms = 2 * 3600 * 1000;
1238 let result = chrono_lite_format(two_hours_ms);
1239 assert_eq!(result, "2h ago");
1240 }
1241
1242 #[test]
1243 fn chrono_lite_format_days() {
1244 let three_days_ms = 3 * 86400 * 1000;
1246 let result = chrono_lite_format(three_days_ms);
1247 assert_eq!(result, "3d ago");
1248 }
1249
1250 #[test]
1255 fn version_manager_with_thresholds() {
1256 let t = QualityThresholds {
1257 min_accuracy: 0.95,
1258 min_precision: 0.90,
1259 min_recall: 0.90,
1260 min_f1: 0.90,
1261 };
1262 let mgr = ModelVersionManager::with_thresholds(t);
1263 assert!((mgr.thresholds().min_accuracy - 0.95).abs() < 0.001);
1264 assert!((mgr.thresholds().min_precision - 0.90).abs() < 0.001);
1265 }
1266
1267 #[test]
1268 fn version_manager_with_max_history_minimum() {
1269 let mgr = ModelVersionManager::new().with_max_history(1);
1271 let mut mgr = mgr;
1273 for i in 0..3 {
1274 let m = ModelQualityMetrics::new(
1275 0.85 + (i as f64 * 0.02),
1276 0.85,
1277 0.85,
1278 0.85,
1279 0.8,
1280 0.2,
1281 1000,
1282 );
1283 let e = ModelEntry::new(
1284 ModelVersion::new(1, i, 0),
1285 m,
1286 format!("v1.{}", i),
1287 format!("/v1.{}", i),
1288 );
1289 mgr.register_version(e).unwrap();
1290 }
1291 assert_eq!(mgr.version_count(), 2);
1292 }
1293
1294 #[test]
1295 fn version_manager_versions_iterator() {
1296 let mut mgr = ModelVersionManager::new();
1297
1298 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
1299 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1300 mgr.register_version(e1).unwrap();
1301
1302 let m2 = ModelQualityMetrics::new(0.92, 0.87, 0.87, 0.87, 0.82, 0.18, 1000);
1303 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
1304 mgr.register_version(e2).unwrap();
1305
1306 let versions: Vec<_> = mgr.versions().collect();
1307 assert_eq!(versions.len(), 2);
1308 assert_eq!(versions[0].version.to_string(), "v1.0.0");
1309 assert_eq!(versions[1].version.to_string(), "v1.1.0");
1310 }
1311
1312 #[test]
1317 fn auto_rollback_not_needed_with_good_metrics() {
1318 let mut mgr = ModelVersionManager::new();
1319
1320 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
1321 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1322 mgr.register_version(e1).unwrap();
1323
1324 let m2 = ModelQualityMetrics::new(0.92, 0.87, 0.87, 0.87, 0.82, 0.18, 1000);
1325 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
1326 mgr.register_version(e2).unwrap();
1327
1328 let good = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 500);
1329 let result = mgr.auto_rollback_if_needed(&good);
1330 assert!(result.is_none());
1331 assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.1.0");
1332 }
1333
1334 #[test]
1339 fn check_quality_accuracy_regression_message() {
1340 let mut mgr = ModelVersionManager::new();
1341
1342 let m1 = ModelQualityMetrics::new(0.95, 0.90, 0.90, 0.90, 0.85, 0.15, 1000);
1343 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1344 mgr.register_version(e1).unwrap();
1345
1346 let regressed = ModelQualityMetrics::new(0.88, 0.85, 0.85, 0.85, 0.8, 0.2, 500);
1348 let reason = mgr.check_quality(®ressed);
1349 assert!(reason.is_some());
1350 let msg = reason.unwrap();
1351 assert!(msg.contains("Accuracy regression"));
1352 assert!(msg.contains(">5% drop"));
1353 }
1354
1355 #[test]
1356 fn check_quality_no_active_version() {
1357 let mgr = ModelVersionManager::new();
1358 let good = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 500);
1360 assert!(mgr.check_quality(&good).is_none());
1361 }
1362
1363 #[test]
1368 fn model_entry_new_defaults() {
1369 let m = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
1370 let entry = ModelEntry::new(ModelVersion::new(1, 0, 0), m, "test", "/path");
1371
1372 assert_eq!(entry.description, "test");
1373 assert_eq!(entry.artifact_path, "/path");
1374 assert!(!entry.is_active);
1375 assert!(!entry.rolled_back);
1376 assert!(entry.rollback_reason.is_none());
1377 assert!(entry.released_at > 0);
1378 }
1379
1380 #[test]
1385 fn rollback_marks_entry_as_rolled_back() {
1386 let mut mgr = ModelVersionManager::new();
1387
1388 let m1 = ModelQualityMetrics::new(0.90, 0.85, 0.85, 0.85, 0.8, 0.2, 1000);
1389 let e1 = ModelEntry::new(ModelVersion::new(1, 0, 0), m1, "v1", "/v1");
1390 mgr.register_version(e1).unwrap();
1391
1392 let m2 = ModelQualityMetrics::new(0.92, 0.87, 0.87, 0.87, 0.82, 0.18, 1000);
1393 let e2 = ModelEntry::new(ModelVersion::new(1, 1, 0), m2, "v1.1", "/v1.1");
1394 mgr.register_version(e2).unwrap();
1395
1396 mgr.rollback("Bad quality").unwrap();
1397
1398 let versions: Vec<_> = mgr.versions().collect();
1400 let v110 = versions.iter().find(|v| v.version.to_string() == "v1.1.0").unwrap();
1401 assert!(v110.rolled_back);
1402 assert_eq!(v110.rollback_reason.as_deref(), Some("Bad quality"));
1403 assert!(!v110.is_active);
1404 }
1405
1406 #[test]
1407 fn rollback_to_clears_previous_rollback_flag() {
1408 let mut mgr = ModelVersionManager::new();
1409
1410 for i in 0..3 {
1412 let m = ModelQualityMetrics::new(
1413 0.86 + (i as f64 * 0.02),
1414 0.82,
1415 0.82,
1416 0.82,
1417 0.75,
1418 0.25,
1419 1000,
1420 );
1421 let e = ModelEntry::new(
1422 ModelVersion::new(1, i, 0),
1423 m,
1424 format!("v1.{}", i),
1425 format!("/v1.{}", i),
1426 );
1427 mgr.register_version(e).unwrap();
1428 }
1429
1430 mgr.rollback_to(&ModelVersion::new(1, 0, 0), "first rollback").unwrap();
1432
1433 let active = mgr.active_version().unwrap();
1435 assert!(!active.rolled_back);
1436 assert!(active.is_active);
1437
1438 assert_eq!(mgr.rollback_history().len(), 1);
1440 }
1441}