Skip to main content

decy_ownership/
model_versioning.rs

1//! Model versioning and rollback for ML-enhanced ownership inference (DECY-ML-017).
2//!
3//! Provides version management for ML models with rollback capability
4//! following Toyota Way's Jidoka principle (stop the line on quality issues).
5//!
6//! # Architecture
7//!
8//! ```text
9//! ┌─────────────────────────────────────────────────────────────────┐
10//! │                   MODEL VERSION MANAGER                         │
11//! │                                                                 │
12//! │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐             │
13//! │  │  v1.0.0     │  │  v1.1.0     │  │  v1.2.0     │  ← current  │
14//! │  │  (baseline) │  │  (improved) │  │  (latest)   │             │
15//! │  └─────────────┘  └─────────────┘  └─────────────┘             │
16//! │        ▲                ▲                ▲                      │
17//! │        │                │                │                      │
18//! │        └────────────────┴────────────────┘                      │
19//! │                         │                                       │
20//! │                    ROLLBACK                                     │
21//! │              (if quality degrades)                              │
22//! └─────────────────────────────────────────────────────────────────┘
23//! ```
24//!
25//! # Jidoka (Stop the Line)
26//!
27//! If any quality metric degrades below threshold:
28//! 1. Automatic rollback to previous version
29//! 2. Alert generated for investigation
30//! 3. Root cause documented before resuming
31
32use serde::{Deserialize, Serialize};
33use std::collections::VecDeque;
34
35/// Semantic version for models.
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37pub struct ModelVersion {
38    /// Major version (breaking changes)
39    pub major: u32,
40    /// Minor version (new features)
41    pub minor: u32,
42    /// Patch version (bug fixes)
43    pub patch: u32,
44}
45
46impl ModelVersion {
47    /// Create a new version.
48    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
49        Self {
50            major,
51            minor,
52            patch,
53        }
54    }
55
56    /// Increment major version (resets minor and patch).
57    pub fn bump_major(&self) -> Self {
58        Self::new(self.major + 1, 0, 0)
59    }
60
61    /// Increment minor version (resets patch).
62    pub fn bump_minor(&self) -> Self {
63        Self::new(self.major, self.minor + 1, 0)
64    }
65
66    /// Increment patch version.
67    pub fn bump_patch(&self) -> Self {
68        Self::new(self.major, self.minor, self.patch + 1)
69    }
70
71    /// Parse from string (e.g., "1.2.3").
72    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/// Quality metrics for a model version.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ModelQualityMetrics {
101    /// Classification accuracy (0.0 - 1.0)
102    pub accuracy: f64,
103    /// Precision (0.0 - 1.0)
104    pub precision: f64,
105    /// Recall (0.0 - 1.0)
106    pub recall: f64,
107    /// F1 score (0.0 - 1.0)
108    pub f1_score: f64,
109    /// Average confidence score
110    pub avg_confidence: f64,
111    /// Fallback rate (0.0 - 1.0)
112    pub fallback_rate: f64,
113    /// Number of validation samples
114    pub sample_count: u64,
115}
116
117impl ModelQualityMetrics {
118    /// Create new metrics.
119    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    /// Check if metrics meet minimum thresholds.
140    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    /// Check if this version is better than another.
148    pub fn is_better_than(&self, other: &Self) -> bool {
149        // Primary: accuracy, secondary: F1
150        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/// Quality thresholds for model acceptance.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct QualityThresholds {
167    /// Minimum accuracy required
168    pub min_accuracy: f64,
169    /// Minimum precision required
170    pub min_precision: f64,
171    /// Minimum recall required
172    pub min_recall: f64,
173    /// Minimum F1 score required
174    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/// A versioned model entry.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ModelEntry {
191    /// Version identifier
192    pub version: ModelVersion,
193    /// Quality metrics at release
194    pub metrics: ModelQualityMetrics,
195    /// Release timestamp (Unix millis)
196    pub released_at: u64,
197    /// Optional description
198    pub description: String,
199    /// Model artifact path/identifier
200    pub artifact_path: String,
201    /// Is this the current active version?
202    pub is_active: bool,
203    /// Was this version rolled back?
204    pub rolled_back: bool,
205    /// Rollback reason (if applicable)
206    pub rollback_reason: Option<String>,
207}
208
209impl ModelEntry {
210    /// Create a new model entry.
211    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/// Result of a rollback operation.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct RollbackResult {
238    /// Whether rollback succeeded
239    pub success: bool,
240    /// Previous version (rolled back from)
241    pub from_version: ModelVersion,
242    /// New active version (rolled back to)
243    pub to_version: ModelVersion,
244    /// Reason for rollback
245    pub reason: String,
246    /// Timestamp
247    pub timestamp: u64,
248}
249
250/// Model version manager with rollback capability.
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct ModelVersionManager {
253    /// Version history (newest last)
254    versions: VecDeque<ModelEntry>,
255    /// Current active version index
256    active_index: Option<usize>,
257    /// Quality thresholds
258    thresholds: QualityThresholds,
259    /// Maximum versions to retain
260    max_history: usize,
261    /// Rollback history
262    rollback_history: Vec<RollbackResult>,
263}
264
265impl Default for ModelVersionManager {
266    fn default() -> Self {
267        Self::new()
268    }
269}
270
271impl ModelVersionManager {
272    /// Create a new version manager.
273    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    /// Create with custom thresholds.
284    pub fn with_thresholds(thresholds: QualityThresholds) -> Self {
285        Self {
286            thresholds,
287            ..Self::new()
288        }
289    }
290
291    /// Set maximum history size.
292    pub fn with_max_history(mut self, max: usize) -> Self {
293        self.max_history = max.max(2); // Keep at least 2 for rollback
294        self
295    }
296
297    /// Get current active version.
298    pub fn active_version(&self) -> Option<&ModelEntry> {
299        self.active_index.and_then(|i| self.versions.get(i))
300    }
301
302    /// Get all versions.
303    pub fn versions(&self) -> impl Iterator<Item = &ModelEntry> {
304        self.versions.iter()
305    }
306
307    /// Get version count.
308    pub fn version_count(&self) -> usize {
309        self.versions.len()
310    }
311
312    /// Get quality thresholds.
313    pub fn thresholds(&self) -> &QualityThresholds {
314        &self.thresholds
315    }
316
317    /// Register a new model version.
318    ///
319    /// Returns Ok(true) if version was activated, Ok(false) if registered but not activated
320    /// (due to quality issues), or Err if registration failed.
321    pub fn register_version(&mut self, mut entry: ModelEntry) -> Result<bool, String> {
322        // Validate version is newer
323        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        // Check quality thresholds
333        let meets_quality = entry.metrics.meets_thresholds(&self.thresholds);
334
335        // Check if better than current active
336        let is_better = self
337            .active_version()
338            .map(|active| entry.metrics.is_better_than(&active.metrics))
339            .unwrap_or(true);
340
341        // Decide whether to activate
342        let should_activate = meets_quality && is_better;
343
344        if should_activate {
345            // Deactivate current
346            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            // Activate new
353            entry.is_active = true;
354            self.versions.push_back(entry);
355            self.active_index = Some(self.versions.len() - 1);
356        } else {
357            // Register but don't activate
358            entry.is_active = false;
359            self.versions.push_back(entry);
360        }
361
362        // Prune old versions
363        self.prune_history();
364
365        Ok(should_activate)
366    }
367
368    /// Rollback to the previous version.
369    pub fn rollback(&mut self, reason: impl Into<String>) -> Result<RollbackResult, String> {
370        let reason = reason.into();
371
372        // Need at least 2 versions to rollback
373        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        // Find previous non-rolled-back version
381        let prev_idx = self
382            .versions
383            .iter()
384            .enumerate()
385            .rev()
386            .skip(1) // Skip current
387            .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        // Mark current as rolled back
394        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        // Activate previous
401        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    /// Rollback to a specific version.
425    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        // Mark current as rolled back
447        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        // Activate target
454        if let Some(target_entry) = self.versions.get_mut(target_idx) {
455            target_entry.is_active = true;
456            target_entry.rolled_back = false; // Clear previous rollback if any
457        }
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    /// Get rollback history.
479    pub fn rollback_history(&self) -> &[RollbackResult] {
480        &self.rollback_history
481    }
482
483    /// Check if current model needs rollback based on new metrics.
484    ///
485    /// Implements Jidoka (stop the line) principle.
486    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        // Compare with previous version (should not regress)
495        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    /// Auto-rollback if quality check fails.
508    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    /// Generate markdown report.
520    pub fn to_markdown(&self) -> String {
521        let mut report = String::from("## Model Version Report\n\n");
522
523        // Active version
524        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        // Version history
536        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            // Format timestamp
550            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        // Rollback history
563        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            // Don't remove active version
579            if self.active_index == Some(0) {
580                break;
581            }
582
583            self.versions.pop_front();
584
585            // Adjust active index
586            if let Some(idx) = self.active_index {
587                if idx > 0 {
588                    self.active_index = Some(idx - 1);
589                }
590            }
591        }
592    }
593}
594
595/// Simple timestamp formatter (no chrono dependency).
596fn chrono_lite_format(millis: u64) -> String {
597    // Simple: just show as relative or ISO-ish
598    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    // ========================================================================
617    // ModelVersion tests
618    // ========================================================================
619
620    #[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    // ========================================================================
669    // ModelQualityMetrics tests
670    // ========================================================================
671
672    #[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        // Good metrics
691        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        // Bad accuracy
695        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    // ========================================================================
709    // ModelVersionManager tests
710    // ========================================================================
711
712    #[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()); // Should be activated
734
735        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        // Register v1.0.0
745        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        // Register better v1.1.0
750        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); // Should activate better version
755        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        // Register v1.0.0 with good metrics
763        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        // Register worse v1.1.0
768        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); // Should NOT activate worse version
773        assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.0.0");
774        assert_eq!(mgr.version_count(), 2); // But still registered
775    }
776
777    #[test]
778    fn version_manager_register_below_threshold() {
779        let mut mgr = ModelVersionManager::new();
780
781        // Register version below threshold
782        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); // Below threshold, not activated
787    }
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        // Try to register older version
798        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        // Register v1.0.0
809        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        // Register v1.1.0
814        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        // Rollback
821        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        // Register 3 versions
849        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        // Rollback to v1.0.0
871        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        // Good metrics - no issue
886        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        // Bad metrics - below threshold
890        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        // Regression - accuracy dropped >5%
894        let regressed = ModelQualityMetrics::new(0.84, 0.80, 0.80, 0.80, 0.75, 0.25, 500);
895        assert!(mgr.check_quality(&regressed).is_some());
896    }
897
898    #[test]
899    fn version_manager_auto_rollback() {
900        let mut mgr = ModelVersionManager::new();
901
902        // Register two versions
903        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        // Simulate degraded metrics
912        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(&degraded);
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        // Register 5 versions
938        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        // Should only have 3 versions
958        assert_eq!(mgr.version_count(), 3);
959        // Active should still be latest
960        assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.4.0");
961    }
962
963    // ========================================================================
964    // RollbackResult tests
965    // ========================================================================
966
967    #[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    // ========================================================================
993    // Additional coverage: ModelVersion::default and parse edge cases
994    // ========================================================================
995
996    #[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)); // duplicate
1016        assert_eq!(set.len(), 1);
1017        set.insert(ModelVersion::new(1, 0, 1));
1018        assert_eq!(set.len(), 2);
1019    }
1020
1021    // ========================================================================
1022    // Additional coverage: ModelQualityMetrics defaults and edge cases
1023    // ========================================================================
1024
1025    #[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        // Accuracy within 0.01 — should use F1 tiebreaker
1040        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        // Accuracy diff = 0.005 <= 0.01, so F1 decides
1044        assert!(m2.is_better_than(&m1)); // m2 has higher F1
1045        assert!(!m1.is_better_than(&m2)); // m1 has lower F1
1046    }
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        // Same accuracy, same F1 — neither is better
1054        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        // Exactly at thresholds — should pass
1068        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        // Below precision
1072        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        // Below recall
1076        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        // Below F1
1080        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    // ========================================================================
1085    // Additional coverage: rollback error paths
1086    // ========================================================================
1087
1088    #[test]
1089    fn rollback_no_active_version() {
1090        let mut mgr = ModelVersionManager::new();
1091
1092        // Register 2 versions below threshold (neither activated)
1093        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        // Register below threshold
1140        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    // ========================================================================
1150    // Additional coverage: to_markdown with all status variants + rollback history
1151    // ========================================================================
1152
1153    #[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        // Register 3 versions
1165        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        // Rollback to v1.0.0 — marks v1.2.0 as rolled back
1178        mgr.rollback_to(&ModelVersion::new(1, 0, 0), "Quality issue").unwrap();
1179
1180        let md = mgr.to_markdown();
1181        // v1.0.0 should be Active, v1.2.0 should be Rolled Back, v1.1.0 should be Available
1182        assert!(md.contains("Active"));
1183        assert!(md.contains("Rolled Back"));
1184        assert!(md.contains("Available"));
1185        // Rollback history section
1186        assert!(md.contains("Rollback History"));
1187        assert!(md.contains("Quality issue"));
1188    }
1189
1190    // ========================================================================
1191    // Additional coverage: prune_history with active at index 0
1192    // ========================================================================
1193
1194    #[test]
1195    fn prune_history_active_at_index_zero_breaks() {
1196        let mut mgr = ModelVersionManager::new().with_max_history(2);
1197
1198        // Register v1.0.0 (activated)
1199        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        // Register v1.1.0 (activated, better)
1204        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        // Rollback to v1.0.0 (now active is at index 0)
1209        mgr.rollback("test").unwrap();
1210        assert_eq!(mgr.active_version().unwrap().version.to_string(), "v1.0.0");
1211
1212        // Register v1.2.0 (not activated because worse than v1.0.0=0.92)
1213        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        // Now we have 3 versions but max_history=2
1218        // prune should break because active_index == Some(0)
1219        assert!(mgr.version_count() >= 2);
1220        // Active is still valid
1221        assert!(mgr.active_version().is_some());
1222    }
1223
1224    // ========================================================================
1225    // Additional coverage: chrono_lite_format branches
1226    // ========================================================================
1227
1228    #[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        // 2 hours in millis
1237        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        // 3 days in millis
1245        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    // ========================================================================
1251    // Additional coverage: with_thresholds and accessors
1252    // ========================================================================
1253
1254    #[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        // Setting max_history to 1 should be clamped to 2
1270        let mgr = ModelVersionManager::new().with_max_history(1);
1271        // Register 3 versions — should keep at least 2
1272        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    // ========================================================================
1313    // Additional coverage: auto_rollback_if_needed with good metrics
1314    // ========================================================================
1315
1316    #[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    // ========================================================================
1335    // Additional coverage: check_quality accuracy regression message
1336    // ========================================================================
1337
1338    #[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        // >5% regression: 0.95 → 0.88 = 7% drop (but 0.88 still meets default threshold 0.85)
1347        let regressed = ModelQualityMetrics::new(0.88, 0.85, 0.85, 0.85, 0.8, 0.2, 500);
1348        let reason = mgr.check_quality(&regressed);
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        // No active version, but metrics meet threshold
1359        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    // ========================================================================
1364    // Additional coverage: ModelEntry fields
1365    // ========================================================================
1366
1367    #[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    // ========================================================================
1381    // Additional coverage: rollback marks entry correctly
1382    // ========================================================================
1383
1384    #[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        // v1.1.0 should be marked as rolled back with reason
1399        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        // Register 3 versions
1411        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        // Rollback v1.2.0 → v1.0.0
1431        mgr.rollback_to(&ModelVersion::new(1, 0, 0), "first rollback").unwrap();
1432
1433        // v1.0.0 should have rolled_back cleared and is_active true
1434        let active = mgr.active_version().unwrap();
1435        assert!(!active.rolled_back);
1436        assert!(active.is_active);
1437
1438        // Rollback history should have entry
1439        assert_eq!(mgr.rollback_history().len(), 1);
1440    }
1441}