//! JEPA model checkpoint serialization. //! //! Binary format: simple, self-describing, no external dependencies. //! //! File layout: //! ```text //! [0..4] magic: b"JEPA" //! [4..8] version: u32 LE = 1 //! [8..12] field_count: u32 LE //! For each field: //! [N..N+4] name_len: u32 LE //! [N+4..] name: UTF-8 bytes (name_len bytes) //! [..] value_count: u32 LE //! [..] values: f32 LE (value_count × 4 bytes) //! [end-8] step: u64 LE //! [end-4] checksum: u32 LE (sum of all value bytes, wrapping) //! ``` use super::jepa_vit::CpuViTEncoder; // ============================================================================ // Public types // ============================================================================ /// Named weight tensor saved to a JEPA checkpoint. #[derive(Debug, Clone)] pub struct WeightField { pub name: String, pub values: Vec, } /// A complete JEPA model checkpoint. #[derive(Debug, Clone)] pub struct JepaModelCheckpoint { pub step: usize, pub fields: Vec, /// JSON-encoded JepaRunConfig fields for reference. pub config_summary: String, pub mean_loss: f32, pub ema_tau: f32, } /// Save/load errors. #[derive(Debug)] pub enum CheckpointError { Io(String), InvalidMagic, VersionMismatch { found: u32, expected: u32, }, ChecksumMismatch, MissingField(String), WrongSize { field: String, expected: usize, found: usize, }, } impl std::fmt::Display for CheckpointError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CheckpointError::Io(msg) => write!(f, "I/O error: {msg}"), CheckpointError::InvalidMagic => write!(f, "invalid magic bytes (expected b\"JEPA\")"), CheckpointError::VersionMismatch { found, expected } => { write!(f, "version mismatch: found {found}, expected {expected}") } CheckpointError::ChecksumMismatch => { write!(f, "checksum mismatch: data may be corrupt") } CheckpointError::MissingField(name) => write!(f, "missing field: {name}"), CheckpointError::WrongSize { field, expected, found, } => { write!( f, "field '{field}' has wrong size: expected {expected}, found {found}" ) } } } } // ============================================================================ // Binary format helpers // ============================================================================ const MAGIC: &[u8; 4] = b"JEPA"; const VERSION: u32 = 1; /// Read a u32 from `data` starting at `*cursor` (little-endian). Advances cursor. fn read_u32(data: &[u8], cursor: &mut usize) -> Option { if *cursor + 4 > data.len() { return None; } let v = u32::from_le_bytes([ data[*cursor], data[*cursor + 1], data[*cursor + 2], data[*cursor + 3], ]); *cursor += 4; Some(v) } /// Read a u64 from `data` starting at `*cursor` (little-endian). Advances cursor. fn read_u64(data: &[u8], cursor: &mut usize) -> Option { if *cursor + 8 > data.len() { return None; } let v = u64::from_le_bytes([ data[*cursor], data[*cursor + 1], data[*cursor + 2], data[*cursor + 3], data[*cursor + 4], data[*cursor + 5], data[*cursor + 6], data[*cursor + 7], ]); *cursor += 8; Some(v) } /// Write a u32 in little-endian to `buf`. #[inline] fn push_u32(buf: &mut Vec, v: u32) { buf.extend_from_slice(&v.to_le_bytes()); } /// Write a u64 in little-endian to `buf`. #[inline] fn push_u64(buf: &mut Vec, v: u64) { buf.extend_from_slice(&v.to_le_bytes()); } // ============================================================================ // Serialize / Deserialize // ============================================================================ /// Serialize a [`JepaModelCheckpoint`] to binary bytes. /// /// Format: /// 1. `b"JEPA"` magic (4 bytes) /// 2. version = 1 as u32 LE (4 bytes) /// 3. field_count as u32 LE (4 bytes) /// 4. For each WeightField: name_len u32 LE, name bytes, value_count u32 LE, f32 values /// 5. step as u64 LE (8 bytes) /// 6. checksum: wrapping sum of all f32 raw bytes, as u32 LE (4 bytes) pub fn serialize_checkpoint(ckpt: &JepaModelCheckpoint) -> Vec { let mut buf = Vec::new(); // Magic buf.extend_from_slice(MAGIC); // Version push_u32(&mut buf, VERSION); // Field count push_u32(&mut buf, ckpt.fields.len() as u32); // Accumulate checksum across all value bytes let mut checksum = 0u32; for field in &ckpt.fields { // name_len + name bytes let name_bytes = field.name.as_bytes(); push_u32(&mut buf, name_bytes.len() as u32); buf.extend_from_slice(name_bytes); // value_count + f32 values push_u32(&mut buf, field.values.len() as u32); for &v in &field.values { let bytes = v.to_le_bytes(); // Accumulate each byte into checksum for b in bytes { checksum = checksum.wrapping_add(b as u32); } buf.extend_from_slice(&bytes); } } // Step push_u64(&mut buf, ckpt.step as u64); // Checksum push_u32(&mut buf, checksum); buf } /// Deserialize a [`JepaModelCheckpoint`] from binary bytes. /// /// Returns `Err` on format violations (magic, version, checksum). pub fn deserialize_checkpoint(data: &[u8]) -> Result { let mut cursor = 0usize; // Magic if data.len() < 4 || &data[0..4] != MAGIC { return Err(CheckpointError::InvalidMagic); } cursor += 4; // Version let version = read_u32(data, &mut cursor).ok_or(CheckpointError::InvalidMagic)?; if version != VERSION { return Err(CheckpointError::VersionMismatch { found: version, expected: VERSION, }); } // Field count let field_count = read_u32(data, &mut cursor) .ok_or_else(|| CheckpointError::Io("truncated at field_count".to_string()))? as usize; // Read fields let mut fields = Vec::with_capacity(field_count); let mut checksum_computed = 0u32; for _ in 0..field_count { // name_len let name_len = read_u32(data, &mut cursor) .ok_or_else(|| CheckpointError::Io("truncated at name_len".to_string()))? as usize; // name bytes if cursor + name_len > data.len() { return Err(CheckpointError::Io("truncated at name bytes".to_string())); } let name = String::from_utf8(data[cursor..cursor + name_len].to_vec()) .map_err(|e| CheckpointError::Io(format!("invalid UTF-8 in field name: {e}")))?; cursor += name_len; // value_count let value_count = read_u32(data, &mut cursor) .ok_or_else(|| CheckpointError::Io("truncated at value_count".to_string()))? as usize; // f32 values let byte_len = value_count * 4; if cursor + byte_len > data.len() { return Err(CheckpointError::Io(format!( "truncated reading values for field '{name}': need {byte_len} bytes at offset {cursor}, have {}", data.len() ))); } let mut values = Vec::with_capacity(value_count); for i in 0..value_count { let off = cursor + i * 4; let bytes = [data[off], data[off + 1], data[off + 2], data[off + 3]]; for b in bytes { checksum_computed = checksum_computed.wrapping_add(b as u32); } values.push(f32::from_le_bytes(bytes)); } cursor += byte_len; fields.push(WeightField { name, values }); } // Step let step = read_u64(data, &mut cursor) .ok_or_else(|| CheckpointError::Io("truncated at step".to_string()))? as usize; // Checksum let checksum_stored = read_u32(data, &mut cursor) .ok_or_else(|| CheckpointError::Io("truncated at checksum".to_string()))?; if checksum_stored != checksum_computed { return Err(CheckpointError::ChecksumMismatch); } Ok(JepaModelCheckpoint { step, fields, config_summary: String::new(), mean_loss: 0.0, ema_tau: 0.0, }) } // ============================================================================ // File I/O // ============================================================================ /// Write a checkpoint to disk at `path`. pub fn save_checkpoint(ckpt: &JepaModelCheckpoint, path: &str) -> Result<(), CheckpointError> { let bytes = serialize_checkpoint(ckpt); std::fs::write(path, &bytes) .map_err(|e| CheckpointError::Io(format!("failed to write '{path}': {e}")))?; Ok(()) } /// Load a checkpoint from disk. pub fn load_checkpoint(path: &str) -> Result { let data = std::fs::read(path) .map_err(|e| CheckpointError::Io(format!("failed to read '{path}': {e}")))?; deserialize_checkpoint(&data) } // ============================================================================ // Encoder <-> WeightField conversion // ============================================================================ /// Extract weights from a [`CpuViTEncoder`] into a list of [`WeightField`]s. /// /// Field names: /// - `"patch_embed"` — `[num_patches * embed_dim]` sinusoidal PE + projection /// - `"proj_w"` — `[embed_dim * embed_dim]` patch projection weights /// - `"proj_b"` — `[embed_dim]` patch projection bias /// - `"block_{i}_qkv_w"` — `[embed_dim * 3 * embed_dim]` /// - `"block_{i}_qkv_b"` — `[3 * embed_dim]` /// - `"block_{i}_out_w"` — `[embed_dim * embed_dim]` /// - `"block_{i}_out_b"` — `[embed_dim]` /// - `"block_{i}_ffn1_w"` — `[embed_dim * ffn_dim]` /// - `"block_{i}_ffn1_b"` — `[ffn_dim]` /// - `"block_{i}_ffn2_w"` — `[ffn_dim * embed_dim]` /// - `"block_{i}_ffn2_b"` — `[embed_dim]` pub fn encoder_to_fields(encoder: &CpuViTEncoder) -> Vec { let mut fields = Vec::new(); fields.push(WeightField { name: "patch_embed".to_string(), values: encoder.patch_embed.clone(), }); fields.push(WeightField { name: "proj_w".to_string(), values: encoder.proj_w.clone(), }); fields.push(WeightField { name: "proj_b".to_string(), values: encoder.proj_b.clone(), }); for (i, block) in encoder.blocks.iter().enumerate() { fields.push(WeightField { name: format!("block_{i}_qkv_w"), values: block.qkv_w.clone(), }); fields.push(WeightField { name: format!("block_{i}_qkv_b"), values: block.qkv_b.clone(), }); fields.push(WeightField { name: format!("block_{i}_out_w"), values: block.out_w.clone(), }); fields.push(WeightField { name: format!("block_{i}_out_b"), values: block.out_b.clone(), }); fields.push(WeightField { name: format!("block_{i}_ffn1_w"), values: block.ffn1_w.clone(), }); fields.push(WeightField { name: format!("block_{i}_ffn1_b"), values: block.ffn1_b.clone(), }); fields.push(WeightField { name: format!("block_{i}_ffn2_w"), values: block.ffn2_w.clone(), }); fields.push(WeightField { name: format!("block_{i}_ffn2_b"), values: block.ffn2_b.clone(), }); } fields } /// Apply [`WeightField`]s back into a [`CpuViTEncoder`] in-place. /// /// Returns `Err(MissingField)` if a required field is absent, /// or `Err(WrongSize)` if a field has incorrect length. pub fn apply_fields_to_encoder( encoder: &mut CpuViTEncoder, fields: &[WeightField], ) -> Result<(), CheckpointError> { // Build a lookup map from field name to values slice let map: std::collections::HashMap<&str, &[f32]> = fields .iter() .map(|f| (f.name.as_str(), f.values.as_slice())) .collect(); macro_rules! apply_field { ($name:expr, $target:expr) => {{ let name: &str = $name; let vals = map .get(name) .ok_or_else(|| CheckpointError::MissingField(name.to_string()))?; if vals.len() != $target.len() { return Err(CheckpointError::WrongSize { field: name.to_string(), expected: $target.len(), found: vals.len(), }); } $target.copy_from_slice(vals); }}; } apply_field!("patch_embed", encoder.patch_embed); apply_field!("proj_w", encoder.proj_w); apply_field!("proj_b", encoder.proj_b); for (i, block) in encoder.blocks.iter_mut().enumerate() { apply_field!(&format!("block_{i}_qkv_w"), block.qkv_w); apply_field!(&format!("block_{i}_qkv_b"), block.qkv_b); apply_field!(&format!("block_{i}_out_w"), block.out_w); apply_field!(&format!("block_{i}_out_b"), block.out_b); apply_field!(&format!("block_{i}_ffn1_w"), block.ffn1_w); apply_field!(&format!("block_{i}_ffn1_b"), block.ffn1_b); apply_field!(&format!("block_{i}_ffn2_w"), block.ffn2_w); apply_field!(&format!("block_{i}_ffn2_b"), block.ffn2_b); } Ok(()) } // ============================================================================ // Tests // ============================================================================ #[cfg(test)] mod tests { use super::super::jepa_vit::{CpuViTEncoder, JepaViTConfig}; use super::*; fn tiny_config() -> JepaViTConfig { JepaViTConfig { embed_dim: 32, depth: 2, num_heads: 4, mlp_ratio: 2.0, patch_size: 16, image_size: 64, } } fn empty_ckpt(step: usize) -> JepaModelCheckpoint { JepaModelCheckpoint { step, fields: Vec::new(), config_summary: String::new(), mean_loss: 0.0, ema_tau: 0.0, } } fn single_field_ckpt(step: usize) -> JepaModelCheckpoint { JepaModelCheckpoint { step, fields: vec![WeightField { name: "test".to_string(), values: vec![1.0, 2.0, 3.0], }], config_summary: String::new(), mean_loss: 0.0, ema_tau: 0.0, } } // 1. Magic bytes #[test] fn test_magic_bytes() { let ckpt = empty_ckpt(0); let bytes = serialize_checkpoint(&ckpt); assert_eq!(&bytes[0..4], b"JEPA", "first 4 bytes must be b\"JEPA\""); } // 2. Version #[test] fn test_version() { let ckpt = empty_ckpt(0); let bytes = serialize_checkpoint(&ckpt); let v = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); assert_eq!(v, 1, "version field must be 1"); } // 3. Round-trip: empty fields #[test] fn test_round_trip_empty_fields() { let ckpt = empty_ckpt(42); let bytes = serialize_checkpoint(&ckpt); let loaded = deserialize_checkpoint(&bytes).expect("deserialize should succeed"); assert_eq!(loaded.step, 42); assert_eq!(loaded.fields.len(), 0); } // 4. Round-trip: single field #[test] fn test_round_trip_single_field() { let ckpt = single_field_ckpt(7); let bytes = serialize_checkpoint(&ckpt); let loaded = deserialize_checkpoint(&bytes).expect("deserialize should succeed"); assert_eq!(loaded.step, 7); assert_eq!(loaded.fields.len(), 1); assert_eq!(loaded.fields[0].name, "test"); assert_eq!(loaded.fields[0].values, vec![1.0f32, 2.0, 3.0]); } // 5. Round-trip: multiple fields #[test] fn test_round_trip_multiple_fields() { let fields: Vec = (0..5) .map(|i| WeightField { name: format!("field_{i}"), values: (0..i * 10 + 1).map(|j| j as f32 * 0.1).collect(), }) .collect(); let ckpt = JepaModelCheckpoint { step: 100, fields, config_summary: "test".to_string(), mean_loss: 1.23, ema_tau: 0.996, }; let bytes = serialize_checkpoint(&ckpt); let loaded = deserialize_checkpoint(&bytes).expect("deserialize should succeed"); assert_eq!(loaded.step, 100); assert_eq!(loaded.fields.len(), 5); for i in 0..5 { assert_eq!(loaded.fields[i].name, format!("field_{i}")); assert_eq!(loaded.fields[i].values.len(), i * 10 + 1); } } // 6. Round-trip: large field #[test] fn test_round_trip_large_field() { let n = 50_000; let ckpt = JepaModelCheckpoint { step: 999, fields: vec![WeightField { name: "large".to_string(), values: (0..n).map(|i| i as f32 * 1e-5).collect(), }], config_summary: String::new(), mean_loss: 0.0, ema_tau: 0.0, }; let bytes = serialize_checkpoint(&ckpt); let loaded = deserialize_checkpoint(&bytes).expect("large field should round-trip"); assert_eq!(loaded.fields[0].values.len(), n); // Spot-check a few values assert!((loaded.fields[0].values[0] - 0.0).abs() < 1e-10); assert!((loaded.fields[0].values[1] - 1e-5).abs() < 1e-12); } // 7. Invalid magic #[test] fn test_invalid_magic() { let ckpt = empty_ckpt(0); let mut bytes = serialize_checkpoint(&ckpt); bytes[0] = 0xFF; // corrupt first magic byte let result = deserialize_checkpoint(&bytes); assert!( matches!(result, Err(CheckpointError::InvalidMagic)), "expected InvalidMagic, got {result:?}" ); } // 8. Version mismatch #[test] fn test_version_mismatch() { let ckpt = empty_ckpt(0); let mut bytes = serialize_checkpoint(&ckpt); // Overwrite version bytes (offset 4..8) with version=2 let v2 = 2u32.to_le_bytes(); bytes[4] = v2[0]; bytes[5] = v2[1]; bytes[6] = v2[2]; bytes[7] = v2[3]; let result = deserialize_checkpoint(&bytes); assert!( matches!( result, Err(CheckpointError::VersionMismatch { found: 2, expected: 1 }) ), "expected VersionMismatch{{found:2, expected:1}}, got {result:?}" ); } // 9. Checksum corruption #[test] fn test_checksum_corruption() { let ckpt = single_field_ckpt(1); let mut bytes = serialize_checkpoint(&ckpt); // Flip the very last byte (checksum) let last = bytes.len() - 1; bytes[last] ^= 0xFF; let result = deserialize_checkpoint(&bytes); assert!( matches!(result, Err(CheckpointError::ChecksumMismatch)), "expected ChecksumMismatch, got {result:?}" ); } // 10. Save / load round-trip via temp file #[test] fn test_save_load_roundtrip() { let ckpt = JepaModelCheckpoint { step: 12345, fields: vec![ WeightField { name: "w1".to_string(), values: vec![1.0, 2.0, 3.0], }, WeightField { name: "w2".to_string(), values: vec![4.0, 5.0], }, ], config_summary: "{}".to_string(), mean_loss: 0.42, ema_tau: 0.999, }; let tmp = std::env::temp_dir().join("jepa_test_save_load.jepa"); let path = tmp.to_str().expect("temp path is valid UTF-8"); save_checkpoint(&ckpt, path).expect("save_checkpoint should succeed"); let loaded = load_checkpoint(path).expect("load_checkpoint should succeed"); assert_eq!(loaded.step, 12345); assert_eq!(loaded.fields.len(), 2); assert_eq!(loaded.fields[0].name, "w1"); assert_eq!(loaded.fields[0].values, vec![1.0f32, 2.0, 3.0]); assert_eq!(loaded.fields[1].name, "w2"); assert_eq!(loaded.fields[1].values, vec![4.0f32, 5.0]); // Clean up let _ = std::fs::remove_file(path); } // 11. Load nonexistent file → Err #[test] fn test_load_nonexistent_file() { let result = load_checkpoint("nonexistent_jepa_file_xyz_abc.jepa"); assert!( result.is_err(), "loading a nonexistent file must return Err" ); } // 12. encoder_to_fields produces "patch_embed" field #[test] fn test_encoder_to_fields_names() { let enc = CpuViTEncoder::new(tiny_config()); let fields = encoder_to_fields(&enc); let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); assert!( names.contains(&"patch_embed"), "fields must contain 'patch_embed'; got: {names:?}" ); } // 13. encoder_to_fields count (tiny has depth=2 → 3 + 8*2 = 19 fields) #[test] fn test_encoder_to_fields_count() { let enc = CpuViTEncoder::new(tiny_config()); let fields = encoder_to_fields(&enc); // 3 base fields (patch_embed, proj_w, proj_b) + 8 per block × depth let expected = 3 + 8 * tiny_config().depth; assert_eq!( fields.len(), expected, "expected {expected} fields for tiny config (depth=2), got {}", fields.len() ); } // 14. apply_fields_to_encoder round-trip: extract from A, apply to B → same weights #[test] fn test_apply_fields_roundtrip() { let enc_a = CpuViTEncoder::new(tiny_config()); let mut enc_b = CpuViTEncoder::new(tiny_config()); // Modify enc_b's patch_embed so they start different for v in enc_b.patch_embed.iter_mut() { *v = 0.0; } assert_ne!( enc_a.patch_embed, enc_b.patch_embed, "encoders should start different" ); // Extract from A, apply to B let fields = encoder_to_fields(&enc_a); apply_fields_to_encoder(&mut enc_b, &fields).expect("apply_fields should succeed"); // Now B's patch_embed should match A's assert_eq!( enc_a.patch_embed, enc_b.patch_embed, "patch_embed should match after apply_fields" ); } // 15. apply_fields_to_encoder: missing required field → MissingField error #[test] fn test_apply_fields_missing() { let mut enc = CpuViTEncoder::new(tiny_config()); // Provide only a subset of fields (missing patch_embed) let fields: Vec = Vec::new(); let result = apply_fields_to_encoder(&mut enc, &fields); assert!( matches!(result, Err(CheckpointError::MissingField(ref name)) if name == "patch_embed"), "expected MissingField(\"patch_embed\"), got {result:?}" ); } // 16. apply_fields_to_encoder: wrong size → WrongSize error #[test] fn test_apply_fields_wrong_size() { let mut enc = CpuViTEncoder::new(tiny_config()); // Build a mostly-correct field list but wrong size for patch_embed let mut fields = encoder_to_fields(&enc); // Corrupt patch_embed size for f in fields.iter_mut() { if f.name == "patch_embed" { f.values = vec![1.0, 2.0]; // wrong length break; } } let result = apply_fields_to_encoder(&mut enc, &fields); assert!( matches!(result, Err(CheckpointError::WrongSize { ref field, .. }) if field == "patch_embed"), "expected WrongSize for patch_embed, got {result:?}" ); } // 17. CheckpointError::Display is non-empty #[test] fn test_checkpoint_error_display() { let s = CheckpointError::InvalidMagic.to_string(); assert!( !s.is_empty(), "Display for InvalidMagic should be non-empty" ); let s2 = CheckpointError::VersionMismatch { found: 2, expected: 1, } .to_string(); assert!( !s2.is_empty(), "Display for VersionMismatch should be non-empty" ); let s3 = CheckpointError::ChecksumMismatch.to_string(); assert!( !s3.is_empty(), "Display for ChecksumMismatch should be non-empty" ); let s4 = CheckpointError::MissingField("foo".to_string()).to_string(); assert!( !s4.is_empty(), "Display for MissingField should be non-empty" ); let s5 = CheckpointError::Io("disk full".to_string()).to_string(); assert!(!s5.is_empty(), "Display for Io should be non-empty"); } // 18. Step is preserved exactly #[test] fn test_step_preserved() { let ckpt = empty_ckpt(99999); let bytes = serialize_checkpoint(&ckpt); let loaded = deserialize_checkpoint(&bytes).expect("deserialize should succeed"); assert_eq!(loaded.step, 99999, "step must be preserved exactly"); } }