Coverage Report

Created: 2026-01-25 15:05

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/realizar/src/audit.rs
Line
Count
Source
1
//! Audit Trail and Provenance Logging
2
//!
3
//! Per spec ยง12: Comprehensive audit record for every inference request.
4
//! Implements GDPR Article 13/14 and SOC 2 compliance requirements.
5
//!
6
//! ## Toyota Way: Jidoka (Built-in Quality)
7
//!
8
//! Every inference operation includes automatic quality checks:
9
//! - CRC32 checksum verification
10
//! - NaN/Inf detection in outputs
11
//! - Latency anomaly detection
12
//! - Confidence score validation
13
//!
14
//! ## Key Features
15
//!
16
//! - Full provenance tracking (model hash, distillation lineage)
17
//! - Latency breakdown (preprocessing, inference, postprocessing)
18
//! - Quality gates (NaN check, confidence check)
19
//! - Batched async flush for high throughput
20
21
// Module-level clippy allows for audit infrastructure
22
#![allow(clippy::must_use_candidate)] // Builder pattern methods don't need must_use
23
#![allow(clippy::return_self_not_must_use)] // Builder pattern methods return Self
24
#![allow(clippy::missing_errors_doc)] // Error conditions are self-explanatory
25
26
use chrono::{DateTime, Utc};
27
use serde::{Deserialize, Serialize};
28
use std::collections::VecDeque;
29
use std::sync::atomic::{AtomicU64, Ordering};
30
use std::sync::Mutex;
31
use std::time::Duration;
32
use uuid::Uuid;
33
34
/// Comprehensive audit record for every inference request
35
/// Per GDPR Article 13/14 and SOC 2 compliance requirements
36
#[derive(Debug, Clone, Serialize, Deserialize)]
37
pub struct AuditRecord {
38
    // === Identification ===
39
    /// Unique request identifier
40
    pub request_id: String,
41
    /// Timestamp (ISO 8601 with timezone)
42
    pub timestamp: DateTime<Utc>,
43
    /// Client identifier (hashed for privacy)
44
    pub client_id_hash: Option<String>,
45
46
    // === Model Information ===
47
    /// Model file SHA256 hash (provenance)
48
    pub model_hash: String,
49
    /// Model version string
50
    pub model_version: String,
51
    /// Model type (LogisticRegression, RandomForest, etc.)
52
    pub model_type: String,
53
    /// Distillation lineage (if applicable)
54
    pub distillation_teacher_hash: Option<String>,
55
56
    // === Request Details ===
57
    /// Input feature dimensions
58
    pub input_dims: Vec<usize>,
59
    /// Input feature hash (for reproducibility without storing raw data)
60
    pub input_hash: String,
61
    /// Request options (explain, confidence threshold, etc.)
62
    pub options: AuditOptions,
63
64
    // === Response Details ===
65
    /// Output prediction (class or value)
66
    pub prediction: serde_json::Value,
67
    /// Confidence score (if classification)
68
    pub confidence: Option<f32>,
69
    /// Explanation summary (if explainability enabled)
70
    pub explanation_summary: Option<String>,
71
72
    // === Performance ===
73
    /// Total latency in milliseconds
74
    pub latency_ms: f64,
75
    /// Breakdown: preprocessing, inference, postprocessing
76
    pub latency_breakdown: LatencyBreakdown,
77
    /// Peak memory usage in bytes
78
    pub memory_peak_bytes: u64,
79
80
    // === Quality Checks (Jidoka) ===
81
    /// Did output pass NaN/Inf check?
82
    pub quality_nan_check: bool,
83
    /// Did output pass confidence threshold?
84
    pub quality_confidence_check: bool,
85
    /// Any warnings generated?
86
    pub warnings: Vec<String>,
87
}
88
89
impl AuditRecord {
90
    /// Create a new audit record with minimal required fields
91
21
    pub fn new(request_id: Uuid, model_hash: &str, model_type: &str) -> Self {
92
21
        Self {
93
21
            request_id: request_id.to_string(),
94
21
            timestamp: Utc::now(),
95
21
            client_id_hash: None,
96
21
            model_hash: model_hash.to_string(),
97
21
            model_version: String::new(),
98
21
            model_type: model_type.to_string(),
99
21
            distillation_teacher_hash: None,
100
21
            input_dims: Vec::new(),
101
21
            input_hash: String::new(),
102
21
            options: AuditOptions::default(),
103
21
            prediction: serde_json::Value::Null,
104
21
            confidence: None,
105
21
            explanation_summary: None,
106
21
            latency_ms: 0.0,
107
21
            latency_breakdown: LatencyBreakdown::default(),
108
21
            memory_peak_bytes: 0,
109
21
            quality_nan_check: true,
110
21
            quality_confidence_check: true,
111
21
            warnings: Vec::new(),
112
21
        }
113
21
    }
114
115
    /// Builder pattern: set client ID hash
116
1
    pub fn with_client_hash(mut self, hash: impl Into<String>) -> Self {
117
1
        self.client_id_hash = Some(hash.into());
118
1
        self
119
1
    }
120
121
    /// Builder pattern: set model version
122
1
    pub fn with_model_version(mut self, version: impl Into<String>) -> Self {
123
1
        self.model_version = version.into();
124
1
        self
125
1
    }
126
127
    /// Builder pattern: set input dimensions
128
11
    pub fn with_input_dims(mut self, dims: Vec<usize>) -> Self {
129
11
        self.input_dims = dims;
130
11
        self
131
11
    }
132
133
    /// Builder pattern: set input hash
134
1
    pub fn with_input_hash(mut self, hash: impl Into<String>) -> Self {
135
1
        self.input_hash = hash.into();
136
1
        self
137
1
    }
138
139
    /// Builder pattern: set prediction result
140
4
    pub fn with_prediction(mut self, prediction: serde_json::Value) -> Self {
141
4
        self.prediction = prediction;
142
4
        self
143
4
    }
144
145
    /// Builder pattern: set confidence score
146
1
    pub fn with_confidence(mut self, confidence: f32) -> Self {
147
1
        self.confidence = Some(confidence);
148
1
        self
149
1
    }
150
151
    /// Builder pattern: set latency
152
1
    pub fn with_latency(mut self, latency: Duration) -> Self {
153
1
        self.latency_ms = latency.as_secs_f64() * 1000.0;
154
1
        self
155
1
    }
156
157
    /// Builder pattern: set latency breakdown
158
0
    pub fn with_latency_breakdown(mut self, breakdown: LatencyBreakdown) -> Self {
159
0
        self.latency_breakdown = breakdown;
160
0
        self
161
0
    }
162
163
    /// Builder pattern: add a warning
164
1
    pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
165
1
        self.warnings.push(warning.into());
166
1
        self
167
1
    }
168
169
    /// Builder pattern: set quality check results
170
1
    pub fn with_quality_checks(mut self, nan_check: bool, confidence_check: bool) -> Self {
171
1
        self.quality_nan_check = nan_check;
172
1
        self.quality_confidence_check = confidence_check;
173
1
        self
174
1
    }
175
176
    /// Builder pattern: set distillation teacher hash
177
1
    pub fn with_distillation_teacher(mut self, teacher_hash: impl Into<String>) -> Self {
178
1
        self.distillation_teacher_hash = Some(teacher_hash.into());
179
1
        self
180
1
    }
181
}
182
183
/// Request options included in audit record
184
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
185
pub struct AuditOptions {
186
    /// Was explainability requested?
187
    pub explain: bool,
188
    /// Confidence threshold (if any)
189
    pub confidence_threshold: Option<f32>,
190
    /// Max tokens (for LLM requests)
191
    pub max_tokens: Option<usize>,
192
    /// Temperature (for LLM requests)
193
    pub temperature: Option<f32>,
194
}
195
196
impl AuditOptions {
197
    /// Create options for APR model request
198
2
    pub fn apr(explain: bool, confidence_threshold: Option<f32>) -> Self {
199
2
        Self {
200
2
            explain,
201
2
            confidence_threshold,
202
2
            max_tokens: None,
203
2
            temperature: None,
204
2
        }
205
2
    }
206
207
    /// Create options for LLM request
208
2
    pub fn llm(max_tokens: usize, temperature: f32) -> Self {
209
2
        Self {
210
2
            explain: false,
211
2
            confidence_threshold: None,
212
2
            max_tokens: Some(max_tokens),
213
2
            temperature: Some(temperature),
214
2
        }
215
2
    }
216
}
217
218
/// Latency breakdown for detailed performance analysis
219
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
220
pub struct LatencyBreakdown {
221
    /// Preprocessing time in milliseconds
222
    pub preprocessing_ms: f64,
223
    /// Inference time in milliseconds
224
    pub inference_ms: f64,
225
    /// Postprocessing time in milliseconds
226
    pub postprocessing_ms: f64,
227
    /// Explanation generation time (if enabled)
228
    pub explanation_ms: Option<f64>,
229
}
230
231
impl LatencyBreakdown {
232
    /// Create a new latency breakdown
233
4
    pub fn new(preprocessing_ms: f64, inference_ms: f64, postprocessing_ms: f64) -> Self {
234
4
        Self {
235
4
            preprocessing_ms,
236
4
            inference_ms,
237
4
            postprocessing_ms,
238
4
            explanation_ms: None,
239
4
        }
240
4
    }
241
242
    /// Add explanation time
243
3
    pub fn with_explanation(mut self, explanation_ms: f64) -> Self {
244
3
        self.explanation_ms = Some(explanation_ms);
245
3
        self
246
3
    }
247
248
    /// Total time in milliseconds
249
2
    pub fn total_ms(&self) -> f64 {
250
2
        self.preprocessing_ms
251
2
            + self.inference_ms
252
2
            + self.postprocessing_ms
253
2
            + self.explanation_ms.unwrap_or(0.0)
254
2
    }
255
}
256
257
/// Model provenance tracking (Jidoka: full traceability)
258
#[derive(Debug, Clone, Serialize, Deserialize)]
259
pub struct ProvenanceChain {
260
    /// Original training data hash
261
    pub training_data_hash: Option<String>,
262
    /// Training code commit SHA
263
    pub training_code_sha: Option<String>,
264
    /// Training environment specification
265
    pub training_env: Option<TrainingEnv>,
266
    /// Distillation chain (for distilled models)
267
    pub distillation_chain: Vec<DistillationStep>,
268
    /// Quantization provenance
269
    pub quantization: Option<QuantizationProvenance>,
270
    /// Signature chain (all signers)
271
    pub signatures: Vec<SignatureRecord>,
272
}
273
274
impl Default for ProvenanceChain {
275
0
    fn default() -> Self {
276
0
        Self::new()
277
0
    }
278
}
279
280
impl ProvenanceChain {
281
    /// Create an empty provenance chain
282
5
    pub fn new() -> Self {
283
5
        Self {
284
5
            training_data_hash: None,
285
5
            training_code_sha: None,
286
5
            training_env: None,
287
5
            distillation_chain: Vec::new(),
288
5
            quantization: None,
289
5
            signatures: Vec::new(),
290
5
        }
291
5
    }
292
293
    /// Add training data hash
294
2
    pub fn with_training_data(mut self, hash: impl Into<String>) -> Self {
295
2
        self.training_data_hash = Some(hash.into());
296
2
        self
297
2
    }
298
299
    /// Add training code commit
300
2
    pub fn with_code_sha(mut self, sha: impl Into<String>) -> Self {
301
2
        self.training_code_sha = Some(sha.into());
302
2
        self
303
2
    }
304
305
    /// Add distillation step
306
1
    pub fn add_distillation(&mut self, step: DistillationStep) {
307
1
        self.distillation_chain.push(step);
308
1
    }
309
310
    /// Add signature
311
1
    pub fn add_signature(&mut self, signature: SignatureRecord) {
312
1
        self.signatures.push(signature);
313
1
    }
314
}
315
316
/// Training environment specification
317
#[derive(Debug, Clone, Serialize, Deserialize)]
318
pub struct TrainingEnv {
319
    /// Framework version (e.g., "aprender 0.1.0")
320
    pub framework: String,
321
    /// Hardware description
322
    pub hardware: String,
323
    /// Random seed used
324
    pub seed: Option<u64>,
325
}
326
327
/// Distillation step in provenance chain
328
#[derive(Debug, Clone, Serialize, Deserialize)]
329
pub struct DistillationStep {
330
    /// Teacher model hash
331
    pub teacher_hash: String,
332
    /// Distillation method (Standard, Progressive, Ensemble)
333
    pub method: String,
334
    /// Temperature used
335
    pub temperature: f32,
336
    /// Alpha (soft vs hard loss weight)
337
    pub alpha: f32,
338
    /// Final distillation loss
339
    pub final_loss: f32,
340
    /// Timestamp of distillation
341
    pub timestamp: DateTime<Utc>,
342
}
343
344
/// Quantization provenance
345
#[derive(Debug, Clone, Serialize, Deserialize)]
346
pub struct QuantizationProvenance {
347
    /// Original model hash (before quantization)
348
    pub original_hash: String,
349
    /// Quantization method (Q4_0, Q8_0, AWQ, etc.)
350
    pub method: String,
351
    /// Bits per weight
352
    pub bits: u8,
353
    /// Calibration data hash (if applicable)
354
    pub calibration_hash: Option<String>,
355
}
356
357
/// Signature record for signed models
358
#[derive(Debug, Clone, Serialize, Deserialize)]
359
pub struct SignatureRecord {
360
    /// Signer identity (e.g., "paiml-release-key")
361
    pub signer: String,
362
    /// Signature algorithm (Ed25519)
363
    pub algorithm: String,
364
    /// Signature timestamp
365
    pub timestamp: DateTime<Utc>,
366
    /// Signature bytes (base64 encoded)
367
    pub signature: String,
368
}
369
370
/// Audit sink trait for different output destinations
371
pub trait AuditSink: Send + Sync {
372
    /// Write a batch of audit records
373
    fn write_batch(&self, records: &[AuditRecord]) -> Result<(), AuditError>;
374
375
    /// Flush any pending writes
376
    fn flush(&self) -> Result<(), AuditError>;
377
}
378
379
/// In-memory audit sink for testing
380
#[derive(Debug, Default)]
381
pub struct InMemoryAuditSink {
382
    records: Mutex<Vec<AuditRecord>>,
383
}
384
385
impl InMemoryAuditSink {
386
    /// Create a new in-memory sink
387
141
    pub fn new() -> Self {
388
141
        Self {
389
141
            records: Mutex::new(Vec::new()),
390
141
        }
391
141
    }
392
393
    /// Get all stored records
394
2
    pub fn records(&self) -> Vec<AuditRecord> {
395
2
        self.records.lock().expect("test").clone()
396
2
    }
397
398
    /// Get record count
399
3
    pub fn count(&self) -> usize {
400
3
        self.records.lock().expect("test").len()
401
3
    }
402
}
403
404
impl AuditSink for InMemoryAuditSink {
405
5
    fn write_batch(&self, records: &[AuditRecord]) -> Result<(), AuditError> {
406
5
        let mut storage = self.records.lock().expect("test");
407
5
        storage.extend(records.iter().cloned());
408
5
        Ok(())
409
5
    }
410
411
2
    fn flush(&self) -> Result<(), AuditError> {
412
2
        Ok(())
413
2
    }
414
}
415
416
/// JSON file audit sink
417
pub struct JsonFileAuditSink {
418
    path: std::path::PathBuf,
419
}
420
421
impl JsonFileAuditSink {
422
    /// Create a new JSON file sink
423
5
    pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
424
5
        Self { path: path.into() }
425
5
    }
426
}
427
428
impl AuditSink for JsonFileAuditSink {
429
4
    fn write_batch(&self, records: &[AuditRecord]) -> Result<(), AuditError> {
430
        use std::fs::OpenOptions;
431
        use std::io::Write;
432
433
4
        let mut file = OpenOptions::new()
434
4
            .create(true)
435
4
            .append(true)
436
4
            .open(&self.path)
437
4
            .map_err(|e| AuditError::IoError(
e0
.
to_string0
()))
?0
;
438
439
9
        for 
record5
in records {
440
5
            let json = serde_json::to_string(record)
441
5
                .map_err(|e| AuditError::SerializationError(
e0
.
to_string0
()))
?0
;
442
5
            writeln!(file, "{}", json).map_err(|e| AuditError::IoError(
e0
.
to_string0
()))
?0
;
443
        }
444
445
4
        Ok(())
446
4
    }
447
448
2
    fn flush(&self) -> Result<(), AuditError> {
449
2
        Ok(())
450
2
    }
451
}
452
453
/// Audit error types
454
#[derive(Debug, Clone)]
455
pub enum AuditError {
456
    /// IO error writing audit records
457
    IoError(String),
458
    /// Serialization error
459
    SerializationError(String),
460
    /// Record not found
461
    NotFound(String),
462
}
463
464
impl std::fmt::Display for AuditError {
465
3
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466
3
        match self {
467
1
            Self::IoError(msg) => write!(f, "Audit IO error: {msg}"),
468
1
            Self::SerializationError(msg) => write!(f, "Audit serialization error: {msg}"),
469
1
            Self::NotFound(id) => write!(f, "Audit record not found: {id}"),
470
        }
471
3
    }
472
}
473
474
impl std::error::Error for AuditError {}
475
476
/// High-performance audit logger with batching
477
pub struct AuditLogger {
478
    /// Output sink (file, database, event stream)
479
    sink: Box<dyn AuditSink>,
480
    /// Batch buffer for efficiency
481
    buffer: Mutex<VecDeque<AuditRecord>>,
482
    /// Buffer size threshold for auto-flush
483
    buffer_threshold: usize,
484
    /// Total records logged (for metrics)
485
    total_logged: AtomicU64,
486
    /// Model hash for provenance
487
    model_hash: String,
488
    /// Load timestamp
489
    load_timestamp: DateTime<Utc>,
490
}
491
492
impl AuditLogger {
493
    /// Create a new audit logger with default settings
494
139
    pub fn new(sink: Box<dyn AuditSink>) -> Self {
495
139
        Self {
496
139
            sink,
497
139
            buffer: Mutex::new(VecDeque::new()),
498
139
            buffer_threshold: 1000,
499
139
            total_logged: AtomicU64::new(0),
500
139
            model_hash: String::new(),
501
139
            load_timestamp: Utc::now(),
502
139
        }
503
139
    }
504
505
    /// Create an audit logger with in-memory sink (for testing)
506
4
    pub fn in_memory() -> (Self, std::sync::Arc<InMemoryAuditSink>) {
507
4
        let sink = std::sync::Arc::new(InMemoryAuditSink::new());
508
4
        let logger = Self::new(Box::new(InMemorySinkWrapper(sink.clone())));
509
4
        (logger, sink)
510
4
    }
511
512
    /// Set the model hash for provenance tracking
513
134
    pub fn with_model_hash(mut self, hash: impl Into<String>) -> Self {
514
134
        self.model_hash = hash.into();
515
134
        self
516
134
    }
517
518
    /// Set the load timestamp
519
1
    pub fn with_load_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
520
1
        self.load_timestamp = timestamp;
521
1
        self
522
1
    }
523
524
    /// Set buffer threshold for auto-flush
525
1
    pub fn with_buffer_threshold(mut self, threshold: usize) -> Self {
526
1
        self.buffer_threshold = threshold;
527
1
        self
528
1
    }
529
530
    /// Log a request (returns the request ID)
531
10
    pub fn log_request(&self, model_type: &str, input_dims: &[usize]) -> Uuid {
532
10
        let request_id = Uuid::new_v4();
533
10
        let record = AuditRecord::new(request_id, &self.model_hash, model_type)
534
10
            .with_input_dims(input_dims.to_vec());
535
536
10
        let mut buffer = self.buffer.lock().expect("test");
537
10
        buffer.push_back(record);
538
539
10
        request_id
540
10
    }
541
542
    /// Complete a request with response data
543
10
    pub fn log_response(
544
10
        &self,
545
10
        request_id: Uuid,
546
10
        prediction: serde_json::Value,
547
10
        latency: Duration,
548
10
        confidence: Option<f32>,
549
10
    ) {
550
10
        let mut buffer = self.buffer.lock().expect("test");
551
552
        // Find and update the record
553
10
        if let Some(record) = buffer
554
10
            .iter_mut()
555
23
            .
find10
(|r| r.request_id == request_id.to_string())
556
10
        {
557
10
            record.prediction = prediction;
558
10
            record.latency_ms = latency.as_secs_f64() * 1000.0;
559
10
            record.confidence = confidence;
560
10
        
}0
561
562
        // Auto-flush if buffer exceeds threshold
563
10
        if buffer.len() >= self.buffer_threshold {
564
1
            let _ = self.flush_buffer_locked(&mut buffer);
565
9
        }
566
10
    }
567
568
    /// Manually flush the buffer
569
4
    pub fn flush(&self) -> Result<(), AuditError> {
570
4
        let mut buffer = self.buffer.lock().expect("test");
571
4
        self.flush_buffer_locked(&mut buffer)
572
4
    }
573
574
    /// Internal flush with lock already held
575
5
    fn flush_buffer_locked(&self, buffer: &mut VecDeque<AuditRecord>) -> Result<(), AuditError> {
576
5
        if buffer.is_empty() {
577
1
            return Ok(());
578
4
        }
579
580
4
        let records: Vec<AuditRecord> = buffer.drain(..).collect();
581
4
        let count = records.len() as u64;
582
583
4
        self.sink.write_batch(&records)
?0
;
584
4
        self.total_logged.fetch_add(count, Ordering::Relaxed);
585
586
4
        Ok(())
587
5
    }
588
589
    /// Get total records logged
590
1
    pub fn total_logged(&self) -> u64 {
591
1
        self.total_logged.load(Ordering::Relaxed)
592
1
    }
593
594
    /// Get current buffer size
595
3
    pub fn buffer_size(&self) -> usize {
596
3
        self.buffer.lock().expect("test").len()
597
3
    }
598
599
    /// Get model hash
600
1
    pub fn model_hash(&self) -> &str {
601
1
        &self.model_hash
602
1
    }
603
604
    /// Get load timestamp
605
1
    pub fn load_timestamp(&self) -> DateTime<Utc> {
606
1
        self.load_timestamp
607
1
    }
608
}
609
610
/// Wrapper to make Arc<InMemoryAuditSink> implement AuditSink
611
struct InMemorySinkWrapper(std::sync::Arc<InMemoryAuditSink>);
612
613
impl AuditSink for InMemorySinkWrapper {
614
3
    fn write_batch(&self, records: &[AuditRecord]) -> Result<(), AuditError> {
615
3
        self.0.write_batch(records)
616
3
    }
617
618
0
    fn flush(&self) -> Result<(), AuditError> {
619
0
        self.0.flush()
620
0
    }
621
}
622
623
// ============================================================================
624
// EXTREME TDD TESTS
625
// ============================================================================
626
627
#[cfg(test)]
628
mod tests {
629
    use super::*;
630
    use std::time::Duration;
631
632
    // -------------------------------------------------------------------------
633
    // AuditRecord Tests
634
    // -------------------------------------------------------------------------
635
636
    #[test]
637
1
    fn test_audit_record_new() {
638
1
        let request_id = Uuid::new_v4();
639
1
        let record = AuditRecord::new(request_id, "abc123", "LogisticRegression");
640
641
1
        assert_eq!(record.request_id, request_id.to_string());
642
1
        assert_eq!(record.model_hash, "abc123");
643
1
        assert_eq!(record.model_type, "LogisticRegression");
644
1
        assert!(record.quality_nan_check);
645
1
        assert!(record.quality_confidence_check);
646
1
    }
647
648
    #[test]
649
1
    fn test_audit_record_builder_pattern() {
650
1
        let request_id = Uuid::new_v4();
651
1
        let record = AuditRecord::new(request_id, "hash123", "RandomForest")
652
1
            .with_client_hash("client_hash_456")
653
1
            .with_model_version("1.0.0")
654
1
            .with_input_dims(vec![784])
655
1
            .with_input_hash("input_hash_789")
656
1
            .with_prediction(serde_json::json!({"class": 1}))
657
1
            .with_confidence(0.95)
658
1
            .with_latency(Duration::from_millis(50))
659
1
            .with_warning("Low confidence")
660
1
            .with_quality_checks(true, false);
661
662
1
        assert_eq!(record.client_id_hash, Some("client_hash_456".to_string()));
663
1
        assert_eq!(record.model_version, "1.0.0");
664
1
        assert_eq!(record.input_dims, vec![784]);
665
1
        assert_eq!(record.input_hash, "input_hash_789");
666
1
        assert_eq!(record.prediction, serde_json::json!({"class": 1}));
667
1
        assert_eq!(record.confidence, Some(0.95));
668
1
        assert!((record.latency_ms - 50.0).abs() < 0.1);
669
1
        assert_eq!(record.warnings, vec!["Low confidence"]);
670
1
        assert!(record.quality_nan_check);
671
1
        assert!(!record.quality_confidence_check);
672
1
    }
673
674
    #[test]
675
1
    fn test_audit_record_serialization() {
676
1
        let request_id = Uuid::new_v4();
677
1
        let record =
678
1
            AuditRecord::new(request_id, "hash", "KNN").with_prediction(serde_json::json!(5));
679
680
1
        let json = serde_json::to_string(&record).expect("test");
681
1
        let deserialized: AuditRecord = serde_json::from_str(&json).expect("test");
682
683
1
        assert_eq!(deserialized.model_type, "KNN");
684
1
        assert_eq!(deserialized.prediction, serde_json::json!(5));
685
1
    }
686
687
    #[test]
688
1
    fn test_audit_record_with_distillation_teacher() {
689
1
        let record = AuditRecord::new(Uuid::new_v4(), "student", "MLP")
690
1
            .with_distillation_teacher("teacher_hash_abc");
691
692
1
        assert_eq!(
693
            record.distillation_teacher_hash,
694
1
            Some("teacher_hash_abc".to_string())
695
        );
696
1
    }
697
698
    // -------------------------------------------------------------------------
699
    // AuditOptions Tests
700
    // -------------------------------------------------------------------------
701
702
    #[test]
703
1
    fn test_audit_options_apr() {
704
1
        let options = AuditOptions::apr(true, Some(0.8));
705
706
1
        assert!(options.explain);
707
1
        assert_eq!(options.confidence_threshold, Some(0.8));
708
1
        assert!(options.max_tokens.is_none());
709
1
        assert!(options.temperature.is_none());
710
1
    }
711
712
    #[test]
713
1
    fn test_audit_options_llm() {
714
1
        let options = AuditOptions::llm(256, 0.7);
715
716
1
        assert!(!options.explain);
717
1
        assert!(options.confidence_threshold.is_none());
718
1
        assert_eq!(options.max_tokens, Some(256));
719
1
        assert_eq!(options.temperature, Some(0.7));
720
1
    }
721
722
    // -------------------------------------------------------------------------
723
    // LatencyBreakdown Tests
724
    // -------------------------------------------------------------------------
725
726
    #[test]
727
1
    fn test_latency_breakdown_new() {
728
1
        let breakdown = LatencyBreakdown::new(1.5, 10.0, 0.5);
729
730
1
        assert!((breakdown.preprocessing_ms - 1.5).abs() < 0.001);
731
1
        assert!((breakdown.inference_ms - 10.0).abs() < 0.001);
732
1
        assert!((breakdown.postprocessing_ms - 0.5).abs() < 0.001);
733
1
        assert!(breakdown.explanation_ms.is_none());
734
1
    }
735
736
    #[test]
737
1
    fn test_latency_breakdown_with_explanation() {
738
1
        let breakdown = LatencyBreakdown::new(1.0, 5.0, 1.0).with_explanation(3.0);
739
740
1
        assert_eq!(breakdown.explanation_ms, Some(3.0));
741
1
    }
742
743
    #[test]
744
1
    fn test_latency_breakdown_total() {
745
1
        let breakdown = LatencyBreakdown::new(1.0, 5.0, 2.0);
746
1
        assert!((breakdown.total_ms() - 8.0).abs() < 0.001);
747
748
1
        let with_explain = breakdown.with_explanation(2.0);
749
1
        assert!((with_explain.total_ms() - 10.0).abs() < 0.001);
750
1
    }
751
752
    // -------------------------------------------------------------------------
753
    // ProvenanceChain Tests
754
    // -------------------------------------------------------------------------
755
756
    #[test]
757
1
    fn test_provenance_chain_new() {
758
1
        let chain = ProvenanceChain::new();
759
760
1
        assert!(chain.training_data_hash.is_none());
761
1
        assert!(chain.training_code_sha.is_none());
762
1
        assert!(chain.distillation_chain.is_empty());
763
1
        assert!(chain.signatures.is_empty());
764
1
    }
765
766
    #[test]
767
1
    fn test_provenance_chain_builder() {
768
1
        let chain = ProvenanceChain::new()
769
1
            .with_training_data("data_hash_123")
770
1
            .with_code_sha("abc123def");
771
772
1
        assert_eq!(chain.training_data_hash, Some("data_hash_123".to_string()));
773
1
        assert_eq!(chain.training_code_sha, Some("abc123def".to_string()));
774
1
    }
775
776
    #[test]
777
1
    fn test_provenance_chain_add_distillation() {
778
1
        let mut chain = ProvenanceChain::new();
779
1
        chain.add_distillation(DistillationStep {
780
1
            teacher_hash: "teacher123".to_string(),
781
1
            method: "Standard".to_string(),
782
1
            temperature: 4.0,
783
1
            alpha: 0.5,
784
1
            final_loss: 0.01,
785
1
            timestamp: Utc::now(),
786
1
        });
787
788
1
        assert_eq!(chain.distillation_chain.len(), 1);
789
1
        assert_eq!(chain.distillation_chain[0].teacher_hash, "teacher123");
790
1
    }
791
792
    #[test]
793
1
    fn test_provenance_chain_add_signature() {
794
1
        let mut chain = ProvenanceChain::new();
795
1
        chain.add_signature(SignatureRecord {
796
1
            signer: "paiml-release".to_string(),
797
1
            algorithm: "Ed25519".to_string(),
798
1
            timestamp: Utc::now(),
799
1
            signature: "base64sig==".to_string(),
800
1
        });
801
802
1
        assert_eq!(chain.signatures.len(), 1);
803
1
        assert_eq!(chain.signatures[0].signer, "paiml-release");
804
1
    }
805
806
    // -------------------------------------------------------------------------
807
    // InMemoryAuditSink Tests
808
    // -------------------------------------------------------------------------
809
810
    #[test]
811
1
    fn test_in_memory_sink_write_batch() {
812
1
        let sink = InMemoryAuditSink::new();
813
1
        let records = vec![
814
1
            AuditRecord::new(Uuid::new_v4(), "h1", "LR"),
815
1
            AuditRecord::new(Uuid::new_v4(), "h2", "RF"),
816
        ];
817
818
1
        sink.write_batch(&records).expect("test");
819
820
1
        assert_eq!(sink.count(), 2);
821
1
        let stored = sink.records();
822
1
        assert_eq!(stored[0].model_type, "LR");
823
1
        assert_eq!(stored[1].model_type, "RF");
824
1
    }
825
826
    #[test]
827
1
    fn test_in_memory_sink_flush() {
828
1
        let sink = InMemoryAuditSink::new();
829
1
        assert!(sink.flush().is_ok());
830
1
    }
831
832
    // -------------------------------------------------------------------------
833
    // AuditLogger Tests
834
    // -------------------------------------------------------------------------
835
836
    #[test]
837
1
    fn test_audit_logger_in_memory() {
838
1
        let (logger, sink) = AuditLogger::in_memory();
839
840
1
        let request_id = logger.log_request("LogisticRegression", &[784]);
841
1
        logger.log_response(
842
1
            request_id,
843
1
            serde_json::json!({"class": 1}),
844
1
            Duration::from_millis(5),
845
1
            Some(0.95),
846
        );
847
848
1
        assert_eq!(logger.buffer_size(), 1);
849
1
        logger.flush().expect("test");
850
1
        assert_eq!(logger.buffer_size(), 0);
851
1
        assert_eq!(sink.count(), 1);
852
1
    }
853
854
    #[test]
855
1
    fn test_audit_logger_with_model_hash() {
856
1
        let (logger, _sink) = AuditLogger::in_memory();
857
1
        let logger = logger.with_model_hash("sha256_abc123");
858
859
1
        assert_eq!(logger.model_hash(), "sha256_abc123");
860
1
    }
861
862
    #[test]
863
1
    fn test_audit_logger_total_logged() {
864
1
        let (logger, _sink) = AuditLogger::in_memory();
865
866
6
        for _ in 0..5 {
867
5
            let id = logger.log_request("Test", &[10]);
868
5
            logger.log_response(id, serde_json::json!(0), Duration::from_millis(1), None);
869
5
        }
870
871
1
        logger.flush().expect("test");
872
1
        assert_eq!(logger.total_logged(), 5);
873
1
    }
874
875
    #[test]
876
1
    fn test_audit_logger_auto_flush() {
877
1
        let sink = std::sync::Arc::new(InMemoryAuditSink::new());
878
1
        let logger =
879
1
            AuditLogger::new(Box::new(InMemorySinkWrapper(sink.clone()))).with_buffer_threshold(3);
880
881
        // Log 3 requests - should auto-flush
882
4
        for _ in 0..3 {
883
3
            let id = logger.log_request("Test", &[5]);
884
3
            logger.log_response(id, serde_json::json!(1), Duration::from_millis(1), None);
885
3
        }
886
887
        // Buffer should be empty after auto-flush
888
1
        assert_eq!(logger.buffer_size(), 0);
889
1
        assert_eq!(sink.count(), 3);
890
1
    }
891
892
    #[test]
893
1
    fn test_audit_logger_load_timestamp() {
894
1
        let timestamp = Utc::now();
895
1
        let (logger, _) = AuditLogger::in_memory();
896
1
        let logger = logger.with_load_timestamp(timestamp);
897
898
1
        assert_eq!(logger.load_timestamp(), timestamp);
899
1
    }
900
901
    // -------------------------------------------------------------------------
902
    // AuditError Tests
903
    // -------------------------------------------------------------------------
904
905
    #[test]
906
1
    fn test_audit_error_display() {
907
1
        let io_err = AuditError::IoError("disk full".to_string());
908
1
        assert!(io_err.to_string().contains("disk full"));
909
910
1
        let ser_err = AuditError::SerializationError("invalid json".to_string());
911
1
        assert!(ser_err.to_string().contains("invalid json"));
912
913
1
        let not_found = AuditError::NotFound("abc-123".to_string());
914
1
        assert!(not_found.to_string().contains("abc-123"));
915
1
    }
916
917
    // -------------------------------------------------------------------------
918
    // Serialization Round-trip Tests
919
    // -------------------------------------------------------------------------
920
921
    #[test]
922
1
    fn test_latency_breakdown_serialization() {
923
1
        let breakdown = LatencyBreakdown::new(1.0, 5.0, 2.0).with_explanation(1.5);
924
1
        let json = serde_json::to_string(&breakdown).expect("test");
925
1
        let restored: LatencyBreakdown = serde_json::from_str(&json).expect("test");
926
927
1
        assert!((restored.inference_ms - 5.0).abs() < 0.001);
928
1
        assert_eq!(restored.explanation_ms, Some(1.5));
929
1
    }
930
931
    #[test]
932
1
    fn test_provenance_chain_serialization() {
933
1
        let chain = ProvenanceChain::new()
934
1
            .with_training_data("data123")
935
1
            .with_code_sha("commit456");
936
937
1
        let json = serde_json::to_string(&chain).expect("test");
938
1
        let restored: ProvenanceChain = serde_json::from_str(&json).expect("test");
939
940
1
        assert_eq!(restored.training_data_hash, Some("data123".to_string()));
941
1
        assert_eq!(restored.training_code_sha, Some("commit456".to_string()));
942
1
    }
943
944
    #[test]
945
1
    fn test_quantization_provenance_serialization() {
946
1
        let quant = QuantizationProvenance {
947
1
            original_hash: "original_model_hash".to_string(),
948
1
            method: "Q4_K_M".to_string(),
949
1
            bits: 4,
950
1
            calibration_hash: Some("calibration_data_hash".to_string()),
951
1
        };
952
953
1
        let json = serde_json::to_string(&quant).expect("test");
954
1
        let restored: QuantizationProvenance = serde_json::from_str(&json).expect("test");
955
956
1
        assert_eq!(restored.method, "Q4_K_M");
957
1
        assert_eq!(restored.bits, 4);
958
1
    }
959
960
    // -------------------------------------------------------------------------
961
    // JsonFileAuditSink Tests (95% coverage push)
962
    // -------------------------------------------------------------------------
963
964
    #[test]
965
1
    fn test_json_file_audit_sink_new() {
966
        // Just verify sink can be created without panic
967
1
        let _sink = JsonFileAuditSink::new("/tmp/test_audit.jsonl");
968
1
    }
969
970
    #[test]
971
1
    fn test_json_file_audit_sink_write_batch() {
972
        use std::fs;
973
1
        let path = std::env::temp_dir().join("audit_test_write_batch.jsonl");
974
1
        let sink = JsonFileAuditSink::new(&path);
975
976
1
        let records = vec![
977
1
            AuditRecord::new(Uuid::new_v4(), "hash1", "LR")
978
1
                .with_prediction(serde_json::json!({"class": 0})),
979
1
            AuditRecord::new(Uuid::new_v4(), "hash2", "RF")
980
1
                .with_prediction(serde_json::json!({"class": 1})),
981
        ];
982
983
1
        sink.write_batch(&records).expect("write_batch");
984
1
        sink.flush().expect("flush");
985
986
        // Verify file was written
987
1
        let content = fs::read_to_string(&path).expect("read file");
988
1
        assert!(content.contains("hash1"));
989
1
        assert!(content.contains("hash2"));
990
1
        assert!(content.lines().count() == 2);
991
992
1
        let _ = fs::remove_file(&path);
993
1
    }
994
995
    #[test]
996
1
    fn test_json_file_audit_sink_append() {
997
        use std::fs;
998
1
        let path = std::env::temp_dir().join("audit_test_append.jsonl");
999
1
        let sink = JsonFileAuditSink::new(&path);
1000
1001
        // Write first batch
1002
1
        let records1 = vec![AuditRecord::new(Uuid::new_v4(), "h1", "T1")];
1003
1
        sink.write_batch(&records1).expect("write 1");
1004
1005
        // Write second batch (should append)
1006
1
        let records2 = vec![AuditRecord::new(Uuid::new_v4(), "h2", "T2")];
1007
1
        sink.write_batch(&records2).expect("write 2");
1008
1009
1
        let content = fs::read_to_string(&path).expect("read");
1010
1
        assert_eq!(content.lines().count(), 2);
1011
1012
1
        let _ = fs::remove_file(&path);
1013
1
    }
1014
1015
    #[test]
1016
1
    fn test_json_file_audit_sink_flush_noop() {
1017
1
        let sink = JsonFileAuditSink::new("/tmp/nonexistent_audit.jsonl");
1018
        // Flush does nothing but returns Ok
1019
1
        assert!(sink.flush().is_ok());
1020
1
    }
1021
1022
    #[test]
1023
1
    fn test_audit_logger_with_file_sink() {
1024
        use std::fs;
1025
1
        let path = std::env::temp_dir().join("audit_test_logger_file.jsonl");
1026
1
        let sink = JsonFileAuditSink::new(&path);
1027
1
        let logger = AuditLogger::new(Box::new(sink));
1028
1029
1
        let id = logger.log_request("FileTest", &[100, 200]);
1030
1
        logger.log_response(
1031
1
            id,
1032
1
            serde_json::json!(42),
1033
1
            Duration::from_millis(10),
1034
1
            Some(0.99),
1035
        );
1036
1
        logger.flush().expect("flush");
1037
1038
1
        let content = fs::read_to_string(&path).expect("read");
1039
1
        assert!(content.contains("FileTest"));
1040
1
        assert!(content.contains("100"));
1041
1
        assert!(content.contains("200"));
1042
1043
1
        let _ = fs::remove_file(&path);
1044
1
    }
1045
1046
    #[test]
1047
1
    fn test_audit_record_with_explanation_summary() {
1048
1
        let record = AuditRecord::new(Uuid::new_v4(), "h", "T");
1049
        // Verify explanation_summary is None by default
1050
1
        assert!(record.explanation_summary.is_none());
1051
1
    }
1052
1053
    #[test]
1054
1
    fn test_audit_options_default_values() {
1055
1
        let apr_opts = AuditOptions::apr(false, None);
1056
1
        assert!(!apr_opts.explain);
1057
1
        assert!(apr_opts.confidence_threshold.is_none());
1058
1059
1
        let llm_opts = AuditOptions::llm(100, 1.0);
1060
1
        assert_eq!(llm_opts.max_tokens, Some(100));
1061
1
        assert_eq!(llm_opts.temperature, Some(1.0));
1062
1
    }
1063
}