Skip to main content

axonml_serialize/
lib.rs

1//! Model serialization for AxonML.
2//!
3//! Save/load in .axonml (binary), .json, and SafeTensors formats. `StateDict`
4//! (PyTorch-compatible named parameter collection), `Checkpoint` with
5//! `TrainingState` (loss/val-loss/metric history, epoch/step, config map),
6//! format auto-detection (by extension and magic bytes), PyTorch weight
7//! conversion (`convert_pytorch_key`, `convert_full_dict`, linear weight
8//! transposition), metadata embedding, ONNX shape utilities.
9//!
10//! # File
11//! `crates/axonml-serialize/src/lib.rs`
12//!
13//! # Author
14//! Andrew Jewell Sr. — AutomataNexus LLC
15//! ORCID: 0009-0005-2158-7060
16//!
17//! # Updated
18//! April 14, 2026 11:15 PM EST
19//!
20//! # Disclaimer
21//! Use at own risk. This software is provided "as is", without warranty of any
22//! kind, express or implied. The author and AutomataNexus shall not be held
23//! liable for any damages arising from the use of this software.
24
25#![warn(missing_docs)]
26#![warn(clippy::all)]
27#![warn(clippy::pedantic)]
28// ML/tensor-specific allowances
29#![allow(clippy::cast_possible_truncation)]
30#![allow(clippy::cast_sign_loss)]
31#![allow(clippy::cast_precision_loss)]
32#![allow(clippy::cast_possible_wrap)]
33#![allow(clippy::missing_errors_doc)]
34#![allow(clippy::missing_panics_doc)]
35#![allow(clippy::must_use_candidate)]
36#![allow(clippy::module_name_repetitions)]
37#![allow(clippy::similar_names)]
38#![allow(clippy::many_single_char_names)]
39#![allow(clippy::too_many_arguments)]
40#![allow(clippy::doc_markdown)]
41#![allow(clippy::cast_lossless)]
42#![allow(clippy::needless_pass_by_value)]
43#![allow(clippy::redundant_closure_for_method_calls)]
44#![allow(clippy::uninlined_format_args)]
45#![allow(clippy::ptr_arg)]
46#![allow(clippy::return_self_not_must_use)]
47#![allow(clippy::not_unsafe_ptr_arg_deref)]
48#![allow(clippy::items_after_statements)]
49#![allow(clippy::unreadable_literal)]
50#![allow(clippy::if_same_then_else)]
51#![allow(clippy::needless_range_loop)]
52#![allow(clippy::trivially_copy_pass_by_ref)]
53#![allow(clippy::unnecessary_wraps)]
54#![allow(clippy::match_same_arms)]
55#![allow(clippy::unused_self)]
56#![allow(clippy::too_many_lines)]
57#![allow(clippy::single_match_else)]
58#![allow(clippy::fn_params_excessive_bools)]
59#![allow(clippy::struct_excessive_bools)]
60#![allow(clippy::format_push_string)]
61#![allow(clippy::erasing_op)]
62#![allow(clippy::type_repetition_in_bounds)]
63#![allow(clippy::iter_without_into_iter)]
64#![allow(clippy::should_implement_trait)]
65#![allow(clippy::use_debug)]
66#![allow(clippy::case_sensitive_file_extension_comparisons)]
67#![allow(clippy::large_enum_variant)]
68#![allow(clippy::panic)]
69#![allow(clippy::struct_field_names)]
70#![allow(clippy::missing_fields_in_debug)]
71#![allow(clippy::upper_case_acronyms)]
72#![allow(clippy::assigning_clones)]
73#![allow(clippy::option_if_let_else)]
74#![allow(clippy::manual_let_else)]
75#![allow(clippy::explicit_iter_loop)]
76#![allow(clippy::default_trait_access)]
77#![allow(clippy::only_used_in_recursion)]
78#![allow(clippy::manual_clamp)]
79#![allow(clippy::ref_option)]
80#![allow(clippy::multiple_bound_locations)]
81#![allow(clippy::comparison_chain)]
82#![allow(clippy::manual_assert)]
83#![allow(clippy::unnecessary_debug_formatting)]
84
85// =============================================================================
86// Modules
87// =============================================================================
88
89mod bundle;
90mod checkpoint;
91mod convert;
92mod format;
93mod state_dict;
94
95// =============================================================================
96// Re-exports
97// =============================================================================
98
99pub use bundle::{
100    AXONML_BUNDLE_VERSION, AXONML_MAGIC, BundleError, BundleHeader, BundleResult, ModelBundle,
101    load_bundle, load_bundle_from_bytes, load_header, save_bundle,
102};
103pub use checkpoint::{Checkpoint, CheckpointBuilder, TrainingState};
104pub use convert::{
105    OnnxOpType, convert_from_pytorch, from_onnx_shape, from_pytorch_key, pytorch_layer_mapping,
106    to_onnx_shape, to_pytorch_key, transpose_linear_weights,
107};
108pub use format::{Format, detect_format, detect_format_from_bytes};
109pub use state_dict::{StateDict, StateDictEntry, TensorData};
110
111// =============================================================================
112// Imports
113// =============================================================================
114
115use axonml_core::{Error, Result};
116use axonml_nn::Module;
117use std::fs::File;
118use std::io::{BufReader, BufWriter, Read, Write};
119use std::path::Path;
120
121// =============================================================================
122// High-Level API
123// =============================================================================
124
125/// Save a model's state dictionary to a file.
126///
127/// The format is automatically determined from the file extension.
128pub fn save_model<M: Module, P: AsRef<Path>>(model: &M, path: P) -> Result<()> {
129    let path = path.as_ref();
130    let format = detect_format(path);
131    let state_dict = StateDict::from_module(model);
132
133    save_state_dict(&state_dict, path, format)
134}
135
136/// Load a model's parameters from a saved state dictionary file.
137///
138/// Matches parameters by name. Returns the number of parameters loaded.
139/// Parameters not found in the state dict are left unchanged.
140///
141/// # Example
142/// ```rust,ignore
143/// let mut model = MyModel::new();
144/// let loaded = load_model(&model, "model.axonml")?;
145/// println!("Loaded {loaded} parameters");
146/// ```
147pub fn load_model<M: Module, P: AsRef<Path>>(model: &M, path: P) -> Result<usize> {
148    let state_dict = load_state_dict(path)?;
149    let named_params = model.named_parameters();
150    let mut loaded = 0;
151
152    for (name, param) in &named_params {
153        if let Some(entry) = state_dict.get(name) {
154            if let Ok(tensor) = entry.data.to_tensor() {
155                if tensor.shape() == param.data().shape() {
156                    param.update_data(tensor);
157                    loaded += 1;
158                }
159            }
160        }
161    }
162
163    // If named_parameters returned empty (model doesn't implement it),
164    // fall back to positional parameter matching
165    if named_params.is_empty() {
166        let params = model.parameters();
167        let entries: Vec<_> = state_dict.entries().collect();
168        for ((_name, entry), param) in entries.iter().zip(params.iter()) {
169            if let Ok(tensor) = entry.data.to_tensor() {
170                if tensor.shape() == param.data().shape() {
171                    param.update_data(tensor);
172                    loaded += 1;
173                }
174            }
175        }
176    }
177
178    Ok(loaded)
179}
180
181/// Save a state dictionary to a file with specified format.
182pub fn save_state_dict<P: AsRef<Path>>(
183    state_dict: &StateDict,
184    path: P,
185    format: Format,
186) -> Result<()> {
187    let path = path.as_ref();
188    let file = File::create(path).map_err(|e| Error::InvalidOperation {
189        message: e.to_string(),
190    })?;
191    let mut writer = BufWriter::new(file);
192
193    match format {
194        Format::Axonml => {
195            let encoded = bincode::serialize(state_dict).map_err(|e| Error::InvalidOperation {
196                message: e.to_string(),
197            })?;
198            writer
199                .write_all(&encoded)
200                .map_err(|e| Error::InvalidOperation {
201                    message: e.to_string(),
202                })?;
203        }
204        Format::Json => {
205            serde_json::to_writer_pretty(&mut writer, state_dict).map_err(|e| {
206                Error::InvalidOperation {
207                    message: e.to_string(),
208                }
209            })?;
210        }
211        #[cfg(feature = "safetensors")]
212        Format::SafeTensors => {
213            save_safetensors(state_dict, path)?;
214        }
215        #[cfg(not(feature = "safetensors"))]
216        Format::SafeTensors => {
217            return Err(Error::InvalidOperation {
218                message: "SafeTensors format requires 'safetensors' feature".to_string(),
219            });
220        }
221    }
222
223    Ok(())
224}
225
226/// Load a state dictionary from a file.
227pub fn load_state_dict<P: AsRef<Path>>(path: P) -> Result<StateDict> {
228    let path = path.as_ref();
229    let format = detect_format(path);
230
231    let file = File::open(path).map_err(|e| Error::InvalidOperation {
232        message: e.to_string(),
233    })?;
234    let mut reader = BufReader::new(file);
235
236    match format {
237        Format::Axonml => {
238            let mut bytes = Vec::new();
239            reader
240                .read_to_end(&mut bytes)
241                .map_err(|e| Error::InvalidOperation {
242                    message: e.to_string(),
243                })?;
244            bincode::deserialize(&bytes).map_err(|e| Error::InvalidOperation {
245                message: e.to_string(),
246            })
247        }
248        Format::Json => serde_json::from_reader(reader).map_err(|e| Error::InvalidOperation {
249            message: e.to_string(),
250        }),
251        #[cfg(feature = "safetensors")]
252        Format::SafeTensors => load_safetensors(path),
253        #[cfg(not(feature = "safetensors"))]
254        Format::SafeTensors => Err(Error::InvalidOperation {
255            message: "SafeTensors format requires 'safetensors' feature".to_string(),
256        }),
257    }
258}
259
260/// Save a complete training checkpoint.
261pub fn save_checkpoint<P: AsRef<Path>>(checkpoint: &Checkpoint, path: P) -> Result<()> {
262    let path = path.as_ref();
263    let file = File::create(path).map_err(|e| Error::InvalidOperation {
264        message: e.to_string(),
265    })?;
266    let writer = BufWriter::new(file);
267
268    bincode::serialize_into(writer, checkpoint).map_err(|e| Error::InvalidOperation {
269        message: e.to_string(),
270    })
271}
272
273/// Load a training checkpoint.
274pub fn load_checkpoint<P: AsRef<Path>>(path: P) -> Result<Checkpoint> {
275    let path = path.as_ref();
276    let file = File::open(path).map_err(|e| Error::InvalidOperation {
277        message: e.to_string(),
278    })?;
279    let reader = BufReader::new(file);
280
281    bincode::deserialize_from(reader).map_err(|e| Error::InvalidOperation {
282        message: e.to_string(),
283    })
284}
285
286// =============================================================================
287// SafeTensors Support
288// =============================================================================
289
290#[cfg(feature = "safetensors")]
291fn save_safetensors<P: AsRef<Path>>(state_dict: &StateDict, path: P) -> Result<()> {
292    use safetensors::tensor::{Dtype, TensorView};
293    use std::collections::HashMap;
294
295    // Build TensorView data for each parameter
296    let mut tensor_data: HashMap<String, Vec<u8>> = HashMap::new();
297    let mut tensor_shapes: HashMap<String, Vec<usize>> = HashMap::new();
298
299    for (name, entry) in state_dict.entries() {
300        let data_bytes: Vec<u8> = entry
301            .data
302            .values
303            .iter()
304            .flat_map(|f| f.to_le_bytes())
305            .collect();
306        tensor_data.insert(name.clone(), data_bytes);
307        tensor_shapes.insert(name.clone(), entry.data.shape.clone());
308    }
309
310    // Create TensorViews referencing the data
311    let views: Vec<(String, TensorView<'_>)> = tensor_data
312        .iter()
313        .map(|(name, data)| {
314            let shape = tensor_shapes.get(name).expect("shape missing");
315            (
316                name.clone(),
317                TensorView::new(Dtype::F32, shape.clone(), data).expect("invalid tensor view"),
318            )
319        })
320        .collect();
321
322    let view_refs: Vec<(&str, TensorView<'_>)> = views
323        .iter()
324        .map(|(name, view)| (name.as_str(), view.clone()))
325        .collect();
326
327    let bytes =
328        safetensors::tensor::serialize(&view_refs, &None).map_err(|e| Error::InvalidOperation {
329            message: format!("SafeTensors serialize failed: {e}"),
330        })?;
331
332    std::fs::write(path, bytes).map_err(|e| Error::InvalidOperation {
333        message: e.to_string(),
334    })
335}
336
337#[cfg(feature = "safetensors")]
338fn load_safetensors<P: AsRef<Path>>(path: P) -> Result<StateDict> {
339    let bytes = std::fs::read(path).map_err(|e| Error::InvalidOperation {
340        message: e.to_string(),
341    })?;
342
343    let tensors =
344        safetensors::SafeTensors::deserialize(&bytes).map_err(|e| Error::InvalidOperation {
345            message: e.to_string(),
346        })?;
347
348    let mut state_dict = StateDict::new();
349
350    for (name, tensor) in tensors.tensors() {
351        let data = tensor.data();
352        let shape: Vec<usize> = tensor.shape().to_vec();
353
354        // Convert bytes to f32 based on dtype
355        let dtype = tensor.dtype();
356        let values: Vec<f32> = match dtype {
357            safetensors::Dtype::F32 => data
358                .chunks_exact(4)
359                .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
360                .collect(),
361            safetensors::Dtype::F16 => data
362                .chunks_exact(2)
363                .map(|c| {
364                    let h = half::f16::from_le_bytes([c[0], c[1]]);
365                    h.to_f32()
366                })
367                .collect(),
368            safetensors::Dtype::BF16 => data
369                .chunks_exact(2)
370                .map(|c| {
371                    let h = half::bf16::from_le_bytes([c[0], c[1]]);
372                    h.to_f32()
373                })
374                .collect(),
375            safetensors::Dtype::F64 => data
376                .chunks_exact(8)
377                .map(|c| {
378                    let v = f64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]]);
379                    v as f32
380                })
381                .collect(),
382            other => {
383                return Err(Error::InvalidOperation {
384                    message: format!(
385                        "Unsupported safetensors dtype: {:?} for tensor '{}'",
386                        other, name
387                    ),
388                });
389            }
390        };
391
392        state_dict.insert(name.to_string(), TensorData { shape, values });
393    }
394
395    Ok(state_dict)
396}
397
398// =============================================================================
399// Tests
400// =============================================================================
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn test_format_detection() {
408        assert_eq!(detect_format("model.axonml"), Format::Axonml);
409        assert_eq!(detect_format("model.json"), Format::Json);
410        assert_eq!(detect_format("model.safetensors"), Format::SafeTensors);
411        assert_eq!(detect_format("model.bin"), Format::Axonml); // default
412    }
413
414    #[test]
415    fn test_state_dict_creation() {
416        let state_dict = StateDict::new();
417        assert!(state_dict.is_empty());
418        assert_eq!(state_dict.len(), 0);
419    }
420
421    #[test]
422    fn test_state_dict_insert_get() {
423        let mut state_dict = StateDict::new();
424        let data = TensorData {
425            shape: vec![2, 3],
426            values: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
427        };
428
429        state_dict.insert("layer.weight".to_string(), data);
430
431        assert_eq!(state_dict.len(), 1);
432        assert!(state_dict.contains("layer.weight"));
433
434        let retrieved = state_dict.get("layer.weight").unwrap();
435        assert_eq!(retrieved.data.shape, vec![2, 3]);
436    }
437
438    #[test]
439    fn test_tensor_data_to_tensor() {
440        let data = TensorData {
441            shape: vec![2, 2],
442            values: vec![1.0, 2.0, 3.0, 4.0],
443        };
444
445        let tensor = data.to_tensor().unwrap();
446        assert_eq!(tensor.shape(), &[2, 2]);
447        assert_eq!(tensor.to_vec(), vec![1.0, 2.0, 3.0, 4.0]);
448    }
449
450    #[test]
451    fn test_state_dict_file_roundtrip_axonml() {
452        let mut sd = StateDict::new();
453        sd.insert(
454            "layer.weight".to_string(),
455            TensorData {
456                shape: vec![3, 2],
457                values: vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
458            },
459        );
460        sd.insert(
461            "layer.bias".to_string(),
462            TensorData {
463                shape: vec![3],
464                values: vec![0.1, 0.2, 0.3],
465            },
466        );
467
468        let path = std::env::temp_dir().join("axonml_test_roundtrip.axonml");
469        save_state_dict(&sd, &path, Format::Axonml).expect("save failed");
470
471        let loaded = load_state_dict(&path).expect("load failed");
472        assert_eq!(loaded.len(), 2);
473        assert!(loaded.contains("layer.weight"));
474        assert!(loaded.contains("layer.bias"));
475
476        let w = loaded.get("layer.weight").unwrap();
477        assert_eq!(w.data.shape, vec![3, 2]);
478        assert_eq!(w.data.values, vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
479
480        std::fs::remove_file(&path).ok();
481    }
482
483    #[test]
484    fn test_state_dict_file_roundtrip_json() {
485        let mut sd = StateDict::new();
486        sd.insert(
487            "fc.weight".to_string(),
488            TensorData {
489                shape: vec![2, 2],
490                values: vec![1.5, -2.5, 3.5, -4.5],
491            },
492        );
493
494        let path = std::env::temp_dir().join("axonml_test_roundtrip.json");
495        save_state_dict(&sd, &path, Format::Json).expect("save failed");
496
497        let loaded = load_state_dict(&path).expect("load failed");
498        assert_eq!(loaded.len(), 1);
499        let w = loaded.get("fc.weight").unwrap();
500        assert_eq!(w.data.values, vec![1.5, -2.5, 3.5, -4.5]);
501
502        std::fs::remove_file(&path).ok();
503    }
504
505    #[test]
506    fn test_checkpoint_file_roundtrip() {
507        let mut state = TrainingState::new();
508        state.epoch = 10;
509        state.global_step = 5000;
510        state.record_loss(0.5);
511        state.record_loss(0.3);
512        state.update_best("loss", 0.3, false);
513
514        let checkpoint = Checkpoint::builder()
515            .training_state(state)
516            .epoch(10)
517            .global_step(5000)
518            .config("lr", "0.001")
519            .build();
520
521        let path = std::env::temp_dir().join("axonml_test_checkpoint.axonml");
522        save_checkpoint(&checkpoint, &path).expect("save failed");
523
524        let loaded = load_checkpoint(&path).expect("load failed");
525        assert_eq!(loaded.epoch(), 10);
526        assert_eq!(loaded.global_step(), 5000);
527        assert_eq!(loaded.best_metric(), Some(0.3));
528        assert!(loaded.config.contains_key("lr"));
529        assert!(loaded.timestamp.contains('T')); // ISO 8601 format
530
531        std::fs::remove_file(&path).ok();
532    }
533
534    // =========================================================================
535    // Model Save/Load Roundtrip
536    // =========================================================================
537
538    #[test]
539    fn test_save_load_model_roundtrip() {
540        use axonml_nn::Linear;
541
542        let model = Linear::new(4, 3);
543        let original_data: Vec<f32> = model
544            .parameters()
545            .iter()
546            .flat_map(|p| p.data().to_vec())
547            .collect();
548
549        // Save
550        let path = std::env::temp_dir().join("axonml_test_model_rt.axonml");
551        save_model(&model, &path).expect("save_model failed");
552
553        // Load into new model
554        let model2 = Linear::new(4, 3);
555        let state_dict = load_state_dict(&path).expect("load failed");
556
557        // Apply loaded weights
558        let params2 = model2.named_parameters();
559        for (name, param) in &params2 {
560            if let Some(entry) = state_dict.get(name) {
561                if let Ok(tensor) = entry.data.to_tensor() {
562                    param.update_data(tensor);
563                }
564            }
565        }
566
567        let loaded_data: Vec<f32> = model2
568            .parameters()
569            .iter()
570            .flat_map(|p| p.data().to_vec())
571            .collect();
572
573        // Weights should match exactly
574        assert_eq!(original_data.len(), loaded_data.len());
575        for (a, b) in original_data.iter().zip(loaded_data.iter()) {
576            assert!(
577                (a - b).abs() < 1e-7,
578                "Model weights should survive save/load roundtrip: {} vs {}",
579                a,
580                b
581            );
582        }
583
584        std::fs::remove_file(&path).ok();
585    }
586
587    #[test]
588    fn test_state_dict_from_module() {
589        use axonml_nn::Linear;
590
591        let model = Linear::new(5, 3);
592        let sd = StateDict::from_module(&model);
593
594        // Linear has weight and bias
595        assert!(
596            sd.len() >= 2,
597            "Linear should have at least 2 params, got {}",
598            sd.len()
599        );
600
601        // Check that tensor data is correct
602        for (name, entry) in sd.entries() {
603            let tensor = entry.data.to_tensor().expect("Should reconstruct tensor");
604            assert!(
605                tensor.to_vec().iter().all(|v| v.is_finite()),
606                "All values should be finite for param {}",
607                name
608            );
609        }
610    }
611
612    #[test]
613    fn test_checkpoint_with_model_state_roundtrip() {
614        use axonml_nn::Linear;
615
616        let model = Linear::new(3, 2);
617        let sd = StateDict::from_module(&model);
618
619        let mut state = TrainingState::new();
620        state.epoch = 5;
621        state.record_loss(0.8);
622        state.record_loss(0.5);
623        state.record_val_loss(0.6);
624        state.record_metric("accuracy", 0.92);
625        state.update_best("accuracy", 0.92, true);
626
627        let checkpoint = Checkpoint::builder()
628            .model_state(sd)
629            .training_state(state)
630            .epoch(5)
631            .global_step(1000)
632            .config("model", "linear_3_2")
633            .config("optimizer", "adam")
634            .build();
635
636        let path = std::env::temp_dir().join("axonml_test_full_checkpoint.axonml");
637        save_checkpoint(&checkpoint, &path).expect("save failed");
638
639        let loaded = load_checkpoint(&path).expect("load failed");
640
641        // Verify all fields survived
642        assert_eq!(loaded.epoch(), 5);
643        assert_eq!(loaded.global_step(), 1000);
644        assert_eq!(loaded.best_metric(), Some(0.92));
645        assert_eq!(loaded.training_state.loss_history, vec![0.8, 0.5]);
646        assert_eq!(loaded.training_state.val_loss_history, vec![0.6]);
647        assert_eq!(loaded.config.get("model"), Some(&"linear_3_2".to_string()));
648        assert_eq!(loaded.config.get("optimizer"), Some(&"adam".to_string()));
649
650        // Verify model state
651        assert!(loaded.model_state.len() >= 2);
652
653        std::fs::remove_file(&path).ok();
654    }
655
656    #[test]
657    fn test_training_state_custom_metrics() {
658        let mut state = TrainingState::new();
659        state.record_metric("auc", 0.85);
660        state.record_metric("auc", 0.90);
661        state.record_metric("f1", 0.75);
662
663        assert_eq!(state.custom_metrics.get("auc").unwrap().len(), 2);
664        assert_eq!(state.custom_metrics.get("f1").unwrap().len(), 1);
665
666        assert!(state.update_best("auc", 0.90, true));
667        assert!(!state.update_best("auc", 0.85, true)); // worse
668        assert!(state.update_best("auc", 0.95, true));
669        assert_eq!(state.best_metric, Some(0.95));
670    }
671}