1use std::collections::HashMap;
46use std::fs;
47use std::io::{self, Write};
48use std::path::Path;
49
50use serde::{Deserialize, Serialize};
51
52pub const AXONML_MAGIC: &[u8; 6] = b"AXONML";
58
59pub const AXONML_BUNDLE_VERSION: u8 = 1;
61
62#[derive(Debug)]
68pub enum BundleError {
69 Io(io::Error),
71 Serde(serde_json::Error),
73 BadMagic,
75 BadVersion(u8),
77 Truncated(&'static str),
79 Invalid(String),
81}
82
83impl std::fmt::Display for BundleError {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 match self {
86 Self::Io(e) => write!(f, "bundle I/O error: {e}"),
87 Self::Serde(e) => write!(f, "bundle JSON error: {e}"),
88 Self::BadMagic => write!(f, "invalid .axonml file: bad magic bytes"),
89 Self::BadVersion(v) => write!(
90 f,
91 "unsupported .axonml bundle version: {v} (expected {AXONML_BUNDLE_VERSION})"
92 ),
93 Self::Truncated(what) => write!(f, "invalid .axonml file: truncated {what}"),
94 Self::Invalid(msg) => write!(f, "invalid .axonml bundle: {msg}"),
95 }
96 }
97}
98
99impl std::error::Error for BundleError {}
100
101impl From<io::Error> for BundleError {
102 fn from(e: io::Error) -> Self {
103 Self::Io(e)
104 }
105}
106
107impl From<serde_json::Error> for BundleError {
108 fn from(e: serde_json::Error) -> Self {
109 Self::Serde(e)
110 }
111}
112
113pub type BundleResult<T> = Result<T, BundleError>;
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct BundleHeader {
124 pub architecture: String,
126 pub input_features: usize,
128 pub num_parameters: usize,
130 pub quantized: bool,
132 pub quant_bits: Option<u8>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct ModelBundle {
148 pub architecture: String,
150 pub input_features: usize,
152 #[serde(default)]
154 pub hyperparameters: HashMap<String, serde_json::Value>,
155 pub weights: Vec<f32>,
157 #[serde(default)]
159 pub norm_means: Vec<f32>,
160 #[serde(default)]
162 pub norm_stds: Vec<f32>,
163 #[serde(default)]
165 pub anomaly_threshold: Option<f32>,
166}
167
168impl ModelBundle {
169 pub fn new(architecture: &str, input_features: usize, weights: Vec<f32>) -> Self {
171 Self {
172 architecture: architecture.to_string(),
173 input_features,
174 hyperparameters: HashMap::new(),
175 weights,
176 norm_means: Vec::new(),
177 norm_stds: Vec::new(),
178 anomaly_threshold: None,
179 }
180 }
181
182 pub fn with_hyperparam(mut self, key: &str, value: impl Into<serde_json::Value>) -> Self {
184 self.hyperparameters.insert(key.to_string(), value.into());
185 self
186 }
187
188 pub fn with_normalization(mut self, means: Vec<f32>, stds: Vec<f32>) -> Self {
190 self.norm_means = means;
191 self.norm_stds = stds;
192 self
193 }
194
195 pub fn with_threshold(mut self, threshold: f32) -> Self {
197 self.anomaly_threshold = Some(threshold);
198 self
199 }
200
201 pub fn header(&self) -> BundleHeader {
203 BundleHeader {
204 architecture: self.architecture.clone(),
205 input_features: self.input_features,
206 num_parameters: self.weights.len(),
207 quantized: false,
208 quant_bits: None,
209 }
210 }
211}
212
213pub fn save_bundle<P: AsRef<Path>>(
222 bundle: &ModelBundle,
223 path: P,
224) -> BundleResult<std::path::PathBuf> {
225 let raw = path.as_ref();
226 if let Some(parent) = raw.parent() {
227 if !parent.as_os_str().is_empty() {
228 fs::create_dir_all(parent)?;
229 }
230 }
231
232 let final_path = if raw.extension().is_none_or(|e| e != "axonml") {
233 raw.with_extension("axonml")
234 } else {
235 raw.to_path_buf()
236 };
237
238 let header_json = serde_json::to_vec(&bundle.header())?;
239 let weights_json = serde_json::to_vec(bundle)?;
240
241 let mut file = fs::File::create(&final_path)?;
242 file.write_all(AXONML_MAGIC)?;
243 file.write_all(&[AXONML_BUNDLE_VERSION])?;
244 file.write_all(&(header_json.len() as u32).to_le_bytes())?;
245 file.write_all(&header_json)?;
246 file.write_all(&(weights_json.len() as u32).to_le_bytes())?;
247 file.write_all(&weights_json)?;
248
249 Ok(final_path)
250}
251
252pub fn load_bundle<P: AsRef<Path>>(path: P) -> BundleResult<(BundleHeader, ModelBundle)> {
262 let data = fs::read(path)?;
263 load_bundle_from_bytes(&data)
264}
265
266pub fn load_bundle_from_bytes(data: &[u8]) -> BundleResult<(BundleHeader, ModelBundle)> {
268 if data.len() < 11 {
269 return Err(BundleError::Truncated("file (too short for header)"));
270 }
271 if &data[0..6] != AXONML_MAGIC {
272 return Err(BundleError::BadMagic);
273 }
274 let version = data[6];
275 if version != AXONML_BUNDLE_VERSION {
276 return Err(BundleError::BadVersion(version));
277 }
278
279 let header_len = u32::from_le_bytes([data[7], data[8], data[9], data[10]]) as usize;
280 let header_start = 11usize;
281 let header_end = header_start
282 .checked_add(header_len)
283 .ok_or(BundleError::Invalid("header length overflow".into()))?;
284 if data.len() < header_end + 4 {
285 return Err(BundleError::Truncated("header"));
286 }
287 let header: BundleHeader = serde_json::from_slice(&data[header_start..header_end])?;
288
289 let weights_len_bytes = &data[header_end..header_end + 4];
290 let weights_len = u32::from_le_bytes([
291 weights_len_bytes[0],
292 weights_len_bytes[1],
293 weights_len_bytes[2],
294 weights_len_bytes[3],
295 ]) as usize;
296 let weights_start = header_end + 4;
297 let weights_end = weights_start
298 .checked_add(weights_len)
299 .ok_or(BundleError::Invalid("weights length overflow".into()))?;
300 if data.len() < weights_end {
301 return Err(BundleError::Truncated("weights"));
302 }
303 let bundle: ModelBundle = serde_json::from_slice(&data[weights_start..weights_end])?;
304
305 Ok((header, bundle))
306}
307
308pub fn load_header<P: AsRef<Path>>(path: P) -> BundleResult<BundleHeader> {
313 let data = fs::read(path)?;
314 if data.len() < 11 {
315 return Err(BundleError::Truncated("file (too short for header)"));
316 }
317 if &data[0..6] != AXONML_MAGIC {
318 return Err(BundleError::BadMagic);
319 }
320 let version = data[6];
321 if version != AXONML_BUNDLE_VERSION {
322 return Err(BundleError::BadVersion(version));
323 }
324 let header_len = u32::from_le_bytes([data[7], data[8], data[9], data[10]]) as usize;
325 let header_end = 11usize
326 .checked_add(header_len)
327 .ok_or(BundleError::Invalid("header length overflow".into()))?;
328 if data.len() < header_end {
329 return Err(BundleError::Truncated("header"));
330 }
331 Ok(serde_json::from_slice(&data[11..header_end])?)
332}
333
334#[cfg(test)]
339mod tests {
340 use super::*;
341 use tempfile::NamedTempFile;
342
343 #[test]
344 fn round_trip_sentinel_bundle() {
345 let weights: Vec<f32> = (0..256).map(|i| i as f32 * 0.01).collect();
346 let bundle = ModelBundle::new("sentinel", 11, weights.clone())
347 .with_hyperparam("hidden_dim", 128)
348 .with_hyperparam("num_layers", 2)
349 .with_normalization(vec![0.0; 11], vec![1.0; 11])
350 .with_threshold(0.5);
351
352 let tmp = NamedTempFile::new().unwrap();
353 let final_path = save_bundle(&bundle, tmp.path()).unwrap();
354
355 let (header, loaded) = load_bundle(&final_path).unwrap();
356 assert_eq!(header.architecture, "sentinel");
357 assert_eq!(header.input_features, 11);
358 assert_eq!(header.num_parameters, weights.len());
359 assert!(!header.quantized);
360
361 assert_eq!(loaded.architecture, "sentinel");
362 assert_eq!(loaded.weights, weights);
363 assert_eq!(
364 loaded
365 .hyperparameters
366 .get("hidden_dim")
367 .and_then(|v| v.as_i64()),
368 Some(128)
369 );
370 assert_eq!(loaded.anomaly_threshold, Some(0.5));
371 }
372
373 #[test]
374 fn header_only_decode_skips_weights() {
375 let weights: Vec<f32> = (0..10_000).map(|i| i as f32).collect();
376 let bundle = ModelBundle::new("bert", 128, weights);
377
378 let tmp = NamedTempFile::new().unwrap();
379 let path = save_bundle(&bundle, tmp.path()).unwrap();
380
381 let header = load_header(&path).unwrap();
382 assert_eq!(header.architecture, "bert");
383 assert_eq!(header.num_parameters, 10_000);
384 }
385
386 #[test]
387 fn rejects_bad_magic() {
388 let mut bytes = vec![b'X', b'X', b'X', b'X', b'X', b'X'];
389 bytes.extend_from_slice(&[1]);
390 bytes.extend_from_slice(&0u32.to_le_bytes());
391 let err = load_bundle_from_bytes(&bytes).unwrap_err();
392 assert!(matches!(err, BundleError::BadMagic));
393 }
394
395 #[test]
396 fn rejects_bad_version() {
397 let mut bytes = AXONML_MAGIC.to_vec();
398 bytes.push(99);
399 bytes.extend_from_slice(&0u32.to_le_bytes());
400 let err = load_bundle_from_bytes(&bytes).unwrap_err();
401 assert!(matches!(err, BundleError::BadVersion(99)));
402 }
403
404 #[test]
405 fn rejects_truncated_header() {
406 let mut bytes = AXONML_MAGIC.to_vec();
407 bytes.push(AXONML_BUNDLE_VERSION);
408 bytes.extend_from_slice(&500u32.to_le_bytes()); bytes.extend_from_slice(&[0, 0, 0, 0]);
411 let err = load_bundle_from_bytes(&bytes).unwrap_err();
412 assert!(matches!(err, BundleError::Truncated(_)));
413 }
414
415 #[test]
416 fn save_adds_axonml_extension_when_missing() {
417 let bundle = ModelBundle::new("phantom", 20, vec![1.0, 2.0, 3.0]);
418 let dir = tempfile::tempdir().unwrap();
419 let no_ext = dir.path().join("my_model");
420 let final_path = save_bundle(&bundle, &no_ext).unwrap();
421 assert_eq!(final_path.extension().unwrap(), "axonml");
422 assert!(final_path.exists());
423 }
424}