Skip to main content

fugue_evo/checkpoint/
recovery.rs

1//! Checkpoint recovery and persistence
2//!
3//! Provides serialization to/from files with compression and versioning.
4
5use serde::{de::DeserializeOwned, Serialize};
6
7#[cfg(feature = "checkpoint")]
8use bincode::Options;
9#[cfg(feature = "checkpoint")]
10use std::fs::{self, File};
11#[cfg(feature = "checkpoint")]
12use std::io::{BufReader, BufWriter, Read, Write};
13#[cfg(feature = "checkpoint")]
14use std::path::{Path, PathBuf};
15
16use super::state::{Checkpoint, CHECKPOINT_VERSION};
17use crate::error::CheckpointError;
18
19/// Default cap on the size (in bytes) of a checkpoint file that
20/// [`load_checkpoint`] will deserialize (EV-48). Files larger than this, and
21/// individual length-prefixed fields larger than this, are rejected with a
22/// typed error rather than triggering an unbounded allocation.
23pub const DEFAULT_MAX_CHECKPOINT_BYTES: u64 = 256 * 1024 * 1024;
24
25/// Oldest checkpoint schema version this build can still load (EV-45).
26///
27/// Checkpoints older than this are rejected with
28/// [`CheckpointError::VersionTooOld`] rather than being silently
29/// misinterpreted.
30pub const MIN_SUPPORTED_CHECKPOINT_VERSION: u32 = 1;
31
32/// Validate a checkpoint schema version against the supported range (EV-45).
33///
34/// Applied uniformly to every format (JSON, binary, compressed binary) so that
35/// a JSON checkpoint written by an incompatible library version is gated
36/// exactly like its binary siblings.
37#[cfg(feature = "checkpoint")]
38fn check_version(version: u32) -> Result<(), CheckpointError> {
39    if version > CHECKPOINT_VERSION {
40        return Err(CheckpointError::VersionMismatch {
41            expected: CHECKPOINT_VERSION,
42            found: version,
43        });
44    }
45    if version < MIN_SUPPORTED_CHECKPOINT_VERSION {
46        return Err(CheckpointError::VersionTooOld(version));
47    }
48    Ok(())
49}
50
51/// bincode options for reading checkpoint data with a total-size limit (EV-48).
52///
53/// Uses fixint encoding + trailing-byte tolerance so the produced format is
54/// byte-compatible with `bincode::serialize`/`serialize_into` (used on the
55/// write path), while adding a byte limit that turns a corrupted length prefix
56/// into a clean error instead of a huge allocation.
57#[cfg(feature = "checkpoint")]
58fn bincode_read_options(limit: u64) -> impl Options {
59    bincode::DefaultOptions::new()
60        .with_fixint_encoding()
61        .allow_trailing_bytes()
62        .with_limit(limit)
63}
64
65/// Compute the temporary sibling path used for atomic writes (EV-47).
66#[cfg(feature = "checkpoint")]
67fn temp_path_for(path: &Path) -> PathBuf {
68    let mut os = path.as_os_str().to_owned();
69    os.push(".tmp");
70    PathBuf::from(os)
71}
72
73/// Parse the numeric index out of a checkpoint filename such as
74/// `evolution_00000042.ckpt` (EV-46 / EV-87).
75///
76/// Width-agnostic: it accepts both the current zero-padded 8-digit names and
77/// legacy 4-digit (`{:04}`) names, so ordering and restart-resume keep working
78/// across a format change.
79#[cfg(feature = "checkpoint")]
80fn parse_checkpoint_index(file_name: &str, base_name: &str) -> Option<usize> {
81    let rest = file_name.strip_prefix(base_name)?.strip_prefix('_')?;
82    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
83    if digits.is_empty() {
84        return None;
85    }
86    // Whatever follows the digits must be an extension separator (or nothing).
87    let tail = &rest[digits.len()..];
88    if !tail.is_empty() && !tail.starts_with('.') {
89        return None;
90    }
91    digits.parse::<usize>().ok()
92}
93
94/// Scan a directory for the highest existing checkpoint index for `base_name`
95/// (EV-46). Returns `None` if the directory is unreadable or has no matching
96/// files.
97#[cfg(feature = "checkpoint")]
98fn scan_max_index(directory: &Path, base_name: &str) -> Option<usize> {
99    std::fs::read_dir(directory)
100        .ok()?
101        .filter_map(|e| e.ok())
102        .filter_map(|e| {
103            let name = e.file_name().to_string_lossy().into_owned();
104            parse_checkpoint_index(&name, base_name)
105        })
106        .max()
107}
108
109/// Format for checkpoint serialization
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum CheckpointFormat {
112    /// JSON format (human-readable, larger)
113    Json,
114    /// Binary format (compact, fast)
115    Binary,
116    /// Compressed binary (smallest, slower)
117    CompressedBinary,
118}
119
120impl Default for CheckpointFormat {
121    fn default() -> Self {
122        Self::Binary
123    }
124}
125
126/// Save a checkpoint to a file
127#[cfg(feature = "checkpoint")]
128pub fn save_checkpoint<G>(
129    checkpoint: &Checkpoint<G>,
130    path: impl AsRef<Path>,
131    format: CheckpointFormat,
132) -> Result<(), CheckpointError>
133where
134    G: Clone + Serialize + crate::genome::traits::EvolutionaryGenome,
135{
136    let path = path.as_ref();
137
138    // EV-47: write to a temporary sibling file, fsync it, then atomically
139    // rename into place. A crash mid-write can only corrupt the throwaway
140    // `.tmp` file, never the destination the reader loads from.
141    let tmp_path = temp_path_for(path);
142
143    let write_result = (|| -> Result<(), CheckpointError> {
144        let file = File::create(&tmp_path)?;
145        let mut writer = BufWriter::new(file);
146
147        match format {
148            CheckpointFormat::Json => {
149                serde_json::to_writer_pretty(&mut writer, checkpoint)
150                    .map_err(|e| CheckpointError::SerializeError(Box::new(e)))?;
151            }
152            CheckpointFormat::Binary => {
153                // Write version header first
154                writer.write_all(&CHECKPOINT_VERSION.to_le_bytes())?;
155                // Write magic bytes for format identification
156                writer.write_all(b"FEVO")?;
157                // Serialize with bincode
158                bincode::serialize_into(&mut writer, checkpoint)
159                    .map_err(|e| CheckpointError::SerializeError(Box::new(e)))?;
160            }
161            CheckpointFormat::CompressedBinary => {
162                // Write version and magic
163                writer.write_all(&CHECKPOINT_VERSION.to_le_bytes())?;
164                writer.write_all(b"FEVC")?; // C for compressed
165                                            // Serialize to bytes first
166                let bytes = bincode::serialize(checkpoint)
167                    .map_err(|e| CheckpointError::SerializeError(Box::new(e)))?;
168                // Compress with simple RLE-like compression
169                let compressed = compress_data(&bytes);
170                // Write length and data
171                writer.write_all(&(compressed.len() as u64).to_le_bytes())?;
172                writer.write_all(&compressed)?;
173            }
174        }
175
176        writer.flush()?;
177        // Recover the underlying File and fsync data + metadata *before* the
178        // rename, so a successful rename implies fully-durable contents.
179        let file = writer.into_inner().map_err(|e| e.into_error())?;
180        file.sync_all()?;
181        Ok(())
182    })();
183
184    if let Err(e) = write_result {
185        // Best-effort cleanup of the partial temp file.
186        let _ = fs::remove_file(&tmp_path);
187        return Err(e);
188    }
189
190    fs::rename(&tmp_path, path)?;
191    Ok(())
192}
193
194/// Load a checkpoint from a file using the default size limit
195/// ([`DEFAULT_MAX_CHECKPOINT_BYTES`]).
196#[cfg(feature = "checkpoint")]
197pub fn load_checkpoint<G>(path: impl AsRef<Path>) -> Result<Checkpoint<G>, CheckpointError>
198where
199    G: Clone + Serialize + DeserializeOwned + crate::genome::traits::EvolutionaryGenome,
200{
201    load_checkpoint_with_limit(path, DEFAULT_MAX_CHECKPOINT_BYTES)
202}
203
204/// Load a checkpoint from a file, rejecting inputs larger than `max_bytes`.
205///
206/// The limit guards against unbounded allocation from a corrupted or hostile
207/// checkpoint (EV-48): both the on-disk file size and every length-prefixed
208/// field decoded by bincode are bounded by `max_bytes`. The embedded schema
209/// version is validated for every format, including JSON (EV-45).
210#[cfg(feature = "checkpoint")]
211pub fn load_checkpoint_with_limit<G>(
212    path: impl AsRef<Path>,
213    max_bytes: u64,
214) -> Result<Checkpoint<G>, CheckpointError>
215where
216    G: Clone + Serialize + DeserializeOwned + crate::genome::traits::EvolutionaryGenome,
217{
218    let path = path.as_ref();
219    if !path.exists() {
220        return Err(CheckpointError::NotFound(path.display().to_string()));
221    }
222
223    // EV-48: reject oversized files up front, before any large read/allocation.
224    let file_len = fs::metadata(path)?.len();
225    if file_len > max_bytes {
226        return Err(CheckpointError::TooLarge {
227            size: file_len,
228            limit: max_bytes,
229        });
230    }
231
232    let file = File::open(path)?;
233    let mut reader = BufReader::new(file);
234
235    // Try to detect format by reading first bytes
236    let mut header = [0u8; 8];
237    reader.read_exact(&mut header)?;
238
239    // Check for binary format magic
240    if &header[4..8] == b"FEVO" {
241        // Binary format
242        let version = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
243        check_version(version)?;
244
245        let checkpoint: Checkpoint<G> = bincode_read_options(max_bytes)
246            .deserialize_from(&mut reader)
247            .map_err(|e| CheckpointError::DeserializeError(e))?;
248        check_version(checkpoint.version)?;
249        Ok(checkpoint)
250    } else if &header[4..8] == b"FEVC" {
251        // Compressed binary format
252        let version = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
253        check_version(version)?;
254
255        // Read length
256        let mut len_bytes = [0u8; 8];
257        reader.read_exact(&mut len_bytes)?;
258        let compressed_len = u64::from_le_bytes(len_bytes);
259        // EV-48: a corrupted length prefix must not drive a huge allocation.
260        if compressed_len > max_bytes {
261            return Err(CheckpointError::TooLarge {
262                size: compressed_len,
263                limit: max_bytes,
264            });
265        }
266
267        // Read compressed data
268        let mut compressed = vec![0u8; compressed_len as usize];
269        reader.read_exact(&mut compressed)?;
270
271        // Decompress
272        let decompressed = decompress_data(&compressed).map_err(CheckpointError::Corrupted)?;
273
274        let checkpoint: Checkpoint<G> = bincode_read_options(max_bytes)
275            .deserialize(&decompressed)
276            .map_err(|e| CheckpointError::DeserializeError(e))?;
277        check_version(checkpoint.version)?;
278        Ok(checkpoint)
279    } else {
280        // Try JSON format - need to re-read from start
281        drop(reader);
282        let file = File::open(path)?;
283        let reader = BufReader::new(file);
284
285        let checkpoint: Checkpoint<G> = serde_json::from_reader(reader)
286            .map_err(|e| CheckpointError::DeserializeError(Box::new(e)))?;
287        // EV-45: JSON was previously accepted with no version gate at all.
288        check_version(checkpoint.version)?;
289        Ok(checkpoint)
290    }
291}
292
293/// Simple compression using run-length encoding for repeated bytes
294#[cfg(feature = "checkpoint")]
295fn compress_data(data: &[u8]) -> Vec<u8> {
296    if data.is_empty() {
297        return Vec::new();
298    }
299
300    let mut compressed = Vec::with_capacity(data.len());
301    let mut i = 0;
302
303    while i < data.len() {
304        let byte = data[i];
305        let mut count = 1u8;
306
307        // Count consecutive identical bytes (max 255)
308        while i + (count as usize) < data.len() && data[i + (count as usize)] == byte && count < 255
309        {
310            count += 1;
311        }
312
313        if count >= 4 || byte == 0xFF {
314            // Use RLE: 0xFF, count, byte
315            compressed.push(0xFF);
316            compressed.push(count);
317            compressed.push(byte);
318        } else {
319            // Store literally
320            for _ in 0..count {
321                if byte == 0xFF {
322                    compressed.push(0xFF);
323                    compressed.push(1);
324                    compressed.push(0xFF);
325                } else {
326                    compressed.push(byte);
327                }
328            }
329        }
330
331        i += count as usize;
332    }
333
334    compressed
335}
336
337/// Decompress RLE-encoded data
338#[cfg(feature = "checkpoint")]
339fn decompress_data(data: &[u8]) -> Result<Vec<u8>, String> {
340    let mut decompressed = Vec::new();
341    let mut i = 0;
342
343    while i < data.len() {
344        if data[i] == 0xFF {
345            if i + 2 >= data.len() {
346                return Err("Truncated RLE sequence".to_string());
347            }
348            let count = data[i + 1] as usize;
349            let byte = data[i + 2];
350            for _ in 0..count {
351                decompressed.push(byte);
352            }
353            i += 3;
354        } else {
355            decompressed.push(data[i]);
356            i += 1;
357        }
358    }
359
360    Ok(decompressed)
361}
362
363/// Checkpoint manager for automatic saving
364#[cfg(feature = "checkpoint")]
365pub struct CheckpointManager {
366    /// Directory for checkpoint files
367    pub directory: std::path::PathBuf,
368    /// Base filename for checkpoints
369    pub base_name: String,
370    /// Serialization format
371    pub format: CheckpointFormat,
372    /// How many checkpoints to keep
373    pub keep_n: usize,
374    /// Save interval (generations)
375    pub interval: usize,
376    /// Maximum checkpoint size accepted when loading (EV-48)
377    pub max_bytes: u64,
378    /// Current checkpoint index
379    current_index: usize,
380}
381
382#[cfg(feature = "checkpoint")]
383impl CheckpointManager {
384    /// Create a new checkpoint manager.
385    ///
386    /// The manager is restart-safe (EV-46): it scans `directory` for existing
387    /// checkpoints named after `base_name` and continues the index *after* the
388    /// highest one found, so a fresh manager constructed after a crash never
389    /// overwrites or shadows pre-crash checkpoints.
390    pub fn new(directory: impl Into<std::path::PathBuf>, base_name: impl Into<String>) -> Self {
391        let directory = directory.into();
392        let base_name = base_name.into();
393        let current_index = scan_max_index(&directory, &base_name)
394            .map(|max| max + 1)
395            .unwrap_or(0);
396        Self {
397            directory,
398            base_name,
399            format: CheckpointFormat::Binary,
400            keep_n: 3,
401            interval: 100,
402            max_bytes: DEFAULT_MAX_CHECKPOINT_BYTES,
403            current_index,
404        }
405    }
406
407    /// Set the serialization format
408    pub fn with_format(mut self, format: CheckpointFormat) -> Self {
409        self.format = format;
410        self
411    }
412
413    /// Set the maximum checkpoint size accepted by [`load_latest`] (EV-48).
414    ///
415    /// [`load_latest`]: CheckpointManager::load_latest
416    pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
417        self.max_bytes = max_bytes;
418        self
419    }
420
421    /// The index the next [`save`](CheckpointManager::save) will write to.
422    ///
423    /// Exposed primarily so restart-resume behavior (EV-46) is observable/testable.
424    pub fn current_index(&self) -> usize {
425        self.current_index
426    }
427
428    /// Set how many checkpoints to keep
429    pub fn keep(mut self, n: usize) -> Self {
430        self.keep_n = n;
431        self
432    }
433
434    /// Set the save interval
435    pub fn every(mut self, generations: usize) -> Self {
436        self.interval = generations;
437        self
438    }
439
440    /// Check if a checkpoint should be saved at this generation
441    pub fn should_save(&self, generation: usize) -> bool {
442        generation > 0 && generation.is_multiple_of(self.interval)
443    }
444
445    /// Get the path for the current checkpoint
446    pub fn current_path(&self) -> std::path::PathBuf {
447        let extension = match self.format {
448            CheckpointFormat::Json => "json",
449            CheckpointFormat::Binary | CheckpointFormat::CompressedBinary => "ckpt",
450        };
451        // EV-87: zero-pad to a fixed 8-digit width so lexicographic and numeric
452        // ordering agree well beyond the 4-digit (`{:04}`) overflow at 10000.
453        self.directory.join(format!(
454            "{}_{:08}.{}",
455            self.base_name, self.current_index, extension
456        ))
457    }
458
459    /// Save a checkpoint and rotate old ones
460    pub fn save<G>(&mut self, checkpoint: &Checkpoint<G>) -> Result<(), CheckpointError>
461    where
462        G: Clone + Serialize + crate::genome::traits::EvolutionaryGenome,
463    {
464        // Ensure directory exists
465        std::fs::create_dir_all(&self.directory)?;
466
467        // Save current checkpoint
468        let path = self.current_path();
469        save_checkpoint(checkpoint, &path, self.format)?;
470
471        // Rotate: remove old checkpoints
472        self.current_index += 1;
473        if self.current_index > self.keep_n {
474            let old_index = self.current_index - self.keep_n - 1;
475            let extension = match self.format {
476                CheckpointFormat::Json => "json",
477                CheckpointFormat::Binary | CheckpointFormat::CompressedBinary => "ckpt",
478            };
479            let old_path = self
480                .directory
481                .join(format!("{}_{:08}.{}", self.base_name, old_index, extension));
482            let _ = std::fs::remove_file(old_path); // Ignore errors
483        }
484
485        Ok(())
486    }
487
488    /// Find and load the latest checkpoint
489    pub fn load_latest<G>(&self) -> Result<Option<Checkpoint<G>>, CheckpointError>
490    where
491        G: Clone + Serialize + DeserializeOwned + crate::genome::traits::EvolutionaryGenome,
492    {
493        let extension = match self.format {
494            CheckpointFormat::Json => "json",
495            CheckpointFormat::Binary | CheckpointFormat::CompressedBinary => "ckpt",
496        };
497
498        // Find all checkpoint files
499        let _pattern = format!("{}_*.{}", self.base_name, extension);
500        let mut checkpoints: Vec<_> = std::fs::read_dir(&self.directory)?
501            .filter_map(|e| e.ok())
502            .filter(|e| e.file_name().to_string_lossy().starts_with(&self.base_name))
503            .collect();
504
505        if checkpoints.is_empty() {
506            return Ok(None);
507        }
508
509        // Sort by parsed numeric index (newest/highest first). EV-87: comparing
510        // filenames as strings breaks once the index widens (e.g. "10000" <
511        // "9999" lexically), so we parse the index and compare numerically.
512        // Names that do not parse (e.g. legacy/foreign files) sort last.
513        checkpoints.sort_by(|a, b| {
514            let ia = parse_checkpoint_index(&a.file_name().to_string_lossy(), &self.base_name);
515            let ib = parse_checkpoint_index(&b.file_name().to_string_lossy(), &self.base_name);
516            ib.cmp(&ia)
517        });
518
519        // Try to load the newest checkpoint
520        for entry in checkpoints {
521            match load_checkpoint_with_limit(entry.path(), self.max_bytes) {
522                Ok(checkpoint) => return Ok(Some(checkpoint)),
523                Err(_) => continue, // Try next if corrupted
524            }
525        }
526
527        Ok(None)
528    }
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use crate::genome::real_vector::RealVector;
535    use crate::population::individual::Individual;
536    use tempfile::tempdir;
537
538    #[test]
539    fn test_save_load_json() {
540        let dir = tempdir().unwrap();
541        let path = dir.path().join("test.json");
542
543        let population: Vec<Individual<RealVector>> = vec![
544            Individual::new(RealVector::new(vec![1.0, 2.0])),
545            Individual::new(RealVector::new(vec![3.0, 4.0])),
546        ];
547        let checkpoint = Checkpoint::new(10, population);
548
549        save_checkpoint(&checkpoint, &path, CheckpointFormat::Json).unwrap();
550        let loaded: Checkpoint<RealVector> = load_checkpoint(&path).unwrap();
551
552        assert_eq!(loaded.generation, 10);
553        assert_eq!(loaded.population.len(), 2);
554    }
555
556    #[test]
557    fn test_save_load_binary() {
558        let dir = tempdir().unwrap();
559        let path = dir.path().join("test.ckpt");
560
561        let population: Vec<Individual<RealVector>> =
562            vec![Individual::new(RealVector::new(vec![1.0, 2.0, 3.0]))];
563        let checkpoint = Checkpoint::new(5, population)
564            .with_evaluations(500)
565            .with_metadata("test", "value");
566
567        save_checkpoint(&checkpoint, &path, CheckpointFormat::Binary).unwrap();
568        let loaded: Checkpoint<RealVector> = load_checkpoint(&path).unwrap();
569
570        assert_eq!(loaded.generation, 5);
571        assert_eq!(loaded.evaluations, 500);
572        assert_eq!(loaded.metadata.get("test"), Some(&"value".to_string()));
573    }
574
575    #[test]
576    fn test_save_load_compressed() {
577        let dir = tempdir().unwrap();
578        let path = dir.path().join("test_compressed.ckpt");
579
580        // Create a larger checkpoint with repetitive data
581        let population: Vec<Individual<RealVector>> = (0..100)
582            .map(|i| Individual::new(RealVector::new(vec![i as f64; 10])))
583            .collect();
584        let checkpoint = Checkpoint::new(100, population);
585
586        save_checkpoint(&checkpoint, &path, CheckpointFormat::CompressedBinary).unwrap();
587        let loaded: Checkpoint<RealVector> = load_checkpoint(&path).unwrap();
588
589        assert_eq!(loaded.generation, 100);
590        assert_eq!(loaded.population.len(), 100);
591    }
592
593    #[test]
594    fn test_compression_decompression() {
595        let original = vec![0u8, 0, 0, 0, 0, 1, 2, 3, 3, 3, 3, 3, 3, 4, 5];
596        let compressed = compress_data(&original);
597        let decompressed = decompress_data(&compressed).unwrap();
598        assert_eq!(original, decompressed);
599    }
600
601    #[test]
602    fn test_checkpoint_manager() {
603        let dir = tempdir().unwrap();
604        let mut manager = CheckpointManager::new(dir.path(), "evolution")
605            .with_format(CheckpointFormat::Binary)
606            .keep(2)
607            .every(10);
608
609        // Save multiple checkpoints
610        for gen in [10, 20, 30, 40] {
611            let population: Vec<Individual<RealVector>> =
612                vec![Individual::new(RealVector::new(vec![gen as f64]))];
613            let checkpoint = Checkpoint::new(gen, population);
614            manager.save(&checkpoint).unwrap();
615        }
616
617        // Load latest
618        let loaded: Option<Checkpoint<RealVector>> = manager.load_latest().unwrap();
619        assert!(loaded.is_some());
620        assert_eq!(loaded.unwrap().generation, 40);
621    }
622
623    #[test]
624    fn test_version_check() {
625        let population: Vec<Individual<RealVector>> = vec![];
626        let checkpoint = Checkpoint::new(0, population);
627        assert!(checkpoint.is_compatible());
628    }
629
630    #[test]
631    fn test_json_version_gate_too_new() {
632        // regression: EV-45 - a too-new JSON checkpoint was silently accepted;
633        // it must now be rejected exactly like its binary siblings.
634        let dir = tempdir().unwrap();
635        let path = dir.path().join("future.json");
636
637        let population: Vec<Individual<RealVector>> = vec![];
638        let mut checkpoint = Checkpoint::new(3, population);
639        checkpoint.version = CHECKPOINT_VERSION + 1; // pretend a newer library wrote it
640
641        save_checkpoint(&checkpoint, &path, CheckpointFormat::Json).unwrap();
642
643        let err = load_checkpoint::<RealVector>(&path).unwrap_err();
644        assert!(
645            matches!(err, CheckpointError::VersionMismatch { .. }),
646            "expected VersionMismatch, got {err:?}"
647        );
648    }
649
650    #[test]
651    fn test_json_version_gate_too_old() {
652        // regression: EV-45 - VersionTooOld must actually be returned (it was
653        // previously dead code), for JSON as for any other format.
654        let dir = tempdir().unwrap();
655        let path = dir.path().join("ancient.json");
656
657        let population: Vec<Individual<RealVector>> = vec![];
658        let mut checkpoint = Checkpoint::new(3, population);
659        checkpoint.version = MIN_SUPPORTED_CHECKPOINT_VERSION - 1;
660
661        save_checkpoint(&checkpoint, &path, CheckpointFormat::Json).unwrap();
662
663        let err = load_checkpoint::<RealVector>(&path).unwrap_err();
664        assert!(
665            matches!(err, CheckpointError::VersionTooOld(v) if v == MIN_SUPPORTED_CHECKPOINT_VERSION - 1),
666            "expected VersionTooOld, got {err:?}"
667        );
668    }
669
670    #[test]
671    fn test_manager_is_restart_safe() {
672        // regression: EV-46 - a manager reconstructed after a restart must
673        // continue the index sequence, not reset to 0 and shadow/overwrite
674        // pre-crash checkpoints.
675        let dir = tempdir().unwrap();
676
677        {
678            let mut manager = CheckpointManager::new(dir.path(), "evolution")
679                .with_format(CheckpointFormat::Binary)
680                .keep(10);
681            for gen in [10usize, 20, 30] {
682                let population: Vec<Individual<RealVector>> =
683                    vec![Individual::new(RealVector::new(vec![gen as f64]))];
684                manager.save(&Checkpoint::new(gen, population)).unwrap();
685            }
686            assert_eq!(manager.current_index(), 3);
687        }
688
689        // Simulate a restart: brand-new manager over the same directory.
690        let manager2 = CheckpointManager::new(dir.path(), "evolution")
691            .with_format(CheckpointFormat::Binary)
692            .keep(10);
693        assert_eq!(
694            manager2.current_index(),
695            3,
696            "restarted manager must continue after the highest existing index"
697        );
698
699        // The genuinely-newest pre-restart checkpoint must still load.
700        let loaded: Option<Checkpoint<RealVector>> = manager2.load_latest().unwrap();
701        assert_eq!(loaded.unwrap().generation, 30);
702    }
703
704    #[test]
705    fn test_atomic_save_preserves_destination_on_failure() {
706        // regression: EV-47 - a failed save must not truncate/destroy the
707        // existing good checkpoint. Pre-fix, save_checkpoint called
708        // File::create(path) directly and truncated the destination before
709        // writing; here we block the temp file and assert the old file survives.
710        let dir = tempdir().unwrap();
711        let path = dir.path().join("evolution.ckpt");
712
713        // Write a good checkpoint (generation 111).
714        let good: Vec<Individual<RealVector>> = vec![Individual::new(RealVector::new(vec![1.0]))];
715        save_checkpoint(&Checkpoint::new(111, good), &path, CheckpointFormat::Binary).unwrap();
716
717        // Block the atomic temp path so the next write fails before rename.
718        let mut tmp = path.clone().into_os_string();
719        tmp.push(".tmp");
720        std::fs::create_dir(&tmp).unwrap();
721
722        let newer: Vec<Individual<RealVector>> = vec![Individual::new(RealVector::new(vec![2.0]))];
723        let result = save_checkpoint(
724            &Checkpoint::new(222, newer),
725            &path,
726            CheckpointFormat::Binary,
727        );
728        assert!(
729            result.is_err(),
730            "save should fail when temp file is blocked"
731        );
732
733        // The original good checkpoint must be intact and loadable.
734        let loaded: Checkpoint<RealVector> = load_checkpoint(&path).unwrap();
735        assert_eq!(loaded.generation, 111);
736
737        // No leftover .tmp *file* (we created a dir there deliberately).
738        std::fs::remove_dir(&tmp).unwrap();
739    }
740
741    #[test]
742    fn test_atomic_save_leaves_no_temp_file() {
743        // EV-47: after a successful atomic save, the throwaway temp file must be
744        // gone (renamed into place).
745        let dir = tempdir().unwrap();
746        let path = dir.path().join("evolution.ckpt");
747        let population: Vec<Individual<RealVector>> =
748            vec![Individual::new(RealVector::new(vec![1.0]))];
749        save_checkpoint(
750            &Checkpoint::new(5, population),
751            &path,
752            CheckpointFormat::Binary,
753        )
754        .unwrap();
755
756        let mut tmp = path.clone().into_os_string();
757        tmp.push(".tmp");
758        assert!(!PathBuf::from(tmp).exists(), "temp file must not remain");
759    }
760
761    #[test]
762    fn test_load_rejects_oversized_file() {
763        // regression: EV-48 - loading must reject a file larger than the limit
764        // with a typed error instead of an unbounded read/allocation.
765        let dir = tempdir().unwrap();
766        let path = dir.path().join("big.ckpt");
767        let population: Vec<Individual<RealVector>> = (0..50)
768            .map(|i| Individual::new(RealVector::new(vec![i as f64; 8])))
769            .collect();
770        save_checkpoint(
771            &Checkpoint::new(1, population),
772            &path,
773            CheckpointFormat::Binary,
774        )
775        .unwrap();
776
777        let err = load_checkpoint_with_limit::<RealVector>(&path, 16).unwrap_err();
778        assert!(
779            matches!(err, CheckpointError::TooLarge { limit: 16, .. }),
780            "expected TooLarge, got {err:?}"
781        );
782    }
783
784    #[test]
785    fn test_load_rejects_corrupt_length_prefix() {
786        // regression: EV-48 - a corrupted length prefix must not drive a huge
787        // allocation; it is caught by the limit and returned as a typed error.
788        let dir = tempdir().unwrap();
789        let path = dir.path().join("corrupt.ckpt");
790
791        let mut bytes = Vec::new();
792        bytes.extend_from_slice(&CHECKPOINT_VERSION.to_le_bytes());
793        bytes.extend_from_slice(b"FEVC");
794        bytes.extend_from_slice(&u64::MAX.to_le_bytes()); // absurd length prefix
795        std::fs::write(&path, &bytes).unwrap();
796
797        let err = load_checkpoint::<RealVector>(&path).unwrap_err();
798        assert!(
799            matches!(err, CheckpointError::TooLarge { .. }),
800            "expected TooLarge, got {err:?}"
801        );
802    }
803
804    #[test]
805    fn test_load_latest_orders_across_digit_boundary() {
806        // regression: EV-87 - lexicographic filename ordering picks the wrong
807        // "latest" once the index widens ("100000" < "99999" as strings). The
808        // manager must order by parsed numeric index instead, and still parse
809        // legacy unpadded names.
810        let dir = tempdir().unwrap();
811
812        // Emulate legacy unpadded filenames straddling the 5->6 digit boundary.
813        for (idx, gen) in [(99999usize, 99999usize), (100000, 100000)] {
814            let path = dir.path().join(format!("evolution_{idx}.ckpt"));
815            let population: Vec<Individual<RealVector>> =
816                vec![Individual::new(RealVector::new(vec![gen as f64]))];
817            save_checkpoint(
818                &Checkpoint::new(gen, population),
819                &path,
820                CheckpointFormat::Binary,
821            )
822            .unwrap();
823        }
824
825        let manager = CheckpointManager::new(dir.path(), "evolution");
826        let loaded: Option<Checkpoint<RealVector>> = manager.load_latest().unwrap();
827        assert_eq!(
828            loaded.unwrap().generation,
829            100000,
830            "index 100000 is newer than 99999 despite lexicographic order"
831        );
832    }
833
834    #[test]
835    fn test_current_path_is_zero_padded_to_8() {
836        // EV-87: names must be fixed-width 8-digit so string and numeric order agree.
837        let dir = tempdir().unwrap();
838        let manager = CheckpointManager::new(dir.path(), "evolution");
839        let name = manager.current_path();
840        assert!(
841            name.file_name().unwrap().to_string_lossy() == "evolution_00000000.ckpt",
842            "got {name:?}"
843        );
844    }
845}