Skip to main content

ipfrs_semantic/
semantic_versioning_tracker.rs

1//! Semantic drift tracker across model/embedding versions.
2//!
3//! [`SemanticVersioningTracker`] detects and quantifies how concept embeddings
4//! shift between successive model versions, enabling data-driven migration
5//! recommendations and compatibility analysis.
6
7use std::collections::{HashMap, VecDeque};
8
9// ---------------------------------------------------------------------------
10// Type aliases
11// ---------------------------------------------------------------------------
12
13/// Unique integer identifier for a registered model version.
14pub type SvtVersionId = u64;
15
16// ---------------------------------------------------------------------------
17// Errors
18// ---------------------------------------------------------------------------
19
20/// Errors produced by [`SemanticVersioningTracker`].
21#[derive(Debug, Clone, PartialEq)]
22pub enum SvtError {
23    /// A version with this ID was not found.
24    VersionNotFound(SvtVersionId),
25    /// The concept was not registered for the requested version.
26    AnchorNotFound {
27        concept: String,
28        version_id: SvtVersionId,
29    },
30    /// The two embedding vectors have incompatible lengths.
31    DimMismatch { expected: usize, got: usize },
32    /// Not enough anchors to compute meaningful statistics.
33    InsufficientAnchors { found: usize, required: usize },
34    /// An operation was attempted on a deprecated version.
35    VersionDeprecated(SvtVersionId),
36    /// The concept string is empty or otherwise invalid.
37    InvalidConcept,
38}
39
40impl std::fmt::Display for SvtError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            SvtError::VersionNotFound(id) => write!(f, "version {id} not found"),
44            SvtError::AnchorNotFound {
45                concept,
46                version_id,
47            } => {
48                write!(f, "anchor '{concept}' not found for version {version_id}")
49            }
50            SvtError::DimMismatch { expected, got } => {
51                write!(f, "dimension mismatch: expected {expected}, got {got}")
52            }
53            SvtError::InsufficientAnchors { found, required } => {
54                write!(
55                    f,
56                    "insufficient anchors: {found} found, {required} required"
57                )
58            }
59            SvtError::VersionDeprecated(id) => write!(f, "version {id} is deprecated"),
60            SvtError::InvalidConcept => write!(f, "concept name must not be empty"),
61        }
62    }
63}
64
65impl std::error::Error for SvtError {}
66
67// ---------------------------------------------------------------------------
68// Configuration
69// ---------------------------------------------------------------------------
70
71/// Configuration knobs for [`SemanticVersioningTracker`].
72#[derive(Debug, Clone)]
73pub struct SvtTrackerConfig {
74    /// Cosine-distance threshold above which a concept is considered drifted.
75    pub drift_threshold: f64,
76    /// Minimum number of shared anchor concepts required to produce a report.
77    pub min_anchors: usize,
78    /// Maximum number of consecutive version pairs to consider when computing
79    /// time-series similarity.
80    pub window_size: usize,
81    /// When `true`, the tracker automatically marks a version as inactive
82    /// when its measured overall drift against the latest active version
83    /// exceeds `drift_threshold * 2.0`.
84    pub auto_deprecate: bool,
85}
86
87impl Default for SvtTrackerConfig {
88    fn default() -> Self {
89        Self {
90            drift_threshold: 0.15,
91            min_anchors: 1,
92            window_size: 20,
93            auto_deprecate: false,
94        }
95    }
96}
97
98// ---------------------------------------------------------------------------
99// Version
100// ---------------------------------------------------------------------------
101
102/// Metadata for a single registered model/embedding version.
103#[derive(Debug, Clone)]
104pub struct SvtVersion {
105    /// Unique numeric identifier (same as the map key).
106    pub id: SvtVersionId,
107    /// Human-readable label, e.g. `"bert-base-v2"`.
108    pub name: String,
109    /// Unix-epoch timestamp (seconds) at registration time.
110    pub created_at: u64,
111    /// Whether this version is currently active (not deprecated).
112    pub is_active: bool,
113    /// Embedding dimensionality expected for all anchors in this version.
114    pub embedding_dim: usize,
115    /// Number of anchor concepts registered for this version.
116    pub anchor_count: u32,
117}
118
119// ---------------------------------------------------------------------------
120// Drift event
121// ---------------------------------------------------------------------------
122
123/// A single drift observation recorded in the tracker log.
124#[derive(Debug, Clone)]
125pub struct SvtDriftEvent {
126    /// Unix-epoch timestamp when the event was recorded.
127    pub ts: u64,
128    /// First version in the pair.
129    pub version_a: SvtVersionId,
130    /// Second version in the pair.
131    pub version_b: SvtVersionId,
132    /// The anchor concept that was evaluated.
133    pub concept: String,
134    /// Cosine distance between the two embeddings (0 = identical, 1 = orthogonal).
135    pub drift_score: f64,
136    /// Whether `drift_score >= config.drift_threshold`.
137    pub is_significant: bool,
138}
139
140// ---------------------------------------------------------------------------
141// Drift report
142// ---------------------------------------------------------------------------
143
144/// Aggregated drift analysis between two versions.
145#[derive(Debug, Clone)]
146pub struct SvtDriftReport {
147    /// First version in the comparison.
148    pub version_a: SvtVersionId,
149    /// Second version in the comparison.
150    pub version_b: SvtVersionId,
151    /// Mean cosine distance across all shared anchor concepts.
152    pub overall_drift: f64,
153    /// Concepts whose drift score exceeds `drift_threshold`, sorted by score
154    /// (descending).
155    pub drifted_concepts: Vec<(String, f64)>,
156    /// Concepts whose drift score is at or below `drift_threshold`.
157    pub stable_concepts: Vec<String>,
158    /// Human-readable migration recommendation.
159    pub recommendation: String,
160}
161
162// ---------------------------------------------------------------------------
163// Tracker stats
164// ---------------------------------------------------------------------------
165
166/// Aggregate statistics for the tracker itself.
167#[derive(Debug, Clone)]
168pub struct SvtTrackerStats {
169    /// Total number of registered versions (including deprecated).
170    pub total_versions: usize,
171    /// Number of currently active versions.
172    pub active_versions: usize,
173    /// Total number of (concept, version) anchor pairs stored.
174    pub total_anchors: usize,
175    /// Number of distinct concept names.
176    pub distinct_concepts: usize,
177    /// Number of drift events stored in the log.
178    pub drift_events: usize,
179    /// Mean overall drift across all logged events.
180    pub mean_logged_drift: f64,
181    /// Concept with the highest mean stability score (lowest mean drift).
182    pub most_stable_concept: Option<String>,
183    /// Concept with the lowest stability score (highest mean drift).
184    pub most_drifted_concept: Option<String>,
185}
186
187// ---------------------------------------------------------------------------
188// Internal helpers
189// ---------------------------------------------------------------------------
190
191/// Cosine similarity between two equal-length vectors.
192/// Returns `0.0` if either vector has zero magnitude.
193#[inline]
194fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
195    let dot: f64 = a.iter().zip(b).map(|(x, y)| x * y).sum();
196    let na = a.iter().map(|x| x * x).sum::<f64>().sqrt();
197    let nb = b.iter().map(|x| x * x).sum::<f64>().sqrt();
198    if na == 0.0 || nb == 0.0 {
199        0.0
200    } else {
201        dot / (na * nb)
202    }
203}
204
205/// Cosine *distance* (1 – similarity), clamped to [0, 1].
206#[inline]
207fn cosine_distance(a: &[f64], b: &[f64]) -> f64 {
208    (1.0 - cosine_similarity(a, b)).clamp(0.0, 1.0)
209}
210
211/// Minimal xorshift64 PRNG used for synthetic timestamps when the platform
212/// does not expose a wall clock (also useful in tests).
213#[inline]
214fn xorshift64(state: &mut u64) -> u64 {
215    let mut x = *state;
216    x ^= x << 13;
217    x ^= x >> 7;
218    x ^= x << 17;
219    *state = x;
220    x
221}
222
223// ---------------------------------------------------------------------------
224// Maximum drift log capacity
225// ---------------------------------------------------------------------------
226
227const DRIFT_LOG_CAP: usize = 500;
228
229// ---------------------------------------------------------------------------
230// SemanticVersioningTracker
231// ---------------------------------------------------------------------------
232
233/// Tracks semantic drift of concept embeddings across model versions.
234///
235/// # Overview
236///
237/// The tracker maintains a registry of named model/embedding *versions* and a
238/// set of *anchor concepts* — representative items whose embeddings should be
239/// stable between compatible versions.  For each concept that exists in two
240/// versions the tracker computes the cosine distance of their embeddings,
241/// which it calls the *drift score*.
242///
243/// # Example
244///
245/// ```rust
246/// use ipfrs_semantic::semantic_versioning_tracker::{
247///     SemanticVersioningTracker, SvtTrackerConfig,
248/// };
249///
250/// let config = SvtTrackerConfig { drift_threshold: 0.1, ..Default::default() };
251/// let mut tracker = SemanticVersioningTracker::new(config);
252///
253/// let v1 = tracker.register_version("bert-v1", 3).unwrap();
254/// let v2 = tracker.register_version("bert-v2", 3).unwrap();
255///
256/// tracker.add_anchor("cat", v1, vec![1.0, 0.0, 0.0]).unwrap();
257/// tracker.add_anchor("cat", v2, vec![0.98, 0.1, 0.05]).unwrap();
258///
259/// let report = tracker.compute_drift(v1, v2).unwrap();
260/// assert!(report.overall_drift < 0.1);
261/// ```
262#[derive(Debug)]
263pub struct SemanticVersioningTracker {
264    /// Registered versions keyed by their numeric ID.
265    versions: HashMap<SvtVersionId, SvtVersion>,
266    /// Anchor concept → per-version (id, embedding) pairs.
267    anchors: HashMap<String, Vec<(SvtVersionId, Vec<f64>)>>,
268    /// Bounded log of recorded drift events.
269    drift_log: VecDeque<SvtDriftEvent>,
270    /// Tracker configuration.
271    config: SvtTrackerConfig,
272    /// Monotonically increasing ID counter.
273    next_id: SvtVersionId,
274    /// xorshift64 PRNG state (used for tiebreaking / synthetic timestamps).
275    rng_state: u64,
276}
277
278/// Convenience alias matching the naming convention requested.
279pub type SvtSemanticVersioningTracker = SemanticVersioningTracker;
280
281impl SemanticVersioningTracker {
282    // -----------------------------------------------------------------------
283    // Construction
284    // -----------------------------------------------------------------------
285
286    /// Creates a new tracker with the supplied configuration.
287    pub fn new(config: SvtTrackerConfig) -> Self {
288        Self {
289            versions: HashMap::new(),
290            anchors: HashMap::new(),
291            drift_log: VecDeque::with_capacity(DRIFT_LOG_CAP + 1),
292            config,
293            next_id: 1,
294            rng_state: 0x5851_F42D_4C95_7F2D,
295        }
296    }
297
298    /// Returns the current Unix epoch timestamp in seconds.
299    /// Falls back to a value derived from the internal xorshift64 PRNG state
300    /// when the system clock is unavailable (e.g. no-std or time went backwards).
301    fn now_ts(&mut self) -> u64 {
302        use std::time::{SystemTime, UNIX_EPOCH};
303        SystemTime::now()
304            .duration_since(UNIX_EPOCH)
305            .map(|d| d.as_secs())
306            .unwrap_or_else(|_| xorshift64(&mut self.rng_state))
307    }
308
309    /// Creates a tracker with default configuration.
310    pub fn with_defaults() -> Self {
311        Self::new(SvtTrackerConfig::default())
312    }
313
314    // -----------------------------------------------------------------------
315    // Version management
316    // -----------------------------------------------------------------------
317
318    /// Registers a new version and returns its assigned [`SvtVersionId`].
319    ///
320    /// # Errors
321    ///
322    /// Returns [`SvtError::DimMismatch`] (with `expected = 0`) if `dim == 0`.
323    pub fn register_version(
324        &mut self,
325        name: impl Into<String>,
326        dim: usize,
327    ) -> Result<SvtVersionId, SvtError> {
328        if dim == 0 {
329            return Err(SvtError::DimMismatch {
330                expected: 1,
331                got: 0,
332            });
333        }
334        let id = self.next_id;
335        self.next_id = self.next_id.wrapping_add(1);
336        let version = SvtVersion {
337            id,
338            name: name.into(),
339            created_at: self.now_ts(),
340            is_active: true,
341            embedding_dim: dim,
342            anchor_count: 0,
343        };
344        self.versions.insert(id, version);
345        Ok(id)
346    }
347
348    /// Marks a version as deprecated (inactive).
349    ///
350    /// # Errors
351    ///
352    /// Returns [`SvtError::VersionNotFound`] if the ID is unknown.
353    pub fn deprecate_version(&mut self, id: SvtVersionId) -> Result<(), SvtError> {
354        let ver = self
355            .versions
356            .get_mut(&id)
357            .ok_or(SvtError::VersionNotFound(id))?;
358        ver.is_active = false;
359        Ok(())
360    }
361
362    /// Re-activates a previously deprecated version.
363    ///
364    /// # Errors
365    ///
366    /// Returns [`SvtError::VersionNotFound`] if the ID is unknown.
367    pub fn activate_version(&mut self, id: SvtVersionId) -> Result<(), SvtError> {
368        let ver = self
369            .versions
370            .get_mut(&id)
371            .ok_or(SvtError::VersionNotFound(id))?;
372        ver.is_active = true;
373        Ok(())
374    }
375
376    /// Returns an immutable reference to the version metadata.
377    ///
378    /// # Errors
379    ///
380    /// Returns [`SvtError::VersionNotFound`] if the ID is unknown.
381    pub fn get_version(&self, id: SvtVersionId) -> Result<&SvtVersion, SvtError> {
382        self.versions.get(&id).ok_or(SvtError::VersionNotFound(id))
383    }
384
385    /// Returns all registered versions sorted by ID (ascending).
386    pub fn list_versions(&self) -> Vec<&SvtVersion> {
387        let mut v: Vec<&SvtVersion> = self.versions.values().collect();
388        v.sort_by_key(|ver| ver.id);
389        v
390    }
391
392    /// Returns only active versions sorted by ID (ascending).
393    pub fn active_versions(&self) -> Vec<&SvtVersion> {
394        let mut v: Vec<&SvtVersion> = self.versions.values().filter(|ver| ver.is_active).collect();
395        v.sort_by_key(|ver| ver.id);
396        v
397    }
398
399    // -----------------------------------------------------------------------
400    // Anchor management
401    // -----------------------------------------------------------------------
402
403    /// Registers a concept embedding for a specific version.
404    ///
405    /// If an anchor for this `(concept, version_id)` pair already exists it is
406    /// **replaced**.
407    ///
408    /// # Errors
409    ///
410    /// - [`SvtError::InvalidConcept`] – concept name is empty.
411    /// - [`SvtError::VersionNotFound`] – version ID unknown.
412    /// - [`SvtError::DimMismatch`] – `embedding.len()` differs from the
413    ///   version's declared `embedding_dim`.
414    pub fn add_anchor(
415        &mut self,
416        concept: &str,
417        version_id: SvtVersionId,
418        embedding: Vec<f64>,
419    ) -> Result<(), SvtError> {
420        if concept.is_empty() {
421            return Err(SvtError::InvalidConcept);
422        }
423        let ver = self
424            .versions
425            .get_mut(&version_id)
426            .ok_or(SvtError::VersionNotFound(version_id))?;
427
428        if embedding.len() != ver.embedding_dim {
429            return Err(SvtError::DimMismatch {
430                expected: ver.embedding_dim,
431                got: embedding.len(),
432            });
433        }
434
435        let entry = self.anchors.entry(concept.to_owned()).or_default();
436
437        // Replace existing entry for this version if present.
438        if let Some(existing) = entry.iter_mut().find(|(vid, _)| *vid == version_id) {
439            existing.1 = embedding;
440        } else {
441            entry.push((version_id, embedding));
442            ver.anchor_count = ver.anchor_count.saturating_add(1);
443        }
444
445        Ok(())
446    }
447
448    /// Returns the embedding for a specific `(concept, version)` pair.
449    ///
450    /// # Errors
451    ///
452    /// - [`SvtError::AnchorNotFound`] if no such pair exists.
453    pub fn get_anchor(&self, concept: &str, version_id: SvtVersionId) -> Result<&[f64], SvtError> {
454        let entries = self
455            .anchors
456            .get(concept)
457            .ok_or_else(|| SvtError::AnchorNotFound {
458                concept: concept.to_owned(),
459                version_id,
460            })?;
461        entries
462            .iter()
463            .find(|(vid, _)| *vid == version_id)
464            .map(|(_, emb)| emb.as_slice())
465            .ok_or_else(|| SvtError::AnchorNotFound {
466                concept: concept.to_owned(),
467                version_id,
468            })
469    }
470
471    /// Returns all concepts that have anchors registered in the given version.
472    pub fn concepts_for_version(&self, version_id: SvtVersionId) -> Vec<&str> {
473        self.anchors
474            .iter()
475            .filter_map(|(concept, entries)| {
476                if entries.iter().any(|(vid, _)| *vid == version_id) {
477                    Some(concept.as_str())
478                } else {
479                    None
480                }
481            })
482            .collect()
483    }
484
485    // -----------------------------------------------------------------------
486    // Core drift analysis
487    // -----------------------------------------------------------------------
488
489    /// Computes a full drift report between two versions.
490    ///
491    /// For each concept present in **both** versions the method computes the
492    /// cosine distance and records it in the drift log.
493    ///
494    /// # Errors
495    ///
496    /// - [`SvtError::VersionNotFound`] if either version ID is unknown.
497    /// - [`SvtError::InsufficientAnchors`] if fewer than `config.min_anchors`
498    ///   shared concepts exist.
499    pub fn compute_drift(
500        &mut self,
501        ver_a: SvtVersionId,
502        ver_b: SvtVersionId,
503    ) -> Result<SvtDriftReport, SvtError> {
504        // Validate that both versions exist.
505        if !self.versions.contains_key(&ver_a) {
506            return Err(SvtError::VersionNotFound(ver_a));
507        }
508        if !self.versions.contains_key(&ver_b) {
509            return Err(SvtError::VersionNotFound(ver_b));
510        }
511
512        // Collect shared concepts.
513        let shared: Vec<String> = self
514            .anchors
515            .iter()
516            .filter_map(|(concept, entries)| {
517                let has_a = entries.iter().any(|(vid, _)| *vid == ver_a);
518                let has_b = entries.iter().any(|(vid, _)| *vid == ver_b);
519                if has_a && has_b {
520                    Some(concept.clone())
521                } else {
522                    None
523                }
524            })
525            .collect();
526
527        if shared.len() < self.config.min_anchors {
528            return Err(SvtError::InsufficientAnchors {
529                found: shared.len(),
530                required: self.config.min_anchors,
531            });
532        }
533
534        let ts = self.now_ts();
535        let threshold = self.config.drift_threshold;
536        let mut concept_scores: Vec<(String, f64)> = Vec::with_capacity(shared.len());
537
538        for concept in &shared {
539            let entries = match self.anchors.get(concept) {
540                Some(e) => e,
541                None => continue,
542            };
543            let emb_a = match entries.iter().find(|(vid, _)| *vid == ver_a) {
544                Some((_, e)) => e.as_slice(),
545                None => continue,
546            };
547            let emb_b = match entries.iter().find(|(vid, _)| *vid == ver_b) {
548                Some((_, e)) => e.as_slice(),
549                None => continue,
550            };
551
552            let score = cosine_distance(emb_a, emb_b);
553            concept_scores.push((concept.clone(), score));
554
555            // Record to drift log (bounded).
556            let event = SvtDriftEvent {
557                ts,
558                version_a: ver_a,
559                version_b: ver_b,
560                concept: concept.clone(),
561                drift_score: score,
562                is_significant: score >= threshold,
563            };
564            self.push_drift_event(event);
565        }
566
567        // Compute overall drift.
568        let overall_drift = if concept_scores.is_empty() {
569            0.0
570        } else {
571            concept_scores.iter().map(|(_, s)| s).sum::<f64>() / concept_scores.len() as f64
572        };
573
574        // Split into drifted / stable.
575        let mut drifted_concepts: Vec<(String, f64)> = concept_scores
576            .iter()
577            .filter(|(_, s)| *s >= threshold)
578            .cloned()
579            .collect();
580        drifted_concepts.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
581
582        let stable_concepts: Vec<String> = concept_scores
583            .iter()
584            .filter(|(_, s)| *s < threshold)
585            .map(|(c, _)| c.clone())
586            .collect();
587
588        // Build recommendation.
589        let recommendation =
590            self.build_recommendation(ver_a, ver_b, overall_drift, &drifted_concepts);
591
592        // Auto-deprecate if configured.
593        if self.config.auto_deprecate && overall_drift >= threshold * 2.0 {
594            if let Some(ver) = self.versions.get_mut(&ver_a) {
595                ver.is_active = false;
596            }
597        }
598
599        Ok(SvtDriftReport {
600            version_a: ver_a,
601            version_b: ver_b,
602            overall_drift,
603            drifted_concepts,
604            stable_concepts,
605            recommendation,
606        })
607    }
608
609    /// Returns concepts whose drift between the two versions exceeds `threshold`,
610    /// sorted by score descending.
611    ///
612    /// # Errors
613    ///
614    /// Returns [`SvtError::VersionNotFound`] if either ID is unknown.
615    pub fn find_drifted_concepts(
616        &self,
617        ver_a: SvtVersionId,
618        ver_b: SvtVersionId,
619        threshold: f64,
620    ) -> Result<Vec<(String, f64)>, SvtError> {
621        if !self.versions.contains_key(&ver_a) {
622            return Err(SvtError::VersionNotFound(ver_a));
623        }
624        if !self.versions.contains_key(&ver_b) {
625            return Err(SvtError::VersionNotFound(ver_b));
626        }
627
628        let mut result: Vec<(String, f64)> = self
629            .anchors
630            .iter()
631            .filter_map(|(concept, entries)| {
632                let emb_a = entries.iter().find(|(vid, _)| *vid == ver_a)?.1.as_slice();
633                let emb_b = entries.iter().find(|(vid, _)| *vid == ver_b)?.1.as_slice();
634                let score = cosine_distance(emb_a, emb_b);
635                if score >= threshold {
636                    Some((concept.clone(), score))
637                } else {
638                    None
639                }
640            })
641            .collect();
642
643        result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
644        Ok(result)
645    }
646
647    // -----------------------------------------------------------------------
648    // Time-series analysis
649    // -----------------------------------------------------------------------
650
651    /// Computes pairwise cosine **similarity** (not distance) between
652    /// consecutive version pairs that share an anchor for `concept`.
653    ///
654    /// Pairs are ordered by (version_a_id, version_b_id) ascending.  At most
655    /// `config.window_size` pairs are returned.
656    ///
657    /// # Errors
658    ///
659    /// - [`SvtError::InvalidConcept`] if `concept` is empty.
660    /// - [`SvtError::InsufficientAnchors`] if fewer than 2 versions have the
661    ///   anchor.
662    pub fn semantic_similarity_over_time(
663        &self,
664        concept: &str,
665    ) -> Result<Vec<(SvtVersionId, SvtVersionId, f64)>, SvtError> {
666        if concept.is_empty() {
667            return Err(SvtError::InvalidConcept);
668        }
669
670        let entries = self
671            .anchors
672            .get(concept)
673            .ok_or(SvtError::InsufficientAnchors {
674                found: 0,
675                required: 2,
676            })?;
677
678        // Sort by version ID so we get a canonical ordering.
679        let mut sorted: Vec<(SvtVersionId, &[f64])> = entries
680            .iter()
681            .map(|(vid, emb)| (*vid, emb.as_slice()))
682            .collect();
683        sorted.sort_by_key(|(vid, _)| *vid);
684
685        if sorted.len() < 2 {
686            return Err(SvtError::InsufficientAnchors {
687                found: sorted.len(),
688                required: 2,
689            });
690        }
691
692        let window = self.config.window_size.max(1);
693        let pairs: Vec<(SvtVersionId, SvtVersionId, f64)> = sorted
694            .windows(2)
695            .take(window)
696            .map(|w| {
697                let (va, emb_a) = w[0];
698                let (vb, emb_b) = w[1];
699                let sim = cosine_similarity(emb_a, emb_b);
700                (va, vb, sim)
701            })
702            .collect();
703
704        Ok(pairs)
705    }
706
707    /// Computes a stability score in [0, 1] for a concept across all its
708    /// consecutive version pairs.
709    ///
710    /// `stability = 1 – mean_drift` where `mean_drift` is the mean cosine
711    /// distance over all consecutive pairs.  Returns `1.0` if fewer than 2
712    /// versions have the anchor (no drift measured).
713    ///
714    /// # Errors
715    ///
716    /// - [`SvtError::InvalidConcept`] if `concept` is empty.
717    pub fn stability_score(&self, concept: &str) -> Result<f64, SvtError> {
718        if concept.is_empty() {
719            return Err(SvtError::InvalidConcept);
720        }
721
722        let entries = match self.anchors.get(concept) {
723            Some(e) => e,
724            None => return Ok(1.0),
725        };
726
727        let mut sorted: Vec<(SvtVersionId, &[f64])> = entries
728            .iter()
729            .map(|(vid, emb)| (*vid, emb.as_slice()))
730            .collect();
731        sorted.sort_by_key(|(vid, _)| *vid);
732
733        if sorted.len() < 2 {
734            return Ok(1.0);
735        }
736
737        let total_drift: f64 = sorted
738            .windows(2)
739            .map(|w| cosine_distance(w[0].1, w[1].1))
740            .sum();
741        let n = (sorted.len() - 1) as f64;
742        Ok((1.0 - total_drift / n).clamp(0.0, 1.0))
743    }
744
745    // -----------------------------------------------------------------------
746    // Migration helpers
747    // -----------------------------------------------------------------------
748
749    /// Returns a list of concept names that should be re-verified when
750    /// migrating from `from` to `to`.
751    ///
752    /// A concept is flagged for re-verification when its drift score between
753    /// the two versions exceeds `config.drift_threshold`.
754    ///
755    /// # Errors
756    ///
757    /// Returns [`SvtError::VersionNotFound`] if either ID is unknown.
758    pub fn recommend_migration(
759        &self,
760        from: SvtVersionId,
761        to: SvtVersionId,
762    ) -> Result<Vec<String>, SvtError> {
763        let drifted = self.find_drifted_concepts(from, to, self.config.drift_threshold)?;
764        Ok(drifted.into_iter().map(|(c, _)| c).collect())
765    }
766
767    // -----------------------------------------------------------------------
768    // Statistics
769    // -----------------------------------------------------------------------
770
771    /// Returns aggregate statistics for this tracker instance.
772    pub fn tracker_stats(&self) -> SvtTrackerStats {
773        let total_versions = self.versions.len();
774        let active_versions = self.versions.values().filter(|v| v.is_active).count();
775
776        let total_anchors: usize = self.anchors.values().map(|v| v.len()).sum();
777        let distinct_concepts = self.anchors.len();
778        let drift_events = self.drift_log.len();
779
780        let mean_logged_drift = if drift_events == 0 {
781            0.0
782        } else {
783            self.drift_log.iter().map(|e| e.drift_score).sum::<f64>() / drift_events as f64
784        };
785
786        // Most stable / most drifted concept (by per-concept mean drift in log).
787        let mut concept_drift_sums: HashMap<&str, (f64, usize)> = HashMap::new();
788        for event in &self.drift_log {
789            let entry = concept_drift_sums
790                .entry(event.concept.as_str())
791                .or_insert((0.0, 0));
792            entry.0 += event.drift_score;
793            entry.1 += 1;
794        }
795
796        let most_stable_concept = concept_drift_sums
797            .iter()
798            .min_by(|a, b| {
799                let avg_a = a.1 .0 / a.1 .1 as f64;
800                let avg_b = b.1 .0 / b.1 .1 as f64;
801                avg_a
802                    .partial_cmp(&avg_b)
803                    .unwrap_or(std::cmp::Ordering::Equal)
804            })
805            .map(|(c, _)| (*c).to_owned());
806
807        let most_drifted_concept = concept_drift_sums
808            .iter()
809            .max_by(|a, b| {
810                let avg_a = a.1 .0 / a.1 .1 as f64;
811                let avg_b = b.1 .0 / b.1 .1 as f64;
812                avg_a
813                    .partial_cmp(&avg_b)
814                    .unwrap_or(std::cmp::Ordering::Equal)
815            })
816            .map(|(c, _)| (*c).to_owned());
817
818        SvtTrackerStats {
819            total_versions,
820            active_versions,
821            total_anchors,
822            distinct_concepts,
823            drift_events,
824            mean_logged_drift,
825            most_stable_concept,
826            most_drifted_concept,
827        }
828    }
829
830    /// Returns a read-only slice of the drift log (oldest first).
831    pub fn drift_log(&self) -> &VecDeque<SvtDriftEvent> {
832        &self.drift_log
833    }
834
835    /// Clears all recorded drift events.
836    pub fn clear_drift_log(&mut self) {
837        self.drift_log.clear();
838    }
839
840    /// Returns the current tracker configuration.
841    pub fn config(&self) -> &SvtTrackerConfig {
842        &self.config
843    }
844
845    /// Returns a mutable reference to the configuration.
846    pub fn config_mut(&mut self) -> &mut SvtTrackerConfig {
847        &mut self.config
848    }
849
850    // -----------------------------------------------------------------------
851    // Private helpers
852    // -----------------------------------------------------------------------
853
854    /// Pushes an event to the drift log, discarding the oldest entry when
855    /// the log is at capacity.
856    fn push_drift_event(&mut self, event: SvtDriftEvent) {
857        if self.drift_log.len() >= DRIFT_LOG_CAP {
858            self.drift_log.pop_front();
859        }
860        self.drift_log.push_back(event);
861    }
862
863    /// Builds a human-readable recommendation string.
864    fn build_recommendation(
865        &self,
866        ver_a: SvtVersionId,
867        ver_b: SvtVersionId,
868        overall_drift: f64,
869        drifted: &[(String, f64)],
870    ) -> String {
871        let threshold = self.config.drift_threshold;
872        if overall_drift < threshold * 0.5 {
873            format!(
874                "Versions {ver_a} → {ver_b} are semantically compatible \
875                 (overall drift {overall_drift:.4} < {half:.4}). \
876                 Migration should be transparent.",
877                half = threshold * 0.5
878            )
879        } else if overall_drift < threshold {
880            format!(
881                "Versions {ver_a} → {ver_b} show minor drift (overall {overall_drift:.4}). \
882                 Spot-check {n} concept(s) before production rollout.",
883                n = drifted.len()
884            )
885        } else if overall_drift < threshold * 2.0 {
886            let top: Vec<&str> = drifted.iter().take(5).map(|(c, _)| c.as_str()).collect();
887            format!(
888                "Versions {ver_a} → {ver_b} show significant drift (overall {overall_drift:.4}). \
889                 Re-evaluate embeddings for: {top}.",
890                top = top.join(", ")
891            )
892        } else {
893            let top: Vec<&str> = drifted.iter().take(10).map(|(c, _)| c.as_str()).collect();
894            format!(
895                "Versions {ver_a} → {ver_b} are semantically incompatible \
896                 (overall drift {overall_drift:.4} >= {dbl:.4}). \
897                 Full re-indexing recommended. Affected concepts: {top}.",
898                dbl = threshold * 2.0,
899                top = top.join(", ")
900            )
901        }
902    }
903
904    // -----------------------------------------------------------------------
905    // Batch operations
906    // -----------------------------------------------------------------------
907
908    /// Registers multiple anchors at once.
909    ///
910    /// Returns a `Vec` of errors (one per failed anchor); successful insertions
911    /// are committed even if some fail.
912    pub fn add_anchors_batch(
913        &mut self,
914        items: impl IntoIterator<Item = (String, SvtVersionId, Vec<f64>)>,
915    ) -> Vec<SvtError> {
916        let mut errors = Vec::new();
917        let batch: Vec<_> = items.into_iter().collect();
918        for (concept, version_id, embedding) in batch {
919            if let Err(e) = self.add_anchor(&concept, version_id, embedding) {
920                errors.push(e);
921            }
922        }
923        errors
924    }
925
926    /// Computes drift reports for all consecutive active-version pairs,
927    /// returning `(report_or_error)` for each pair.
928    ///
929    /// Pairs are ordered by ascending version IDs.
930    pub fn compute_all_consecutive_drifts(&mut self) -> Vec<Result<SvtDriftReport, SvtError>> {
931        let ids: Vec<SvtVersionId> = {
932            let mut v: Vec<SvtVersionId> = self
933                .versions
934                .values()
935                .filter(|ver| ver.is_active)
936                .map(|ver| ver.id)
937                .collect();
938            v.sort_unstable();
939            v
940        };
941
942        let pairs: Vec<(SvtVersionId, SvtVersionId)> =
943            ids.windows(2).map(|w| (w[0], w[1])).collect();
944
945        pairs
946            .into_iter()
947            .map(|(a, b)| self.compute_drift(a, b))
948            .collect()
949    }
950
951    /// Returns the mean stability score across all registered concepts.
952    ///
953    /// Concepts with fewer than two version anchors contribute `1.0`.
954    pub fn global_stability(&self) -> f64 {
955        if self.anchors.is_empty() {
956            return 1.0;
957        }
958        let total: f64 = self
959            .anchors
960            .keys()
961            .map(|c| self.stability_score(c).unwrap_or(1.0))
962            .sum();
963        total / self.anchors.len() as f64
964    }
965
966    /// Returns all concepts sorted by stability score descending (most
967    /// stable first).
968    ///
969    /// # Errors
970    ///
971    /// This function only returns `Err` variants from internal calls; in
972    /// practice they are suppressed and the concept is scored as `1.0`.
973    pub fn concepts_by_stability(&self) -> Vec<(String, f64)> {
974        let mut scores: Vec<(String, f64)> = self
975            .anchors
976            .keys()
977            .map(|c| {
978                let s = self.stability_score(c).unwrap_or(1.0);
979                (c.clone(), s)
980            })
981            .collect();
982        scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
983        scores
984    }
985
986    /// Returns the `n` most drifted concepts between two versions.
987    ///
988    /// # Errors
989    ///
990    /// Returns [`SvtError::VersionNotFound`] if either ID is unknown.
991    pub fn top_drifted_concepts(
992        &self,
993        ver_a: SvtVersionId,
994        ver_b: SvtVersionId,
995        n: usize,
996    ) -> Result<Vec<(String, f64)>, SvtError> {
997        let mut all = self.find_drifted_concepts(ver_a, ver_b, 0.0)?;
998        all.truncate(n);
999        Ok(all)
1000    }
1001
1002    /// Returns `true` if the two versions are semantically compatible, i.e.
1003    /// their overall drift is strictly below `drift_threshold`.
1004    ///
1005    /// # Errors
1006    ///
1007    /// Returns [`SvtError::VersionNotFound`] if either ID is unknown, or
1008    /// [`SvtError::InsufficientAnchors`] if not enough shared concepts exist.
1009    pub fn are_compatible(
1010        &mut self,
1011        ver_a: SvtVersionId,
1012        ver_b: SvtVersionId,
1013    ) -> Result<bool, SvtError> {
1014        let report = self.compute_drift(ver_a, ver_b)?;
1015        Ok(report.overall_drift < self.config.drift_threshold)
1016    }
1017
1018    /// Removes all anchors for a given version ID and decrements `anchor_count`.
1019    ///
1020    /// # Errors
1021    ///
1022    /// Returns [`SvtError::VersionNotFound`] if the version is unknown.
1023    pub fn remove_version_anchors(&mut self, version_id: SvtVersionId) -> Result<usize, SvtError> {
1024        if !self.versions.contains_key(&version_id) {
1025            return Err(SvtError::VersionNotFound(version_id));
1026        }
1027        let mut removed = 0usize;
1028        for entries in self.anchors.values_mut() {
1029            let before = entries.len();
1030            entries.retain(|(vid, _)| *vid != version_id);
1031            removed += before - entries.len();
1032        }
1033        // Remove any empty concept slots.
1034        self.anchors.retain(|_, entries| !entries.is_empty());
1035        // Update anchor count on the version.
1036        if let Some(ver) = self.versions.get_mut(&version_id) {
1037            ver.anchor_count = 0;
1038        }
1039        Ok(removed)
1040    }
1041
1042    /// Computes per-concept drift scores between two versions for all shared
1043    /// concepts, without recording to the drift log.
1044    ///
1045    /// # Errors
1046    ///
1047    /// Returns [`SvtError::VersionNotFound`] if either ID is unknown.
1048    pub fn concept_drift_matrix(
1049        &self,
1050        ver_a: SvtVersionId,
1051        ver_b: SvtVersionId,
1052    ) -> Result<HashMap<String, f64>, SvtError> {
1053        if !self.versions.contains_key(&ver_a) {
1054            return Err(SvtError::VersionNotFound(ver_a));
1055        }
1056        if !self.versions.contains_key(&ver_b) {
1057            return Err(SvtError::VersionNotFound(ver_b));
1058        }
1059        let matrix: HashMap<String, f64> = self
1060            .anchors
1061            .iter()
1062            .filter_map(|(concept, entries)| {
1063                let emb_a = entries.iter().find(|(vid, _)| *vid == ver_a)?.1.as_slice();
1064                let emb_b = entries.iter().find(|(vid, _)| *vid == ver_b)?.1.as_slice();
1065                Some((concept.clone(), cosine_distance(emb_a, emb_b)))
1066            })
1067            .collect();
1068        Ok(matrix)
1069    }
1070}
1071
1072// ---------------------------------------------------------------------------
1073// Tests
1074// ---------------------------------------------------------------------------
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079
1080    // ---- helpers -----------------------------------------------------------
1081
1082    fn tracker() -> SemanticVersioningTracker {
1083        SemanticVersioningTracker::with_defaults()
1084    }
1085
1086    fn tracker_with(threshold: f64) -> SemanticVersioningTracker {
1087        SemanticVersioningTracker::new(SvtTrackerConfig {
1088            drift_threshold: threshold,
1089            min_anchors: 1,
1090            window_size: 20,
1091            auto_deprecate: false,
1092        })
1093    }
1094
1095    /// Returns a unit vector in dimension `dim` with 1.0 at position `pos`.
1096    fn unit(dim: usize, pos: usize) -> Vec<f64> {
1097        let mut v = vec![0.0f64; dim];
1098        v[pos] = 1.0;
1099        v
1100    }
1101
1102    /// Almost-unit: 1.0 at pos, small epsilon elsewhere.
1103    fn near_unit(dim: usize, pos: usize, eps: f64) -> Vec<f64> {
1104        let mut v = vec![eps; dim];
1105        v[pos] = 1.0;
1106        v
1107    }
1108
1109    // ---- cosine helpers ----------------------------------------------------
1110
1111    #[test]
1112    fn cosine_similarity_identical() {
1113        let v = vec![1.0, 2.0, 3.0];
1114        let s = cosine_similarity(&v, &v);
1115        assert!((s - 1.0).abs() < 1e-10);
1116    }
1117
1118    #[test]
1119    fn cosine_similarity_orthogonal() {
1120        let a = vec![1.0, 0.0];
1121        let b = vec![0.0, 1.0];
1122        let s = cosine_similarity(&a, &b);
1123        assert!(s.abs() < 1e-10);
1124    }
1125
1126    #[test]
1127    fn cosine_similarity_opposite() {
1128        let a = vec![1.0, 0.0];
1129        let b = vec![-1.0, 0.0];
1130        let s = cosine_similarity(&a, &b);
1131        assert!((s + 1.0).abs() < 1e-10);
1132    }
1133
1134    #[test]
1135    fn cosine_similarity_zero_vector() {
1136        let z = vec![0.0, 0.0];
1137        let a = vec![1.0, 0.0];
1138        assert_eq!(cosine_similarity(&z, &a), 0.0);
1139        assert_eq!(cosine_similarity(&a, &z), 0.0);
1140        assert_eq!(cosine_similarity(&z, &z), 0.0);
1141    }
1142
1143    #[test]
1144    fn cosine_distance_identical_is_zero() {
1145        let v = vec![1.0, 0.5, 0.5];
1146        assert!((cosine_distance(&v, &v)).abs() < 1e-10);
1147    }
1148
1149    #[test]
1150    fn cosine_distance_orthogonal_is_one() {
1151        let a = vec![1.0, 0.0];
1152        let b = vec![0.0, 1.0];
1153        assert!((cosine_distance(&a, &b) - 1.0).abs() < 1e-10);
1154    }
1155
1156    // ---- xorshift64 --------------------------------------------------------
1157
1158    #[test]
1159    fn xorshift64_produces_nonzero() {
1160        let mut state: u64 = 42;
1161        let v = xorshift64(&mut state);
1162        assert_ne!(v, 0);
1163        assert_ne!(state, 42);
1164    }
1165
1166    #[test]
1167    fn xorshift64_sequence_different() {
1168        let mut state: u64 = 0xCAFE_BABE;
1169        let v1 = xorshift64(&mut state);
1170        let v2 = xorshift64(&mut state);
1171        assert_ne!(v1, v2);
1172    }
1173
1174    // ---- registration ------------------------------------------------------
1175
1176    #[test]
1177    fn register_version_basic() {
1178        let mut t = tracker();
1179        let id = t
1180            .register_version("v1", 128)
1181            .expect("test: register version v1 with dim 128 should succeed");
1182        assert!(id > 0);
1183        let ver = t
1184            .get_version(id)
1185            .expect("test: get version by id should succeed for just-registered version");
1186        assert_eq!(ver.name, "v1");
1187        assert_eq!(ver.embedding_dim, 128);
1188        assert!(ver.is_active);
1189        assert_eq!(ver.anchor_count, 0);
1190    }
1191
1192    #[test]
1193    fn register_version_ids_monotone() {
1194        let mut t = tracker();
1195        let a = t
1196            .register_version("a", 16)
1197            .expect("test: register version a should succeed");
1198        let b = t
1199            .register_version("b", 16)
1200            .expect("test: register version b should succeed");
1201        let c = t
1202            .register_version("c", 16)
1203            .expect("test: register version c should succeed");
1204        assert!(a < b);
1205        assert!(b < c);
1206    }
1207
1208    #[test]
1209    fn register_version_zero_dim_err() {
1210        let mut t = tracker();
1211        let res = t.register_version("bad", 0);
1212        assert!(res.is_err());
1213    }
1214
1215    #[test]
1216    fn deprecate_and_activate() {
1217        let mut t = tracker();
1218        let id = t
1219            .register_version("v1", 8)
1220            .expect("test: register version v1 should succeed");
1221        t.deprecate_version(id)
1222            .expect("test: deprecate version should succeed for known id");
1223        assert!(
1224            !t.get_version(id)
1225                .expect("test: get version should succeed for known id")
1226                .is_active
1227        );
1228        t.activate_version(id)
1229            .expect("test: activate version should succeed for known id");
1230        assert!(
1231            t.get_version(id)
1232                .expect("test: get version should succeed for known id")
1233                .is_active
1234        );
1235    }
1236
1237    #[test]
1238    fn deprecate_nonexistent_err() {
1239        let mut t = tracker();
1240        assert!(t.deprecate_version(9999).is_err());
1241    }
1242
1243    #[test]
1244    fn activate_nonexistent_err() {
1245        let mut t = tracker();
1246        assert!(t.activate_version(9999).is_err());
1247    }
1248
1249    #[test]
1250    fn list_versions_sorted() {
1251        let mut t = tracker();
1252        let c = t
1253            .register_version("c", 4)
1254            .expect("test: register version c should succeed");
1255        let a = t
1256            .register_version("a", 4)
1257            .expect("test: register version a should succeed");
1258        let b = t
1259            .register_version("b", 4)
1260            .expect("test: register version b should succeed");
1261        let listed: Vec<SvtVersionId> = t.list_versions().iter().map(|v| v.id).collect();
1262        assert!(listed.contains(&a));
1263        assert!(listed.contains(&b));
1264        assert!(listed.contains(&c));
1265        // IDs must be ascending.
1266        for w in listed.windows(2) {
1267            assert!(w[0] < w[1]);
1268        }
1269    }
1270
1271    #[test]
1272    fn active_versions_filters_deprecated() {
1273        let mut t = tracker();
1274        let a = t
1275            .register_version("a", 4)
1276            .expect("test: register version a should succeed");
1277        let b = t
1278            .register_version("b", 4)
1279            .expect("test: register version b should succeed");
1280        t.deprecate_version(a)
1281            .expect("test: deprecate version a should succeed");
1282        let active_ids: Vec<SvtVersionId> = t.active_versions().iter().map(|v| v.id).collect();
1283        assert!(!active_ids.contains(&a));
1284        assert!(active_ids.contains(&b));
1285    }
1286
1287    // ---- anchor management -------------------------------------------------
1288
1289    #[test]
1290    fn add_anchor_basic() {
1291        let mut t = tracker();
1292        let v = t
1293            .register_version("v", 3)
1294            .expect("test: register version v should succeed");
1295        t.add_anchor("cat", v, unit(3, 0))
1296            .expect("test: add anchor cat to version v should succeed");
1297        let emb = t
1298            .get_anchor("cat", v)
1299            .expect("test: get anchor cat for version v should succeed");
1300        assert_eq!(emb, &unit(3, 0));
1301    }
1302
1303    #[test]
1304    fn add_anchor_replaces() {
1305        let mut t = tracker();
1306        let v = t
1307            .register_version("v", 3)
1308            .expect("test: register version v should succeed");
1309        t.add_anchor("dog", v, unit(3, 0))
1310            .expect("test: add anchor dog to version v should succeed");
1311        t.add_anchor("dog", v, unit(3, 1))
1312            .expect("test: replace anchor dog for version v should succeed"); // replace
1313        let emb = t
1314            .get_anchor("dog", v)
1315            .expect("test: get replaced anchor dog should succeed");
1316        assert_eq!(emb, &unit(3, 1));
1317        // anchor_count should not double-count
1318        assert_eq!(
1319            t.get_version(v)
1320                .expect("test: get version v should succeed")
1321                .anchor_count,
1322            1
1323        );
1324    }
1325
1326    #[test]
1327    fn add_anchor_dim_mismatch_err() {
1328        let mut t = tracker();
1329        let v = t
1330            .register_version("v", 4)
1331            .expect("test: register version v should succeed");
1332        assert!(t.add_anchor("x", v, vec![1.0, 0.0]).is_err());
1333    }
1334
1335    #[test]
1336    fn add_anchor_empty_concept_err() {
1337        let mut t = tracker();
1338        let v = t
1339            .register_version("v", 2)
1340            .expect("test: register version v should succeed");
1341        assert!(t.add_anchor("", v, vec![1.0, 0.0]).is_err());
1342    }
1343
1344    #[test]
1345    fn add_anchor_unknown_version_err() {
1346        let mut t = tracker();
1347        assert!(t.add_anchor("x", 999, vec![1.0]).is_err());
1348    }
1349
1350    #[test]
1351    fn get_anchor_missing_concept_err() {
1352        let mut t = tracker();
1353        let v = t
1354            .register_version("v", 2)
1355            .expect("test: register version v should succeed");
1356        assert!(t.get_anchor("missing", v).is_err());
1357    }
1358
1359    #[test]
1360    fn get_anchor_missing_version_for_concept_err() {
1361        let mut t = tracker();
1362        let v1 = t
1363            .register_version("v1", 2)
1364            .expect("test: register version v1 should succeed");
1365        let v2 = t
1366            .register_version("v2", 2)
1367            .expect("test: register version v2 should succeed");
1368        t.add_anchor("x", v1, vec![1.0, 0.0])
1369            .expect("test: add anchor x to v1 should succeed");
1370        assert!(t.get_anchor("x", v2).is_err());
1371    }
1372
1373    #[test]
1374    fn anchor_count_increments() {
1375        let mut t = tracker();
1376        let v = t
1377            .register_version("v", 2)
1378            .expect("test: register version v should succeed");
1379        assert_eq!(
1380            t.get_version(v)
1381                .expect("test: get version v should succeed")
1382                .anchor_count,
1383            0
1384        );
1385        t.add_anchor("a", v, vec![1.0, 0.0])
1386            .expect("test: add anchor a to version v should succeed");
1387        assert_eq!(
1388            t.get_version(v)
1389                .expect("test: get version v should succeed after anchor a")
1390                .anchor_count,
1391            1
1392        );
1393        t.add_anchor("b", v, vec![0.0, 1.0])
1394            .expect("test: add anchor b to version v should succeed");
1395        assert_eq!(
1396            t.get_version(v)
1397                .expect("test: get version v should succeed after anchor b")
1398                .anchor_count,
1399            2
1400        );
1401    }
1402
1403    #[test]
1404    fn concepts_for_version_correct() {
1405        let mut t = tracker();
1406        let v1 = t
1407            .register_version("v1", 2)
1408            .expect("test: register version v1 should succeed");
1409        let v2 = t
1410            .register_version("v2", 2)
1411            .expect("test: register version v2 should succeed");
1412        t.add_anchor("cat", v1, vec![1.0, 0.0])
1413            .expect("test: add anchor cat to v1 should succeed");
1414        t.add_anchor("dog", v1, vec![0.0, 1.0])
1415            .expect("test: add anchor dog to v1 should succeed");
1416        t.add_anchor("cat", v2, vec![1.0, 0.0])
1417            .expect("test: add anchor cat to v2 should succeed");
1418        let v1_concepts = t.concepts_for_version(v1);
1419        assert!(v1_concepts.contains(&"cat"));
1420        assert!(v1_concepts.contains(&"dog"));
1421        let v2_concepts = t.concepts_for_version(v2);
1422        assert!(v2_concepts.contains(&"cat"));
1423        assert!(!v2_concepts.contains(&"dog"));
1424    }
1425
1426    // ---- drift computation -------------------------------------------------
1427
1428    #[test]
1429    fn compute_drift_zero_for_identical_embeddings() {
1430        let mut t = tracker_with(0.1);
1431        let v1 = t
1432            .register_version("v1", 3)
1433            .expect("test: register version v1 should succeed");
1434        let v2 = t
1435            .register_version("v2", 3)
1436            .expect("test: register version v2 should succeed");
1437        t.add_anchor("x", v1, vec![1.0, 0.0, 0.0])
1438            .expect("test: add anchor x to v1 should succeed");
1439        t.add_anchor("x", v2, vec![1.0, 0.0, 0.0])
1440            .expect("test: add anchor x to v2 should succeed");
1441        let report = t
1442            .compute_drift(v1, v2)
1443            .expect("test: compute drift with identical embeddings should succeed");
1444        assert!(report.overall_drift < 1e-10);
1445        assert!(report.drifted_concepts.is_empty());
1446        assert!(!report.stable_concepts.is_empty());
1447    }
1448
1449    #[test]
1450    fn compute_drift_max_for_orthogonal() {
1451        let mut t = tracker_with(0.5);
1452        let v1 = t
1453            .register_version("v1", 2)
1454            .expect("test: register version v1 should succeed");
1455        let v2 = t
1456            .register_version("v2", 2)
1457            .expect("test: register version v2 should succeed");
1458        t.add_anchor("x", v1, unit(2, 0))
1459            .expect("test: add anchor x to v1 should succeed");
1460        t.add_anchor("x", v2, unit(2, 1))
1461            .expect("test: add anchor x to v2 should succeed");
1462        let report = t
1463            .compute_drift(v1, v2)
1464            .expect("test: compute drift for orthogonal embeddings should succeed");
1465        assert!((report.overall_drift - 1.0).abs() < 1e-10);
1466        assert_eq!(report.drifted_concepts.len(), 1);
1467    }
1468
1469    #[test]
1470    fn compute_drift_unknown_version_err() {
1471        let mut t = tracker();
1472        let v1 = t
1473            .register_version("v1", 2)
1474            .expect("test: register version v1 should succeed");
1475        assert!(t.compute_drift(v1, 9999).is_err());
1476        assert!(t.compute_drift(9999, v1).is_err());
1477    }
1478
1479    #[test]
1480    fn compute_drift_insufficient_anchors_err() {
1481        let mut t = SemanticVersioningTracker::new(SvtTrackerConfig {
1482            drift_threshold: 0.1,
1483            min_anchors: 3,
1484            window_size: 10,
1485            auto_deprecate: false,
1486        });
1487        let v1 = t
1488            .register_version("v1", 2)
1489            .expect("test: register version v1 should succeed");
1490        let v2 = t
1491            .register_version("v2", 2)
1492            .expect("test: register version v2 should succeed");
1493        t.add_anchor("x", v1, vec![1.0, 0.0])
1494            .expect("test: add anchor x to v1 should succeed");
1495        t.add_anchor("x", v2, vec![1.0, 0.0])
1496            .expect("test: add anchor x to v2 should succeed");
1497        // Only 1 shared concept, need 3.
1498        assert!(t.compute_drift(v1, v2).is_err());
1499    }
1500
1501    #[test]
1502    fn compute_drift_records_to_log() {
1503        let mut t = tracker_with(0.1);
1504        let v1 = t
1505            .register_version("v1", 2)
1506            .expect("test: register version v1 should succeed");
1507        let v2 = t
1508            .register_version("v2", 2)
1509            .expect("test: register version v2 should succeed");
1510        t.add_anchor("a", v1, unit(2, 0))
1511            .expect("test: add anchor a to v1 should succeed");
1512        t.add_anchor("a", v2, unit(2, 0))
1513            .expect("test: add anchor a to v2 should succeed");
1514        t.add_anchor("b", v1, unit(2, 1))
1515            .expect("test: add anchor b to v1 should succeed");
1516        t.add_anchor("b", v2, unit(2, 0))
1517            .expect("test: add anchor b to v2 should succeed");
1518        let _ = t
1519            .compute_drift(v1, v2)
1520            .expect("test: compute drift should succeed");
1521        assert_eq!(t.drift_log().len(), 2);
1522    }
1523
1524    #[test]
1525    fn compute_drift_report_has_recommendation() {
1526        let mut t = tracker_with(0.1);
1527        let v1 = t
1528            .register_version("v1", 2)
1529            .expect("test: register version v1 should succeed");
1530        let v2 = t
1531            .register_version("v2", 2)
1532            .expect("test: register version v2 should succeed");
1533        t.add_anchor("x", v1, unit(2, 0))
1534            .expect("test: add anchor x to v1 should succeed");
1535        t.add_anchor("x", v2, unit(2, 0))
1536            .expect("test: add anchor x to v2 should succeed");
1537        let report = t
1538            .compute_drift(v1, v2)
1539            .expect("test: compute drift should succeed");
1540        assert!(!report.recommendation.is_empty());
1541    }
1542
1543    #[test]
1544    fn compute_drift_drifted_sorted_descending() {
1545        let mut t = tracker_with(0.01);
1546        let v1 = t
1547            .register_version("v1", 4)
1548            .expect("test: register version v1 should succeed");
1549        let v2 = t
1550            .register_version("v2", 4)
1551            .expect("test: register version v2 should succeed");
1552        // large drift
1553        t.add_anchor("a", v1, unit(4, 0))
1554            .expect("test: add anchor a to v1 should succeed");
1555        t.add_anchor("a", v2, unit(4, 1))
1556            .expect("test: add anchor a to v2 should succeed");
1557        // small (but still above threshold=0.01) drift:
1558        // near_unit(4, 0, 0.5) = [1.0, 0.5, 0.5, 0.5]; cosine-distance vs unit(4,0) ≈ 0.24
1559        t.add_anchor("b", v1, near_unit(4, 0, 0.5))
1560            .expect("test: add anchor b to v1 should succeed");
1561        t.add_anchor("b", v2, unit(4, 0))
1562            .expect("test: add anchor b to v2 should succeed");
1563        let report = t
1564            .compute_drift(v1, v2)
1565            .expect("test: compute drift should succeed");
1566        // Both should be in drifted (threshold 0.01).
1567        assert!(report.drifted_concepts.len() >= 2);
1568        // Must be sorted descending.
1569        for w in report.drifted_concepts.windows(2) {
1570            assert!(w[0].1 >= w[1].1);
1571        }
1572    }
1573
1574    // ---- find_drifted_concepts ---------------------------------------------
1575
1576    #[test]
1577    fn find_drifted_concepts_above_threshold() {
1578        let mut t = tracker_with(0.3);
1579        let v1 = t
1580            .register_version("v1", 2)
1581            .expect("test: register version v1 should succeed");
1582        let v2 = t
1583            .register_version("v2", 2)
1584            .expect("test: register version v2 should succeed");
1585        t.add_anchor("same", v1, unit(2, 0))
1586            .expect("test: add anchor same to v1 should succeed");
1587        t.add_anchor("same", v2, unit(2, 0))
1588            .expect("test: add anchor same to v2 should succeed");
1589        t.add_anchor("diff", v1, unit(2, 0))
1590            .expect("test: add anchor diff to v1 should succeed");
1591        t.add_anchor("diff", v2, unit(2, 1))
1592            .expect("test: add anchor diff to v2 should succeed");
1593        let drifted = t
1594            .find_drifted_concepts(v1, v2, 0.3)
1595            .expect("test: find drifted concepts should succeed");
1596        assert_eq!(drifted.len(), 1);
1597        assert_eq!(drifted[0].0, "diff");
1598    }
1599
1600    #[test]
1601    fn find_drifted_concepts_unknown_version_err() {
1602        let mut t = tracker();
1603        let v = t
1604            .register_version("v", 2)
1605            .expect("test: register version v should succeed");
1606        assert!(t.find_drifted_concepts(v, 999, 0.1).is_err());
1607    }
1608
1609    // ---- semantic_similarity_over_time ------------------------------------
1610
1611    #[test]
1612    fn semantic_similarity_over_time_basic() {
1613        let mut t = tracker_with(0.1);
1614        let v1 = t
1615            .register_version("v1", 3)
1616            .expect("test: register version v1 should succeed");
1617        let v2 = t
1618            .register_version("v2", 3)
1619            .expect("test: register version v2 should succeed");
1620        let v3 = t
1621            .register_version("v3", 3)
1622            .expect("test: register version v3 should succeed");
1623        t.add_anchor("cat", v1, unit(3, 0))
1624            .expect("test: add anchor cat to v1 should succeed");
1625        t.add_anchor("cat", v2, near_unit(3, 0, 0.01))
1626            .expect("test: add anchor cat to v2 should succeed");
1627        t.add_anchor("cat", v3, unit(3, 0))
1628            .expect("test: add anchor cat to v3 should succeed");
1629        let pairs = t
1630            .semantic_similarity_over_time("cat")
1631            .expect("test: semantic similarity over time for cat should succeed");
1632        assert_eq!(pairs.len(), 2);
1633        // All similarities should be high.
1634        for (_, _, sim) in &pairs {
1635            assert!(*sim > 0.9);
1636        }
1637    }
1638
1639    #[test]
1640    fn semantic_similarity_over_time_empty_concept_err() {
1641        let t = tracker();
1642        assert!(t.semantic_similarity_over_time("").is_err());
1643    }
1644
1645    #[test]
1646    fn semantic_similarity_over_time_missing_concept_err() {
1647        let t = tracker();
1648        assert!(t.semantic_similarity_over_time("ghost").is_err());
1649    }
1650
1651    #[test]
1652    fn semantic_similarity_over_time_single_version_err() {
1653        let mut t = tracker();
1654        let v = t
1655            .register_version("v", 2)
1656            .expect("test: register version v should succeed");
1657        t.add_anchor("x", v, unit(2, 0))
1658            .expect("test: add anchor x to v should succeed");
1659        assert!(t.semantic_similarity_over_time("x").is_err());
1660    }
1661
1662    #[test]
1663    fn semantic_similarity_over_time_window_limit() {
1664        let mut t = SemanticVersioningTracker::new(SvtTrackerConfig {
1665            drift_threshold: 0.1,
1666            min_anchors: 1,
1667            window_size: 3,
1668            auto_deprecate: false,
1669        });
1670        for i in 0..10usize {
1671            let v = t
1672                .register_version(format!("v{i}"), 2)
1673                .expect("test: register version should succeed");
1674            t.add_anchor("x", v, vec![1.0, i as f64])
1675                .expect("test: add anchor x to version should succeed");
1676        }
1677        let pairs = t
1678            .semantic_similarity_over_time("x")
1679            .expect("test: semantic similarity over time for x should succeed");
1680        assert!(pairs.len() <= 3);
1681    }
1682
1683    // ---- stability_score ---------------------------------------------------
1684
1685    #[test]
1686    fn stability_score_no_anchors_returns_one() {
1687        let t = tracker();
1688        // Concept not registered at all.
1689        let s = t
1690            .stability_score("ghost")
1691            .expect("test: stability score for unregistered concept should return Ok(1.0)");
1692        assert!((s - 1.0).abs() < 1e-10);
1693    }
1694
1695    #[test]
1696    fn stability_score_one_version_returns_one() {
1697        let mut t = tracker();
1698        let v = t
1699            .register_version("v", 2)
1700            .expect("test: register version v should succeed");
1701        t.add_anchor("x", v, unit(2, 0))
1702            .expect("test: add anchor x to v should succeed");
1703        let s = t
1704            .stability_score("x")
1705            .expect("test: stability score should succeed");
1706        assert!((s - 1.0).abs() < 1e-10);
1707    }
1708
1709    #[test]
1710    fn stability_score_identical_embeddings_is_one() {
1711        let mut t = tracker();
1712        let v1 = t
1713            .register_version("v1", 2)
1714            .expect("test: register version v1 should succeed");
1715        let v2 = t
1716            .register_version("v2", 2)
1717            .expect("test: register version v2 should succeed");
1718        t.add_anchor("x", v1, unit(2, 0))
1719            .expect("test: add anchor x to v1 should succeed");
1720        t.add_anchor("x", v2, unit(2, 0))
1721            .expect("test: add anchor x to v2 should succeed");
1722        let s = t
1723            .stability_score("x")
1724            .expect("test: stability score for identical embeddings should succeed");
1725        assert!((s - 1.0).abs() < 1e-10);
1726    }
1727
1728    #[test]
1729    fn stability_score_orthogonal_embeddings_is_zero() {
1730        let mut t = tracker();
1731        let v1 = t
1732            .register_version("v1", 2)
1733            .expect("test: register version v1 should succeed");
1734        let v2 = t
1735            .register_version("v2", 2)
1736            .expect("test: register version v2 should succeed");
1737        t.add_anchor("x", v1, unit(2, 0))
1738            .expect("test: add anchor x to v1 should succeed");
1739        t.add_anchor("x", v2, unit(2, 1))
1740            .expect("test: add anchor x to v2 should succeed");
1741        let s = t
1742            .stability_score("x")
1743            .expect("test: stability score for orthogonal embeddings should succeed");
1744        assert!(s.abs() < 1e-10);
1745    }
1746
1747    #[test]
1748    fn stability_score_empty_concept_err() {
1749        let t = tracker();
1750        assert!(t.stability_score("").is_err());
1751    }
1752
1753    // ---- recommend_migration -----------------------------------------------
1754
1755    #[test]
1756    fn recommend_migration_returns_drifted() {
1757        let mut t = tracker_with(0.3);
1758        let v1 = t
1759            .register_version("v1", 2)
1760            .expect("test: register version v1 should succeed");
1761        let v2 = t
1762            .register_version("v2", 2)
1763            .expect("test: register version v2 should succeed");
1764        t.add_anchor("stable", v1, unit(2, 0))
1765            .expect("test: add anchor stable to v1 should succeed");
1766        t.add_anchor("stable", v2, unit(2, 0))
1767            .expect("test: add anchor stable to v2 should succeed");
1768        t.add_anchor("drifted", v1, unit(2, 0))
1769            .expect("test: add anchor drifted to v1 should succeed");
1770        t.add_anchor("drifted", v2, unit(2, 1))
1771            .expect("test: add anchor drifted to v2 should succeed");
1772        let recs = t
1773            .recommend_migration(v1, v2)
1774            .expect("test: recommend migration should succeed");
1775        assert!(recs.contains(&"drifted".to_string()));
1776        assert!(!recs.contains(&"stable".to_string()));
1777    }
1778
1779    #[test]
1780    fn recommend_migration_unknown_version_err() {
1781        let mut t = tracker();
1782        let v = t
1783            .register_version("v", 2)
1784            .expect("test: register version v should succeed");
1785        assert!(t.recommend_migration(v, 9999).is_err());
1786    }
1787
1788    // ---- tracker_stats -----------------------------------------------------
1789
1790    #[test]
1791    fn tracker_stats_empty() {
1792        let t = tracker();
1793        let stats = t.tracker_stats();
1794        assert_eq!(stats.total_versions, 0);
1795        assert_eq!(stats.active_versions, 0);
1796        assert_eq!(stats.total_anchors, 0);
1797        assert_eq!(stats.distinct_concepts, 0);
1798        assert_eq!(stats.drift_events, 0);
1799        assert!((stats.mean_logged_drift).abs() < 1e-10);
1800    }
1801
1802    #[test]
1803    fn tracker_stats_after_operations() {
1804        let mut t = tracker_with(0.1);
1805        let v1 = t
1806            .register_version("v1", 2)
1807            .expect("test: register version v1 should succeed");
1808        let v2 = t
1809            .register_version("v2", 2)
1810            .expect("test: register version v2 should succeed");
1811        t.deprecate_version(v1)
1812            .expect("test: deprecate version v1 should succeed");
1813        t.add_anchor("x", v1, unit(2, 0))
1814            .expect("test: add anchor x to v1 should succeed");
1815        t.add_anchor("x", v2, unit(2, 0))
1816            .expect("test: add anchor x to v2 should succeed");
1817        t.add_anchor("y", v1, unit(2, 1))
1818            .expect("test: add anchor y to v1 should succeed");
1819        t.add_anchor("y", v2, unit(2, 1))
1820            .expect("test: add anchor y to v2 should succeed");
1821        let _ = t
1822            .compute_drift(v1, v2)
1823            .expect("test: compute drift should succeed");
1824        let stats = t.tracker_stats();
1825        assert_eq!(stats.total_versions, 2);
1826        assert_eq!(stats.active_versions, 1);
1827        assert_eq!(stats.distinct_concepts, 2);
1828        assert_eq!(stats.total_anchors, 4);
1829        assert_eq!(stats.drift_events, 2);
1830    }
1831
1832    // ---- drift log ---------------------------------------------------------
1833
1834    #[test]
1835    fn drift_log_bounded_at_500() {
1836        let mut t = tracker_with(0.0);
1837        let v1 = t
1838            .register_version("v1", 2)
1839            .expect("test: register version v1 should succeed");
1840        let v2 = t
1841            .register_version("v2", 2)
1842            .expect("test: register version v2 should succeed");
1843        // Register 600 concepts.
1844        for i in 0usize..600 {
1845            let c = format!("c{i}");
1846            t.add_anchor(&c, v1, unit(2, 0))
1847                .expect("test: add anchor to v1 should succeed");
1848            t.add_anchor(&c, v2, unit(2, 1))
1849                .expect("test: add anchor to v2 should succeed");
1850        }
1851        let _ = t
1852            .compute_drift(v1, v2)
1853            .expect("test: compute drift with 600 concepts should succeed");
1854        assert!(t.drift_log().len() <= 500);
1855    }
1856
1857    #[test]
1858    fn clear_drift_log() {
1859        let mut t = tracker_with(0.1);
1860        let v1 = t
1861            .register_version("v1", 2)
1862            .expect("test: register version v1 should succeed");
1863        let v2 = t
1864            .register_version("v2", 2)
1865            .expect("test: register version v2 should succeed");
1866        t.add_anchor("x", v1, unit(2, 0))
1867            .expect("test: add anchor x to v1 should succeed");
1868        t.add_anchor("x", v2, unit(2, 0))
1869            .expect("test: add anchor x to v2 should succeed");
1870        let _ = t
1871            .compute_drift(v1, v2)
1872            .expect("test: compute drift should succeed");
1873        assert!(!t.drift_log().is_empty());
1874        t.clear_drift_log();
1875        assert!(t.drift_log().is_empty());
1876    }
1877
1878    // ---- batch operations --------------------------------------------------
1879
1880    #[test]
1881    fn add_anchors_batch_partial_failure() {
1882        let mut t = tracker();
1883        let v = t
1884            .register_version("v", 2)
1885            .expect("test: register version v should succeed");
1886        let items = vec![
1887            ("good".to_string(), v, vec![1.0, 0.0]),
1888            ("bad".to_string(), v, vec![1.0, 0.0, 0.0]), // dim mismatch
1889        ];
1890        let errors = t.add_anchors_batch(items);
1891        assert_eq!(errors.len(), 1);
1892        // "good" should still be registered.
1893        assert!(t.get_anchor("good", v).is_ok());
1894    }
1895
1896    #[test]
1897    fn compute_all_consecutive_drifts() {
1898        let mut t = tracker_with(0.1);
1899        let v1 = t
1900            .register_version("v1", 2)
1901            .expect("test: register version v1 should succeed");
1902        let v2 = t
1903            .register_version("v2", 2)
1904            .expect("test: register version v2 should succeed");
1905        let v3 = t
1906            .register_version("v3", 2)
1907            .expect("test: register version v3 should succeed");
1908        for v in [v1, v2, v3] {
1909            t.add_anchor("x", v, unit(2, 0))
1910                .expect("test: add anchor x to version should succeed");
1911        }
1912        let results = t.compute_all_consecutive_drifts();
1913        assert_eq!(results.len(), 2);
1914        for r in results {
1915            assert!(r.is_ok());
1916        }
1917    }
1918
1919    // ---- global_stability --------------------------------------------------
1920
1921    #[test]
1922    fn global_stability_no_concepts_is_one() {
1923        let t = tracker();
1924        assert!((t.global_stability() - 1.0).abs() < 1e-10);
1925    }
1926
1927    #[test]
1928    fn global_stability_with_perfect_concepts() {
1929        let mut t = tracker();
1930        let v1 = t
1931            .register_version("v1", 2)
1932            .expect("test: register version v1 should succeed");
1933        let v2 = t
1934            .register_version("v2", 2)
1935            .expect("test: register version v2 should succeed");
1936        t.add_anchor("x", v1, unit(2, 0))
1937            .expect("test: add anchor x to v1 should succeed");
1938        t.add_anchor("x", v2, unit(2, 0))
1939            .expect("test: add anchor x to v2 should succeed");
1940        let gs = t.global_stability();
1941        assert!((gs - 1.0).abs() < 1e-10);
1942    }
1943
1944    // ---- concepts_by_stability ---------------------------------------------
1945
1946    #[test]
1947    fn concepts_by_stability_sorted() {
1948        let mut t = tracker();
1949        let v1 = t
1950            .register_version("v1", 2)
1951            .expect("test: register version v1 should succeed");
1952        let v2 = t
1953            .register_version("v2", 2)
1954            .expect("test: register version v2 should succeed");
1955        t.add_anchor("stable", v1, unit(2, 0))
1956            .expect("test: add anchor stable to v1 should succeed");
1957        t.add_anchor("stable", v2, unit(2, 0))
1958            .expect("test: add anchor stable to v2 should succeed");
1959        t.add_anchor("unstable", v1, unit(2, 0))
1960            .expect("test: add anchor unstable to v1 should succeed");
1961        t.add_anchor("unstable", v2, unit(2, 1))
1962            .expect("test: add anchor unstable to v2 should succeed");
1963        let sorted = t.concepts_by_stability();
1964        // stable should come first (score 1.0 > score 0.0).
1965        assert_eq!(sorted[0].0, "stable");
1966        assert_eq!(sorted[1].0, "unstable");
1967    }
1968
1969    // ---- top_drifted_concepts ----------------------------------------------
1970
1971    #[test]
1972    fn top_drifted_concepts_limits_result() {
1973        let mut t = tracker_with(0.0);
1974        let v1 = t
1975            .register_version("v1", 4)
1976            .expect("test: register version v1 should succeed");
1977        let v2 = t
1978            .register_version("v2", 4)
1979            .expect("test: register version v2 should succeed");
1980        for i in 0..4usize {
1981            let c = format!("c{i}");
1982            t.add_anchor(&c, v1, unit(4, 0))
1983                .expect("test: add anchor to v1 should succeed");
1984            t.add_anchor(&c, v2, unit(4, (i + 1) % 4))
1985                .expect("test: add anchor to v2 should succeed");
1986        }
1987        let top2 = t
1988            .top_drifted_concepts(v1, v2, 2)
1989            .expect("test: top drifted concepts should succeed");
1990        assert!(top2.len() <= 2);
1991    }
1992
1993    // ---- are_compatible ----------------------------------------------------
1994
1995    #[test]
1996    fn are_compatible_identical_true() {
1997        let mut t = tracker_with(0.1);
1998        let v1 = t
1999            .register_version("v1", 2)
2000            .expect("test: register version v1 should succeed");
2001        let v2 = t
2002            .register_version("v2", 2)
2003            .expect("test: register version v2 should succeed");
2004        t.add_anchor("x", v1, unit(2, 0))
2005            .expect("test: add anchor x to v1 should succeed");
2006        t.add_anchor("x", v2, unit(2, 0))
2007            .expect("test: add anchor x to v2 should succeed");
2008        assert!(t
2009            .are_compatible(v1, v2)
2010            .expect("test: are_compatible should succeed"));
2011    }
2012
2013    #[test]
2014    fn are_compatible_orthogonal_false() {
2015        let mut t = tracker_with(0.1);
2016        let v1 = t
2017            .register_version("v1", 2)
2018            .expect("test: register version v1 should succeed");
2019        let v2 = t
2020            .register_version("v2", 2)
2021            .expect("test: register version v2 should succeed");
2022        t.add_anchor("x", v1, unit(2, 0))
2023            .expect("test: add anchor x to v1 should succeed");
2024        t.add_anchor("x", v2, unit(2, 1))
2025            .expect("test: add anchor x to v2 should succeed");
2026        assert!(!t
2027            .are_compatible(v1, v2)
2028            .expect("test: are_compatible should succeed"));
2029    }
2030
2031    // ---- remove_version_anchors --------------------------------------------
2032
2033    #[test]
2034    fn remove_version_anchors_clears_entries() {
2035        let mut t = tracker();
2036        let v1 = t
2037            .register_version("v1", 2)
2038            .expect("test: register version v1 should succeed");
2039        let v2 = t
2040            .register_version("v2", 2)
2041            .expect("test: register version v2 should succeed");
2042        t.add_anchor("x", v1, unit(2, 0))
2043            .expect("test: add anchor x to v1 should succeed");
2044        t.add_anchor("x", v2, unit(2, 0))
2045            .expect("test: add anchor x to v2 should succeed");
2046        let removed = t
2047            .remove_version_anchors(v1)
2048            .expect("test: remove version anchors should succeed");
2049        assert_eq!(removed, 1);
2050        assert!(t.get_anchor("x", v1).is_err());
2051        assert!(t.get_anchor("x", v2).is_ok());
2052    }
2053
2054    #[test]
2055    fn remove_version_anchors_unknown_version_err() {
2056        let mut t = tracker();
2057        assert!(t.remove_version_anchors(9999).is_err());
2058    }
2059
2060    // ---- concept_drift_matrix ----------------------------------------------
2061
2062    #[test]
2063    fn concept_drift_matrix_correct() {
2064        let mut t = tracker();
2065        let v1 = t
2066            .register_version("v1", 2)
2067            .expect("test: register version v1 should succeed");
2068        let v2 = t
2069            .register_version("v2", 2)
2070            .expect("test: register version v2 should succeed");
2071        t.add_anchor("same", v1, unit(2, 0))
2072            .expect("test: add anchor same to v1 should succeed");
2073        t.add_anchor("same", v2, unit(2, 0))
2074            .expect("test: add anchor same to v2 should succeed");
2075        t.add_anchor("diff", v1, unit(2, 0))
2076            .expect("test: add anchor diff to v1 should succeed");
2077        t.add_anchor("diff", v2, unit(2, 1))
2078            .expect("test: add anchor diff to v2 should succeed");
2079        let matrix = t
2080            .concept_drift_matrix(v1, v2)
2081            .expect("test: concept drift matrix should succeed");
2082        assert!((matrix["same"]).abs() < 1e-10);
2083        assert!((matrix["diff"] - 1.0).abs() < 1e-10);
2084    }
2085
2086    #[test]
2087    fn concept_drift_matrix_does_not_log() {
2088        let mut t = tracker();
2089        let v1 = t
2090            .register_version("v1", 2)
2091            .expect("test: register version v1 should succeed");
2092        let v2 = t
2093            .register_version("v2", 2)
2094            .expect("test: register version v2 should succeed");
2095        t.add_anchor("x", v1, unit(2, 0))
2096            .expect("test: add anchor x to v1 should succeed");
2097        t.add_anchor("x", v2, unit(2, 0))
2098            .expect("test: add anchor x to v2 should succeed");
2099        let _ = t
2100            .concept_drift_matrix(v1, v2)
2101            .expect("test: concept drift matrix should succeed");
2102        // matrix should NOT push events to the drift log.
2103        assert!(t.drift_log().is_empty());
2104    }
2105
2106    // ---- auto-deprecate ----------------------------------------------------
2107
2108    #[test]
2109    fn auto_deprecate_deactivates_old_version() {
2110        let mut t = SemanticVersioningTracker::new(SvtTrackerConfig {
2111            drift_threshold: 0.1,
2112            min_anchors: 1,
2113            window_size: 10,
2114            auto_deprecate: true,
2115        });
2116        let v1 = t
2117            .register_version("v1", 2)
2118            .expect("test: register version v1 should succeed");
2119        let v2 = t
2120            .register_version("v2", 2)
2121            .expect("test: register version v2 should succeed");
2122        // Overall drift will be 1.0 (orthogonal) >= 0.1 * 2 = 0.2.
2123        t.add_anchor("x", v1, unit(2, 0))
2124            .expect("test: add anchor x to v1 should succeed");
2125        t.add_anchor("x", v2, unit(2, 1))
2126            .expect("test: add anchor x to v2 should succeed");
2127        let _ = t
2128            .compute_drift(v1, v2)
2129            .expect("test: compute drift should succeed");
2130        assert!(
2131            !t.get_version(v1)
2132                .expect("test: get version v1 after auto-deprecate should succeed")
2133                .is_active
2134        );
2135    }
2136
2137    #[test]
2138    fn auto_deprecate_does_not_deactivate_when_drift_low() {
2139        let mut t = SemanticVersioningTracker::new(SvtTrackerConfig {
2140            drift_threshold: 0.5,
2141            min_anchors: 1,
2142            window_size: 10,
2143            auto_deprecate: true,
2144        });
2145        let v1 = t
2146            .register_version("v1", 2)
2147            .expect("test: register version v1 should succeed");
2148        let v2 = t
2149            .register_version("v2", 2)
2150            .expect("test: register version v2 should succeed");
2151        t.add_anchor("x", v1, unit(2, 0))
2152            .expect("test: add anchor x to v1 should succeed");
2153        t.add_anchor("x", v2, unit(2, 0))
2154            .expect("test: add anchor x to v2 should succeed");
2155        let _ = t
2156            .compute_drift(v1, v2)
2157            .expect("test: compute drift should succeed");
2158        // drift = 0.0 < 0.5 * 2 = 1.0 → should NOT deprecate.
2159        assert!(
2160            t.get_version(v1)
2161                .expect("test: get version v1 should succeed")
2162                .is_active
2163        );
2164    }
2165
2166    // ---- recommendation text -----------------------------------------------
2167
2168    #[test]
2169    fn recommendation_transparent_when_very_low_drift() {
2170        let mut t = tracker_with(0.5);
2171        let v1 = t
2172            .register_version("v1", 2)
2173            .expect("test: register version v1 should succeed");
2174        let v2 = t
2175            .register_version("v2", 2)
2176            .expect("test: register version v2 should succeed");
2177        t.add_anchor("x", v1, unit(2, 0))
2178            .expect("test: add anchor x to v1 should succeed");
2179        t.add_anchor("x", v2, unit(2, 0))
2180            .expect("test: add anchor x to v2 should succeed");
2181        let r = t
2182            .compute_drift(v1, v2)
2183            .expect("test: compute drift should succeed");
2184        assert!(
2185            r.recommendation.contains("transparent") || r.recommendation.contains("compatible")
2186        );
2187    }
2188
2189    #[test]
2190    fn recommendation_full_reindex_when_very_high_drift() {
2191        let mut t = tracker_with(0.1);
2192        let v1 = t
2193            .register_version("v1", 2)
2194            .expect("test: register version v1 should succeed");
2195        let v2 = t
2196            .register_version("v2", 2)
2197            .expect("test: register version v2 should succeed");
2198        t.add_anchor("x", v1, unit(2, 0))
2199            .expect("test: add anchor x to v1 should succeed");
2200        t.add_anchor("x", v2, unit(2, 1))
2201            .expect("test: add anchor x to v2 should succeed");
2202        let r = t
2203            .compute_drift(v1, v2)
2204            .expect("test: compute drift should succeed");
2205        assert!(r.recommendation.contains("incompatible") || r.recommendation.contains("re-index"));
2206    }
2207
2208    // ---- drift event fields ------------------------------------------------
2209
2210    #[test]
2211    fn drift_event_significant_flag() {
2212        let mut t = tracker_with(0.3);
2213        let v1 = t
2214            .register_version("v1", 2)
2215            .expect("test: register version v1 should succeed");
2216        let v2 = t
2217            .register_version("v2", 2)
2218            .expect("test: register version v2 should succeed");
2219        t.add_anchor("x", v1, unit(2, 0))
2220            .expect("test: add anchor x to v1 should succeed");
2221        t.add_anchor("x", v2, unit(2, 1))
2222            .expect("test: add anchor x to v2 should succeed"); // drift = 1.0 ≥ 0.3
2223        let _ = t
2224            .compute_drift(v1, v2)
2225            .expect("test: compute drift should succeed");
2226        let event = t
2227            .drift_log()
2228            .back()
2229            .expect("test: drift log should have at least one event after compute_drift");
2230        assert!(event.is_significant);
2231        assert!((event.drift_score - 1.0).abs() < 1e-10);
2232    }
2233
2234    #[test]
2235    fn drift_event_not_significant_for_identical() {
2236        let mut t = tracker_with(0.3);
2237        let v1 = t
2238            .register_version("v1", 2)
2239            .expect("test: register version v1 should succeed");
2240        let v2 = t
2241            .register_version("v2", 2)
2242            .expect("test: register version v2 should succeed");
2243        t.add_anchor("x", v1, unit(2, 0))
2244            .expect("test: add anchor x to v1 should succeed");
2245        t.add_anchor("x", v2, unit(2, 0))
2246            .expect("test: add anchor x to v2 should succeed");
2247        let _ = t
2248            .compute_drift(v1, v2)
2249            .expect("test: compute drift should succeed");
2250        let event = t
2251            .drift_log()
2252            .back()
2253            .expect("test: drift log should have at least one event after compute_drift");
2254        assert!(!event.is_significant);
2255    }
2256
2257    // ---- config accessors --------------------------------------------------
2258
2259    #[test]
2260    fn config_accessor_returns_correct_threshold() {
2261        let t = tracker_with(0.42);
2262        assert!((t.config().drift_threshold - 0.42).abs() < 1e-10);
2263    }
2264
2265    #[test]
2266    fn config_mut_modifies_threshold() {
2267        let mut t = tracker_with(0.1);
2268        t.config_mut().drift_threshold = 0.9;
2269        assert!((t.config().drift_threshold - 0.9).abs() < 1e-10);
2270    }
2271}