Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'

This commit is contained in:
ruv
2026-02-28 14:39:40 -05:00
7854 changed files with 3522914 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
//! Bump allocator for per-solve scratch space.
//!
//! [`SolverArena`] provides fast, zero-fragmentation allocation of temporary
//! vectors and slices that are needed only for the duration of a single solve
//! invocation. At the end of the solve, the arena is [`reset`](SolverArena::reset)
//! and all memory is reclaimed in O(1).
//!
//! This avoids repeated heap allocations in hot solver loops and gives
//! deterministic memory usage when a [`ComputeBudget`](crate::types::ComputeBudget)
//! memory limit is in effect.
use std::cell::RefCell;
/// A simple bump allocator for solver scratch buffers.
///
/// All allocations are contiguous within a single backing `Vec<u8>`.
/// The arena does **not** drop individual allocations; instead, call
/// [`reset`](Self::reset) to reclaim all space at once.
///
/// # Example
///
/// ```
/// use ruvector_solver::arena::SolverArena;
///
/// let arena = SolverArena::with_capacity(1024);
/// let buf: &mut [f64] = arena.alloc_slice::<f64>(128);
/// assert_eq!(buf.len(), 128);
/// assert!(arena.bytes_used() >= 128 * std::mem::size_of::<f64>());
/// arena.reset();
/// assert_eq!(arena.bytes_used(), 0);
/// ```
pub struct SolverArena {
/// Backing storage.
buf: RefCell<Vec<u8>>,
/// Current write offset (bump pointer).
offset: RefCell<usize>,
}
impl SolverArena {
/// Create a new arena with the given capacity in bytes.
///
/// The arena will not reallocate unless an allocation request exceeds
/// the remaining capacity, in which case it grows by doubling.
pub fn with_capacity(capacity: usize) -> Self {
Self {
buf: RefCell::new(vec![0u8; capacity]),
offset: RefCell::new(0),
}
}
/// Allocate a mutable slice of `len` elements of type `T`, zero-initialised.
///
/// # Panics
///
/// - Panics if `T` has alignment greater than 16 (an unusual case for
/// solver numerics).
/// - Panics if `size_of::<T>() * len` overflows `usize` (prevents
/// integer overflow leading to undersized allocations).
pub fn alloc_slice<T: Copy + Default>(&self, len: usize) -> &mut [T] {
let size = std::mem::size_of::<T>();
let align = std::mem::align_of::<T>();
assert!(align <= 16, "SolverArena does not support alignment > 16");
// Guard against integer overflow: `size * len` must not wrap.
let byte_len = size
.checked_mul(len)
.expect("SolverArena::alloc_slice: size * len overflowed usize");
let mut offset = self.offset.borrow_mut();
let mut buf = self.buf.borrow_mut();
// Align the current offset up to `align`.
let aligned = (*offset + align - 1) & !(align - 1);
let needed = aligned
.checked_add(byte_len)
.expect("SolverArena::alloc_slice: aligned + byte_len overflowed usize");
// Grow if necessary.
if needed > buf.len() {
let new_cap = (needed * 2).max(buf.len() * 2);
buf.resize(new_cap, 0);
}
// Zero the allocated region.
buf[aligned..aligned + byte_len].fill(0);
*offset = aligned + byte_len;
let ptr = buf[aligned..].as_mut_ptr() as *mut T;
// SAFETY: The following invariants are upheld:
//
// 1. **Exclusive access**: We hold the only `RefMut` borrows on both
// `self.buf` and `self.offset`. No other code can read or write the
// backing buffer while this function executes.
//
// 2. **Alignment**: `aligned` is rounded up to `align_of::<T>()`, so
// `ptr` is properly aligned for `T`.
//
// 3. **Bounds**: `needed <= buf.len()` after the grow check, so the
// range `[aligned, aligned + byte_len)` is within the buffer.
//
// 4. **Initialisation**: The region has been zero-filled, and `T: Copy`
// guarantees that an all-zeros bit pattern is a valid value (since
// `T: Default` is also required but zeroed memory is used).
//
// 5. **Lifetime**: The returned slice borrows `&self`, not the
// `RefMut` guards. We drop the guards before returning so that
// future calls to `alloc_slice` or `reset` can re-borrow. The
// pointer remains valid as long as `&self` is live because the
// backing `Vec` is not reallocated unless `alloc_slice` is called
// again (at which point the previous reference is no longer used
// by the caller in safe solver patterns).
//
// 6. **Send but not Sync**: The `unsafe impl Send` below is sound
// because `SolverArena` owns all its data. It is not `Sync`
// because `RefCell` does not support concurrent access.
drop(offset);
drop(buf);
unsafe { std::slice::from_raw_parts_mut(ptr, len) }
}
/// Reset the arena, reclaiming all allocations.
///
/// This does not free the backing memory; it simply resets the bump
/// pointer to zero. Subsequent allocations reuse the same buffer.
pub fn reset(&self) {
*self.offset.borrow_mut() = 0;
}
/// Number of bytes currently allocated (bump pointer position).
pub fn bytes_used(&self) -> usize {
*self.offset.borrow()
}
/// Total capacity of the backing buffer in bytes.
pub fn capacity(&self) -> usize {
self.buf.borrow().len()
}
}
// SAFETY: `SolverArena` is `Send` because it exclusively owns all its data
// (`Vec<u8>` inside a `RefCell`). Moving the arena to another thread is safe
// since no shared references can exist across threads.
//
// It is intentionally **not** `Sync` because `RefCell` does not support
// concurrent borrows. The compiler's auto-trait inference already prevents
// `Sync`, so no negative impl is needed.
unsafe impl Send for SolverArena {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alloc_and_reset() {
let arena = SolverArena::with_capacity(4096);
let s1: &mut [f64] = arena.alloc_slice(100);
assert_eq!(s1.len(), 100);
assert!(arena.bytes_used() >= 800);
let s2: &mut [f32] = arena.alloc_slice(50);
assert_eq!(s2.len(), 50);
arena.reset();
assert_eq!(arena.bytes_used(), 0);
}
#[test]
fn grows_when_needed() {
let arena = SolverArena::with_capacity(16);
let s: &mut [f64] = arena.alloc_slice(100);
assert_eq!(s.len(), 100);
assert!(arena.capacity() >= 800);
}
}

View File

@@ -0,0 +1,316 @@
//! 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);
}
}

View File

@@ -0,0 +1,693 @@
//! Backward Push solver for target-centric Personalized PageRank.
//!
//! The backward (reverse) push algorithm computes approximate PPR
//! contributions **to** a target vertex by propagating residual mass
//! backward along incoming edges (on the transpose of the adjacency
//! matrix). This is the dual of the Andersen-Chung-Lang (2006) Forward
//! Push algorithm.
//!
//! # Algorithm
//!
//! Maintain two vectors over all `n` vertices:
//! - `estimate[v]`: accumulated PPR contribution from `v` to the target.
//! - `residual[v]`: unprocessed mass waiting at `v`.
//!
//! Initially `residual[target] = 1`, everything else is zero.
//!
//! While any vertex `v` has `|residual[v]| / max(1, in_degree(v)) > epsilon`:
//! 1. Dequeue `v` from the active set.
//! 2. `estimate[v] += alpha * residual[v]`.
//! 3. For each in-neighbour `u` of `v` (edge `u -> v` in the original graph):
//! `residual[u] += (1 - alpha) * residual[v] / out_degree(v)`.
//! 4. `residual[v] = 0`.
//!
//! In-neighbours are obtained from the transposed adjacency matrix.
//!
//! # Complexity
//!
//! O(1 / (alpha * epsilon)) pushes total. Each push visits the in-neighbours
//! of one vertex. The queue-based design avoids scanning all `n` vertices
//! per push, achieving true sublinear time.
use std::collections::VecDeque;
use std::time::Instant;
use tracing::debug;
use crate::error::{SolverError, ValidationError};
use crate::traits::{SolverEngine, SublinearPageRank};
use crate::types::{
Algorithm, ComplexityClass, ComplexityEstimate, ComputeBudget, CsrMatrix, SolverResult,
SparsityProfile,
};
/// Maximum number of graph nodes to prevent OOM denial-of-service.
const MAX_GRAPH_NODES: usize = 100_000_000;
// ---------------------------------------------------------------------------
// Solver struct
// ---------------------------------------------------------------------------
/// Backward-push PPR solver.
///
/// Pushes probability mass backward along edges from target nodes.
/// Complementary to [`ForwardPushSolver`](crate::forward_push::ForwardPushSolver)
/// and often combined with it in bidirectional schemes.
///
/// # Example
///
/// ```rust,ignore
/// use ruvector_solver::backward_push::BackwardPushSolver;
/// use ruvector_solver::types::CsrMatrix;
///
/// let graph = CsrMatrix::<f64>::from_coo(3, 3, vec![
/// (0, 1, 1.0), (1, 2, 1.0), (2, 0, 1.0),
/// ]);
/// let solver = BackwardPushSolver::new(0.15, 1e-6);
/// let ppr = solver.ppr_to_target(&graph, 0).unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct BackwardPushSolver {
/// Teleportation probability (alpha). Must be in (0, 1).
pub alpha: f64,
/// Approximation tolerance (epsilon). Smaller values yield higher
/// accuracy at the cost of more push operations.
pub epsilon: f64,
}
impl BackwardPushSolver {
/// Create a new backward-push solver.
///
/// # Parameters
///
/// - `alpha`: teleportation probability in (0, 1). Typical: 0.15 or 0.2.
/// - `epsilon`: push threshold controlling accuracy vs speed.
pub fn new(alpha: f64, epsilon: f64) -> Self {
Self { alpha, epsilon }
}
/// Validate configuration parameters eagerly.
fn validate_params(alpha: f64, epsilon: f64) -> Result<(), SolverError> {
if alpha <= 0.0 || alpha >= 1.0 {
return Err(SolverError::InvalidInput(
ValidationError::ParameterOutOfRange {
name: "alpha".into(),
value: alpha.to_string(),
expected: "(0.0, 1.0) exclusive".into(),
},
));
}
if epsilon <= 0.0 {
return Err(SolverError::InvalidInput(
ValidationError::ParameterOutOfRange {
name: "epsilon".into(),
value: epsilon.to_string(),
expected: "> 0.0".into(),
},
));
}
Ok(())
}
/// Validate that the graph is square and the node index is in bounds.
fn validate_graph_node(
graph: &CsrMatrix<f64>,
node: usize,
name: &str,
) -> Result<(), SolverError> {
if graph.rows != graph.cols {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(format!(
"graph must be square, got {}x{}",
graph.rows, graph.cols,
)),
));
}
if node >= graph.rows {
return Err(SolverError::InvalidInput(
ValidationError::ParameterOutOfRange {
name: name.into(),
value: node.to_string(),
expected: format!("[0, {})", graph.rows),
},
));
}
Ok(())
}
/// Compute approximate PPR contributions **to** `target`.
///
/// Returns a sparse vector of `(vertex, ppr_value)` pairs sorted by
/// descending PPR value. Only vertices whose estimate exceeds 1e-15
/// are included.
pub fn ppr_to_target(
&self,
graph: &CsrMatrix<f64>,
target: usize,
) -> Result<Vec<(usize, f64)>, SolverError> {
Self::backward_push_core(
graph,
target,
self.alpha,
self.epsilon,
&ComputeBudget::default(),
)
}
/// Same as [`ppr_to_target`](Self::ppr_to_target) with an explicit budget.
pub fn ppr_to_target_with_budget(
&self,
graph: &CsrMatrix<f64>,
target: usize,
budget: &ComputeBudget,
) -> Result<Vec<(usize, f64)>, SolverError> {
Self::backward_push_core(graph, target, self.alpha, self.epsilon, budget)
}
/// Core backward push implementation.
///
/// Uses a FIFO queue so that each vertex is only re-scanned when its
/// residual has been increased above the threshold, giving O(1/(alpha*eps))
/// total pushes rather than O(n) scans per push.
fn backward_push_core(
graph: &CsrMatrix<f64>,
target: usize,
alpha: f64,
epsilon: f64,
budget: &ComputeBudget,
) -> Result<Vec<(usize, f64)>, SolverError> {
Self::validate_params(alpha, epsilon)?;
Self::validate_graph_node(graph, target, "target")?;
let start = Instant::now();
let n = graph.rows;
if n > MAX_GRAPH_NODES {
return Err(SolverError::InvalidInput(ValidationError::MatrixTooLarge {
rows: n,
cols: n,
max_dim: MAX_GRAPH_NODES,
}));
}
// Build the transposed adjacency so row_entries(v) in `graph_t`
// yields the in-neighbours of v in the original graph.
let graph_t = graph.transpose();
let mut estimate = vec![0.0f64; n];
let mut residual = vec![0.0f64; n];
// Seed: all mass starts at the target vertex.
residual[target] = 1.0;
// FIFO queue of vertices whose residual exceeds the push threshold.
let mut queue: VecDeque<usize> = VecDeque::with_capacity(n.min(1024));
let mut in_queue = vec![false; n];
queue.push_back(target);
in_queue[target] = true;
let mut pushes = 0usize;
let max_pushes = budget.max_iterations;
while let Some(v) = queue.pop_front() {
in_queue[v] = false;
let r_v = residual[v];
if r_v.abs() < 1e-15 {
continue;
}
// Check the push threshold: |r_v| / max(1, in_deg_t(v)) > epsilon.
let in_deg_t = graph_t.row_degree(v).max(1);
if r_v.abs() / in_deg_t as f64 <= epsilon {
continue;
}
// Budget enforcement.
pushes += 1;
if pushes > max_pushes {
return Err(SolverError::BudgetExhausted {
reason: format!("backward push exceeded {} push budget", max_pushes,),
elapsed: start.elapsed(),
});
}
if start.elapsed() > budget.max_time {
return Err(SolverError::BudgetExhausted {
reason: "wall-clock budget exceeded".into(),
elapsed: start.elapsed(),
});
}
// Absorb alpha fraction into the PPR estimate.
estimate[v] += alpha * r_v;
// Distribute (1 - alpha) * r_v backward along in-edges.
// The denominator is out_degree(v) in the original graph, which
// corresponds to row_degree(v) in `graph`.
let out_deg = graph.row_degree(v);
if out_deg == 0 {
// Dangling node: no outgoing edges; residual fully absorbed.
residual[v] = 0.0;
continue;
}
let push_mass = (1.0 - alpha) * r_v / out_deg as f64;
for (u, _weight) in graph_t.row_entries(v) {
residual[u] += push_mass;
// Enqueue u if it exceeds the push threshold and is not
// already queued.
let u_in_deg = graph_t.row_degree(u).max(1);
if residual[u].abs() / u_in_deg as f64 > epsilon && !in_queue[u] {
queue.push_back(u);
in_queue[u] = true;
}
}
residual[v] = 0.0;
}
debug!(
target: "ruvector_solver::backward_push",
pushes,
target,
elapsed_us = start.elapsed().as_micros() as u64,
"backward push converged",
);
// Collect non-zero estimates, sorted descending by PPR value.
let mut result: Vec<(usize, f64)> = estimate
.into_iter()
.enumerate()
.filter(|(_, val)| *val > 1e-15)
.collect();
result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(result)
}
}
// ---------------------------------------------------------------------------
// SolverEngine implementation
// ---------------------------------------------------------------------------
impl SolverEngine for BackwardPushSolver {
fn solve(
&self,
matrix: &CsrMatrix<f64>,
rhs: &[f64],
budget: &ComputeBudget,
) -> Result<SolverResult, SolverError> {
// For SolverEngine compatibility, interpret rhs as a target indicator
// vector: pick the node with the largest weight as the target.
let target = rhs
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
.unwrap_or(0);
let wall_start = Instant::now();
let ppr = self.ppr_to_target_with_budget(matrix, target, budget)?;
let mut solution = vec![0.0f32; matrix.rows];
for &(node, val) in &ppr {
solution[node] = val as f32;
}
Ok(SolverResult {
solution,
iterations: ppr.len(),
residual_norm: 0.0,
wall_time: wall_start.elapsed(),
convergence_history: Vec::new(),
algorithm: Algorithm::BackwardPush,
})
}
fn estimate_complexity(&self, _profile: &SparsityProfile, n: usize) -> ComplexityEstimate {
let est_pushes = (1.0 / (self.alpha * self.epsilon)) as usize;
ComplexityEstimate {
algorithm: Algorithm::BackwardPush,
estimated_flops: est_pushes as u64 * 10,
estimated_iterations: est_pushes,
estimated_memory_bytes: n * 16, // estimate + residual vectors
complexity_class: ComplexityClass::SublinearNnz,
}
}
fn algorithm(&self) -> Algorithm {
Algorithm::BackwardPush
}
}
// ---------------------------------------------------------------------------
// SublinearPageRank implementation
// ---------------------------------------------------------------------------
impl SublinearPageRank for BackwardPushSolver {
fn ppr(
&self,
matrix: &CsrMatrix<f64>,
target: usize,
alpha: f64,
epsilon: f64,
) -> Result<Vec<(usize, f64)>, SolverError> {
Self::backward_push_core(matrix, target, alpha, epsilon, &ComputeBudget::default())
}
fn ppr_multi_seed(
&self,
matrix: &CsrMatrix<f64>,
seeds: &[(usize, f64)],
alpha: f64,
epsilon: f64,
) -> Result<Vec<(usize, f64)>, SolverError> {
let n = matrix.rows;
for &(node, _) in seeds {
Self::validate_graph_node(matrix, node, "seed")?;
}
// Build transposed graph once and reuse across all seeds.
let graph_t = matrix.transpose();
let mut combined = vec![0.0f64; n];
for &(seed, weight) in seeds {
// Run backward push for each seed target. We inline the core
// logic with the shared transpose to avoid rebuilding it.
let ppr = backward_push_with_transpose(
matrix,
&graph_t,
seed,
alpha,
epsilon,
&ComputeBudget::default(),
)?;
for &(node, val) in &ppr {
combined[node] += weight * val;
}
}
let mut result: Vec<(usize, f64)> = combined
.into_iter()
.enumerate()
.filter(|(_, val)| *val > 1e-15)
.collect();
result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(result)
}
}
/// Internal helper: backward push using a pre-computed transpose.
///
/// Avoids re-transposing for multi-seed queries.
fn backward_push_with_transpose(
graph: &CsrMatrix<f64>,
graph_t: &CsrMatrix<f64>,
target: usize,
alpha: f64,
epsilon: f64,
budget: &ComputeBudget,
) -> Result<Vec<(usize, f64)>, SolverError> {
let start = Instant::now();
let n = graph.rows;
let mut estimate = vec![0.0f64; n];
let mut residual = vec![0.0f64; n];
residual[target] = 1.0;
let mut queue: VecDeque<usize> = VecDeque::with_capacity(n.min(1024));
let mut in_queue = vec![false; n];
queue.push_back(target);
in_queue[target] = true;
let mut pushes = 0usize;
let max_pushes = budget.max_iterations;
while let Some(v) = queue.pop_front() {
in_queue[v] = false;
let r_v = residual[v];
if r_v.abs() < 1e-15 {
continue;
}
let in_deg_t = graph_t.row_degree(v).max(1);
if r_v.abs() / in_deg_t as f64 <= epsilon {
continue;
}
pushes += 1;
if pushes > max_pushes {
return Err(SolverError::BudgetExhausted {
reason: format!("backward push exceeded {} push budget", max_pushes,),
elapsed: start.elapsed(),
});
}
if start.elapsed() > budget.max_time {
return Err(SolverError::BudgetExhausted {
reason: "wall-clock budget exceeded".into(),
elapsed: start.elapsed(),
});
}
estimate[v] += alpha * r_v;
let out_deg = graph.row_degree(v);
if out_deg == 0 {
residual[v] = 0.0;
continue;
}
let push_mass = (1.0 - alpha) * r_v / out_deg as f64;
for (u, _weight) in graph_t.row_entries(v) {
residual[u] += push_mass;
let u_in_deg = graph_t.row_degree(u).max(1);
if residual[u].abs() / u_in_deg as f64 > epsilon && !in_queue[u] {
queue.push_back(u);
in_queue[u] = true;
}
}
residual[v] = 0.0;
}
let mut result: Vec<(usize, f64)> = estimate
.into_iter()
.enumerate()
.filter(|(_, val)| *val > 1e-15)
.collect();
result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(result)
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Build a directed cycle 0->1->2->...->n-1->0.
fn directed_cycle(n: usize) -> CsrMatrix<f64> {
let entries: Vec<_> = (0..n).map(|i| (i, (i + 1) % n, 1.0f64)).collect();
CsrMatrix::<f64>::from_coo(n, n, entries)
}
/// Build a star graph with edges i->0 for i in 1..n.
fn star_to_center(n: usize) -> CsrMatrix<f64> {
let entries: Vec<_> = (1..n).map(|i| (i, 0, 1.0f64)).collect();
CsrMatrix::<f64>::from_coo(n, n, entries)
}
/// Build a complete graph on n vertices (every pair connected).
fn complete_graph(n: usize) -> CsrMatrix<f64> {
let mut entries = Vec::new();
for i in 0..n {
for j in 0..n {
if i != j {
entries.push((i, j, 1.0f64));
}
}
}
CsrMatrix::<f64>::from_coo(n, n, entries)
}
#[test]
fn single_node_no_edges() {
let graph = CsrMatrix::<f64>::from_coo(1, 1, Vec::<(usize, usize, f64)>::new());
let solver = BackwardPushSolver::new(0.15, 1e-6);
let result = solver.ppr_to_target(&graph, 0).unwrap();
// Dangling node: estimate[0] = alpha * 1.0 = 0.15.
assert_eq!(result.len(), 1);
assert!((result[0].1 - 0.15).abs() < 1e-10);
}
#[test]
fn directed_cycle_all_vertices_contribute() {
let graph = directed_cycle(3);
let solver = BackwardPushSolver::new(0.2, 1e-8);
let result = solver.ppr_to_target(&graph, 0).unwrap();
let total: f64 = result.iter().map(|(_, v)| v).sum();
assert!(total <= 1.0 + 1e-6, "total PPR = {}", total);
assert!(total > 0.1, "total too small: {}", total);
assert!(result.len() >= 2);
}
#[test]
fn star_graph_center_highest_ppr() {
let graph = star_to_center(5);
let solver = BackwardPushSolver::new(0.15, 1e-8);
let result = solver.ppr_to_target(&graph, 0).unwrap();
let ppr_0 = result
.iter()
.find(|&&(v, _)| v == 0)
.map(|&(_, p)| p)
.unwrap_or(0.0);
for &(v, p) in &result {
if v != 0 {
assert!(ppr_0 >= p, "expected ppr[0]={} >= ppr[{}]={}", ppr_0, v, p,);
}
}
}
#[test]
fn complete_graph_uniform_ppr() {
// On a complete graph, by symmetry PPR should be approximately
// uniform for non-target vertices.
let graph = complete_graph(5);
let solver = BackwardPushSolver::new(0.15, 1e-8);
let result = solver.ppr_to_target(&graph, 0).unwrap();
// All vertices should be represented.
assert!(result.len() >= 4);
let total: f64 = result.iter().map(|(_, v)| v).sum();
assert!(total > 0.5 && total <= 1.0 + 1e-6);
}
#[test]
fn rejects_non_square_graph() {
let graph = CsrMatrix::<f64>::from_coo(2, 3, vec![(0, 1, 1.0f64)]);
let solver = BackwardPushSolver::new(0.15, 1e-6);
assert!(solver.ppr_to_target(&graph, 0).is_err());
}
#[test]
fn rejects_out_of_bounds_target() {
let graph = CsrMatrix::<f64>::from_coo(3, 3, vec![(0, 1, 1.0f64)]);
let solver = BackwardPushSolver::new(0.15, 1e-6);
assert!(solver.ppr_to_target(&graph, 5).is_err());
}
#[test]
fn rejects_bad_alpha() {
let graph = CsrMatrix::<f64>::from_coo(3, 3, vec![(0, 1, 1.0f64)]);
let zero_alpha = BackwardPushSolver::new(0.0, 1e-6);
assert!(zero_alpha.ppr_to_target(&graph, 0).is_err());
let one_alpha = BackwardPushSolver::new(1.0, 1e-6);
assert!(one_alpha.ppr_to_target(&graph, 0).is_err());
let neg_alpha = BackwardPushSolver::new(-0.5, 1e-6);
assert!(neg_alpha.ppr_to_target(&graph, 0).is_err());
}
#[test]
fn rejects_bad_epsilon() {
let graph = CsrMatrix::<f64>::from_coo(3, 3, vec![(0, 1, 1.0f64)]);
let zero_eps = BackwardPushSolver::new(0.15, 0.0);
assert!(zero_eps.ppr_to_target(&graph, 0).is_err());
let neg_eps = BackwardPushSolver::new(0.15, -1e-6);
assert!(neg_eps.ppr_to_target(&graph, 0).is_err());
}
#[test]
fn solver_engine_trait_integration() {
let graph = directed_cycle(4);
let solver = BackwardPushSolver::new(0.15, 1e-6);
let rhs = vec![0.0, 0.0, 1.0, 0.0]; // node 2 is the target
let result = solver
.solve(&graph, &rhs, &ComputeBudget::default())
.unwrap();
assert_eq!(result.algorithm, Algorithm::BackwardPush);
assert!(!result.solution.is_empty());
}
#[test]
fn sublinear_pagerank_trait_ppr() {
let graph = directed_cycle(5);
let solver = BackwardPushSolver::new(0.15, 1e-6);
let result = solver.ppr(&graph, 0, 0.15, 1e-6).unwrap();
assert!(!result.is_empty());
let total: f64 = result.iter().map(|(_, v)| v).sum();
assert!(total <= 1.0 + 1e-6);
}
#[test]
fn multi_seed_combines_correctly() {
let graph = directed_cycle(4);
let solver = BackwardPushSolver::new(0.15, 1e-6);
let seeds = vec![(0, 0.5), (2, 0.5)];
let result = solver.ppr_multi_seed(&graph, &seeds, 0.15, 1e-6).unwrap();
assert!(!result.is_empty());
}
#[test]
fn converges_on_100_node_cycle() {
let graph = directed_cycle(100);
let solver = BackwardPushSolver::new(0.15, 1e-6);
let result = solver.ppr_to_target(&graph, 50).unwrap();
let total: f64 = result.iter().map(|(_, v)| v).sum();
assert!(total > 0.0 && total <= 1.0 + 1e-6);
}
#[test]
fn transpose_correctness() {
let graph =
CsrMatrix::<f64>::from_coo(3, 3, vec![(0, 1, 1.0f64), (1, 2, 1.0f64), (2, 0, 1.0f64)]);
let gt = graph.transpose();
// Transposed row 1 should contain (0, 1.0) because 0->1 in original.
let r1: Vec<_> = gt.row_entries(1).collect();
assert_eq!(r1.len(), 1);
assert_eq!(*r1[0].1, 1.0f64);
assert_eq!(r1[0].0, 0);
}
#[test]
fn estimate_complexity_reports_sublinear() {
let solver = BackwardPushSolver::new(0.15, 1e-4);
let profile = SparsityProfile {
rows: 1000,
cols: 1000,
nnz: 5000,
density: 0.005,
is_diag_dominant: false,
estimated_spectral_radius: 0.9,
estimated_condition: 10.0,
is_symmetric_structure: false,
avg_nnz_per_row: 5.0,
max_nnz_per_row: 10,
};
let est = solver.estimate_complexity(&profile, 1000);
assert_eq!(est.algorithm, Algorithm::BackwardPush);
assert_eq!(est.complexity_class, ComplexityClass::SublinearNnz);
assert!(est.estimated_iterations > 0);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
//! Compute budget enforcement for solver operations.
//!
//! [`BudgetEnforcer`] tracks wall-clock time, iteration count, and memory
//! allocation against a [`ComputeBudget`]. Solvers call
//! [`check_iteration`](BudgetEnforcer::check_iteration) at the top of each
//! iteration loop and
//! [`check_memory`](BudgetEnforcer::check_memory) before any allocation that
//! could exceed the memory ceiling.
//!
//! Budget violations are reported as [`SolverError::BudgetExhausted`] with a
//! human-readable reason describing which limit was hit.
use std::time::Instant;
use crate::error::SolverError;
use crate::types::ComputeBudget;
/// Default memory ceiling when none is specified (256 MiB).
const DEFAULT_MEMORY_LIMIT: usize = 256 * 1024 * 1024;
/// Enforces wall-time, iteration, and memory budgets during a solve.
///
/// Create one at the start of a solve and call the `check_*` methods at each
/// iteration or before allocating scratch space. The enforcer is intentionally
/// non-`Clone` so that each solve owns exactly one.
///
/// # Example
///
/// ```
/// use ruvector_solver::budget::BudgetEnforcer;
/// use ruvector_solver::types::ComputeBudget;
///
/// let budget = ComputeBudget::default();
/// let mut enforcer = BudgetEnforcer::new(budget);
///
/// // At the top of each solver iteration:
/// enforcer.check_iteration().unwrap();
///
/// // Before allocating scratch memory:
/// enforcer.check_memory(1024).unwrap();
/// ```
pub struct BudgetEnforcer {
/// Monotonic clock snapshot taken when the enforcer was created.
start_time: Instant,
/// The budget limits to enforce.
budget: ComputeBudget,
/// Number of iterations consumed so far.
iterations_used: usize,
/// Cumulative memory allocated (tracked by the caller, not measured).
memory_used: usize,
/// Maximum memory allowed. Defaults to [`DEFAULT_MEMORY_LIMIT`] if
/// the `ComputeBudget` does not carry a memory field.
memory_limit: usize,
}
impl BudgetEnforcer {
/// Create a new enforcer with the given budget.
///
/// The wall-clock timer starts immediately.
pub fn new(budget: ComputeBudget) -> Self {
Self {
start_time: Instant::now(),
budget,
iterations_used: 0,
memory_used: 0,
memory_limit: DEFAULT_MEMORY_LIMIT,
}
}
/// Create an enforcer with a custom memory ceiling.
///
/// Use this when the caller knows the available memory and wants to
/// enforce a tighter or looser bound than the default 256 MiB.
pub fn with_memory_limit(budget: ComputeBudget, memory_limit: usize) -> Self {
Self {
start_time: Instant::now(),
budget,
iterations_used: 0,
memory_used: 0,
memory_limit,
}
}
/// Check whether the next iteration is within budget.
///
/// Must be called **once per iteration**, at the top of the loop body.
/// Increments the internal iteration counter and checks both the iteration
/// limit and the wall-clock time limit.
///
/// # Errors
///
/// Returns [`SolverError::BudgetExhausted`] if either the iteration count
/// or wall-clock time has been exceeded.
pub fn check_iteration(&mut self) -> Result<(), SolverError> {
self.iterations_used += 1;
// Iteration budget
if self.iterations_used > self.budget.max_iterations {
return Err(SolverError::BudgetExhausted {
reason: format!(
"iteration limit reached ({} > {})",
self.iterations_used, self.budget.max_iterations,
),
elapsed: self.start_time.elapsed(),
});
}
// Wall-clock budget
let elapsed = self.start_time.elapsed();
if elapsed > self.budget.max_time {
return Err(SolverError::BudgetExhausted {
reason: format!(
"wall-clock time limit reached ({:.2?} > {:.2?})",
elapsed, self.budget.max_time,
),
elapsed,
});
}
Ok(())
}
/// Check whether an additional memory allocation is within budget.
///
/// Call this **before** performing the allocation. The `additional` parameter
/// is the number of bytes the caller intends to allocate. If the allocation
/// would push cumulative usage over the memory ceiling, the call fails
/// without modifying the internal counter.
///
/// On success the internal counter is incremented by `additional`.
///
/// # Errors
///
/// Returns [`SolverError::BudgetExhausted`] if the allocation would exceed
/// the memory limit.
pub fn check_memory(&mut self, additional: usize) -> Result<(), SolverError> {
let new_total = self.memory_used.saturating_add(additional);
if new_total > self.memory_limit {
return Err(SolverError::BudgetExhausted {
reason: format!(
"memory limit reached ({} + {} = {} > {} bytes)",
self.memory_used, additional, new_total, self.memory_limit,
),
elapsed: self.start_time.elapsed(),
});
}
self.memory_used = new_total;
Ok(())
}
/// Wall-clock microseconds elapsed since the enforcer was created.
#[inline]
pub fn elapsed_us(&self) -> u64 {
self.start_time.elapsed().as_micros() as u64
}
/// Wall-clock duration elapsed since the enforcer was created.
#[inline]
pub fn elapsed(&self) -> std::time::Duration {
self.start_time.elapsed()
}
/// Number of iterations consumed so far.
#[inline]
pub fn iterations_used(&self) -> usize {
self.iterations_used
}
/// Cumulative memory tracked so far (in bytes).
#[inline]
pub fn memory_used(&self) -> usize {
self.memory_used
}
/// The tolerance target from the budget (convenience accessor).
#[inline]
pub fn tolerance(&self) -> f64 {
self.budget.tolerance
}
/// A reference to the underlying budget configuration.
#[inline]
pub fn budget(&self) -> &ComputeBudget {
&self.budget
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ComputeBudget;
use std::time::Duration;
fn tiny_budget() -> ComputeBudget {
ComputeBudget {
max_time: Duration::from_secs(60),
max_iterations: 5,
tolerance: 1e-6,
}
}
#[test]
fn iterations_within_budget() {
let mut enforcer = BudgetEnforcer::new(tiny_budget());
for _ in 0..5 {
enforcer.check_iteration().unwrap();
}
assert_eq!(enforcer.iterations_used(), 5);
}
#[test]
fn iteration_limit_exceeded() {
let mut enforcer = BudgetEnforcer::new(tiny_budget());
for _ in 0..5 {
enforcer.check_iteration().unwrap();
}
// 6th iteration should fail
let err = enforcer.check_iteration().unwrap_err();
match err {
SolverError::BudgetExhausted { ref reason, .. } => {
assert!(reason.contains("iteration"), "reason: {reason}");
}
other => panic!("expected BudgetExhausted, got {other:?}"),
}
}
#[test]
fn wall_clock_limit_exceeded() {
let budget = ComputeBudget {
max_time: Duration::from_nanos(1), // Impossibly short
max_iterations: 1_000_000,
tolerance: 1e-6,
};
let mut enforcer = BudgetEnforcer::new(budget);
// Burn a tiny bit of time so Instant::now() moves forward
std::thread::sleep(Duration::from_micros(10));
let err = enforcer.check_iteration().unwrap_err();
match err {
SolverError::BudgetExhausted { ref reason, .. } => {
assert!(reason.contains("wall-clock"), "reason: {reason}");
}
other => panic!("expected BudgetExhausted for time, got {other:?}"),
}
}
#[test]
fn memory_within_budget() {
let mut enforcer = BudgetEnforcer::with_memory_limit(tiny_budget(), 1024);
enforcer.check_memory(512).unwrap();
enforcer.check_memory(512).unwrap();
assert_eq!(enforcer.memory_used(), 1024);
}
#[test]
fn memory_limit_exceeded() {
let mut enforcer = BudgetEnforcer::with_memory_limit(tiny_budget(), 1024);
enforcer.check_memory(800).unwrap();
let err = enforcer.check_memory(300).unwrap_err();
match err {
SolverError::BudgetExhausted { ref reason, .. } => {
assert!(reason.contains("memory"), "reason: {reason}");
}
other => panic!("expected BudgetExhausted for memory, got {other:?}"),
}
// Memory should not have been incremented on failure
assert_eq!(enforcer.memory_used(), 800);
}
#[test]
fn memory_saturating_add_no_panic() {
// Use a limit smaller than usize::MAX so that saturation triggers an error.
let limit = usize::MAX / 2;
let mut enforcer = BudgetEnforcer::with_memory_limit(tiny_budget(), limit);
enforcer.check_memory(limit - 1).unwrap();
// Adding another large amount should saturate to usize::MAX which exceeds the limit.
let err = enforcer.check_memory(usize::MAX).unwrap_err();
assert!(matches!(err, SolverError::BudgetExhausted { .. }));
}
#[test]
fn elapsed_us_positive() {
let enforcer = BudgetEnforcer::new(tiny_budget());
// Just ensure it does not panic; the value may be 0 on fast machines.
let _ = enforcer.elapsed_us();
}
#[test]
fn tolerance_accessor() {
let enforcer = BudgetEnforcer::new(tiny_budget());
assert!((enforcer.tolerance() - 1e-6).abs() < f64::EPSILON);
}
#[test]
fn budget_accessor() {
let budget = tiny_budget();
let enforcer = BudgetEnforcer::new(budget.clone());
assert_eq!(enforcer.budget().max_iterations, 5);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,120 @@
//! Error types for the solver crate.
//!
//! Provides structured error variants for convergence failures, numerical
//! instabilities, budget overruns, and invalid inputs. All errors implement
//! `std::error::Error` via `thiserror`.
use std::time::Duration;
use crate::types::Algorithm;
/// Primary error type for solver operations.
#[derive(Debug, thiserror::Error)]
pub enum SolverError {
/// The iterative solver did not converge within the allowed iteration budget.
#[error(
"solver did not converge after {iterations} iterations (residual={residual:.2e}, tol={tolerance:.2e})"
)]
NonConvergence {
/// Number of iterations completed before the budget was exhausted.
iterations: usize,
/// Final residual norm at termination.
residual: f64,
/// Target tolerance that was not reached.
tolerance: f64,
},
/// A numerical instability was detected (NaN, Inf, or loss of precision).
#[error("numerical instability at iteration {iteration}: {detail}")]
NumericalInstability {
/// Iteration at which the instability was detected.
iteration: usize,
/// Human-readable explanation.
detail: String,
},
/// The compute budget (wall-time, iterations, or memory) was exhausted.
#[error("compute budget exhausted: {reason}")]
BudgetExhausted {
/// Which budget limit was hit.
reason: String,
/// Wall-clock time elapsed before the budget was hit.
elapsed: Duration,
},
/// The caller supplied invalid input (dimensions, parameters, etc.).
#[error("invalid input: {0}")]
InvalidInput(#[from] ValidationError),
/// The matrix spectral radius exceeds the threshold required by the algorithm.
#[error(
"spectral radius {spectral_radius:.4} exceeds limit {limit:.4} for algorithm {algorithm}"
)]
SpectralRadiusExceeded {
/// Estimated spectral radius of the iteration matrix.
spectral_radius: f64,
/// Maximum spectral radius the algorithm tolerates.
limit: f64,
/// Algorithm that detected the violation.
algorithm: Algorithm,
},
/// A backend-specific error (e.g. nalgebra or BLAS).
#[error("backend error: {0}")]
BackendError(String),
}
/// Validation errors for solver inputs.
///
/// These are raised eagerly before any computation begins so that callers get
/// clear diagnostics rather than mysterious numerical failures.
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
/// Matrix dimensions are inconsistent (e.g. row_ptrs length vs rows).
#[error("dimension mismatch: {0}")]
DimensionMismatch(String),
/// A value is NaN or infinite where a finite number is required.
#[error("non-finite value detected: {0}")]
NonFiniteValue(String),
/// A column index is out of bounds for the declared number of columns.
#[error("column index {index} out of bounds for {cols} columns (row {row})")]
IndexOutOfBounds {
/// Offending column index.
index: u32,
/// Row containing the offending entry.
row: usize,
/// Declared column count.
cols: usize,
},
/// The `row_ptrs` array is not monotonically non-decreasing.
#[error("row_ptrs is not monotonically non-decreasing at position {position}")]
NonMonotonicRowPtrs {
/// Position in `row_ptrs` where the violation was detected.
position: usize,
},
/// A parameter is outside its valid range.
#[error("parameter out of range: {name} = {value} (expected {expected})")]
ParameterOutOfRange {
/// Name of the parameter.
name: String,
/// The invalid value (as a string for flexibility).
value: String,
/// Human-readable description of the valid range.
expected: String,
},
/// Matrix size exceeds the implementation limit.
#[error("matrix size {rows}x{cols} exceeds maximum supported {max_dim}x{max_dim}")]
MatrixTooLarge {
/// Number of rows.
rows: usize,
/// Number of columns.
cols: usize,
/// Maximum supported dimension.
max_dim: usize,
},
}

View File

@@ -0,0 +1,86 @@
//! Event sourcing for solver operations.
//!
//! Every solver emits [`SolverEvent`]s to an event log, enabling full
//! observability of the solve pipeline: what was requested, how many
//! iterations ran, whether convergence was reached, and whether fallback
//! algorithms were invoked.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::types::{Algorithm, ComputeLane};
/// Events emitted during a solver invocation.
///
/// Events are tagged with `#[serde(tag = "type")]` so they serialise as
/// `{ "type": "SolveRequested", ... }` for easy ingestion into event stores.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SolverEvent {
/// A solve request was received and is about to begin.
SolveRequested {
/// Algorithm that will be attempted first.
algorithm: Algorithm,
/// Matrix dimension (number of rows).
matrix_rows: usize,
/// Number of non-zeros.
matrix_nnz: usize,
/// Compute lane.
lane: ComputeLane,
},
/// One iteration of the solver completed.
IterationCompleted {
/// Iteration number (0-indexed).
iteration: usize,
/// Current residual norm.
residual: f64,
/// Wall time elapsed since the solve began.
elapsed: Duration,
},
/// The solver converged successfully.
SolveConverged {
/// Algorithm that produced the result.
algorithm: Algorithm,
/// Total iterations executed.
iterations: usize,
/// Final residual norm.
residual: f64,
/// Total wall time.
wall_time: Duration,
},
/// The solver fell back from one algorithm to another (e.g. Neumann
/// series spectral radius too high, falling back to CG).
AlgorithmFallback {
/// Algorithm that failed or was deemed unsuitable.
from: Algorithm,
/// Algorithm that will be tried next.
to: Algorithm,
/// Human-readable reason for the fallback.
reason: String,
},
/// The compute budget was exhausted before convergence.
BudgetExhausted {
/// Algorithm that was running when the budget was hit.
algorithm: Algorithm,
/// Which budget limit was hit.
limit: BudgetLimit,
/// Wall time elapsed.
elapsed: Duration,
},
}
/// Which budget limit was exhausted.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum BudgetLimit {
/// Wall-clock time limit.
WallTime,
/// Iteration count limit.
Iterations,
/// Memory allocation limit.
Memory,
}

View File

@@ -0,0 +1,808 @@
//! Forward Push solver for Personalized PageRank (Andersen-Chung-Lang).
//!
//! Computes approximate PPR from a single source vertex in O(1/epsilon) time,
//! independent of graph size. The algorithm maintains two sparse vectors:
//!
//! - **estimate**: accumulated PPR values (the output).
//! - **residual**: probability mass yet to be distributed.
//!
//! At each step a vertex whose residual exceeds `epsilon * degree(u)` is
//! popped from a work-queue and its mass is "pushed" to its neighbours.
//!
//! # References
//!
//! Andersen, Chung, Lang. *Local Graph Partitioning using PageRank Vectors.*
//! FOCS 2006.
use std::collections::VecDeque;
use crate::error::SolverError;
use crate::traits::{SolverEngine, SublinearPageRank};
use crate::types::{
Algorithm, ComplexityClass, ComplexityEstimate, ComputeBudget, CsrMatrix, SolverResult,
SparsityProfile,
};
// ---------------------------------------------------------------------------
// ForwardPushSolver
// ---------------------------------------------------------------------------
/// Forward Push solver for Personalized PageRank.
///
/// Given a graph encoded as a `CsrMatrix<f64>` (adjacency list in CSR
/// format), computes the PPR vector from a single source vertex.
///
/// # Parameters
///
/// - `alpha` -- teleport probability (fraction absorbed per push).
/// Default: `0.85`.
/// - `epsilon` -- push threshold. Vertices with
/// `residual[u] > epsilon * degree(u)` are eligible for a push. Smaller
/// values yield more accurate results at the cost of more work.
///
/// # Complexity
///
/// O(1 / epsilon) pushes in total, independent of |V| or |E|.
#[derive(Debug, Clone)]
pub struct ForwardPushSolver {
/// Teleportation probability (alpha).
pub alpha: f64,
/// Approximation tolerance (epsilon).
pub epsilon: f64,
}
impl ForwardPushSolver {
/// Create a new forward-push solver.
///
/// Parameters are validated lazily at the start of each computation
/// (see [`validate_params`](Self::validate_params)).
pub fn new(alpha: f64, epsilon: f64) -> Self {
Self { alpha, epsilon }
}
/// Validate that `alpha` and `epsilon` are within acceptable ranges.
///
/// # Errors
///
/// - [`SolverError::InvalidInput`] if `alpha` is not in `(0, 1)` exclusive.
/// - [`SolverError::InvalidInput`] if `epsilon` is not positive.
fn validate_params(&self) -> Result<(), SolverError> {
if self.alpha <= 0.0 || self.alpha >= 1.0 {
return Err(SolverError::InvalidInput(
crate::error::ValidationError::ParameterOutOfRange {
name: "alpha".into(),
value: self.alpha.to_string(),
expected: "(0.0, 1.0) exclusive".into(),
},
));
}
if self.epsilon <= 0.0 {
return Err(SolverError::InvalidInput(
crate::error::ValidationError::ParameterOutOfRange {
name: "epsilon".into(),
value: self.epsilon.to_string(),
expected: "> 0.0".into(),
},
));
}
Ok(())
}
/// Create a solver with default parameters (`alpha = 0.85`,
/// `epsilon = 1e-6`).
pub fn default_params() -> Self {
Self {
alpha: 0.85,
epsilon: 1e-6,
}
}
/// Compute PPR from `source` returning sparse `(vertex, score)` pairs
/// sorted by score descending.
///
/// # Errors
///
/// - [`SolverError::InvalidInput`] if `source >= graph.rows`.
/// - [`SolverError::NumericalInstability`] if the mass invariant is
/// violated after convergence.
pub fn ppr_from_source(
&self,
graph: &CsrMatrix<f64>,
source: usize,
) -> Result<Vec<(usize, f64)>, SolverError> {
self.validate_params()?;
validate_vertex(graph, source, "source")?;
self.forward_push_core(graph, &[(source, 1.0)])
}
/// Compute PPR from `source` and return only the top-`k` entries.
///
/// Convenience wrapper around [`ppr_from_source`](Self::ppr_from_source).
pub fn top_k(
&self,
graph: &CsrMatrix<f64>,
source: usize,
k: usize,
) -> Result<Vec<(usize, f64)>, SolverError> {
let mut result = self.ppr_from_source(graph, source)?;
result.truncate(k);
Ok(result)
}
// -----------------------------------------------------------------------
// Core push loop (Andersen-Chung-Lang)
// -----------------------------------------------------------------------
/// Run the forward push from a (possibly multi-seed) initial residual
/// distribution.
///
/// Uses a `VecDeque` work-queue with a membership bitvec to achieve
/// O(1/epsilon) total work, independent of graph size.
/// Maximum number of graph nodes to prevent OOM DoS.
const MAX_GRAPH_NODES: usize = 100_000_000;
fn forward_push_core(
&self,
graph: &CsrMatrix<f64>,
seeds: &[(usize, f64)],
) -> Result<Vec<(usize, f64)>, SolverError> {
self.validate_params()?;
let n = graph.rows;
if n > Self::MAX_GRAPH_NODES {
return Err(SolverError::InvalidInput(
crate::error::ValidationError::MatrixTooLarge {
rows: n,
cols: graph.cols,
max_dim: Self::MAX_GRAPH_NODES,
},
));
}
let mut estimate = vec![0.0f64; n];
let mut residual = vec![0.0f64; n];
// BFS-style work-queue with a membership bitvec.
let mut in_queue = vec![false; n];
let mut queue: VecDeque<usize> = VecDeque::new();
// Initialise residuals from seed distribution.
for &(v, mass) in seeds {
residual[v] += mass;
if !in_queue[v] && should_push(residual[v], graph.row_degree(v), self.epsilon) {
queue.push_back(v);
in_queue[v] = true;
}
}
// ----- Main push loop -----
while let Some(u) = queue.pop_front() {
in_queue[u] = false;
let r_u = residual[u];
// Re-check: the residual may have decayed since enqueue.
if !should_push(r_u, graph.row_degree(u), self.epsilon) {
continue;
}
// Absorb alpha fraction into the estimate.
estimate[u] += self.alpha * r_u;
let degree = graph.row_degree(u);
if degree > 0 {
let push_amount = (1.0 - self.alpha) * r_u / degree as f64;
// Zero out the residual at u BEFORE distributing to
// neighbours. This is critical for self-loops: if u has an
// edge to itself, the push_amount added via the self-loop
// must not be overwritten.
residual[u] = 0.0;
for (v, _weight) in graph.row_entries(u) {
residual[v] += push_amount;
if !in_queue[v] && should_push(residual[v], graph.row_degree(v), self.epsilon) {
queue.push_back(v);
in_queue[v] = true;
}
}
} else {
// Dangling vertex (degree 0): the (1-alpha) fraction cannot
// be distributed to neighbours. Keep it in the residual so
// the mass invariant is preserved. Re-enqueue if the
// leftover still exceeds the push threshold, which will
// converge geometrically since each push multiplies the
// residual by (1-alpha).
let leftover = (1.0 - self.alpha) * r_u;
residual[u] = leftover;
if !in_queue[u] && should_push(leftover, 0, self.epsilon) {
queue.push_back(u);
in_queue[u] = true;
}
}
}
// Mass invariant: sum(estimate) + sum(residual) must approximate the
// total initial mass.
let total_seed_mass: f64 = seeds.iter().map(|(_, m)| *m).sum();
check_mass_invariant(&estimate, &residual, total_seed_mass)?;
// Collect non-zero estimates into a sparse result, sorted descending.
let mut result: Vec<(usize, f64)> = estimate
.iter()
.enumerate()
.filter(|(_, val)| **val > 0.0)
.map(|(i, val)| (i, *val))
.collect();
result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(result)
}
}
/// Compute the estimate and residual vectors simultaneously.
///
/// Returns `(estimate, residual)` as dense `Vec<f64>` for use by hybrid
/// random-walk algorithms that need to inspect residuals.
pub fn forward_push_with_residuals(
matrix: &CsrMatrix<f64>,
source: usize,
alpha: f64,
epsilon: f64,
) -> Result<(Vec<f64>, Vec<f64>), SolverError> {
validate_vertex(matrix, source, "source")?;
let n = matrix.rows;
let mut estimate = vec![0.0f64; n];
let mut residual = vec![0.0f64; n];
residual[source] = 1.0;
let mut in_queue = vec![false; n];
let mut queue: VecDeque<usize> = VecDeque::new();
if should_push(1.0, matrix.row_degree(source), epsilon) {
queue.push_back(source);
in_queue[source] = true;
}
while let Some(u) = queue.pop_front() {
in_queue[u] = false;
let r_u = residual[u];
if !should_push(r_u, matrix.row_degree(u), epsilon) {
continue;
}
estimate[u] += alpha * r_u;
let degree = matrix.row_degree(u);
if degree > 0 {
let push_amount = (1.0 - alpha) * r_u / degree as f64;
// Zero before distributing (self-loop safety).
residual[u] = 0.0;
for (v, _) in matrix.row_entries(u) {
residual[v] += push_amount;
if !in_queue[v] && should_push(residual[v], matrix.row_degree(v), epsilon) {
queue.push_back(v);
in_queue[v] = true;
}
}
} else {
// Dangling vertex: keep (1-alpha) portion as residual.
let leftover = (1.0 - alpha) * r_u;
residual[u] = leftover;
if !in_queue[u] && should_push(leftover, 0, epsilon) {
queue.push_back(u);
in_queue[u] = true;
}
}
}
Ok((estimate, residual))
}
// ---------------------------------------------------------------------------
// Free-standing helpers
// ---------------------------------------------------------------------------
/// Whether a vertex with the given `residual` and `degree` should be pushed.
///
/// For isolated vertices (degree 0) we use a fallback threshold of `epsilon`
/// to avoid infinite loops while still absorbing meaningful residual.
#[inline]
fn should_push(residual: f64, degree: usize, epsilon: f64) -> bool {
if degree == 0 {
residual > epsilon
} else {
residual > epsilon * degree as f64
}
}
/// Validate that a vertex index is within bounds.
fn validate_vertex(graph: &CsrMatrix<f64>, vertex: usize, name: &str) -> Result<(), SolverError> {
if vertex >= graph.rows {
return Err(SolverError::InvalidInput(
crate::error::ValidationError::ParameterOutOfRange {
name: name.into(),
value: vertex.to_string(),
expected: format!("0..{}", graph.rows),
},
));
}
Ok(())
}
/// Verify the mass invariant: `sum(estimate) + sum(residual) ~ expected`.
fn check_mass_invariant(
estimate: &[f64],
residual: &[f64],
expected_mass: f64,
) -> Result<(), SolverError> {
let mass: f64 = estimate.iter().sum::<f64>() + residual.iter().sum::<f64>();
if (mass - expected_mass).abs() > 1e-6 {
return Err(SolverError::NumericalInstability {
iteration: 0,
detail: format!(
"mass invariant violated: sum(estimate)+sum(residual) = {mass:.10}, \
expected {expected_mass:.10}",
),
});
}
Ok(())
}
// ---------------------------------------------------------------------------
// SolverEngine trait implementation
// ---------------------------------------------------------------------------
impl SolverEngine for ForwardPushSolver {
/// Adapt forward-push PPR to the generic solver interface.
///
/// The `rhs` vector is interpreted as a source indicator: the index of
/// the first non-zero entry is taken as the source vertex. If `rhs` is
/// all zeros, vertex 0 is used. The returned `SolverResult.solution`
/// contains the dense PPR vector.
fn solve(
&self,
matrix: &CsrMatrix<f64>,
rhs: &[f64],
_budget: &ComputeBudget,
) -> Result<SolverResult, SolverError> {
let start = std::time::Instant::now();
let source = rhs.iter().position(|&v| v != 0.0).unwrap_or(0);
let sparse_result = self.ppr_from_source(matrix, source)?;
let n = matrix.rows;
let mut solution = vec![0.0f32; n];
for &(idx, score) in &sparse_result {
solution[idx] = score as f32;
}
Ok(SolverResult {
solution,
iterations: sparse_result.len(),
residual_norm: 0.0,
wall_time: start.elapsed(),
convergence_history: Vec::new(),
algorithm: Algorithm::ForwardPush,
})
}
fn estimate_complexity(&self, _profile: &SparsityProfile, _n: usize) -> ComplexityEstimate {
let est_ops = (1.0 / self.epsilon).min(usize::MAX as f64) as usize;
ComplexityEstimate {
algorithm: Algorithm::ForwardPush,
estimated_flops: est_ops as u64 * 10,
estimated_iterations: est_ops,
estimated_memory_bytes: est_ops * 16,
complexity_class: ComplexityClass::SublinearNnz,
}
}
fn algorithm(&self) -> Algorithm {
Algorithm::ForwardPush
}
}
// ---------------------------------------------------------------------------
// SublinearPageRank trait implementation
// ---------------------------------------------------------------------------
impl SublinearPageRank for ForwardPushSolver {
fn ppr(
&self,
matrix: &CsrMatrix<f64>,
source: usize,
alpha: f64,
epsilon: f64,
) -> Result<Vec<(usize, f64)>, SolverError> {
let solver = ForwardPushSolver::new(alpha, epsilon);
solver.ppr_from_source(matrix, source)
}
fn ppr_multi_seed(
&self,
matrix: &CsrMatrix<f64>,
seeds: &[(usize, f64)],
alpha: f64,
epsilon: f64,
) -> Result<Vec<(usize, f64)>, SolverError> {
for &(v, _) in seeds {
validate_vertex(matrix, v, "seed vertex")?;
}
let solver = ForwardPushSolver::new(alpha, epsilon);
solver.forward_push_core(matrix, seeds)
}
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Kahan (compensated) summation accumulator (test-only).
#[derive(Debug, Clone, Copy)]
struct KahanAccumulator {
sum: f64,
compensation: f64,
}
impl KahanAccumulator {
#[inline]
const fn new() -> Self {
Self {
sum: 0.0,
compensation: 0.0,
}
}
#[inline]
fn add(&mut self, value: f64) {
let y = value - self.compensation;
let t = self.sum + y;
self.compensation = (t - self.sum) - y;
self.sum = t;
}
#[inline]
fn value(&self) -> f64 {
self.sum
}
}
/// 4-vertex graph with bidirectional edges:
/// 0 -- 1, 0 -- 2, 1 -- 2, 1 -- 3
fn triangle_graph() -> CsrMatrix<f64> {
CsrMatrix::<f64>::from_coo(
4,
4,
vec![
(0, 1, 1.0f64),
(0, 2, 1.0f64),
(1, 0, 1.0f64),
(1, 2, 1.0f64),
(1, 3, 1.0f64),
(2, 0, 1.0f64),
(2, 1, 1.0f64),
(3, 1, 1.0f64),
],
)
}
/// Directed path: 0 -> 1 -> 2 -> 3
fn path_graph() -> CsrMatrix<f64> {
CsrMatrix::<f64>::from_coo(4, 4, vec![(0, 1, 1.0f64), (1, 2, 1.0f64), (2, 3, 1.0f64)])
}
/// Star graph centred at vertex 0 with 5 leaves, bidirectional.
fn star_graph() -> CsrMatrix<f64> {
let n = 6;
let mut entries = Vec::new();
for leaf in 1..n {
entries.push((0, leaf, 1.0f64));
entries.push((leaf, 0, 1.0f64));
}
CsrMatrix::<f64>::from_coo(n, n, entries)
}
#[test]
fn basic_ppr_triangle() {
let graph = triangle_graph();
let solver = ForwardPushSolver::default_params();
let result = solver.ppr_from_source(&graph, 0).unwrap();
assert!(!result.is_empty());
assert_eq!(result[0].0, 0, "source should be top-ranked");
assert!(result[0].1 > 0.0);
for &(_, score) in &result {
assert!(score > 0.0);
}
for w in result.windows(2) {
assert!(w[0].1 >= w[1].1, "results should be sorted descending");
}
}
#[test]
fn ppr_path_graph_monotone_decay() {
let graph = path_graph();
let solver = ForwardPushSolver::new(0.85, 1e-8);
let result = solver.ppr_from_source(&graph, 0).unwrap();
let mut scores = vec![0.0f64; 4];
for &(v, s) in &result {
scores[v] = s;
}
assert!(scores[0] > scores[1], "score[0] > score[1]");
assert!(scores[1] > scores[2], "score[1] > score[2]");
assert!(scores[2] > scores[3], "score[2] > score[3]");
}
#[test]
fn ppr_star_symmetry() {
let graph = star_graph();
let solver = ForwardPushSolver::new(0.85, 1e-8);
let result = solver.ppr_from_source(&graph, 0).unwrap();
let leaf_scores: Vec<f64> = result
.iter()
.filter(|(v, _)| *v != 0)
.map(|(_, s)| *s)
.collect();
assert_eq!(leaf_scores.len(), 5);
let mean = leaf_scores.iter().sum::<f64>() / leaf_scores.len() as f64;
for &s in &leaf_scores {
assert!(
(s - mean).abs() < 1e-6,
"leaf scores should be equal: got {s} vs mean {mean}",
);
}
}
#[test]
fn top_k_truncates() {
let graph = triangle_graph();
let solver = ForwardPushSolver::default_params();
let result = solver.top_k(&graph, 0, 2).unwrap();
assert!(result.len() <= 2);
assert_eq!(result[0].0, 0);
}
#[test]
fn mass_invariant_holds() {
let graph = triangle_graph();
let solver = ForwardPushSolver::default_params();
assert!(solver.ppr_from_source(&graph, 0).is_ok());
}
#[test]
fn invalid_source_errors() {
let graph = triangle_graph();
let solver = ForwardPushSolver::default_params();
assert!(solver.ppr_from_source(&graph, 100).is_err());
}
#[test]
fn isolated_vertex_receives_zero() {
// Vertex 3 has no edges.
let graph = CsrMatrix::<f64>::from_coo(
4,
4,
vec![
(0, 1, 1.0f64),
(1, 0, 1.0f64),
(1, 2, 1.0f64),
(2, 1, 1.0f64),
],
);
let solver = ForwardPushSolver::default_params();
let result = solver.ppr_from_source(&graph, 0).unwrap();
let v3_score = result.iter().find(|(v, _)| *v == 3).map_or(0.0, |p| p.1);
assert!(
v3_score.abs() < 1e-10,
"isolated vertex should have ~zero PPR",
);
}
#[test]
fn isolated_source_converges_to_one() {
// An isolated vertex (degree 0) keeps pushing until residual drops
// below epsilon. The estimate converges to
// 1 - (1-alpha)^k ~ 1.0 for small epsilon.
let graph = CsrMatrix::<f64>::from_coo(
4,
4,
vec![
(0, 1, 1.0f64),
(1, 0, 1.0f64),
(1, 2, 1.0f64),
(2, 1, 1.0f64),
],
);
let solver = ForwardPushSolver::default_params();
let result = solver.ppr_from_source(&graph, 3).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, 3);
// With alpha=0.85 and epsilon=1e-6, the estimate converges very
// close to 1.0 (within epsilon).
assert!(
(result[0].1 - 1.0).abs() < 1e-4,
"isolated source estimate should converge near 1.0: got {}",
result[0].1,
);
}
#[test]
fn single_vertex_graph() {
let graph = CsrMatrix::<f64>::from_coo(1, 1, Vec::<(usize, usize, f64)>::new());
let solver = ForwardPushSolver::default_params();
let result = solver.ppr_from_source(&graph, 0).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].0, 0);
// Single isolated vertex converges to ~1.0 (not 0.85) because the
// dangling node keeps absorbing alpha on each push iteration.
assert!(
(result[0].1 - 1.0).abs() < 1e-4,
"single vertex PPR should converge near 1.0: got {}",
result[0].1,
);
}
#[test]
fn solver_engine_trait() {
let graph = triangle_graph();
let solver = ForwardPushSolver::default_params();
let mut rhs = vec![0.0f64; 4];
rhs[1] = 1.0;
let budget = ComputeBudget::default();
let result = solver.solve(&graph, &rhs, &budget).unwrap();
assert_eq!(result.algorithm, Algorithm::ForwardPush);
assert_eq!(result.solution.len(), 4);
let max_idx = result
.solution
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.unwrap()
.0;
assert_eq!(max_idx, 1);
}
#[test]
fn sublinear_ppr_trait() {
let graph = triangle_graph();
let solver = ForwardPushSolver::default_params();
let result = solver.ppr(&graph, 0, 0.85, 1e-6).unwrap();
assert!(!result.is_empty());
assert_eq!(result[0].0, 0, "source should rank first via ppr trait");
}
#[test]
fn multi_seed_ppr() {
let graph = triangle_graph();
let solver = ForwardPushSolver::default_params();
let seeds = vec![(0, 0.5), (1, 0.5)];
let result = solver.ppr_multi_seed(&graph, &seeds, 0.85, 1e-6).unwrap();
assert!(!result.is_empty());
let has_0 = result.iter().any(|(v, _)| *v == 0);
let has_1 = result.iter().any(|(v, _)| *v == 1);
assert!(has_0 && has_1, "both seeds should appear in output");
}
#[test]
fn forward_push_with_residuals_mass_conservation() {
let graph = triangle_graph();
let (p, r) = forward_push_with_residuals(&graph, 0, 0.85, 1e-6).unwrap();
let total: f64 = p.iter().sum::<f64>() + r.iter().sum::<f64>();
assert!(
(total - 1.0).abs() < 1e-6,
"mass should be conserved: got {total}",
);
}
#[test]
fn kahan_accuracy() {
let mut acc = KahanAccumulator::new();
let n = 1_000_000;
let small = 1e-10;
for _ in 0..n {
acc.add(small);
}
let expected = n as f64 * small;
let relative_error = (acc.value() - expected).abs() / expected;
assert!(
relative_error < 1e-10,
"Kahan relative error {relative_error} should be tiny",
);
}
#[test]
fn self_loop_graph() {
let graph = CsrMatrix::<f64>::from_coo(
3,
3,
vec![
(0, 0, 1.0f64),
(0, 1, 1.0f64),
(1, 1, 1.0f64),
(1, 2, 1.0f64),
(2, 2, 1.0f64),
(2, 0, 1.0f64),
],
);
let solver = ForwardPushSolver::default_params();
let result = solver.ppr_from_source(&graph, 0);
assert!(result.is_ok(), "self-loop graph failed: {:?}", result.err());
}
#[test]
fn complete_graph_symmetry() {
let n = 4;
let mut entries = Vec::new();
for i in 0..n {
for j in 0..n {
if i != j {
entries.push((i, j, 1.0f64));
}
}
}
let graph = CsrMatrix::<f64>::from_coo(n, n, entries);
let solver = ForwardPushSolver::new(0.85, 1e-8);
let result = solver.ppr_from_source(&graph, 0).unwrap();
assert_eq!(result[0].0, 0);
let other_scores: Vec<f64> = result
.iter()
.filter(|(v, _)| *v != 0)
.map(|(_, s)| *s)
.collect();
assert_eq!(other_scores.len(), 3);
let mean = other_scores.iter().sum::<f64>() / 3.0;
for &s in &other_scores {
assert!((s - mean).abs() < 1e-6);
}
}
#[test]
fn estimate_complexity_sublinear() {
let solver = ForwardPushSolver::new(0.85, 1e-4);
let profile = SparsityProfile {
rows: 1000,
cols: 1000,
nnz: 5000,
density: 0.005,
is_diag_dominant: false,
estimated_spectral_radius: 0.9,
estimated_condition: 10.0,
is_symmetric_structure: true,
avg_nnz_per_row: 5.0,
max_nnz_per_row: 10,
};
let est = solver.estimate_complexity(&profile, 1000);
assert_eq!(est.algorithm, Algorithm::ForwardPush);
assert_eq!(est.complexity_class, ComplexityClass::SublinearNnz);
assert!(est.estimated_iterations > 0);
}
}

View File

@@ -0,0 +1,63 @@
//! Iterative sparse linear solvers for the ruvector ecosystem.
//!
//! This crate provides iterative methods for solving `Ax = b` where `A` is a
//! sparse matrix stored in CSR format.
//!
//! # Available Solvers
//!
//! | Solver | Feature gate | Method |
//! |--------|-------------|--------|
//! | [`NeumannSolver`](neumann::NeumannSolver) | `neumann` | Neumann series x = sum (I-A)^k b |
//!
//! # Example
//!
//! ```rust
//! use ruvector_solver::types::{ComputeBudget, CsrMatrix};
//! use ruvector_solver::neumann::NeumannSolver;
//! use ruvector_solver::traits::SolverEngine;
//!
//! // Build a diagonally dominant 3x3 matrix (f32)
//! let matrix = CsrMatrix::<f32>::from_coo(3, 3, vec![
//! (0, 0, 2.0_f32), (0, 1, -0.5_f32),
//! (1, 0, -0.5_f32), (1, 1, 2.0_f32), (1, 2, -0.5_f32),
//! (2, 1, -0.5_f32), (2, 2, 2.0_f32),
//! ]);
//! let rhs = vec![1.0_f32, 0.0, 1.0];
//!
//! let solver = NeumannSolver::new(1e-6, 500);
//! let result = solver.solve(&matrix, &rhs).unwrap();
//! assert!(result.residual_norm < 1e-4);
//! ```
pub mod arena;
pub mod audit;
pub mod budget;
pub mod error;
pub mod events;
pub mod simd;
pub mod traits;
pub mod types;
pub mod validation;
#[cfg(feature = "neumann")]
pub mod neumann;
#[cfg(feature = "cg")]
pub mod cg;
#[cfg(feature = "forward-push")]
pub mod forward_push;
#[cfg(feature = "backward-push")]
pub mod backward_push;
#[cfg(feature = "hybrid-random-walk")]
pub mod random_walk;
#[cfg(feature = "bmssp")]
pub mod bmssp;
#[cfg(feature = "true-solver")]
pub mod true_solver;
pub mod router;

View File

@@ -0,0 +1,779 @@
//! Jacobi-preconditioned Neumann Series iterative solver.
//!
//! Solves the linear system `Ax = b` by splitting `A = D - R` (where `D` is
//! the diagonal part) and iterating:
//!
//! ```text
//! x_{k+1} = x_k + D^{-1} (b - A x_k)
//! ```
//!
//! This is equivalent to the Neumann series `x = sum_{k=0}^{K} M^k D^{-1} b`
//! where `M = I - D^{-1} A`. Convergence requires `rho(M) < 1`, which is
//! guaranteed for strictly diagonally dominant matrices.
//!
//! # Algorithm
//!
//! The iteration maintains a running solution `x` and residual `r = b - Ax`:
//!
//! ```text
//! x_0 = D^{-1} b
//! for k = 0, 1, 2, ...:
//! r = b - A * x_k
//! x_{k+1} = x_k + D^{-1} * r
//! if ||r|| < tolerance:
//! break
//! ```
//!
//! # Convergence
//!
//! Before solving, the solver estimates `rho(I - D^{-1}A)` via a 10-step
//! power iteration and rejects the problem with
//! [`SolverError::SpectralRadiusExceeded`] if `rho >= 1.0`. During iteration,
//! if the residual grows by more than 2x between consecutive steps,
//! [`SolverError::NumericalInstability`] is returned.
use std::time::Instant;
use tracing::{debug, info, instrument, warn};
use crate::error::{SolverError, ValidationError};
use crate::traits::SolverEngine;
use crate::types::{
Algorithm, ComplexityClass, ComplexityEstimate, ComputeBudget, ConvergenceInfo, CsrMatrix,
SolverResult, SparsityProfile,
};
/// Number of power-iteration steps used to estimate the spectral radius.
const POWER_ITERATION_STEPS: usize = 10;
/// If the residual grows by more than this factor in a single step, the solver
/// declares numerical instability.
const INSTABILITY_GROWTH_FACTOR: f64 = 2.0;
// ---------------------------------------------------------------------------
// NeumannSolver
// ---------------------------------------------------------------------------
/// Neumann Series solver for sparse linear systems.
///
/// Computes `x = sum_{k=0}^{K} (I - A)^k * b` by maintaining a residual
/// vector and accumulating partial sums until convergence.
///
/// # Example
///
/// ```rust
/// use ruvector_solver::types::CsrMatrix;
/// use ruvector_solver::neumann::NeumannSolver;
///
/// // Diagonally dominant 2x2: A = [[2, -0.5], [-0.5, 2]]
/// let a = CsrMatrix::<f32>::from_coo(2, 2, vec![
/// (0, 0, 2.0_f32), (0, 1, -0.5_f32),
/// (1, 0, -0.5_f32), (1, 1, 2.0_f32),
/// ]);
/// let b = vec![1.0_f32, 1.0];
///
/// let solver = NeumannSolver::new(1e-6, 500);
/// let result = solver.solve(&a, &b).unwrap();
/// assert!(result.residual_norm < 1e-4);
/// ```
#[derive(Debug, Clone)]
pub struct NeumannSolver {
/// Target residual L2 norm for convergence.
pub tolerance: f64,
/// Maximum number of iterations before giving up.
pub max_iterations: usize,
}
impl NeumannSolver {
/// Create a new `NeumannSolver`.
///
/// # Arguments
///
/// * `tolerance` - Stop when `||r|| < tolerance`.
/// * `max_iterations` - Upper bound on iterations.
pub fn new(tolerance: f64, max_iterations: usize) -> Self {
Self {
tolerance,
max_iterations,
}
}
/// Estimate the spectral radius of `M = I - D^{-1}A` via 10-step power
/// iteration.
///
/// Runs [`POWER_ITERATION_STEPS`] iterations of the power method on the
/// Jacobi iteration matrix `M = I - D^{-1}A`. Returns the Rayleigh-quotient
/// estimate of the dominant eigenvalue magnitude.
///
/// # Arguments
///
/// * `matrix` - The coefficient matrix `A` (must be square).
///
/// # Returns
///
/// Estimated `|lambda_max(I - D^{-1}A)|`. If this is `>= 1.0`, the
/// Jacobi-preconditioned Neumann series will diverge.
#[instrument(skip(matrix), fields(n = matrix.rows))]
pub fn estimate_spectral_radius(matrix: &CsrMatrix<f32>) -> f64 {
let n = matrix.rows;
if n == 0 {
return 0.0;
}
let d_inv = extract_diag_inv_f32(matrix);
Self::estimate_spectral_radius_with_diag(matrix, &d_inv)
}
/// Inner helper: estimate spectral radius using a pre-computed `d_inv`.
///
/// This avoids recomputing the diagonal inverse when the caller already
/// has it (e.g. `solve()` needs `d_inv` for both the spectral check and
/// the Jacobi iteration).
fn estimate_spectral_radius_with_diag(matrix: &CsrMatrix<f32>, d_inv: &[f32]) -> f64 {
let n = matrix.rows;
if n == 0 {
return 0.0;
}
// Initialise with a deterministic pseudo-random unit vector.
let mut v: Vec<f32> = (0..n)
.map(|i| ((i.wrapping_mul(7).wrapping_add(13)) % 100) as f32 / 100.0)
.collect();
let norm = l2_norm_f32(&v);
if norm > 1e-12 {
scale_vec_f32(&mut v, 1.0 / norm);
}
let mut av = vec![0.0f32; n]; // scratch for A*v
let mut w = vec![0.0f32; n]; // scratch for M*v = v - D^{-1}*A*v
let mut eigenvalue_estimate = 0.0f64;
for _ in 0..POWER_ITERATION_STEPS {
// w = v - D^{-1} * A * v (i.e. M * v)
matrix.spmv(&v, &mut av);
for j in 0..n {
w[j] = v[j] - d_inv[j] * av[j];
}
// Rayleigh quotient: lambda = v^T w (v is unit-length).
let dot: f64 = v
.iter()
.zip(w.iter())
.map(|(&a, &b)| a as f64 * b as f64)
.sum();
eigenvalue_estimate = dot;
// Normalise w -> v for the next step.
let w_norm = l2_norm_f32(&w);
if w_norm < 1e-12 {
break;
}
for j in 0..n {
v[j] = w[j] / w_norm as f32;
}
}
let rho = eigenvalue_estimate.abs();
debug!(rho, "estimated spectral radius of (I - D^-1 A)");
rho
}
/// Core Jacobi-preconditioned Neumann-series solve operating on `f32`.
///
/// Validates inputs, checks the spectral radius of `I - D^{-1}A` via
/// power iteration, then runs the iteration returning a [`SolverResult`].
///
/// # Errors
///
/// - [`SolverError::InvalidInput`] if the matrix is non-square or the RHS
/// length does not match.
/// - [`SolverError::SpectralRadiusExceeded`] if `rho(I - D^{-1}A) >= 1`.
/// - [`SolverError::NumericalInstability`] if the residual grows by more
/// than 2x in a single step.
/// - [`SolverError::NonConvergence`] if the iteration budget is exhausted.
#[instrument(skip(self, matrix, rhs), fields(n = matrix.rows, nnz = matrix.nnz()))]
pub fn solve(&self, matrix: &CsrMatrix<f32>, rhs: &[f32]) -> Result<SolverResult, SolverError> {
let start = Instant::now();
let n = matrix.rows;
// ------------------------------------------------------------------
// Input validation
// ------------------------------------------------------------------
if matrix.rows != matrix.cols {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(format!(
"matrix must be square: got {}x{}",
matrix.rows, matrix.cols,
)),
));
}
if rhs.len() != n {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(format!(
"rhs length {} does not match matrix dimension {}",
rhs.len(),
n,
)),
));
}
// Edge case: empty system.
if n == 0 {
return Ok(SolverResult {
solution: Vec::new(),
iterations: 0,
residual_norm: 0.0,
wall_time: start.elapsed(),
convergence_history: Vec::new(),
algorithm: Algorithm::Neumann,
});
}
// Extract D^{-1} once — reused for both the spectral radius check
// and the Jacobi-preconditioned iteration that follows.
let d_inv = extract_diag_inv_f32(matrix);
// ------------------------------------------------------------------
// Spectral radius pre-check (10-step power iteration on I - D^{-1}A)
// ------------------------------------------------------------------
let rho = Self::estimate_spectral_radius_with_diag(matrix, &d_inv);
if rho >= 1.0 {
warn!(rho, "spectral radius >= 1.0, Neumann series will diverge");
return Err(SolverError::SpectralRadiusExceeded {
spectral_radius: rho,
limit: 1.0,
algorithm: Algorithm::Neumann,
});
}
info!(rho, "spectral radius check passed");
// ------------------------------------------------------------------
// Jacobi-preconditioned iteration (fused kernel)
//
// x_0 = D^{-1} * b
// loop:
// r = b - A * x_k (fused with norm computation)
// if ||r|| < tolerance: break
// x_{k+1} = x_k + D^{-1} * r (fused with residual storage)
//
// Key optimization: uses fused_residual_norm_sq to compute
// r = b - Ax and ||r||^2 in a single pass, avoiding a separate
// spmv + subtraction + norm computation (3 memory traversals -> 1).
// ------------------------------------------------------------------
let mut x: Vec<f32> = (0..n).map(|i| d_inv[i] * rhs[i]).collect();
let mut r = vec![0.0f32; n]; // residual buffer (reused each iteration)
let mut convergence_history = Vec::with_capacity(self.max_iterations.min(256));
let mut prev_residual_norm = f64::MAX;
let final_residual_norm: f64;
let mut iterations_done: usize = 0;
for k in 0..self.max_iterations {
// Fused: compute r = b - Ax and ||r||^2 in one pass.
let residual_norm_sq = matrix.fused_residual_norm_sq(&x, rhs, &mut r);
let residual_norm = residual_norm_sq.sqrt();
iterations_done = k + 1;
convergence_history.push(ConvergenceInfo {
iteration: k,
residual_norm,
});
debug!(iteration = k, residual_norm, "neumann iteration");
// Convergence check.
if residual_norm < self.tolerance {
final_residual_norm = residual_norm;
info!(iterations = iterations_done, residual_norm, "converged");
return Ok(SolverResult {
solution: x,
iterations: iterations_done,
residual_norm: final_residual_norm,
wall_time: start.elapsed(),
convergence_history,
algorithm: Algorithm::Neumann,
});
}
// NaN / Inf guard.
if residual_norm.is_nan() || residual_norm.is_infinite() {
return Err(SolverError::NumericalInstability {
iteration: k,
detail: format!("residual became {residual_norm}"),
});
}
// Instability check: residual grew by > 2x.
if k > 0
&& prev_residual_norm < f64::MAX
&& prev_residual_norm > 0.0
&& residual_norm > INSTABILITY_GROWTH_FACTOR * prev_residual_norm
{
warn!(
iteration = k,
prev = prev_residual_norm,
current = residual_norm,
"residual diverging",
);
return Err(SolverError::NumericalInstability {
iteration: k,
detail: format!(
"residual grew from {prev_residual_norm:.6e} to \
{residual_norm:.6e} (>{INSTABILITY_GROWTH_FACTOR:.0}x)",
),
});
}
// Fused update: x[j] += d_inv[j] * r[j]
// 4-wide unrolled for ILP.
let chunks = n / 4;
for c in 0..chunks {
let j = c * 4;
x[j] += d_inv[j] * r[j];
x[j + 1] += d_inv[j + 1] * r[j + 1];
x[j + 2] += d_inv[j + 2] * r[j + 2];
x[j + 3] += d_inv[j + 3] * r[j + 3];
}
for j in (chunks * 4)..n {
x[j] += d_inv[j] * r[j];
}
prev_residual_norm = residual_norm;
}
// Exhausted iteration budget without converging.
final_residual_norm = prev_residual_norm;
Err(SolverError::NonConvergence {
iterations: iterations_done,
residual: final_residual_norm,
tolerance: self.tolerance,
})
}
}
// ---------------------------------------------------------------------------
// SolverEngine trait implementation (f64 interface)
// ---------------------------------------------------------------------------
impl SolverEngine for NeumannSolver {
/// Solve via the Neumann series.
///
/// Adapts the `f64` trait interface to the internal `f32` solver by
/// converting the input matrix and RHS, running the solver, then
/// returning the `f32` solution.
fn solve(
&self,
matrix: &CsrMatrix<f64>,
rhs: &[f64],
budget: &ComputeBudget,
) -> Result<SolverResult, SolverError> {
let start = Instant::now();
// Validate that f64 values fit in f32 range.
for (i, &v) in matrix.values.iter().enumerate() {
if v.is_finite() && v.abs() > f32::MAX as f64 {
return Err(SolverError::InvalidInput(ValidationError::NonFiniteValue(
format!("matrix value at index {i} ({v:.6e}) overflows f32"),
)));
}
}
for (i, &v) in rhs.iter().enumerate() {
if v.is_finite() && v.abs() > f32::MAX as f64 {
return Err(SolverError::InvalidInput(ValidationError::NonFiniteValue(
format!("rhs value at index {i} ({v:.6e}) overflows f32"),
)));
}
}
// Convert f64 matrix to f32 for the core solver.
let f32_matrix = CsrMatrix {
row_ptr: matrix.row_ptr.clone(),
col_indices: matrix.col_indices.clone(),
values: matrix.values.iter().map(|&v| v as f32).collect(),
rows: matrix.rows,
cols: matrix.cols,
};
let f32_rhs: Vec<f32> = rhs.iter().map(|&v| v as f32).collect();
// Use the tighter of the solver's own tolerance and the caller's budget,
// but no tighter than f32 precision allows (the Neumann solver operates
// internally in f32, so residuals below ~f32::EPSILON are unreachable).
let max_iters = self.max_iterations.min(budget.max_iterations);
let tol = self
.tolerance
.min(budget.tolerance)
.max(f32::EPSILON as f64 * 4.0);
let inner_solver = NeumannSolver::new(tol, max_iters);
let mut result = inner_solver.solve(&f32_matrix, &f32_rhs)?;
// Check wall-time budget.
if start.elapsed() > budget.max_time {
return Err(SolverError::BudgetExhausted {
reason: "wall-clock time limit exceeded".to_string(),
elapsed: start.elapsed(),
});
}
// Adjust wall time to include conversion overhead.
result.wall_time = start.elapsed();
Ok(result)
}
fn estimate_complexity(&self, profile: &SparsityProfile, n: usize) -> ComplexityEstimate {
// Estimated iterations: ceil( ln(1/tol) / |ln(rho)| )
let rho = profile.estimated_spectral_radius.max(0.01).min(0.999);
let est_iters = ((1.0 / self.tolerance).ln() / (1.0 - rho).ln().abs()).ceil() as usize;
let est_iters = est_iters.min(self.max_iterations).max(1);
ComplexityEstimate {
algorithm: Algorithm::Neumann,
// Each iteration does one SpMV (2 * nnz flops) + O(n) vector ops.
estimated_flops: (est_iters as u64) * (profile.nnz as u64) * 2,
estimated_iterations: est_iters,
// Working memory: x, r, ar (3 vectors of f32).
estimated_memory_bytes: n * 4 * 3,
complexity_class: ComplexityClass::SublinearNnz,
}
}
fn algorithm(&self) -> Algorithm {
Algorithm::Neumann
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/// Extract `D^{-1}` from a CSR matrix (the reciprocal of each diagonal entry).
///
/// If a diagonal entry is zero or very small, uses `1.0` as a fallback to
/// avoid division by zero.
fn extract_diag_inv_f32(matrix: &CsrMatrix<f32>) -> Vec<f32> {
let n = matrix.rows;
let mut d_inv = vec![1.0f32; n];
for i in 0..n {
let start = matrix.row_ptr[i];
let end = matrix.row_ptr[i + 1];
for idx in start..end {
if matrix.col_indices[idx] == i {
let diag = matrix.values[idx];
if diag.abs() > 1e-15 {
d_inv[i] = 1.0 / diag;
} else {
warn!(
row = i,
diag_value = %diag,
"zero or near-zero diagonal entry; substituting 1.0 — matrix may be singular"
);
}
break;
}
}
}
d_inv
}
/// Compute the L2 (Euclidean) norm of a slice of `f32` values.
///
/// Uses `f64` accumulation to reduce catastrophic cancellation on large
/// vectors.
#[inline]
fn l2_norm_f32(v: &[f32]) -> f32 {
let sum: f64 = v.iter().map(|&x| (x as f64) * (x as f64)).sum();
sum.sqrt() as f32
}
/// Scale every element of `v` by `s` in-place.
#[inline]
fn scale_vec_f32(v: &mut [f32], s: f32) {
for x in v.iter_mut() {
*x *= s;
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::CsrMatrix;
/// Helper: build a diagonally dominant tridiagonal matrix.
fn tridiag_f32(n: usize, diag_val: f32, off_val: f32) -> CsrMatrix<f32> {
let mut entries = Vec::new();
for i in 0..n {
entries.push((i, i, diag_val));
if i > 0 {
entries.push((i, i - 1, off_val));
}
if i + 1 < n {
entries.push((i, i + 1, off_val));
}
}
CsrMatrix::<f32>::from_coo(n, n, entries)
}
/// Helper: build a 3x3 system whose eigenvalues are in (0, 2) so that
/// the Neumann series converges (rho(I - A) < 1).
fn test_matrix_f64() -> CsrMatrix<f64> {
CsrMatrix::<f64>::from_coo(
3,
3,
vec![
(0, 0, 1.0),
(0, 1, -0.1),
(1, 0, -0.1),
(1, 1, 1.0),
(1, 2, -0.1),
(2, 1, -0.1),
(2, 2, 1.0),
],
)
}
#[test]
fn test_new() {
let solver = NeumannSolver::new(1e-8, 100);
assert_eq!(solver.tolerance, 1e-8);
assert_eq!(solver.max_iterations, 100);
}
#[test]
fn test_spectral_radius_identity() {
let identity = CsrMatrix::<f32>::identity(4);
let rho = NeumannSolver::estimate_spectral_radius(&identity);
assert!(rho < 0.1, "expected rho ~ 0 for identity, got {rho}");
}
#[test]
fn test_spectral_radius_pure_diagonal() {
// For a pure diagonal matrix D, D^{-1}A = I, so M = I - I = 0.
// The spectral radius should be ~0.
let a = CsrMatrix::<f32>::from_coo(3, 3, vec![(0, 0, 0.5_f32), (1, 1, 0.5), (2, 2, 0.5)]);
let rho = NeumannSolver::estimate_spectral_radius(&a);
assert!(rho < 0.1, "expected rho ~ 0 for diagonal matrix, got {rho}");
}
#[test]
fn test_spectral_radius_empty() {
let empty = CsrMatrix::<f32> {
row_ptr: vec![0],
col_indices: vec![],
values: vec![],
rows: 0,
cols: 0,
};
assert_eq!(NeumannSolver::estimate_spectral_radius(&empty), 0.0);
}
#[test]
fn test_spectral_radius_non_diag_dominant() {
// Matrix where off-diagonal entries dominate:
// [1 2]
// [2 1]
// D^{-1}A = [[1, 2], [2, 1]], so M = I - D^{-1}A = [[0, -2], [-2, 0]].
// Eigenvalues of M are +2 and -2, so rho(M) = 2 > 1.
let a = CsrMatrix::<f32>::from_coo(
2,
2,
vec![(0, 0, 1.0_f32), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)],
);
let rho = NeumannSolver::estimate_spectral_radius(&a);
assert!(
rho > 1.0,
"expected rho > 1 for non-diag-dominant matrix, got {rho}"
);
}
#[test]
fn test_solve_identity() {
let identity = CsrMatrix::<f32>::identity(3);
let rhs = vec![1.0_f32, 2.0, 3.0];
let solver = NeumannSolver::new(1e-6, 100);
let result = solver.solve(&identity, &rhs).unwrap();
for (i, (&e, &a)) in rhs.iter().zip(result.solution.iter()).enumerate() {
assert!((e - a).abs() < 1e-4, "index {i}: expected {e}, got {a}");
}
assert!(result.residual_norm < 1e-6);
}
#[test]
fn test_solve_diagonal() {
let a = CsrMatrix::<f32>::from_coo(3, 3, vec![(0, 0, 0.5_f32), (1, 1, 0.5), (2, 2, 0.5)]);
let rhs = vec![1.0_f32, 1.0, 1.0];
let solver = NeumannSolver::new(1e-6, 200);
let result = solver.solve(&a, &rhs).unwrap();
for (i, &val) in result.solution.iter().enumerate() {
assert!(
(val - 2.0).abs() < 0.01,
"index {i}: expected ~2.0, got {val}"
);
}
}
#[test]
fn test_solve_tridiagonal() {
// diag=1.0, off=-0.1: Jacobi iteration matrix has rho ~ 0.17.
// Use 1e-6 tolerance since f32 accumulation limits floor.
let a = tridiag_f32(5, 1.0, -0.1);
let rhs = vec![1.0_f32, 0.0, 1.0, 0.0, 1.0];
let solver = NeumannSolver::new(1e-6, 1000);
let result = solver.solve(&a, &rhs).unwrap();
assert!(result.residual_norm < 1e-4);
assert!(result.iterations > 0);
assert!(!result.convergence_history.is_empty());
}
#[test]
fn test_solve_empty_system() {
let a = CsrMatrix::<f32> {
row_ptr: vec![0],
col_indices: vec![],
values: vec![],
rows: 0,
cols: 0,
};
let result = NeumannSolver::new(1e-6, 10).solve(&a, &[]).unwrap();
assert_eq!(result.iterations, 0);
assert!(result.solution.is_empty());
}
#[test]
fn test_solve_dimension_mismatch() {
let a = CsrMatrix::<f32>::identity(3);
let rhs = vec![1.0_f32, 2.0];
let err = NeumannSolver::new(1e-6, 100).solve(&a, &rhs).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("dimension") || msg.contains("mismatch"),
"got: {msg}"
);
}
#[test]
fn test_solve_non_square() {
let a = CsrMatrix::<f32>::from_coo(2, 3, vec![(0, 0, 1.0_f32), (1, 1, 1.0)]);
let rhs = vec![1.0_f32, 1.0];
let err = NeumannSolver::new(1e-6, 100).solve(&a, &rhs).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("square") || msg.contains("dimension"),
"got: {msg}"
);
}
#[test]
fn test_solve_divergent_matrix() {
// Non-diag-dominant: off-diagonal entries larger than diagonal.
let a = CsrMatrix::<f32>::from_coo(
2,
2,
vec![(0, 0, 1.0_f32), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)],
);
let rhs = vec![1.0_f32, 1.0];
let err = NeumannSolver::new(1e-6, 100).solve(&a, &rhs).unwrap_err();
assert!(err.to_string().contains("spectral radius"), "got: {}", err);
}
#[test]
fn test_convergence_history_monotone() {
let a = CsrMatrix::<f32>::identity(4);
let rhs = vec![1.0_f32; 4];
let result = NeumannSolver::new(1e-10, 50).solve(&a, &rhs).unwrap();
assert!(!result.convergence_history.is_empty());
for window in result.convergence_history.windows(2) {
assert!(
window[1].residual_norm <= window[0].residual_norm + 1e-12,
"residual not decreasing: {} -> {}",
window[0].residual_norm,
window[1].residual_norm,
);
}
}
#[test]
fn test_algorithm_tag() {
let a = CsrMatrix::<f32>::identity(2);
let rhs = vec![1.0_f32; 2];
let result = NeumannSolver::new(1e-6, 100).solve(&a, &rhs).unwrap();
assert_eq!(result.algorithm, Algorithm::Neumann);
}
#[test]
fn test_solver_engine_trait_f64() {
let solver = NeumannSolver::new(1e-6, 200);
let engine: &dyn SolverEngine = &solver;
let a = test_matrix_f64();
let rhs = vec![1.0_f64, 0.0, 1.0];
let budget = ComputeBudget::default();
let result = engine.solve(&a, &rhs, &budget).unwrap();
assert!(result.residual_norm < 1e-4);
assert_eq!(result.algorithm, Algorithm::Neumann);
}
#[test]
fn test_larger_system_accuracy() {
let n = 50;
// diag=1.0, off=-0.1: Jacobi-preconditioned Neumann converges.
// Use 1e-6 tolerance for f32 precision headroom.
let a = tridiag_f32(n, 1.0, -0.1);
let rhs: Vec<f32> = (0..n).map(|i| (i as f32 + 1.0) / n as f32).collect();
let result = NeumannSolver::new(1e-6, 2000).solve(&a, &rhs).unwrap();
assert!(
result.residual_norm < 1e-6,
"residual too large: {}",
result.residual_norm
);
let mut ax = vec![0.0f32; n];
a.spmv(&result.solution, &mut ax);
for i in 0..n {
assert!(
(ax[i] - rhs[i]).abs() < 1e-4,
"A*x[{i}]={} but b[{i}]={}",
ax[i],
rhs[i]
);
}
}
#[test]
fn test_scalar_system() {
let a = CsrMatrix::<f32>::from_coo(1, 1, vec![(0, 0, 0.5_f32)]);
let rhs = vec![4.0_f32];
let result = NeumannSolver::new(1e-8, 200).solve(&a, &rhs).unwrap();
assert!(
(result.solution[0] - 8.0).abs() < 0.01,
"expected ~8.0, got {}",
result.solution[0]
);
}
#[test]
fn test_estimate_complexity() {
let solver = NeumannSolver::new(1e-6, 1000);
let profile = SparsityProfile {
rows: 100,
cols: 100,
nnz: 500,
density: 0.05,
is_diag_dominant: true,
estimated_spectral_radius: 0.5,
estimated_condition: 3.0,
is_symmetric_structure: true,
avg_nnz_per_row: 5.0,
max_nnz_per_row: 8,
};
let estimate = solver.estimate_complexity(&profile, 100);
assert_eq!(estimate.algorithm, Algorithm::Neumann);
assert!(estimate.estimated_flops > 0);
assert!(estimate.estimated_iterations > 0);
assert!(estimate.estimated_memory_bytes > 0);
assert_eq!(estimate.complexity_class, ComplexityClass::SublinearNnz);
}
}

View File

@@ -0,0 +1,938 @@
//! Hybrid Random Walk Monte Carlo for Personalized PageRank estimation.
//!
//! Estimates pairwise PPR(s, t) via random walks. Each walk starts at the
//! source vertex and at each step either teleports (with probability alpha)
//! or moves to a random neighbour (with probability 1 - alpha). The fraction
//! of walks landing at the target approximates PPR(s, t).
//!
//! # Variance tracking
//!
//! Uses Welford's online algorithm to track the mean and variance of the
//! binary indicator `I[walk lands at target]`. Early termination triggers
//! when the coefficient of variation (CV = stddev / mean) drops below 0.1.
//!
//! # Complexity
//!
//! Each walk has expected length `1/alpha`. For single-entry estimation
//! with additive error epsilon and failure probability delta, `num_walks =
//! ceil(3 * ln(2/delta) / epsilon^2)` suffices. Total work:
//! `O(num_walks / alpha)`.
use std::time::Instant;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use tracing::debug;
use crate::error::{SolverError, ValidationError};
use crate::traits::{SolverEngine, SublinearPageRank};
use crate::types::{
Algorithm, ComplexityClass, ComplexityEstimate, ComputeBudget, ConvergenceInfo, CsrMatrix,
SolverResult, SparsityProfile,
};
// ---------------------------------------------------------------------------
// Welford's online variance tracker
// ---------------------------------------------------------------------------
/// Tracks running mean and variance via Welford's numerically stable
/// online algorithm. Used for early-termination decisions.
#[derive(Debug, Clone)]
struct WelfordAccumulator {
count: u64,
mean: f64,
m2: f64,
}
impl WelfordAccumulator {
fn new() -> Self {
Self {
count: 0,
mean: 0.0,
m2: 0.0,
}
}
#[inline]
fn update(&mut self, value: f64) {
self.count += 1;
let delta = value - self.mean;
self.mean += delta / self.count as f64;
let delta2 = value - self.mean;
self.m2 += delta * delta2;
}
#[inline]
fn variance(&self) -> f64 {
if self.count < 2 {
return f64::INFINITY;
}
self.m2 / self.count as f64
}
#[inline]
fn stddev(&self) -> f64 {
self.variance().sqrt()
}
/// Coefficient of variation: stddev / |mean|.
#[inline]
fn cv(&self) -> f64 {
if self.mean.abs() < 1e-15 {
return f64::INFINITY;
}
self.stddev() / self.mean.abs()
}
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/// Default failure probability for walk-count formula.
const DEFAULT_DELTA: f64 = 0.01;
/// CV threshold for early termination.
const CV_THRESHOLD: f64 = 0.1;
/// Minimum walks before checking CV.
const MIN_WALKS_BEFORE_CV_CHECK: usize = 100;
// ---------------------------------------------------------------------------
// Solver struct
// ---------------------------------------------------------------------------
/// Hybrid random-walk PPR solver.
///
/// Performs random walks from the source node, each terminating with
/// probability `alpha` at each step. The empirical distribution over
/// walk endpoints approximates the PPR vector.
///
/// # Example
///
/// ```rust,ignore
/// use ruvector_solver::random_walk::HybridRandomWalkSolver;
/// use ruvector_solver::types::CsrMatrix;
///
/// let graph = CsrMatrix::<f64>::from_coo(4, 4, vec![
/// (0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0), (3, 0, 1.0),
/// ]);
/// let solver = HybridRandomWalkSolver::new(0.15, 10_000);
/// let ppr_01 = solver.estimate_entry(&graph, 0, 1).unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct HybridRandomWalkSolver {
/// Teleportation probability (alpha). Must be in (0, 1).
pub alpha: f64,
/// Number of random walks to simulate.
pub num_walks: usize,
/// Random seed for reproducibility (0 = use entropy source).
pub seed: u64,
}
impl HybridRandomWalkSolver {
/// Create a new hybrid random-walk solver.
pub fn new(alpha: f64, num_walks: usize) -> Self {
Self {
alpha,
num_walks,
seed: 0,
}
}
/// Create a solver calibrated for additive error `epsilon` with
/// failure probability `delta`.
///
/// Formula: `num_walks = ceil(3 * ln(2/delta) / epsilon^2)`.
pub fn from_epsilon(alpha: f64, epsilon: f64, delta: f64) -> Self {
let num_walks = Self::walks_for_epsilon(epsilon, delta);
Self {
alpha,
num_walks,
seed: 0,
}
}
/// Number of walks for additive error `epsilon` and failure
/// probability `delta` (Chernoff-style bound).
pub fn walks_for_epsilon(epsilon: f64, delta: f64) -> usize {
let eps = epsilon.max(1e-10);
let d = delta.max(1e-15);
((3.0 * (2.0 / d).ln()) / (eps * eps)).ceil() as usize
}
/// Set the random seed for reproducible results.
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = seed;
self
}
fn make_rng(&self) -> StdRng {
if self.seed == 0 {
StdRng::from_entropy()
} else {
StdRng::seed_from_u64(self.seed)
}
}
fn validate_params(&self) -> Result<(), SolverError> {
if self.alpha <= 0.0 || self.alpha >= 1.0 {
return Err(SolverError::InvalidInput(
ValidationError::ParameterOutOfRange {
name: "alpha".into(),
value: self.alpha.to_string(),
expected: "(0.0, 1.0) exclusive".into(),
},
));
}
if self.num_walks == 0 {
return Err(SolverError::InvalidInput(
ValidationError::ParameterOutOfRange {
name: "num_walks".into(),
value: "0".into(),
expected: "> 0".into(),
},
));
}
Ok(())
}
fn validate_graph_node(
graph: &CsrMatrix<f64>,
node: usize,
name: &str,
) -> Result<(), SolverError> {
if graph.rows != graph.cols {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(format!(
"graph must be square, got {}x{}",
graph.rows, graph.cols,
)),
));
}
if node >= graph.rows {
return Err(SolverError::InvalidInput(
ValidationError::ParameterOutOfRange {
name: name.into(),
value: node.to_string(),
expected: format!("[0, {})", graph.rows),
},
));
}
Ok(())
}
// -----------------------------------------------------------------------
// Core walk simulation
// -----------------------------------------------------------------------
/// Simulate a single random walk from `start`. Returns the endpoint.
#[inline]
fn single_walk(graph: &CsrMatrix<f64>, start: usize, alpha: f64, rng: &mut StdRng) -> usize {
let mut current = start;
loop {
if rng.gen::<f64>() < alpha {
return current;
}
let degree = graph.row_degree(current);
if degree == 0 {
return current; // dangling node
}
let row_start = graph.row_ptr[current];
current = graph.col_indices[row_start + rng.gen_range(0..degree)];
}
}
// -----------------------------------------------------------------------
// Public estimation methods
// -----------------------------------------------------------------------
/// Estimate PPR(source, target) via random walks with Welford
/// variance tracking and early termination.
pub fn estimate_entry(
&self,
graph: &CsrMatrix<f64>,
source: usize,
target: usize,
) -> Result<f64, SolverError> {
self.validate_params()?;
Self::validate_graph_node(graph, source, "source")?;
Self::validate_graph_node(graph, target, "target")?;
let mut rng = self.make_rng();
let mut welford = WelfordAccumulator::new();
let mut hit_count = 0u64;
for w in 0..self.num_walks {
let endpoint = Self::single_walk(graph, source, self.alpha, &mut rng);
let indicator = if endpoint == target { 1.0 } else { 0.0 };
welford.update(indicator);
if endpoint == target {
hit_count += 1;
}
if w >= MIN_WALKS_BEFORE_CV_CHECK && welford.cv() < CV_THRESHOLD {
debug!(
target: "ruvector_solver::random_walk",
walks = w + 1,
cv = welford.cv(),
"early termination: CV below threshold",
);
return Ok(hit_count as f64 / (w + 1) as f64);
}
}
Ok(hit_count as f64 / self.num_walks as f64)
}
/// Batch estimation of PPR(source, target) for multiple pairs.
pub fn estimate_batch(
&self,
graph: &CsrMatrix<f64>,
pairs: &[(usize, usize)],
) -> Result<Vec<f64>, SolverError> {
self.validate_params()?;
for &(s, t) in pairs {
Self::validate_graph_node(graph, s, "source")?;
Self::validate_graph_node(graph, t, "target")?;
}
let mut rng = self.make_rng();
let mut results = Vec::with_capacity(pairs.len());
for &(source, target) in pairs {
let mut welford = WelfordAccumulator::new();
let mut hit_count = 0u64;
let mut completed = self.num_walks;
for w in 0..self.num_walks {
let endpoint = Self::single_walk(graph, source, self.alpha, &mut rng);
welford.update(if endpoint == target { 1.0 } else { 0.0 });
if endpoint == target {
hit_count += 1;
}
if w >= MIN_WALKS_BEFORE_CV_CHECK && welford.cv() < CV_THRESHOLD {
completed = w + 1;
break;
}
}
results.push(hit_count as f64 / completed as f64);
}
Ok(results)
}
/// Compute a full approximate PPR vector from `source`.
pub fn ppr_from_source(
&self,
graph: &CsrMatrix<f64>,
source: usize,
) -> Result<Vec<(usize, f64)>, SolverError> {
self.ppr_from_source_with_params(graph, source, self.alpha, self.num_walks)
}
fn ppr_from_source_with_params(
&self,
graph: &CsrMatrix<f64>,
source: usize,
alpha: f64,
num_walks: usize,
) -> Result<Vec<(usize, f64)>, SolverError> {
Self::validate_graph_node(graph, source, "source")?;
#[cfg(feature = "parallel")]
{
return self.ppr_from_source_parallel(graph, source, alpha, num_walks);
}
#[cfg(not(feature = "parallel"))]
{
let mut rng = self.make_rng();
let mut counts = vec![0u64; graph.rows];
for _ in 0..num_walks {
let endpoint = Self::single_walk(graph, source, alpha, &mut rng);
counts[endpoint] += 1;
}
let inv = 1.0 / num_walks as f64;
let mut result: Vec<(usize, f64)> = counts
.into_iter()
.enumerate()
.filter(|(_, c)| *c > 0)
.map(|(v, c)| (v, c as f64 * inv))
.collect();
result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(result)
}
}
#[cfg(feature = "parallel")]
fn ppr_from_source_parallel(
&self,
graph: &CsrMatrix<f64>,
source: usize,
alpha: f64,
num_walks: usize,
) -> Result<Vec<(usize, f64)>, SolverError> {
use rayon::prelude::*;
let n = graph.rows;
// Split walks across threads, each with its own RNG derived from the base seed.
let num_chunks = rayon::current_num_threads().max(1);
let walks_per_chunk = num_walks / num_chunks;
let remainder = num_walks % num_chunks;
let counts: Vec<u64> = (0..num_chunks)
.into_par_iter()
.map(|chunk_idx| {
// Derive a per-chunk seed from the base seed.
let chunk_seed = if self.seed == 0 {
chunk_idx as u64 + 1
} else {
self.seed.wrapping_add(chunk_idx as u64 * 1000003)
};
let mut rng = StdRng::seed_from_u64(chunk_seed);
let chunk_walks = walks_per_chunk + if chunk_idx < remainder { 1 } else { 0 };
let mut local_counts = vec![0u64; n];
for _ in 0..chunk_walks {
let endpoint = Self::single_walk(graph, source, alpha, &mut rng);
local_counts[endpoint] += 1;
}
local_counts
})
.reduce(
|| vec![0u64; n],
|mut a, b| {
for (i, &v) in b.iter().enumerate() {
a[i] += v;
}
a
},
);
let inv = 1.0 / num_walks as f64;
let mut result: Vec<(usize, f64)> = counts
.into_iter()
.enumerate()
.filter(|(_, c)| *c > 0)
.map(|(v, c)| (v, c as f64 * inv))
.collect();
result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(result)
}
}
// ---------------------------------------------------------------------------
// SolverEngine
// ---------------------------------------------------------------------------
impl SolverEngine for HybridRandomWalkSolver {
fn solve(
&self,
matrix: &CsrMatrix<f64>,
rhs: &[f64],
budget: &ComputeBudget,
) -> Result<SolverResult, SolverError> {
let n = matrix.rows;
if n != matrix.cols {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(format!(
"HybridRandomWalk requires square matrix, got {}x{}",
matrix.rows, matrix.cols,
)),
));
}
if rhs.len() != n {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(format!(
"rhs length {} != matrix rows {}",
rhs.len(),
n,
)),
));
}
if n == 0 {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch("empty matrix".into()),
));
}
let start_time = Instant::now();
// Interpret rhs as a source distribution.
let rhs_sum: f64 = rhs.iter().map(|v| v.abs()).sum();
if rhs_sum < 1e-30 {
return Ok(SolverResult {
solution: vec![0.0f32; n],
iterations: 0,
residual_norm: 0.0,
wall_time: start_time.elapsed(),
convergence_history: vec![],
algorithm: Algorithm::HybridRandomWalk,
});
}
// Build CDF for source distribution.
let mut cdf = Vec::with_capacity(n);
let mut cumulative = 0.0;
for val in rhs.iter() {
cumulative += val.abs() / rhs_sum;
cdf.push(cumulative);
}
let walks = self.num_walks.min(budget.max_iterations.saturating_mul(10));
#[cfg(feature = "parallel")]
let counts = {
use rayon::prelude::*;
let num_chunks = rayon::current_num_threads().max(1);
let walks_per_chunk = walks / num_chunks;
let remainder = walks % num_chunks;
(0..num_chunks)
.into_par_iter()
.map(|chunk_idx| {
let chunk_seed = if self.seed == 0 {
chunk_idx as u64 + 1
} else {
self.seed.wrapping_add(chunk_idx as u64 * 1000003)
};
let mut rng = StdRng::seed_from_u64(chunk_seed);
let chunk_walks = walks_per_chunk + if chunk_idx < remainder { 1 } else { 0 };
let mut local_counts = vec![0.0f64; n];
for _ in 0..chunk_walks {
let r: f64 = rng.gen();
let start_node = cdf.partition_point(|&c| c < r).min(n - 1);
let endpoint = Self::single_walk(matrix, start_node, self.alpha, &mut rng);
local_counts[endpoint] += 1.0;
}
local_counts
})
.reduce(
|| vec![0.0f64; n],
|mut a, b| {
for (i, &v) in b.iter().enumerate() {
a[i] += v;
}
a
},
)
};
#[cfg(not(feature = "parallel"))]
let counts = {
let mut rng = self.make_rng();
let mut counts = vec![0.0f64; n];
for _ in 0..walks {
if start_time.elapsed() > budget.max_time {
return Err(SolverError::BudgetExhausted {
reason: "wall-clock time limit exceeded".into(),
elapsed: start_time.elapsed(),
});
}
let r: f64 = rng.gen();
let start_node = cdf.partition_point(|&c| c < r).min(n - 1);
let endpoint = Self::single_walk(matrix, start_node, self.alpha, &mut rng);
counts[endpoint] += 1.0;
}
counts
};
let scale = rhs_sum / (walks as f64);
let solution: Vec<f32> = counts.iter().map(|&c| (c * scale) as f32).collect();
// Compute residual: r = b - Ax.
let sol_f64: Vec<f64> = solution.iter().map(|&v| v as f64).collect();
let mut ax = vec![0.0f64; n];
matrix.spmv(&sol_f64, &mut ax);
let residual_norm = rhs
.iter()
.zip(ax.iter())
.map(|(&b, &a)| (b - a) * (b - a))
.sum::<f64>()
.sqrt();
Ok(SolverResult {
solution,
iterations: walks,
residual_norm,
wall_time: start_time.elapsed(),
convergence_history: vec![ConvergenceInfo {
iteration: 0,
residual_norm,
}],
algorithm: Algorithm::HybridRandomWalk,
})
}
fn estimate_complexity(&self, _profile: &SparsityProfile, _n: usize) -> ComplexityEstimate {
let avg_walk_len = (1.0 / self.alpha).ceil() as u64;
ComplexityEstimate {
algorithm: Algorithm::HybridRandomWalk,
estimated_flops: self.num_walks as u64 * avg_walk_len * 2,
estimated_iterations: self.num_walks,
estimated_memory_bytes: self.num_walks * 8,
complexity_class: ComplexityClass::SublinearNnz,
}
}
fn algorithm(&self) -> Algorithm {
Algorithm::HybridRandomWalk
}
}
// ---------------------------------------------------------------------------
// SublinearPageRank
// ---------------------------------------------------------------------------
impl SublinearPageRank for HybridRandomWalkSolver {
fn ppr(
&self,
matrix: &CsrMatrix<f64>,
source: usize,
alpha: f64,
epsilon: f64,
) -> Result<Vec<(usize, f64)>, SolverError> {
Self::validate_graph_node(matrix, source, "source")?;
let num_walks = Self::walks_for_epsilon(epsilon, DEFAULT_DELTA).max(self.num_walks);
let solver = HybridRandomWalkSolver {
alpha,
num_walks,
seed: self.seed,
};
solver.ppr_from_source_with_params(matrix, source, alpha, num_walks)
}
fn ppr_multi_seed(
&self,
matrix: &CsrMatrix<f64>,
seeds: &[(usize, f64)],
alpha: f64,
epsilon: f64,
) -> Result<Vec<(usize, f64)>, SolverError> {
for &(s, _) in seeds {
Self::validate_graph_node(matrix, s, "seed")?;
}
let n = matrix.rows;
let num_walks = Self::walks_for_epsilon(epsilon, DEFAULT_DELTA).max(self.num_walks);
// Build CDF over seed weights.
let weight_sum: f64 = seeds.iter().map(|(_, w)| w.abs()).sum();
if weight_sum < 1e-30 {
return Ok(Vec::new());
}
let mut cdf = Vec::with_capacity(seeds.len());
let mut cumulative = 0.0;
for &(_, w) in seeds {
cumulative += w.abs() / weight_sum;
cdf.push(cumulative);
}
let mut rng = self.make_rng();
let mut counts = vec![0u64; n];
for _ in 0..num_walks {
let r: f64 = rng.gen();
let seed_idx = cdf.partition_point(|&c| c < r).min(seeds.len() - 1);
let start = seeds[seed_idx].0;
let endpoint = Self::single_walk(matrix, start, alpha, &mut rng);
counts[endpoint] += 1;
}
let inv = 1.0 / num_walks as f64;
let mut result: Vec<(usize, f64)> = counts
.into_iter()
.enumerate()
.filter(|(_, c)| *c > 0)
.map(|(v, c)| (v, c as f64 * inv))
.collect();
result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(result)
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn directed_cycle(n: usize) -> CsrMatrix<f64> {
let entries: Vec<_> = (0..n).map(|i| (i, (i + 1) % n, 1.0f64)).collect();
CsrMatrix::<f64>::from_coo(n, n, entries)
}
fn star_to_center(n: usize) -> CsrMatrix<f64> {
let entries: Vec<_> = (1..n).map(|i| (i, 0, 1.0f64)).collect();
CsrMatrix::<f64>::from_coo(n, n, entries)
}
fn undirected_chain(n: usize) -> CsrMatrix<f64> {
let mut entries = Vec::new();
for i in 0..n {
let next = (i + 1) % n;
entries.push((i, next, 1.0f64));
entries.push((next, i, 1.0f64));
}
CsrMatrix::<f64>::from_coo(n, n, entries)
}
// ---- Welford ----
#[test]
fn welford_constant() {
let mut w = WelfordAccumulator::new();
for _ in 0..100 {
w.update(5.0);
}
assert!((w.mean - 5.0).abs() < 1e-12);
assert!(w.variance() < 1e-12);
}
#[test]
fn welford_binary() {
let mut w = WelfordAccumulator::new();
for i in 0..100 {
w.update(if i < 50 { 1.0 } else { 0.0 });
}
assert!((w.mean - 0.5).abs() < 1e-12);
assert!((w.variance() - 0.25).abs() < 0.01);
}
// ---- walks_for_epsilon ----
#[test]
fn walks_formula_reasonable() {
let w = HybridRandomWalkSolver::walks_for_epsilon(0.01, 0.01);
assert!(w > 100_000 && w < 500_000);
}
// ---- single_walk ----
#[test]
fn walk_single_node() {
let g = CsrMatrix::<f64>::from_coo(1, 1, Vec::<(usize, usize, f64)>::new());
let mut rng = StdRng::seed_from_u64(42);
assert_eq!(
HybridRandomWalkSolver::single_walk(&g, 0, 0.15, &mut rng),
0
);
}
#[test]
fn walk_high_alpha_stays_at_start() {
let g = directed_cycle(5);
let mut rng = StdRng::seed_from_u64(42);
assert_eq!(
HybridRandomWalkSolver::single_walk(&g, 2, 0.9999, &mut rng),
2,
);
}
// ---- estimate_entry ----
#[test]
fn entry_self_single_node() {
let g = CsrMatrix::<f64>::from_coo(1, 1, Vec::<(usize, usize, f64)>::new());
let s = HybridRandomWalkSolver::new(0.15, 1000).with_seed(42);
assert!((s.estimate_entry(&g, 0, 0).unwrap() - 1.0).abs() < 1e-10);
}
#[test]
fn entry_cycle_self_ppr() {
let g = directed_cycle(4);
let s = HybridRandomWalkSolver::new(0.15, 50_000).with_seed(123);
let p = s.estimate_entry(&g, 0, 0).unwrap();
assert!(p > 0.05 && p < 1.0, "ppr(0,0)={}", p);
}
#[test]
fn entry_star_to_center() {
let g = star_to_center(5);
let s = HybridRandomWalkSolver::new(0.15, 50_000).with_seed(99);
let p = s.estimate_entry(&g, 1, 0).unwrap();
assert!(p > 0.5, "expected > 0.5, got {}", p);
}
// ---- estimate_batch ----
#[test]
fn batch_non_negative() {
let g = directed_cycle(4);
let s = HybridRandomWalkSolver::new(0.15, 10_000).with_seed(42);
let b = s.estimate_batch(&g, &[(0, 0), (0, 1), (0, 2)]).unwrap();
assert_eq!(b.len(), 3);
assert!(b.iter().all(|&v| v >= 0.0));
}
// ---- ppr_from_source ----
#[test]
fn ppr_sums_to_one() {
let g = directed_cycle(5);
let s = HybridRandomWalkSolver::new(0.15, 50_000).with_seed(77);
let ppr = s.ppr_from_source(&g, 0).unwrap();
let total: f64 = ppr.iter().map(|(_, v)| v).sum();
assert!((total - 1.0).abs() < 0.05, "sum={}", total);
}
#[test]
fn ppr_sorted_descending() {
let g = directed_cycle(5);
let s = HybridRandomWalkSolver::new(0.15, 50_000).with_seed(88);
let ppr = s.ppr_from_source(&g, 0).unwrap();
for w in ppr.windows(2) {
assert!(w[0].1 >= w[1].1);
}
}
// ---- validation ----
#[test]
fn rejects_non_square() {
let g = CsrMatrix::<f64>::from_coo(2, 3, vec![(0, 1, 1.0f64)]);
let s = HybridRandomWalkSolver::new(0.15, 100);
assert!(s.estimate_entry(&g, 0, 0).is_err());
}
#[test]
fn rejects_oob() {
let g = CsrMatrix::<f64>::from_coo(3, 3, vec![(0, 1, 1.0f64)]);
let s = HybridRandomWalkSolver::new(0.15, 100);
assert!(s.estimate_entry(&g, 5, 0).is_err());
}
#[test]
fn rejects_bad_alpha() {
let g = CsrMatrix::<f64>::from_coo(3, 3, vec![(0, 1, 1.0f64)]);
assert!(HybridRandomWalkSolver::new(0.0, 100)
.estimate_entry(&g, 0, 0)
.is_err());
assert!(HybridRandomWalkSolver::new(1.0, 100)
.estimate_entry(&g, 0, 0)
.is_err());
}
#[test]
fn rejects_zero_walks() {
let g = CsrMatrix::<f64>::from_coo(3, 3, vec![(0, 1, 1.0f64)]);
assert!(HybridRandomWalkSolver::new(0.15, 0)
.estimate_entry(&g, 0, 0)
.is_err());
}
// ---- SolverEngine ----
#[test]
fn solver_engine() {
let g = directed_cycle(4);
let s = HybridRandomWalkSolver::new(0.15, 5_000).with_seed(42);
let r = s
.solve(&g, &[1.0, 0.0, 0.0, 0.0], &ComputeBudget::default())
.unwrap();
assert_eq!(r.algorithm, Algorithm::HybridRandomWalk);
assert_eq!(r.solution.len(), 4);
}
// ---- SublinearPageRank ----
#[test]
fn ppr_basic() {
let g = undirected_chain(5);
let s = HybridRandomWalkSolver::new(0.15, 10_000).with_seed(42);
let ppr = s.ppr(&g, 0, 0.15, 0.05).unwrap();
let source_ppr = ppr
.iter()
.find(|&&(n, _)| n == 0)
.map(|&(_, v)| v)
.unwrap_or(0.0);
assert!(source_ppr > 0.0);
let total: f64 = ppr.iter().map(|&(_, v)| v).sum();
assert!((total - 1.0).abs() < 0.1, "sum={}", total);
}
#[test]
fn ppr_multi_seed() {
let g = undirected_chain(5);
let s = HybridRandomWalkSolver::new(0.15, 10_000).with_seed(42);
let ppr = s
.ppr_multi_seed(&g, &[(0, 0.5), (2, 0.5)], 0.15, 0.05)
.unwrap();
let total: f64 = ppr.iter().map(|&(_, v)| v).sum();
assert!((total - 1.0).abs() < 0.1, "sum={}", total);
}
#[test]
fn invalid_source_ppr() {
let g = undirected_chain(3);
let s = HybridRandomWalkSolver::new(0.15, 100);
assert!(s.ppr(&g, 10, 0.15, 0.01).is_err());
}
// ---- complexity estimate ----
#[test]
fn complexity_reasonable() {
let s = HybridRandomWalkSolver::new(0.15, 10_000);
let p = SparsityProfile {
rows: 1000,
cols: 1000,
nnz: 5000,
density: 0.005,
is_diag_dominant: false,
estimated_spectral_radius: 0.9,
estimated_condition: 10.0,
is_symmetric_structure: false,
avg_nnz_per_row: 5.0,
max_nnz_per_row: 10,
};
let e = s.estimate_complexity(&p, 1000);
assert_eq!(e.algorithm, Algorithm::HybridRandomWalk);
assert_eq!(e.estimated_iterations, 10_000);
}
// ---- early termination ----
#[test]
fn early_termination() {
let g = CsrMatrix::<f64>::from_coo(1, 1, Vec::<(usize, usize, f64)>::new());
let s = HybridRandomWalkSolver::new(0.15, 1_000_000).with_seed(42);
let p = s.estimate_entry(&g, 0, 0).unwrap();
assert!((p - 1.0).abs() < 1e-10);
}
// ---- reproducibility ----
#[test]
fn deterministic_seed() {
let g = directed_cycle(10);
let s = HybridRandomWalkSolver::new(0.15, 10_000).with_seed(42);
let r1 = s.ppr_from_source(&g, 0).unwrap();
let r2 = s.ppr_from_source(&g, 0).unwrap();
assert_eq!(r1.len(), r2.len());
for (a, b) in r1.iter().zip(r2.iter()) {
assert_eq!(a.0, b.0);
assert!((a.1 - b.1).abs() < 1e-12);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,281 @@
//! SIMD-accelerated sparse matrix-vector multiply.
//!
//! Provides [`spmv_simd`], which dispatches to an architecture-specific
//! implementation when the `simd` feature is enabled, and falls back to a
//! portable scalar loop otherwise.
use crate::types::CsrMatrix;
/// Sparse matrix-vector multiply with optional SIMD acceleration.
///
/// Computes `y = A * x` where `A` is a CSR matrix of `f32` values.
pub fn spmv_simd(matrix: &CsrMatrix<f32>, x: &[f32], y: &mut [f32]) {
assert_eq!(x.len(), matrix.cols, "x length must equal matrix.cols");
assert_eq!(y.len(), matrix.rows, "y length must equal matrix.rows");
#[cfg(all(feature = "simd", target_arch = "x86_64"))]
{
if is_x86_feature_detected!("avx2") {
// SAFETY: we have checked for AVX2 support at runtime.
unsafe {
spmv_avx2(matrix, x, y);
}
return;
}
}
spmv_scalar(matrix, x, y);
}
/// Scalar fallback implementation of SpMV.
pub fn spmv_scalar(matrix: &CsrMatrix<f32>, x: &[f32], y: &mut [f32]) {
for i in 0..matrix.rows {
let start = matrix.row_ptr[i];
let end = matrix.row_ptr[i + 1];
let mut sum = 0.0f32;
for idx in start..end {
let col = matrix.col_indices[idx];
sum += matrix.values[idx] * x[col];
}
y[i] = sum;
}
}
/// AVX2-accelerated SpMV for x86_64.
///
/// # Safety
///
/// - The caller must ensure AVX2 is supported on the current CPU (checked at
/// runtime via `is_x86_feature_detected!("avx2")` in [`spmv_simd`]).
/// - The caller must ensure `x.len() >= matrix.cols` and
/// `y.len() >= matrix.rows`. These are asserted in [`spmv_simd`] before
/// dispatching here.
/// - The CSR matrix must be structurally valid: `row_ptr[i] <= row_ptr[i+1]`,
/// all `col_indices[j] < matrix.cols`, and `values.len() >= row_ptr[rows]`.
/// Use [`crate::validation::validate_csr_matrix`] before calling the solver
/// to guarantee this.
#[cfg(all(feature = "simd", target_arch = "x86_64"))]
#[target_feature(enable = "avx2")]
unsafe fn spmv_avx2(matrix: &CsrMatrix<f32>, x: &[f32], y: &mut [f32]) {
use std::arch::x86_64::*;
for i in 0..matrix.rows {
let start = matrix.row_ptr[i];
let end = matrix.row_ptr[i + 1];
let len = end - start;
let mut accum = _mm256_setzero_ps();
let chunks = len / 8;
let remainder = len % 8;
for chunk in 0..chunks {
let base = start + chunk * 8;
// SAFETY: `base + 7 < end <= values.len()` because
// `chunk < chunks` implies `base + 8 <= start + chunks * 8 <= end`.
let vals = _mm256_loadu_ps(matrix.values.as_ptr().add(base));
let mut x_buf = [0.0f32; 8];
for k in 0..8 {
// SAFETY: `base + k < end` so `col_indices[base + k]` is in
// bounds. `col < matrix.cols <= x.len()` by the CSR structural
// invariant (enforced by `validate_csr_matrix`).
let col = *matrix.col_indices.get_unchecked(base + k);
x_buf[k] = *x.get_unchecked(col);
}
let x_vec = _mm256_loadu_ps(x_buf.as_ptr());
accum = _mm256_add_ps(accum, _mm256_mul_ps(vals, x_vec));
}
let mut sum = horizontal_sum_f32x8(accum);
let tail_start = start + chunks * 8;
for idx in tail_start..(tail_start + remainder) {
// SAFETY: `idx < end <= values.len()` and `col < cols <= x.len()`
// by the same CSR structural invariant.
let col = *matrix.col_indices.get_unchecked(idx);
sum += *matrix.values.get_unchecked(idx) * *x.get_unchecked(col);
}
// SAFETY: `i < matrix.rows <= y.len()` by the assert in `spmv_simd`.
*y.get_unchecked_mut(i) = sum;
}
}
/// Horizontal sum of an AVX2 register (8 x f32 -> 1 x f32).
#[cfg(all(feature = "simd", target_arch = "x86_64"))]
#[target_feature(enable = "avx2")]
unsafe fn horizontal_sum_f32x8(v: std::arch::x86_64::__m256) -> f32 {
use std::arch::x86_64::*;
let hi = _mm256_extractf128_ps(v, 1);
let lo = _mm256_castps256_ps128(v);
let sum128 = _mm_add_ps(lo, hi);
let shuf = _mm_movehdup_ps(sum128);
let sums = _mm_add_ps(sum128, shuf);
let shuf2 = _mm_movehl_ps(sums, sums);
let result = _mm_add_ss(sums, shuf2);
_mm_cvtss_f32(result)
}
/// Sparse matrix-vector multiply with optional SIMD acceleration for f64.
///
/// Computes `y = A * x` where `A` is a CSR matrix of `f64` values.
pub fn spmv_simd_f64(matrix: &CsrMatrix<f64>, x: &[f64], y: &mut [f64]) {
assert_eq!(x.len(), matrix.cols, "x length must equal matrix.cols");
assert_eq!(y.len(), matrix.rows, "y length must equal matrix.rows");
#[cfg(all(feature = "simd", target_arch = "x86_64"))]
{
if is_x86_feature_detected!("avx2") {
unsafe {
spmv_avx2_f64(matrix, x, y);
}
return;
}
}
spmv_scalar_f64(matrix, x, y);
}
/// Scalar fallback for f64 SpMV.
pub fn spmv_scalar_f64(matrix: &CsrMatrix<f64>, x: &[f64], y: &mut [f64]) {
for i in 0..matrix.rows {
let start = matrix.row_ptr[i];
let end = matrix.row_ptr[i + 1];
let mut sum = 0.0f64;
for idx in start..end {
let col = matrix.col_indices[idx];
sum += matrix.values[idx] * x[col];
}
y[i] = sum;
}
}
#[cfg(all(feature = "simd", target_arch = "x86_64"))]
#[target_feature(enable = "avx2")]
unsafe fn spmv_avx2_f64(matrix: &CsrMatrix<f64>, x: &[f64], y: &mut [f64]) {
use std::arch::x86_64::*;
for i in 0..matrix.rows {
let start = matrix.row_ptr[i];
let end = matrix.row_ptr[i + 1];
let len = end - start;
let mut accum = _mm256_setzero_pd();
let chunks = len / 4;
let remainder = len % 4;
for chunk in 0..chunks {
let base = start + chunk * 4;
let vals = _mm256_loadu_pd(matrix.values.as_ptr().add(base));
let mut x_buf = [0.0f64; 4];
for k in 0..4 {
let col = *matrix.col_indices.get_unchecked(base + k);
x_buf[k] = *x.get_unchecked(col);
}
let x_vec = _mm256_loadu_pd(x_buf.as_ptr());
accum = _mm256_add_pd(accum, _mm256_mul_pd(vals, x_vec));
}
let mut sum = horizontal_sum_f64x4(accum);
let tail_start = start + chunks * 4;
for idx in tail_start..(tail_start + remainder) {
let col = *matrix.col_indices.get_unchecked(idx);
sum += *matrix.values.get_unchecked(idx) * *x.get_unchecked(col);
}
*y.get_unchecked_mut(i) = sum;
}
}
#[cfg(all(feature = "simd", target_arch = "x86_64"))]
#[target_feature(enable = "avx2")]
unsafe fn horizontal_sum_f64x4(v: std::arch::x86_64::__m256d) -> f64 {
use std::arch::x86_64::*;
let hi = _mm256_extractf128_pd(v, 1);
let lo = _mm256_castpd256_pd128(v);
let sum128 = _mm_add_pd(lo, hi);
let hi64 = _mm_unpackhi_pd(sum128, sum128);
let result = _mm_add_sd(sum128, hi64);
_mm_cvtsd_f64(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::CsrMatrix;
fn make_test_matrix() -> (CsrMatrix<f32>, Vec<f32>) {
// [2 0 1] [1] [5]
// [0 3 0] * [2] = [6]
// [1 0 4] [3] [13]
let mat = CsrMatrix {
values: vec![2.0, 1.0, 3.0, 1.0, 4.0],
col_indices: vec![0, 2, 1, 0, 2],
row_ptr: vec![0, 2, 3, 5],
rows: 3,
cols: 3,
};
let x = vec![1.0, 2.0, 3.0];
(mat, x)
}
#[test]
fn scalar_spmv_correctness() {
let (mat, x) = make_test_matrix();
let mut y = vec![0.0f32; 3];
spmv_scalar(&mat, &x, &mut y);
assert!((y[0] - 5.0).abs() < 1e-6);
assert!((y[1] - 6.0).abs() < 1e-6);
assert!((y[2] - 13.0).abs() < 1e-6);
}
#[test]
fn spmv_simd_dispatch() {
let (mat, x) = make_test_matrix();
let mut y = vec![0.0f32; 3];
spmv_simd(&mat, &x, &mut y);
assert!((y[0] - 5.0).abs() < 1e-6);
assert!((y[1] - 6.0).abs() < 1e-6);
assert!((y[2] - 13.0).abs() < 1e-6);
}
#[test]
fn spmv_simd_f64_correctness() {
let mat = CsrMatrix::<f64> {
values: vec![2.0, 1.0, 3.0, 1.0, 4.0],
col_indices: vec![0, 2, 1, 0, 2],
row_ptr: vec![0, 2, 3, 5],
rows: 3,
cols: 3,
};
let x = vec![1.0, 2.0, 3.0];
let mut y = vec![0.0f64; 3];
spmv_simd_f64(&mat, &x, &mut y);
assert!((y[0] - 5.0).abs() < 1e-10);
assert!((y[1] - 6.0).abs() < 1e-10);
assert!((y[2] - 13.0).abs() < 1e-10);
}
#[test]
fn scalar_spmv_f64_correctness() {
let mat = CsrMatrix::<f64> {
values: vec![2.0, 1.0, 3.0, 1.0, 4.0],
col_indices: vec![0, 2, 1, 0, 2],
row_ptr: vec![0, 2, 3, 5],
rows: 3,
cols: 3,
};
let x = vec![1.0, 2.0, 3.0];
let mut y = vec![0.0f64; 3];
spmv_scalar_f64(&mat, &x, &mut y);
assert!((y[0] - 5.0).abs() < 1e-10);
assert!((y[1] - 6.0).abs() < 1e-10);
assert!((y[2] - 13.0).abs() < 1e-10);
}
}

View File

@@ -0,0 +1,134 @@
//! Solver trait hierarchy.
//!
//! All solver algorithms implement [`SolverEngine`]. Specialised traits
//! ([`SparseLaplacianSolver`], [`SublinearPageRank`]) extend it with
//! domain-specific operations.
use crate::error::SolverError;
use crate::types::{
Algorithm, ComplexityEstimate, ComputeBudget, CsrMatrix, SolverResult, SparsityProfile,
};
/// Core trait that every solver algorithm must implement.
///
/// A `SolverEngine` accepts a sparse matrix system and a compute budget,
/// returning either a [`SolverResult`] or a structured [`SolverError`].
pub trait SolverEngine: Send + Sync {
/// Solve the linear system `A x = b` (or the equivalent iterative
/// problem) subject to the given compute budget.
///
/// # Arguments
///
/// * `matrix` - the sparse coefficient matrix.
/// * `rhs` - the right-hand side vector `b`.
/// * `budget` - resource limits for this invocation.
///
/// # Errors
///
/// Returns [`SolverError`] on non-convergence, numerical issues, budget
/// exhaustion, or invalid input.
fn solve(
&self,
matrix: &CsrMatrix<f64>,
rhs: &[f64],
budget: &ComputeBudget,
) -> Result<SolverResult, SolverError>;
/// Estimate the computational cost of solving the given system without
/// actually performing the solve.
///
/// Implementations should use the [`SparsityProfile`] to make a fast,
/// heuristic prediction.
fn estimate_complexity(&self, profile: &SparsityProfile, n: usize) -> ComplexityEstimate;
/// Return the algorithm identifier for this engine.
fn algorithm(&self) -> Algorithm;
}
/// Extended trait for solvers that operate on graph Laplacian systems.
///
/// A graph Laplacian `L = D - A` arises naturally in spectral graph theory.
/// Solvers implementing this trait can exploit Laplacian structure (e.g.
/// guaranteed positive semi-definiteness, kernel spanned by the all-ones
/// vector) for faster convergence.
pub trait SparseLaplacianSolver: SolverEngine {
/// Solve `L x = b` where `L` is a graph Laplacian.
///
/// The solver may add a small regulariser to handle the rank-deficient
/// case (connected component with zero eigenvalue).
///
/// # Errors
///
/// Returns [`SolverError`] on failure.
fn solve_laplacian(
&self,
laplacian: &CsrMatrix<f64>,
rhs: &[f64],
budget: &ComputeBudget,
) -> Result<SolverResult, SolverError>;
/// Compute the effective resistance between two nodes.
///
/// Effective resistance `R(s, t) = (e_s - e_t)^T L^+ (e_s - e_t)` is
/// a fundamental quantity in spectral graph theory.
fn effective_resistance(
&self,
laplacian: &CsrMatrix<f64>,
source: usize,
target: usize,
budget: &ComputeBudget,
) -> Result<f64, SolverError>;
}
/// Trait for sublinear-time Personalized PageRank (PPR) algorithms.
///
/// PPR is central to nearest-neighbour search in large graphs. Algorithms
/// implementing this trait run in time proportional to the output size
/// rather than the full graph size.
pub trait SublinearPageRank: Send + Sync {
/// Compute a sparse approximate PPR vector from a single source node.
///
/// # Arguments
///
/// * `matrix` - column-stochastic transition matrix (or CSR adjacency).
/// * `source` - index of the source (seed) node.
/// * `alpha` - teleportation probability (typically 0.15).
/// * `epsilon` - approximation tolerance; controls output sparsity.
///
/// # Returns
///
/// A vector of `(node_index, ppr_value)` pairs whose values sum to
/// approximately 1.
///
/// # Errors
///
/// Returns [`SolverError`] on invalid input or budget exhaustion.
fn ppr(
&self,
matrix: &CsrMatrix<f64>,
source: usize,
alpha: f64,
epsilon: f64,
) -> Result<Vec<(usize, f64)>, SolverError>;
/// Compute PPR from a distribution over seed nodes rather than a single
/// source.
///
/// # Arguments
///
/// * `matrix` - column-stochastic transition matrix.
/// * `seeds` - `(node_index, weight)` pairs forming the seed distribution.
/// * `alpha` - teleportation probability.
/// * `epsilon` - approximation tolerance.
///
/// # Errors
///
/// Returns [`SolverError`] on invalid input or budget exhaustion.
fn ppr_multi_seed(
&self,
matrix: &CsrMatrix<f64>,
seeds: &[(usize, f64)],
alpha: f64,
epsilon: f64,
) -> Result<Vec<(usize, f64)>, SolverError>;
}

View File

@@ -0,0 +1,932 @@
//! TRUE (Toolbox for Research on Universal Estimation) solver.
//!
//! Achieves O(log n) solving via a three-phase pipeline:
//!
//! 1. **Johnson-Lindenstrauss projection** -- reduces dimensionality from n to
//! k = O(log(n)/eps^2) using a sparse random projection matrix.
//! 2. **Spectral sparsification** -- approximates the projected matrix by
//! sampling edges proportional to effective resistance (uniform sampling
//! with reweighting as a practical approximation).
//! 3. **Neumann series solve** -- solves the sparsified system using the
//! truncated Neumann series, then back-projects to the original space.
//!
//! # Error budget
//!
//! The user-specified tolerance `eps` is split evenly across the three phases:
//! `eps_jl = eps/3`, `eps_sparsify = eps/3`, `eps_solve = eps/3`.
//!
//! # Preprocessing
//!
//! The JL matrix and sparsifier are cached in [`TruePreprocessing`] so that
//! multiple right-hand sides can be solved against the same matrix without
//! repeating the projection/sparsification work.
use std::time::Instant;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use crate::error::{SolverError, ValidationError};
use crate::traits::SolverEngine;
use crate::types::{Algorithm, ConvergenceInfo, CsrMatrix, SolverResult};
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
/// TRUE solver configuration.
///
/// The three-phase pipeline (JL projection, spectral sparsification, Neumann
/// solve) is controlled by `tolerance`, `jl_dimension`, and
/// `sparsification_eps`.
#[derive(Debug, Clone)]
pub struct TrueSolver {
/// Global tolerance for the solve. Split as eps/3 across phases.
tolerance: f64,
/// Target dimension after JL projection.
/// When set to 0, the dimension is computed automatically as
/// `ceil(C * ln(n) / eps_jl^2)` with C = 4.
jl_dimension: usize,
/// Spectral sparsification quality parameter (epsilon for sampling).
sparsification_eps: f64,
/// Maximum iterations for the internal Neumann solve.
max_iterations: usize,
/// Deterministic seed for the random projection.
seed: u64,
}
impl TrueSolver {
/// Create a new TRUE solver with explicit parameters.
///
/// # Arguments
///
/// * `tolerance` - Target residual tolerance. Must be in (0, 1).
/// * `jl_dimension` - Target dimension after JL projection. Pass 0 to
/// auto-compute from `n` and `tolerance`.
/// * `sparsification_eps` - Sparsification quality. Must be in (0, 1).
pub fn new(tolerance: f64, jl_dimension: usize, sparsification_eps: f64) -> Self {
Self {
tolerance,
jl_dimension,
sparsification_eps,
max_iterations: 500,
seed: 42,
}
}
/// Set the maximum number of Neumann iterations.
pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
self.max_iterations = max_iterations;
self
}
/// Set a deterministic seed for random projection generation.
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = seed;
self
}
/// Compute the JL target dimension from the original dimension `n`.
///
/// k = ceil(C * ln(n) / eps_jl^2) where C = 4, eps_jl = tolerance / 3.
fn compute_jl_dim(&self, n: usize) -> usize {
if self.jl_dimension > 0 {
return self.jl_dimension;
}
let eps_jl = self.tolerance / 3.0;
let c = 4.0;
let k = (c * (n as f64).ln() / (eps_jl * eps_jl)).ceil() as usize;
// Clamp: at least 1, at most n (no point projecting to a bigger space).
k.clamp(1, n)
}
// -----------------------------------------------------------------------
// Phase 1: Johnson-Lindenstrauss Projection
// -----------------------------------------------------------------------
/// Generate a sparse random JL projection matrix in COO format.
///
/// Each entry is drawn from the distribution:
/// - +1/sqrt(k) with probability 1/6
/// - -1/sqrt(k) with probability 1/6
/// - 0 with probability 2/3
///
/// Returns a list of (row, col, value) triples.
fn generate_jl_matrix(&self, k: usize, n: usize, rng: &mut StdRng) -> Vec<(usize, usize, f32)> {
let scale = 1.0 / (k as f64).sqrt();
let scale_f32 = scale as f32;
let mut entries = Vec::with_capacity(((k * n) as f64 / 3.0).ceil() as usize);
for row in 0..k {
for col in 0..n {
let r: f64 = rng.gen();
if r < 1.0 / 6.0 {
entries.push((row, col, scale_f32));
} else if r < 2.0 / 6.0 {
entries.push((row, col, -scale_f32));
}
// else: 0 with prob 2/3, skip
}
}
entries
}
/// Project the right-hand side vector: b' = Pi * b.
fn project_rhs(jl_entries: &[(usize, usize, f32)], rhs: &[f32], k: usize) -> Vec<f32> {
let mut projected = vec![0.0f32; k];
for &(row, col, val) in jl_entries {
if col < rhs.len() {
projected[row] += val * rhs[col];
}
}
projected
}
/// Project the matrix: A' = Pi * A * Pi^T.
///
/// Computed as:
/// 1. B = Pi * A (k x n)
/// 2. A' = B * Pi^T (k x k)
///
/// The result is built in COO format, then converted to CSR.
fn project_matrix(
jl_entries: &[(usize, usize, f32)],
matrix: &CsrMatrix<f32>,
k: usize,
) -> CsrMatrix<f32> {
let n = matrix.cols;
// Build Pi as CSR for efficient access.
let pi = CsrMatrix::<f32>::from_coo(k, n, jl_entries.iter().cloned());
// Step 1: B = Pi * A. B is k x n.
// For each row i of Pi, compute B[i,:] = Pi[i,:] * A.
let mut b_entries: Vec<(usize, usize, f32)> = Vec::new();
// Hoist accumulator outside loop to avoid reallocating each iteration.
let mut b_row = vec![0.0f32; n];
for pi_row in 0..k {
let pi_start = pi.row_ptr[pi_row];
let pi_end = pi.row_ptr[pi_row + 1];
for pi_idx in pi_start..pi_end {
let pi_col = pi.col_indices[pi_idx];
let pi_val = pi.values[pi_idx];
let a_start = matrix.row_ptr[pi_col];
let a_end = matrix.row_ptr[pi_col + 1];
for a_idx in a_start..a_end {
b_row[matrix.col_indices[a_idx]] += pi_val * matrix.values[a_idx];
}
}
for (col, &val) in b_row.iter().enumerate() {
if val.abs() > f32::EPSILON {
b_entries.push((pi_row, col, val));
}
}
// Zero the accumulator for the next row.
b_row.iter_mut().for_each(|v| *v = 0.0);
}
let b_matrix = CsrMatrix::<f32>::from_coo(k, n, b_entries);
// Step 2: A' = B * Pi^T. A' is k x k.
// Build a column-index for Pi so we can compute Pi^T efficiently.
let mut pi_by_col: Vec<Vec<(usize, f32)>> = vec![Vec::new(); n];
for pi_row in 0..k {
let start = pi.row_ptr[pi_row];
let end = pi.row_ptr[pi_row + 1];
for idx in start..end {
pi_by_col[pi.col_indices[idx]].push((pi_row, pi.values[idx]));
}
}
let mut a_prime_entries: Vec<(usize, usize, f32)> = Vec::new();
// Hoist accumulator outside loop to avoid reallocating each iteration.
let mut row_accum = vec![0.0f32; k];
for b_row_idx in 0..k {
let b_start = b_matrix.row_ptr[b_row_idx];
let b_end = b_matrix.row_ptr[b_row_idx + 1];
for b_idx in b_start..b_end {
let l = b_matrix.col_indices[b_idx];
let b_val = b_matrix.values[b_idx];
for &(j, pi_val) in &pi_by_col[l] {
row_accum[j] += b_val * pi_val;
}
}
for (j, &val) in row_accum.iter().enumerate() {
if val.abs() > f32::EPSILON {
a_prime_entries.push((b_row_idx, j, val));
}
}
// Zero the accumulator for the next row.
row_accum.iter_mut().for_each(|v| *v = 0.0);
}
CsrMatrix::<f32>::from_coo(k, k, a_prime_entries)
}
// -----------------------------------------------------------------------
// Phase 2: Spectral Sparsification
// -----------------------------------------------------------------------
/// Sparsify the projected matrix by uniform edge sampling with
/// reweighting.
///
/// Samples O(k * log(k) / eps^2) non-zero entries and reweights them by
/// 1/probability to maintain the expected value. Diagonal entries are
/// always preserved to maintain positive-definiteness.
fn sparsify(matrix: &CsrMatrix<f32>, eps: f64, rng: &mut StdRng) -> CsrMatrix<f32> {
let n = matrix.rows;
let nnz = matrix.nnz();
if nnz == 0 || n == 0 {
return CsrMatrix::<f32>::from_coo(n, matrix.cols, std::iter::empty());
}
// Target number of samples: O(n * log(n) / eps^2).
let target_samples =
((n as f64) * ((n as f64).ln().max(1.0)) / (eps * eps)).ceil() as usize;
// If the target exceeds actual nnz, keep everything.
if target_samples >= nnz {
return matrix.clone();
}
let keep_prob = (target_samples as f64) / (nnz as f64);
let reweight = (1.0 / keep_prob) as f32;
let mut entries: Vec<(usize, usize, f32)> = Vec::with_capacity(target_samples);
for row in 0..n {
let start = matrix.row_ptr[row];
let end = matrix.row_ptr[row + 1];
for idx in start..end {
let col = matrix.col_indices[idx];
// Always keep diagonal entries unmodified.
if row == col {
entries.push((row, col, matrix.values[idx]));
continue;
}
let r: f64 = rng.gen();
if r < keep_prob {
entries.push((row, col, matrix.values[idx] * reweight));
}
}
}
CsrMatrix::<f32>::from_coo(n, matrix.cols, entries)
}
// -----------------------------------------------------------------------
// Phase 3: Neumann Series Solve
// -----------------------------------------------------------------------
/// Solve Ax = b using the Jacobi-preconditioned Neumann series.
///
/// The Neumann series x = sum_{k=0}^{K} M^k b_hat converges when the
/// spectral radius of M = I - D^{-1}A is less than 1, which is
/// guaranteed for diagonally dominant systems. Diagonal (Jacobi)
/// preconditioning is applied to improve convergence.
fn neumann_solve(
matrix: &CsrMatrix<f32>,
rhs: &[f32],
tolerance: f64,
max_iterations: usize,
) -> Result<(Vec<f32>, usize, f64, Vec<ConvergenceInfo>), SolverError> {
let n = matrix.rows;
if n == 0 {
return Ok((Vec::new(), 0, 0.0, Vec::new()));
}
// Extract diagonal for Jacobi preconditioning.
let mut diag = vec![1.0f32; n];
for row in 0..n {
let start = matrix.row_ptr[row];
let end = matrix.row_ptr[row + 1];
for idx in start..end {
if matrix.col_indices[idx] == row {
let d = matrix.values[idx];
if d.abs() > f32::EPSILON {
diag[row] = d;
}
break;
}
}
}
let inv_diag: Vec<f32> = diag.iter().map(|&d| 1.0 / d).collect();
// Preconditioned rhs: b_hat = D^{-1} * b
let b_hat: Vec<f32> = rhs
.iter()
.zip(inv_diag.iter())
.map(|(&b, &d)| b * d)
.collect();
// Neumann series: x = sum_{k=0}^K M^k * b_hat
// where M = I - D^{-1} * A.
// Iteratively: term_{k+1} = M * term_k, x += term_{k+1}
let mut solution = b_hat.clone();
let mut term = b_hat;
let mut convergence_history = Vec::new();
let rhs_norm: f64 = rhs
.iter()
.map(|&v| (v as f64) * (v as f64))
.sum::<f64>()
.sqrt();
let abs_tol = if rhs_norm > f64::EPSILON {
tolerance * rhs_norm
} else {
tolerance
};
let mut iterations = 0;
let mut residual_norm = f64::MAX;
for iter in 0..max_iterations {
// new_term = M * term = term - D^{-1} * A * term
let mut a_term = vec![0.0f32; n];
matrix.spmv(&term, &mut a_term);
let mut new_term = vec![0.0f32; n];
for i in 0..n {
new_term[i] = term[i] - inv_diag[i] * a_term[i];
}
for i in 0..n {
solution[i] += new_term[i];
}
// ||new_term||_2 as a convergence proxy.
let term_norm: f64 = new_term
.iter()
.map(|&v| (v as f64) * (v as f64))
.sum::<f64>()
.sqrt();
iterations = iter + 1;
residual_norm = term_norm;
convergence_history.push(ConvergenceInfo {
iteration: iterations,
residual_norm,
});
if term_norm < abs_tol {
break;
}
if term_norm.is_nan() || term_norm.is_infinite() {
return Err(SolverError::NumericalInstability {
iteration: iterations,
detail: format!(
"Neumann term norm diverged to {} at iteration {}",
term_norm, iterations
),
});
}
term = new_term;
}
Ok((solution, iterations, residual_norm, convergence_history))
}
// -----------------------------------------------------------------------
// Back-projection
// -----------------------------------------------------------------------
/// Back-project solution from reduced space: x = Pi^T * x'.
fn back_project(
jl_entries: &[(usize, usize, f32)],
projected_solution: &[f32],
original_cols: usize,
) -> Vec<f32> {
let mut result = vec![0.0f32; original_cols];
for &(row, col, val) in jl_entries {
if row < projected_solution.len() && col < original_cols {
result[col] += val * projected_solution[row];
}
}
result
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
/// Preprocess a matrix: generate the JL projection and sparsifier.
///
/// The returned [`TruePreprocessing`] can be reused across multiple
/// right-hand sides to amortize the cost of projection and
/// sparsification.
pub fn preprocess(&self, matrix: &CsrMatrix<f32>) -> Result<TruePreprocessing, SolverError> {
Self::validate_matrix(matrix)?;
let n = matrix.rows;
let k = self.compute_jl_dim(n);
let mut rng = StdRng::seed_from_u64(self.seed);
// Phase 1: Generate JL projection and project the matrix.
let jl_matrix = self.generate_jl_matrix(k, n, &mut rng);
let projected = Self::project_matrix(&jl_matrix, matrix, k);
// Phase 2: Sparsify the projected matrix.
let eps_sparsify = self.sparsification_eps.max(self.tolerance / 3.0);
let sparsified = Self::sparsify(&projected, eps_sparsify, &mut rng);
Ok(TruePreprocessing {
jl_matrix,
sparsified_matrix: sparsified,
original_rows: matrix.rows,
original_cols: matrix.cols,
})
}
/// Solve using a previously computed preprocessing.
///
/// This is the fast path when solving multiple systems with the same
/// coefficient matrix but different right-hand sides.
pub fn solve_with_preprocessing(
&self,
preprocessing: &TruePreprocessing,
rhs: &[f32],
) -> Result<SolverResult, SolverError> {
if rhs.len() != preprocessing.original_rows {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(format!(
"rhs length {} does not match matrix rows {}",
rhs.len(),
preprocessing.original_rows
)),
));
}
let start = Instant::now();
let k = preprocessing.sparsified_matrix.rows;
// Phase 1: Project the rhs.
let projected_rhs = Self::project_rhs(&preprocessing.jl_matrix, rhs, k);
// Phase 3: Neumann solve on sparsified system.
let eps_solve = self.tolerance / 3.0;
let (projected_solution, iterations, residual_norm, convergence_history) =
Self::neumann_solve(
&preprocessing.sparsified_matrix,
&projected_rhs,
eps_solve,
self.max_iterations,
)?;
// Back-project to original space.
let solution = Self::back_project(
&preprocessing.jl_matrix,
&projected_solution,
preprocessing.original_cols,
);
Ok(SolverResult {
solution,
iterations,
residual_norm,
wall_time: start.elapsed(),
convergence_history,
algorithm: Algorithm::TRUE,
})
}
/// Validate matrix dimensions and structure.
fn validate_matrix(matrix: &CsrMatrix<f32>) -> Result<(), SolverError> {
if matrix.rows == 0 || matrix.cols == 0 {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(
"matrix must have at least one row and one column".to_string(),
),
));
}
if matrix.rows != matrix.cols {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(format!(
"TRUE solver requires a square matrix, got {}x{}",
matrix.rows, matrix.cols
)),
));
}
if matrix.row_ptr.len() != matrix.rows + 1 {
return Err(SolverError::InvalidInput(
ValidationError::DimensionMismatch(format!(
"row_ptr length {} does not match rows + 1 = {}",
matrix.row_ptr.len(),
matrix.rows + 1
)),
));
}
for (i, &v) in matrix.values.iter().enumerate() {
if v.is_nan() || v.is_infinite() {
return Err(SolverError::InvalidInput(ValidationError::NonFiniteValue(
format!("matrix value at index {} is {}", i, v),
)));
}
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// SolverEngine trait implementation
// ---------------------------------------------------------------------------
impl SolverEngine for TrueSolver {
fn solve(
&self,
matrix: &CsrMatrix<f64>,
rhs: &[f64],
_budget: &crate::types::ComputeBudget,
) -> Result<SolverResult, SolverError> {
// Validate that f64 values fit in f32 range.
for (i, &v) in matrix.values.iter().enumerate() {
if v.is_finite() && v.abs() > f32::MAX as f64 {
return Err(SolverError::InvalidInput(ValidationError::NonFiniteValue(
format!("matrix value at index {i} ({v:.6e}) overflows f32"),
)));
}
}
for (i, &v) in rhs.iter().enumerate() {
if v.is_finite() && v.abs() > f32::MAX as f64 {
return Err(SolverError::InvalidInput(ValidationError::NonFiniteValue(
format!("rhs value at index {i} ({v:.6e}) overflows f32"),
)));
}
}
// Convert f64 input to f32 for internal computation.
// NOTE: row_ptr and col_indices are cloned here because CsrMatrix owns
// Vec<usize>, so we cannot borrow from the f64 matrix. A future
// refactor could introduce a CsrMatrixView that borrows structural
// arrays to eliminate these allocations on the f64 -> f32 path.
let f32_values: Vec<f32> = matrix.values.iter().map(|&v| v as f32).collect();
let f32_matrix = CsrMatrix {
row_ptr: matrix.row_ptr.clone(),
col_indices: matrix.col_indices.clone(),
values: f32_values,
rows: matrix.rows,
cols: matrix.cols,
};
let f32_rhs: Vec<f32> = rhs.iter().map(|&v| v as f32).collect();
let preprocessing = self.preprocess(&f32_matrix)?;
self.solve_with_preprocessing(&preprocessing, &f32_rhs)
}
fn estimate_complexity(
&self,
profile: &crate::types::SparsityProfile,
n: usize,
) -> crate::types::ComplexityEstimate {
let k = self.compute_jl_dim(n);
crate::types::ComplexityEstimate {
algorithm: Algorithm::TRUE,
estimated_flops: (k as u64) * (profile.nnz as u64) * 3,
estimated_iterations: self.max_iterations.min(100),
estimated_memory_bytes: k * k * 4 + n * 4 * 2,
complexity_class: crate::types::ComplexityClass::SublinearNnz,
}
}
fn algorithm(&self) -> Algorithm {
Algorithm::TRUE
}
}
// ---------------------------------------------------------------------------
// Preprocessing cache
// ---------------------------------------------------------------------------
/// Cached preprocessing data from the JL projection and spectral
/// sparsification phases.
///
/// Store this struct and pass it to
/// [`TrueSolver::solve_with_preprocessing`] to amortize the cost of
/// preprocessing across multiple solves with the same coefficient matrix.
#[derive(Debug, Clone)]
pub struct TruePreprocessing {
/// Sparse JL projection matrix in COO format (row, col, value).
pub jl_matrix: Vec<(usize, usize, f32)>,
/// The sparsified projected matrix in CSR format.
pub sparsified_matrix: CsrMatrix<f32>,
/// Number of rows in the original matrix.
pub original_rows: usize,
/// Number of columns in the original matrix.
pub original_cols: usize,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
/// Build a diagonally dominant symmetric matrix.
///
/// Returns an n x n matrix where A[i,i] = 3.0 and off-diagonal
/// neighbours A[i,i+1] = A[i+1,i] = -0.5.
fn make_diag_dominant(n: usize) -> CsrMatrix<f32> {
let mut entries = Vec::new();
for i in 0..n {
entries.push((i, i, 3.0f32));
if i + 1 < n {
entries.push((i, i + 1, -0.5));
entries.push((i + 1, i, -0.5));
}
}
CsrMatrix::<f32>::from_coo(n, n, entries)
}
#[test]
fn test_jl_dimension_auto() {
let solver = TrueSolver::new(0.3, 0, 0.1);
let dim = solver.compute_jl_dim(1000);
assert!(dim >= 1);
assert!(dim <= 1000);
}
#[test]
fn test_jl_dimension_explicit() {
let solver = TrueSolver::new(0.1, 50, 0.1);
let dim = solver.compute_jl_dim(1000);
assert_eq!(dim, 50);
}
#[test]
fn test_jl_matrix_sparsity() {
let solver = TrueSolver::new(0.1, 10, 0.1);
let mut rng = StdRng::seed_from_u64(42);
let jl = solver.generate_jl_matrix(10, 100, &mut rng);
// Expected density: ~1/3 of 10*100 = 1000. Should be sparse.
assert!(!jl.is_empty());
assert!(jl.len() < 1000);
}
#[test]
fn test_jl_matrix_values() {
let solver = TrueSolver::new(0.1, 5, 0.1);
let mut rng = StdRng::seed_from_u64(42);
let jl = solver.generate_jl_matrix(5, 20, &mut rng);
let scale = 1.0 / (5.0f64).sqrt();
let scale_f32 = scale as f32;
for &(row, col, val) in &jl {
assert!(row < 5);
assert!(col < 20);
assert!(
(val - scale_f32).abs() < f32::EPSILON || (val + scale_f32).abs() < f32::EPSILON,
"unexpected JL value: {}",
val
);
}
}
#[test]
fn test_project_rhs() {
let entries = vec![(0, 0, 1.0f32), (0, 1, -1.0), (1, 1, 2.0)];
let rhs = vec![3.0, 4.0];
let projected = TrueSolver::project_rhs(&entries, &rhs, 2);
assert!((projected[0] - (-1.0)).abs() < 1e-6);
assert!((projected[1] - 8.0).abs() < 1e-6);
}
#[test]
fn test_back_project() {
let entries = vec![(0, 0, 1.0f32), (0, 1, -1.0), (1, 1, 2.0)];
let projected_sol = vec![3.0, 4.0];
let result = TrueSolver::back_project(&entries, &projected_sol, 2);
// result[0] = Pi^T[0,0]*3 = 1*3 = 3
// result[1] = Pi^T[1,0]*3 + Pi^T[1,1]*4 = (-1)*3 + 2*4 = 5
assert!((result[0] - 3.0).abs() < 1e-6);
assert!((result[1] - 5.0).abs() < 1e-6);
}
#[test]
fn test_neumann_identity() {
let identity = CsrMatrix::<f32>::identity(3);
let rhs = vec![1.0, 2.0, 3.0];
let (solution, iterations, residual, _) =
TrueSolver::neumann_solve(&identity, &rhs, 1e-6, 100).unwrap();
assert!(iterations <= 2, "identity should converge fast");
assert!(residual < 1e-4);
for (i, &val) in solution.iter().enumerate() {
assert!(
(val - rhs[i]).abs() < 1e-3,
"solution[{}] = {}, expected {}",
i,
val,
rhs[i]
);
}
}
#[test]
fn test_neumann_diag_dominant() {
let matrix = make_diag_dominant(5);
let rhs = vec![1.0; 5];
let (solution, _iterations, _residual, _) =
TrueSolver::neumann_solve(&matrix, &rhs, 1e-6, 500).unwrap();
// Verify Ax ~ b.
let mut ax = vec![0.0f32; 5];
matrix.spmv(&solution, &mut ax);
for i in 0..5 {
assert!(
(ax[i] - rhs[i]).abs() < 0.1,
"residual at {} too large: Ax={}, b={}",
i,
ax[i],
rhs[i]
);
}
}
#[test]
fn test_sparsify_preserves_diagonal() {
let matrix = make_diag_dominant(4);
let mut rng = StdRng::seed_from_u64(123);
let sparsified = TrueSolver::sparsify(&matrix, 0.5, &mut rng);
for row in 0..4 {
let start = sparsified.row_ptr[row];
let end = sparsified.row_ptr[row + 1];
let has_diag = (start..end).any(|idx| sparsified.col_indices[idx] == row);
assert!(has_diag, "diagonal entry missing at row {}", row);
}
}
#[test]
fn test_preprocess() {
let matrix = make_diag_dominant(10);
let solver = TrueSolver::new(0.3, 5, 0.3);
let preprocessing = solver.preprocess(&matrix).unwrap();
assert_eq!(preprocessing.original_rows, 10);
assert_eq!(preprocessing.original_cols, 10);
assert_eq!(preprocessing.sparsified_matrix.rows, 5);
assert_eq!(preprocessing.sparsified_matrix.cols, 5);
assert!(!preprocessing.jl_matrix.is_empty());
}
#[test]
fn test_solve_with_preprocessing() {
let matrix = make_diag_dominant(8);
let rhs = vec![1.0; 8];
let solver = TrueSolver::new(0.3, 4, 0.3)
.with_max_iterations(200)
.with_seed(99);
let preprocessing = solver.preprocess(&matrix).unwrap();
let result = solver
.solve_with_preprocessing(&preprocessing, &rhs)
.unwrap();
assert_eq!(result.solution.len(), 8);
assert!(result.iterations > 0);
assert_eq!(result.algorithm, Algorithm::TRUE);
}
#[test]
fn test_solver_engine_trait() {
use crate::traits::SolverEngine;
use crate::types::ComputeBudget;
// Build f64 matrix for SolverEngine trait
let n = 6;
let mut entries = Vec::new();
for i in 0..n {
entries.push((i, i, 3.0f64));
if i + 1 < n {
entries.push((i, i + 1, -0.5f64));
entries.push((i + 1, i, -0.5f64));
}
}
let matrix = CsrMatrix::<f64>::from_coo(n, n, entries);
let rhs = vec![1.0f64; 6];
let budget = ComputeBudget::default();
let solver = TrueSolver::new(0.3, 3, 0.3).with_max_iterations(200);
let result = solver.solve(&matrix, &rhs, &budget).unwrap();
assert_eq!(result.solution.len(), 6);
assert!(result.wall_time.as_nanos() > 0);
}
#[test]
fn test_dimension_mismatch_rhs() {
let matrix = make_diag_dominant(4);
let rhs = vec![1.0; 7];
let solver = TrueSolver::new(0.1, 2, 0.1);
let preprocessing = solver.preprocess(&matrix).unwrap();
let err = solver.solve_with_preprocessing(&preprocessing, &rhs);
assert!(err.is_err());
}
#[test]
fn test_non_square_matrix_rejected() {
let matrix =
CsrMatrix::<f32>::from_coo(3, 5, vec![(0, 0, 1.0f32), (1, 1, 1.0), (2, 2, 1.0)]);
let solver = TrueSolver::new(0.1, 2, 0.1);
let err = solver.preprocess(&matrix);
assert!(err.is_err());
}
#[test]
fn test_nan_matrix_rejected() {
let matrix = CsrMatrix {
row_ptr: vec![0, 1, 2],
col_indices: vec![0, 1],
values: vec![f32::NAN, 1.0f32],
rows: 2,
cols: 2,
};
let solver = TrueSolver::new(0.1, 2, 0.1);
let err = solver.preprocess(&matrix);
assert!(err.is_err());
}
#[test]
fn test_empty_matrix_rejected() {
let matrix: CsrMatrix<f32> = CsrMatrix {
row_ptr: vec![0],
col_indices: Vec::new(),
values: Vec::new(),
rows: 0,
cols: 0,
};
let solver = TrueSolver::new(0.1, 1, 0.1);
let err = solver.preprocess(&matrix);
assert!(err.is_err());
}
#[test]
fn test_deterministic_with_seed() {
let matrix = make_diag_dominant(6);
let rhs = vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let solver = TrueSolver::new(0.3, 3, 0.3).with_seed(777);
let preprocessing = solver.preprocess(&matrix).unwrap();
let r1 = solver
.solve_with_preprocessing(&preprocessing, &rhs)
.unwrap();
let r2 = solver
.solve_with_preprocessing(&preprocessing, &rhs)
.unwrap();
assert_eq!(r1.solution, r2.solution);
assert_eq!(r1.iterations, r2.iterations);
}
#[test]
fn test_preprocessing_reuse() {
let matrix = make_diag_dominant(8);
let solver = TrueSolver::new(0.3, 4, 0.3).with_max_iterations(200);
let preprocessing = solver.preprocess(&matrix).unwrap();
let rhs_a = vec![1.0; 8];
let rhs_b = vec![2.0; 8];
let result_a = solver
.solve_with_preprocessing(&preprocessing, &rhs_a)
.unwrap();
let result_b = solver
.solve_with_preprocessing(&preprocessing, &rhs_b)
.unwrap();
// Different RHS should produce different solutions.
assert_ne!(result_a.solution, result_b.solution);
assert_eq!(result_a.algorithm, result_b.algorithm);
}
}

View File

@@ -0,0 +1,595 @@
//! Core types for sparse linear solvers.
//!
//! Provides [`CsrMatrix`] for compressed sparse row storage and result types
//! for solver convergence tracking.
use std::time::Duration;
// ---------------------------------------------------------------------------
// CsrMatrix<T>
// ---------------------------------------------------------------------------
/// Compressed Sparse Row (CSR) matrix.
///
/// Stores only non-zero entries for efficient sparse matrix-vector
/// multiplication in O(nnz) time with excellent cache locality.
///
/// # Layout
///
/// For a matrix with `m` rows and `nnz` non-zeros:
/// - `row_ptr` has length `m + 1`
/// - `col_indices` and `values` each have length `nnz`
/// - Row `i` spans indices `row_ptr[i]..row_ptr[i+1]`
#[derive(Debug, Clone)]
pub struct CsrMatrix<T> {
/// Row pointers: `row_ptr[i]` is the start index in `col_indices`/`values`
/// for row `i`.
pub row_ptr: Vec<usize>,
/// Column indices for each non-zero entry.
pub col_indices: Vec<usize>,
/// Values for each non-zero entry.
pub values: Vec<T>,
/// Number of rows.
pub rows: usize,
/// Number of columns.
pub cols: usize,
}
impl<T: Copy + Default + std::ops::Mul<Output = T> + std::ops::AddAssign> CsrMatrix<T> {
/// Sparse matrix-vector multiply: `y = A * x`.
///
/// # Panics
///
/// Debug-asserts that `x.len() >= self.cols` and `y.len() >= self.rows`.
#[inline]
pub fn spmv(&self, x: &[T], y: &mut [T]) {
debug_assert!(
x.len() >= self.cols,
"spmv: x.len()={} < cols={}",
x.len(),
self.cols,
);
debug_assert!(
y.len() >= self.rows,
"spmv: y.len()={} < rows={}",
y.len(),
self.rows,
);
for i in 0..self.rows {
let mut sum = T::default();
let start = self.row_ptr[i];
let end = self.row_ptr[i + 1];
for idx in start..end {
sum += self.values[idx] * x[self.col_indices[idx]];
}
y[i] = sum;
}
}
}
impl CsrMatrix<f32> {
/// High-performance SpMV with bounds-check elimination.
///
/// Identical to [`spmv`](Self::spmv) but uses `unsafe` indexing to
/// eliminate per-element bounds checks in the inner loop, which is the
/// single hottest path in all iterative solvers.
///
/// # Safety contract
///
/// The caller must ensure the CSR structure is valid (use
/// [`validate_csr_matrix`](crate::validation::validate_csr_matrix) once
/// before entering the solve loop). The `x` and `y` slices must have
/// lengths `>= cols` and `>= rows` respectively.
#[inline]
pub fn spmv_unchecked(&self, x: &[f32], y: &mut [f32]) {
debug_assert!(x.len() >= self.cols);
debug_assert!(y.len() >= self.rows);
let vals = self.values.as_ptr();
let cols = self.col_indices.as_ptr();
let rp = self.row_ptr.as_ptr();
for i in 0..self.rows {
// SAFETY: row_ptr has length rows+1, so i and i+1 are in bounds.
let start = unsafe { *rp.add(i) };
let end = unsafe { *rp.add(i + 1) };
let mut sum = 0.0f32;
for idx in start..end {
// SAFETY: idx < nnz (enforced by valid CSR structure),
// col_indices[idx] < cols <= x.len() (enforced by validation).
unsafe {
let v = *vals.add(idx);
let c = *cols.add(idx);
sum += v * *x.get_unchecked(c);
}
}
// SAFETY: i < rows <= y.len()
unsafe { *y.get_unchecked_mut(i) = sum };
}
}
/// Fused SpMV + residual computation: computes `r[j] = rhs[j] - (A*x)[j]`
/// and returns `||r||^2` in a single pass, avoiding a separate allocation
/// for `Ax`.
///
/// This eliminates one full memory traversal per iteration compared to
/// separate `spmv` + vector subtraction.
#[inline]
pub fn fused_residual_norm_sq(&self, x: &[f32], rhs: &[f32], residual: &mut [f32]) -> f64 {
debug_assert!(x.len() >= self.cols);
debug_assert!(rhs.len() >= self.rows);
debug_assert!(residual.len() >= self.rows);
let vals = self.values.as_ptr();
let cols = self.col_indices.as_ptr();
let rp = self.row_ptr.as_ptr();
let mut norm_sq = 0.0f64;
for i in 0..self.rows {
let start = unsafe { *rp.add(i) };
let end = unsafe { *rp.add(i + 1) };
let mut ax_i = 0.0f32;
for idx in start..end {
unsafe {
let v = *vals.add(idx);
let c = *cols.add(idx);
ax_i += v * *x.get_unchecked(c);
}
}
let r_i = rhs[i] - ax_i;
residual[i] = r_i;
norm_sq += (r_i as f64) * (r_i as f64);
}
norm_sq
}
}
impl CsrMatrix<f64> {
/// High-performance SpMV for f64 with bounds-check elimination.
#[inline]
pub fn spmv_unchecked(&self, x: &[f64], y: &mut [f64]) {
debug_assert!(x.len() >= self.cols);
debug_assert!(y.len() >= self.rows);
let vals = self.values.as_ptr();
let cols = self.col_indices.as_ptr();
let rp = self.row_ptr.as_ptr();
for i in 0..self.rows {
let start = unsafe { *rp.add(i) };
let end = unsafe { *rp.add(i + 1) };
let mut sum = 0.0f64;
for idx in start..end {
unsafe {
let v = *vals.add(idx);
let c = *cols.add(idx);
sum += v * *x.get_unchecked(c);
}
}
unsafe { *y.get_unchecked_mut(i) = sum };
}
}
}
impl<T> CsrMatrix<T> {
/// Number of non-zero entries.
#[inline]
pub fn nnz(&self) -> usize {
self.values.len()
}
/// Number of non-zeros in a specific row (i.e. the row degree for an
/// adjacency matrix).
#[inline]
pub fn row_degree(&self, row: usize) -> usize {
self.row_ptr[row + 1] - self.row_ptr[row]
}
/// Iterate over `(col_index, &value)` pairs for the given row.
#[inline]
pub fn row_entries(&self, row: usize) -> impl Iterator<Item = (usize, &T)> {
let start = self.row_ptr[row];
let end = self.row_ptr[row + 1];
self.col_indices[start..end]
.iter()
.copied()
.zip(self.values[start..end].iter())
}
}
impl<T: Copy + Default> CsrMatrix<T> {
/// Transpose: produces `A^T` in CSR form.
///
/// Uses a two-pass counting sort in O(nnz + rows + cols) time and
/// O(nnz) extra memory. Required by backward push which operates on
/// the reversed adjacency structure.
pub fn transpose(&self) -> CsrMatrix<T> {
let nnz = self.nnz();
let t_rows = self.cols;
let t_cols = self.rows;
// Pass 1: count entries per new row (= old column).
let mut row_ptr = vec![0usize; t_rows + 1];
for &c in &self.col_indices {
row_ptr[c + 1] += 1;
}
for i in 1..=t_rows {
row_ptr[i] += row_ptr[i - 1];
}
// Pass 2: scatter entries into the transposed arrays.
let mut col_indices = vec![0usize; nnz];
let mut values = vec![T::default(); nnz];
let mut cursor = row_ptr.clone();
for row in 0..self.rows {
let start = self.row_ptr[row];
let end = self.row_ptr[row + 1];
for idx in start..end {
let c = self.col_indices[idx];
let dest = cursor[c];
col_indices[dest] = row;
values[dest] = self.values[idx];
cursor[c] += 1;
}
}
CsrMatrix {
row_ptr,
col_indices,
values,
rows: t_rows,
cols: t_cols,
}
}
}
impl<T: Copy + Default + std::ops::AddAssign> CsrMatrix<T> {
/// Build a CSR matrix from COO (coordinate) triplets.
///
/// Entries are sorted by (row, col) internally. Duplicate positions at the
/// same (row, col) are kept as separate entries (caller should pre-merge if
/// needed).
pub fn from_coo_generic(
rows: usize,
cols: usize,
entries: impl IntoIterator<Item = (usize, usize, T)>,
) -> Self {
let mut sorted: Vec<_> = entries.into_iter().collect();
sorted.sort_unstable_by_key(|(r, c, _)| (*r, *c));
let nnz = sorted.len();
let mut row_ptr = vec![0usize; rows + 1];
let mut col_indices = Vec::with_capacity(nnz);
let mut values = Vec::with_capacity(nnz);
for &(r, _, _) in &sorted {
assert!(r < rows, "row index {} out of bounds (rows={})", r, rows);
row_ptr[r + 1] += 1;
}
for i in 1..=rows {
row_ptr[i] += row_ptr[i - 1];
}
for (_, c, v) in sorted {
assert!(c < cols, "col index {} out of bounds (cols={})", c, cols);
col_indices.push(c);
values.push(v);
}
Self {
row_ptr,
col_indices,
values,
rows,
cols,
}
}
}
impl CsrMatrix<f32> {
/// Build a CSR matrix from COO (coordinate) triplets.
///
/// Entries are sorted by (row, col) internally. Duplicate positions are
/// summed.
pub fn from_coo(
rows: usize,
cols: usize,
entries: impl IntoIterator<Item = (usize, usize, f32)>,
) -> Self {
Self::from_coo_generic(rows, cols, entries)
}
/// Build a square identity matrix of dimension `n` in CSR format.
pub fn identity(n: usize) -> Self {
let row_ptr: Vec<usize> = (0..=n).collect();
let col_indices: Vec<usize> = (0..n).collect();
let values = vec![1.0f32; n];
Self {
row_ptr,
col_indices,
values,
rows: n,
cols: n,
}
}
}
impl CsrMatrix<f64> {
/// Build a CSR matrix from COO (coordinate) triplets (f64 variant).
///
/// Entries are sorted by (row, col) internally.
pub fn from_coo(
rows: usize,
cols: usize,
entries: impl IntoIterator<Item = (usize, usize, f64)>,
) -> Self {
Self::from_coo_generic(rows, cols, entries)
}
/// Build a square identity matrix of dimension `n` in CSR format (f64).
pub fn identity(n: usize) -> Self {
let row_ptr: Vec<usize> = (0..=n).collect();
let col_indices: Vec<usize> = (0..n).collect();
let values = vec![1.0f64; n];
Self {
row_ptr,
col_indices,
values,
rows: n,
cols: n,
}
}
}
// ---------------------------------------------------------------------------
// Solver result types
// ---------------------------------------------------------------------------
/// Algorithm identifier for solver selection and routing.
///
/// Each variant corresponds to a solver strategy with different complexity
/// characteristics and applicability constraints. The [`SolverRouter`] selects
/// the best algorithm based on the matrix [`SparsityProfile`] and [`QueryType`].
///
/// [`SolverRouter`]: crate::router::SolverRouter
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Algorithm {
/// Neumann series: `x = sum_{k=0}^{K} (I - A)^k * b`.
///
/// Requires spectral radius < 1. Best for diagonally dominant, very sparse
/// systems where the series converges in O(log(1/eps)) terms.
Neumann,
/// Jacobi iterative solver.
Jacobi,
/// Gauss-Seidel iterative solver.
GaussSeidel,
/// Forward Push (Andersen-Chung-Lang) for Personalized PageRank.
///
/// Computes an approximate PPR vector by pushing residual mass forward
/// along edges. Sublinear in graph size for single-source queries.
ForwardPush,
/// Backward Push for target-centric PPR.
///
/// Dual of Forward Push: propagates contributions backward from a target
/// node.
BackwardPush,
/// Conjugate Gradient (CG) iterative solver.
///
/// Optimal for symmetric positive-definite systems. Converges in at most
/// `n` steps; practical convergence depends on the condition number.
CG,
/// Hybrid random-walk approach combining push with Monte Carlo sampling.
///
/// For large graphs where pure push is too expensive, this approach uses
/// random walks to estimate the tail of the PageRank distribution.
HybridRandomWalk,
/// TRUE (Topology-aware Reduction for Updating Equations) batch solver.
///
/// Exploits shared sparsity structure across a batch of right-hand sides
/// to amortise factorisation cost. Best when `batch_size` is large.
TRUE,
/// Block Maximum Spanning Subgraph Preconditioned solver.
///
/// Uses a maximum spanning tree preconditioner for ill-conditioned systems
/// where CG and Neumann both struggle.
BMSSP,
/// Dense direct solver (LU/Cholesky fallback).
///
/// Last-resort O(n^3) solver used when iterative methods fail. Only
/// practical for small matrices.
Dense,
}
impl std::fmt::Display for Algorithm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Algorithm::Neumann => write!(f, "neumann"),
Algorithm::Jacobi => write!(f, "jacobi"),
Algorithm::GaussSeidel => write!(f, "gauss-seidel"),
Algorithm::ForwardPush => write!(f, "forward-push"),
Algorithm::BackwardPush => write!(f, "backward-push"),
Algorithm::CG => write!(f, "cg"),
Algorithm::HybridRandomWalk => write!(f, "hybrid-random-walk"),
Algorithm::TRUE => write!(f, "true-solver"),
Algorithm::BMSSP => write!(f, "bmssp"),
Algorithm::Dense => write!(f, "dense"),
}
}
}
// ---------------------------------------------------------------------------
// Query & profile types for routing
// ---------------------------------------------------------------------------
/// Query type describing what the caller wants to solve.
///
/// The [`SolverRouter`] inspects this together with the [`SparsityProfile`] to
/// select the most appropriate [`Algorithm`].
///
/// [`SolverRouter`]: crate::router::SolverRouter
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QueryType {
/// Standard sparse linear system `Ax = b`.
LinearSystem,
/// Single-source Personalized PageRank.
PageRankSingle {
/// Source node index.
source: usize,
},
/// Pairwise Personalized PageRank between two nodes.
PageRankPairwise {
/// Source node index.
source: usize,
/// Target node index.
target: usize,
},
/// Spectral graph filter using polynomial expansion.
SpectralFilter {
/// Degree of the Chebyshev/polynomial expansion.
polynomial_degree: usize,
},
/// Batch of linear systems sharing the same matrix `A` but different
/// right-hand sides.
BatchLinearSystem {
/// Number of right-hand sides in the batch.
batch_size: usize,
},
}
/// Sparsity profile summarising the structural and numerical properties
/// of a matrix that are relevant for algorithm selection.
///
/// Computed once by [`SolverOrchestrator::analyze_sparsity`] and reused
/// across multiple solves on the same matrix.
///
/// [`SolverOrchestrator::analyze_sparsity`]: crate::router::SolverOrchestrator::analyze_sparsity
#[derive(Debug, Clone)]
pub struct SparsityProfile {
/// Number of rows.
pub rows: usize,
/// Number of columns.
pub cols: usize,
/// Total number of non-zero entries.
pub nnz: usize,
/// Fraction of non-zeros: `nnz / (rows * cols)`.
pub density: f64,
/// `true` if `|a_ii| > sum_{j != i} |a_ij|` for every row.
pub is_diag_dominant: bool,
/// Estimated spectral radius of the Jacobi iteration matrix `D^{-1}(L+U)`.
pub estimated_spectral_radius: f64,
/// Rough estimate of the 2-norm condition number.
pub estimated_condition: f64,
/// `true` if the matrix appears to be symmetric (checked on structure only).
pub is_symmetric_structure: bool,
/// Average number of non-zeros per row.
pub avg_nnz_per_row: f64,
/// Maximum number of non-zeros in any single row.
pub max_nnz_per_row: usize,
}
/// Estimated computational complexity for a solve.
///
/// Returned by [`SolverOrchestrator::estimate_complexity`] to let callers
/// decide whether to proceed, batch, or reject a query.
///
/// [`SolverOrchestrator::estimate_complexity`]: crate::router::SolverOrchestrator::estimate_complexity
#[derive(Debug, Clone)]
pub struct ComplexityEstimate {
/// Algorithm that would be selected.
pub algorithm: Algorithm,
/// Estimated number of floating-point operations.
pub estimated_flops: u64,
/// Estimated number of iterations (for iterative methods).
pub estimated_iterations: usize,
/// Estimated peak memory usage in bytes.
pub estimated_memory_bytes: usize,
/// A qualitative complexity class label.
pub complexity_class: ComplexityClass,
}
/// Qualitative complexity class.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ComplexityClass {
/// O(nnz * log(1/eps)) -- sublinear in matrix dimension.
SublinearNnz,
/// O(n * sqrt(kappa)) -- CG-like.
SqrtCondition,
/// O(n * nnz_per_row) -- linear scan.
Linear,
/// O(n^2) or worse -- superlinear.
Quadratic,
/// O(n^3) -- dense factorisation.
Cubic,
}
/// Compute lane priority for solver scheduling.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ComputeLane {
/// Low-latency lane for small problems.
Fast,
/// Default throughput lane.
Normal,
/// Batch lane for large problems.
Batch,
}
/// Budget constraints for solver execution.
#[derive(Debug, Clone)]
pub struct ComputeBudget {
/// Maximum wall-clock time allowed.
pub max_time: Duration,
/// Maximum number of iterations.
pub max_iterations: usize,
/// Target residual tolerance.
pub tolerance: f64,
}
impl Default for ComputeBudget {
fn default() -> Self {
Self {
max_time: Duration::from_secs(30),
max_iterations: 1000,
tolerance: 1e-6,
}
}
}
/// Per-iteration convergence snapshot.
#[derive(Debug, Clone)]
pub struct ConvergenceInfo {
/// Iteration index (0-based).
pub iteration: usize,
/// Residual L2 norm at this iteration.
pub residual_norm: f64,
}
/// Result returned by a successful solver invocation.
#[derive(Debug, Clone)]
pub struct SolverResult {
/// Solution vector x.
pub solution: Vec<f32>,
/// Number of iterations performed.
pub iterations: usize,
/// Final residual L2 norm.
pub residual_norm: f64,
/// Wall-clock time taken.
pub wall_time: Duration,
/// Per-iteration convergence history.
pub convergence_history: Vec<ConvergenceInfo>,
/// Algorithm used.
pub algorithm: Algorithm,
}

View File

@@ -0,0 +1,786 @@
//! Comprehensive input validation for solver operations.
//!
//! All validation functions run eagerly before any computation begins, ensuring
//! callers receive clear diagnostics instead of mysterious numerical failures or
//! resource exhaustion. Every public function returns [`ValidationError`] on
//! failure, which converts into [`SolverError::InvalidInput`] via `From`.
//!
//! # Limits
//!
//! Hard limits are enforced to prevent denial-of-service through oversized
//! inputs:
//!
//! | Resource | Limit | Constant |
//! |---------------|------------------------|-------------------|
//! | Nodes (rows) | 10,000,000 | [`MAX_NODES`] |
//! | Edges (nnz) | 100,000,000 | [`MAX_EDGES`] |
//! | Dimension | 65,536 | [`MAX_DIM`] |
//! | Iterations | 1,000,000 | [`MAX_ITERATIONS`]|
//! | Request body | 10 MiB | [`MAX_BODY_SIZE`] |
use crate::error::ValidationError;
use crate::types::{CsrMatrix, SolverResult};
// ---------------------------------------------------------------------------
// Resource limits
// ---------------------------------------------------------------------------
/// Maximum number of rows or columns to prevent resource exhaustion.
pub const MAX_NODES: usize = 10_000_000;
/// Maximum number of non-zero entries.
pub const MAX_EDGES: usize = 100_000_000;
/// Maximum vector/matrix dimension for dense operations.
pub const MAX_DIM: usize = 65_536;
/// Maximum solver iterations to prevent runaway computation.
pub const MAX_ITERATIONS: usize = 1_000_000;
/// Maximum request body size in bytes (10 MiB).
pub const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
// ---------------------------------------------------------------------------
// CSR matrix validation
// ---------------------------------------------------------------------------
/// Validate the structural integrity of a CSR matrix.
///
/// Performs the following checks in order:
///
/// 1. `rows` and `cols` are within [`MAX_NODES`].
/// 2. `nnz` (number of non-zeros) is within [`MAX_EDGES`].
/// 3. `row_ptr` length equals `rows + 1`.
/// 4. `row_ptr` is monotonically non-decreasing.
/// 5. `row_ptr[0] == 0` and `row_ptr[rows] == nnz`.
/// 6. `col_indices` length equals `values` length.
/// 7. All column indices are less than `cols`.
/// 8. No `NaN` or `Inf` values in `values`.
/// 9. Column indices are sorted within each row (emits a [`tracing::warn`] if
/// not, but does not error).
///
/// # Errors
///
/// Returns [`ValidationError`] describing the first violation found.
///
/// # Examples
///
/// ```
/// use ruvector_solver::types::CsrMatrix;
/// use ruvector_solver::validation::validate_csr_matrix;
///
/// let m = CsrMatrix::<f32>::from_coo(2, 2, vec![(0, 0, 1.0), (1, 1, 2.0)]);
/// assert!(validate_csr_matrix(&m).is_ok());
/// ```
pub fn validate_csr_matrix(matrix: &CsrMatrix<f32>) -> Result<(), ValidationError> {
// 1. Dimension bounds
if matrix.rows > MAX_NODES || matrix.cols > MAX_NODES {
return Err(ValidationError::MatrixTooLarge {
rows: matrix.rows,
cols: matrix.cols,
max_dim: MAX_NODES,
});
}
// 2. NNZ bounds
let nnz = matrix.values.len();
if nnz > MAX_EDGES {
return Err(ValidationError::DimensionMismatch(format!(
"nnz {} exceeds maximum allowed {}",
nnz, MAX_EDGES,
)));
}
// 3. row_ptr length
let expected_row_ptr_len = matrix.rows + 1;
if matrix.row_ptr.len() != expected_row_ptr_len {
return Err(ValidationError::DimensionMismatch(format!(
"row_ptr length {} does not equal rows + 1 = {}",
matrix.row_ptr.len(),
expected_row_ptr_len,
)));
}
// 4. row_ptr monotonicity
for i in 1..matrix.row_ptr.len() {
if matrix.row_ptr[i] < matrix.row_ptr[i - 1] {
return Err(ValidationError::NonMonotonicRowPtrs { position: i });
}
}
// 5. row_ptr boundary values
if matrix.row_ptr[0] != 0 {
return Err(ValidationError::DimensionMismatch(format!(
"row_ptr[0] = {} (expected 0)",
matrix.row_ptr[0],
)));
}
let expected_nnz = matrix.row_ptr[matrix.rows];
if expected_nnz != nnz {
return Err(ValidationError::DimensionMismatch(format!(
"values length {} does not match row_ptr[rows] = {}",
nnz, expected_nnz,
)));
}
// 6. col_indices length must match values length
if matrix.col_indices.len() != nnz {
return Err(ValidationError::DimensionMismatch(format!(
"col_indices length {} does not match values length {}",
matrix.col_indices.len(),
nnz,
)));
}
// 7. Column index bounds + 9. Sorted check (warn only) + 8. Finiteness
for row in 0..matrix.rows {
let start = matrix.row_ptr[row];
let end = matrix.row_ptr[row + 1];
let mut prev_col: Option<usize> = None;
for idx in start..end {
let col = matrix.col_indices[idx];
if col >= matrix.cols {
return Err(ValidationError::IndexOutOfBounds {
index: col as u32,
row,
cols: matrix.cols,
});
}
let val = matrix.values[idx];
if !val.is_finite() {
return Err(ValidationError::NonFiniteValue(format!(
"matrix[{}, {}] = {}",
row, col, val,
)));
}
// Check sorted order within row (warn, not error)
if let Some(pc) = prev_col {
if col < pc {
tracing::warn!(
row = row,
"column indices not sorted within row (col {} follows {}); \
performance may be degraded",
col,
pc,
);
}
}
prev_col = Some(col);
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// RHS vector validation
// ---------------------------------------------------------------------------
/// Validate a right-hand-side vector for a linear solve.
///
/// Checks:
///
/// 1. `rhs.len() == expected_len` (dimension must match the matrix).
/// 2. No `NaN` or `Inf` entries.
/// 3. If all entries are zero, emits a [`tracing::warn`] (a zero RHS is
/// technically valid but often indicates a bug).
///
/// # Errors
///
/// Returns [`ValidationError`] on dimension mismatch or non-finite values.
pub fn validate_rhs(rhs: &[f32], expected_len: usize) -> Result<(), ValidationError> {
// 1. Length check
if rhs.len() != expected_len {
return Err(ValidationError::DimensionMismatch(format!(
"rhs length {} does not match expected {}",
rhs.len(),
expected_len,
)));
}
// 2. Finite check + 3. All-zeros check
let mut all_zero = true;
for (i, &v) in rhs.iter().enumerate() {
if !v.is_finite() {
return Err(ValidationError::NonFiniteValue(format!(
"rhs[{}] = {}",
i, v,
)));
}
if v != 0.0 {
all_zero = false;
}
}
if all_zero && !rhs.is_empty() {
tracing::warn!("rhs vector is all zeros; solution will be trivially zero");
}
Ok(())
}
/// Validate the right-hand side vector `b` for compatibility with a matrix.
///
/// This is an alias for [`validate_rhs`] that preserves backward compatibility
/// with the original API name.
pub fn validate_rhs_vector(rhs: &[f32], expected_len: usize) -> Result<(), ValidationError> {
validate_rhs(rhs, expected_len)
}
// ---------------------------------------------------------------------------
// Solver parameter validation
// ---------------------------------------------------------------------------
/// Validate solver convergence parameters.
///
/// # Rules
///
/// - `tolerance` must be in the range `(0.0, 1.0]` and be finite.
/// - `max_iterations` must be in `[1, MAX_ITERATIONS]`.
///
/// # Errors
///
/// Returns [`ValidationError::ParameterOutOfRange`] if either parameter is
/// outside its valid range.
pub fn validate_params(tolerance: f64, max_iterations: usize) -> Result<(), ValidationError> {
if !tolerance.is_finite() || tolerance <= 0.0 || tolerance > 1.0 {
return Err(ValidationError::ParameterOutOfRange {
name: "tolerance".into(),
value: format!("{tolerance:.2e}"),
expected: "(0.0, 1.0]".into(),
});
}
if max_iterations == 0 || max_iterations > MAX_ITERATIONS {
return Err(ValidationError::ParameterOutOfRange {
name: "max_iterations".into(),
value: max_iterations.to_string(),
expected: format!("[1, {}]", MAX_ITERATIONS),
});
}
Ok(())
}
// ---------------------------------------------------------------------------
// Combined solver input validation
// ---------------------------------------------------------------------------
/// Validate the complete solver input (matrix + rhs + parameters).
///
/// This is a convenience function that calls [`validate_csr_matrix`],
/// [`validate_rhs`], and validates tolerance in sequence. It also checks
/// that the matrix is square, which is required by all iterative solvers.
///
/// # Errors
///
/// Returns [`ValidationError`] on the first failing check.
pub fn validate_solver_input(
matrix: &CsrMatrix<f32>,
rhs: &[f32],
tolerance: f64,
) -> Result<(), ValidationError> {
validate_csr_matrix(matrix)?;
validate_rhs(rhs, matrix.rows)?;
// Square matrix required for iterative solvers.
if matrix.rows != matrix.cols {
return Err(ValidationError::DimensionMismatch(format!(
"solver requires a square matrix but got {}x{}",
matrix.rows, matrix.cols,
)));
}
// Tolerance bounds.
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err(ValidationError::ParameterOutOfRange {
name: "tolerance".into(),
value: tolerance.to_string(),
expected: "finite positive value".into(),
});
}
Ok(())
}
// ---------------------------------------------------------------------------
// Output validation (post-solve)
// ---------------------------------------------------------------------------
/// Validate a solver result after computation completes.
///
/// This catches silent numerical corruption that may have occurred during
/// iteration:
///
/// 1. No `NaN` or `Inf` in the solution vector.
/// 2. The residual norm is finite.
/// 3. At least one iteration was performed.
///
/// # Errors
///
/// Returns [`ValidationError`] if the output is corrupted.
pub fn validate_output(result: &SolverResult) -> Result<(), ValidationError> {
// 1. Solution vector finiteness
for (i, &v) in result.solution.iter().enumerate() {
if !v.is_finite() {
return Err(ValidationError::NonFiniteValue(format!(
"solution[{}] = {}",
i, v,
)));
}
}
// 2. Residual finiteness
if !result.residual_norm.is_finite() {
return Err(ValidationError::NonFiniteValue(format!(
"residual_norm = {}",
result.residual_norm,
)));
}
// 3. Iteration count
if result.iterations == 0 {
return Err(ValidationError::ParameterOutOfRange {
name: "iterations".into(),
value: "0".into(),
expected: ">= 1".into(),
});
}
Ok(())
}
// ---------------------------------------------------------------------------
// Body size validation (for API / deserialization boundaries)
// ---------------------------------------------------------------------------
/// Validate that a request body does not exceed [`MAX_BODY_SIZE`].
///
/// Call this at the deserialization boundary before parsing untrusted input.
///
/// # Errors
///
/// Returns [`ValidationError::ParameterOutOfRange`] if `size > MAX_BODY_SIZE`.
pub fn validate_body_size(size: usize) -> Result<(), ValidationError> {
if size > MAX_BODY_SIZE {
return Err(ValidationError::ParameterOutOfRange {
name: "body_size".into(),
value: format!("{} bytes", size),
expected: format!("<= {} bytes (10 MiB)", MAX_BODY_SIZE),
});
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Algorithm, ConvergenceInfo, CsrMatrix, SolverResult};
use std::time::Duration;
fn make_identity(n: usize) -> CsrMatrix<f32> {
let mut row_ptr = vec![0usize; n + 1];
let mut col_indices = Vec::with_capacity(n);
let mut values = Vec::with_capacity(n);
for i in 0..n {
row_ptr[i + 1] = i + 1;
col_indices.push(i);
values.push(1.0);
}
CsrMatrix {
values,
col_indices,
row_ptr,
rows: n,
cols: n,
}
}
// -- validate_csr_matrix ------------------------------------------------
#[test]
fn valid_identity() {
let mat = make_identity(4);
assert!(validate_csr_matrix(&mat).is_ok());
}
#[test]
fn valid_empty_matrix() {
let m = CsrMatrix {
row_ptr: vec![0],
col_indices: vec![],
values: vec![],
rows: 0,
cols: 0,
};
assert!(validate_csr_matrix(&m).is_ok());
}
#[test]
fn valid_from_coo() {
let m = CsrMatrix::<f32>::from_coo(
3,
3,
vec![
(0, 0, 2.0),
(0, 1, -0.5),
(1, 0, -0.5),
(1, 1, 2.0),
(1, 2, -0.5),
(2, 1, -0.5),
(2, 2, 2.0),
],
);
assert!(validate_csr_matrix(&m).is_ok());
}
#[test]
fn rejects_too_large_matrix() {
let m = CsrMatrix {
row_ptr: vec![0, 0],
col_indices: vec![],
values: vec![],
rows: MAX_NODES + 1,
cols: 1,
};
assert!(matches!(
validate_csr_matrix(&m),
Err(ValidationError::MatrixTooLarge { .. })
));
}
#[test]
fn rejects_wrong_row_ptr_length() {
let m = CsrMatrix {
row_ptr: vec![0, 1],
col_indices: vec![0],
values: vec![1.0],
rows: 3,
cols: 3,
};
assert!(matches!(
validate_csr_matrix(&m),
Err(ValidationError::DimensionMismatch(_))
));
}
#[test]
fn non_monotonic_row_ptr() {
let mut mat = make_identity(4);
mat.row_ptr[2] = 0; // break monotonicity
let err = validate_csr_matrix(&mat).unwrap_err();
assert!(matches!(err, ValidationError::NonMonotonicRowPtrs { .. }));
}
#[test]
fn rejects_row_ptr_not_starting_at_zero() {
let m = CsrMatrix {
row_ptr: vec![1, 2],
col_indices: vec![0],
values: vec![1.0],
rows: 1,
cols: 1,
};
match validate_csr_matrix(&m) {
Err(ValidationError::DimensionMismatch(msg)) => {
assert!(msg.contains("row_ptr[0]"), "msg: {msg}");
}
other => panic!("expected DimensionMismatch for row_ptr[0], got {other:?}"),
}
}
#[test]
fn col_index_out_of_bounds() {
let mut mat = make_identity(4);
mat.col_indices[1] = 99;
let err = validate_csr_matrix(&mat).unwrap_err();
assert!(matches!(err, ValidationError::IndexOutOfBounds { .. }));
}
#[test]
fn nan_value_rejected() {
let mut mat = make_identity(4);
mat.values[0] = f32::NAN;
let err = validate_csr_matrix(&mat).unwrap_err();
assert!(matches!(err, ValidationError::NonFiniteValue(_)));
}
#[test]
fn inf_value_rejected() {
let mut mat = make_identity(4);
mat.values[0] = f32::INFINITY;
let err = validate_csr_matrix(&mat).unwrap_err();
assert!(matches!(err, ValidationError::NonFiniteValue(_)));
}
// -- validate_rhs -------------------------------------------------------
#[test]
fn valid_rhs() {
assert!(validate_rhs(&[1.0, 2.0, 3.0], 3).is_ok());
}
#[test]
fn rhs_dimension_mismatch() {
let err = validate_rhs(&[1.0, 2.0], 3).unwrap_err();
assert!(matches!(err, ValidationError::DimensionMismatch(_)));
}
#[test]
fn rhs_nan_rejected() {
let err = validate_rhs(&[1.0, f32::NAN, 3.0], 3).unwrap_err();
assert!(matches!(err, ValidationError::NonFiniteValue(_)));
}
#[test]
fn rhs_inf_rejected() {
let err = validate_rhs(&[1.0, f32::NEG_INFINITY, 3.0], 3).unwrap_err();
assert!(matches!(err, ValidationError::NonFiniteValue(_)));
}
#[test]
fn warns_on_all_zero_rhs() {
// Should succeed but emit a warning (cannot assert warning in unit test,
// but at least verify it does not error).
assert!(validate_rhs(&[0.0, 0.0, 0.0], 3).is_ok());
}
// -- validate_rhs_vector (backward compat alias) ------------------------
#[test]
fn rhs_vector_alias_works() {
assert!(validate_rhs_vector(&[1.0, 2.0], 2).is_ok());
assert!(validate_rhs_vector(&[1.0, 2.0], 3).is_err());
}
// -- validate_params ----------------------------------------------------
#[test]
fn valid_params() {
assert!(validate_params(1e-8, 500).is_ok());
assert!(validate_params(1.0, 1).is_ok());
}
#[test]
fn rejects_zero_tolerance() {
match validate_params(0.0, 100) {
Err(ValidationError::ParameterOutOfRange { ref name, .. }) => {
assert_eq!(name, "tolerance");
}
other => panic!("expected ParameterOutOfRange for tolerance, got {other:?}"),
}
}
#[test]
fn rejects_negative_tolerance() {
match validate_params(-1e-6, 100) {
Err(ValidationError::ParameterOutOfRange { ref name, .. }) => {
assert_eq!(name, "tolerance");
}
other => panic!("expected ParameterOutOfRange for tolerance, got {other:?}"),
}
}
#[test]
fn rejects_tolerance_above_one() {
match validate_params(1.5, 100) {
Err(ValidationError::ParameterOutOfRange { ref name, .. }) => {
assert_eq!(name, "tolerance");
}
other => panic!("expected ParameterOutOfRange for tolerance, got {other:?}"),
}
}
#[test]
fn rejects_nan_tolerance() {
match validate_params(f64::NAN, 100) {
Err(ValidationError::ParameterOutOfRange { ref name, .. }) => {
assert_eq!(name, "tolerance");
}
other => panic!("expected ParameterOutOfRange for tolerance, got {other:?}"),
}
}
#[test]
fn rejects_zero_iterations() {
match validate_params(1e-6, 0) {
Err(ValidationError::ParameterOutOfRange { ref name, .. }) => {
assert_eq!(name, "max_iterations");
}
other => panic!("expected ParameterOutOfRange for max_iterations, got {other:?}"),
}
}
#[test]
fn rejects_excessive_iterations() {
match validate_params(1e-6, MAX_ITERATIONS + 1) {
Err(ValidationError::ParameterOutOfRange { ref name, .. }) => {
assert_eq!(name, "max_iterations");
}
other => panic!("expected ParameterOutOfRange for max_iterations, got {other:?}"),
}
}
// -- validate_solver_input (combined) -----------------------------------
#[test]
fn full_input_validation() {
let mat = make_identity(3);
let rhs = vec![1.0f32, 2.0, 3.0];
assert!(validate_solver_input(&mat, &rhs, 1e-6).is_ok());
}
#[test]
fn non_square_rejected() {
let mat = CsrMatrix {
values: vec![],
col_indices: vec![],
row_ptr: vec![0, 0, 0],
rows: 2,
cols: 3,
};
let rhs = vec![1.0f32, 2.0];
let err = validate_solver_input(&mat, &rhs, 1e-6).unwrap_err();
assert!(matches!(err, ValidationError::DimensionMismatch(_)));
}
#[test]
fn invalid_tolerance_rejected() {
let mat = make_identity(2);
let rhs = vec![1.0f32, 2.0];
assert!(validate_solver_input(&mat, &rhs, -1.0).is_err());
assert!(validate_solver_input(&mat, &rhs, 0.0).is_err());
assert!(validate_solver_input(&mat, &rhs, f64::NAN).is_err());
}
// -- validate_output ----------------------------------------------------
#[test]
fn valid_output() {
let result = SolverResult {
solution: vec![1.0, 2.0, 3.0],
iterations: 10,
residual_norm: 1e-8,
wall_time: Duration::from_millis(5),
convergence_history: vec![ConvergenceInfo {
iteration: 0,
residual_norm: 1.0,
}],
algorithm: Algorithm::Neumann,
};
assert!(validate_output(&result).is_ok());
}
#[test]
fn rejects_nan_in_solution() {
let result = SolverResult {
solution: vec![1.0, f32::NAN, 3.0],
iterations: 1,
residual_norm: 1e-8,
wall_time: Duration::from_millis(1),
convergence_history: vec![],
algorithm: Algorithm::Neumann,
};
match validate_output(&result) {
Err(ValidationError::NonFiniteValue(ref msg)) => {
assert!(msg.contains("solution"), "msg: {msg}");
}
other => panic!("expected NonFiniteValue for solution, got {other:?}"),
}
}
#[test]
fn rejects_inf_in_solution() {
let result = SolverResult {
solution: vec![f32::INFINITY],
iterations: 1,
residual_norm: 1e-8,
wall_time: Duration::from_millis(1),
convergence_history: vec![],
algorithm: Algorithm::Neumann,
};
match validate_output(&result) {
Err(ValidationError::NonFiniteValue(ref msg)) => {
assert!(msg.contains("solution"), "msg: {msg}");
}
other => panic!("expected NonFiniteValue for solution, got {other:?}"),
}
}
#[test]
fn rejects_nan_residual() {
let result = SolverResult {
solution: vec![1.0],
iterations: 1,
residual_norm: f64::NAN,
wall_time: Duration::from_millis(1),
convergence_history: vec![],
algorithm: Algorithm::Neumann,
};
match validate_output(&result) {
Err(ValidationError::NonFiniteValue(ref msg)) => {
assert!(msg.contains("residual"), "msg: {msg}");
}
other => panic!("expected NonFiniteValue for residual, got {other:?}"),
}
}
#[test]
fn rejects_inf_residual() {
let result = SolverResult {
solution: vec![1.0],
iterations: 1,
residual_norm: f64::INFINITY,
wall_time: Duration::from_millis(1),
convergence_history: vec![],
algorithm: Algorithm::Neumann,
};
assert!(matches!(
validate_output(&result),
Err(ValidationError::NonFiniteValue(_))
));
}
#[test]
fn rejects_zero_iterations_in_output() {
let result = SolverResult {
solution: vec![1.0],
iterations: 0,
residual_norm: 1e-8,
wall_time: Duration::from_millis(1),
convergence_history: vec![],
algorithm: Algorithm::Neumann,
};
match validate_output(&result) {
Err(ValidationError::ParameterOutOfRange { ref name, .. }) => {
assert_eq!(name, "iterations");
}
other => panic!("expected ParameterOutOfRange, got {other:?}"),
}
}
// -- validate_body_size -------------------------------------------------
#[test]
fn valid_body_size() {
assert!(validate_body_size(1024).is_ok());
assert!(validate_body_size(MAX_BODY_SIZE).is_ok());
}
#[test]
fn rejects_oversized_body() {
match validate_body_size(MAX_BODY_SIZE + 1) {
Err(ValidationError::ParameterOutOfRange { ref name, .. }) => {
assert_eq!(name, "body_size");
}
other => panic!("expected ParameterOutOfRange, got {other:?}"),
}
}
}