/home/noah/src/realizar/src/model_loader.rs
Line | Count | Source |
1 | | //! Unified Model Loader |
2 | | //! |
3 | | //! Per spec §3.2 and §5: Unified loading for APR, GGUF, and SafeTensors formats. |
4 | | //! |
5 | | //! ## Jidoka (Built-in Quality) |
6 | | //! |
7 | | //! - Format auto-detection from magic bytes |
8 | | //! - CRC32 verification for APR format |
9 | | //! - Header validation for SafeTensors |
10 | | //! - Graceful error handling with detailed messages |
11 | | //! |
12 | | //! ## APR Format Support (First-class) |
13 | | //! |
14 | | //! Per spec §3.1: APR is the primary format for classical ML models from aprender. |
15 | | //! Supports all 18 model types: |
16 | | //! |
17 | | //! | Type | Description | |
18 | | //! |------|-------------| |
19 | | //! | `LinearRegression` | OLS/Ridge/Lasso | |
20 | | //! | `LogisticRegression` | Binary/Multinomial classification | |
21 | | //! | `DecisionTree` | CART/ID3 | |
22 | | //! | `RandomForest` | Bagging ensemble | |
23 | | //! | `GradientBoosting` | Boosting ensemble | |
24 | | //! | `KMeans` | Lloyd's clustering | |
25 | | //! | `PCA` | Dimensionality reduction | |
26 | | //! | `NaiveBayes` | Gaussian NB | |
27 | | //! | `KNN` | k-Nearest Neighbors | |
28 | | //! | `SVM` | Linear SVM | |
29 | | //! | `NgramLM` | N-gram language model | |
30 | | //! | `TFIDF` | TF-IDF vectorizer | |
31 | | //! | `CountVectorizer` | Count vectorizer | |
32 | | //! | `NeuralSequential` | Feed-forward NN | |
33 | | //! | `NeuralCustom` | Custom architecture | |
34 | | //! | `ContentRecommender` | Content-based rec | |
35 | | //! | `MixtureOfExperts` | Sparse/dense MoE | |
36 | | //! | `Custom` | User-defined | |
37 | | //! |
38 | | //! ## GGUF Support (Backwards Compatible) |
39 | | //! |
40 | | //! Per spec §3.3: GGUF for LLM inference with llama.cpp compatibility. |
41 | | //! |
42 | | //! ## SafeTensors Support (Backwards Compatible) |
43 | | //! |
44 | | //! Per spec §3.4: SafeTensors for HuggingFace model weights. |
45 | | |
46 | | use std::path::Path; |
47 | | |
48 | | use crate::format::{detect_and_verify_format, detect_format, FormatError, ModelFormat}; |
49 | | |
50 | | /// Model loading errors |
51 | | #[derive(Debug, Clone)] |
52 | | pub enum LoadError { |
53 | | /// Format detection failed |
54 | | FormatError(FormatError), |
55 | | /// File I/O error |
56 | | IoError(String), |
57 | | /// Model parsing error |
58 | | ParseError(String), |
59 | | /// Unsupported model type for serving |
60 | | UnsupportedType(String), |
61 | | /// CRC32 checksum mismatch (APR) |
62 | | IntegrityError(String), |
63 | | /// Model type mismatch (requested vs detected) |
64 | | TypeMismatch { |
65 | | /// Expected model type |
66 | | expected: String, |
67 | | /// Actual model type in file |
68 | | actual: String, |
69 | | }, |
70 | | } |
71 | | |
72 | | impl std::fmt::Display for LoadError { |
73 | 13 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
74 | 13 | match self { |
75 | 2 | Self::FormatError(e) => write!(f, "Format detection error: {e}"), |
76 | 2 | Self::IoError(msg) => write!(f, "I/O error: {msg}"), |
77 | 2 | Self::ParseError(msg) => write!(f, "Parse error: {msg}"), |
78 | 2 | Self::UnsupportedType(t) => write!(f, "Unsupported model type: {t}"), |
79 | 2 | Self::IntegrityError(msg) => write!(f, "Integrity check failed: {msg}"), |
80 | 3 | Self::TypeMismatch { expected, actual } => { |
81 | 3 | write!(f, "Model type mismatch: expected {expected}, got {actual}") |
82 | | }, |
83 | | } |
84 | 13 | } |
85 | | } |
86 | | |
87 | | impl std::error::Error for LoadError {} |
88 | | |
89 | | impl From<FormatError> for LoadError { |
90 | 1 | fn from(e: FormatError) -> Self { |
91 | 1 | Self::FormatError(e) |
92 | 1 | } |
93 | | } |
94 | | |
95 | | impl From<std::io::Error> for LoadError { |
96 | 0 | fn from(e: std::io::Error) -> Self { |
97 | 0 | Self::IoError(e.to_string()) |
98 | 0 | } |
99 | | } |
100 | | |
101 | | /// Model metadata extracted during loading |
102 | | #[derive(Debug, Clone)] |
103 | | pub struct ModelMetadata { |
104 | | /// Detected format |
105 | | pub format: ModelFormat, |
106 | | /// Model type (if detected) |
107 | | pub model_type: Option<String>, |
108 | | /// Model version |
109 | | pub version: Option<String>, |
110 | | /// Input dimensions (for validation) |
111 | | pub input_dim: Option<usize>, |
112 | | /// Output dimensions |
113 | | pub output_dim: Option<usize>, |
114 | | /// File size in bytes |
115 | | pub file_size: u64, |
116 | | } |
117 | | |
118 | | impl ModelMetadata { |
119 | | /// Create new metadata with format only |
120 | | #[must_use] |
121 | 14 | pub fn new(format: ModelFormat) -> Self { |
122 | 14 | Self { |
123 | 14 | format, |
124 | 14 | model_type: None, |
125 | 14 | version: None, |
126 | 14 | input_dim: None, |
127 | 14 | output_dim: None, |
128 | 14 | file_size: 0, |
129 | 14 | } |
130 | 14 | } |
131 | | |
132 | | /// Set model type |
133 | | #[must_use] |
134 | 4 | pub fn with_model_type(mut self, model_type: impl Into<String>) -> Self { |
135 | 4 | self.model_type = Some(model_type.into()); |
136 | 4 | self |
137 | 4 | } |
138 | | |
139 | | /// Set version |
140 | | #[must_use] |
141 | 3 | pub fn with_version(mut self, version: impl Into<String>) -> Self { |
142 | 3 | self.version = Some(version.into()); |
143 | 3 | self |
144 | 3 | } |
145 | | |
146 | | /// Set input dimensions |
147 | | #[must_use] |
148 | 3 | pub fn with_input_dim(mut self, dim: usize) -> Self { |
149 | 3 | self.input_dim = Some(dim); |
150 | 3 | self |
151 | 3 | } |
152 | | |
153 | | /// Set output dimensions |
154 | | #[must_use] |
155 | 2 | pub fn with_output_dim(mut self, dim: usize) -> Self { |
156 | 2 | self.output_dim = Some(dim); |
157 | 2 | self |
158 | 2 | } |
159 | | |
160 | | /// Set file size |
161 | | #[must_use] |
162 | 8 | pub fn with_file_size(mut self, size: u64) -> Self { |
163 | 8 | self.file_size = size; |
164 | 8 | self |
165 | 8 | } |
166 | | } |
167 | | |
168 | | /// Detect model format from file path and contents |
169 | | /// |
170 | | /// Per spec §3.2: Jidoka - verify both path and magic bytes match. |
171 | | /// |
172 | | /// # Arguments |
173 | | /// |
174 | | /// * `path` - Path to model file |
175 | | /// |
176 | | /// # Returns |
177 | | /// |
178 | | /// Model metadata with detected format |
179 | | /// |
180 | | /// # Errors |
181 | | /// |
182 | | /// Returns error if: |
183 | | /// - File cannot be read |
184 | | /// - Format cannot be detected |
185 | | /// - Extension doesn't match magic bytes |
186 | | /// |
187 | | /// # Example |
188 | | /// |
189 | | /// ```rust,ignore |
190 | | /// use realizar::model_loader::detect_model; |
191 | | /// use std::path::Path; |
192 | | /// |
193 | | /// let metadata = detect_model(Path::new("model.apr"))?; |
194 | | /// assert_eq!(metadata.format, ModelFormat::Apr); |
195 | | /// ``` |
196 | 0 | pub fn detect_model(path: &Path) -> Result<ModelMetadata, LoadError> { |
197 | | // Read first 8 bytes for magic detection |
198 | 0 | let data = std::fs::read(path)?; |
199 | 0 | if data.len() < 8 { |
200 | 0 | return Err(LoadError::ParseError(format!( |
201 | 0 | "File too small: {} bytes", |
202 | 0 | data.len() |
203 | 0 | ))); |
204 | 0 | } |
205 | | |
206 | | // Verify format from path and data |
207 | 0 | let format = detect_and_verify_format(path, &data[..8])?; |
208 | | |
209 | 0 | Ok(ModelMetadata::new(format).with_file_size(data.len() as u64)) |
210 | 0 | } |
211 | | |
212 | | /// Detect model format from bytes only (no path verification) |
213 | | /// |
214 | | /// Useful for embedded models via `include_bytes!()`. |
215 | | /// |
216 | | /// # Arguments |
217 | | /// |
218 | | /// * `data` - Model file bytes |
219 | | /// |
220 | | /// # Returns |
221 | | /// |
222 | | /// Model metadata with detected format |
223 | | /// |
224 | | /// # Errors |
225 | | /// |
226 | | /// Returns error if: |
227 | | /// - Data is too small for format detection (<8 bytes) |
228 | | /// - Format cannot be detected from magic bytes |
229 | | /// |
230 | | /// # Example |
231 | | /// |
232 | | /// ```rust,ignore |
233 | | /// use realizar::model_loader::detect_model_from_bytes; |
234 | | /// |
235 | | /// const MODEL: &[u8] = include_bytes!("../models/model.apr"); |
236 | | /// let metadata = detect_model_from_bytes(MODEL)?; |
237 | | /// ``` |
238 | 6 | pub fn detect_model_from_bytes(data: &[u8]) -> Result<ModelMetadata, LoadError> { |
239 | 6 | if data.len() < 8 { |
240 | 1 | return Err(LoadError::ParseError(format!( |
241 | 1 | "Data too small: {} bytes", |
242 | 1 | data.len() |
243 | 1 | ))); |
244 | 5 | } |
245 | | |
246 | 5 | let format = detect_format(&data[..8])?0 ; |
247 | | |
248 | 5 | Ok(ModelMetadata::new(format).with_file_size(data.len() as u64)) |
249 | 6 | } |
250 | | |
251 | | /// Load APR model type from metadata bytes |
252 | | /// |
253 | | /// Reads model type from APR header (bytes 4-6 after magic). |
254 | | /// |
255 | | /// # Arguments |
256 | | /// |
257 | | /// * `data` - APR file bytes (at least 8 bytes) |
258 | | /// |
259 | | /// # Returns |
260 | | /// |
261 | | /// APR model type string (e.g., "LogisticRegression") |
262 | 50 | pub fn read_apr_model_type(data: &[u8]) -> Option<String> { |
263 | 50 | if data.len() < 8 { |
264 | 2 | return None; |
265 | 48 | } |
266 | | |
267 | | // APR header layout: APRN (4 bytes) + type_id (2 bytes) + version (2 bytes) |
268 | | // Per aprender format spec |
269 | 48 | let type_id = u16::from_le_bytes([data[4], data[5]]); |
270 | | |
271 | | // Map type ID to name (from aprender::format::ModelType) |
272 | 48 | let type_name44 = match type_id { |
273 | 4 | 0x0001 => "LinearRegression", |
274 | 5 | 0x0002 => "LogisticRegression", |
275 | 3 | 0x0003 => "DecisionTree", |
276 | 3 | 0x0004 => "RandomForest", |
277 | 2 | 0x0005 => "GradientBoosting", |
278 | 2 | 0x0006 => "KMeans", |
279 | 2 | 0x0007 => "PCA", |
280 | 2 | 0x0008 => "NaiveBayes", |
281 | 2 | 0x0009 => "KNN", |
282 | 2 | 0x000A => "SVM", |
283 | 2 | 0x0010 => "NgramLM", |
284 | 2 | 0x0011 => "TFIDF", |
285 | 2 | 0x0012 => "CountVectorizer", |
286 | 2 | 0x0020 => "NeuralSequential", |
287 | 2 | 0x0021 => "NeuralCustom", |
288 | 2 | 0x0030 => "ContentRecommender", |
289 | 2 | 0x0040 => "MixtureOfExperts", |
290 | 3 | 0x00FF => "Custom", |
291 | 4 | _ => return None, |
292 | | }; |
293 | | |
294 | 44 | Some(type_name.to_string()) |
295 | 50 | } |
296 | | |
297 | | /// Validate that loaded model matches expected type |
298 | | /// |
299 | | /// Per Jidoka: fail fast if type mismatch. |
300 | | /// |
301 | | /// # Arguments |
302 | | /// |
303 | | /// * `expected` - Expected model type |
304 | | /// * `actual` - Actual model type from file |
305 | | /// |
306 | | /// # Returns |
307 | | /// |
308 | | /// Ok if types match, Err otherwise |
309 | | /// |
310 | | /// # Errors |
311 | | /// |
312 | | /// Returns `LoadError::TypeMismatch` if expected and actual types differ. |
313 | 3 | pub fn validate_model_type(expected: &str, actual: &str) -> Result<(), LoadError> { |
314 | 3 | if expected != actual { |
315 | 2 | return Err(LoadError::TypeMismatch { |
316 | 2 | expected: expected.to_string(), |
317 | 2 | actual: actual.to_string(), |
318 | 2 | }); |
319 | 1 | } |
320 | 1 | Ok(()) |
321 | 3 | } |
322 | | |
323 | | #[cfg(test)] |
324 | | mod tests { |
325 | | use super::*; |
326 | | |
327 | | // ===== EXTREME TDD: LoadError Tests ===== |
328 | | |
329 | | #[test] |
330 | 1 | fn test_load_error_format_error() { |
331 | 1 | let err = LoadError::FormatError(FormatError::UnknownFormat); |
332 | 1 | assert!(err.to_string().contains("Format detection error")); |
333 | 1 | assert!(err.to_string().contains("Unknown")); |
334 | 1 | } |
335 | | |
336 | | #[test] |
337 | 1 | fn test_load_error_io_error() { |
338 | 1 | let err = LoadError::IoError("file not found".to_string()); |
339 | 1 | assert!(err.to_string().contains("I/O error")); |
340 | 1 | assert!(err.to_string().contains("file not found")); |
341 | 1 | } |
342 | | |
343 | | #[test] |
344 | 1 | fn test_load_error_parse_error() { |
345 | 1 | let err = LoadError::ParseError("invalid header".to_string()); |
346 | 1 | assert!(err.to_string().contains("Parse error")); |
347 | 1 | assert!(err.to_string().contains("invalid header")); |
348 | 1 | } |
349 | | |
350 | | #[test] |
351 | 1 | fn test_load_error_unsupported_type() { |
352 | 1 | let err = LoadError::UnsupportedType("UnknownModel".to_string()); |
353 | 1 | assert!(err.to_string().contains("Unsupported model type")); |
354 | 1 | assert!(err.to_string().contains("UnknownModel")); |
355 | 1 | } |
356 | | |
357 | | #[test] |
358 | 1 | fn test_load_error_integrity_error() { |
359 | 1 | let err = LoadError::IntegrityError("CRC32 mismatch".to_string()); |
360 | 1 | assert!(err.to_string().contains("Integrity check failed")); |
361 | 1 | assert!(err.to_string().contains("CRC32")); |
362 | 1 | } |
363 | | |
364 | | #[test] |
365 | 1 | fn test_load_error_type_mismatch() { |
366 | 1 | let err = LoadError::TypeMismatch { |
367 | 1 | expected: "LogisticRegression".to_string(), |
368 | 1 | actual: "DecisionTree".to_string(), |
369 | 1 | }; |
370 | 1 | assert!(err.to_string().contains("type mismatch")); |
371 | 1 | assert!(err.to_string().contains("LogisticRegression")); |
372 | 1 | assert!(err.to_string().contains("DecisionTree")); |
373 | 1 | } |
374 | | |
375 | | #[test] |
376 | 1 | fn test_load_error_from_format_error() { |
377 | 1 | let format_err = FormatError::TooShort { len: 3 }; |
378 | 1 | let load_err: LoadError = format_err.into(); |
379 | 1 | assert!(matches!0 (load_err, LoadError::FormatError(_))); |
380 | 1 | } |
381 | | |
382 | | // ===== EXTREME TDD: ModelMetadata Tests ===== |
383 | | |
384 | | #[test] |
385 | 1 | fn test_model_metadata_new() { |
386 | 1 | let meta = ModelMetadata::new(ModelFormat::Apr); |
387 | 1 | assert_eq!(meta.format, ModelFormat::Apr); |
388 | 1 | assert!(meta.model_type.is_none()); |
389 | 1 | assert!(meta.version.is_none()); |
390 | 1 | assert!(meta.input_dim.is_none()); |
391 | 1 | assert!(meta.output_dim.is_none()); |
392 | 1 | assert_eq!(meta.file_size, 0); |
393 | 1 | } |
394 | | |
395 | | #[test] |
396 | 1 | fn test_model_metadata_with_model_type() { |
397 | 1 | let meta = ModelMetadata::new(ModelFormat::Apr).with_model_type("LogisticRegression"); |
398 | 1 | assert_eq!(meta.model_type, Some("LogisticRegression".to_string())); |
399 | 1 | } |
400 | | |
401 | | #[test] |
402 | 1 | fn test_model_metadata_with_version() { |
403 | 1 | let meta = ModelMetadata::new(ModelFormat::Gguf).with_version("v1.0.0"); |
404 | 1 | assert_eq!(meta.version, Some("v1.0.0".to_string())); |
405 | 1 | } |
406 | | |
407 | | #[test] |
408 | 1 | fn test_model_metadata_with_input_dim() { |
409 | 1 | let meta = ModelMetadata::new(ModelFormat::SafeTensors).with_input_dim(784); |
410 | 1 | assert_eq!(meta.input_dim, Some(784)); |
411 | 1 | } |
412 | | |
413 | | #[test] |
414 | 1 | fn test_model_metadata_with_output_dim() { |
415 | 1 | let meta = ModelMetadata::new(ModelFormat::Apr).with_output_dim(10); |
416 | 1 | assert_eq!(meta.output_dim, Some(10)); |
417 | 1 | } |
418 | | |
419 | | #[test] |
420 | 1 | fn test_model_metadata_with_file_size() { |
421 | 1 | let meta = ModelMetadata::new(ModelFormat::Gguf).with_file_size(1_000_000); |
422 | 1 | assert_eq!(meta.file_size, 1_000_000); |
423 | 1 | } |
424 | | |
425 | | #[test] |
426 | 1 | fn test_model_metadata_chained_builders() { |
427 | 1 | let meta = ModelMetadata::new(ModelFormat::Apr) |
428 | 1 | .with_model_type("RandomForest") |
429 | 1 | .with_version("v2.1") |
430 | 1 | .with_input_dim(128) |
431 | 1 | .with_output_dim(3) |
432 | 1 | .with_file_size(50_000); |
433 | | |
434 | 1 | assert_eq!(meta.format, ModelFormat::Apr); |
435 | 1 | assert_eq!(meta.model_type, Some("RandomForest".to_string())); |
436 | 1 | assert_eq!(meta.version, Some("v2.1".to_string())); |
437 | 1 | assert_eq!(meta.input_dim, Some(128)); |
438 | 1 | assert_eq!(meta.output_dim, Some(3)); |
439 | 1 | assert_eq!(meta.file_size, 50_000); |
440 | 1 | } |
441 | | |
442 | | // ===== EXTREME TDD: detect_model_from_bytes Tests ===== |
443 | | |
444 | | #[test] |
445 | 1 | fn test_detect_model_from_bytes_apr() { |
446 | 1 | let mut data = b"APR\0".to_vec(); |
447 | 1 | data.extend_from_slice(&[0x02, 0x00, 0x01, 0x00]); // LogisticRegression type |
448 | 1 | data.extend_from_slice(&[0u8; 100]); // Padding |
449 | | |
450 | 1 | let meta = detect_model_from_bytes(&data).expect("Should detect APR"); |
451 | 1 | assert_eq!(meta.format, ModelFormat::Apr); |
452 | 1 | assert_eq!(meta.file_size, 108); |
453 | 1 | } |
454 | | |
455 | | #[test] |
456 | 1 | fn test_detect_model_from_bytes_gguf() { |
457 | 1 | let mut data = b"GGUF".to_vec(); |
458 | 1 | data.extend_from_slice(&[0u8; 100]); // Padding |
459 | | |
460 | 1 | let meta = detect_model_from_bytes(&data).expect("Should detect GGUF"); |
461 | 1 | assert_eq!(meta.format, ModelFormat::Gguf); |
462 | 1 | } |
463 | | |
464 | | #[test] |
465 | 1 | fn test_detect_model_from_bytes_safetensors() { |
466 | 1 | let header_size: u64 = 100; |
467 | 1 | let mut data = header_size.to_le_bytes().to_vec(); |
468 | 1 | data.extend_from_slice(&[0u8; 200]); |
469 | | |
470 | 1 | let meta = detect_model_from_bytes(&data).expect("Should detect SafeTensors"); |
471 | 1 | assert_eq!(meta.format, ModelFormat::SafeTensors); |
472 | 1 | } |
473 | | |
474 | | #[test] |
475 | 1 | fn test_detect_model_from_bytes_too_small() { |
476 | 1 | let data = b"APR"; |
477 | 1 | let result = detect_model_from_bytes(data); |
478 | 1 | assert!(result.is_err()); |
479 | 1 | assert!(matches!0 (result.unwrap_err(), LoadError::ParseError(_))); |
480 | 1 | } |
481 | | |
482 | | // ===== EXTREME TDD: read_apr_model_type Tests ===== |
483 | | |
484 | | #[test] |
485 | 1 | fn test_read_apr_model_type_linear_regression() { |
486 | 1 | let mut data = b"APR\0".to_vec(); |
487 | 1 | data.extend_from_slice(&0x0001u16.to_le_bytes()); |
488 | 1 | data.extend_from_slice(&[0, 0]); |
489 | | |
490 | 1 | assert_eq!( |
491 | 1 | read_apr_model_type(&data), |
492 | 1 | Some("LinearRegression".to_string()) |
493 | | ); |
494 | 1 | } |
495 | | |
496 | | #[test] |
497 | 1 | fn test_read_apr_model_type_logistic_regression() { |
498 | 1 | let mut data = b"APR\0".to_vec(); |
499 | 1 | data.extend_from_slice(&0x0002u16.to_le_bytes()); |
500 | 1 | data.extend_from_slice(&[0, 0]); |
501 | | |
502 | 1 | assert_eq!( |
503 | 1 | read_apr_model_type(&data), |
504 | 1 | Some("LogisticRegression".to_string()) |
505 | | ); |
506 | 1 | } |
507 | | |
508 | | #[test] |
509 | 1 | fn test_read_apr_model_type_decision_tree() { |
510 | 1 | let mut data = b"APR\0".to_vec(); |
511 | 1 | data.extend_from_slice(&0x0003u16.to_le_bytes()); |
512 | 1 | data.extend_from_slice(&[0, 0]); |
513 | | |
514 | 1 | assert_eq!(read_apr_model_type(&data), Some("DecisionTree".to_string())); |
515 | 1 | } |
516 | | |
517 | | #[test] |
518 | 1 | fn test_read_apr_model_type_random_forest() { |
519 | 1 | let mut data = b"APR\0".to_vec(); |
520 | 1 | data.extend_from_slice(&0x0004u16.to_le_bytes()); |
521 | 1 | data.extend_from_slice(&[0, 0]); |
522 | | |
523 | 1 | assert_eq!(read_apr_model_type(&data), Some("RandomForest".to_string())); |
524 | 1 | } |
525 | | |
526 | | #[test] |
527 | 1 | fn test_read_apr_model_type_gradient_boosting() { |
528 | 1 | let mut data = b"APR\0".to_vec(); |
529 | 1 | data.extend_from_slice(&0x0005u16.to_le_bytes()); |
530 | 1 | data.extend_from_slice(&[0, 0]); |
531 | | |
532 | 1 | assert_eq!( |
533 | 1 | read_apr_model_type(&data), |
534 | 1 | Some("GradientBoosting".to_string()) |
535 | | ); |
536 | 1 | } |
537 | | |
538 | | #[test] |
539 | 1 | fn test_read_apr_model_type_kmeans() { |
540 | 1 | let mut data = b"APR\0".to_vec(); |
541 | 1 | data.extend_from_slice(&0x0006u16.to_le_bytes()); |
542 | 1 | data.extend_from_slice(&[0, 0]); |
543 | | |
544 | 1 | assert_eq!(read_apr_model_type(&data), Some("KMeans".to_string())); |
545 | 1 | } |
546 | | |
547 | | #[test] |
548 | 1 | fn test_read_apr_model_type_pca() { |
549 | 1 | let mut data = b"APR\0".to_vec(); |
550 | 1 | data.extend_from_slice(&0x0007u16.to_le_bytes()); |
551 | 1 | data.extend_from_slice(&[0, 0]); |
552 | | |
553 | 1 | assert_eq!(read_apr_model_type(&data), Some("PCA".to_string())); |
554 | 1 | } |
555 | | |
556 | | #[test] |
557 | 1 | fn test_read_apr_model_type_naive_bayes() { |
558 | 1 | let mut data = b"APR\0".to_vec(); |
559 | 1 | data.extend_from_slice(&0x0008u16.to_le_bytes()); |
560 | 1 | data.extend_from_slice(&[0, 0]); |
561 | | |
562 | 1 | assert_eq!(read_apr_model_type(&data), Some("NaiveBayes".to_string())); |
563 | 1 | } |
564 | | |
565 | | #[test] |
566 | 1 | fn test_read_apr_model_type_knn() { |
567 | 1 | let mut data = b"APR\0".to_vec(); |
568 | 1 | data.extend_from_slice(&0x0009u16.to_le_bytes()); |
569 | 1 | data.extend_from_slice(&[0, 0]); |
570 | | |
571 | 1 | assert_eq!(read_apr_model_type(&data), Some("KNN".to_string())); |
572 | 1 | } |
573 | | |
574 | | #[test] |
575 | 1 | fn test_read_apr_model_type_svm() { |
576 | 1 | let mut data = b"APR\0".to_vec(); |
577 | 1 | data.extend_from_slice(&0x000Au16.to_le_bytes()); |
578 | 1 | data.extend_from_slice(&[0, 0]); |
579 | | |
580 | 1 | assert_eq!(read_apr_model_type(&data), Some("SVM".to_string())); |
581 | 1 | } |
582 | | |
583 | | #[test] |
584 | 1 | fn test_read_apr_model_type_ngram_lm() { |
585 | 1 | let mut data = b"APR\0".to_vec(); |
586 | 1 | data.extend_from_slice(&0x0010u16.to_le_bytes()); |
587 | 1 | data.extend_from_slice(&[0, 0]); |
588 | | |
589 | 1 | assert_eq!(read_apr_model_type(&data), Some("NgramLM".to_string())); |
590 | 1 | } |
591 | | |
592 | | #[test] |
593 | 1 | fn test_read_apr_model_type_tfidf() { |
594 | 1 | let mut data = b"APR\0".to_vec(); |
595 | 1 | data.extend_from_slice(&0x0011u16.to_le_bytes()); |
596 | 1 | data.extend_from_slice(&[0, 0]); |
597 | | |
598 | 1 | assert_eq!(read_apr_model_type(&data), Some("TFIDF".to_string())); |
599 | 1 | } |
600 | | |
601 | | #[test] |
602 | 1 | fn test_read_apr_model_type_count_vectorizer() { |
603 | 1 | let mut data = b"APR\0".to_vec(); |
604 | 1 | data.extend_from_slice(&0x0012u16.to_le_bytes()); |
605 | 1 | data.extend_from_slice(&[0, 0]); |
606 | | |
607 | 1 | assert_eq!( |
608 | 1 | read_apr_model_type(&data), |
609 | 1 | Some("CountVectorizer".to_string()) |
610 | | ); |
611 | 1 | } |
612 | | |
613 | | #[test] |
614 | 1 | fn test_read_apr_model_type_neural_sequential() { |
615 | 1 | let mut data = b"APR\0".to_vec(); |
616 | 1 | data.extend_from_slice(&0x0020u16.to_le_bytes()); |
617 | 1 | data.extend_from_slice(&[0, 0]); |
618 | | |
619 | 1 | assert_eq!( |
620 | 1 | read_apr_model_type(&data), |
621 | 1 | Some("NeuralSequential".to_string()) |
622 | | ); |
623 | 1 | } |
624 | | |
625 | | #[test] |
626 | 1 | fn test_read_apr_model_type_neural_custom() { |
627 | 1 | let mut data = b"APR\0".to_vec(); |
628 | 1 | data.extend_from_slice(&0x0021u16.to_le_bytes()); |
629 | 1 | data.extend_from_slice(&[0, 0]); |
630 | | |
631 | 1 | assert_eq!(read_apr_model_type(&data), Some("NeuralCustom".to_string())); |
632 | 1 | } |
633 | | |
634 | | #[test] |
635 | 1 | fn test_read_apr_model_type_content_recommender() { |
636 | 1 | let mut data = b"APR\0".to_vec(); |
637 | 1 | data.extend_from_slice(&0x0030u16.to_le_bytes()); |
638 | 1 | data.extend_from_slice(&[0, 0]); |
639 | | |
640 | 1 | assert_eq!( |
641 | 1 | read_apr_model_type(&data), |
642 | 1 | Some("ContentRecommender".to_string()) |
643 | | ); |
644 | 1 | } |
645 | | |
646 | | #[test] |
647 | 1 | fn test_read_apr_model_type_mixture_of_experts() { |
648 | 1 | let mut data = b"APR\0".to_vec(); |
649 | 1 | data.extend_from_slice(&0x0040u16.to_le_bytes()); |
650 | 1 | data.extend_from_slice(&[0, 0]); |
651 | | |
652 | 1 | assert_eq!( |
653 | 1 | read_apr_model_type(&data), |
654 | 1 | Some("MixtureOfExperts".to_string()) |
655 | | ); |
656 | 1 | } |
657 | | |
658 | | #[test] |
659 | 1 | fn test_read_apr_model_type_custom() { |
660 | 1 | let mut data = b"APR\0".to_vec(); |
661 | 1 | data.extend_from_slice(&0x00FFu16.to_le_bytes()); |
662 | 1 | data.extend_from_slice(&[0, 0]); |
663 | | |
664 | 1 | assert_eq!(read_apr_model_type(&data), Some("Custom".to_string())); |
665 | 1 | } |
666 | | |
667 | | #[test] |
668 | 1 | fn test_read_apr_model_type_unknown() { |
669 | 1 | let mut data = b"APR\0".to_vec(); |
670 | 1 | data.extend_from_slice(&0xFFFFu16.to_le_bytes()); // Unknown type |
671 | 1 | data.extend_from_slice(&[0, 0]); |
672 | | |
673 | 1 | assert_eq!(read_apr_model_type(&data), None); |
674 | 1 | } |
675 | | |
676 | | #[test] |
677 | 1 | fn test_read_apr_model_type_too_short() { |
678 | 1 | let data = b"APR\0"; // Only 4 bytes |
679 | 1 | assert_eq!(read_apr_model_type(data), None); |
680 | 1 | } |
681 | | |
682 | | // ===== EXTREME TDD: validate_model_type Tests ===== |
683 | | |
684 | | #[test] |
685 | 1 | fn test_validate_model_type_match() { |
686 | 1 | let result = validate_model_type("LogisticRegression", "LogisticRegression"); |
687 | 1 | assert!(result.is_ok()); |
688 | 1 | } |
689 | | |
690 | | #[test] |
691 | 1 | fn test_validate_model_type_mismatch() { |
692 | 1 | let result = validate_model_type("LogisticRegression", "DecisionTree"); |
693 | 1 | assert!(result.is_err()); |
694 | | |
695 | 1 | if let Err(LoadError::TypeMismatch { expected, actual }) = result { |
696 | 1 | assert_eq!(expected, "LogisticRegression"); |
697 | 1 | assert_eq!(actual, "DecisionTree"); |
698 | | } else { |
699 | 0 | panic!("Expected TypeMismatch error"); |
700 | | } |
701 | 1 | } |
702 | | |
703 | | #[test] |
704 | 1 | fn test_validate_model_type_case_sensitive() { |
705 | | // Type names are case-sensitive |
706 | 1 | let result = validate_model_type("logisticregression", "LogisticRegression"); |
707 | 1 | assert!(result.is_err()); |
708 | 1 | } |
709 | | |
710 | | // ===== EXTREME TDD: Integration Tests ===== |
711 | | |
712 | | #[test] |
713 | 1 | fn test_detect_and_extract_apr_type() { |
714 | | // Simulate APR file with LogisticRegression type |
715 | 1 | let mut data = b"APR\0".to_vec(); |
716 | 1 | data.extend_from_slice(&0x0002u16.to_le_bytes()); // LogisticRegression |
717 | 1 | data.extend_from_slice(&[0, 0]); // version placeholder |
718 | 1 | data.extend_from_slice(&[0u8; 100]); // Padding |
719 | | |
720 | 1 | let meta = detect_model_from_bytes(&data).expect("Detection should succeed"); |
721 | 1 | assert_eq!(meta.format, ModelFormat::Apr); |
722 | | |
723 | 1 | let model_type = read_apr_model_type(&data).expect("Should extract model type"); |
724 | 1 | assert_eq!(model_type, "LogisticRegression"); |
725 | 1 | } |
726 | | |
727 | | #[test] |
728 | 1 | fn test_full_metadata_extraction() { |
729 | 1 | let mut data = b"APR\0".to_vec(); |
730 | 1 | data.extend_from_slice(&0x0004u16.to_le_bytes()); // RandomForest |
731 | 1 | data.extend_from_slice(&[0, 0]); |
732 | 1 | data.extend_from_slice(&[0u8; 500]); |
733 | | |
734 | 1 | let meta = detect_model_from_bytes(&data) |
735 | 1 | .expect("Detection should succeed") |
736 | 1 | .with_model_type(read_apr_model_type(&data).unwrap_or_default()) |
737 | 1 | .with_version("v1.0") |
738 | 1 | .with_input_dim(128); |
739 | | |
740 | 1 | assert_eq!(meta.format, ModelFormat::Apr); |
741 | 1 | assert_eq!(meta.model_type, Some("RandomForest".to_string())); |
742 | 1 | assert_eq!(meta.version, Some("v1.0".to_string())); |
743 | 1 | assert_eq!(meta.input_dim, Some(128)); |
744 | 1 | assert_eq!(meta.file_size, 508); |
745 | 1 | } |
746 | | |
747 | | // ===== EXTREME TDD: Debug/Error Trait Tests ===== |
748 | | |
749 | | #[test] |
750 | 1 | fn test_load_error_debug() { |
751 | 1 | let err = LoadError::IoError("test".to_string()); |
752 | 1 | let debug_str = format!("{err:?}"); |
753 | 1 | assert!(debug_str.contains("IoError")); |
754 | 1 | } |
755 | | |
756 | | #[test] |
757 | 1 | fn test_model_metadata_debug() { |
758 | 1 | let meta = ModelMetadata::new(ModelFormat::Apr); |
759 | 1 | let debug_str = format!("{meta:?}"); |
760 | 1 | assert!(debug_str.contains("Apr")); |
761 | 1 | } |
762 | | |
763 | | #[test] |
764 | 1 | fn test_model_metadata_clone() { |
765 | 1 | let meta = ModelMetadata::new(ModelFormat::Gguf) |
766 | 1 | .with_model_type("LLM") |
767 | 1 | .with_file_size(1000); |
768 | 1 | let cloned = meta.clone(); |
769 | | |
770 | 1 | assert_eq!(cloned.format, ModelFormat::Gguf); |
771 | 1 | assert_eq!(cloned.model_type, Some("LLM".to_string())); |
772 | 1 | assert_eq!(cloned.file_size, 1000); |
773 | 1 | } |
774 | | |
775 | | #[test] |
776 | 1 | fn test_load_error_clone() { |
777 | 1 | let err = LoadError::ParseError("test".to_string()); |
778 | 1 | let cloned = err.clone(); |
779 | 1 | assert!(matches!0 (cloned, LoadError::ParseError(_))); |
780 | 1 | } |
781 | | } |