Files
rustytorch/crates/training/rtx-transformers/src/ssl/jepa_checkpoint.rs
T
osobhandClaude Sonnet 5 4aaa36a57a style: cargo fmt --workspace (whitespace/wrapping only, no semantic change)
Whole-workspace rustfmt pass picked up while iterating on Mamba GPU
backward work. Verified formatting-only via diff sampling; no logic
changed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-10 07:09:36 -07:00

787 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<f32>,
}
/// A complete JEPA model checkpoint.
#[derive(Debug, Clone)]
pub struct JepaModelCheckpoint {
pub step: usize,
pub fields: Vec<WeightField>,
/// 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<u32> {
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<u64> {
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<u8>, 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<u8>, 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<u8> {
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<JepaModelCheckpoint, CheckpointError> {
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<JepaModelCheckpoint, CheckpointError> {
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<WeightField> {
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<WeightField> = (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<WeightField> = 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");
}
}