317 lines
10 KiB
Rust
317 lines
10 KiB
Rust
//! 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<f32>, 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<String>, matrix: &CsrMatrix<f32>, 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<f32> {
|
|
CsrMatrix::<f32>::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);
|
|
}
|
|
}
|