Skip to main content

pacha/model/
version.rs

1//! Semantic versioning for ML models.
2//!
3//! Follows Semantic Versioning 2.0.0 with ML-specific semantics:
4//! - MAJOR: Architecture change (incompatible inputs/outputs)
5//! - MINOR: Retraining with new data (backward compatible)
6//! - PATCH: Bug fixes, quantization, optimization
7
8use crate::error::{PachaError, Result};
9use serde::{Deserialize, Serialize};
10use std::cmp::Ordering;
11use std::fmt;
12use std::str::FromStr;
13
14/// Semantic version for a model.
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct ModelVersion {
17    /// Major version (architecture changes).
18    pub major: u32,
19    /// Minor version (retraining).
20    pub minor: u32,
21    /// Patch version (optimizations).
22    pub patch: u32,
23    /// Optional pre-release identifier (e.g., "beta.1").
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub prerelease: Option<String>,
26    /// Build metadata (e.g., training run ID).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub build: Option<String>,
29}
30
31impl ModelVersion {
32    /// Create a new version.
33    #[must_use]
34    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
35        Self { major, minor, patch, prerelease: None, build: None }
36    }
37
38    /// Create version 0.0.0.
39    #[must_use]
40    pub fn zero() -> Self {
41        Self::new(0, 0, 0)
42    }
43
44    /// Create version 1.0.0.
45    #[must_use]
46    pub fn initial() -> Self {
47        Self::new(1, 0, 0)
48    }
49
50    /// Set pre-release identifier.
51    #[must_use]
52    pub fn with_prerelease(mut self, prerelease: impl Into<String>) -> Self {
53        self.prerelease = Some(prerelease.into());
54        self
55    }
56
57    /// Set build metadata.
58    #[must_use]
59    pub fn with_build(mut self, build: impl Into<String>) -> Self {
60        self.build = Some(build.into());
61        self
62    }
63
64    /// Increment major version (resets minor and patch).
65    #[must_use]
66    pub fn bump_major(&self) -> Self {
67        Self::new(self.major + 1, 0, 0)
68    }
69
70    /// Increment minor version (resets patch).
71    #[must_use]
72    pub fn bump_minor(&self) -> Self {
73        Self::new(self.major, self.minor + 1, 0)
74    }
75
76    /// Increment patch version.
77    #[must_use]
78    pub fn bump_patch(&self) -> Self {
79        Self::new(self.major, self.minor, self.patch + 1)
80    }
81
82    /// Check if this is a pre-release version.
83    #[must_use]
84    pub fn is_prerelease(&self) -> bool {
85        self.prerelease.is_some()
86    }
87
88    /// Check if this is a stable version (>= 1.0.0, no prerelease).
89    #[must_use]
90    pub fn is_stable(&self) -> bool {
91        self.major >= 1 && self.prerelease.is_none()
92    }
93
94    /// Parse a version string.
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if the string is not a valid semantic version.
99    pub fn parse(s: &str) -> Result<Self> {
100        s.parse()
101    }
102}
103
104impl Default for ModelVersion {
105    fn default() -> Self {
106        Self::initial()
107    }
108}
109
110impl fmt::Display for ModelVersion {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
113        if let Some(ref pre) = self.prerelease {
114            write!(f, "-{pre}")?;
115        }
116        if let Some(ref build) = self.build {
117            write!(f, "+{build}")?;
118        }
119        Ok(())
120    }
121}
122
123impl FromStr for ModelVersion {
124    type Err = PachaError;
125
126    fn from_str(s: &str) -> Result<Self> {
127        // Split off build metadata first (after +)
128        let (version_pre, build) = match s.split_once('+') {
129            Some((v, b)) => (v, Some(b.to_string())),
130            None => (s, None),
131        };
132
133        // Split off prerelease (after -)
134        let (version, prerelease) = match version_pre.split_once('-') {
135            Some((v, p)) => (v, Some(p.to_string())),
136            None => (version_pre, None),
137        };
138
139        // Parse major.minor.patch
140        let parts: Vec<&str> = version.split('.').collect();
141        if parts.len() != 3 {
142            return Err(PachaError::InvalidVersion(format!(
143                "expected MAJOR.MINOR.PATCH, got '{s}'"
144            )));
145        }
146
147        let major = parts[0]
148            .parse::<u32>()
149            .map_err(|_| PachaError::InvalidVersion(format!("invalid major version in '{s}'")))?;
150        let minor = parts[1]
151            .parse::<u32>()
152            .map_err(|_| PachaError::InvalidVersion(format!("invalid minor version in '{s}'")))?;
153        let patch = parts[2]
154            .parse::<u32>()
155            .map_err(|_| PachaError::InvalidVersion(format!("invalid patch version in '{s}'")))?;
156
157        Ok(Self { major, minor, patch, prerelease, build })
158    }
159}
160
161impl PartialOrd for ModelVersion {
162    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
163        Some(self.cmp(other))
164    }
165}
166
167impl Ord for ModelVersion {
168    fn cmp(&self, other: &Self) -> Ordering {
169        // Compare major, minor, patch
170        match self.major.cmp(&other.major) {
171            Ordering::Equal => {}
172            ord => return ord,
173        }
174        match self.minor.cmp(&other.minor) {
175            Ordering::Equal => {}
176            ord => return ord,
177        }
178        match self.patch.cmp(&other.patch) {
179            Ordering::Equal => {}
180            ord => return ord,
181        }
182
183        // Pre-release versions have lower precedence
184        match (&self.prerelease, &other.prerelease) {
185            (None, None) => Ordering::Equal,
186            (Some(_), None) => Ordering::Less,
187            (None, Some(_)) => Ordering::Greater,
188            (Some(a), Some(b)) => a.cmp(b),
189        }
190        // Build metadata is ignored for precedence
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use proptest::prelude::*;
198
199    #[test]
200    fn test_version_new() {
201        let v = ModelVersion::new(1, 2, 3);
202        assert_eq!(v.major, 1);
203        assert_eq!(v.minor, 2);
204        assert_eq!(v.patch, 3);
205        assert!(v.prerelease.is_none());
206        assert!(v.build.is_none());
207    }
208
209    #[test]
210    fn test_version_display() {
211        assert_eq!(ModelVersion::new(1, 2, 3).to_string(), "1.2.3");
212        assert_eq!(
213            ModelVersion::new(1, 0, 0).with_prerelease("beta.1").to_string(),
214            "1.0.0-beta.1"
215        );
216        assert_eq!(ModelVersion::new(1, 0, 0).with_build("run-123").to_string(), "1.0.0+run-123");
217        assert_eq!(
218            ModelVersion::new(1, 0, 0).with_prerelease("rc.1").with_build("abc").to_string(),
219            "1.0.0-rc.1+abc"
220        );
221    }
222
223    #[test]
224    fn test_version_parse() {
225        assert_eq!("1.2.3".parse::<ModelVersion>().unwrap(), ModelVersion::new(1, 2, 3));
226        assert_eq!("0.0.0".parse::<ModelVersion>().unwrap(), ModelVersion::zero());
227
228        let with_pre: ModelVersion = "1.0.0-beta.1".parse().unwrap();
229        assert_eq!(with_pre.prerelease, Some("beta.1".to_string()));
230
231        let with_build: ModelVersion = "1.0.0+run-123".parse().unwrap();
232        assert_eq!(with_build.build, Some("run-123".to_string()));
233
234        let full: ModelVersion = "2.1.0-rc.1+build.456".parse().unwrap();
235        assert_eq!(full.major, 2);
236        assert_eq!(full.minor, 1);
237        assert_eq!(full.patch, 0);
238        assert_eq!(full.prerelease, Some("rc.1".to_string()));
239        assert_eq!(full.build, Some("build.456".to_string()));
240    }
241
242    #[test]
243    fn test_version_parse_errors() {
244        assert!("1.2".parse::<ModelVersion>().is_err());
245        assert!("1.2.3.4".parse::<ModelVersion>().is_err());
246        assert!("a.b.c".parse::<ModelVersion>().is_err());
247        assert!("1.2.three".parse::<ModelVersion>().is_err());
248    }
249
250    #[test]
251    fn test_version_bump() {
252        let v = ModelVersion::new(1, 2, 3);
253
254        assert_eq!(v.bump_major(), ModelVersion::new(2, 0, 0));
255        assert_eq!(v.bump_minor(), ModelVersion::new(1, 3, 0));
256        assert_eq!(v.bump_patch(), ModelVersion::new(1, 2, 4));
257    }
258
259    #[test]
260    fn test_version_ordering() {
261        let v100 = ModelVersion::new(1, 0, 0);
262        let v110 = ModelVersion::new(1, 1, 0);
263        let v111 = ModelVersion::new(1, 1, 1);
264        let v200 = ModelVersion::new(2, 0, 0);
265
266        assert!(v100 < v110);
267        assert!(v110 < v111);
268        assert!(v111 < v200);
269    }
270
271    #[test]
272    fn test_prerelease_ordering() {
273        let stable = ModelVersion::new(1, 0, 0);
274        let beta = ModelVersion::new(1, 0, 0).with_prerelease("beta");
275        let alpha = ModelVersion::new(1, 0, 0).with_prerelease("alpha");
276
277        // Pre-release < stable
278        assert!(beta < stable);
279        assert!(alpha < stable);
280        // Alphabetic ordering for pre-releases
281        assert!(alpha < beta);
282    }
283
284    #[test]
285    fn test_is_stable() {
286        assert!(ModelVersion::new(1, 0, 0).is_stable());
287        assert!(ModelVersion::new(2, 5, 3).is_stable());
288        assert!(!ModelVersion::new(0, 9, 0).is_stable());
289        assert!(!ModelVersion::new(1, 0, 0).with_prerelease("beta").is_stable());
290    }
291
292    #[test]
293    fn test_serialization() {
294        let v = ModelVersion::new(1, 2, 3).with_prerelease("rc.1");
295        let json = serde_json::to_string(&v).unwrap();
296        let deserialized: ModelVersion = serde_json::from_str(&json).unwrap();
297        assert_eq!(v, deserialized);
298    }
299
300    // Property-based tests
301    proptest! {
302        #[test]
303        fn prop_version_roundtrip(major: u32, minor: u32, patch: u32) {
304            let v = ModelVersion::new(major, minor, patch);
305            let s = v.to_string();
306            let parsed: ModelVersion = s.parse().unwrap();
307            prop_assert_eq!(v, parsed);
308        }
309
310        #[test]
311        fn prop_bump_major_resets(major in 0u32..1000, minor in 0u32..1000, patch in 0u32..1000) {
312            let v = ModelVersion::new(major, minor, patch);
313            let bumped = v.bump_major();
314            prop_assert_eq!(bumped.major, major + 1);
315            prop_assert_eq!(bumped.minor, 0);
316            prop_assert_eq!(bumped.patch, 0);
317        }
318
319        #[test]
320        fn prop_bump_minor_resets_patch(major in 0u32..1000, minor in 0u32..1000, patch in 0u32..1000) {
321            let v = ModelVersion::new(major, minor, patch);
322            let bumped = v.bump_minor();
323            prop_assert_eq!(bumped.major, major);
324            prop_assert_eq!(bumped.minor, minor + 1);
325            prop_assert_eq!(bumped.patch, 0);
326        }
327
328        #[test]
329        fn prop_ordering_transitive(
330            a_major in 0u32..10, a_minor in 0u32..10, a_patch in 0u32..10,
331            b_major in 0u32..10, b_minor in 0u32..10, b_patch in 0u32..10,
332            c_major in 0u32..10, c_minor in 0u32..10, c_patch in 0u32..10,
333        ) {
334            let a = ModelVersion::new(a_major, a_minor, a_patch);
335            let b = ModelVersion::new(b_major, b_minor, b_patch);
336            let c = ModelVersion::new(c_major, c_minor, c_patch);
337
338            if a < b && b < c {
339                prop_assert!(a < c);
340            }
341        }
342    }
343}
344
345// ─── Kani Formal Verification ────────────────────────────────────────────
346
347#[cfg(kani)]
348mod kani_proofs {
349    use super::*;
350
351    #[kani::proof]
352    fn verify_bump_major_resets() {
353        let major: u32 = kani::any();
354        let minor: u32 = kani::any();
355        let patch: u32 = kani::any();
356        kani::assume(major < u32::MAX);
357        let v = ModelVersion::new(major, minor, patch);
358        let bumped = v.bump_major();
359        assert!(bumped.major == major + 1);
360        assert!(bumped.minor == 0);
361        assert!(bumped.patch == 0);
362    }
363
364    #[kani::proof]
365    fn verify_bump_ordering() {
366        let major: u32 = kani::any();
367        let minor: u32 = kani::any();
368        let patch: u32 = kani::any();
369        kani::assume(major < 100 && minor < 100 && patch < u32::MAX);
370        let v = ModelVersion::new(major, minor, patch);
371        let bumped = v.bump_patch();
372        assert!(bumped > v);
373    }
374
375    #[kani::proof]
376    fn verify_version_equality_reflexive() {
377        let major: u32 = kani::any();
378        let minor: u32 = kani::any();
379        let patch: u32 = kani::any();
380        kani::assume(major <= 100 && minor <= 100 && patch <= 100);
381        let v = ModelVersion::new(major, minor, patch);
382        assert!(v == v.clone());
383    }
384}