Skip to main content

ai_model_vault/
utils.rs

1//! AI Model Utilities - Common operations for AI models
2//!
3//! Provides utilities for model archiving, compression, retrieval optimization,
4//! quantization metadata, pruning info, and model analysis.
5
6use std::collections::HashMap;
7use std::io::{Read, Write};
8use std::path::{Path, PathBuf};
9
10use crate::error::{Result, VaultError};
11use crate::formats::{ModelFormat, ModelMetadata};
12
13/// Reject an archive member name that would escape the extraction directory.
14///
15/// Archive member names are attacker-controlled for any archive the user did
16/// not produce. `extract_zip` returned them verbatim and `handle_extract` fed
17/// them to `Path::join`, which discards its base when given an absolute path
18/// and walks upward on `..` — so a member named `../../evil` wrote outside the
19/// `--output` directory ("zip slip", CWE-22). A longer prefix or a drive
20/// letter reaches anywhere the invoking user can write.
21///
22/// Both `create_tar` and `create_zip` write bare model names, so requiring a
23/// single ordinary component rejects nothing this crate produces.
24fn safe_archive_name(raw: &str) -> Result<String> {
25    use std::path::Component;
26
27    let mut components = Path::new(raw).components();
28    match (components.next(), components.next()) {
29        (Some(Component::Normal(name)), None) => Ok(name.to_string_lossy().into_owned()),
30        _ => Err(VaultError::InvalidInput(format!(
31            "Refusing to extract archive member {raw:?}: member names must be a \
32             single file name, with no directory separators, parent references, \
33             or drive/root prefix"
34        ))),
35    }
36}
37
38/// Largest single archive member `extract_tar` / `extract_zip` will decompress.
39///
40/// Both extractors buffer members in memory, so without a ceiling the process
41/// resident set is whatever the archive says it should be. A compressed member
42/// costs the attacker almost nothing to declare: a zip of a few hundred KiB of
43/// zeroes expands to gigabytes ("zip bomb"), and a tar header can claim any
44/// `u64` length at all. Refusing oversized members turns an out-of-memory kill
45/// — which takes down whatever else shares the process — into an error the
46/// caller can report.
47///
48/// 8 GiB clears the largest model files this crate handles by a wide margin.
49pub const MAX_ARCHIVE_MEMBER_BYTES: u64 = 8 * 1024 * 1024 * 1024;
50
51/// Largest total uncompressed payload a single archive may expand to.
52///
53/// The per-member cap alone does not bound the whole: a million members each
54/// just under the limit still exhausts memory. This bounds the sum.
55pub const MAX_ARCHIVE_TOTAL_BYTES: u64 = 16 * 1024 * 1024 * 1024;
56
57/// Reject a member whose declared size already exceeds the per-member cap, or
58/// whose size would push the archive past the total cap.
59fn check_archive_budget(name: &str, declared: u64, running_total: u64) -> Result<()> {
60    if declared > MAX_ARCHIVE_MEMBER_BYTES {
61        return Err(VaultError::InvalidInput(format!(
62            "Refusing to extract archive member {name:?}: declared size {declared} bytes \
63             exceeds the {MAX_ARCHIVE_MEMBER_BYTES}-byte per-member limit"
64        )));
65    }
66    if running_total.saturating_add(declared) > MAX_ARCHIVE_TOTAL_BYTES {
67        return Err(VaultError::InvalidInput(format!(
68            "Refusing to extract archive member {name:?}: archive expands past the \
69             {MAX_ARCHIVE_TOTAL_BYTES}-byte total limit"
70        )));
71    }
72    Ok(())
73}
74
75/// Read at most `MAX_ARCHIVE_MEMBER_BYTES` from `reader`, erroring if there is
76/// more.
77///
78/// The declared size is only a claim. A tar header can understate the payload
79/// and a zip local header can lie outright, so the read itself is bounded too:
80/// take one byte more than the cap allows and treat a full buffer as proof the
81/// member overran.
82///
83/// `limit` is a parameter rather than the constant so tests can exercise the
84/// overrun path without materialising 8 GiB; callers always pass
85/// [`MAX_ARCHIVE_MEMBER_BYTES`].
86fn read_member_bounded<R: Read>(name: &str, reader: &mut R, limit: u64) -> Result<Vec<u8>> {
87    let mut data = Vec::new();
88    let read = reader.take(limit + 1).read_to_end(&mut data)?;
89
90    if read as u64 > limit {
91        return Err(VaultError::InvalidInput(format!(
92            "Refusing to extract archive member {name:?}: actual contents exceed the \
93             {limit}-byte per-member limit (the declared size understated it)"
94        )));
95    }
96
97    Ok(data)
98}
99
100/// Model archival utilities
101pub struct ModelArchive;
102
103impl ModelArchive {
104    /// Create a TAR archive of multiple models
105    pub fn create_tar(models: Vec<(String, Vec<u8>)>, output_path: &Path) -> Result<usize> {
106        let file = std::fs::File::create(output_path)?;
107        let mut ar = tar::Builder::new(file);
108
109        let mut total_size = 0;
110
111        for (name, data) in models {
112            let mut header = tar::Header::new_gnu();
113            header.set_size(data.len() as u64);
114            header.set_mode(0o644);
115            header.set_cksum();
116
117            ar.append_data(&mut header, &name, &data[..])?;
118            total_size += data.len();
119        }
120
121        ar.finish()?;
122        Ok(total_size)
123    }
124
125    /// Extract models from TAR archive
126    pub fn extract_tar(archive_path: &Path) -> Result<Vec<(String, Vec<u8>)>> {
127        let file = std::fs::File::open(archive_path)?;
128        let mut ar = tar::Archive::new(file);
129
130        let mut models = Vec::new();
131        let mut total: u64 = 0;
132
133        for entry in ar.entries()? {
134            let mut entry = entry?;
135            let path = entry.path()?;
136            // tar entries were previously reduced with `file_name()`, which is
137            // safe but silent: `../../etc/passwd` became `passwd` and quietly
138            // overwrote a legitimate member of the same name. Reject instead,
139            // matching the zip path.
140            let name = safe_archive_name(&path.to_string_lossy())?;
141
142            check_archive_budget(&name, entry.size(), total)?;
143            let data = read_member_bounded(&name, &mut entry, MAX_ARCHIVE_MEMBER_BYTES)?;
144            total = total.saturating_add(data.len() as u64);
145
146            models.push((name, data));
147        }
148
149        Ok(models)
150    }
151
152    /// Create a ZIP archive of multiple models
153    pub fn create_zip(models: Vec<(String, Vec<u8>)>, output_path: &Path) -> Result<usize> {
154        let file = std::fs::File::create(output_path)?;
155        let mut zip = zip::ZipWriter::new(file);
156
157        let mut total_size = 0;
158
159        for (name, data) in models {
160            let options = zip::write::SimpleFileOptions::default()
161                .compression_method(zip::CompressionMethod::Deflated)
162                .unix_permissions(0o644);
163
164            zip.start_file(&name, options)?;
165            zip.write_all(&data)?;
166            total_size += data.len();
167        }
168
169        zip.finish()?;
170        Ok(total_size)
171    }
172
173    /// Extract models from ZIP archive
174    pub fn extract_zip(archive_path: &Path) -> Result<Vec<(String, Vec<u8>)>> {
175        let file = std::fs::File::open(archive_path)?;
176        let mut zip = zip::ZipArchive::new(file)?;
177
178        let mut models = Vec::new();
179        let mut total: u64 = 0;
180
181        for i in 0..zip.len() {
182            let mut file = zip.by_index(i)?;
183            // Validated before any data is read, and the whole archive is
184            // collected before the caller writes anything — so a hostile
185            // member aborts the extraction rather than leaving files behind.
186            let name = safe_archive_name(file.name())?;
187
188            check_archive_budget(&name, file.size(), total)?;
189            let data = read_member_bounded(&name, &mut file, MAX_ARCHIVE_MEMBER_BYTES)?;
190            total = total.saturating_add(data.len() as u64);
191
192            models.push((name, data));
193        }
194
195        Ok(models)
196    }
197}
198
199/// Model compression utilities (metadata and analysis)
200pub struct CompressionAnalyzer;
201
202impl CompressionAnalyzer {
203    /// Calculate compression ratio
204    #[must_use]
205    pub fn compression_ratio(original_size: u64, compressed_size: u64) -> f64 {
206        if compressed_size == 0 {
207            return 0.0;
208        }
209        original_size as f64 / compressed_size as f64
210    }
211
212    /// Estimate compression ratio for model format
213    #[must_use]
214    pub fn estimate_ratio(format: &ModelFormat) -> f64 {
215        match format {
216            ModelFormat::Safetensors => 1.05, // Low compression (already optimized)
217            ModelFormat::GGUF => 1.0,         // Pre-quantized/compressed
218            ModelFormat::PyTorch => 1.3,      // Moderate compression
219            ModelFormat::ONNX => 1.2,
220            ModelFormat::TensorRT => 1.0, // Compiled format
221            ModelFormat::TFLite => 1.1,
222            ModelFormat::HDF5 => 1.4,   // Good compression potential
223            ModelFormat::Pickle => 1.5, // Python pickle compresses well
224            _ => 1.2,                   // Default estimate
225        }
226    }
227
228    /// Analyze compression effectiveness
229    pub fn analyze_compression(
230        original_size: u64,
231        compressed_size: u64,
232        format: &ModelFormat,
233    ) -> CompressionReport {
234        let actual_ratio = Self::compression_ratio(original_size, compressed_size);
235        let estimated_ratio = Self::estimate_ratio(format);
236        let space_saved = original_size.saturating_sub(compressed_size);
237        let space_saved_percent = (space_saved as f64 / original_size as f64) * 100.0;
238
239        CompressionReport {
240            original_size,
241            compressed_size,
242            space_saved,
243            compression_ratio: actual_ratio,
244            estimated_ratio,
245            space_saved_percent,
246            efficiency: actual_ratio / estimated_ratio,
247        }
248    }
249}
250
251/// Compression analysis report
252#[derive(Debug, Clone)]
253pub struct CompressionReport {
254    pub original_size: u64,
255    pub compressed_size: u64,
256    pub space_saved: u64,
257    pub compression_ratio: f64,
258    pub estimated_ratio: f64,
259    pub space_saved_percent: f64,
260    pub efficiency: f64,
261}
262
263/// Model retrieval optimization (caching and prefetching)
264pub struct RetrievalOptimizer {
265    cache: HashMap<String, CachedModel>,
266    max_cache_size: usize,
267    current_cache_size: usize,
268    /// Strictly increasing stamp handed out on every insert and every hit.
269    next_seq: u64,
270}
271
272#[derive(Debug, Clone)]
273struct CachedModel {
274    data: Vec<u8>,
275    access_count: usize,
276    last_access: std::time::SystemTime,
277    /// Tie-breaker for eviction. `last_access` alone is not enough: two
278    /// entries touched within the same clock tick carry *equal* `SystemTime`
279    /// values, and the eviction scan then picked whichever the randomized
280    /// `HashMap` iteration order reached first -- so the cache was not
281    /// reliably LRU. `SystemTime` is also not monotonic and can move
282    /// backwards under NTP. This counter has neither problem.
283    seq: u64,
284}
285
286impl RetrievalOptimizer {
287    /// Create new retrieval optimizer with cache size limit (bytes)
288    pub fn new(max_cache_size: usize) -> Self {
289        Self {
290            cache: HashMap::new(),
291            max_cache_size,
292            current_cache_size: 0,
293            next_seq: 0,
294        }
295    }
296
297    /// Hand out the next eviction stamp.
298    fn bump_seq(&mut self) -> u64 {
299        let seq = self.next_seq;
300        self.next_seq += 1;
301        seq
302    }
303
304    /// Cache a model for faster retrieval
305    pub fn cache_model(&mut self, key: String, data: Vec<u8>) -> Result<()> {
306        let data_size = data.len();
307
308        // Evict old entries if needed
309        while self.current_cache_size + data_size > self.max_cache_size && !self.cache.is_empty() {
310            self.evict_lru();
311        }
312
313        // Don't cache if model is larger than max cache size
314        if data_size > self.max_cache_size {
315            return Ok(());
316        }
317
318        let seq = self.bump_seq();
319        self.current_cache_size += data_size;
320        self.cache.insert(
321            key,
322            CachedModel {
323                data,
324                access_count: 1,
325                last_access: std::time::SystemTime::now(),
326                seq,
327            },
328        );
329
330        Ok(())
331    }
332
333    /// Retrieve model from cache
334    pub fn get_cached(&mut self, key: &str) -> Option<Vec<u8>> {
335        let seq = self.bump_seq();
336        if let Some(cached) = self.cache.get_mut(key) {
337            cached.access_count += 1;
338            cached.last_access = std::time::SystemTime::now();
339            cached.seq = seq;
340            Some(cached.data.clone())
341        } else {
342            None
343        }
344    }
345
346    /// Evict least recently used model.
347    ///
348    /// Ordered by `seq` rather than `last_access` -- see [`CachedModel::seq`].
349    fn evict_lru(&mut self) {
350        if let Some(key) = self
351            .cache
352            .iter()
353            .min_by_key(|(_, v)| v.seq)
354            .map(|(k, _)| k.clone())
355        {
356            if let Some(cached) = self.cache.remove(&key) {
357                self.current_cache_size -= cached.data.len();
358            }
359        }
360    }
361
362    /// Clear cache
363    pub fn clear_cache(&mut self) {
364        self.cache.clear();
365        self.current_cache_size = 0;
366    }
367
368    /// Get cache statistics
369    pub fn cache_stats(&self) -> CacheStats {
370        CacheStats {
371            total_entries: self.cache.len(),
372            total_size: self.current_cache_size,
373            max_size: self.max_cache_size,
374            utilization: (self.current_cache_size as f64 / self.max_cache_size as f64) * 100.0,
375        }
376    }
377}
378
379/// Cache statistics
380#[derive(Debug, Clone)]
381pub struct CacheStats {
382    pub total_entries: usize,
383    pub total_size: usize,
384    pub max_size: usize,
385    pub utilization: f64,
386}
387
388/// Quantization metadata utilities
389pub struct QuantizationInfo;
390
391impl QuantizationInfo {
392    /// Common quantization schemes
393    pub fn schemes() -> Vec<&'static str> {
394        vec![
395            "FP32",   // Full precision
396            "FP16",   // Half precision
397            "BF16",   // Brain float 16
398            "INT8",   // 8-bit integer
399            "INT4",   // 4-bit integer
400            "Q8_0",   // GGUF 8-bit quantization
401            "Q4_0",   // GGUF 4-bit quantization
402            "Q4_K_M", // GGUF 4-bit K-quant medium
403            "Q5_K_M", // GGUF 5-bit K-quant medium
404            "Q6_K",   // GGUF 6-bit K-quant
405        ]
406    }
407
408    /// Estimate model size after quantization
409    #[must_use]
410    pub fn estimate_size(original_size: u64, from_bits: u8, to_bits: u8) -> u64 {
411        if from_bits == 0 || to_bits == 0 {
412            return original_size;
413        }
414        (original_size as f64 * (to_bits as f64 / from_bits as f64)) as u64
415    }
416
417    /// Calculate memory savings from quantization
418    #[must_use]
419    pub fn memory_savings(original_size: u64, quantized_size: u64) -> QuantizationSavings {
420        let saved_bytes = original_size.saturating_sub(quantized_size);
421        let saved_percent = (saved_bytes as f64 / original_size as f64) * 100.0;
422
423        QuantizationSavings {
424            original_size,
425            quantized_size,
426            saved_bytes,
427            saved_percent,
428            size_ratio: original_size as f64 / quantized_size as f64,
429        }
430    }
431
432    /// Validate quantization scheme
433    pub fn is_valid_scheme(scheme: &str) -> bool {
434        Self::schemes().contains(&scheme)
435    }
436}
437
438/// Quantization savings information
439#[derive(Debug, Clone)]
440pub struct QuantizationSavings {
441    pub original_size: u64,
442    pub quantized_size: u64,
443    pub saved_bytes: u64,
444    pub saved_percent: f64,
445    pub size_ratio: f64,
446}
447
448/// Model pruning metadata
449pub struct PruningInfo {
450    pub pruning_method: PruningMethod,
451    pub sparsity_level: f64,
452    pub original_params: u64,
453    pub remaining_params: u64,
454}
455
456#[derive(Debug, Clone, PartialEq)]
457pub enum PruningMethod {
458    Magnitude,
459    Structured,
460    Unstructured,
461    GradientBased,
462    LayerWise,
463    Custom(String),
464}
465
466impl PruningInfo {
467    /// Create new pruning information
468    pub fn new(method: PruningMethod, sparsity: f64, original: u64, remaining: u64) -> Self {
469        Self {
470            pruning_method: method,
471            sparsity_level: sparsity.clamp(0.0, 1.0),
472            original_params: original,
473            remaining_params: remaining,
474        }
475    }
476
477    /// Calculate actual sparsity from parameters
478    #[must_use]
479    pub fn calculate_sparsity(&self) -> f64 {
480        if self.original_params == 0 {
481            return 0.0;
482        }
483        1.0 - (self.remaining_params as f64 / self.original_params as f64)
484    }
485
486    /// Estimate size reduction from pruning
487    #[must_use]
488    pub fn size_reduction(&self) -> f64 {
489        self.calculate_sparsity() * 100.0
490    }
491}
492
493/// Model analysis utilities
494pub struct ModelAnalyzer;
495
496impl ModelAnalyzer {
497    /// Analyze model file
498    pub fn analyze(data: &[u8], metadata: &ModelMetadata) -> ModelAnalysis {
499        let size_bytes = data.len() as u64;
500        let size_mb = size_bytes as f64 / (1024.0 * 1024.0);
501        let size_gb = size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
502
503        // Estimate parameters based on format and size
504        let estimated_params = Self::estimate_parameters(size_bytes, &metadata.format);
505
506        ModelAnalysis {
507            format: metadata.format.clone(),
508            size_bytes,
509            size_mb,
510            size_gb,
511            estimated_parameters: estimated_params,
512            framework: metadata.framework.clone(),
513            task: metadata.task.clone(),
514            architecture: metadata.architecture.clone(),
515        }
516    }
517
518    /// Estimate parameter count from size
519    fn estimate_parameters(size_bytes: u64, format: &ModelFormat) -> Option<u64> {
520        // Rough estimation: 4 bytes per FP32 parameter
521        let base_estimate = size_bytes / 4;
522
523        match format {
524            ModelFormat::GGUF => Some(base_estimate * 2), // Quantized, more params for same size
525            ModelFormat::Safetensors | ModelFormat::PyTorch => Some(base_estimate),
526            ModelFormat::ONNX => Some((base_estimate as f64 * 0.9) as u64), // Overhead
527            ModelFormat::TFLite => Some(base_estimate * 4),                 // Compressed
528            _ => Some(base_estimate),
529        }
530    }
531
532    /// Get human-readable size
533    #[must_use]
534    pub fn format_size(bytes: u64) -> String {
535        const KB: u64 = 1024;
536        const MB: u64 = KB * 1024;
537        const GB: u64 = MB * 1024;
538        const TB: u64 = GB * 1024;
539
540        if bytes >= TB {
541            format!("{:.2} TB", bytes as f64 / TB as f64)
542        } else if bytes >= GB {
543            format!("{:.2} GB", bytes as f64 / GB as f64)
544        } else if bytes >= MB {
545            format!("{:.2} MB", bytes as f64 / MB as f64)
546        } else if bytes >= KB {
547            format!("{:.2} KB", bytes as f64 / KB as f64)
548        } else {
549            format!("{} B", bytes)
550        }
551    }
552
553    /// Get human-readable parameter count
554    #[must_use]
555    pub fn format_parameters(params: u64) -> String {
556        const K: u64 = 1_000;
557        const M: u64 = K * 1_000;
558        const B: u64 = M * 1_000;
559
560        if params >= B {
561            format!("{:.2}B", params as f64 / B as f64)
562        } else if params >= M {
563            format!("{:.2}M", params as f64 / M as f64)
564        } else if params >= K {
565            format!("{:.2}K", params as f64 / K as f64)
566        } else {
567            format!("{}", params)
568        }
569    }
570}
571
572/// Model analysis result
573#[derive(Debug, Clone)]
574pub struct ModelAnalysis {
575    pub format: ModelFormat,
576    pub size_bytes: u64,
577    pub size_mb: f64,
578    pub size_gb: f64,
579    pub estimated_parameters: Option<u64>,
580    pub framework: Option<String>,
581    pub task: Option<String>,
582    pub architecture: Option<String>,
583}
584
585/// Model export utilities
586pub struct ModelExporter;
587
588impl ModelExporter {
589    /// Export model with metadata
590    pub fn export_with_metadata(
591        model_data: Vec<u8>,
592        metadata: &ModelMetadata,
593        output_dir: &Path,
594    ) -> Result<PathBuf> {
595        std::fs::create_dir_all(output_dir)?;
596
597        // Save model file
598        let model_filename = format!("{}.{}", metadata.name, metadata.format.extension());
599        let model_path = output_dir.join(&model_filename);
600        std::fs::write(&model_path, model_data)?;
601
602        // Save metadata as JSON
603        let metadata_filename = format!("{}.meta.json", metadata.name);
604        let metadata_path = output_dir.join(&metadata_filename);
605        let metadata_json = serde_json::to_string_pretty(&metadata)?;
606        std::fs::write(metadata_path, metadata_json)?;
607
608        Ok(model_path)
609    }
610
611    /// Export model to directory structure
612    pub fn export_to_directory(
613        models: Vec<(Vec<u8>, ModelMetadata)>,
614        output_dir: &Path,
615    ) -> Result<Vec<PathBuf>> {
616        std::fs::create_dir_all(output_dir)?;
617
618        let mut paths = Vec::new();
619
620        for (data, metadata) in models {
621            let path = Self::export_with_metadata(data, &metadata, output_dir)?;
622            paths.push(path);
623        }
624
625        Ok(paths)
626    }
627}
628
629/// Model deduplication utilities
630pub struct ModelDeduplicator;
631
632impl ModelDeduplicator {
633    /// Calculate model hash for deduplication
634    #[must_use]
635    pub fn calculate_hash(data: &[u8]) -> String {
636        use sha2::{Digest, Sha256};
637        let mut hasher = Sha256::new();
638        hasher.update(data);
639        hex::encode(hasher.finalize())
640    }
641
642    /// Find duplicate models by hash
643    pub fn find_duplicates(models: Vec<(String, Vec<u8>)>) -> HashMap<String, Vec<String>> {
644        let mut hash_map: HashMap<String, Vec<String>> = HashMap::new();
645
646        for (name, data) in models {
647            let hash = Self::calculate_hash(&data);
648            hash_map.entry(hash).or_default().push(name);
649        }
650
651        // Filter to only duplicates (more than one model with same hash)
652        hash_map
653            .into_iter()
654            .filter(|(_, names)| names.len() > 1)
655            .collect()
656    }
657
658    /// Calculate content similarity (simple byte comparison)
659    #[must_use]
660    pub fn similarity_score(data1: &[u8], data2: &[u8]) -> f64 {
661        if data1.len() != data2.len() {
662            return 0.0;
663        }
664
665        let matching_bytes = data1
666            .iter()
667            .zip(data2.iter())
668            .filter(|(a, b)| a == b)
669            .count();
670
671        (matching_bytes as f64 / data1.len() as f64) * 100.0
672    }
673}
674
675#[cfg(test)]
676// Exact float comparison is intentional here: these assert on literal
677// constants that round-trip bit-for-bit, not on computed results.
678#[allow(clippy::float_cmp)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn test_compression_ratio() {
684        let ratio = CompressionAnalyzer::compression_ratio(1000, 500);
685        assert_eq!(ratio, 2.0);
686
687        let ratio = CompressionAnalyzer::compression_ratio(1000, 250);
688        assert_eq!(ratio, 4.0);
689    }
690
691    #[test]
692    fn test_compression_analysis() {
693        let report = CompressionAnalyzer::analyze_compression(1000, 750, &ModelFormat::PyTorch);
694
695        assert_eq!(report.original_size, 1000);
696        assert_eq!(report.compressed_size, 750);
697        assert_eq!(report.space_saved, 250);
698        assert_eq!(report.space_saved_percent, 25.0);
699    }
700
701    #[test]
702    fn test_quantization_size_estimate() {
703        let size = QuantizationInfo::estimate_size(1000, 32, 16);
704        assert_eq!(size, 500);
705
706        let size = QuantizationInfo::estimate_size(1000, 32, 8);
707        assert_eq!(size, 250);
708    }
709
710    #[test]
711    fn test_quantization_savings() {
712        let savings = QuantizationInfo::memory_savings(1000, 250);
713        assert_eq!(savings.saved_bytes, 750);
714        assert_eq!(savings.saved_percent, 75.0);
715        assert_eq!(savings.size_ratio, 4.0);
716    }
717
718    #[test]
719    fn test_pruning_info() {
720        let info = PruningInfo::new(PruningMethod::Magnitude, 0.5, 1000, 500);
721        assert_eq!(info.calculate_sparsity(), 0.5);
722        assert_eq!(info.size_reduction(), 50.0);
723    }
724
725    #[test]
726    fn test_retrieval_optimizer() {
727        let mut optimizer = RetrievalOptimizer::new(1000);
728
729        // Cache a model
730        optimizer
731            .cache_model("model1".to_string(), vec![0; 100])
732            .unwrap();
733        assert_eq!(optimizer.current_cache_size, 100);
734
735        // Retrieve cached model
736        let cached = optimizer.get_cached("model1");
737        assert!(cached.is_some());
738
739        // Cache another model
740        optimizer
741            .cache_model("model2".to_string(), vec![0; 100])
742            .unwrap();
743        assert_eq!(optimizer.current_cache_size, 200);
744    }
745
746    #[test]
747    fn test_model_analyzer_format_size() {
748        assert_eq!(ModelAnalyzer::format_size(500), "500 B");
749        assert_eq!(ModelAnalyzer::format_size(1024), "1.00 KB");
750        assert_eq!(ModelAnalyzer::format_size(1024 * 1024), "1.00 MB");
751        assert_eq!(ModelAnalyzer::format_size(1024 * 1024 * 1024), "1.00 GB");
752    }
753
754    #[test]
755    fn test_model_analyzer_format_parameters() {
756        assert_eq!(ModelAnalyzer::format_parameters(500), "500");
757        assert_eq!(ModelAnalyzer::format_parameters(1_500), "1.50K");
758        assert_eq!(ModelAnalyzer::format_parameters(1_500_000), "1.50M");
759        assert_eq!(ModelAnalyzer::format_parameters(7_000_000_000), "7.00B");
760    }
761
762    #[test]
763    fn test_deduplicator_hash() {
764        let data1 = b"test data";
765        let hash1 = ModelDeduplicator::calculate_hash(data1);
766        let hash2 = ModelDeduplicator::calculate_hash(data1);
767
768        assert_eq!(hash1, hash2);
769        assert_eq!(hash1.len(), 64); // SHA256 produces 64 hex chars
770    }
771
772    #[test]
773    fn test_deduplicator_similarity() {
774        let data1 = b"test data";
775        let data2 = b"test data";
776        let data3 = b"different";
777
778        let score1 = ModelDeduplicator::similarity_score(data1, data2);
779        assert_eq!(score1, 100.0);
780
781        let score2 = ModelDeduplicator::similarity_score(data1, data3);
782        assert_eq!(score2, 0.0); // Different lengths
783    }
784
785    #[test]
786    fn test_create_and_extract_tar() {
787        // Covers lines 18, plus extract_tar
788        let temp_dir = tempfile::tempdir().unwrap();
789        let tar_path = temp_dir.path().join("test.tar");
790
791        let models = vec![
792            ("model_a.bin".to_string(), vec![1, 2, 3, 4]),
793            ("model_b.bin".to_string(), vec![5, 6, 7, 8, 9]),
794        ];
795
796        let total = ModelArchive::create_tar(models, &tar_path).unwrap();
797        assert_eq!(total, 9); // 4 + 5
798
799        let extracted = ModelArchive::extract_tar(&tar_path).unwrap();
800        assert_eq!(extracted.len(), 2);
801        assert_eq!(extracted[0].1, vec![1, 2, 3, 4]);
802    }
803
804    #[test]
805    fn test_create_and_extract_zip() {
806        // Covers lines 64, 85
807        let temp_dir = tempfile::tempdir().unwrap();
808        let zip_path = temp_dir.path().join("test.zip");
809
810        let models = vec![
811            ("model_x.bin".to_string(), vec![10, 20, 30]),
812            ("model_y.bin".to_string(), vec![40, 50]),
813        ];
814
815        let total = ModelArchive::create_zip(models, &zip_path).unwrap();
816        assert_eq!(total, 5);
817
818        let extracted = ModelArchive::extract_zip(&zip_path).unwrap();
819        assert_eq!(extracted.len(), 2);
820    }
821
822    #[test]
823    fn test_model_analyzer_analyze() {
824        // Covers line 381
825        let data = vec![0u8; 1024 * 1024]; // 1 MB
826        let meta = ModelMetadata::new("test_model".to_string(), ModelFormat::PyTorch);
827        let analysis = ModelAnalyzer::analyze(&data, &meta);
828
829        assert_eq!(analysis.size_bytes, 1024 * 1024);
830        assert!((analysis.size_mb - 1.0).abs() < 0.01);
831        assert!(analysis.estimated_parameters.unwrap() > 0);
832    }
833
834    #[test]
835    fn test_compression_ratio_zero() {
836        let ratio = CompressionAnalyzer::compression_ratio(1000, 0);
837        assert_eq!(ratio, 0.0);
838    }
839
840    #[test]
841    fn test_estimate_ratio_all_formats() {
842        assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::Safetensors) > 1.0);
843        assert_eq!(CompressionAnalyzer::estimate_ratio(&ModelFormat::GGUF), 1.0);
844        assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::PyTorch) > 1.0);
845        assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::ONNX) > 1.0);
846        assert_eq!(
847            CompressionAnalyzer::estimate_ratio(&ModelFormat::TensorRT),
848            1.0
849        );
850        assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::TFLite) >= 1.0);
851        assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::HDF5) > 1.0);
852        assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::Pickle) > 1.0);
853        // Default case
854        assert!(CompressionAnalyzer::estimate_ratio(&ModelFormat::CoreML) > 0.0);
855    }
856
857    #[test]
858    fn test_retrieval_optimizer_eviction() {
859        let mut opt = RetrievalOptimizer::new(100);
860        opt.cache_model("m1".to_string(), vec![0; 60]).unwrap();
861        opt.cache_model("m2".to_string(), vec![0; 60]).unwrap();
862        // m1 should have been evicted
863        assert!(opt.get_cached("m1").is_none());
864        assert!(opt.get_cached("m2").is_some());
865    }
866
867    #[test]
868    fn test_retrieval_optimizer_oversized() {
869        let mut opt = RetrievalOptimizer::new(10);
870        opt.cache_model("big".to_string(), vec![0; 100]).unwrap();
871        assert!(opt.get_cached("big").is_none());
872    }
873
874    #[test]
875    fn test_retrieval_optimizer_clear() {
876        let mut opt = RetrievalOptimizer::new(1000);
877        opt.cache_model("m1".to_string(), vec![1]).unwrap();
878        opt.clear_cache();
879        assert!(opt.get_cached("m1").is_none());
880        let stats = opt.cache_stats();
881        assert_eq!(stats.total_entries, 0);
882        assert_eq!(stats.total_size, 0);
883    }
884
885    #[test]
886    fn test_retrieval_optimizer_stats() {
887        let mut opt = RetrievalOptimizer::new(1000);
888        opt.cache_model("m1".to_string(), vec![0; 100]).unwrap();
889        let stats = opt.cache_stats();
890        assert_eq!(stats.total_entries, 1);
891        assert_eq!(stats.total_size, 100);
892        assert_eq!(stats.max_size, 1000);
893        assert!((stats.utilization - 10.0).abs() < 0.01);
894    }
895
896    #[test]
897    fn test_quantization_schemes() {
898        let schemes = QuantizationInfo::schemes();
899        assert!(schemes.contains(&"FP32"));
900        assert!(schemes.contains(&"Q4_K_M"));
901        assert!(!schemes.is_empty());
902    }
903
904    #[test]
905    fn test_quantization_estimate_size_zero_bits() {
906        let size = QuantizationInfo::estimate_size(1000, 0, 8);
907        assert_eq!(size, 1000);
908        let size2 = QuantizationInfo::estimate_size(1000, 32, 0);
909        assert_eq!(size2, 1000);
910    }
911
912    #[test]
913    fn test_quantization_is_valid_scheme() {
914        assert!(QuantizationInfo::is_valid_scheme("FP32"));
915        assert!(QuantizationInfo::is_valid_scheme("Q4_K_M"));
916        assert!(!QuantizationInfo::is_valid_scheme("INVALID"));
917    }
918
919    #[test]
920    fn test_pruning_zero_params() {
921        let info = PruningInfo::new(PruningMethod::Magnitude, 0.5, 0, 0);
922        assert_eq!(info.calculate_sparsity(), 0.0);
923        assert_eq!(info.size_reduction(), 0.0);
924    }
925
926    #[test]
927    fn test_pruning_methods() {
928        assert_eq!(PruningMethod::Magnitude, PruningMethod::Magnitude);
929        assert_ne!(PruningMethod::Structured, PruningMethod::Unstructured);
930        // Construct the remaining variants to keep them exercised.
931        assert_ne!(PruningMethod::GradientBased, PruningMethod::LayerWise);
932        assert_eq!(
933            PruningMethod::Custom("test".into()),
934            PruningMethod::Custom("test".into())
935        );
936    }
937
938    #[test]
939    fn test_model_analyzer_format_size_tb() {
940        let tb = 1024u64 * 1024 * 1024 * 1024;
941        assert!(ModelAnalyzer::format_size(tb).contains("TB"));
942    }
943
944    #[test]
945    fn test_model_analyzer_estimate_params_formats() {
946        // Test analysis with different formats to hit different estimate branches
947        let data = vec![0u8; 4000]; // 4KB
948        for fmt in &[
949            ModelFormat::GGUF,
950            ModelFormat::ONNX,
951            ModelFormat::TFLite,
952            ModelFormat::Safetensors,
953        ] {
954            let meta = ModelMetadata::new("m".to_string(), fmt.clone());
955            let analysis = ModelAnalyzer::analyze(&data, &meta);
956            assert!(analysis.estimated_parameters.is_some());
957        }
958    }
959
960    #[test]
961    fn test_model_exporter_export_with_metadata() {
962        let temp_dir = tempfile::tempdir().unwrap();
963        let meta = ModelMetadata::new("exported".to_string(), ModelFormat::PyTorch);
964        let data = vec![1, 2, 3, 4];
965        let path = ModelExporter::export_with_metadata(data, &meta, temp_dir.path()).unwrap();
966        assert!(path.exists());
967        assert!(temp_dir.path().join("exported.meta.json").exists());
968    }
969
970    #[test]
971    fn test_model_exporter_export_to_directory() {
972        let temp_dir = tempfile::tempdir().unwrap();
973        let models = vec![
974            (
975                vec![1, 2],
976                ModelMetadata::new("a".to_string(), ModelFormat::ONNX),
977            ),
978            (
979                vec![3, 4],
980                ModelMetadata::new("b".to_string(), ModelFormat::PyTorch),
981            ),
982        ];
983        let paths = ModelExporter::export_to_directory(models, temp_dir.path()).unwrap();
984        assert_eq!(paths.len(), 2);
985        for p in &paths {
986            assert!(p.exists());
987        }
988    }
989
990    #[test]
991    fn test_deduplicator_find_duplicates() {
992        let models = vec![
993            ("model_a".to_string(), vec![1, 2, 3]),
994            ("model_b".to_string(), vec![1, 2, 3]), // duplicate of a
995            ("model_c".to_string(), vec![4, 5, 6]), // unique
996        ];
997        let dupes = ModelDeduplicator::find_duplicates(models);
998        assert_eq!(dupes.len(), 1);
999        let names: Vec<&Vec<String>> = dupes.values().collect();
1000        let names = &names[0];
1001        assert!(names.contains(&"model_a".to_string()));
1002        assert!(names.contains(&"model_b".to_string()));
1003    }
1004
1005    #[test]
1006    fn test_deduplicator_similarity_partial() {
1007        let data1 = vec![1, 2, 3, 4];
1008        let data2 = vec![1, 2, 0, 0];
1009        let score = ModelDeduplicator::similarity_score(&data1, &data2);
1010        assert!((score - 50.0).abs() < 0.1);
1011    }
1012
1013    #[test]
1014    fn test_similarity_score_different_lengths() {
1015        let a = vec![1u8, 2, 3];
1016        let b = vec![1u8, 2, 3, 4];
1017        let score = ModelDeduplicator::similarity_score(&a, &b);
1018        assert_eq!(score, 0.0);
1019    }
1020
1021    #[test]
1022    fn test_estimate_parameters_custom_format() {
1023        // Covers L411 — wildcard match arm in estimate_parameters for Custom format
1024        let data = vec![0u8; 1024]; // 1KB
1025        let meta = ModelMetadata::new(
1026            "custom_fmt".to_string(),
1027            ModelFormat::Custom("myformat".to_string()),
1028        );
1029        let analysis = ModelAnalyzer::analyze(&data, &meta);
1030        // Custom format should hit the wildcard `_ => Some(base_estimate)` at L411
1031        assert!(analysis.estimated_parameters.is_some());
1032        // base_estimate = 1024 / 4 = 256
1033        assert_eq!(analysis.estimated_parameters.unwrap(), 256);
1034    }
1035    // ── Archive extraction path traversal (zip slip, CWE-22) ────────────────
1036
1037    #[test]
1038    fn test_safe_archive_name_accepts_ordinary_names() {
1039        assert_eq!(
1040            safe_archive_name("model.safetensors").unwrap(),
1041            "model.safetensors"
1042        );
1043        assert_eq!(safe_archive_name("llama-7b.bin").unwrap(), "llama-7b.bin");
1044    }
1045
1046    /// Rejected on every platform: `/` is a separator everywhere, and `..`,
1047    /// `.` and the empty string are never ordinary file names.
1048    #[test]
1049    fn test_safe_archive_name_rejects_escapes() {
1050        for hostile in [
1051            "../evil",
1052            "../../evil",
1053            "a/b",
1054            "a/../b",
1055            "/etc/passwd",
1056            "..",
1057            ".",
1058            "",
1059        ] {
1060            assert!(
1061                safe_archive_name(hostile).is_err(),
1062                "{hostile:?} must be rejected"
1063            );
1064        }
1065    }
1066
1067    /// Backslash is a separator on Windows and an ordinary character on Unix,
1068    /// so the correct answer genuinely differs by platform. `std::path`
1069    /// already encodes that, and `safe_archive_name` inherits it — these two
1070    /// tests pin both halves rather than assuming Windows rules everywhere.
1071    #[cfg(windows)]
1072    #[test]
1073    fn test_safe_archive_name_rejects_windows_separators_and_prefixes() {
1074        for hostile in [
1075            r"a\b",
1076            r"..\..\evil",
1077            r"C:\Windows\evil",
1078            "C:/Windows/evil",
1079            r"\\server\share\x",
1080        ] {
1081            assert!(
1082                safe_archive_name(hostile).is_err(),
1083                "{hostile:?} must be rejected on Windows"
1084            );
1085        }
1086    }
1087
1088    /// On Unix a member literally named `a\b` is one legal file, not a path.
1089    /// Extracting it creates a single oddly-named file inside the output
1090    /// directory, which is not an escape — so accepting it is correct.
1091    #[cfg(unix)]
1092    #[test]
1093    fn test_safe_archive_name_treats_backslash_as_an_ordinary_character() {
1094        assert_eq!(safe_archive_name(r"a\b").unwrap(), r"a\b");
1095        assert_eq!(
1096            safe_archive_name(r"C:\Windows\evil").unwrap(),
1097            r"C:\Windows\evil"
1098        );
1099
1100        // ...but a real Unix traversal is still refused.
1101        assert!(safe_archive_name("../evil").is_err());
1102    }
1103
1104    /// A ZIP whose member name climbs out of the extraction directory must be
1105    /// refused outright, not extracted and not silently renamed.
1106    #[test]
1107    fn test_extract_zip_refuses_a_member_that_escapes() {
1108        let dir = tempfile::tempdir().unwrap();
1109        let archive = dir.path().join("evil.zip");
1110
1111        {
1112            let file = std::fs::File::create(&archive).unwrap();
1113            let mut zip = zip::ZipWriter::new(file);
1114            let opts = zip::write::SimpleFileOptions::default();
1115            zip.start_file("legit.bin", opts).unwrap();
1116            zip.write_all(b"harmless").unwrap();
1117            zip.start_file("../../ESCAPED.txt", opts).unwrap();
1118            zip.write_all(b"pwned").unwrap();
1119            zip.finish().unwrap();
1120        }
1121
1122        let err = ModelArchive::extract_zip(&archive).unwrap_err();
1123        assert!(
1124            matches!(err, VaultError::InvalidInput(_)),
1125            "expected InvalidInput, got {err:?}"
1126        );
1127        assert!(err.to_string().contains("ESCAPED.txt"), "got: {err}");
1128    }
1129
1130    /// The same for TAR, which previously reduced the name with `file_name()`
1131    /// — safe from traversal, but it silently turned `../../x` into `x`.
1132    #[test]
1133    fn test_extract_tar_refuses_a_member_that_escapes() {
1134        let dir = tempfile::tempdir().unwrap();
1135        let archive = dir.path().join("evil.tar");
1136
1137        {
1138            let file = std::fs::File::create(&archive).unwrap();
1139            let mut ar = tar::Builder::new(file);
1140            let payload = b"pwned";
1141
1142            // `append_data` refuses a `..` path, which is exactly the point:
1143            // tar-rs will not *produce* a hostile archive, so one has to be
1144            // forged by writing the header's name field directly — which is
1145            // what an attacker does. The reader must not trust it.
1146            let mut header = tar::Header::new_gnu();
1147            let hostile = b"../../ESCAPED.txt";
1148            {
1149                let gnu = header.as_gnu_mut().expect("new_gnu is a GNU header");
1150                gnu.name[..hostile.len()].copy_from_slice(hostile);
1151            }
1152            header.set_size(payload.len() as u64);
1153            header.set_mode(0o644);
1154            header.set_cksum();
1155
1156            ar.append(&header, &payload[..]).unwrap();
1157            ar.finish().unwrap();
1158        }
1159
1160        // Confirm the forged name really is in the archive, so a future change
1161        // to tar-rs that sanitises on read cannot make this test vacuous.
1162        {
1163            let file = std::fs::File::open(&archive).unwrap();
1164            let mut probe = tar::Archive::new(file);
1165            let names: Vec<String> = probe
1166                .entries()
1167                .unwrap()
1168                .map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
1169                .collect();
1170            assert!(
1171                names.iter().any(|n| n.contains("..")),
1172                "forged archive lost its traversal: {names:?}"
1173            );
1174        }
1175
1176        let err = ModelArchive::extract_tar(&archive).unwrap_err();
1177        assert!(
1178            matches!(err, VaultError::InvalidInput(_)),
1179            "expected InvalidInput, got {err:?}"
1180        );
1181    }
1182
1183    /// Archives this crate produces must still round-trip.
1184    #[test]
1185    fn test_round_trip_still_works_for_both_formats() {
1186        let dir = tempfile::tempdir().unwrap();
1187        let models = vec![
1188            ("alpha.safetensors".to_string(), b"aaa".to_vec()),
1189            ("beta.gguf".to_string(), b"bbb".to_vec()),
1190        ];
1191
1192        let tar_path = dir.path().join("a.tar");
1193        ModelArchive::create_tar(models.clone(), &tar_path).unwrap();
1194        let mut out = ModelArchive::extract_tar(&tar_path).unwrap();
1195        out.sort();
1196        assert_eq!(out, models);
1197
1198        let zip_path = dir.path().join("a.zip");
1199        ModelArchive::create_zip(models.clone(), &zip_path).unwrap();
1200        let mut out = ModelArchive::extract_zip(&zip_path).unwrap();
1201        out.sort();
1202        assert_eq!(out, models);
1203    }
1204
1205    #[test]
1206    fn test_archive_budget_rejects_an_oversized_member() {
1207        let err = check_archive_budget("bomb.bin", MAX_ARCHIVE_MEMBER_BYTES + 1, 0).unwrap_err();
1208        assert!(
1209            err.to_string().contains("per-member limit"),
1210            "expected a per-member limit error, got: {err}"
1211        );
1212
1213        // Exactly at the limit is allowed — the cap is inclusive.
1214        check_archive_budget("big.bin", MAX_ARCHIVE_MEMBER_BYTES, 0).unwrap();
1215    }
1216
1217    #[test]
1218    fn test_archive_budget_rejects_many_members_that_are_each_legal() {
1219        // Each member is under the per-member cap, so only the running total
1220        // catches this one.
1221        let member = MAX_ARCHIVE_MEMBER_BYTES;
1222        let mut total: u64 = 0;
1223        let mut rejected_at = None;
1224
1225        for i in 0..8 {
1226            match check_archive_budget("chunk.bin", member, total) {
1227                Ok(()) => total += member,
1228                Err(err) => {
1229                    assert!(
1230                        err.to_string().contains("total limit"),
1231                        "expected a total-limit error, got: {err}"
1232                    );
1233                    rejected_at = Some(i);
1234                    break;
1235                }
1236            }
1237        }
1238
1239        assert_eq!(
1240            rejected_at,
1241            Some(2),
1242            "16 GiB of budget should admit exactly two 8 GiB members"
1243        );
1244    }
1245
1246    #[test]
1247    fn test_archive_budget_does_not_overflow_on_a_u64_max_claim() {
1248        // `running_total + declared` would wrap without the saturating add, and
1249        // a wrapped sum compares as *under* the limit.
1250        let err = check_archive_budget("liar.bin", u64::MAX, u64::MAX).unwrap_err();
1251        assert!(err.to_string().contains("limit"), "got: {err}");
1252    }
1253
1254    #[test]
1255    fn test_read_member_bounded_rejects_contents_that_overrun_the_declared_size() {
1256        // The declared size is only a claim; this is the backstop for a member
1257        // whose header understates it.
1258        let payload = vec![0u8; 64];
1259
1260        let mut reader = &payload[..];
1261        let err = read_member_bounded("liar.bin", &mut reader, 16).unwrap_err();
1262        assert!(
1263            err.to_string().contains("understated"),
1264            "expected an overrun error, got: {err}"
1265        );
1266
1267        // A member that fits is returned whole, including one exactly at the
1268        // limit — the +1 read must not be mistaken for an overrun.
1269        let mut reader = &payload[..];
1270        assert_eq!(
1271            read_member_bounded("ok.bin", &mut reader, 64).unwrap(),
1272            payload
1273        );
1274    }
1275
1276    /// Eviction must pick the least-recently-used entry every time, not
1277    /// whichever one the `HashMap` iteration order happens to reach first.
1278    ///
1279    /// This is a regression test for a real flake: `evict_lru` ordered by
1280    /// `last_access`, and three inserts in a row complete well inside one
1281    /// clock tick, so all three timestamps compared equal. `min_by_key`
1282    /// returns the *first* minimum it encounters, and `RandomState` reseeds
1283    /// iteration order per process -- so which entry got evicted varied run
1284    /// to run. It surfaced as `cache_eviction` failing one release build
1285    /// after passing 27 CI jobs on the same commit.
1286    ///
1287    /// The loop is what makes this deterministic: a single round reproduced
1288    /// the bug only when the ordering happened to land wrong.
1289    #[test]
1290    fn cache_evicts_strictly_least_recently_used() {
1291        for round in 0..64 {
1292            let mut opt = RetrievalOptimizer::new(200);
1293
1294            opt.cache_model("m1".into(), vec![0; 100]).unwrap();
1295            opt.cache_model("m2".into(), vec![0; 100]).unwrap();
1296
1297            // Touch m1 so m2 is now the least recently used, inverting the
1298            // insertion order. A timestamp-only comparison cannot see this
1299            // on a fast machine.
1300            assert!(opt.get_cached("m1").is_some(), "round {round}");
1301
1302            opt.cache_model("m3".into(), vec![0; 100]).unwrap();
1303
1304            assert!(
1305                opt.get_cached("m2").is_none(),
1306                "round {round}: m2 was least recently used and should have been evicted"
1307            );
1308            assert!(
1309                opt.get_cached("m1").is_some(),
1310                "round {round}: m1 was touched"
1311            );
1312            assert!(
1313                opt.get_cached("m3").is_some(),
1314                "round {round}: m3 is newest"
1315            );
1316        }
1317    }
1318
1319    /// Plain insertion order, with no intervening access: oldest goes first.
1320    #[test]
1321    fn cache_evicts_in_insertion_order_when_untouched() {
1322        for round in 0..64 {
1323            let mut opt = RetrievalOptimizer::new(200);
1324            opt.cache_model("m1".into(), vec![0; 100]).unwrap();
1325            opt.cache_model("m2".into(), vec![0; 100]).unwrap();
1326            opt.cache_model("m3".into(), vec![0; 100]).unwrap();
1327
1328            assert!(opt.get_cached("m1").is_none(), "round {round}");
1329            assert!(opt.get_cached("m3").is_some(), "round {round}");
1330        }
1331    }
1332
1333    /// The two tests above only tie the timestamps if the platform clock is
1334    /// coarse enough that three inserts land in one tick. That is true on the
1335    /// macOS runner -- which is where this bug surfaced -- and false on
1336    /// Windows, where `SystemTime` advances between the calls and the buggy
1337    /// implementation passes. A regression test that only fails on one
1338    /// platform is not much of a guard.
1339    ///
1340    /// So force the condition instead of hoping for it: stamp every entry
1341    /// with an *identical* `last_access` and check eviction still respects
1342    /// access order. Ordering by `last_access` cannot pass this; ordering by
1343    /// `seq` cannot fail it.
1344    #[test]
1345    fn cache_eviction_is_deterministic_when_timestamps_are_identical() {
1346        for round in 0..64 {
1347            let mut opt = RetrievalOptimizer::new(200);
1348            opt.cache_model("m1".into(), vec![0; 100]).unwrap();
1349            opt.cache_model("m2".into(), vec![0; 100]).unwrap();
1350
1351            // Collapse the clock: every entry now looks equally recent.
1352            let frozen = std::time::SystemTime::now();
1353            for entry in opt.cache.values_mut() {
1354                entry.last_access = frozen;
1355            }
1356
1357            opt.cache_model("m3".into(), vec![0; 100]).unwrap();
1358
1359            assert!(
1360                opt.cache.contains_key("m2"),
1361                "round {round}: m1 was inserted first and must be evicted first, \
1362                 but eviction fell back to HashMap iteration order"
1363            );
1364            assert!(!opt.cache.contains_key("m1"), "round {round}");
1365            assert!(opt.cache.contains_key("m3"), "round {round}");
1366        }
1367    }
1368}