1use 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
19pub const DEFAULT_MAX_CHECKPOINT_BYTES: u64 = 256 * 1024 * 1024;
24
25pub const MIN_SUPPORTED_CHECKPOINT_VERSION: u32 = 1;
31
32#[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#[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#[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#[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 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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub enum CheckpointFormat {
112 Json,
114 Binary,
116 CompressedBinary,
118}
119
120impl Default for CheckpointFormat {
121 fn default() -> Self {
122 Self::Binary
123 }
124}
125
126#[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 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 writer.write_all(&CHECKPOINT_VERSION.to_le_bytes())?;
155 writer.write_all(b"FEVO")?;
157 bincode::serialize_into(&mut writer, checkpoint)
159 .map_err(|e| CheckpointError::SerializeError(Box::new(e)))?;
160 }
161 CheckpointFormat::CompressedBinary => {
162 writer.write_all(&CHECKPOINT_VERSION.to_le_bytes())?;
164 writer.write_all(b"FEVC")?; let bytes = bincode::serialize(checkpoint)
167 .map_err(|e| CheckpointError::SerializeError(Box::new(e)))?;
168 let compressed = compress_data(&bytes);
170 writer.write_all(&(compressed.len() as u64).to_le_bytes())?;
172 writer.write_all(&compressed)?;
173 }
174 }
175
176 writer.flush()?;
177 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 let _ = fs::remove_file(&tmp_path);
187 return Err(e);
188 }
189
190 fs::rename(&tmp_path, path)?;
191 Ok(())
192}
193
194#[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#[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 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 let mut header = [0u8; 8];
237 reader.read_exact(&mut header)?;
238
239 if &header[4..8] == b"FEVO" {
241 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 let version = u32::from_le_bytes([header[0], header[1], header[2], header[3]]);
253 check_version(version)?;
254
255 let mut len_bytes = [0u8; 8];
257 reader.read_exact(&mut len_bytes)?;
258 let compressed_len = u64::from_le_bytes(len_bytes);
259 if compressed_len > max_bytes {
261 return Err(CheckpointError::TooLarge {
262 size: compressed_len,
263 limit: max_bytes,
264 });
265 }
266
267 let mut compressed = vec![0u8; compressed_len as usize];
269 reader.read_exact(&mut compressed)?;
270
271 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 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 check_version(checkpoint.version)?;
289 Ok(checkpoint)
290 }
291}
292
293#[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 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 compressed.push(0xFF);
316 compressed.push(count);
317 compressed.push(byte);
318 } else {
319 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#[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#[cfg(feature = "checkpoint")]
365pub struct CheckpointManager {
366 pub directory: std::path::PathBuf,
368 pub base_name: String,
370 pub format: CheckpointFormat,
372 pub keep_n: usize,
374 pub interval: usize,
376 pub max_bytes: u64,
378 current_index: usize,
380}
381
382#[cfg(feature = "checkpoint")]
383impl CheckpointManager {
384 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 pub fn with_format(mut self, format: CheckpointFormat) -> Self {
409 self.format = format;
410 self
411 }
412
413 pub fn with_max_bytes(mut self, max_bytes: u64) -> Self {
417 self.max_bytes = max_bytes;
418 self
419 }
420
421 pub fn current_index(&self) -> usize {
425 self.current_index
426 }
427
428 pub fn keep(mut self, n: usize) -> Self {
430 self.keep_n = n;
431 self
432 }
433
434 pub fn every(mut self, generations: usize) -> Self {
436 self.interval = generations;
437 self
438 }
439
440 pub fn should_save(&self, generation: usize) -> bool {
442 generation > 0 && generation.is_multiple_of(self.interval)
443 }
444
445 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 self.directory.join(format!(
454 "{}_{:08}.{}",
455 self.base_name, self.current_index, extension
456 ))
457 }
458
459 pub fn save<G>(&mut self, checkpoint: &Checkpoint<G>) -> Result<(), CheckpointError>
461 where
462 G: Clone + Serialize + crate::genome::traits::EvolutionaryGenome,
463 {
464 std::fs::create_dir_all(&self.directory)?;
466
467 let path = self.current_path();
469 save_checkpoint(checkpoint, &path, self.format)?;
470
471 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); }
484
485 Ok(())
486 }
487
488 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 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 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 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, }
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 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 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 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 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; 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 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 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 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 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 let dir = tempdir().unwrap();
711 let path = dir.path().join("evolution.ckpt");
712
713 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 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 let loaded: Checkpoint<RealVector> = load_checkpoint(&path).unwrap();
735 assert_eq!(loaded.generation, 111);
736
737 std::fs::remove_dir(&tmp).unwrap();
739 }
740
741 #[test]
742 fn test_atomic_save_leaves_no_temp_file() {
743 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 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 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()); 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 let dir = tempdir().unwrap();
811
812 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 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}