/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 | | } |