Skip to main content

sklears_neural/
checkpointing.rs

1//! Model checkpointing and state management for neural networks.
2//!
3//! This module provides functionality to save and load model states, training progress,
4//! and optimizer states during training. This is essential for long-running training
5//! jobs and experimentation.
6
7use crate::NeuralResult;
8use scirs2_core::ndarray::{Array1, Array2};
9#[cfg(feature = "serde")]
10use serde::{de::DeserializeOwned, Deserialize, Serialize};
11use sklears_core::error::SklearsError;
12use std::collections::HashMap;
13use std::fs;
14#[cfg(feature = "mmap")]
15use std::io::Write;
16use std::path::Path;
17
18#[cfg(feature = "serde")]
19use serde_json;
20
21#[cfg(feature = "serde")]
22use oxicode;
23
24#[cfg(feature = "serde")]
25use chrono;
26
27/// Format for saving checkpoints
28#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub enum CheckpointFormat {
30    /// JSON format (human-readable, larger files)
31    Json,
32    /// Binary format (compact, faster I/O)
33    #[default]
34    Binary,
35    /// Custom format with compression
36    Compressed,
37    /// Memory-mapped format for large models (efficient random access)
38    #[cfg(feature = "mmap")]
39    MemoryMapped,
40}
41
42#[cfg(feature = "serde")]
43fn serialize_to_binary<T>(value: &T) -> Result<Vec<u8>, oxicode::Error>
44where
45    T: Serialize,
46{
47    oxicode::serde::encode_to_vec(value, oxicode::config::standard())
48}
49
50#[cfg(feature = "serde")]
51fn deserialize_from_binary<T>(bytes: &[u8]) -> Result<T, oxicode::Error>
52where
53    T: DeserializeOwned,
54{
55    let (value, _bytes_read) =
56        oxicode::serde::decode_from_slice(bytes, oxicode::config::standard())?;
57    Ok(value)
58}
59
60/// Model weights and biases
61#[derive(Debug, Clone)]
62#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
63pub struct ModelWeights {
64    /// Weight matrices for each layer
65    pub weights: Vec<Array2<f64>>,
66    /// Bias vectors for each layer
67    pub biases: Vec<Array1<f64>>,
68    /// Layer names (optional)
69    pub layer_names: Option<Vec<String>>,
70}
71
72impl ModelWeights {
73    /// Create new model weights
74    pub fn new(weights: Vec<Array2<f64>>, biases: Vec<Array1<f64>>) -> Self {
75        Self {
76            weights,
77            biases,
78            layer_names: None,
79        }
80    }
81
82    /// Create model weights with layer names
83    pub fn with_names(
84        weights: Vec<Array2<f64>>,
85        biases: Vec<Array1<f64>>,
86        layer_names: Vec<String>,
87    ) -> Self {
88        Self {
89            weights,
90            biases,
91            layer_names: Some(layer_names),
92        }
93    }
94
95    /// Get number of layers
96    pub fn num_layers(&self) -> usize {
97        self.weights.len().min(self.biases.len())
98    }
99
100    /// Validate that weights and biases are consistent
101    pub fn validate(&self) -> NeuralResult<()> {
102        if self.weights.len() != self.biases.len() {
103            return Err(SklearsError::InvalidParameter {
104                name: "weights_biases".to_string(),
105                reason: format!(
106                    "Number of weight matrices ({}) doesn't match number of bias vectors ({})",
107                    self.weights.len(),
108                    self.biases.len()
109                ),
110            });
111        }
112
113        for (i, (weight, bias)) in self.weights.iter().zip(self.biases.iter()).enumerate() {
114            if weight.ncols() != bias.len() {
115                return Err(SklearsError::ShapeMismatch {
116                    expected: format!("weight.ncols()={}", weight.ncols()),
117                    actual: format!("bias.len()={} at layer {}", bias.len(), i),
118                });
119            }
120        }
121
122        if let Some(ref names) = self.layer_names {
123            if names.len() != self.num_layers() {
124                return Err(SklearsError::InvalidParameter {
125                    name: "layer_names".to_string(),
126                    reason: format!(
127                        "Number of layer names ({}) doesn't match number of layers ({})",
128                        names.len(),
129                        self.num_layers()
130                    ),
131                });
132            }
133        }
134
135        Ok(())
136    }
137
138    /// Get total number of parameters
139    pub fn total_parameters(&self) -> usize {
140        let weight_params: usize = self.weights.iter().map(|w| w.len()).sum();
141        let bias_params: usize = self.biases.iter().map(|b| b.len()).sum();
142        weight_params + bias_params
143    }
144}
145
146/// Optimizer state for resuming training
147#[derive(Debug, Clone)]
148#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
149pub struct OptimizerState {
150    /// Optimizer type identifier
151    pub optimizer_type: String,
152    /// Current learning rate
153    pub learning_rate: f64,
154    /// Momentum states (for momentum-based optimizers)
155    pub momentum_states: Option<Vec<Array2<f64>>>,
156    /// Velocity states (for Adam-like optimizers)
157    pub velocity_states: Option<Vec<Array2<f64>>>,
158    /// Bias correction terms
159    pub bias_correction: Option<HashMap<String, f64>>,
160    /// Additional optimizer-specific parameters
161    pub parameters: HashMap<String, f64>,
162    /// Step count
163    pub step_count: usize,
164}
165
166impl OptimizerState {
167    /// Create new optimizer state
168    pub fn new(optimizer_type: String, learning_rate: f64) -> Self {
169        Self {
170            optimizer_type,
171            learning_rate,
172            momentum_states: None,
173            velocity_states: None,
174            bias_correction: None,
175            parameters: HashMap::new(),
176            step_count: 0,
177        }
178    }
179
180    /// Add momentum states
181    pub fn with_momentum(mut self, momentum_states: Vec<Array2<f64>>) -> Self {
182        self.momentum_states = Some(momentum_states);
183        self
184    }
185
186    /// Add velocity states
187    pub fn with_velocity(mut self, velocity_states: Vec<Array2<f64>>) -> Self {
188        self.velocity_states = Some(velocity_states);
189        self
190    }
191
192    /// Add bias correction terms
193    pub fn with_bias_correction(mut self, bias_correction: HashMap<String, f64>) -> Self {
194        self.bias_correction = Some(bias_correction);
195        self
196    }
197
198    /// Add parameter
199    pub fn with_parameter(mut self, key: String, value: f64) -> Self {
200        self.parameters.insert(key, value);
201        self
202    }
203
204    /// Set step count
205    pub fn with_step_count(mut self, step_count: usize) -> Self {
206        self.step_count = step_count;
207        self
208    }
209}
210
211/// Training metrics and history
212#[derive(Debug, Clone)]
213#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
214pub struct TrainingHistory {
215    /// Training loss history
216    pub train_loss: Vec<f64>,
217    /// Validation loss history
218    pub val_loss: Option<Vec<f64>>,
219    /// Training accuracy history
220    pub train_accuracy: Option<Vec<f64>>,
221    /// Validation accuracy history
222    pub val_accuracy: Option<Vec<f64>>,
223    /// Learning rate history
224    pub learning_rates: Vec<f64>,
225    /// Epoch numbers
226    pub epochs: Vec<usize>,
227    /// Additional custom metrics
228    pub custom_metrics: HashMap<String, Vec<f64>>,
229}
230
231impl TrainingHistory {
232    /// Create new training history
233    pub fn new() -> Self {
234        Self {
235            train_loss: Vec::new(),
236            val_loss: None,
237            train_accuracy: None,
238            val_accuracy: None,
239            learning_rates: Vec::new(),
240            epochs: Vec::new(),
241            custom_metrics: HashMap::new(),
242        }
243    }
244
245    /// Add training step
246    pub fn add_epoch(&mut self, epoch: usize, train_loss: f64, learning_rate: f64) {
247        self.epochs.push(epoch);
248        self.train_loss.push(train_loss);
249        self.learning_rates.push(learning_rate);
250    }
251
252    /// Add validation metrics
253    pub fn add_validation(&mut self, val_loss: f64, val_accuracy: Option<f64>) {
254        if self.val_loss.is_none() {
255            self.val_loss = Some(Vec::new());
256        }
257        self.val_loss
258            .as_mut()
259            .expect("val_loss not available")
260            .push(val_loss);
261
262        if let Some(acc) = val_accuracy {
263            if self.val_accuracy.is_none() {
264                self.val_accuracy = Some(Vec::new());
265            }
266            self.val_accuracy
267                .as_mut()
268                .expect("val_accuracy not available")
269                .push(acc);
270        }
271    }
272
273    /// Add custom metric
274    pub fn add_custom_metric(&mut self, name: &str, value: f64) {
275        self.custom_metrics
276            .entry(name.to_string())
277            .or_default()
278            .push(value);
279    }
280
281    /// Get best epoch (minimum validation loss)
282    pub fn best_epoch(&self) -> Option<usize> {
283        self.val_loss.as_ref().and_then(|losses| {
284            losses
285                .iter()
286                .enumerate()
287                .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
288                .map(|(idx, _)| self.epochs[idx])
289        })
290    }
291
292    /// Check if training is improving
293    pub fn is_improving(&self, patience: usize) -> bool {
294        if let Some(ref val_losses) = self.val_loss {
295            if val_losses.len() < patience {
296                return true; // Too early to tell
297            }
298
299            let recent_best = val_losses[val_losses.len() - patience..]
300                .iter()
301                .fold(f64::INFINITY, |a, &b| a.min(b));
302            let overall_best = val_losses.iter().fold(f64::INFINITY, |a, &b| a.min(b));
303
304            recent_best <= overall_best
305        } else {
306            // Use training loss if no validation loss available
307            if self.train_loss.len() < patience {
308                return true;
309            }
310
311            let recent_best = self.train_loss[self.train_loss.len() - patience..]
312                .iter()
313                .fold(f64::INFINITY, |a, &b| a.min(b));
314            let overall_best = self.train_loss.iter().fold(f64::INFINITY, |a, &b| a.min(b));
315
316            recent_best <= overall_best
317        }
318    }
319}
320
321impl Default for TrainingHistory {
322    fn default() -> Self {
323        Self::new()
324    }
325}
326
327/// Complete model checkpoint
328#[derive(Debug, Clone)]
329#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
330pub struct ModelCheckpoint {
331    /// Model architecture identifier
332    pub model_type: String,
333    /// Model weights and biases
334    pub weights: ModelWeights,
335    /// Optimizer state
336    pub optimizer_state: Option<OptimizerState>,
337    /// Training history
338    pub training_history: TrainingHistory,
339    /// Current epoch
340    pub current_epoch: usize,
341    /// Model hyperparameters
342    #[cfg(feature = "serde")]
343    pub hyperparameters: HashMap<String, serde_json::Value>,
344    /// Checkpoint metadata
345    pub metadata: CheckpointMetadata,
346    /// Model version
347    pub version: String,
348}
349
350/// Checkpoint metadata
351#[derive(Debug, Clone)]
352#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
353pub struct CheckpointMetadata {
354    /// Creation timestamp
355    #[cfg(feature = "serde")]
356    pub created_at: chrono::DateTime<chrono::Utc>,
357    /// Training duration so far
358    pub training_duration: Option<std::time::Duration>,
359    /// Git commit hash (if available)
360    pub git_commit: Option<String>,
361    /// Environment information
362    pub environment: HashMap<String, String>,
363    /// Custom tags
364    pub tags: Vec<String>,
365    /// Description
366    pub description: Option<String>,
367}
368
369impl Default for CheckpointMetadata {
370    fn default() -> Self {
371        let mut environment = HashMap::new();
372        environment.insert(
373            "sklears_version".to_string(),
374            env!("CARGO_PKG_VERSION").to_string(),
375        );
376
377        #[cfg(feature = "serde")]
378        {
379            Self {
380                created_at: chrono::Utc::now(),
381                training_duration: None,
382                git_commit: None,
383                environment,
384                tags: Vec::new(),
385                description: None,
386            }
387        }
388        #[cfg(not(feature = "serde"))]
389        {
390            Self {
391                training_duration: None,
392                git_commit: None,
393                environment,
394                tags: Vec::new(),
395                description: None,
396            }
397        }
398    }
399}
400
401impl ModelCheckpoint {
402    /// Create new model checkpoint
403    pub fn new(model_type: String, weights: ModelWeights, current_epoch: usize) -> Self {
404        #[cfg(feature = "serde")]
405        {
406            Self {
407                model_type,
408                weights,
409                optimizer_state: None,
410                training_history: TrainingHistory::new(),
411                current_epoch,
412                hyperparameters: HashMap::new(),
413                metadata: CheckpointMetadata::default(),
414                version: env!("CARGO_PKG_VERSION").to_string(),
415            }
416        }
417        #[cfg(not(feature = "serde"))]
418        {
419            Self {
420                model_type,
421                weights,
422                optimizer_state: None,
423                training_history: TrainingHistory::new(),
424                current_epoch,
425                metadata: CheckpointMetadata::default(),
426                version: env!("CARGO_PKG_VERSION").to_string(),
427            }
428        }
429    }
430
431    /// Add optimizer state
432    pub fn with_optimizer(mut self, optimizer_state: OptimizerState) -> Self {
433        self.optimizer_state = Some(optimizer_state);
434        self
435    }
436
437    /// Add training history
438    pub fn with_history(mut self, training_history: TrainingHistory) -> Self {
439        self.training_history = training_history;
440        self
441    }
442
443    /// Add hyperparameters
444    #[cfg(feature = "serde")]
445    pub fn with_hyperparameters(
446        mut self,
447        hyperparameters: HashMap<String, serde_json::Value>,
448    ) -> Self {
449        self.hyperparameters = hyperparameters;
450        self
451    }
452
453    /// Add metadata
454    pub fn with_metadata(mut self, metadata: CheckpointMetadata) -> Self {
455        self.metadata = metadata;
456        self
457    }
458
459    /// Validate checkpoint consistency
460    pub fn validate(&self) -> NeuralResult<()> {
461        self.weights.validate()?;
462
463        // Validate optimizer state consistency
464        if let Some(ref opt_state) = self.optimizer_state {
465            if let Some(ref momentum) = opt_state.momentum_states {
466                if momentum.len() != self.weights.num_layers() {
467                    return Err(SklearsError::InvalidParameter {
468                        name: "momentum_states".to_string(),
469                        reason: "Momentum states count doesn't match number of layers".to_string(),
470                    });
471                }
472            }
473
474            if let Some(ref velocity) = opt_state.velocity_states {
475                if velocity.len() != self.weights.num_layers() {
476                    return Err(SklearsError::InvalidParameter {
477                        name: "velocity_states".to_string(),
478                        reason: "Velocity states count doesn't match number of layers".to_string(),
479                    });
480                }
481            }
482        }
483
484        Ok(())
485    }
486}
487
488/// Checkpoint manager for saving and loading model states
489pub struct CheckpointManager {
490    /// Base directory for checkpoints
491    pub checkpoint_dir: std::path::PathBuf,
492    /// Default checkpoint format
493    pub format: CheckpointFormat,
494    /// Maximum number of checkpoints to keep
495    pub max_checkpoints: Option<usize>,
496    /// Checkpoint naming pattern
497    pub naming_pattern: String,
498}
499
500impl CheckpointManager {
501    /// Create new checkpoint manager
502    pub fn new<P: AsRef<Path>>(checkpoint_dir: P) -> NeuralResult<Self> {
503        let checkpoint_dir = checkpoint_dir.as_ref().to_path_buf();
504
505        // Create directory if it doesn't exist
506        if !checkpoint_dir.exists() {
507            fs::create_dir_all(&checkpoint_dir).map_err(|e| SklearsError::InvalidParameter {
508                name: "checkpoint_dir".to_string(),
509                reason: format!("Failed to create checkpoint directory: {}", e),
510            })?;
511        }
512
513        Ok(Self {
514            checkpoint_dir,
515            format: CheckpointFormat::default(),
516            max_checkpoints: Some(10),
517            naming_pattern: "checkpoint_epoch_{:04d}".to_string(),
518        })
519    }
520
521    /// Set checkpoint format
522    pub fn with_format(mut self, format: CheckpointFormat) -> Self {
523        self.format = format;
524        self
525    }
526
527    /// Set maximum number of checkpoints to keep
528    pub fn with_max_checkpoints(mut self, max_checkpoints: Option<usize>) -> Self {
529        self.max_checkpoints = max_checkpoints;
530        self
531    }
532
533    /// Set naming pattern
534    pub fn with_naming_pattern(mut self, pattern: String) -> Self {
535        self.naming_pattern = pattern;
536        self
537    }
538
539    /// Save checkpoint
540    #[cfg(feature = "serde")]
541    pub fn save_checkpoint(
542        &self,
543        checkpoint: &ModelCheckpoint,
544    ) -> NeuralResult<std::path::PathBuf> {
545        checkpoint.validate()?;
546
547        let filename = self
548            .naming_pattern
549            .replace("{:04d}", &format!("{:04}", checkpoint.current_epoch));
550        let file_path = match self.format {
551            CheckpointFormat::Json => {
552                let path = self.checkpoint_dir.join(format!("{}.json", filename));
553                let json_data = serde_json::to_string_pretty(checkpoint).map_err(|e| {
554                    SklearsError::InvalidParameter {
555                        name: "checkpoint".to_string(),
556                        reason: format!("Failed to serialize checkpoint to JSON: {}", e),
557                    }
558                })?;
559                fs::write(&path, json_data).map_err(|e| SklearsError::InvalidParameter {
560                    name: "file_path".to_string(),
561                    reason: format!("Failed to write checkpoint file: {}", e),
562                })?;
563                path
564            }
565            CheckpointFormat::Binary => {
566                let path = self.checkpoint_dir.join(format!("{}.bin", filename));
567                let binary_data = serialize_to_binary(checkpoint).map_err(|e| {
568                    SklearsError::InvalidParameter {
569                        name: "checkpoint".to_string(),
570                        reason: format!("Failed to serialize checkpoint to binary: {}", e),
571                    }
572                })?;
573                fs::write(&path, binary_data).map_err(|e| SklearsError::InvalidParameter {
574                    name: "file_path".to_string(),
575                    reason: format!("Failed to write checkpoint file: {}", e),
576                })?;
577                path
578            }
579            CheckpointFormat::Compressed => {
580                let path = self.checkpoint_dir.join(format!("{}.bin.gz", filename));
581                let binary_data = serialize_to_binary(checkpoint).map_err(|e| {
582                    SklearsError::InvalidParameter {
583                        name: "checkpoint".to_string(),
584                        reason: format!("Failed to serialize checkpoint: {}", e),
585                    }
586                })?;
587
588                let compressed = oxiarc_deflate::gzip_compress(&binary_data, 6).map_err(|e| {
589                    SklearsError::InvalidParameter {
590                        name: "compression".to_string(),
591                        reason: format!("Failed to compress checkpoint: {}", e),
592                    }
593                })?;
594                fs::write(&path, &compressed).map_err(|e| SklearsError::InvalidParameter {
595                    name: "file_path".to_string(),
596                    reason: format!("Failed to write checkpoint file: {}", e),
597                })?;
598                path
599            }
600            #[cfg(feature = "mmap")]
601            CheckpointFormat::MemoryMapped => {
602                let path = self.checkpoint_dir.join(format!("{}.mmap", filename));
603                self.save_memory_mapped_checkpoint(checkpoint, &path)?;
604                path
605            }
606        };
607
608        // Clean up old checkpoints if max_checkpoints is set
609        if let Some(max_checkpoints) = self.max_checkpoints {
610            self.cleanup_old_checkpoints(max_checkpoints)?;
611        }
612
613        Ok(file_path)
614    }
615
616    /// Load checkpoint
617    #[cfg(feature = "serde")]
618    pub fn load_checkpoint<P: AsRef<Path>>(&self, file_path: P) -> NeuralResult<ModelCheckpoint> {
619        let file_path = file_path.as_ref();
620        let extension = file_path.extension().and_then(|s| s.to_str()).unwrap_or("");
621
622        let checkpoint: ModelCheckpoint = match extension {
623            "json" => {
624                let json_data =
625                    fs::read_to_string(file_path).map_err(|e| SklearsError::InvalidParameter {
626                        name: "file_path".to_string(),
627                        reason: format!("Failed to read checkpoint file: {}", e),
628                    })?;
629                serde_json::from_str(&json_data).map_err(|e| SklearsError::InvalidParameter {
630                    name: "checkpoint".to_string(),
631                    reason: format!("Failed to deserialize JSON checkpoint: {}", e),
632                })?
633            }
634            "bin" => {
635                let binary_data =
636                    fs::read(file_path).map_err(|e| SklearsError::InvalidParameter {
637                        name: "file_path".to_string(),
638                        reason: format!("Failed to read checkpoint file: {}", e),
639                    })?;
640                deserialize_from_binary(&binary_data).map_err(|e| {
641                    SklearsError::InvalidParameter {
642                        name: "checkpoint".to_string(),
643                        reason: format!("Failed to deserialize binary checkpoint: {}", e),
644                    }
645                })?
646            }
647            "gz" => {
648                let raw = fs::read(file_path).map_err(|e| SklearsError::InvalidParameter {
649                    name: "file_path".to_string(),
650                    reason: format!("Failed to read checkpoint file: {}", e),
651                })?;
652                let binary_data = oxiarc_deflate::gzip_decompress(&raw).map_err(|e| {
653                    SklearsError::InvalidParameter {
654                        name: "decompression".to_string(),
655                        reason: format!("Failed to decompress checkpoint: {}", e),
656                    }
657                })?;
658                deserialize_from_binary(&binary_data).map_err(|e| {
659                    SklearsError::InvalidParameter {
660                        name: "checkpoint".to_string(),
661                        reason: format!("Failed to deserialize compressed checkpoint: {}", e),
662                    }
663                })?
664            }
665            #[cfg(feature = "mmap")]
666            "mmap" => self.load_memory_mapped_checkpoint(file_path)?,
667            _ => {
668                return Err(SklearsError::InvalidParameter {
669                    name: "file_extension".to_string(),
670                    reason: format!("Unsupported checkpoint file extension: {}", extension),
671                });
672            }
673        };
674
675        checkpoint.validate()?;
676        Ok(checkpoint)
677    }
678
679    /// List available checkpoints
680    pub fn list_checkpoints(&self) -> NeuralResult<Vec<std::path::PathBuf>> {
681        let mut checkpoints = Vec::new();
682
683        let entries =
684            fs::read_dir(&self.checkpoint_dir).map_err(|e| SklearsError::InvalidParameter {
685                name: "checkpoint_dir".to_string(),
686                reason: format!("Failed to read checkpoint directory: {}", e),
687            })?;
688
689        for entry in entries {
690            let entry = entry.map_err(|e| SklearsError::InvalidParameter {
691                name: "directory_entry".to_string(),
692                reason: format!("Failed to read directory entry: {}", e),
693            })?;
694
695            let path = entry.path();
696            if path.is_file() {
697                if let Some(extension) = path.extension().and_then(|s| s.to_str()) {
698                    if matches!(extension, "json" | "bin" | "gz") {
699                        checkpoints.push(path);
700                    }
701                }
702            }
703        }
704
705        // Sort by modification time (newest first)
706        checkpoints.sort_by_key(|path| {
707            fs::metadata(path)
708                .and_then(|metadata| metadata.modified())
709                .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
710        });
711        checkpoints.reverse();
712
713        Ok(checkpoints)
714    }
715
716    /// Get latest checkpoint
717    pub fn get_latest_checkpoint(&self) -> NeuralResult<Option<std::path::PathBuf>> {
718        let checkpoints = self.list_checkpoints()?;
719        Ok(checkpoints.into_iter().next())
720    }
721
722    /// Clean up old checkpoints
723    #[allow(dead_code)]
724    fn cleanup_old_checkpoints(&self, max_checkpoints: usize) -> NeuralResult<()> {
725        let checkpoints = self.list_checkpoints()?;
726
727        if checkpoints.len() > max_checkpoints {
728            for checkpoint_path in checkpoints.into_iter().skip(max_checkpoints) {
729                fs::remove_file(&checkpoint_path).map_err(|e| SklearsError::InvalidParameter {
730                    name: "cleanup".to_string(),
731                    reason: format!("Failed to remove old checkpoint: {}", e),
732                })?;
733            }
734        }
735
736        Ok(())
737    }
738
739    /// Save checkpoint using memory-mapped storage
740    #[cfg(feature = "mmap")]
741    fn save_memory_mapped_checkpoint(
742        &self,
743        checkpoint: &ModelCheckpoint,
744        path: &Path,
745    ) -> NeuralResult<()> {
746        // First, serialize the checkpoint to binary to get the size
747        let binary_data =
748            serialize_to_binary(checkpoint).map_err(|e| SklearsError::InvalidParameter {
749                name: "checkpoint".to_string(),
750                reason: format!("Failed to serialize checkpoint: {}", e),
751            })?;
752
753        // Create a file with the required size and proper permissions
754        let mut file = fs::OpenOptions::new()
755            .read(true)
756            .write(true)
757            .create(true)
758            .truncate(true)
759            .open(path)
760            .map_err(|e| SklearsError::InvalidParameter {
761                name: "file_path".to_string(),
762                reason: format!("Failed to create memory-mapped file: {}", e),
763            })?;
764
765        // Write the data first to set proper file size
766        file.write_all(&binary_data)
767            .map_err(|e| SklearsError::InvalidParameter {
768                name: "file_write".to_string(),
769                reason: format!("Failed to write data to file: {}", e),
770            })?;
771        file.flush().map_err(|e| SklearsError::InvalidParameter {
772            name: "file_flush".to_string(),
773            reason: format!("Failed to flush file: {}", e),
774        })?;
775
776        // Optionally create a read-only memory map for verification
777        // (In practice, memory-mapped files are more useful for reading large files)
778
779        Ok(())
780    }
781
782    /// Load checkpoint from memory-mapped storage
783    #[cfg(feature = "mmap")]
784    fn load_memory_mapped_checkpoint(&self, path: &Path) -> NeuralResult<ModelCheckpoint> {
785        use memmap2::Mmap;
786
787        // Open file
788        let file = fs::File::open(path).map_err(|e| SklearsError::InvalidParameter {
789            name: "file_path".to_string(),
790            reason: format!("Failed to open memory-mapped file: {}", e),
791        })?;
792
793        // Create read-only memory map
794        let mmap = unsafe {
795            Mmap::map(&file).map_err(|e| SklearsError::InvalidParameter {
796                name: "memory_map".to_string(),
797                reason: format!("Failed to create memory map: {}", e),
798            })?
799        };
800
801        // Deserialize from memory-mapped data
802        let checkpoint: ModelCheckpoint =
803            deserialize_from_binary(&mmap).map_err(|e| SklearsError::InvalidParameter {
804                name: "checkpoint".to_string(),
805                reason: format!("Failed to deserialize memory-mapped checkpoint: {}", e),
806            })?;
807
808        Ok(checkpoint)
809    }
810}
811
812/// Trait for models that support checkpointing
813pub trait Checkpointable {
814    /// Save current model state to checkpoint
815    fn to_checkpoint(&self, current_epoch: usize) -> NeuralResult<ModelCheckpoint>;
816
817    /// Load model state from checkpoint
818    /// Load model state from a checkpoint
819    fn load_from_checkpoint(&mut self, checkpoint: &ModelCheckpoint) -> NeuralResult<()>;
820
821    /// Get model type identifier
822    fn model_type(&self) -> String;
823}
824
825#[allow(non_snake_case)]
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use approx::assert_abs_diff_eq;
830
831    #[test]
832    fn test_model_weights_creation() {
833        let weights = vec![
834            Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
835                .expect("array shape mismatch"),
836            Array2::from_shape_vec((3, 1), vec![7.0, 8.0, 9.0]).expect("array shape mismatch"),
837        ];
838        let biases = vec![
839            Array1::from_vec(vec![0.1, 0.2, 0.3]),
840            Array1::from_vec(vec![0.4]),
841        ];
842
843        let model_weights = ModelWeights::new(weights, biases);
844        assert_eq!(model_weights.num_layers(), 2);
845        assert_eq!(model_weights.total_parameters(), 13); // weights: (2×3) + (3×1) = 9, biases: 3 + 1 = 4, total = 13
846        assert!(model_weights.validate().is_ok());
847    }
848
849    #[test]
850    fn test_model_weights_validation() {
851        let weights = vec![
852            Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
853                .expect("array shape mismatch"),
854        ];
855        let biases = vec![
856            Array1::from_vec(vec![0.1, 0.2]), // Wrong size!
857        ];
858
859        let model_weights = ModelWeights::new(weights, biases);
860        assert!(model_weights.validate().is_err());
861    }
862
863    #[test]
864    fn test_optimizer_state() {
865        let opt_state = OptimizerState::new("Adam".to_string(), 0.001)
866            .with_step_count(100)
867            .with_parameter("beta1".to_string(), 0.9)
868            .with_parameter("beta2".to_string(), 0.999);
869
870        assert_eq!(opt_state.optimizer_type, "Adam");
871        assert_abs_diff_eq!(opt_state.learning_rate, 0.001, epsilon = 1e-10);
872        assert_eq!(opt_state.step_count, 100);
873        assert_abs_diff_eq!(opt_state.parameters["beta1"], 0.9, epsilon = 1e-10);
874    }
875
876    #[test]
877    fn test_training_history() {
878        let mut history = TrainingHistory::new();
879
880        history.add_epoch(0, 1.0, 0.01);
881        history.add_validation(0.9, Some(0.85));
882
883        history.add_epoch(1, 0.8, 0.01);
884        history.add_validation(0.7, Some(0.88));
885
886        history.add_custom_metric("f1_score", 0.82);
887        history.add_custom_metric("f1_score", 0.86);
888
889        assert_eq!(history.epochs.len(), 2);
890        assert_eq!(history.train_loss, vec![1.0, 0.8]);
891        assert_eq!(history.val_loss, Some(vec![0.9, 0.7]));
892        assert_eq!(history.best_epoch(), Some(1)); // Epoch 1 has lowest val loss
893        assert!(history.is_improving(2));
894        assert_eq!(history.custom_metrics["f1_score"], vec![0.82, 0.86]);
895    }
896
897    #[test]
898    fn test_checkpoint_creation() {
899        let weights = vec![
900            Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
901                .expect("array shape mismatch"),
902        ];
903        let biases = vec![Array1::from_vec(vec![0.1, 0.2, 0.3])];
904        let model_weights = ModelWeights::new(weights, biases);
905
906        let checkpoint = ModelCheckpoint::new("MLP".to_string(), model_weights, 10);
907
908        assert_eq!(checkpoint.model_type, "MLP");
909        assert_eq!(checkpoint.current_epoch, 10);
910        assert!(checkpoint.validate().is_ok());
911    }
912
913    #[cfg(feature = "serde")]
914    #[test]
915    fn test_checkpoint_manager() {
916        use std::env;
917
918        let temp_dir = env::temp_dir().join("sklears_checkpoint_test");
919        let manager = CheckpointManager::new(&temp_dir).expect("construction should succeed");
920
921        assert!(temp_dir.exists());
922        assert_eq!(manager.checkpoint_dir, temp_dir);
923
924        // Clean up
925        let _ = fs::remove_dir_all(&temp_dir);
926    }
927
928    #[test]
929    #[cfg(all(feature = "mmap", feature = "serde"))]
930    fn test_memory_mapped_checkpoint() {
931        use std::env;
932
933        let temp_dir = env::temp_dir().join("sklears_mmap_test");
934        let checkpoint_manager = CheckpointManager::new(&temp_dir)
935            .expect("operation should succeed")
936            .with_format(CheckpointFormat::MemoryMapped);
937
938        // Create test checkpoint
939        let weights = vec![
940            Array2::from_shape_vec((2, 3), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
941                .expect("array shape mismatch"),
942            Array2::from_shape_vec((3, 1), vec![7.0, 8.0, 9.0]).expect("array shape mismatch"),
943        ];
944        let biases = vec![
945            Array1::from_vec(vec![0.1, 0.2, 0.3]),
946            Array1::from_vec(vec![0.4]),
947        ];
948        let model_weights = ModelWeights::new(weights, biases);
949
950        let optimizer_state = OptimizerState::new("Adam".to_string(), 0.001).with_step_count(100);
951
952        let checkpoint = ModelCheckpoint::new("MLP".to_string(), model_weights, 42)
953            .with_optimizer(optimizer_state);
954
955        // Save checkpoint
956        let saved_path = checkpoint_manager
957            .save_checkpoint(&checkpoint)
958            .expect("operation should succeed");
959        assert!(saved_path.exists());
960        assert_eq!(
961            saved_path.extension().expect("operation should succeed"),
962            "mmap"
963        );
964
965        // Load checkpoint
966        let loaded_checkpoint = checkpoint_manager
967            .load_checkpoint(&saved_path)
968            .expect("operation should succeed");
969
970        // Verify loaded checkpoint
971        assert_eq!(loaded_checkpoint.current_epoch, 42);
972        assert_eq!(loaded_checkpoint.weights.num_layers(), 2);
973        assert_eq!(loaded_checkpoint.weights.total_parameters(), 13);
974
975        if let Some(ref opt_state) = loaded_checkpoint.optimizer_state {
976            assert_eq!(opt_state.optimizer_type, "Adam");
977            assert_abs_diff_eq!(opt_state.learning_rate, 0.001, epsilon = 1e-10);
978            assert_eq!(opt_state.step_count, 100);
979        } else {
980            panic!("Optimizer state should be present");
981        }
982
983        // Verify weight values
984        assert_abs_diff_eq!(
985            loaded_checkpoint.weights.weights[0][[0, 0]],
986            1.0,
987            epsilon = 1e-10
988        );
989        assert_abs_diff_eq!(
990            loaded_checkpoint.weights.weights[0][[1, 2]],
991            6.0,
992            epsilon = 1e-10
993        );
994        assert_abs_diff_eq!(loaded_checkpoint.weights.biases[0][1], 0.2, epsilon = 1e-10);
995
996        // Clean up
997        let _ = fs::remove_dir_all(&temp_dir);
998    }
999}