Skip to main content

scirs2_core/array_protocol/
serialization.rs

1// Copyright (c) 2025, `SciRS2` Team
2//
3// Licensed under the Apache License, Version 2.0
4// (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
5//
6
7//! Serialization and deserialization of neural network models.
8//!
9//! This module provides utilities for saving and loading neural network models,
10//! including their parameters, architecture, and optimizer state.
11
12use std::collections::HashMap;
13use std::fs::{self, File};
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16
17use ::ndarray::{Array, Ix1, Ix2, IxDyn};
18
19#[cfg(feature = "serialization")]
20use serde::{Deserialize, Serialize};
21#[cfg(feature = "serialization")]
22use serde_json;
23
24use chrono;
25
26use crate::array_protocol::grad::{Optimizer, SGD};
27use crate::array_protocol::ml_ops::ActivationFunc;
28use crate::array_protocol::neural::{
29    BatchNorm, Conv2D, Dropout, Layer, Linear, MaxPool2D, Sequential,
30};
31use crate::array_protocol::{ArrayProtocol, NdarrayWrapper};
32use crate::error::{CoreError, CoreResult, ErrorContext};
33
34/// Trait for serializable objects.
35pub trait Serializable {
36    /// Serialize the object to a byte vector.
37    fn serialize(&self) -> CoreResult<Vec<u8>>;
38
39    /// Deserialize the object from a byte vector.
40    fn deserialize(bytes: &[u8]) -> CoreResult<Self>
41    where
42        Self: Sized;
43
44    /// Get the object type name.
45    fn type_name(&self) -> &str;
46}
47
48/// Serialized model file format.
49#[derive(Serialize, Deserialize)]
50pub struct ModelFile {
51    /// Model architecture metadata.
52    pub metadata: ModelMetadata,
53
54    /// Model architecture.
55    pub architecture: ModelArchitecture,
56
57    /// Parameter file paths relative to the model file.
58    pub parameter_files: HashMap<String, String>,
59
60    /// Optimizer state file path relative to the model file.
61    pub optimizer_state: Option<String>,
62}
63
64/// Model metadata.
65#[derive(Serialize, Deserialize)]
66pub struct ModelMetadata {
67    /// Model name.
68    pub name: String,
69
70    /// Model version.
71    pub version: String,
72
73    /// Framework version.
74    pub framework_version: String,
75
76    /// Creation date.
77    pub created_at: String,
78
79    /// Input shape.
80    pub inputshape: Vec<usize>,
81
82    /// Output shape.
83    pub outputshape: Vec<usize>,
84
85    /// Additional metadata.
86    pub additional_info: HashMap<String, String>,
87}
88
89/// Model architecture.
90#[cfg_attr(feature = "serialization", derive(Serialize, Deserialize))]
91pub struct ModelArchitecture {
92    /// Model type.
93    pub model_type: String,
94
95    /// Layer configurations.
96    pub layers: Vec<LayerConfig>,
97}
98
99/// Layer configuration.
100#[cfg_attr(feature = "serialization", derive(Serialize, Deserialize))]
101pub struct LayerConfig {
102    /// Layer type.
103    pub layer_type: String,
104
105    /// Layer name.
106    pub name: String,
107
108    /// Layer configuration.
109    #[cfg(feature = "serialization")]
110    pub config: serde_json::Value,
111    #[cfg(not(feature = "serialization"))]
112    pub config: HashMap<String, String>, // Fallback when serialization is not enabled
113}
114
115/// Model serializer for saving neural network models.
116pub struct ModelSerializer {
117    /// Base directory for saving models.
118    basedir: PathBuf,
119}
120
121impl ModelSerializer {
122    /// Create a new model serializer.
123    pub fn new(basedir: impl AsRef<Path>) -> Self {
124        Self {
125            basedir: basedir.as_ref().to_path_buf(),
126        }
127    }
128
129    /// Save a model to disk.
130    pub fn save_model(
131        &self,
132        model: &Sequential,
133        name: &str,
134        version: &str,
135        optimizer: Option<&dyn Optimizer>,
136    ) -> CoreResult<PathBuf> {
137        // Create model directory
138        let modeldir = self.basedir.join(name).join(version);
139        fs::create_dir_all(&modeldir)?;
140
141        // Create metadata
142        let metadata = ModelMetadata {
143            name: name.to_string(),
144            version: version.to_string(),
145            framework_version: "0.1.0".to_string(),
146            created_at: chrono::Utc::now().to_rfc3339(),
147            inputshape: vec![],  // This would be determined from the model
148            outputshape: vec![], // This would be determined from the model
149            additional_info: HashMap::new(),
150        };
151
152        // Create architecture
153        let architecture = self.create_architecture(model)?;
154
155        // Save parameters
156        let mut parameter_files = HashMap::new();
157        self.save_parameters(model, &modeldir, &mut parameter_files)?;
158
159        // Save optimizer state if provided
160        let optimizer_state = if let Some(optimizer) = optimizer {
161            let optimizerpath = self.save_optimizer(optimizer, &modeldir)?;
162            Some(
163                optimizerpath
164                    .file_name()
165                    .expect("Operation failed")
166                    .to_string_lossy()
167                    .to_string(),
168            )
169        } else {
170            None
171        };
172
173        // Create model file
174        let model_file = ModelFile {
175            metadata,
176            architecture,
177            parameter_files,
178            optimizer_state,
179        };
180
181        // Serialize model file
182        let model_file_path = modeldir.join("model.json");
183        let model_file_json = serde_json::to_string_pretty(&model_file)?;
184        let mut file = File::create(&model_file_path)?;
185        file.write_all(model_file_json.as_bytes())?;
186
187        Ok(model_file_path)
188    }
189
190    /// Load a model from disk.
191    pub fn loadmodel(
192        &self,
193        name: &str,
194        version: &str,
195    ) -> CoreResult<(Sequential, Option<Box<dyn Optimizer>>)> {
196        // Get model directory
197        let modeldir = self.basedir.join(name).join(version);
198
199        // Load model file
200        let model_file_path = modeldir.join("model.json");
201        let mut file = File::open(&model_file_path)?;
202        let mut model_file_json = String::new();
203        file.read_to_string(&mut model_file_json)?;
204
205        let model_file: ModelFile = serde_json::from_str(&model_file_json)?;
206
207        // Create model from architecture
208        let mut model = self.create_model_from_architecture(&model_file.architecture)?;
209
210        // Load parameters
211        self.load_parameters(&mut model, &modeldir, &model_file.parameter_files)?;
212
213        // Load optimizer if available
214        let optimizer = if let Some(optimizer_state) = &model_file.optimizer_state {
215            let optimizerpath = modeldir.join(optimizer_state);
216            Some(self.load_optimizer(&optimizerpath)?)
217        } else {
218            None
219        };
220
221        Ok((model, optimizer))
222    }
223
224    /// Create architecture from a model.
225    fn create_architecture(&self, model: &Sequential) -> CoreResult<ModelArchitecture> {
226        let mut layers = Vec::new();
227
228        for layer in model.layers() {
229            let layer_config = self.create_layer_config(layer.as_ref())?;
230            layers.push(layer_config);
231        }
232
233        Ok(ModelArchitecture {
234            model_type: "Sequential".to_string(),
235            layers,
236        })
237    }
238
239    /// Create layer configuration from a layer.
240    fn create_layer_config(&self, layer: &dyn Layer) -> CoreResult<LayerConfig> {
241        let layer_type = layer.layer_type();
242        if !["Linear", "Conv2D", "MaxPool2D", "BatchNorm", "Dropout"].contains(&layer_type) {
243            return Err(CoreError::NotImplementedError(ErrorContext::new(format!(
244                "Serialization not implemented for layer type: {}",
245                layer.name()
246            ))));
247        };
248
249        // Create configuration based on layer type
250        let config = match layer_type {
251            "Linear" => {
252                // Without downcasting, we can't extract the actual configuration
253                // This would need to be stored in the layer itself
254                serde_json::json!({
255                    "in_features": 0,
256                    "out_features": 0,
257                    "bias": true,
258                    "activation": "relu",
259                })
260            }
261            "Conv2D" => {
262                serde_json::json!({
263                    "filter_height": 3,
264                    "filter_width": 3,
265                    "in_channels": 0,
266                    "out_channels": 0,
267                    "stride": [1, 1],
268                    "padding": [0, 0],
269                    "bias": true,
270                    "activation": "relu",
271                })
272            }
273            "MaxPool2D" => {
274                serde_json::json!({
275                    "kernel_size": [2, 2],
276                    "stride": [2, 2],
277                    "padding": [0, 0],
278                })
279            }
280            "BatchNorm" => {
281                serde_json::json!({
282                    "num_features": 0,
283                    "epsilon": 1e-5,
284                    "momentum": 0.1,
285                })
286            }
287            "Dropout" => {
288                serde_json::json!({
289                    "rate": 0.5,
290                    "seed": null,
291                })
292            }
293            _ => serde_json::json!({}),
294        };
295
296        Ok(LayerConfig {
297            layer_type: layer_type.to_string(),
298            name: layer.name().to_string(),
299            config,
300        })
301    }
302
303    /// Save parameters of a model.
304    fn save_parameters(
305        &self,
306        model: &Sequential,
307        modeldir: &Path,
308        parameter_files: &mut HashMap<String, String>,
309    ) -> CoreResult<()> {
310        // Create parameters directory
311        let params_dir = modeldir.join("parameters");
312        fs::create_dir_all(&params_dir)?;
313
314        // Save parameters for each layer
315        for (i, layer) in model.layers().iter().enumerate() {
316            for (j, param) in layer.parameters().iter().enumerate() {
317                // Generate parameter file name
318                let param_name = format!("layer_{i}_param_{j}");
319                let param_file = format!("{param_name}.npz");
320                let param_path = params_dir.join(&param_file);
321
322                // Save parameter
323                self.save_parameter(param.as_ref(), &param_path)?;
324
325                // Add to parameter files map
326                parameter_files.insert(param_name, format!("parameters/{param_file}"));
327            }
328        }
329
330        Ok(())
331    }
332
333    /// Save a single parameter.
334    fn save_parameter(&self, param: &dyn ArrayProtocol, path: &Path) -> CoreResult<()> {
335        // Layer parameters (weight matrices, bias vectors) are naturally
336        // Ix2/Ix1, not IxDyn — downcast regardless of the concrete dimension
337        // type rather than assuming IxDyn (see `super::downcast_arg_to_ixdyn`).
338        if let Some(ndarray) = super::downcast_arg_to_ixdyn::<f64>(param.as_any()) {
339            // Save the array shape and data
340            let shape: Vec<usize> = ndarray.shape().to_vec();
341            let data: Vec<f64> = ndarray.iter().cloned().collect();
342
343            let save_data = serde_json::json!({
344                "shape": shape,
345                "data": data,
346            });
347
348            let mut file = File::create(path)?;
349            let json_str = serde_json::to_string(&save_data)?;
350            file.write_all(json_str.as_bytes())?;
351
352            Ok(())
353        } else {
354            Err(CoreError::NotImplementedError(ErrorContext::new(
355                "Parameter serialization not implemented for this array type".to_string(),
356            )))
357        }
358    }
359
360    /// Save optimizer state.
361    fn save_optimizer(&self, _optimizer: &dyn Optimizer, modeldir: &Path) -> CoreResult<PathBuf> {
362        // Create optimizer state file
363        let optimizerpath = modeldir.join("optimizer.json");
364
365        // Save basic optimizer metadata
366        // Since the Optimizer trait doesn't have methods to extract its type or config,
367        // we'll just save a placeholder for now
368        let optimizer_data = serde_json::json!({
369            "type": "SGD", // Default to SGD for now
370            "config": {
371                "learningrate": 0.01,
372                "momentum": null
373            },
374            "state": {} // Optimizer state would be saved here
375        });
376
377        let mut file = File::create(&optimizerpath)?;
378        let json_str = serde_json::to_string_pretty(&optimizer_data)?;
379        file.write_all(json_str.as_bytes())?;
380
381        Ok(optimizerpath)
382    }
383
384    /// Create a model from architecture.
385    fn create_model_from_architecture(
386        &self,
387        architecture: &ModelArchitecture,
388    ) -> CoreResult<Sequential> {
389        let mut model = Sequential::new(&architecture.model_type, Vec::new());
390
391        // Create layers from configuration
392        for layer_config in &architecture.layers {
393            let layer = self.create_layer_from_config(layer_config)?;
394            model.add_layer(layer);
395        }
396
397        Ok(model)
398    }
399
400    /// Create a layer from configuration.
401    fn create_layer_from_config(&self, config: &LayerConfig) -> CoreResult<Box<dyn Layer>> {
402        match config.layer_type.as_str() {
403            "Linear" => {
404                // Extract configuration
405                let in_features = config.config["in_features"].as_u64().unwrap_or(0) as usize;
406                let out_features = config.config["out_features"].as_u64().unwrap_or(0) as usize;
407                let bias = config.config["bias"].as_bool().unwrap_or(true);
408                let activation = match config.config["activation"].as_str() {
409                    Some("relu") => Some(ActivationFunc::ReLU),
410                    Some("sigmoid") => Some(ActivationFunc::Sigmoid),
411                    Some("tanh") => Some(ActivationFunc::Tanh),
412                    _ => None,
413                };
414
415                // Create layer
416                Ok(Box::new(Linear::new_random(
417                    &config.name,
418                    in_features,
419                    out_features,
420                    bias,
421                    activation,
422                )))
423            }
424            "Conv2D" => {
425                // Extract configuration
426                let filter_height = config.config["filter_height"].as_u64().unwrap_or(3) as usize;
427                let filter_width = config.config["filter_width"].as_u64().unwrap_or(3) as usize;
428                let in_channels = config.config["in_channels"].as_u64().unwrap_or(0) as usize;
429                let out_channels = config.config["out_channels"].as_u64().unwrap_or(0) as usize;
430                let stride = (
431                    config.config["stride"][0].as_u64().unwrap_or(1) as usize,
432                    config.config["stride"][1].as_u64().unwrap_or(1) as usize,
433                );
434                let padding = (
435                    config.config["padding"][0].as_u64().unwrap_or(0) as usize,
436                    config.config["padding"][1].as_u64().unwrap_or(0) as usize,
437                );
438                let bias = config.config["bias"].as_bool().unwrap_or(true);
439                let activation = match config.config["activation"].as_str() {
440                    Some("relu") => Some(ActivationFunc::ReLU),
441                    Some("sigmoid") => Some(ActivationFunc::Sigmoid),
442                    Some("tanh") => Some(ActivationFunc::Tanh),
443                    _ => None,
444                };
445
446                // Create layer
447                Ok(Box::new(Conv2D::withshape(
448                    &config.name,
449                    filter_height,
450                    filter_width,
451                    in_channels,
452                    out_channels,
453                    stride,
454                    padding,
455                    bias,
456                    activation,
457                )))
458            }
459            "MaxPool2D" => {
460                // Extract configuration
461                let kernel_size = (
462                    config.config["kernel_size"][0].as_u64().unwrap_or(2) as usize,
463                    config.config["kernel_size"][1].as_u64().unwrap_or(2) as usize,
464                );
465                let stride = if config.config["stride"].is_array() {
466                    Some((
467                        config.config["stride"][0].as_u64().unwrap_or(2) as usize,
468                        config.config["stride"][1].as_u64().unwrap_or(2) as usize,
469                    ))
470                } else {
471                    None
472                };
473                let padding = (
474                    config.config["padding"][0].as_u64().unwrap_or(0) as usize,
475                    config.config["padding"][1].as_u64().unwrap_or(0) as usize,
476                );
477
478                // Create layer
479                Ok(Box::new(MaxPool2D::new(
480                    &config.name,
481                    kernel_size,
482                    stride,
483                    padding,
484                )))
485            }
486            "BatchNorm" => {
487                // Extract configuration
488                let num_features = config.config["num_features"].as_u64().unwrap_or(0) as usize;
489                let epsilon = config.config["epsilon"].as_f64().unwrap_or(1e-5);
490                let momentum = config.config["momentum"].as_f64().unwrap_or(0.1);
491
492                // Create layer
493                Ok(Box::new(BatchNorm::withshape(
494                    &config.name,
495                    num_features,
496                    Some(epsilon),
497                    Some(momentum),
498                )))
499            }
500            "Dropout" => {
501                // Extract configuration
502                let rate = config.config["rate"].as_f64().unwrap_or(0.5);
503                let seed = config.config["seed"].as_u64();
504
505                // Create layer
506                Ok(Box::new(Dropout::new(&config.name, rate, seed)))
507            }
508            _ => Err(CoreError::NotImplementedError(ErrorContext::new(format!(
509                "Deserialization not implemented for layer type: {layer_type}",
510                layer_type = config.layer_type
511            )))),
512        }
513    }
514
515    /// Rebuilds a loaded parameter as the same concrete `NdarrayWrapper<f64, D>`
516    /// dimensionality that `existing` (the pre-load parameter value) uses,
517    /// mirroring `save_parameter`'s IxDyn/Ix2/Ix1 scope so the reloaded value
518    /// stays dispatch-compatible with operations (like `matmul`/`add`) that
519    /// require both operands to share the same concrete `D`.
520    fn rebuild_parameter(
521        existing: &dyn ArrayProtocol,
522        shape: &[usize],
523        data: Vec<f64>,
524    ) -> CoreResult<Box<dyn ArrayProtocol>> {
525        let dyn_arr = Array::from_shape_vec(IxDyn(shape), data).map_err(|e| {
526            CoreError::InvalidArgument(ErrorContext::new(format!(
527                "Parameter shape mismatch on load: {e}"
528            )))
529        })?;
530
531        if existing
532            .as_any()
533            .downcast_ref::<NdarrayWrapper<f64, Ix1>>()
534            .is_some()
535        {
536            let arr = dyn_arr.into_dimensionality::<Ix1>().map_err(|e| {
537                CoreError::InvalidArgument(ErrorContext::new(format!(
538                    "expected a 1-D parameter on load: {e}"
539                )))
540            })?;
541            return Ok(Box::new(NdarrayWrapper::new(arr)));
542        }
543        if existing
544            .as_any()
545            .downcast_ref::<NdarrayWrapper<f64, Ix2>>()
546            .is_some()
547        {
548            let arr = dyn_arr.into_dimensionality::<Ix2>().map_err(|e| {
549                CoreError::InvalidArgument(ErrorContext::new(format!(
550                    "expected a 2-D parameter on load: {e}"
551                )))
552            })?;
553            return Ok(Box::new(NdarrayWrapper::new(arr)));
554        }
555        if existing
556            .as_any()
557            .downcast_ref::<NdarrayWrapper<f64, IxDyn>>()
558            .is_some()
559        {
560            return Ok(Box::new(NdarrayWrapper::new(dyn_arr)));
561        }
562
563        Err(CoreError::NotImplementedError(ErrorContext::new(
564            "Parameter loading not implemented for this array type (e.g. Conv2D's Ix4 filters — \
565             matches save_parameter's existing IxDyn/Ix2/Ix1 scope)"
566                .to_string(),
567        )))
568    }
569
570    /// Load parameters into a model.
571    fn load_parameters(
572        &self,
573        model: &mut Sequential,
574        modeldir: &Path,
575        parameter_files: &HashMap<String, String>,
576    ) -> CoreResult<()> {
577        // For each layer, load its parameters and actually write them back
578        // (via `update_parameter`), instead of only reading and discarding
579        // them.
580        for (i, layer) in model.layers_mut().iter_mut().enumerate() {
581            // Snapshot the existing parameters/names before mutating the
582            // layer, since `parameter_names()` must be called before (or
583            // independently of) `update_parameter()`.
584            let existing_params = layer.parameters();
585            let names = layer.parameter_names();
586
587            for (j, existing) in existing_params.iter().enumerate() {
588                let param_name = format!("layer_{i}_param_{j}");
589                let Some(param_file) = parameter_files.get(&param_name) else {
590                    continue;
591                };
592                let param_path = modeldir.join(param_file);
593
594                if !param_path.exists() {
595                    return Err(CoreError::InvalidArgument(ErrorContext::new(format!(
596                        "Parameter file not found: {path}",
597                        path = param_path.display()
598                    ))));
599                }
600
601                let mut file = File::open(&param_path)?;
602                let mut json_str = String::new();
603                file.read_to_string(&mut json_str)?;
604
605                let load_data: serde_json::Value = serde_json::from_str(&json_str)?;
606                let shape: Vec<usize> = serde_json::from_value(load_data["shape"].clone())?;
607                let data: Vec<f64> = serde_json::from_value(load_data["data"].clone())?;
608
609                let new_value = Self::rebuild_parameter(existing.as_ref(), &shape, data)?;
610
611                let name = names.get(j).ok_or_else(|| {
612                    CoreError::InvalidArgument(ErrorContext::new(format!(
613                        "layer {i} has {nparams} parameter(s) but only {nnames} name(s)",
614                        nparams = existing_params.len(),
615                        nnames = names.len()
616                    )))
617                })?;
618                layer.update_parameter(name, new_value).map_err(|e| {
619                    CoreError::InvalidArgument(ErrorContext::new(format!(
620                        "Failed to update parameter '{name}' on layer {i}: {e}"
621                    )))
622                })?;
623            }
624        }
625
626        Ok(())
627    }
628
629    /// Load optimizer state.
630    fn load_optimizer(&self, optimizerpath: &Path) -> CoreResult<Box<dyn Optimizer>> {
631        // Check if optimizer file exists
632        if !optimizerpath.exists() {
633            return Err(CoreError::InvalidArgument(ErrorContext::new(format!(
634                "Optimizer file not found: {path}",
635                path = optimizerpath.display()
636            ))));
637        }
638
639        // Load optimizer metadata
640        let mut file = File::open(optimizerpath)?;
641        let mut json_str = String::new();
642        file.read_to_string(&mut json_str)?;
643
644        let optimizer_data: serde_json::Value = serde_json::from_str(&json_str)?;
645
646        // Create optimizer based on type
647        match optimizer_data["type"].as_str() {
648            Some("SGD") => {
649                let config = &optimizer_data["config"];
650                let learningrate = config["learningrate"].as_f64().unwrap_or(0.01);
651                let momentum = config["momentum"].as_f64();
652                Ok(Box::new(SGD::new(learningrate, momentum)))
653            }
654            _ => {
655                // Default to SGD for unknown types
656                Ok(Box::new(SGD::new(0.01, None)))
657            }
658        }
659    }
660}
661
662/// ONNX model exporter.
663pub struct OnnxExporter;
664
665impl OnnxExporter {
666    /// Export a model to ONNX format.
667    pub fn export(
668        &self,
669        _model: &Sequential,
670        path: impl AsRef<Path>,
671        _inputshape: &[usize],
672    ) -> CoreResult<()> {
673        // This is a simplified implementation for demonstration purposes.
674        // In a real implementation, this would convert the model to ONNX format.
675
676        // For now, we'll just create an empty file as a placeholder
677        File::create(path.as_ref())?;
678
679        Ok(())
680    }
681}
682
683/// Create a model checkpoint.
684#[allow(dead_code)]
685pub fn save_checkpoint(
686    model: &Sequential,
687    optimizer: &dyn Optimizer,
688    path: impl AsRef<Path>,
689    epoch: usize,
690    metrics: HashMap<String, f64>,
691) -> CoreResult<()> {
692    // Create checkpoint directory
693    let checkpoint_dir = path.as_ref().parent().unwrap_or(Path::new("."));
694    fs::create_dir_all(checkpoint_dir)?;
695
696    // Create checkpoint metadata
697    let metadata = serde_json::json!({
698        "epoch": epoch,
699        "metrics": metrics,
700        "timestamp": chrono::Utc::now().to_rfc3339(),
701    });
702
703    // Save metadata
704    let metadata_path = path.as_ref().with_extension("json");
705    let metadata_json = serde_json::to_string_pretty(&metadata)?;
706    let mut file = File::create(&metadata_path)?;
707    file.write_all(metadata_json.as_bytes())?;
708
709    // Create serializer
710    let serializer = ModelSerializer::new(checkpoint_dir);
711
712    // Save model and optimizer
713    let model_name = "checkpoint";
714    let model_version = format!("epoch_{epoch}");
715    serializer.save_model(model, model_name, &model_version, Some(optimizer))?;
716
717    Ok(())
718}
719
720/// Type alias for model checkpoint data
721pub type ModelCheckpoint = (Sequential, Box<dyn Optimizer>, usize, HashMap<String, f64>);
722
723/// Load a model checkpoint.
724#[cfg(feature = "serialization")]
725#[allow(dead_code)]
726pub fn load_checkpoint(path: impl AsRef<Path>) -> CoreResult<ModelCheckpoint> {
727    // Load metadata
728    let metadata_path = path.as_ref().with_extension("json");
729    let mut file = File::open(&metadata_path)?;
730    let mut metadata_json = String::new();
731    file.read_to_string(&mut metadata_json)?;
732
733    let metadata: serde_json::Value = serde_json::from_str(&metadata_json)?;
734
735    // Extract metadata
736    let epoch = metadata["epoch"].as_u64().unwrap_or(0) as usize;
737    let metrics: HashMap<String, f64> =
738        serde_json::from_value(metadata["metrics"].clone()).unwrap_or_else(|_| HashMap::new());
739
740    // Create serializer
741    let checkpoint_dir = path.as_ref().parent().unwrap_or(Path::new("."));
742    let serializer = ModelSerializer::new(checkpoint_dir);
743
744    // Load model and optimizer
745    let model_name = "checkpoint";
746    let model_version = format!("epoch_{epoch}");
747    let (model, optimizer) = serializer.loadmodel(model_name, &model_version)?;
748
749    Ok((model, optimizer.expect("Operation failed"), epoch, metrics))
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use crate::array_protocol;
756    use crate::array_protocol::grad::SGD;
757    use crate::array_protocol::ml_ops::ActivationFunc;
758    use crate::array_protocol::neural::{Linear, Sequential};
759    use tempfile::tempdir;
760
761    /// Extracts a layer's `param_idx`-th parameter as an owned `Array2<f64>`.
762    fn param_ix2(model: &Sequential, layer_idx: usize, param_idx: usize) -> ::ndarray::Array2<f64> {
763        model.layers()[layer_idx].parameters()[param_idx]
764            .as_any()
765            .downcast_ref::<NdarrayWrapper<f64, ::ndarray::Ix2>>()
766            .expect("parameter should be a NdarrayWrapper<f64, Ix2>")
767            .as_array()
768            .clone()
769    }
770
771    /// Extracts a layer's `param_idx`-th parameter as an owned `Array1<f64>`.
772    fn param_ix1(model: &Sequential, layer_idx: usize, param_idx: usize) -> ::ndarray::Array1<f64> {
773        model.layers()[layer_idx].parameters()[param_idx]
774            .as_any()
775            .downcast_ref::<NdarrayWrapper<f64, ::ndarray::Ix1>>()
776            .expect("parameter should be a NdarrayWrapper<f64, Ix1>")
777            .as_array()
778            .clone()
779    }
780
781    #[test]
782    fn test_model_serializer() {
783        // Initialize the array protocol system
784        array_protocol::init();
785
786        // Create a temporary directory
787        let temp_dir = tempdir().expect("failed to create temp dir");
788
789        // Create a model
790        let mut model = Sequential::new("test_model", Vec::new());
791
792        // Add layers. `new_random`'s Xavier-initialized weights are
793        // non-constant by construction (this is what makes the round-trip
794        // check below meaningful: a no-op `load_parameters` — the bug this
795        // regression-tests — would leave `loadedmodel`'s weights at
796        // whatever *fresh* random values `create_model_from_architecture`
797        // happened to draw, which would not match `model`'s original values).
798        model.add_layer(Box::new(Linear::new_random(
799            "fc1",
800            10,
801            5,
802            true,
803            Some(ActivationFunc::ReLU),
804        )));
805
806        model.add_layer(Box::new(Linear::new_random("fc2", 5, 2, true, None)));
807
808        // Capture the original parameter values before saving.
809        let orig_fc1_weights = param_ix2(&model, 0, 0);
810        let orig_fc1_bias = param_ix1(&model, 0, 1);
811        let orig_fc2_weights = param_ix2(&model, 1, 0);
812        let orig_fc2_bias = param_ix1(&model, 1, 1);
813
814        // Create optimizer
815        let optimizer = SGD::new(0.01, Some(0.9));
816
817        // Create serializer
818        let serializer = ModelSerializer::new(temp_dir.path());
819
820        // Save model
821        serializer
822            .save_model(&model, "test_model", "v1", Some(&optimizer))
823            .expect("save_model should succeed");
824
825        // Load model
826        let (loadedmodel, loaded_optimizer) = serializer
827            .loadmodel("test_model", "v1")
828            .expect("loadmodel should succeed");
829
830        // Check model
831        assert_eq!(loadedmodel.layers().len(), 2);
832        assert!(loaded_optimizer.is_some());
833
834        // The actual regression check: loaded parameter VALUES must match
835        // what was saved, element-for-element — not just shape/count. Uses a
836        // tight-but-nonzero tolerance rather than `assert_eq!`: round-tripping
837        // an f64 through `serde_json`'s text-based JSON representation can
838        // perturb the last bit or two for specific values (verified directly:
839        // `serde_json::to_string` emits the exact shortest round-trippable
840        // decimal for e.g. `-0.11303865380207907`, but
841        // `serde_json::from_str`/`from_value` parses that same text back to
842        // `-0.11303865380207909`, one ULP off — a `serde_json`
843        // float-parsing characteristic, not something `save_parameter`/
844        // `load_parameters` control). `1e-9` is ~1e8 times looser than that
845        // single-ULP noise, while still ~1e6 times tighter than the smallest
846        // Xavier-initialized weight magnitude here — nowhere near loose
847        // enough to hide a real bug (wrong shape, wrong values, or a no-op
848        // load would all fail this by many orders of magnitude).
849        assert_arrays_close(&param_ix2(&loadedmodel, 0, 0), &orig_fc1_weights, 1e-9);
850        assert_arrays_close_1d(&param_ix1(&loadedmodel, 0, 1), &orig_fc1_bias, 1e-9);
851        assert_arrays_close(&param_ix2(&loadedmodel, 1, 0), &orig_fc2_weights, 1e-9);
852        assert_arrays_close_1d(&param_ix1(&loadedmodel, 1, 1), &orig_fc2_bias, 1e-9);
853
854        // Guard against a degenerate all-zero/all-equal round trip
855        // trivially "passing" (Xavier init makes this exceedingly unlikely,
856        // but assert it outright rather than relying on that alone).
857        assert!(orig_fc1_weights
858            .iter()
859            .any(|&v| v != orig_fc1_weights[[0, 0]]));
860    }
861
862    /// Asserts two 2-D arrays match within `tol` per element (see
863    /// `test_model_serializer` for why this isn't `assert_eq!`).
864    fn assert_arrays_close(
865        actual: &::ndarray::Array2<f64>,
866        expected: &::ndarray::Array2<f64>,
867        tol: f64,
868    ) {
869        assert_eq!(actual.dim(), expected.dim());
870        for ((i, j), &e) in expected.indexed_iter() {
871            let a = actual[[i, j]];
872            assert!(
873                (a - e).abs() < tol,
874                "mismatch at [{i},{j}]: actual={a}, expected={e}"
875            );
876        }
877    }
878
879    /// 1-D counterpart of [`assert_arrays_close`].
880    fn assert_arrays_close_1d(
881        actual: &::ndarray::Array1<f64>,
882        expected: &::ndarray::Array1<f64>,
883        tol: f64,
884    ) {
885        assert_eq!(actual.dim(), expected.dim());
886        for (i, &e) in expected.indexed_iter() {
887            let a = actual[i];
888            assert!(
889                (a - e).abs() < tol,
890                "mismatch at [{i}]: actual={a}, expected={e}"
891            );
892        }
893    }
894
895    #[test]
896    fn test_save_load_checkpoint() {
897        // Initialize the array protocol system
898        array_protocol::init();
899
900        // Create a temporary directory
901        let temp_dir = tempdir().expect("failed to create temp dir");
902
903        // Create a model
904        let mut model = Sequential::new("test_model", Vec::new());
905
906        // Add layers
907        model.add_layer(Box::new(Linear::new_random(
908            "fc1",
909            10,
910            5,
911            true,
912            Some(ActivationFunc::ReLU),
913        )));
914
915        // Create optimizer
916        let optimizer = SGD::new(0.01, Some(0.9));
917
918        // Create metrics
919        let mut metrics = HashMap::new();
920        metrics.insert("loss".to_string(), 0.1);
921        metrics.insert("accuracy".to_string(), 0.9);
922
923        // Save checkpoint
924        let checkpoint_path = temp_dir.path().join("checkpoint");
925        save_checkpoint(&model, &optimizer, &checkpoint_path, 10, metrics.clone())
926            .expect("save_checkpoint should succeed");
927
928        // Load checkpoint
929        let (loadedmodel, loaded_optimizer, loaded_epoch, loaded_metrics) =
930            load_checkpoint(&checkpoint_path).expect("load_checkpoint should succeed");
931
932        // Check loaded data
933        assert_eq!(loadedmodel.layers().len(), 1);
934        assert_eq!(loaded_epoch, 10);
935        assert_eq!(loaded_metrics.get("loss"), metrics.get("loss"));
936        assert_eq!(loaded_metrics.get("accuracy"), metrics.get("accuracy"));
937    }
938}