Skip to main content

jugar_apr/
lib.rs

1//! Aprender Package Resource (.apr) model format.
2//!
3//! Per spec Section 4.1: The .apr format is a compact, portable container
4//! for trained AI behaviors that can be hot-swapped like trading cards.
5//!
6//! # File Structure
7//!
8//! ```text
9//! ┌─────────────────────────────────────────────────────────────┐
10//! │                    .APR File Structure                       │
11//! ├─────────────────────────────────────────────────────────────┤
12//! │  Magic Number: "APNR" (4 bytes)                             │
13//! │  Version: u16 (2 bytes)                                      │
14//! │  Checksum: CRC32 (4 bytes)                                   │
15//! ├─────────────────────────────────────────────────────────────┤
16//! │  Metadata (CBOR encoded):                                    │
17//! │    - name, version, author, license                          │
18//! │    - difficulty_levels, input_schema, output_schema          │
19//! ├─────────────────────────────────────────────────────────────┤
20//! │  Model Data (compressed):                                    │
21//! │    - weights: [f32; N]                                       │
22//! │    - biases: [f32; M]                                        │
23//! │    - architecture: string                                    │
24//! └─────────────────────────────────────────────────────────────┘
25//! ```
26
27#![warn(missing_docs)]
28#![warn(clippy::all)]
29#![warn(clippy::pedantic)]
30#![warn(clippy::nursery)]
31#![allow(clippy::module_name_repetitions)]
32
33mod error;
34mod format;
35mod metadata;
36mod model;
37
38pub use error::AprError;
39pub use format::{AprFile, APR_MAGIC, APR_VERSION};
40pub use metadata::AprMetadata;
41pub use model::{AprModel, ModelArchitecture, ModelData};
42
43/// Maximum allowed model size (1 MB per spec Section 9.1)
44pub const MAX_MODEL_SIZE: usize = 1024 * 1024;
45
46/// Minimum model version supported
47pub const MIN_SUPPORTED_VERSION: u16 = 1;
48
49/// Current model version
50pub const CURRENT_VERSION: u16 = 1;
51
52#[cfg(test)]
53#[allow(clippy::unwrap_used, clippy::expect_used)]
54mod tests {
55    use super::*;
56
57    // ========================================================================
58    // EXTREME TDD: Tests written FIRST per spec requirements
59    // ========================================================================
60
61    mod magic_number_tests {
62        use super::*;
63
64        #[test]
65        fn test_apr_magic_is_apnr() {
66            // Per spec Section 4.1: Magic Number: "APNR" (4 bytes)
67            assert_eq!(APR_MAGIC, b"APNR");
68            assert_eq!(APR_MAGIC.len(), 4);
69        }
70
71        #[test]
72        fn test_apr_magic_detection() {
73            let valid = b"APNRxxxxxx";
74            let invalid = b"WRONGxxxxx";
75
76            assert!(AprFile::has_magic(valid));
77            assert!(!AprFile::has_magic(invalid));
78        }
79
80        #[test]
81        fn test_rejects_wrong_magic() {
82            let bad_magic = b"BAAD\x00\x01\x00\x00\x00\x00";
83            let result = AprFile::from_bytes(bad_magic);
84            assert!(matches!(result, Err(AprError::InvalidMagic { .. })));
85        }
86    }
87
88    mod version_tests {
89        use super::*;
90
91        #[test]
92        fn test_apr_version_is_u16() {
93            // Per spec Section 4.1: Version: u16 (2 bytes)
94            assert_eq!(APR_VERSION, 1_u16);
95        }
96
97        #[test]
98        fn test_current_version_supported() {
99            // Use runtime check to validate version constants
100            let current = CURRENT_VERSION;
101            let min = MIN_SUPPORTED_VERSION;
102            assert!(current >= min);
103        }
104
105        #[test]
106        fn test_rejects_unsupported_version() {
107            // Version 0 should be rejected
108            let mut bytes = Vec::new();
109            bytes.extend_from_slice(APR_MAGIC);
110            bytes.extend_from_slice(&0_u16.to_le_bytes()); // Version 0
111            bytes.extend_from_slice(&0_u32.to_le_bytes()); // Checksum placeholder
112
113            let result = AprFile::from_bytes(&bytes);
114            assert!(matches!(result, Err(AprError::UnsupportedVersion { .. })));
115        }
116    }
117
118    mod checksum_tests {
119        use super::*;
120
121        #[test]
122        fn test_checksum_is_crc32() {
123            // Per spec Section 4.1: Checksum: CRC32 (4 bytes)
124            let data = b"test data";
125            let checksum = crc32fast::hash(data);
126            assert_eq!(core::mem::size_of_val(&checksum), 4);
127        }
128
129        #[test]
130        fn test_checksum_verification() {
131            // Create a minimal valid APR file
132            let model = AprModel::new_test_model();
133            let bytes = model.to_bytes().expect("Should serialize");
134
135            // Verify it can be loaded back
136            let loaded = AprFile::from_bytes(&bytes).expect("Should load");
137            assert_eq!(loaded.model.metadata.name, model.metadata.name);
138        }
139
140        #[test]
141        fn test_rejects_corrupted_checksum() {
142            let model = AprModel::new_test_model();
143            let mut bytes = model.to_bytes().expect("Should serialize");
144
145            // Corrupt the checksum (bytes 6-9)
146            if bytes.len() > 9 {
147                bytes[6] ^= 0xFF;
148            }
149
150            let result = AprFile::from_bytes(&bytes);
151            assert!(matches!(result, Err(AprError::ChecksumMismatch { .. })));
152        }
153    }
154
155    mod metadata_tests {
156        use super::*;
157
158        #[test]
159        fn test_metadata_has_required_fields() {
160            let metadata = AprMetadata::builder()
161                .name("test-model")
162                .version("1.0.0")
163                .author("Test Author")
164                .license("MIT")
165                .build()
166                .expect("Should build metadata");
167
168            assert_eq!(metadata.name, "test-model");
169            assert_eq!(metadata.version.to_string(), "1.0.0");
170            assert_eq!(metadata.author, "Test Author");
171            assert_eq!(metadata.license, "MIT");
172        }
173
174        #[test]
175        fn test_metadata_optional_difficulty_levels() {
176            let metadata = AprMetadata::builder()
177                .name("pong-ai")
178                .version("1.0.0")
179                .author("PAIML")
180                .license("MIT")
181                .difficulty_levels(10)
182                .build()
183                .expect("Should build");
184
185            assert_eq!(metadata.difficulty_levels, Some(10));
186        }
187
188        #[test]
189        fn test_metadata_validates_name_length() {
190            // Per spec Section 3.1: 3-20 chars for game names
191            let result = AprMetadata::builder()
192                .name("ab") // Too short
193                .version("1.0.0")
194                .author("Test")
195                .license("MIT")
196                .build();
197
198            assert!(result.is_err());
199        }
200
201        #[test]
202        fn test_metadata_cbor_roundtrip() {
203            let original = AprMetadata::builder()
204                .name("test-model")
205                .version("1.0.0")
206                .author("Test")
207                .license("MIT")
208                .description("A test model")
209                .build()
210                .expect("Should build");
211
212            let encoded = original.to_cbor().expect("Should encode");
213            let decoded = AprMetadata::from_cbor(&encoded).expect("Should decode");
214
215            assert_eq!(original.name, decoded.name);
216            assert_eq!(original.description, decoded.description);
217        }
218    }
219
220    mod model_data_tests {
221        use super::*;
222
223        #[test]
224        fn test_model_data_weights_and_biases() {
225            let data = ModelData {
226                weights: vec![0.5, -0.3, 0.8, 0.1],
227                biases: vec![0.0, 0.1],
228                architecture: ModelArchitecture::Mlp {
229                    layers: vec![2, 4, 1],
230                },
231            };
232
233            assert_eq!(data.weights.len(), 4);
234            assert_eq!(data.biases.len(), 2);
235        }
236
237        #[test]
238        fn test_model_architecture_mlp() {
239            // Per spec: "mlp-2-16-1"
240            let arch = ModelArchitecture::Mlp {
241                layers: vec![2, 16, 1],
242            };
243
244            assert_eq!(arch.to_string(), "mlp-2-16-1");
245        }
246
247        #[test]
248        fn test_model_data_compression() {
249            let data = ModelData {
250                weights: vec![0.5; 1000], // Large weight array
251                biases: vec![0.0; 100],
252                architecture: ModelArchitecture::Mlp {
253                    layers: vec![10, 100, 10],
254                },
255            };
256
257            let compressed = data.compress().expect("Should compress");
258            let decompressed = ModelData::decompress(&compressed).expect("Should decompress");
259
260            assert_eq!(data.weights, decompressed.weights);
261            assert_eq!(data.biases, decompressed.biases);
262        }
263    }
264
265    mod size_limit_tests {
266        use super::*;
267
268        #[test]
269        fn test_max_model_size_is_1mb() {
270            // Per spec Section 9.1: max_model_size: 1 MB
271            assert_eq!(MAX_MODEL_SIZE, 1024 * 1024);
272        }
273
274        #[test]
275        #[allow(clippy::cast_precision_loss)]
276        fn test_rejects_oversized_model() {
277            // Create a model that would exceed 1MB even after compression
278            // Use varied values to prevent good compression
279            let huge_data = ModelData {
280                weights: (0..MAX_MODEL_SIZE)
281                    .map(|i| (i as f32) * 0.000_001)
282                    .collect(),
283                biases: vec![0.0],
284                architecture: ModelArchitecture::Mlp { layers: vec![1] },
285            };
286
287            let model = AprModel {
288                metadata: AprMetadata::builder()
289                    .name("too-big")
290                    .version("1.0.0")
291                    .author("Test")
292                    .license("MIT")
293                    .build()
294                    .expect("metadata"),
295                data: huge_data,
296            };
297
298            let result = model.to_bytes();
299            assert!(matches!(result, Err(AprError::ModelTooLarge { .. })));
300        }
301    }
302
303    mod roundtrip_tests {
304        use super::*;
305
306        #[test]
307        fn test_full_roundtrip() {
308            let original = AprModel {
309                metadata: AprMetadata::builder()
310                    .name("pong-champion")
311                    .version("2.0.0")
312                    .author("PAIML")
313                    .license("MIT")
314                    .description("A really good pong player!")
315                    .difficulty_levels(10)
316                    .build()
317                    .expect("metadata"),
318                data: ModelData {
319                    weights: vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
320                    biases: vec![0.01, 0.02],
321                    architecture: ModelArchitecture::Mlp {
322                        layers: vec![2, 4, 2],
323                    },
324                },
325            };
326
327            // Serialize
328            let bytes = original.to_bytes().expect("Should serialize");
329
330            // Verify header
331            assert_eq!(&bytes[0..4], APR_MAGIC);
332
333            // Deserialize
334            let file = AprFile::from_bytes(&bytes).expect("Should deserialize");
335
336            // Verify all fields
337            assert_eq!(file.model.metadata.name, "pong-champion");
338            assert_eq!(file.model.metadata.version.to_string(), "2.0.0");
339            assert_eq!(file.model.metadata.author, "PAIML");
340            assert_eq!(file.model.metadata.difficulty_levels, Some(10));
341            assert_eq!(file.model.data.weights.len(), 8);
342            assert_eq!(file.model.data.biases.len(), 2);
343        }
344
345        #[test]
346        fn test_deterministic_serialization() {
347            let model = AprModel::new_test_model();
348
349            let bytes1 = model.to_bytes().expect("Should serialize");
350            let bytes2 = model.to_bytes().expect("Should serialize again");
351
352            assert_eq!(bytes1, bytes2, "Serialization should be deterministic");
353        }
354    }
355
356    mod builtin_model_tests {
357        use super::*;
358
359        #[test]
360        fn test_builtin_chase() {
361            // Per spec: ai: builtin:chase
362            let model = AprModel::builtin("chase").expect("Should have builtin chase");
363            assert_eq!(model.metadata.name, "builtin-chase");
364        }
365
366        #[test]
367        fn test_builtin_patrol() {
368            // Per spec: ai: builtin:patrol
369            let model = AprModel::builtin("patrol").expect("Should have builtin patrol");
370            assert_eq!(model.metadata.name, "builtin-patrol");
371        }
372
373        #[test]
374        fn test_builtin_wander() {
375            // Per spec: ai: builtin:wander
376            let model = AprModel::builtin("wander").expect("Should have builtin wander");
377            assert_eq!(model.metadata.name, "builtin-wander");
378        }
379
380        #[test]
381        fn test_unknown_builtin_fails() {
382            let result = AprModel::builtin("nonexistent");
383            assert!(matches!(result, Err(AprError::UnknownBuiltin { .. })));
384        }
385    }
386
387    mod cosmin_quality_tests {
388        use super::*;
389
390        // Per spec Section 12.5: COSMIN-aligned model validation
391        #[test]
392        fn test_model_reliability_score() {
393            let model = AprModel::new_test_model();
394            let quality = model.assess_quality();
395
396            // ICC > 0.70 required per spec
397            assert!(
398                quality.test_retest_reliability >= 0.70,
399                "Reliability should be >= 0.70"
400            );
401        }
402
403        #[test]
404        fn test_model_meets_minimum_standards() {
405            let model = AprModel::new_test_model();
406            let quality = model.assess_quality();
407
408            assert!(
409                quality.meets_minimum_standards(),
410                "Test model should meet minimum standards"
411            );
412        }
413    }
414}