Skip to main content

ruvector_solver/
audit.rs

1//! Audit trail for solver invocations.
2//!
3//! Every solve operation can produce a [`SolverAuditEntry`] that captures a
4//! tamper-evident fingerprint of the input, output, convergence metrics, and
5//! timing. Entries are cheap to produce and can be streamed to any log sink
6//! (structured logging, event store, or external SIEM).
7//!
8//! # Hashing
9//!
10//! We use [`std::hash::DefaultHasher`] (SipHash-2-4 on most platforms) rather
11//! than a cryptographic hash. This is sufficient for audit deduplication and
12//! integrity detection but is **not** suitable for security-critical tamper
13//! proofing. If cryptographic guarantees are needed, swap in a SHA-256
14//! implementation behind a feature gate.
15
16use std::hash::{DefaultHasher, Hash, Hasher};
17use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
18
19use serde::{Deserialize, Serialize};
20
21use crate::types::{Algorithm, CsrMatrix, SolverResult};
22
23// ---------------------------------------------------------------------------
24// Audit entry
25// ---------------------------------------------------------------------------
26
27/// A single audit trail record for one solver invocation.
28///
29/// Captures a deterministic fingerprint of the problem (input hash), the
30/// solution (output hash), performance counters, and a monotonic timestamp.
31///
32/// # Serialization
33///
34/// Derives `Serialize` / `Deserialize` so entries can be persisted as JSON,
35/// MessagePack, or any serde-compatible format.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SolverAuditEntry {
38    /// Unique identifier for this solve request.
39    pub request_id: String,
40
41    /// Algorithm that produced the result.
42    pub algorithm: Algorithm,
43
44    /// 8-byte hash of the input (matrix + rhs). Produced by
45    /// [`hash_input`].
46    pub input_hash: [u8; 8],
47
48    /// 8-byte hash of the output solution vector. Produced by
49    /// [`hash_output`].
50    pub output_hash: [u8; 8],
51
52    /// Number of iterations the solver executed.
53    pub iterations: usize,
54
55    /// Wall-clock time in microseconds.
56    pub wall_time_us: u64,
57
58    /// Whether the solver converged within tolerance.
59    pub converged: bool,
60
61    /// Final residual L2 norm.
62    pub residual: f64,
63
64    /// Timestamp as nanoseconds since the Unix epoch.
65    pub timestamp_ns: u128,
66
67    /// Number of rows in the input matrix.
68    pub matrix_rows: usize,
69
70    /// Number of non-zero entries in the input matrix.
71    pub matrix_nnz: usize,
72}
73
74// ---------------------------------------------------------------------------
75// Hash helpers
76// ---------------------------------------------------------------------------
77
78/// Compute a deterministic 8-byte fingerprint of the solver input.
79///
80/// Hashes the matrix dimensions, structural arrays (`row_ptr`, `col_indices`),
81/// value bytes, and the right-hand-side vector.
82pub fn hash_input(matrix: &CsrMatrix<f32>, rhs: &[f32]) -> [u8; 8] {
83    let mut h = DefaultHasher::new();
84
85    // Matrix structure
86    matrix.rows.hash(&mut h);
87    matrix.cols.hash(&mut h);
88    matrix.row_ptr.hash(&mut h);
89    matrix.col_indices.hash(&mut h);
90
91    // Values as raw bytes (avoids floating-point hashing issues)
92    for &v in &matrix.values {
93        v.to_bits().hash(&mut h);
94    }
95
96    // RHS
97    for &v in rhs {
98        v.to_bits().hash(&mut h);
99    }
100
101    h.finish().to_le_bytes()
102}
103
104/// Compute a deterministic 8-byte fingerprint of the solution vector.
105pub fn hash_output(solution: &[f32]) -> [u8; 8] {
106    let mut h = DefaultHasher::new();
107    for &v in solution {
108        v.to_bits().hash(&mut h);
109    }
110    h.finish().to_le_bytes()
111}
112
113// ---------------------------------------------------------------------------
114// Builder
115// ---------------------------------------------------------------------------
116
117/// Convenience builder for [`SolverAuditEntry`].
118///
119/// Start a timer at the beginning of a solve, then call [`finish`] with the
120/// result to produce a complete audit record.
121///
122/// # Example
123///
124/// ```ignore
125/// let audit = AuditBuilder::start("req-42", &matrix, &rhs);
126/// let result = solver.solve(&matrix, &rhs)?;
127/// let entry = audit.finish(&result, tolerance);
128/// tracing::info!(?entry, "solve completed");
129/// ```
130pub struct AuditBuilder {
131    request_id: String,
132    input_hash: [u8; 8],
133    matrix_rows: usize,
134    matrix_nnz: usize,
135    start: Instant,
136    timestamp_ns: u128,
137}
138
139impl AuditBuilder {
140    /// Begin an audit trace for a new solve request.
141    ///
142    /// Records the wall-clock start time and computes the input hash eagerly
143    /// so that the hash is taken before any mutation.
144    pub fn start(request_id: impl Into<String>, matrix: &CsrMatrix<f32>, rhs: &[f32]) -> Self {
145        let timestamp_ns = SystemTime::now()
146            .duration_since(UNIX_EPOCH)
147            .unwrap_or(Duration::ZERO)
148            .as_nanos();
149
150        Self {
151            request_id: request_id.into(),
152            input_hash: hash_input(matrix, rhs),
153            matrix_rows: matrix.rows,
154            matrix_nnz: matrix.values.len(),
155            start: Instant::now(),
156            timestamp_ns,
157        }
158    }
159
160    /// Finalize the audit entry after the solver returns.
161    ///
162    /// `tolerance` is the target tolerance that was requested so that
163    /// `converged` can be computed from the residual.
164    pub fn finish(self, result: &SolverResult, tolerance: f64) -> SolverAuditEntry {
165        let elapsed = self.start.elapsed();
166
167        SolverAuditEntry {
168            request_id: self.request_id,
169            algorithm: result.algorithm,
170            input_hash: self.input_hash,
171            output_hash: hash_output(&result.solution),
172            iterations: result.iterations,
173            wall_time_us: elapsed.as_micros() as u64,
174            converged: result.residual_norm <= tolerance,
175            residual: result.residual_norm,
176            timestamp_ns: self.timestamp_ns,
177            matrix_rows: self.matrix_rows,
178            matrix_nnz: self.matrix_nnz,
179        }
180    }
181}
182
183// ---------------------------------------------------------------------------
184// Tests
185// ---------------------------------------------------------------------------
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::types::{Algorithm, ConvergenceInfo, SolverResult};
191    use std::time::Duration;
192
193    fn sample_matrix() -> CsrMatrix<f32> {
194        CsrMatrix::<f32>::from_coo(
195            2,
196            2,
197            vec![(0, 0, 2.0), (0, 1, -0.5), (1, 0, -0.5), (1, 1, 2.0)],
198        )
199    }
200
201    fn sample_result() -> SolverResult {
202        SolverResult {
203            solution: vec![0.5, 0.5],
204            iterations: 10,
205            residual_norm: 1e-9,
206            wall_time: Duration::from_millis(2),
207            convergence_history: vec![ConvergenceInfo {
208                iteration: 9,
209                residual_norm: 1e-9,
210            }],
211            algorithm: Algorithm::Neumann,
212        }
213    }
214
215    #[test]
216    fn hash_input_deterministic() {
217        let m = sample_matrix();
218        let rhs = vec![1.0f32, 1.0];
219        let h1 = hash_input(&m, &rhs);
220        let h2 = hash_input(&m, &rhs);
221        assert_eq!(h1, h2, "same input must produce same hash");
222    }
223
224    #[test]
225    fn hash_input_changes_with_values() {
226        let m1 = sample_matrix();
227        let mut m2 = sample_matrix();
228        m2.values[0] = 3.0;
229        let rhs = vec![1.0f32, 1.0];
230        assert_ne!(
231            hash_input(&m1, &rhs),
232            hash_input(&m2, &rhs),
233            "different values must produce different hashes",
234        );
235    }
236
237    #[test]
238    fn hash_input_changes_with_rhs() {
239        let m = sample_matrix();
240        let rhs1 = vec![1.0f32, 1.0];
241        let rhs2 = vec![1.0f32, 2.0];
242        assert_ne!(
243            hash_input(&m, &rhs1),
244            hash_input(&m, &rhs2),
245            "different rhs must produce different hashes",
246        );
247    }
248
249    #[test]
250    fn hash_output_deterministic() {
251        let sol = vec![0.5f32, 0.5];
252        assert_eq!(hash_output(&sol), hash_output(&sol));
253    }
254
255    #[test]
256    fn hash_output_changes() {
257        let sol1 = vec![0.5f32, 0.5];
258        let sol2 = vec![0.5f32, 0.6];
259        assert_ne!(hash_output(&sol1), hash_output(&sol2));
260    }
261
262    #[test]
263    fn audit_builder_produces_entry() {
264        let m = sample_matrix();
265        let rhs = vec![1.0f32, 1.0];
266        let builder = AuditBuilder::start("test-req-1", &m, &rhs);
267
268        let result = sample_result();
269        let entry = builder.finish(&result, 1e-6);
270
271        assert_eq!(entry.request_id, "test-req-1");
272        assert_eq!(entry.algorithm, Algorithm::Neumann);
273        assert_eq!(entry.iterations, 10);
274        assert!(entry.converged, "residual 1e-9 < tolerance 1e-6");
275        assert_eq!(entry.matrix_rows, 2);
276        assert_eq!(entry.matrix_nnz, 4);
277        assert!(entry.timestamp_ns > 0);
278    }
279
280    #[test]
281    fn audit_builder_not_converged() {
282        let m = sample_matrix();
283        let rhs = vec![1.0f32, 1.0];
284        let builder = AuditBuilder::start("test-req-2", &m, &rhs);
285
286        let mut result = sample_result();
287        result.residual_norm = 0.1; // Above tolerance
288        let entry = builder.finish(&result, 1e-6);
289
290        assert!(!entry.converged);
291    }
292
293    #[test]
294    fn audit_entry_is_serializable() {
295        // Verify that the entry can be serialized/deserialized via serde.
296        // We test using bincode (available as a dev-dep) or just verify the
297        // derive attributes are correct by round-tripping through Debug.
298        let m = sample_matrix();
299        let rhs = vec![1.0f32, 1.0];
300        let builder = AuditBuilder::start("ser-test", &m, &rhs);
301        let result = sample_result();
302        let entry = builder.finish(&result, 1e-6);
303
304        // At minimum, verify Debug output contains expected fields.
305        let debug = format!("{:?}", entry);
306        assert!(debug.contains("ser-test"), "debug: {debug}");
307        assert!(debug.contains("Neumann"), "debug: {debug}");
308
309        // Verify Clone works (which Serialize/Deserialize depend on for some codecs).
310        let cloned = entry.clone();
311        assert_eq!(cloned.request_id, entry.request_id);
312        assert_eq!(cloned.input_hash, entry.input_hash);
313        assert_eq!(cloned.output_hash, entry.output_hash);
314        assert_eq!(cloned.iterations, entry.iterations);
315    }
316}