//! Audit trail for solver invocations. //! //! Every solve operation can produce a [`SolverAuditEntry`] that captures a //! tamper-evident fingerprint of the input, output, convergence metrics, and //! timing. Entries are cheap to produce and can be streamed to any log sink //! (structured logging, event store, or external SIEM). //! //! # Hashing //! //! We use [`std::hash::DefaultHasher`] (SipHash-2-4 on most platforms) rather //! than a cryptographic hash. This is sufficient for audit deduplication and //! integrity detection but is **not** suitable for security-critical tamper //! proofing. If cryptographic guarantees are needed, swap in a SHA-256 //! implementation behind a feature gate. use std::hash::{DefaultHasher, Hash, Hasher}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use crate::types::{Algorithm, CsrMatrix, SolverResult}; // --------------------------------------------------------------------------- // Audit entry // --------------------------------------------------------------------------- /// A single audit trail record for one solver invocation. /// /// Captures a deterministic fingerprint of the problem (input hash), the /// solution (output hash), performance counters, and a monotonic timestamp. /// /// # Serialization /// /// Derives `Serialize` / `Deserialize` so entries can be persisted as JSON, /// MessagePack, or any serde-compatible format. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SolverAuditEntry { /// Unique identifier for this solve request. pub request_id: String, /// Algorithm that produced the result. pub algorithm: Algorithm, /// 8-byte hash of the input (matrix + rhs). Produced by /// [`hash_input`]. pub input_hash: [u8; 8], /// 8-byte hash of the output solution vector. Produced by /// [`hash_output`]. pub output_hash: [u8; 8], /// Number of iterations the solver executed. pub iterations: usize, /// Wall-clock time in microseconds. pub wall_time_us: u64, /// Whether the solver converged within tolerance. pub converged: bool, /// Final residual L2 norm. pub residual: f64, /// Timestamp as nanoseconds since the Unix epoch. pub timestamp_ns: u128, /// Number of rows in the input matrix. pub matrix_rows: usize, /// Number of non-zero entries in the input matrix. pub matrix_nnz: usize, } // --------------------------------------------------------------------------- // Hash helpers // --------------------------------------------------------------------------- /// Compute a deterministic 8-byte fingerprint of the solver input. /// /// Hashes the matrix dimensions, structural arrays (`row_ptr`, `col_indices`), /// value bytes, and the right-hand-side vector. pub fn hash_input(matrix: &CsrMatrix, rhs: &[f32]) -> [u8; 8] { let mut h = DefaultHasher::new(); // Matrix structure matrix.rows.hash(&mut h); matrix.cols.hash(&mut h); matrix.row_ptr.hash(&mut h); matrix.col_indices.hash(&mut h); // Values as raw bytes (avoids floating-point hashing issues) for &v in &matrix.values { v.to_bits().hash(&mut h); } // RHS for &v in rhs { v.to_bits().hash(&mut h); } h.finish().to_le_bytes() } /// Compute a deterministic 8-byte fingerprint of the solution vector. pub fn hash_output(solution: &[f32]) -> [u8; 8] { let mut h = DefaultHasher::new(); for &v in solution { v.to_bits().hash(&mut h); } h.finish().to_le_bytes() } // --------------------------------------------------------------------------- // Builder // --------------------------------------------------------------------------- /// Convenience builder for [`SolverAuditEntry`]. /// /// Start a timer at the beginning of a solve, then call [`finish`] with the /// result to produce a complete audit record. /// /// # Example /// /// ```ignore /// let audit = AuditBuilder::start("req-42", &matrix, &rhs); /// let result = solver.solve(&matrix, &rhs)?; /// let entry = audit.finish(&result, tolerance); /// tracing::info!(?entry, "solve completed"); /// ``` pub struct AuditBuilder { request_id: String, input_hash: [u8; 8], matrix_rows: usize, matrix_nnz: usize, start: Instant, timestamp_ns: u128, } impl AuditBuilder { /// Begin an audit trace for a new solve request. /// /// Records the wall-clock start time and computes the input hash eagerly /// so that the hash is taken before any mutation. pub fn start(request_id: impl Into, matrix: &CsrMatrix, rhs: &[f32]) -> Self { let timestamp_ns = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or(Duration::ZERO) .as_nanos(); Self { request_id: request_id.into(), input_hash: hash_input(matrix, rhs), matrix_rows: matrix.rows, matrix_nnz: matrix.values.len(), start: Instant::now(), timestamp_ns, } } /// Finalize the audit entry after the solver returns. /// /// `tolerance` is the target tolerance that was requested so that /// `converged` can be computed from the residual. pub fn finish(self, result: &SolverResult, tolerance: f64) -> SolverAuditEntry { let elapsed = self.start.elapsed(); SolverAuditEntry { request_id: self.request_id, algorithm: result.algorithm, input_hash: self.input_hash, output_hash: hash_output(&result.solution), iterations: result.iterations, wall_time_us: elapsed.as_micros() as u64, converged: result.residual_norm <= tolerance, residual: result.residual_norm, timestamp_ns: self.timestamp_ns, matrix_rows: self.matrix_rows, matrix_nnz: self.matrix_nnz, } } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use crate::types::{Algorithm, ConvergenceInfo, SolverResult}; use std::time::Duration; fn sample_matrix() -> CsrMatrix { CsrMatrix::::from_coo( 2, 2, vec![(0, 0, 2.0), (0, 1, -0.5), (1, 0, -0.5), (1, 1, 2.0)], ) } fn sample_result() -> SolverResult { SolverResult { solution: vec![0.5, 0.5], iterations: 10, residual_norm: 1e-9, wall_time: Duration::from_millis(2), convergence_history: vec![ConvergenceInfo { iteration: 9, residual_norm: 1e-9, }], algorithm: Algorithm::Neumann, } } #[test] fn hash_input_deterministic() { let m = sample_matrix(); let rhs = vec![1.0f32, 1.0]; let h1 = hash_input(&m, &rhs); let h2 = hash_input(&m, &rhs); assert_eq!(h1, h2, "same input must produce same hash"); } #[test] fn hash_input_changes_with_values() { let m1 = sample_matrix(); let mut m2 = sample_matrix(); m2.values[0] = 3.0; let rhs = vec![1.0f32, 1.0]; assert_ne!( hash_input(&m1, &rhs), hash_input(&m2, &rhs), "different values must produce different hashes", ); } #[test] fn hash_input_changes_with_rhs() { let m = sample_matrix(); let rhs1 = vec![1.0f32, 1.0]; let rhs2 = vec![1.0f32, 2.0]; assert_ne!( hash_input(&m, &rhs1), hash_input(&m, &rhs2), "different rhs must produce different hashes", ); } #[test] fn hash_output_deterministic() { let sol = vec![0.5f32, 0.5]; assert_eq!(hash_output(&sol), hash_output(&sol)); } #[test] fn hash_output_changes() { let sol1 = vec![0.5f32, 0.5]; let sol2 = vec![0.5f32, 0.6]; assert_ne!(hash_output(&sol1), hash_output(&sol2)); } #[test] fn audit_builder_produces_entry() { let m = sample_matrix(); let rhs = vec![1.0f32, 1.0]; let builder = AuditBuilder::start("test-req-1", &m, &rhs); let result = sample_result(); let entry = builder.finish(&result, 1e-6); assert_eq!(entry.request_id, "test-req-1"); assert_eq!(entry.algorithm, Algorithm::Neumann); assert_eq!(entry.iterations, 10); assert!(entry.converged, "residual 1e-9 < tolerance 1e-6"); assert_eq!(entry.matrix_rows, 2); assert_eq!(entry.matrix_nnz, 4); assert!(entry.timestamp_ns > 0); } #[test] fn audit_builder_not_converged() { let m = sample_matrix(); let rhs = vec![1.0f32, 1.0]; let builder = AuditBuilder::start("test-req-2", &m, &rhs); let mut result = sample_result(); result.residual_norm = 0.1; // Above tolerance let entry = builder.finish(&result, 1e-6); assert!(!entry.converged); } #[test] fn audit_entry_is_serializable() { // Verify that the entry can be serialized/deserialized via serde. // We test using bincode (available as a dev-dep) or just verify the // derive attributes are correct by round-tripping through Debug. let m = sample_matrix(); let rhs = vec![1.0f32, 1.0]; let builder = AuditBuilder::start("ser-test", &m, &rhs); let result = sample_result(); let entry = builder.finish(&result, 1e-6); // At minimum, verify Debug output contains expected fields. let debug = format!("{:?}", entry); assert!(debug.contains("ser-test"), "debug: {debug}"); assert!(debug.contains("Neumann"), "debug: {debug}"); // Verify Clone works (which Serialize/Deserialize depend on for some codecs). let cloned = entry.clone(); assert_eq!(cloned.request_id, entry.request_id); assert_eq!(cloned.input_hash, entry.input_hash); assert_eq!(cloned.output_hash, entry.output_hash); assert_eq!(cloned.iterations, entry.iterations); } }