Merge commit 'd803bfe2b1fe7f5e219e50ac20d6801a0a58ac75' as 'vendor/ruvector'
This commit is contained in:
176
vendor/ruvector/crates/ruvector-solver/src/arena.rs
vendored
Normal file
176
vendor/ruvector/crates/ruvector-solver/src/arena.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
316
vendor/ruvector/crates/ruvector-solver/src/audit.rs
vendored
Normal file
316
vendor/ruvector/crates/ruvector-solver/src/audit.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
693
vendor/ruvector/crates/ruvector-solver/src/backward_push.rs
vendored
Normal file
693
vendor/ruvector/crates/ruvector-solver/src/backward_push.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
1162
vendor/ruvector/crates/ruvector-solver/src/bmssp.rs
vendored
Normal file
1162
vendor/ruvector/crates/ruvector-solver/src/bmssp.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
310
vendor/ruvector/crates/ruvector-solver/src/budget.rs
vendored
Normal file
310
vendor/ruvector/crates/ruvector-solver/src/budget.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
1099
vendor/ruvector/crates/ruvector-solver/src/cg.rs
vendored
Normal file
1099
vendor/ruvector/crates/ruvector-solver/src/cg.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
120
vendor/ruvector/crates/ruvector-solver/src/error.rs
vendored
Normal file
120
vendor/ruvector/crates/ruvector-solver/src/error.rs
vendored
Normal 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,
|
||||
},
|
||||
}
|
||||
86
vendor/ruvector/crates/ruvector-solver/src/events.rs
vendored
Normal file
86
vendor/ruvector/crates/ruvector-solver/src/events.rs
vendored
Normal 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,
|
||||
}
|
||||
808
vendor/ruvector/crates/ruvector-solver/src/forward_push.rs
vendored
Normal file
808
vendor/ruvector/crates/ruvector-solver/src/forward_push.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
63
vendor/ruvector/crates/ruvector-solver/src/lib.rs
vendored
Normal file
63
vendor/ruvector/crates/ruvector-solver/src/lib.rs
vendored
Normal 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;
|
||||
779
vendor/ruvector/crates/ruvector-solver/src/neumann.rs
vendored
Normal file
779
vendor/ruvector/crates/ruvector-solver/src/neumann.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
938
vendor/ruvector/crates/ruvector-solver/src/random_walk.rs
vendored
Normal file
938
vendor/ruvector/crates/ruvector-solver/src/random_walk.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1614
vendor/ruvector/crates/ruvector-solver/src/router.rs
vendored
Normal file
1614
vendor/ruvector/crates/ruvector-solver/src/router.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
281
vendor/ruvector/crates/ruvector-solver/src/simd.rs
vendored
Normal file
281
vendor/ruvector/crates/ruvector-solver/src/simd.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
134
vendor/ruvector/crates/ruvector-solver/src/traits.rs
vendored
Normal file
134
vendor/ruvector/crates/ruvector-solver/src/traits.rs
vendored
Normal 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>;
|
||||
}
|
||||
932
vendor/ruvector/crates/ruvector-solver/src/true_solver.rs
vendored
Normal file
932
vendor/ruvector/crates/ruvector-solver/src/true_solver.rs
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
595
vendor/ruvector/crates/ruvector-solver/src/types.rs
vendored
Normal file
595
vendor/ruvector/crates/ruvector-solver/src/types.rs
vendored
Normal 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,
|
||||
}
|
||||
786
vendor/ruvector/crates/ruvector-solver/src/validation.rs
vendored
Normal file
786
vendor/ruvector/crates/ruvector-solver/src/validation.rs
vendored
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user