Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
This commit is contained in:
67
crates/ruvector-solver/Cargo.toml
Normal file
67
crates/ruvector-solver/Cargo.toml
Normal file
@@ -0,0 +1,67 @@
|
||||
[package]
|
||||
name = "ruvector-solver"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Sublinear-time solver for RuVector: O(log n) to O(√n) algorithms for sparse linear systems, PageRank, and spectral methods"
|
||||
keywords = ["linear-algebra", "sparse-matrix", "pagerank", "solver", "sublinear"]
|
||||
categories = ["mathematics", "science", "algorithms"]
|
||||
|
||||
[features]
|
||||
default = ["neumann", "cg", "forward-push"]
|
||||
nalgebra-backend = ["nalgebra"]
|
||||
parallel = ["rayon", "crossbeam"]
|
||||
simd = []
|
||||
wasm = []
|
||||
full = ["nalgebra-backend", "parallel", "all-algorithms"]
|
||||
neumann = []
|
||||
forward-push = []
|
||||
backward-push = []
|
||||
hybrid-random-walk = ["getrandom"]
|
||||
true-solver = ["neumann"]
|
||||
cg = []
|
||||
bmssp = []
|
||||
all-algorithms = ["neumann", "forward-push", "backward-push", "hybrid-random-walk", "true-solver", "cg", "bmssp"]
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
nalgebra = { version = "0.33", default-features = false, features = ["std"], optional = true }
|
||||
rayon = { workspace = true, optional = true }
|
||||
crossbeam = { workspace = true, optional = true }
|
||||
getrandom = { version = "0.2", optional = true }
|
||||
dashmap = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
approx = "0.5"
|
||||
|
||||
[[bench]]
|
||||
name = "solver_baseline"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "solver_neumann"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "solver_cg"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "solver_push"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "solver_e2e"
|
||||
harness = false
|
||||
364
crates/ruvector-solver/README.md
Normal file
364
crates/ruvector-solver/README.md
Normal file
@@ -0,0 +1,364 @@
|
||||
# ruvector-solver
|
||||
|
||||
[](https://crates.io/crates/ruvector-solver)
|
||||
[](https://docs.rs/ruvector-solver)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
|
||||
**Sublinear-time sparse solvers -- O(log n) PageRank, spectral methods, and linear systems in Rust and WASM.**
|
||||
|
||||
Most numerical libraries use dense solvers that slow down dramatically as data grows. ruvector-solver provides seven specialized sparse algorithms that run in O(log n) to O(sqrt(n)) time, automatically picks the best one for your problem, and works in native Rust or in the browser via WebAssembly. It powers the graph analytics and AI coherence layers inside [RuVector](https://github.com/ruvnet/ruvector).
|
||||
|
||||
| | Dense Solvers (e.g. nalgebra) | ruvector-solver |
|
||||
|---|---|---|
|
||||
| **Speed at scale** | O(n^3) -- slows fast | O(nnz * log n) to O(log n) -- stays fast |
|
||||
| **Memory** | Stores full n*n matrix | Only stores non-zero entries (CSR) |
|
||||
| **SIMD acceleration** | Partial | AVX2 8-wide + fused kernels |
|
||||
| **Algorithm selection** | Manual | Automatic router picks the best of 7 |
|
||||
| **PageRank** | Not available | 3 sublinear algorithms built in |
|
||||
| **Browser / WASM** | No | Full wasm-bindgen bindings |
|
||||
|
||||
## Quick Start
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ruvector-solver = "0.1"
|
||||
```
|
||||
|
||||
```rust
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
use ruvector_solver::neumann::NeumannSolver;
|
||||
|
||||
// Build a sparse 3x3 system and solve it
|
||||
let a = CsrMatrix::<f32>::from_coo(3, 3, vec![
|
||||
(0, 0, 2.0_f32), (0, 1, -0.5),
|
||||
(1, 0, -0.5), (1, 1, 2.0), (1, 2, -0.5),
|
||||
(2, 1, -0.5), (2, 2, 2.0),
|
||||
]);
|
||||
let b = vec![1.0_f32, 0.0, 1.0];
|
||||
|
||||
let solver = NeumannSolver::new(1e-6, 500);
|
||||
let result = solver.solve(&a, &b).unwrap();
|
||||
|
||||
println!("solution: {:?}", result.solution);
|
||||
println!("iterations: {}", result.iterations);
|
||||
println!("residual: {:.2e}", result.residual_norm);
|
||||
```
|
||||
|
||||
Or let the router pick the best algorithm automatically:
|
||||
|
||||
```rust
|
||||
use ruvector_solver::router::{SolverRouter, QueryType};
|
||||
use ruvector_solver::types::{CsrMatrix, ComputeBudget};
|
||||
|
||||
let router = SolverRouter::new();
|
||||
let (algo, result) = router.solve(&matrix, &rhs, &ComputeBudget::default(), QueryType::LinearSystem).unwrap();
|
||||
println!("Router selected: {:?}", algo);
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
| Feature | What It Does | Why It Matters |
|
||||
|---------|-------------|----------------|
|
||||
| **7 specialized algorithms** | Neumann, CG, Forward Push, Backward Push, Random Walk, TRUE, BMSSP | Each tuned for a specific problem class |
|
||||
| **Automatic routing** | SolverRouter analyzes matrix structure and picks the optimal algorithm | No need to be a numerical methods expert |
|
||||
| **Fallback chain** | If the selected algorithm fails, tries CG, then dense | Robust convergence in production |
|
||||
| **AVX2 SIMD SpMV** | 8-wide vectorized sparse matrix-vector multiply | Maximizes throughput on x86_64 |
|
||||
| **Fused residual + norm** | Computes residual and norm in one pass instead of three | 3x less memory traffic per iteration |
|
||||
| **Arena allocator** | Bump allocation for scratch buffers, O(1) reset | Zero heap allocation inside solve loops |
|
||||
| **WASM support** | Full wasm-bindgen bindings | Run solvers in the browser |
|
||||
| **ComputeBudget** | Set max time, iterations, and tolerance | Predictable resource usage |
|
||||
|
||||
## Algorithms
|
||||
|
||||
| Algorithm | Module | Complexity | Applicable to |
|
||||
|-----------|--------|------------|---------------|
|
||||
| Jacobi-preconditioned Neumann series | `neumann` | O(nnz * log(1/eps)) | Diagonally dominant Ax = b |
|
||||
| Conjugate Gradient (Hestenes-Stiefel) | `cg` | O(nnz * sqrt(kappa)) | Symmetric positive-definite Ax = b |
|
||||
| Forward Push (Andersen-Chung-Lang) | `forward_push` | O(1/epsilon) | Single-source Personalized PageRank |
|
||||
| Backward Push | `backward_push` | O(1/epsilon) | Reverse relevance / target-centric PPR |
|
||||
| Hybrid Random Walk | `random_walk` | O(sqrt(n)/epsilon) | Large-graph PPR with push initialisation |
|
||||
| TRUE (JL + sparsification + Neumann) | `true_solver` | O(nnz * log n) | Batch linear systems with shared A |
|
||||
| BMSSP Multigrid (V-cycle + Jacobi) | `bmssp` | O(n log n) | Ill-conditioned / graph Laplacian systems |
|
||||
|
||||
## Feature Flags
|
||||
|
||||
| Feature | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `neumann` | Yes | Jacobi-preconditioned Neumann series solver |
|
||||
| `cg` | Yes | Conjugate Gradient (Hestenes-Stiefel) solver |
|
||||
| `forward-push` | Yes | Forward push for single-source PPR |
|
||||
| `backward-push` | No | Backward push for reverse relevance computation |
|
||||
| `hybrid-random-walk` | No | Hybrid random walk with push initialisation (enables `getrandom`) |
|
||||
| `true-solver` | No | TRUE batch solver (implies `neumann`) |
|
||||
| `bmssp` | No | BMSSP multigrid solver (V-cycle with Jacobi smoothing) |
|
||||
| `all-algorithms` | No | Enables every algorithm above |
|
||||
| `simd` | No | AVX2 SIMD-accelerated SpMV (x86_64 only) |
|
||||
| `wasm` | No | WebAssembly target support |
|
||||
| `parallel` | No | Multi-threaded SpMV and solver loops (enables `rayon`, `crossbeam`) |
|
||||
| `full` | No | All algorithms + `parallel` + `nalgebra-backend` |
|
||||
|
||||
Enable all algorithms:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
ruvector-solver = { version = "0.1", features = ["all-algorithms"] }
|
||||
```
|
||||
|
||||
## Performance Optimisations
|
||||
|
||||
### Bounds-check-free SpMV (`spmv_unchecked`)
|
||||
|
||||
The inner SpMV loop is the single hottest path in every iterative solver. The
|
||||
`spmv_unchecked` method on `CsrMatrix<f32>` and `CsrMatrix<f64>` uses raw
|
||||
pointers to eliminate per-element bounds checks, relying on a one-time CSR
|
||||
structural validation (`validation::validate_csr_matrix`) performed before
|
||||
entering the solve loop.
|
||||
|
||||
### Fused residual + norm computation (`fused_residual_norm_sq`)
|
||||
|
||||
Standard implementations compute the residual `r = b - Ax` and its squared norm
|
||||
`||r||^2` in separate passes (SpMV, vector subtraction, dot product -- three
|
||||
full memory traversals). `fused_residual_norm_sq` computes both in a single
|
||||
pass, reducing memory traffic by roughly 3x per iteration.
|
||||
|
||||
### AVX2 8-wide SIMD SpMV
|
||||
|
||||
When the `simd` feature is enabled on x86_64, `spmv_simd` dispatches to an
|
||||
AVX2 kernel that processes 8 `f32` values per instruction using `_mm256`
|
||||
intrinsics with a horizontal sum reduction at the end of each row. Falls back
|
||||
to a portable scalar loop on other architectures.
|
||||
|
||||
### 4-wide unrolled Jacobi update
|
||||
|
||||
The Neumann iteration's update step `x[j] += d_inv[j] * r[j]` is manually
|
||||
unrolled 4-wide for instruction-level parallelism, with a scalar remainder loop
|
||||
for dimensions not divisible by 4.
|
||||
|
||||
### Arena allocator
|
||||
|
||||
`SolverArena` provides bump allocation for per-solve scratch buffers. All
|
||||
temporary vectors are allocated from a single contiguous backing buffer and
|
||||
reclaimed in O(1) via `arena.reset()`, eliminating heap allocation overhead
|
||||
inside the iteration loop.
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
+-------------------+
|
||||
| SolverRouter |
|
||||
| (algorithm select)|
|
||||
+--------+----------+
|
||||
|
|
||||
+----------+-----------+-----------+----------+
|
||||
| | | | |
|
||||
+----v---+ +----v---+ +----v------+ +--v----+ +---v----+
|
||||
|Neumann | | CG | |ForwardPush| | TRUE | | BMSSP |
|
||||
|Solver | | Solver | | Solver | |Solver | |Solver |
|
||||
+----+---+ +----+---+ +-----+-----+ +--+----+ +---+----+
|
||||
| | | | |
|
||||
+-----+----+-----+-----+-----+----+-----+---+
|
||||
| | | |
|
||||
+----v---+ +---v----+ +----v----+ +----v-----+
|
||||
|types.rs| |simd.rs | |arena.rs | |budget.rs |
|
||||
|CsrMatrix| |AVX2 | |Bump | |ComputeBudget|
|
||||
+--------+ |SpMV | |Alloc | |enforcement|
|
||||
+-------+ +--------+ +----------+
|
||||
| | |
|
||||
+----v---+ +---v------+ +--v---------+
|
||||
|traits.rs| |validate.rs| |error.rs |
|
||||
|SolverEngine| |CSR check| |SolverError |
|
||||
+--------+ +---------+ +-----------+
|
||||
```
|
||||
|
||||
The `SolverRouter` analyses the matrix `SparsityProfile` and `QueryType`
|
||||
to select the optimal algorithm. When the selected algorithm fails,
|
||||
`SolverOrchestrator::solve_with_fallback` tries a deterministic fallback
|
||||
chain: **selected -> CG -> Dense**.
|
||||
|
||||
## API Overview
|
||||
|
||||
### Core types (`types.rs`)
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `CsrMatrix<T>` | Compressed Sparse Row matrix with `spmv`, `spmv_unchecked`, `from_coo`, `transpose` |
|
||||
| `SolverResult` | Solution vector, iteration count, residual norm, wall time, convergence history |
|
||||
| `ComputeBudget` | Maximum time, max iterations, target tolerance |
|
||||
| `Algorithm` | Enum of all solver algorithms (Neumann, CG, ForwardPush, ...) |
|
||||
| `SparsityProfile` | Matrix structural analysis (density, diagonal dominance, spectral radius estimate) |
|
||||
| `QueryType` | What the caller wants to solve (LinearSystem, PageRankSingle, Batch, ...) |
|
||||
| `ComplexityEstimate` | Predicted flops, iterations, memory, and complexity class |
|
||||
|
||||
### Traits (`traits.rs`)
|
||||
|
||||
| Trait | Description |
|
||||
|-------|-------------|
|
||||
| `SolverEngine` | Core trait: `solve(matrix, rhs, budget) -> SolverResult` |
|
||||
| `SparseLaplacianSolver` | Extension for graph Laplacian systems and effective resistance |
|
||||
| `SublinearPageRank` | Extension for sublinear PPR: `ppr(matrix, source, alpha, epsilon)` |
|
||||
|
||||
### Error hierarchy (`error.rs`)
|
||||
|
||||
| Error | Cause |
|
||||
|-------|-------|
|
||||
| `SolverError::NonConvergence` | Iteration budget exhausted without reaching tolerance |
|
||||
| `SolverError::NumericalInstability` | NaN/Inf or residual growth > 2x detected |
|
||||
| `SolverError::SpectralRadiusExceeded` | Spectral radius >= 1.0 (Neumann series would diverge) |
|
||||
| `SolverError::BudgetExhausted` | Wall-clock time limit exceeded |
|
||||
| `SolverError::InvalidInput` | Dimension mismatch, non-finite values, index out of bounds |
|
||||
| `SolverError::BackendError` | Backend-specific failure (nalgebra, BLAS) |
|
||||
|
||||
### Infrastructure modules
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `router.rs` | `SolverRouter` for automatic algorithm selection; `SolverOrchestrator` with fallback |
|
||||
| `simd.rs` | AVX2-accelerated SpMV with runtime feature detection |
|
||||
| `validation.rs` | CSR structural validation (index bounds, monotonic row_ptr, NaN/Inf) |
|
||||
| `arena.rs` | `SolverArena` bump allocator for zero per-iteration heap allocation |
|
||||
| `budget.rs` | `ComputeBudget` enforcement during solve |
|
||||
| `audit.rs` | Audit logging for solver invocations |
|
||||
| `events.rs` | Event system for solver lifecycle hooks |
|
||||
|
||||
## Testing
|
||||
|
||||
The crate includes **177 tests** (138 unit tests + 39 integration/doctests):
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
cargo test -p ruvector-solver
|
||||
|
||||
# Run tests with all algorithms enabled
|
||||
cargo test -p ruvector-solver --features all-algorithms
|
||||
|
||||
# Run a specific test module
|
||||
cargo test -p ruvector-solver -- neumann::tests
|
||||
```
|
||||
|
||||
### Benchmarks
|
||||
|
||||
Five Criterion benchmark groups are provided:
|
||||
|
||||
```bash
|
||||
# Run all benchmarks
|
||||
cargo bench -p ruvector-solver
|
||||
|
||||
# Run a specific benchmark
|
||||
cargo bench -p ruvector-solver --bench solver_neumann
|
||||
```
|
||||
|
||||
| Benchmark | Description |
|
||||
|-----------|-------------|
|
||||
| `solver_baseline` | Baseline SpMV and vector operations |
|
||||
| `solver_neumann` | Neumann solver convergence on tridiagonal systems |
|
||||
| `solver_cg` | Conjugate Gradient on SPD matrices |
|
||||
| `solver_push` | Forward/backward push on graph adjacency matrices |
|
||||
| `solver_e2e` | End-to-end solve through the router with algorithm selection |
|
||||
|
||||
<details>
|
||||
<summary><strong>Tutorial: Solving a Sparse Linear System</strong></summary>
|
||||
|
||||
### Step 1: Build a CSR matrix
|
||||
|
||||
```rust
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
// 4x4 tridiagonal system (diagonally dominant)
|
||||
let a = CsrMatrix::<f32>::from_coo(4, 4, vec![
|
||||
(0, 0, 3.0), (0, 1, -1.0),
|
||||
(1, 0, -1.0), (1, 1, 3.0), (1, 2, -1.0),
|
||||
(2, 1, -1.0), (2, 2, 3.0), (2, 3, -1.0),
|
||||
(3, 2, -1.0), (3, 3, 3.0),
|
||||
]);
|
||||
let b = vec![2.0f32, 1.0, 1.0, 2.0];
|
||||
```
|
||||
|
||||
### Step 2: Choose a solver
|
||||
|
||||
```rust
|
||||
use ruvector_solver::neumann::NeumannSolver;
|
||||
|
||||
let solver = NeumannSolver::new(1e-6, 500);
|
||||
let result = solver.solve(&a, &b).unwrap();
|
||||
|
||||
println!("Solution: {:?}", result.solution);
|
||||
println!("Iterations: {}", result.iterations);
|
||||
println!("Residual: {:.2e}", result.residual_norm);
|
||||
```
|
||||
|
||||
### Step 3: Use the automatic router
|
||||
|
||||
```rust
|
||||
use ruvector_solver::router::{SolverRouter, QueryType};
|
||||
use ruvector_solver::types::{CsrMatrix, ComputeBudget};
|
||||
|
||||
let a64 = CsrMatrix::<f64>::from_coo(4, 4, vec![/* ... */]);
|
||||
let b64 = vec![2.0, 1.0, 1.0, 2.0];
|
||||
let budget = ComputeBudget::default();
|
||||
|
||||
let router = SolverRouter::new();
|
||||
let (algo, result) = router.solve(&a64, &b64, &budget, QueryType::LinearSystem).unwrap();
|
||||
println!("Router selected: {:?}", algo);
|
||||
```
|
||||
|
||||
### Step 4: Validate input
|
||||
|
||||
```rust
|
||||
use ruvector_solver::validation::validate_csr_matrix;
|
||||
|
||||
let errors = validate_csr_matrix(&a);
|
||||
assert!(errors.is_empty(), "CSR validation failed: {:?}", errors);
|
||||
```
|
||||
|
||||
### Step 5: Benchmark
|
||||
|
||||
```bash
|
||||
cargo bench -p ruvector-solver --bench solver_neumann
|
||||
cargo bench -p ruvector-solver --bench solver_e2e
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Tutorial: PageRank with Forward Push</strong></summary>
|
||||
|
||||
```rust
|
||||
use ruvector_solver::forward_push::ForwardPushSolver;
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
// Build adjacency matrix for a small graph
|
||||
let adj = CsrMatrix::<f32>::from_coo(4, 4, vec![
|
||||
(0, 1, 1.0), (1, 0, 1.0),
|
||||
(1, 2, 1.0), (2, 1, 1.0),
|
||||
(2, 3, 1.0), (3, 2, 1.0),
|
||||
(0, 3, 1.0), (3, 0, 1.0),
|
||||
]);
|
||||
|
||||
let solver = ForwardPushSolver::new(0.85, 1e-6); // alpha=0.85
|
||||
let ppr = solver.ppr(&adj, 0); // PPR from node 0
|
||||
|
||||
println!("PPR scores: {:?}", ppr);
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Related Crates
|
||||
|
||||
| Crate | Role |
|
||||
|-------|------|
|
||||
| [`ruvector-attn-mincut`](../ruvector-attn-mincut/README.md) | Min-cut gating using graph solvers |
|
||||
| [`ruvector-coherence`](../ruvector-coherence/README.md) | Coherence metrics for attention comparison |
|
||||
| [`ruvector-profiler`](../ruvector-profiler/README.md) | Benchmarking memory, power, latency |
|
||||
|
||||
## Minimum Supported Rust Version
|
||||
|
||||
Rust **1.77** or later.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the [MIT License](../../LICENSE).
|
||||
|
||||
---
|
||||
|
||||
Part of [RuVector](https://github.com/ruvnet/ruvector) -- the self-learning vector database.
|
||||
193
crates/ruvector-solver/benches/solver_baseline.rs
Normal file
193
crates/ruvector-solver/benches/solver_baseline.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! Baseline benchmarks for dense and sparse matrix-vector operations.
|
||||
//!
|
||||
//! These benchmarks establish performance baselines for the core linear algebra
|
||||
//! primitives used throughout the solver crate: naive dense matrix-vector
|
||||
//! multiply and CSR sparse matrix-vector multiply (SpMV).
|
||||
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers: deterministic random data generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a dense matrix stored as a flat row-major `Vec<f32>`.
|
||||
///
|
||||
/// Uses a deterministic seed so benchmark results are reproducible across runs.
|
||||
fn random_dense_matrix(rows: usize, cols: usize, seed: u64) -> Vec<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
(0..rows * cols).map(|_| rng.gen_range(-1.0..1.0)).collect()
|
||||
}
|
||||
|
||||
/// Generate a random CSR matrix with approximately `density` fraction of
|
||||
/// non-zero entries.
|
||||
///
|
||||
/// The matrix is square (`n x n`). Each entry in the upper triangle is
|
||||
/// included independently with probability `density`, then mirrored to the
|
||||
/// lower triangle for symmetry. Diagonal entries are always present and set
|
||||
/// to a value ensuring strict diagonal dominance.
|
||||
fn random_csr_matrix(n: usize, density: f64, seed: u64) -> CsrMatrix<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
let mut entries: Vec<(usize, usize, f32)> = Vec::new();
|
||||
|
||||
// Off-diagonal entries (symmetric).
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if rng.gen::<f64>() < density {
|
||||
let val: f32 = rng.gen_range(-0.5..0.5);
|
||||
entries.push((i, j, val));
|
||||
entries.push((j, i, val));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build row-wise absolute sums for diagonal dominance.
|
||||
let mut row_abs_sums = vec![0.0f32; n];
|
||||
for &(r, _c, v) in &entries {
|
||||
row_abs_sums[r] += v.abs();
|
||||
}
|
||||
|
||||
// Diagonal entries: ensure diagonal dominance for solver stability.
|
||||
for i in 0..n {
|
||||
entries.push((i, i, row_abs_sums[i] + 1.0));
|
||||
}
|
||||
|
||||
CsrMatrix::<f32>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
/// Generate a random vector of length `n` with values in [-1, 1].
|
||||
fn random_vector(n: usize, seed: u64) -> Vec<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
(0..n).map(|_| rng.gen_range(-1.0..1.0)).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dense matrix-vector multiply (naive baseline)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Naive dense matrix-vector multiply: `y = A * x`.
|
||||
///
|
||||
/// `a` is stored in row-major order with dimensions `rows x cols`.
|
||||
#[inline(never)]
|
||||
fn dense_matvec(a: &[f32], x: &[f32], y: &mut [f32], rows: usize, cols: usize) {
|
||||
for i in 0..rows {
|
||||
let mut sum = 0.0f32;
|
||||
let row_start = i * cols;
|
||||
for j in 0..cols {
|
||||
sum += a[row_start + j] * x[j];
|
||||
}
|
||||
y[i] = sum;
|
||||
}
|
||||
}
|
||||
|
||||
fn dense_matvec_baseline(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("dense_matvec");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(100);
|
||||
|
||||
for size in [64, 256, 1024, 4096] {
|
||||
let a = random_dense_matrix(size, size, 42);
|
||||
let x = random_vector(size, 43);
|
||||
let mut y = vec![0.0f32; size];
|
||||
|
||||
group.throughput(Throughput::Elements((size * size) as u64));
|
||||
group.bench_with_input(BenchmarkId::new("naive", size), &size, |b, &n| {
|
||||
b.iter(|| {
|
||||
dense_matvec(
|
||||
criterion::black_box(&a),
|
||||
criterion::black_box(&x),
|
||||
criterion::black_box(&mut y),
|
||||
n,
|
||||
n,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sparse matrix-vector multiply (CSR SpMV)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn sparse_spmv_baseline(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("sparse_spmv");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(100);
|
||||
|
||||
for (n, density) in [(1000, 0.01), (1000, 0.05), (10_000, 0.01)] {
|
||||
let csr = random_csr_matrix(n, density, 44);
|
||||
let x = random_vector(n, 45);
|
||||
let mut y = vec![0.0f32; n];
|
||||
|
||||
let label = format!("{}x{}_{:.0}pct", n, n, density * 100.0);
|
||||
group.throughput(Throughput::Elements(csr.nnz() as u64));
|
||||
group.bench_with_input(BenchmarkId::new(&label, n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
csr.spmv(criterion::black_box(&x), criterion::black_box(&mut y));
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dense vs sparse crossover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Benchmark that compares dense and sparse matvec at the same dimension
|
||||
/// to help identify the crossover point where sparse becomes faster.
|
||||
fn dense_vs_sparse_crossover(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("dense_vs_sparse_crossover");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(100);
|
||||
|
||||
for size in [64, 128, 256, 512, 1024] {
|
||||
let density = 0.05;
|
||||
|
||||
// Dense setup.
|
||||
let a_dense = random_dense_matrix(size, size, 42);
|
||||
let x = random_vector(size, 43);
|
||||
let mut y_dense = vec![0.0f32; size];
|
||||
|
||||
group.throughput(Throughput::Elements((size * size) as u64));
|
||||
group.bench_with_input(BenchmarkId::new("dense", size), &size, |b, &n| {
|
||||
b.iter(|| {
|
||||
dense_matvec(
|
||||
criterion::black_box(&a_dense),
|
||||
criterion::black_box(&x),
|
||||
criterion::black_box(&mut y_dense),
|
||||
n,
|
||||
n,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Sparse setup.
|
||||
let csr = random_csr_matrix(size, density, 44);
|
||||
let mut y_sparse = vec![0.0f32; size];
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("sparse_5pct", size), &size, |b, _| {
|
||||
b.iter(|| {
|
||||
csr.spmv(
|
||||
criterion::black_box(&x),
|
||||
criterion::black_box(&mut y_sparse),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
baselines,
|
||||
dense_matvec_baseline,
|
||||
sparse_spmv_baseline,
|
||||
dense_vs_sparse_crossover
|
||||
);
|
||||
criterion_main!(baselines);
|
||||
378
crates/ruvector-solver/benches/solver_cg.rs
Normal file
378
crates/ruvector-solver/benches/solver_cg.rs
Normal file
@@ -0,0 +1,378 @@
|
||||
//! Benchmarks for the Conjugate Gradient (CG) solver.
|
||||
//!
|
||||
//! CG is the method of choice for symmetric positive-definite (SPD) systems.
|
||||
//! These benchmarks measure scaling behaviour, the effect of diagonal
|
||||
//! preconditioning, and a head-to-head comparison with the Neumann series
|
||||
//! solver.
|
||||
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a symmetric positive-definite (SPD) CSR matrix.
|
||||
///
|
||||
/// Constructs a sparse SPD matrix by generating random off-diagonal entries
|
||||
/// and ensuring strict diagonal dominance: `a_{ii} = sum_j |a_{ij}| + 1`.
|
||||
fn spd_csr_matrix(n: usize, density: f64, seed: u64) -> CsrMatrix<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
let mut entries: Vec<(usize, usize, f32)> = Vec::new();
|
||||
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if rng.gen::<f64>() < density {
|
||||
let val: f32 = rng.gen_range(-0.3..0.3);
|
||||
entries.push((i, j, val));
|
||||
entries.push((j, i, val));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut row_abs_sums = vec![0.0f32; n];
|
||||
for &(r, _c, v) in &entries {
|
||||
row_abs_sums[r] += v.abs();
|
||||
}
|
||||
for i in 0..n {
|
||||
entries.push((i, i, row_abs_sums[i] + 1.0));
|
||||
}
|
||||
|
||||
CsrMatrix::<f32>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
/// Random vector with deterministic seed.
|
||||
fn random_vector(n: usize, seed: u64) -> Vec<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
(0..n).map(|_| rng.gen_range(-1.0..1.0)).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline CG solver for benchmarking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Conjugate gradient solver for SPD systems `Ax = b`.
|
||||
///
|
||||
/// This is a textbook CG implementation inlined here so the benchmark does
|
||||
/// not depend on the (currently stub) cg module.
|
||||
#[inline(never)]
|
||||
fn cg_solve(
|
||||
matrix: &CsrMatrix<f32>,
|
||||
rhs: &[f32],
|
||||
tolerance: f64,
|
||||
max_iter: usize,
|
||||
) -> (Vec<f32>, usize, f64) {
|
||||
let n = matrix.rows;
|
||||
let mut x = vec![0.0f32; n];
|
||||
let mut r = rhs.to_vec(); // r_0 = b - A*x_0, with x_0 = 0 => r_0 = b
|
||||
let mut p = r.clone();
|
||||
let mut ap = vec![0.0f32; n];
|
||||
|
||||
let mut rs_old: f64 = r.iter().map(|&v| (v as f64) * (v as f64)).sum();
|
||||
let mut iterations = 0;
|
||||
|
||||
for k in 0..max_iter {
|
||||
// ap = A * p
|
||||
matrix.spmv(&p, &mut ap);
|
||||
|
||||
// alpha = (r^T r) / (p^T A p)
|
||||
let p_ap: f64 = p
|
||||
.iter()
|
||||
.zip(ap.iter())
|
||||
.map(|(&pi, &api)| (pi as f64) * (api as f64))
|
||||
.sum();
|
||||
|
||||
if p_ap.abs() < 1e-30 {
|
||||
iterations = k + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
let alpha = rs_old / p_ap;
|
||||
|
||||
// x = x + alpha * p
|
||||
for i in 0..n {
|
||||
x[i] += (alpha as f32) * p[i];
|
||||
}
|
||||
|
||||
// r = r - alpha * ap
|
||||
for i in 0..n {
|
||||
r[i] -= (alpha as f32) * ap[i];
|
||||
}
|
||||
|
||||
let rs_new: f64 = r.iter().map(|&v| (v as f64) * (v as f64)).sum();
|
||||
iterations = k + 1;
|
||||
|
||||
if rs_new.sqrt() < tolerance {
|
||||
break;
|
||||
}
|
||||
|
||||
// p = r + (rs_new / rs_old) * p
|
||||
let beta = rs_new / rs_old;
|
||||
for i in 0..n {
|
||||
p[i] = r[i] + (beta as f32) * p[i];
|
||||
}
|
||||
|
||||
rs_old = rs_new;
|
||||
}
|
||||
|
||||
let residual_norm = rs_old.sqrt();
|
||||
(x, iterations, residual_norm)
|
||||
}
|
||||
|
||||
/// Diagonal-preconditioned CG solver.
|
||||
///
|
||||
/// Uses the Jacobi (diagonal) preconditioner: `M = diag(A)`.
|
||||
/// Solves `M^{-1} A x = M^{-1} b` via the preconditioned CG algorithm.
|
||||
#[inline(never)]
|
||||
fn pcg_solve(
|
||||
matrix: &CsrMatrix<f32>,
|
||||
rhs: &[f32],
|
||||
tolerance: f64,
|
||||
max_iter: usize,
|
||||
) -> (Vec<f32>, usize, f64) {
|
||||
let n = matrix.rows;
|
||||
|
||||
// Extract diagonal for preconditioner.
|
||||
let mut diag_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 d = matrix.values[idx];
|
||||
diag_inv[i] = if d.abs() > 1e-12 { 1.0 / d } else { 1.0 };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut x = vec![0.0f32; n];
|
||||
let mut r = rhs.to_vec();
|
||||
let mut z: Vec<f32> = r
|
||||
.iter()
|
||||
.zip(diag_inv.iter())
|
||||
.map(|(&ri, &di)| ri * di)
|
||||
.collect();
|
||||
let mut p = z.clone();
|
||||
let mut ap = vec![0.0f32; n];
|
||||
|
||||
let mut rz_old: f64 = r
|
||||
.iter()
|
||||
.zip(z.iter())
|
||||
.map(|(&ri, &zi)| (ri as f64) * (zi as f64))
|
||||
.sum();
|
||||
|
||||
let mut iterations = 0;
|
||||
|
||||
for k in 0..max_iter {
|
||||
matrix.spmv(&p, &mut ap);
|
||||
|
||||
let p_ap: f64 = p
|
||||
.iter()
|
||||
.zip(ap.iter())
|
||||
.map(|(&pi, &api)| (pi as f64) * (api as f64))
|
||||
.sum();
|
||||
|
||||
if p_ap.abs() < 1e-30 {
|
||||
iterations = k + 1;
|
||||
break;
|
||||
}
|
||||
|
||||
let alpha = rz_old / p_ap;
|
||||
|
||||
for i in 0..n {
|
||||
x[i] += (alpha as f32) * p[i];
|
||||
r[i] -= (alpha as f32) * ap[i];
|
||||
}
|
||||
|
||||
let residual_norm: f64 = r
|
||||
.iter()
|
||||
.map(|&v| (v as f64) * (v as f64))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
iterations = k + 1;
|
||||
|
||||
if residual_norm < tolerance {
|
||||
break;
|
||||
}
|
||||
|
||||
// z = M^{-1} r
|
||||
for i in 0..n {
|
||||
z[i] = r[i] * diag_inv[i];
|
||||
}
|
||||
|
||||
let rz_new: f64 = r
|
||||
.iter()
|
||||
.zip(z.iter())
|
||||
.map(|(&ri, &zi)| (ri as f64) * (zi as f64))
|
||||
.sum();
|
||||
|
||||
let beta = rz_new / rz_old;
|
||||
for i in 0..n {
|
||||
p[i] = z[i] + (beta as f32) * p[i];
|
||||
}
|
||||
|
||||
rz_old = rz_new;
|
||||
}
|
||||
|
||||
let residual_norm = r
|
||||
.iter()
|
||||
.map(|&v| (v as f64) * (v as f64))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
(x, iterations, residual_norm)
|
||||
}
|
||||
|
||||
/// Neumann series iteration (inlined for comparison benchmark).
|
||||
#[inline(never)]
|
||||
fn neumann_solve(
|
||||
matrix: &CsrMatrix<f32>,
|
||||
rhs: &[f32],
|
||||
tolerance: f64,
|
||||
max_iter: usize,
|
||||
) -> (Vec<f32>, usize, f64) {
|
||||
let n = matrix.rows;
|
||||
let mut x = vec![0.0f32; n];
|
||||
let mut residual_buf = vec![0.0f32; n];
|
||||
let mut iterations = 0;
|
||||
let mut residual_norm = f64::MAX;
|
||||
|
||||
for k in 0..max_iter {
|
||||
matrix.spmv(&x, &mut residual_buf);
|
||||
for i in 0..n {
|
||||
residual_buf[i] = rhs[i] - residual_buf[i];
|
||||
}
|
||||
|
||||
residual_norm = residual_buf
|
||||
.iter()
|
||||
.map(|&v| (v as f64) * (v as f64))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
|
||||
iterations = k + 1;
|
||||
if residual_norm < tolerance {
|
||||
break;
|
||||
}
|
||||
|
||||
for i in 0..n {
|
||||
x[i] += residual_buf[i];
|
||||
}
|
||||
}
|
||||
|
||||
(x, iterations, residual_norm)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: CG scaling with problem size
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cg_scaling(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("cg_scaling");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
|
||||
for &n in &[100, 1000, 10_000] {
|
||||
let density = if n <= 1000 { 0.02 } else { 0.005 };
|
||||
let matrix = spd_csr_matrix(n, density, 42);
|
||||
let rhs = random_vector(n, 43);
|
||||
|
||||
let sample_count = if n >= 10_000 { 20 } else { 100 };
|
||||
group.sample_size(sample_count);
|
||||
group.throughput(Throughput::Elements(matrix.nnz() as u64));
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("n", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
cg_solve(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
1e-6,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: with vs without diagonal preconditioner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cg_preconditioning(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("cg_preconditioning");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(100);
|
||||
|
||||
for &n in &[500, 1000, 2000] {
|
||||
let matrix = spd_csr_matrix(n, 0.02, 42);
|
||||
let rhs = random_vector(n, 43);
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("cg_plain", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
cg_solve(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
1e-6,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("cg_diag_precond", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
pcg_solve(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
1e-6,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: CG vs Neumann for same problem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cg_vs_neumann(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("cg_vs_neumann");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(100);
|
||||
|
||||
for &n in &[100, 500, 1000] {
|
||||
let matrix = spd_csr_matrix(n, 0.02, 42);
|
||||
let rhs = random_vector(n, 43);
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("cg", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
cg_solve(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
1e-6,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("neumann", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
neumann_solve(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
1e-6,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(cg, cg_scaling, cg_preconditioning, cg_vs_neumann);
|
||||
criterion_main!(cg);
|
||||
390
crates/ruvector-solver/benches/solver_e2e.rs
Normal file
390
crates/ruvector-solver/benches/solver_e2e.rs
Normal file
@@ -0,0 +1,390 @@
|
||||
//! End-to-end benchmarks for the solver orchestration layer.
|
||||
//!
|
||||
//! These benchmarks measure the overhead of algorithm selection (routing) and
|
||||
//! the full end-to-end solve path including routing, validation, solver
|
||||
//! dispatch, and result construction.
|
||||
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use ruvector_solver::types::{Algorithm, CsrMatrix};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a diagonally dominant CSR matrix.
|
||||
fn diag_dominant_csr(n: usize, density: f64, seed: u64) -> CsrMatrix<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
let mut entries: Vec<(usize, usize, f32)> = Vec::new();
|
||||
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if rng.gen::<f64>() < density {
|
||||
let val: f32 = rng.gen_range(-0.3..0.3);
|
||||
entries.push((i, j, val));
|
||||
entries.push((j, i, val));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut row_abs_sums = vec![0.0f32; n];
|
||||
for &(r, _c, v) in &entries {
|
||||
row_abs_sums[r] += v.abs();
|
||||
}
|
||||
for i in 0..n {
|
||||
entries.push((i, i, row_abs_sums[i] + 1.0));
|
||||
}
|
||||
|
||||
CsrMatrix::<f32>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
/// Random vector with deterministic seed.
|
||||
fn random_vector(n: usize, seed: u64) -> Vec<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
(0..n).map(|_| rng.gen_range(-1.0..1.0)).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline algorithm router for benchmarking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Properties extracted from the matrix for routing decisions.
|
||||
#[allow(dead_code)]
|
||||
struct MatrixProperties {
|
||||
n: usize,
|
||||
nnz: usize,
|
||||
density: f64,
|
||||
is_symmetric: bool,
|
||||
max_row_degree: usize,
|
||||
diag_dominance_ratio: f64,
|
||||
}
|
||||
|
||||
/// Analyze a CSR matrix to extract routing-relevant properties.
|
||||
#[inline(never)]
|
||||
fn analyze_matrix(matrix: &CsrMatrix<f32>) -> MatrixProperties {
|
||||
let n = matrix.rows;
|
||||
let nnz = matrix.nnz();
|
||||
let density = nnz as f64 / (n as f64 * n as f64);
|
||||
|
||||
// Check symmetry (sample-based for large matrices).
|
||||
let sample_size = n.min(100);
|
||||
let mut is_symmetric = true;
|
||||
'outer: for i in 0..sample_size {
|
||||
let start = matrix.row_ptr[i];
|
||||
let end = matrix.row_ptr[i + 1];
|
||||
for idx in start..end {
|
||||
let j = matrix.col_indices[idx];
|
||||
if j == i {
|
||||
continue;
|
||||
}
|
||||
// Check if (j, i) exists with the same value.
|
||||
let j_start = matrix.row_ptr[j];
|
||||
let j_end = matrix.row_ptr[j + 1];
|
||||
let mut found = false;
|
||||
for jidx in j_start..j_end {
|
||||
if matrix.col_indices[jidx] == i {
|
||||
if (matrix.values[jidx] - matrix.values[idx]).abs() > 1e-6 {
|
||||
is_symmetric = false;
|
||||
break 'outer;
|
||||
}
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
is_symmetric = false;
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Max row degree.
|
||||
let mut max_row_degree = 0;
|
||||
for i in 0..n {
|
||||
let deg = matrix.row_ptr[i + 1] - matrix.row_ptr[i];
|
||||
max_row_degree = max_row_degree.max(deg);
|
||||
}
|
||||
|
||||
// Diagonal dominance ratio (sampled).
|
||||
let mut diag_dominance_ratio = 0.0;
|
||||
let check_rows = n.min(100);
|
||||
for i in 0..check_rows {
|
||||
let start = matrix.row_ptr[i];
|
||||
let end = matrix.row_ptr[i + 1];
|
||||
let mut diag = 0.0f32;
|
||||
let mut off_diag_sum = 0.0f32;
|
||||
for idx in start..end {
|
||||
if matrix.col_indices[idx] == i {
|
||||
diag = matrix.values[idx].abs();
|
||||
} else {
|
||||
off_diag_sum += matrix.values[idx].abs();
|
||||
}
|
||||
}
|
||||
if off_diag_sum > 0.0 {
|
||||
diag_dominance_ratio += (diag / off_diag_sum) as f64;
|
||||
} else {
|
||||
diag_dominance_ratio += 10.0; // Perfect dominance.
|
||||
}
|
||||
}
|
||||
diag_dominance_ratio /= check_rows as f64;
|
||||
|
||||
MatrixProperties {
|
||||
n,
|
||||
nnz,
|
||||
density,
|
||||
is_symmetric,
|
||||
max_row_degree,
|
||||
diag_dominance_ratio,
|
||||
}
|
||||
}
|
||||
|
||||
/// Select the best algorithm based on matrix properties.
|
||||
#[inline(never)]
|
||||
fn select_algorithm(props: &MatrixProperties, tolerance: f64) -> Algorithm {
|
||||
// High diagonal dominance => Neumann series converges fast.
|
||||
if props.diag_dominance_ratio > 2.0 && tolerance > 1e-8 {
|
||||
return Algorithm::Neumann;
|
||||
}
|
||||
|
||||
// SPD matrix => CG is optimal.
|
||||
if props.is_symmetric && props.diag_dominance_ratio > 1.0 {
|
||||
return Algorithm::CG;
|
||||
}
|
||||
|
||||
// Very sparse, large graph => forward push for PPR-like problems.
|
||||
if props.density < 0.01 && props.n > 1000 {
|
||||
return Algorithm::ForwardPush;
|
||||
}
|
||||
|
||||
// Default fallback.
|
||||
if props.is_symmetric {
|
||||
Algorithm::CG
|
||||
} else {
|
||||
Algorithm::Neumann
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline solvers for e2e benchmarking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Neumann series (Richardson iteration).
|
||||
#[inline(never)]
|
||||
fn neumann_solve(
|
||||
matrix: &CsrMatrix<f32>,
|
||||
rhs: &[f32],
|
||||
tolerance: f64,
|
||||
max_iter: usize,
|
||||
) -> (Vec<f32>, usize, f64) {
|
||||
let n = matrix.rows;
|
||||
let mut x = vec![0.0f32; n];
|
||||
let mut r = vec![0.0f32; n];
|
||||
let mut iterations = 0;
|
||||
let mut residual_norm = f64::MAX;
|
||||
|
||||
for k in 0..max_iter {
|
||||
matrix.spmv(&x, &mut r);
|
||||
for i in 0..n {
|
||||
r[i] = rhs[i] - r[i];
|
||||
}
|
||||
residual_norm = r
|
||||
.iter()
|
||||
.map(|&v| (v as f64) * (v as f64))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
iterations = k + 1;
|
||||
if residual_norm < tolerance {
|
||||
break;
|
||||
}
|
||||
for i in 0..n {
|
||||
x[i] += r[i];
|
||||
}
|
||||
}
|
||||
(x, iterations, residual_norm)
|
||||
}
|
||||
|
||||
/// Conjugate gradient.
|
||||
#[inline(never)]
|
||||
fn cg_solve(
|
||||
matrix: &CsrMatrix<f32>,
|
||||
rhs: &[f32],
|
||||
tolerance: f64,
|
||||
max_iter: usize,
|
||||
) -> (Vec<f32>, usize, f64) {
|
||||
let n = matrix.rows;
|
||||
let mut x = vec![0.0f32; n];
|
||||
let mut r = rhs.to_vec();
|
||||
let mut p = r.clone();
|
||||
let mut ap = vec![0.0f32; n];
|
||||
|
||||
let mut rs_old: f64 = r.iter().map(|&v| (v as f64) * (v as f64)).sum();
|
||||
let mut iterations = 0;
|
||||
|
||||
for k in 0..max_iter {
|
||||
matrix.spmv(&p, &mut ap);
|
||||
|
||||
let p_ap: f64 = p
|
||||
.iter()
|
||||
.zip(ap.iter())
|
||||
.map(|(&pi, &api)| (pi as f64) * (api as f64))
|
||||
.sum();
|
||||
|
||||
if p_ap.abs() < 1e-30 {
|
||||
iterations = k + 1;
|
||||
break;
|
||||
}
|
||||
let alpha = rs_old / p_ap;
|
||||
|
||||
for i in 0..n {
|
||||
x[i] += (alpha as f32) * p[i];
|
||||
r[i] -= (alpha as f32) * ap[i];
|
||||
}
|
||||
|
||||
let rs_new: f64 = r.iter().map(|&v| (v as f64) * (v as f64)).sum();
|
||||
iterations = k + 1;
|
||||
if rs_new.sqrt() < tolerance {
|
||||
break;
|
||||
}
|
||||
|
||||
let beta = rs_new / rs_old;
|
||||
for i in 0..n {
|
||||
p[i] = r[i] + (beta as f32) * p[i];
|
||||
}
|
||||
rs_old = rs_new;
|
||||
}
|
||||
|
||||
let residual_norm = rs_old.sqrt();
|
||||
(x, iterations, residual_norm)
|
||||
}
|
||||
|
||||
/// Full orchestrated solve: analyze -> route -> solve.
|
||||
#[inline(never)]
|
||||
fn orchestrator_solve_impl(
|
||||
matrix: &CsrMatrix<f32>,
|
||||
rhs: &[f32],
|
||||
tolerance: f64,
|
||||
max_iter: usize,
|
||||
) -> (Vec<f32>, usize, f64, Algorithm) {
|
||||
let props = analyze_matrix(matrix);
|
||||
let algorithm = select_algorithm(&props, tolerance);
|
||||
|
||||
let (solution, iterations, residual) = match algorithm {
|
||||
Algorithm::Neumann => neumann_solve(matrix, rhs, tolerance, max_iter),
|
||||
Algorithm::CG => cg_solve(matrix, rhs, tolerance, max_iter),
|
||||
// Fall back to CG for unimplemented algorithms.
|
||||
_ => cg_solve(matrix, rhs, tolerance, max_iter),
|
||||
};
|
||||
|
||||
(solution, iterations, residual, algorithm)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: router overhead (analyze + select, no solve)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn router_overhead(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("router_overhead");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(100);
|
||||
|
||||
for &n in &[100, 1000, 10_000] {
|
||||
let density = if n <= 1000 { 0.02 } else { 0.005 };
|
||||
let matrix = diag_dominant_csr(n, density, 42);
|
||||
|
||||
group.throughput(Throughput::Elements(n as u64));
|
||||
group.bench_with_input(BenchmarkId::new("analyze_and_route", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
let props = analyze_matrix(criterion::black_box(&matrix));
|
||||
select_algorithm(criterion::black_box(&props), 1e-6)
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: full orchestrated solve (end-to-end)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn orchestrator_solve(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("orchestrator_solve");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
|
||||
for &n in &[100, 500, 1000, 5000] {
|
||||
let density = if n <= 1000 { 0.02 } else { 0.005 };
|
||||
let matrix = diag_dominant_csr(n, density, 42);
|
||||
let rhs = random_vector(n, 43);
|
||||
|
||||
let sample_count = if n >= 5000 { 20 } else { 100 };
|
||||
group.sample_size(sample_count);
|
||||
group.throughput(Throughput::Elements(matrix.nnz() as u64));
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("e2e", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
orchestrator_solve_impl(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
1e-6,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: routing overhead as fraction of total solve time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn routing_fraction(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("routing_fraction");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(100);
|
||||
|
||||
let n = 1000;
|
||||
let density = 0.02;
|
||||
let matrix = diag_dominant_csr(n, density, 42);
|
||||
let rhs = random_vector(n, 43);
|
||||
|
||||
// Route only.
|
||||
group.bench_function("route_only", |b| {
|
||||
b.iter(|| {
|
||||
let props = analyze_matrix(criterion::black_box(&matrix));
|
||||
select_algorithm(criterion::black_box(&props), 1e-6)
|
||||
});
|
||||
});
|
||||
|
||||
// Solve only (skip routing).
|
||||
group.bench_function("solve_only_cg", |b| {
|
||||
b.iter(|| {
|
||||
cg_solve(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
1e-6,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
// Full e2e (route + solve).
|
||||
group.bench_function("e2e_routed", |b| {
|
||||
b.iter(|| {
|
||||
orchestrator_solve_impl(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
1e-6,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(e2e, router_overhead, orchestrator_solve, routing_fraction);
|
||||
criterion_main!(e2e);
|
||||
313
crates/ruvector-solver/benches/solver_neumann.rs
Normal file
313
crates/ruvector-solver/benches/solver_neumann.rs
Normal file
@@ -0,0 +1,313 @@
|
||||
//! Benchmarks for the Neumann series solver.
|
||||
//!
|
||||
//! The Neumann series approximates `(I - M)^{-1} b = sum_{k=0}^{K} M^k b`
|
||||
//! and converges when the spectral radius of `M` is less than 1. These
|
||||
//! benchmarks measure convergence rate vs tolerance, scaling behaviour, and
|
||||
//! crossover against dense direct solves.
|
||||
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a diagonally dominant CSR matrix suitable for Neumann iteration.
|
||||
///
|
||||
/// The iteration matrix `M = I - D^{-1} A` has spectral radius < 1 when `A`
|
||||
/// is strictly diagonally dominant. We construct `A` so that each diagonal
|
||||
/// entry equals the sum of absolute off-diagonal values in its row plus 1.0.
|
||||
fn diag_dominant_csr(n: usize, density: f64, seed: u64) -> CsrMatrix<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
let mut entries: Vec<(usize, usize, f32)> = Vec::new();
|
||||
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if rng.gen::<f64>() < density {
|
||||
let val: f32 = rng.gen_range(-0.3..0.3);
|
||||
entries.push((i, j, val));
|
||||
entries.push((j, i, val));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut row_abs_sums = vec![0.0f32; n];
|
||||
for &(r, _c, v) in &entries {
|
||||
row_abs_sums[r] += v.abs();
|
||||
}
|
||||
for i in 0..n {
|
||||
entries.push((i, i, row_abs_sums[i] + 1.0));
|
||||
}
|
||||
|
||||
CsrMatrix::<f32>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
/// Random vector with deterministic seed.
|
||||
fn random_vector(n: usize, seed: u64) -> Vec<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
(0..n).map(|_| rng.gen_range(-1.0..1.0)).collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline Neumann series solver for benchmarking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Neumann series iteration: x_{k+1} = x_k + (b - A * x_k).
|
||||
///
|
||||
/// This is equivalent to the Richardson iteration with omega = 1 for a
|
||||
/// diagonally-dominant system. We inline it here so the benchmark does
|
||||
/// not depend on the (currently stub) neumann module.
|
||||
#[inline(never)]
|
||||
fn neumann_solve(
|
||||
matrix: &CsrMatrix<f32>,
|
||||
rhs: &[f32],
|
||||
tolerance: f64,
|
||||
max_iter: usize,
|
||||
) -> (Vec<f32>, usize, f64) {
|
||||
let n = matrix.rows;
|
||||
let mut x = vec![0.0f32; n];
|
||||
let mut residual_buf = vec![0.0f32; n];
|
||||
let mut iterations = 0;
|
||||
let mut residual_norm = f64::MAX;
|
||||
|
||||
for k in 0..max_iter {
|
||||
// Compute residual: r = b - A*x.
|
||||
matrix.spmv(&x, &mut residual_buf);
|
||||
for i in 0..n {
|
||||
residual_buf[i] = rhs[i] - residual_buf[i];
|
||||
}
|
||||
|
||||
// Residual L2 norm.
|
||||
residual_norm = residual_buf
|
||||
.iter()
|
||||
.map(|&v| (v as f64) * (v as f64))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
|
||||
iterations = k + 1;
|
||||
if residual_norm < tolerance {
|
||||
break;
|
||||
}
|
||||
|
||||
// Update: x = x + r (Richardson step).
|
||||
for i in 0..n {
|
||||
x[i] += residual_buf[i];
|
||||
}
|
||||
}
|
||||
|
||||
(x, iterations, residual_norm)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: convergence vs tolerance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn neumann_convergence(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("neumann_convergence");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(100);
|
||||
|
||||
let n = 500;
|
||||
let matrix = diag_dominant_csr(n, 0.02, 42);
|
||||
let rhs = random_vector(n, 43);
|
||||
|
||||
for &tol in &[1e-2, 1e-4, 1e-6] {
|
||||
let label = format!("eps_{:.0e}", tol);
|
||||
group.bench_with_input(BenchmarkId::new(&label, n), &tol, |b, &eps| {
|
||||
b.iter(|| {
|
||||
neumann_solve(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
eps,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: scaling with problem size
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn neumann_scaling(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("neumann_scaling");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
|
||||
for &n in &[100, 1000, 10_000] {
|
||||
// Use sparser matrices for larger sizes to keep runtime reasonable.
|
||||
let density = if n <= 1000 { 0.02 } else { 0.005 };
|
||||
let matrix = diag_dominant_csr(n, density, 42);
|
||||
let rhs = random_vector(n, 43);
|
||||
|
||||
let sample_count = if n >= 10_000 { 20 } else { 100 };
|
||||
group.sample_size(sample_count);
|
||||
group.throughput(Throughput::Elements(matrix.nnz() as u64));
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("n", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
neumann_solve(
|
||||
criterion::black_box(&matrix),
|
||||
criterion::black_box(&rhs),
|
||||
1e-4,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: Neumann vs dense direct solve crossover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Naive dense direct solve via Gaussian elimination with partial pivoting.
|
||||
///
|
||||
/// This is intentionally unoptimized to represent a "no-library" baseline.
|
||||
#[inline(never)]
|
||||
fn dense_direct_solve(a: &[f32], b: &[f32], n: usize) -> Vec<f32> {
|
||||
// Build augmented matrix [A | b] in row-major order.
|
||||
let mut aug = vec![0.0f64; n * (n + 1)];
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
aug[i * (n + 1) + j] = a[i * n + j] as f64;
|
||||
}
|
||||
aug[i * (n + 1) + n] = b[i] as f64;
|
||||
}
|
||||
|
||||
// Forward elimination with partial pivoting.
|
||||
for col in 0..n {
|
||||
// Find pivot.
|
||||
let mut max_row = col;
|
||||
let mut max_val = aug[col * (n + 1) + col].abs();
|
||||
for row in (col + 1)..n {
|
||||
let val = aug[row * (n + 1) + col].abs();
|
||||
if val > max_val {
|
||||
max_val = val;
|
||||
max_row = row;
|
||||
}
|
||||
}
|
||||
|
||||
// Swap rows.
|
||||
if max_row != col {
|
||||
for j in 0..=n {
|
||||
let idx_a = col * (n + 1) + j;
|
||||
let idx_b = max_row * (n + 1) + j;
|
||||
aug.swap(idx_a, idx_b);
|
||||
}
|
||||
}
|
||||
|
||||
let pivot = aug[col * (n + 1) + col];
|
||||
if pivot.abs() < 1e-15 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Eliminate below.
|
||||
for row in (col + 1)..n {
|
||||
let factor = aug[row * (n + 1) + col] / pivot;
|
||||
for j in col..=n {
|
||||
let val = aug[col * (n + 1) + j];
|
||||
aug[row * (n + 1) + j] -= factor * val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back substitution.
|
||||
let mut x = vec![0.0f64; n];
|
||||
for i in (0..n).rev() {
|
||||
let mut sum = aug[i * (n + 1) + n];
|
||||
for j in (i + 1)..n {
|
||||
sum -= aug[i * (n + 1) + j] * x[j];
|
||||
}
|
||||
let diag = aug[i * (n + 1) + i];
|
||||
x[i] = if diag.abs() > 1e-15 { sum / diag } else { 0.0 };
|
||||
}
|
||||
|
||||
x.iter().map(|&v| v as f32).collect()
|
||||
}
|
||||
|
||||
/// Generate the dense representation of a diag-dominant matrix.
|
||||
fn diag_dominant_dense(n: usize, density: f64, seed: u64) -> Vec<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
let mut a = vec![0.0f32; n * n];
|
||||
|
||||
// Off-diagonal.
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if rng.gen::<f64>() < density {
|
||||
let val: f32 = rng.gen_range(-0.3..0.3);
|
||||
a[i * n + j] = val;
|
||||
a[j * n + i] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Diagonal dominance.
|
||||
for i in 0..n {
|
||||
let mut row_sum = 0.0f32;
|
||||
for j in 0..n {
|
||||
if j != i {
|
||||
row_sum += a[i * n + j].abs();
|
||||
}
|
||||
}
|
||||
a[i * n + i] = row_sum + 1.0;
|
||||
}
|
||||
|
||||
a
|
||||
}
|
||||
|
||||
fn neumann_vs_dense(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("neumann_vs_dense");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
|
||||
// Crossover analysis: compare iterative Neumann vs dense direct solve.
|
||||
// For small n, dense wins; for large sparse n, Neumann should win.
|
||||
for &n in &[50, 100, 200, 500] {
|
||||
let density = 0.05;
|
||||
let rhs = random_vector(n, 43);
|
||||
|
||||
let sample_count = if n >= 500 { 20 } else { 100 };
|
||||
group.sample_size(sample_count);
|
||||
|
||||
// Neumann (sparse).
|
||||
let csr = diag_dominant_csr(n, density, 42);
|
||||
group.bench_with_input(BenchmarkId::new("neumann_sparse", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
neumann_solve(
|
||||
criterion::black_box(&csr),
|
||||
criterion::black_box(&rhs),
|
||||
1e-4,
|
||||
5000,
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
// Dense direct solve.
|
||||
let a_dense = diag_dominant_dense(n, density, 42);
|
||||
group.bench_with_input(BenchmarkId::new("dense_direct", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
dense_direct_solve(
|
||||
criterion::black_box(&a_dense),
|
||||
criterion::black_box(&rhs),
|
||||
n,
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
neumann,
|
||||
neumann_convergence,
|
||||
neumann_scaling,
|
||||
neumann_vs_dense
|
||||
);
|
||||
criterion_main!(neumann);
|
||||
222
crates/ruvector-solver/benches/solver_push.rs
Normal file
222
crates/ruvector-solver/benches/solver_push.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! Benchmarks for the forward push algorithm (Andersen-Chung-Lang).
|
||||
//!
|
||||
//! Forward push computes approximate Personalized PageRank (PPR) vectors in
|
||||
//! sublinear time. These benchmarks measure scaling with graph size and the
|
||||
//! effect of tolerance on the number of push operations.
|
||||
|
||||
use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a random sparse graph as a CSR matrix suitable for PageRank.
|
||||
///
|
||||
/// Each entry `A[i][j]` represents the transition probability from node `i`
|
||||
/// to node `j`. The matrix is row-stochastic: each row sums to 1. The
|
||||
/// graph is constructed by giving each node `avg_degree` random outgoing
|
||||
/// edges.
|
||||
fn random_graph_csr(n: usize, avg_degree: usize, seed: u64) -> CsrMatrix<f32> {
|
||||
let mut rng = StdRng::seed_from_u64(seed);
|
||||
let mut entries: Vec<(usize, usize, f32)> = Vec::new();
|
||||
|
||||
for i in 0..n {
|
||||
let degree = (avg_degree as f64 * (0.5 + rng.gen::<f64>())) as usize;
|
||||
let degree = degree.max(1).min(n - 1);
|
||||
|
||||
// Select random neighbours (without replacement for small degree).
|
||||
let mut neighbours = Vec::with_capacity(degree);
|
||||
for _ in 0..degree {
|
||||
let mut j = rng.gen_range(0..n);
|
||||
while j == i {
|
||||
j = rng.gen_range(0..n);
|
||||
}
|
||||
neighbours.push(j);
|
||||
}
|
||||
neighbours.sort_unstable();
|
||||
neighbours.dedup();
|
||||
|
||||
let weight = 1.0 / neighbours.len() as f32;
|
||||
for &j in &neighbours {
|
||||
entries.push((i, j, weight));
|
||||
}
|
||||
}
|
||||
|
||||
CsrMatrix::<f32>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline forward push for benchmarking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Forward push algorithm for approximate Personalized PageRank.
|
||||
///
|
||||
/// Computes an approximate PPR vector `pi` for a source node `source` with
|
||||
/// teleport probability `alpha`. The algorithm maintains a residual vector
|
||||
/// and pushes mass from nodes whose residual exceeds `tolerance`.
|
||||
///
|
||||
/// Returns `(estimate, residual, num_pushes)`.
|
||||
#[inline(never)]
|
||||
fn forward_push(
|
||||
matrix: &CsrMatrix<f32>,
|
||||
source: usize,
|
||||
alpha: f32,
|
||||
tolerance: f32,
|
||||
) -> (Vec<f32>, Vec<f32>, usize) {
|
||||
let n = matrix.rows;
|
||||
let mut estimate = vec![0.0f32; n];
|
||||
let mut residual = vec![0.0f32; n];
|
||||
residual[source] = 1.0;
|
||||
|
||||
let mut queue: VecDeque<usize> = VecDeque::new();
|
||||
queue.push_back(source);
|
||||
let mut in_queue = vec![false; n];
|
||||
in_queue[source] = true;
|
||||
|
||||
let mut num_pushes = 0usize;
|
||||
|
||||
while let Some(u) = queue.pop_front() {
|
||||
in_queue[u] = false;
|
||||
let r_u = residual[u];
|
||||
|
||||
if r_u.abs() < tolerance {
|
||||
continue;
|
||||
}
|
||||
|
||||
num_pushes += 1;
|
||||
|
||||
// Absorb alpha fraction.
|
||||
estimate[u] += alpha * r_u;
|
||||
let push_mass = (1.0 - alpha) * r_u;
|
||||
residual[u] = 0.0;
|
||||
|
||||
// Distribute remaining mass to neighbours.
|
||||
let start = matrix.row_ptr[u];
|
||||
let end = matrix.row_ptr[u + 1];
|
||||
let degree = end - start;
|
||||
|
||||
if degree > 0 {
|
||||
for idx in start..end {
|
||||
let v = matrix.col_indices[idx];
|
||||
let w = matrix.values[idx];
|
||||
residual[v] += push_mass * w;
|
||||
|
||||
if !in_queue[v] && residual[v].abs() >= tolerance {
|
||||
queue.push_back(v);
|
||||
in_queue[v] = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Dangling node: teleport back to source.
|
||||
residual[source] += push_mass;
|
||||
if !in_queue[source] && residual[source].abs() >= tolerance {
|
||||
queue.push_back(source);
|
||||
in_queue[source] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(estimate, residual, num_pushes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: forward push scaling with graph size
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn forward_push_scaling(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("forward_push_scaling");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
|
||||
let alpha = 0.15f32;
|
||||
let tolerance = 1e-4f32;
|
||||
|
||||
for &n in &[100, 1000, 10_000, 100_000] {
|
||||
let avg_degree = 10;
|
||||
let graph = random_graph_csr(n, avg_degree, 42);
|
||||
|
||||
let sample_count = if n >= 100_000 {
|
||||
10
|
||||
} else if n >= 10_000 {
|
||||
20
|
||||
} else {
|
||||
100
|
||||
};
|
||||
group.sample_size(sample_count);
|
||||
group.throughput(Throughput::Elements(n as u64));
|
||||
|
||||
group.bench_with_input(BenchmarkId::new("n", n), &n, |b, _| {
|
||||
b.iter(|| {
|
||||
forward_push(
|
||||
criterion::black_box(&graph),
|
||||
0, // source node
|
||||
alpha,
|
||||
tolerance,
|
||||
)
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: forward push tolerance sensitivity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn forward_push_tolerance(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("forward_push_tolerance");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(100);
|
||||
|
||||
let n = 10_000;
|
||||
let avg_degree = 10;
|
||||
let alpha = 0.15f32;
|
||||
let graph = random_graph_csr(n, avg_degree, 42);
|
||||
|
||||
for &tol in &[1e-2f32, 1e-4, 1e-6] {
|
||||
let label = format!("eps_{:.0e}", tol);
|
||||
group.bench_with_input(BenchmarkId::new(&label, n), &tol, |b, &eps| {
|
||||
b.iter(|| forward_push(criterion::black_box(&graph), 0, alpha, eps));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: forward push with varying graph density
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn forward_push_density(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("forward_push_density");
|
||||
group.warm_up_time(Duration::from_secs(3));
|
||||
group.sample_size(50);
|
||||
|
||||
let n = 10_000;
|
||||
let alpha = 0.15f32;
|
||||
let tolerance = 1e-4f32;
|
||||
|
||||
for &avg_degree in &[5, 10, 20, 50] {
|
||||
let graph = random_graph_csr(n, avg_degree, 42);
|
||||
|
||||
let label = format!("deg_{}", avg_degree);
|
||||
group.throughput(Throughput::Elements(graph.nnz() as u64));
|
||||
group.bench_with_input(BenchmarkId::new(&label, n), &avg_degree, |b, _| {
|
||||
b.iter(|| forward_push(criterion::black_box(&graph), 0, alpha, tolerance));
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
push,
|
||||
forward_push_scaling,
|
||||
forward_push_tolerance,
|
||||
forward_push_density
|
||||
);
|
||||
criterion_main!(push);
|
||||
14
crates/ruvector-solver/build.rs
Normal file
14
crates/ruvector-solver/build.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
fn main() {
|
||||
// Emit cfg flags for SIMD detection at build time
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
{
|
||||
if std::env::var("CARGO_CFG_TARGET_FEATURE").map_or(false, |f| f.contains("avx2")) {
|
||||
println!("cargo:rustc-cfg=has_avx2");
|
||||
}
|
||||
if std::env::var("CARGO_CFG_TARGET_FEATURE").map_or(false, |f| f.contains("avx512f")) {
|
||||
println!("cargo:rustc-cfg=has_avx512");
|
||||
}
|
||||
}
|
||||
}
|
||||
176
crates/ruvector-solver/src/arena.rs
Normal file
176
crates/ruvector-solver/src/arena.rs
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
crates/ruvector-solver/src/audit.rs
Normal file
316
crates/ruvector-solver/src/audit.rs
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
crates/ruvector-solver/src/backward_push.rs
Normal file
693
crates/ruvector-solver/src/backward_push.rs
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
crates/ruvector-solver/src/bmssp.rs
Normal file
1162
crates/ruvector-solver/src/bmssp.rs
Normal file
File diff suppressed because it is too large
Load Diff
310
crates/ruvector-solver/src/budget.rs
Normal file
310
crates/ruvector-solver/src/budget.rs
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
crates/ruvector-solver/src/cg.rs
Normal file
1099
crates/ruvector-solver/src/cg.rs
Normal file
File diff suppressed because it is too large
Load Diff
120
crates/ruvector-solver/src/error.rs
Normal file
120
crates/ruvector-solver/src/error.rs
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
crates/ruvector-solver/src/events.rs
Normal file
86
crates/ruvector-solver/src/events.rs
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
crates/ruvector-solver/src/forward_push.rs
Normal file
808
crates/ruvector-solver/src/forward_push.rs
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
crates/ruvector-solver/src/lib.rs
Normal file
63
crates/ruvector-solver/src/lib.rs
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
crates/ruvector-solver/src/neumann.rs
Normal file
779
crates/ruvector-solver/src/neumann.rs
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
crates/ruvector-solver/src/random_walk.rs
Normal file
938
crates/ruvector-solver/src/random_walk.rs
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
crates/ruvector-solver/src/router.rs
Normal file
1614
crates/ruvector-solver/src/router.rs
Normal file
File diff suppressed because it is too large
Load Diff
281
crates/ruvector-solver/src/simd.rs
Normal file
281
crates/ruvector-solver/src/simd.rs
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
crates/ruvector-solver/src/traits.rs
Normal file
134
crates/ruvector-solver/src/traits.rs
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
crates/ruvector-solver/src/true_solver.rs
Normal file
932
crates/ruvector-solver/src/true_solver.rs
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
crates/ruvector-solver/src/types.rs
Normal file
595
crates/ruvector-solver/src/types.rs
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
crates/ruvector-solver/src/validation.rs
Normal file
786
crates/ruvector-solver/src/validation.rs
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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
325
crates/ruvector-solver/tests/helpers.rs
Normal file
325
crates/ruvector-solver/tests/helpers.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
//! Shared test helpers for the ruvector-solver integration test suite.
|
||||
//!
|
||||
//! Provides deterministic random matrix generators, dense reference solvers,
|
||||
//! and floating-point comparison utilities used across all test modules.
|
||||
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Random number generator (simple LCG for deterministic reproducibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A minimal linear congruential generator for deterministic test data.
|
||||
///
|
||||
/// Uses the Numerical Recipes LCG parameters. Not cryptographically secure,
|
||||
/// but perfectly adequate for generating reproducible test matrices.
|
||||
pub struct Lcg {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl Lcg {
|
||||
/// Create a new LCG with the given seed.
|
||||
pub fn new(seed: u64) -> Self {
|
||||
Self { state: seed }
|
||||
}
|
||||
|
||||
/// Generate the next u64 value.
|
||||
pub fn next_u64(&mut self) -> u64 {
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Generate a uniform f64 in [0, 1).
|
||||
pub fn next_f64(&mut self) -> f64 {
|
||||
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
/// Generate a uniform f64 in [lo, hi).
|
||||
pub fn next_f64_range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (hi - lo) * self.next_f64()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Matrix generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a random diagonally dominant CSR matrix of dimension `n`.
|
||||
///
|
||||
/// Each row has approximately `density * n` non-zero off-diagonal entries
|
||||
/// (at least 1). The diagonal entry is set to `1 + sum_of_abs_off_diag`
|
||||
/// to guarantee strict diagonal dominance.
|
||||
///
|
||||
/// The resulting matrix is suitable for Neumann and Jacobi solvers.
|
||||
pub fn random_diag_dominant_csr(n: usize, density: f64, seed: u64) -> CsrMatrix<f64> {
|
||||
let mut rng = Lcg::new(seed);
|
||||
let mut entries: Vec<(usize, usize, f64)> = Vec::new();
|
||||
|
||||
for i in 0..n {
|
||||
let mut off_diag_sum = 0.0f64;
|
||||
|
||||
for j in 0..n {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
if rng.next_f64() < density {
|
||||
let val = rng.next_f64_range(-1.0, 1.0);
|
||||
entries.push((i, j, val));
|
||||
off_diag_sum += val.abs();
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure at least one off-diagonal entry per row for non-trivial testing.
|
||||
if off_diag_sum == 0.0 && n > 1 {
|
||||
let j = (i + 1) % n;
|
||||
let val = rng.next_f64_range(0.1, 0.5);
|
||||
entries.push((i, j, val));
|
||||
off_diag_sum = val;
|
||||
}
|
||||
|
||||
// Diagonal: strictly dominant.
|
||||
let diag_val = off_diag_sum + 1.0 + rng.next_f64();
|
||||
entries.push((i, i, diag_val));
|
||||
}
|
||||
|
||||
CsrMatrix::<f64>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
/// Generate a random graph Laplacian CSR matrix of dimension `n`.
|
||||
///
|
||||
/// A graph Laplacian `L = D - A` where:
|
||||
/// - `A` is the adjacency matrix of a random undirected graph.
|
||||
/// - `D` is the degree matrix.
|
||||
/// - Each row sums to zero (L * ones = 0).
|
||||
///
|
||||
/// The resulting matrix is symmetric positive semi-definite.
|
||||
pub fn random_laplacian_csr(n: usize, density: f64, seed: u64) -> CsrMatrix<f64> {
|
||||
let mut rng = Lcg::new(seed);
|
||||
|
||||
// Build symmetric adjacency: for i < j, randomly include edge (i,j).
|
||||
let mut adj = vec![vec![0.0f64; n]; n];
|
||||
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
if rng.next_f64() < density {
|
||||
let weight = rng.next_f64_range(0.1, 2.0);
|
||||
adj[i][j] = weight;
|
||||
adj[j][i] = weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the graph is connected: add a path 0-1-2-...-n-1.
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
if adj[i][i + 1] == 0.0 {
|
||||
let weight = rng.next_f64_range(0.1, 1.0);
|
||||
adj[i][i + 1] = weight;
|
||||
adj[i + 1][i] = weight;
|
||||
}
|
||||
}
|
||||
|
||||
// Build Laplacian: L[i][j] = -A[i][j] for i != j, L[i][i] = sum_j A[i][j].
|
||||
let mut entries: Vec<(usize, usize, f64)> = Vec::new();
|
||||
|
||||
for i in 0..n {
|
||||
let mut degree = 0.0f64;
|
||||
for j in 0..n {
|
||||
if i != j && adj[i][j] != 0.0 {
|
||||
entries.push((i, j, -adj[i][j]));
|
||||
degree += adj[i][j];
|
||||
}
|
||||
}
|
||||
entries.push((i, i, degree));
|
||||
}
|
||||
|
||||
CsrMatrix::<f64>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
/// Generate a random SPD (symmetric positive definite) matrix.
|
||||
///
|
||||
/// Constructs `A = B^T B + epsilon * I` where `B` has random entries,
|
||||
/// guaranteeing positive definiteness.
|
||||
pub fn random_spd_csr(n: usize, density: f64, seed: u64) -> CsrMatrix<f64> {
|
||||
let mut rng = Lcg::new(seed);
|
||||
|
||||
// Build a random dense matrix B, then compute A = B^T B + eps * I.
|
||||
// For efficiency with CSR, we do this differently: build a sparse
|
||||
// symmetric matrix and add a diagonal shift.
|
||||
let mut dense = vec![vec![0.0f64; n]; n];
|
||||
|
||||
for i in 0..n {
|
||||
for j in i..n {
|
||||
if i == j || rng.next_f64() < density {
|
||||
let val = rng.next_f64_range(-1.0, 1.0);
|
||||
dense[i][j] += val;
|
||||
if i != j {
|
||||
dense[j][i] += val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute A = M^T M where M = dense (makes it PSD).
|
||||
let mut a = vec![vec![0.0f64; n]; n];
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
let mut sum = 0.0;
|
||||
for k in 0..n {
|
||||
sum += dense[k][i] * dense[k][j];
|
||||
}
|
||||
a[i][j] = sum;
|
||||
}
|
||||
}
|
||||
|
||||
// Add diagonal shift to ensure positive definiteness.
|
||||
for i in 0..n {
|
||||
a[i][i] += 1.0;
|
||||
}
|
||||
|
||||
// Convert to COO.
|
||||
let mut entries: Vec<(usize, usize, f64)> = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if a[i][j].abs() > 1e-15 {
|
||||
entries.push((i, j, a[i][j]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CsrMatrix::<f64>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
/// Generate a deterministic random vector of length `n`.
|
||||
pub fn random_vector(n: usize, seed: u64) -> Vec<f64> {
|
||||
let mut rng = Lcg::new(seed);
|
||||
(0..n).map(|_| rng.next_f64_range(-1.0, 1.0)).collect()
|
||||
}
|
||||
|
||||
/// Build a simple undirected graph as a CSR adjacency matrix.
|
||||
///
|
||||
/// Each entry `(u, v)` in `edges` creates entries `A[u][v] = 1` and
|
||||
/// `A[v][u] = 1`.
|
||||
pub fn adjacency_from_edges(n: usize, edges: &[(usize, usize)]) -> CsrMatrix<f64> {
|
||||
let mut entries: Vec<(usize, usize, f64)> = Vec::new();
|
||||
for &(u, v) in edges {
|
||||
entries.push((u, v, 1.0));
|
||||
if u != v {
|
||||
entries.push((v, u, 1.0));
|
||||
}
|
||||
}
|
||||
CsrMatrix::<f64>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dense reference solver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Solve `Ax = b` using dense Gaussian elimination with partial pivoting.
|
||||
///
|
||||
/// This is an O(n^3) reference solver used only for small test problems
|
||||
/// to verify iterative solver accuracy.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if the matrix is singular or dimensions are inconsistent.
|
||||
pub fn dense_solve(matrix: &CsrMatrix<f64>, rhs: &[f64]) -> Vec<f64> {
|
||||
let n = matrix.rows;
|
||||
assert_eq!(n, matrix.cols, "dense_solve requires a square matrix");
|
||||
assert_eq!(rhs.len(), n, "rhs length must match matrix dimension");
|
||||
|
||||
// Convert CSR to dense augmented matrix [A | b].
|
||||
let mut aug = vec![vec![0.0f64; n + 1]; n];
|
||||
for i in 0..n {
|
||||
aug[i][n] = rhs[i];
|
||||
let start = matrix.row_ptr[i];
|
||||
let end = matrix.row_ptr[i + 1];
|
||||
for idx in start..end {
|
||||
let j = matrix.col_indices[idx];
|
||||
aug[i][j] = matrix.values[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// Forward elimination with partial pivoting.
|
||||
for col in 0..n {
|
||||
// Find pivot.
|
||||
let mut max_row = col;
|
||||
let mut max_val = aug[col][col].abs();
|
||||
for row in (col + 1)..n {
|
||||
if aug[row][col].abs() > max_val {
|
||||
max_val = aug[row][col].abs();
|
||||
max_row = row;
|
||||
}
|
||||
}
|
||||
assert!(max_val > 1e-15, "matrix is singular or near-singular");
|
||||
aug.swap(col, max_row);
|
||||
|
||||
// Eliminate.
|
||||
let pivot = aug[col][col];
|
||||
for row in (col + 1)..n {
|
||||
let factor = aug[row][col] / pivot;
|
||||
for j in col..=n {
|
||||
aug[row][j] -= factor * aug[col][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back substitution.
|
||||
let mut x = vec![0.0f64; n];
|
||||
for i in (0..n).rev() {
|
||||
let mut sum = aug[i][n];
|
||||
for j in (i + 1)..n {
|
||||
sum -= aug[i][j] * x[j];
|
||||
}
|
||||
x[i] = sum / aug[i][i];
|
||||
}
|
||||
|
||||
x
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Floating-point comparison utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the L2 norm of a vector.
|
||||
pub fn l2_norm(v: &[f64]) -> f64 {
|
||||
v.iter().map(|&x| x * x).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
/// Compute the L2 distance between two vectors.
|
||||
pub fn l2_distance(a: &[f64], b: &[f64]) -> f64 {
|
||||
assert_eq!(a.len(), b.len(), "vectors must have same length");
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.map(|(&ai, &bi)| (ai - bi) * (ai - bi))
|
||||
.sum::<f64>()
|
||||
.sqrt()
|
||||
}
|
||||
|
||||
/// Compute the relative error ||approx - exact|| / ||exact||.
|
||||
///
|
||||
/// Returns absolute error if the exact solution has zero norm.
|
||||
pub fn relative_error(approx: &[f64], exact: &[f64]) -> f64 {
|
||||
let exact_norm = l2_norm(exact);
|
||||
let error = l2_distance(approx, exact);
|
||||
if exact_norm > 1e-15 {
|
||||
error / exact_norm
|
||||
} else {
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the residual `b - A*x` for a sparse system.
|
||||
pub fn compute_residual(matrix: &CsrMatrix<f64>, x: &[f64], rhs: &[f64]) -> Vec<f64> {
|
||||
let n = matrix.rows;
|
||||
let mut ax = vec![0.0f64; n];
|
||||
matrix.spmv(x, &mut ax);
|
||||
(0..n).map(|i| rhs[i] - ax[i]).collect()
|
||||
}
|
||||
|
||||
/// Convert an f32 solution vector to f64 for comparison.
|
||||
pub fn f32_to_f64(v: &[f32]) -> Vec<f64> {
|
||||
v.iter().map(|&x| x as f64).collect()
|
||||
}
|
||||
249
crates/ruvector-solver/tests/test_cg.rs
Normal file
249
crates/ruvector-solver/tests/test_cg.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
//! Integration tests for the Conjugate Gradient (CG) solver.
|
||||
//!
|
||||
//! Tests cover correctness on SPD systems, Laplacian solves, preconditioning
|
||||
//! benefits, known-solution verification, and tolerance scaling.
|
||||
|
||||
mod helpers;
|
||||
|
||||
use approx::assert_relative_eq;
|
||||
use ruvector_solver::cg::ConjugateGradientSolver;
|
||||
use ruvector_solver::traits::SolverEngine;
|
||||
use ruvector_solver::types::{Algorithm, ComputeBudget, CsrMatrix};
|
||||
|
||||
use helpers::{
|
||||
compute_residual, dense_solve, f32_to_f64, l2_norm, random_laplacian_csr, random_spd_csr,
|
||||
random_vector, relative_error,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: default compute budget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn default_budget() -> ComputeBudget {
|
||||
ComputeBudget {
|
||||
max_time: std::time::Duration::from_secs(30),
|
||||
max_iterations: 10_000,
|
||||
tolerance: 1e-12,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SPD system: solve and verify convergence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_cg_spd_system() {
|
||||
let n = 15;
|
||||
let matrix = random_spd_csr(n, 0.4, 42);
|
||||
let rhs = random_vector(n, 43);
|
||||
let budget = default_budget();
|
||||
|
||||
let solver = ConjugateGradientSolver::new(1e-8, 500, false);
|
||||
let result = solver.solve(&matrix, &rhs, &budget).unwrap();
|
||||
|
||||
assert_eq!(result.algorithm, Algorithm::CG);
|
||||
assert!(
|
||||
result.residual_norm < 1e-4,
|
||||
"residual too large: {}",
|
||||
result.residual_norm
|
||||
);
|
||||
|
||||
// Independent residual check.
|
||||
let x = f32_to_f64(&result.solution);
|
||||
let residual = compute_residual(&matrix, &x, &rhs);
|
||||
let resid_norm = l2_norm(&residual);
|
||||
assert!(
|
||||
resid_norm < 1e-3,
|
||||
"independent residual check: {}",
|
||||
resid_norm
|
||||
);
|
||||
|
||||
// Compare with dense solve.
|
||||
let exact = dense_solve(&matrix, &rhs);
|
||||
let rel_err = relative_error(&x, &exact);
|
||||
assert!(rel_err < 1e-2, "relative error vs dense solve: {}", rel_err);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph Laplacian system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_cg_laplacian() {
|
||||
let n = 12;
|
||||
let laplacian = random_laplacian_csr(n, 0.3, 44);
|
||||
|
||||
// Laplacians are singular (L * ones = 0), so we add a small regulariser
|
||||
// to make it SPD: A = L + epsilon * I.
|
||||
let epsilon = 0.01;
|
||||
let mut entries: Vec<(usize, usize, f64)> = Vec::new();
|
||||
for i in 0..n {
|
||||
let start = laplacian.row_ptr[i];
|
||||
let end = laplacian.row_ptr[i + 1];
|
||||
for idx in start..end {
|
||||
let j = laplacian.col_indices[idx];
|
||||
let mut v = laplacian.values[idx];
|
||||
if i == j {
|
||||
v += epsilon;
|
||||
}
|
||||
entries.push((i, j, v));
|
||||
}
|
||||
}
|
||||
let reg_laplacian = CsrMatrix::<f64>::from_coo(n, n, entries);
|
||||
|
||||
let rhs = random_vector(n, 45);
|
||||
let budget = default_budget();
|
||||
|
||||
let solver = ConjugateGradientSolver::new(1e-8, 1000, false);
|
||||
let result = solver.solve(®_laplacian, &rhs, &budget).unwrap();
|
||||
|
||||
assert!(
|
||||
result.residual_norm < 1e-4,
|
||||
"laplacian solve residual: {}",
|
||||
result.residual_norm
|
||||
);
|
||||
|
||||
// Verify Ax = b.
|
||||
let x = f32_to_f64(&result.solution);
|
||||
let residual = compute_residual(®_laplacian, &x, &rhs);
|
||||
let resid_norm = l2_norm(&residual);
|
||||
assert!(
|
||||
resid_norm < 1e-3,
|
||||
"laplacian residual check: {}",
|
||||
resid_norm
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preconditioned CG reduces iterations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_cg_preconditioned() {
|
||||
let n = 30;
|
||||
let matrix = random_spd_csr(n, 0.3, 46);
|
||||
let rhs = random_vector(n, 47);
|
||||
let budget = default_budget();
|
||||
|
||||
let unprecond = ConjugateGradientSolver::new(1e-8, 1000, false);
|
||||
let precond = ConjugateGradientSolver::new(1e-8, 1000, true);
|
||||
|
||||
let result_no = unprecond.solve(&matrix, &rhs, &budget).unwrap();
|
||||
let result_yes = precond.solve(&matrix, &rhs, &budget).unwrap();
|
||||
|
||||
// Both should converge.
|
||||
assert!(
|
||||
result_no.residual_norm < 1e-4,
|
||||
"unpreconditioned residual: {}",
|
||||
result_no.residual_norm
|
||||
);
|
||||
assert!(
|
||||
result_yes.residual_norm < 1e-4,
|
||||
"preconditioned residual: {}",
|
||||
result_yes.residual_norm
|
||||
);
|
||||
|
||||
// Preconditioner should take <= iterations (it won't always be strictly
|
||||
// fewer on well-conditioned systems, but should not take more).
|
||||
assert!(
|
||||
result_yes.iterations <= result_no.iterations + 2,
|
||||
"preconditioned ({}) should not take much more than unpreconditioned ({})",
|
||||
result_yes.iterations,
|
||||
result_no.iterations
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Known solution verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_cg_known_solution() {
|
||||
// Diagonal system D*x = b => x_i = b_i / d_i
|
||||
let diag_vals = vec![2.0, 5.0, 10.0, 1.0];
|
||||
let n = diag_vals.len();
|
||||
let entries: Vec<(usize, usize, f64)> = diag_vals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &d)| (i, i, d))
|
||||
.collect();
|
||||
let matrix = CsrMatrix::<f64>::from_coo(n, n, entries);
|
||||
|
||||
let rhs = vec![4.0, 15.0, 30.0, 7.0];
|
||||
let expected = vec![2.0, 3.0, 3.0, 7.0]; // b_i / d_i
|
||||
|
||||
let budget = default_budget();
|
||||
let solver = ConjugateGradientSolver::new(1e-10, 100, false);
|
||||
let result = solver.solve(&matrix, &rhs, &budget).unwrap();
|
||||
|
||||
let x = f32_to_f64(&result.solution);
|
||||
for i in 0..n {
|
||||
assert_relative_eq!(x[i], expected[i], epsilon = 1e-4);
|
||||
}
|
||||
|
||||
// Also test a tridiagonal system with known answer.
|
||||
// A = [4 -1 0] b = [3] => solve manually:
|
||||
// [-1 4 -1] [2] x0 = (3 + x1)/4
|
||||
// [0 -1 4] [3] x2 = (3 + x1)/4 => x0 = x2 (by symmetry)
|
||||
// x1 = (2 + x0 + x2)/4 = (2 + 2*x0)/4
|
||||
// From row 0: x0 = (3 + x1)/4
|
||||
// From row 1: x1 = (2 + 2*x0)/4 = (1 + x0)/2
|
||||
// Sub: x0 = (3 + (1+x0)/2)/4 = (3.5 + x0/2)/4 = 7/8 + x0/8
|
||||
// => 7x0/8 = 7/8 => x0 = 1, x1 = 1, x2 = 1
|
||||
let tri = CsrMatrix::<f64>::from_coo(
|
||||
3,
|
||||
3,
|
||||
vec![
|
||||
(0, 0, 4.0),
|
||||
(0, 1, -1.0),
|
||||
(1, 0, -1.0),
|
||||
(1, 1, 4.0),
|
||||
(1, 2, -1.0),
|
||||
(2, 1, -1.0),
|
||||
(2, 2, 4.0),
|
||||
],
|
||||
);
|
||||
let rhs_tri = vec![3.0, 2.0, 3.0];
|
||||
let result_tri = solver.solve(&tri, &rhs_tri, &budget).unwrap();
|
||||
let x_tri = f32_to_f64(&result_tri.solution);
|
||||
|
||||
for i in 0..3 {
|
||||
assert_relative_eq!(x_tri[i], 1.0, epsilon = 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tolerance levels: accuracy scales with epsilon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_cg_tolerance_levels() {
|
||||
let n = 20;
|
||||
let matrix = random_spd_csr(n, 0.3, 48);
|
||||
let rhs = random_vector(n, 49);
|
||||
let exact = dense_solve(&matrix, &rhs);
|
||||
let budget = default_budget();
|
||||
|
||||
let tolerances = [1e-4, 1e-6, 1e-8, 1e-10];
|
||||
let mut prev_error = f64::INFINITY;
|
||||
|
||||
for &tol in &tolerances {
|
||||
let solver = ConjugateGradientSolver::new(tol, 5000, false);
|
||||
let result = solver.solve(&matrix, &rhs, &budget).unwrap();
|
||||
|
||||
let x = f32_to_f64(&result.solution);
|
||||
let rel_err = relative_error(&x, &exact);
|
||||
|
||||
// Error should generally decrease with tighter tolerance.
|
||||
// Allow some slack for f32 precision limits at very tight tolerances.
|
||||
assert!(
|
||||
rel_err < tol.sqrt() * 100.0 || rel_err < prev_error * 10.0,
|
||||
"tol={:.0e}: relative error {:.2e} is too large (prev={:.2e})",
|
||||
tol,
|
||||
rel_err,
|
||||
prev_error
|
||||
);
|
||||
|
||||
prev_error = rel_err;
|
||||
}
|
||||
}
|
||||
325
crates/ruvector-solver/tests/test_csr_matrix.rs
Normal file
325
crates/ruvector-solver/tests/test_csr_matrix.rs
Normal file
@@ -0,0 +1,325 @@
|
||||
//! Integration tests for `CsrMatrix` — construction, SpMV, transpose, and
|
||||
//! structural queries.
|
||||
|
||||
mod helpers;
|
||||
|
||||
use approx::assert_relative_eq;
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Construction from COO triplets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_csr_from_triplets() {
|
||||
// 3x3 matrix:
|
||||
// [ 2 -1 0 ]
|
||||
// [-1 3 -1 ]
|
||||
// [ 0 -1 2 ]
|
||||
let triplets: Vec<(usize, usize, f64)> = vec![
|
||||
(0, 0, 2.0),
|
||||
(0, 1, -1.0),
|
||||
(1, 0, -1.0),
|
||||
(1, 1, 3.0),
|
||||
(1, 2, -1.0),
|
||||
(2, 1, -1.0),
|
||||
(2, 2, 2.0),
|
||||
];
|
||||
|
||||
let mat = CsrMatrix::<f64>::from_coo(3, 3, triplets);
|
||||
|
||||
assert_eq!(mat.rows, 3);
|
||||
assert_eq!(mat.cols, 3);
|
||||
assert_eq!(mat.values.len(), 7);
|
||||
assert_eq!(mat.col_indices.len(), 7);
|
||||
assert_eq!(mat.row_ptr.len(), 4); // rows + 1
|
||||
|
||||
// Verify row_ptr encodes correct row boundaries.
|
||||
assert_eq!(mat.row_ptr[0], 0);
|
||||
assert_eq!(mat.row_ptr[1], 2); // row 0 has 2 entries
|
||||
assert_eq!(mat.row_ptr[2], 5); // row 1 has 3 entries
|
||||
assert_eq!(mat.row_ptr[3], 7); // row 2 has 2 entries
|
||||
|
||||
// Verify row_degree for each row.
|
||||
assert_eq!(mat.row_degree(0), 2);
|
||||
assert_eq!(mat.row_degree(1), 3);
|
||||
assert_eq!(mat.row_degree(2), 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SpMV correctness vs dense multiply
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_csr_spmv() {
|
||||
// A = [ 2 -1 0 ] x = [1] Ax = [ 2 - 1 + 0 ] = [ 1 ]
|
||||
// [-1 3 -1 ] [1] [-1 + 3 - 1 ] = [ 1 ]
|
||||
// [ 0 -1 2 ] [1] [ 0 - 1 + 2 ] = [ 1 ]
|
||||
let mat = CsrMatrix::<f64>::from_coo(
|
||||
3,
|
||||
3,
|
||||
vec![
|
||||
(0, 0, 2.0),
|
||||
(0, 1, -1.0),
|
||||
(1, 0, -1.0),
|
||||
(1, 1, 3.0),
|
||||
(1, 2, -1.0),
|
||||
(2, 1, -1.0),
|
||||
(2, 2, 2.0),
|
||||
],
|
||||
);
|
||||
|
||||
let x = vec![1.0, 1.0, 1.0];
|
||||
let mut y = vec![0.0f64; 3];
|
||||
mat.spmv(&x, &mut y);
|
||||
|
||||
assert_relative_eq!(y[0], 1.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(y[1], 1.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(y[2], 1.0, epsilon = 1e-12);
|
||||
|
||||
// Non-trivial x.
|
||||
let x2 = vec![1.0, 2.0, 3.0];
|
||||
let mut y2 = vec![0.0f64; 3];
|
||||
mat.spmv(&x2, &mut y2);
|
||||
|
||||
// Manual: A*[1,2,3] = [2*1 + (-1)*2, -1*1 + 3*2 + (-1)*3, (-1)*2 + 2*3]
|
||||
// = [0, 2, 4]
|
||||
assert_relative_eq!(y2[0], 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(y2[1], 2.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(y2[2], 4.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Density calculation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_csr_density() {
|
||||
// 4x4 matrix with 6 non-zero entries
|
||||
let mat = CsrMatrix::<f64>::from_coo(
|
||||
4,
|
||||
4,
|
||||
vec![
|
||||
(0, 0, 1.0),
|
||||
(1, 1, 2.0),
|
||||
(2, 2, 3.0),
|
||||
(3, 3, 4.0),
|
||||
(0, 1, 0.5),
|
||||
(1, 0, 0.5),
|
||||
],
|
||||
);
|
||||
|
||||
let nnz = mat.values.len();
|
||||
let total_entries = mat.rows * mat.cols;
|
||||
let density = nnz as f64 / total_entries as f64;
|
||||
|
||||
assert_eq!(nnz, 6);
|
||||
assert_relative_eq!(density, 6.0 / 16.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transpose correctness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_csr_transpose() {
|
||||
// Non-symmetric matrix:
|
||||
// A = [ 1 2 0 ] A^T = [ 1 0 3 ]
|
||||
// [ 0 3 0 ] [ 2 3 0 ]
|
||||
// [ 3 0 4 ] [ 0 0 4 ]
|
||||
let mat = CsrMatrix::<f64>::from_coo(
|
||||
3,
|
||||
3,
|
||||
vec![
|
||||
(0, 0, 1.0),
|
||||
(0, 1, 2.0),
|
||||
(1, 1, 3.0),
|
||||
(2, 0, 3.0),
|
||||
(2, 2, 4.0),
|
||||
],
|
||||
);
|
||||
|
||||
let at = mat.transpose();
|
||||
|
||||
assert_eq!(at.rows, 3);
|
||||
assert_eq!(at.cols, 3);
|
||||
assert_eq!(at.values.len(), 5);
|
||||
|
||||
// Verify A^T * e_0. Since A^T[i][j] = A[j][i], the first column of A^T
|
||||
// is the first row of A: [1, 2, 0].
|
||||
let e0 = vec![1.0, 0.0, 0.0];
|
||||
let mut y = vec![0.0f64; 3];
|
||||
at.spmv(&e0, &mut y);
|
||||
// (A^T * e_0)[i] = A^T[i][0] = A[0][i], so [A[0][0], A[0][1], A[0][2]] = [1, 2, 0]
|
||||
assert_relative_eq!(y[0], 1.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(y[1], 2.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(y[2], 0.0, epsilon = 1e-12);
|
||||
|
||||
// Verify transpose of transpose recovers the original.
|
||||
let att = at.transpose();
|
||||
let x = vec![1.0, 2.0, 3.0];
|
||||
let mut y_orig = vec![0.0f64; 3];
|
||||
let mut y_double = vec![0.0f64; 3];
|
||||
mat.spmv(&x, &mut y_orig);
|
||||
att.spmv(&x, &mut y_double);
|
||||
for i in 0..3 {
|
||||
assert_relative_eq!(y_orig[i], y_double[i], epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty matrix handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_csr_empty() {
|
||||
let mat = CsrMatrix::<f64>::from_coo(0, 0, Vec::<(usize, usize, f64)>::new());
|
||||
|
||||
assert_eq!(mat.rows, 0);
|
||||
assert_eq!(mat.cols, 0);
|
||||
assert_eq!(mat.values.len(), 0);
|
||||
assert_eq!(mat.row_ptr.len(), 1); // [0]
|
||||
assert_eq!(mat.row_ptr[0], 0);
|
||||
|
||||
// SpMV on empty should work trivially (no-op).
|
||||
let x: Vec<f64> = vec![];
|
||||
let mut y: Vec<f64> = vec![];
|
||||
mat.spmv(&x, &mut y);
|
||||
|
||||
// Transpose of empty is empty.
|
||||
let at = mat.transpose();
|
||||
assert_eq!(at.rows, 0);
|
||||
assert_eq!(at.cols, 0);
|
||||
|
||||
// Matrix with rows but no entries.
|
||||
let mat2 = CsrMatrix::<f64>::from_coo(3, 3, Vec::<(usize, usize, f64)>::new());
|
||||
assert_eq!(mat2.values.len(), 0);
|
||||
let mut y2 = vec![0.0f64; 3];
|
||||
mat2.spmv(&[1.0, 2.0, 3.0], &mut y2);
|
||||
for &v in &y2 {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-15);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity matrix: SpMV(I, x) = x
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_csr_identity() {
|
||||
for n in [1, 5, 20, 100] {
|
||||
let identity = CsrMatrix::<f64>::identity(n);
|
||||
|
||||
assert_eq!(identity.rows, n);
|
||||
assert_eq!(identity.cols, n);
|
||||
assert_eq!(identity.values.len(), n);
|
||||
|
||||
// Generate a deterministic test vector.
|
||||
let x: Vec<f64> = (0..n).map(|i| (i as f64 + 1.0) * 0.7).collect();
|
||||
let mut y = vec![0.0f64; n];
|
||||
identity.spmv(&x, &mut y);
|
||||
|
||||
for i in 0..n {
|
||||
assert_relative_eq!(y[i], x[i], epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diagonal matrix correctness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_csr_diagonal() {
|
||||
let diag_vals = vec![2.0, 3.0, 5.0, 7.0];
|
||||
let n = diag_vals.len();
|
||||
|
||||
let entries: Vec<(usize, usize, f64)> = diag_vals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &v)| (i, i, v))
|
||||
.collect();
|
||||
let mat = CsrMatrix::<f64>::from_coo(n, n, entries);
|
||||
|
||||
// D * x = element-wise product of diag and x.
|
||||
let x = vec![1.0, 2.0, 3.0, 4.0];
|
||||
let mut y = vec![0.0f64; n];
|
||||
mat.spmv(&x, &mut y);
|
||||
|
||||
for i in 0..n {
|
||||
assert_relative_eq!(y[i], diag_vals[i] * x[i], epsilon = 1e-12);
|
||||
}
|
||||
|
||||
// Verify each row has exactly 1 non-zero.
|
||||
for i in 0..n {
|
||||
assert_eq!(mat.row_degree(i), 1);
|
||||
}
|
||||
|
||||
// Transpose of diagonal is the same matrix.
|
||||
let dt = mat.transpose();
|
||||
let mut yt = vec![0.0f64; n];
|
||||
dt.spmv(&x, &mut yt);
|
||||
for i in 0..n {
|
||||
assert_relative_eq!(y[i], yt[i], epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Symmetric detection (structural)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_csr_symmetric() {
|
||||
// Symmetric matrix.
|
||||
let sym = CsrMatrix::<f64>::from_coo(
|
||||
3,
|
||||
3,
|
||||
vec![
|
||||
(0, 0, 2.0),
|
||||
(0, 1, 1.0),
|
||||
(1, 0, 1.0),
|
||||
(1, 1, 3.0),
|
||||
(1, 2, 1.0),
|
||||
(2, 1, 1.0),
|
||||
(2, 2, 2.0),
|
||||
],
|
||||
);
|
||||
|
||||
// Check symmetry by comparing A and A^T via SpMV.
|
||||
let at = sym.transpose();
|
||||
let x = vec![1.0, 2.0, 3.0];
|
||||
let mut y_a = vec![0.0f64; 3];
|
||||
let mut y_at = vec![0.0f64; 3];
|
||||
sym.spmv(&x, &mut y_a);
|
||||
at.spmv(&x, &mut y_at);
|
||||
|
||||
for i in 0..3 {
|
||||
assert_relative_eq!(y_a[i], y_at[i], epsilon = 1e-12);
|
||||
}
|
||||
|
||||
// Use the orchestrator's sparsity analysis to check symmetry detection.
|
||||
let profile = ruvector_solver::router::SolverOrchestrator::analyze_sparsity(&sym);
|
||||
assert!(
|
||||
profile.is_symmetric_structure,
|
||||
"symmetric matrix should be detected as symmetric"
|
||||
);
|
||||
|
||||
// Non-symmetric matrix.
|
||||
let asym = CsrMatrix::<f64>::from_coo(
|
||||
3,
|
||||
3,
|
||||
vec![
|
||||
(0, 0, 1.0),
|
||||
(0, 1, 2.0),
|
||||
// no (1, 0) entry
|
||||
(1, 1, 3.0),
|
||||
(2, 2, 4.0),
|
||||
],
|
||||
);
|
||||
|
||||
let profile_asym = ruvector_solver::router::SolverOrchestrator::analyze_sparsity(&asym);
|
||||
assert!(
|
||||
!profile_asym.is_symmetric_structure,
|
||||
"asymmetric matrix should not be detected as symmetric"
|
||||
);
|
||||
}
|
||||
234
crates/ruvector-solver/tests/test_neumann.rs
Normal file
234
crates/ruvector-solver/tests/test_neumann.rs
Normal file
@@ -0,0 +1,234 @@
|
||||
//! Integration tests for the Neumann series solver.
|
||||
//!
|
||||
//! The Neumann solver solves Ax = b by iterating x_{k+1} = b + (I - A) x_k.
|
||||
//! Convergence requires spectral radius rho(I - A) < 1, which is guaranteed
|
||||
//! for diagonally dominant systems.
|
||||
|
||||
mod helpers;
|
||||
|
||||
use approx::assert_relative_eq;
|
||||
use ruvector_solver::error::SolverError;
|
||||
use ruvector_solver::neumann::NeumannSolver;
|
||||
use ruvector_solver::traits::SolverEngine;
|
||||
use ruvector_solver::types::{Algorithm, ComputeBudget, CsrMatrix};
|
||||
|
||||
use helpers::{
|
||||
compute_residual, dense_solve, f32_to_f64, l2_norm, random_diag_dominant_csr, random_vector,
|
||||
relative_error,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: call solver via the SolverEngine trait (f64 interface)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn solve_via_trait(
|
||||
solver: &NeumannSolver,
|
||||
matrix: &CsrMatrix<f64>,
|
||||
rhs: &[f64],
|
||||
budget: &ComputeBudget,
|
||||
) -> Result<ruvector_solver::types::SolverResult, SolverError> {
|
||||
SolverEngine::solve(solver, matrix, rhs, budget)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Solve a diagonally dominant system, verify ||Ax - b|| < eps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_neumann_diagonal_dominant() {
|
||||
let n = 20;
|
||||
let matrix = random_diag_dominant_csr(n, 0.3, 42);
|
||||
let rhs = random_vector(n, 43);
|
||||
let budget = ComputeBudget::default();
|
||||
|
||||
let solver = NeumannSolver::new(1e-8, 500);
|
||||
let result = solve_via_trait(&solver, &matrix, &rhs, &budget).unwrap();
|
||||
|
||||
assert!(
|
||||
result.residual_norm < 1e-6,
|
||||
"residual too large: {}",
|
||||
result.residual_norm
|
||||
);
|
||||
|
||||
// Double-check by computing residual independently.
|
||||
let x = f32_to_f64(&result.solution);
|
||||
let residual = compute_residual(&matrix, &x, &rhs);
|
||||
let resid_norm = l2_norm(&residual);
|
||||
assert!(
|
||||
resid_norm < 1e-4,
|
||||
"independent residual check failed: {}",
|
||||
resid_norm
|
||||
);
|
||||
|
||||
// Compare with dense solve.
|
||||
let exact = dense_solve(&matrix, &rhs);
|
||||
let rel_err = relative_error(&x, &exact);
|
||||
assert!(rel_err < 1e-3, "relative error vs dense solve: {}", rel_err);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verify geometric convergence rate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_neumann_convergence_rate() {
|
||||
let n = 15;
|
||||
let matrix = random_diag_dominant_csr(n, 0.2, 44);
|
||||
let rhs = random_vector(n, 45);
|
||||
let budget = ComputeBudget::default();
|
||||
|
||||
let solver = NeumannSolver::new(1e-12, 500);
|
||||
let result = solve_via_trait(&solver, &matrix, &rhs, &budget).unwrap();
|
||||
|
||||
// The convergence history should show monotonic decrease (geometric).
|
||||
let history = &result.convergence_history;
|
||||
assert!(
|
||||
history.len() >= 3,
|
||||
"need at least 3 iterations for rate check"
|
||||
);
|
||||
|
||||
// Check that residual decreases monotonically for at least the first
|
||||
// several iterations (allowing a small tolerance for floating point).
|
||||
let mut decreasing_count = 0;
|
||||
for w in history.windows(2) {
|
||||
if w[1].residual_norm < w[0].residual_norm * 1.01 {
|
||||
decreasing_count += 1;
|
||||
}
|
||||
}
|
||||
let decrease_ratio = decreasing_count as f64 / (history.len() - 1) as f64;
|
||||
assert!(
|
||||
decrease_ratio > 0.8,
|
||||
"expected mostly decreasing residuals, got {:.0}% decreasing",
|
||||
decrease_ratio * 100.0
|
||||
);
|
||||
|
||||
// Estimate the convergence factor from the last few iterations.
|
||||
if history.len() >= 4 {
|
||||
let n_hist = history.len();
|
||||
let r_late = history[n_hist - 1].residual_norm;
|
||||
let r_early = history[n_hist - 4].residual_norm;
|
||||
if r_early > 1e-15 {
|
||||
let avg_factor = (r_late / r_early).powf(1.0 / 3.0);
|
||||
assert!(
|
||||
avg_factor < 1.0,
|
||||
"convergence factor should be < 1, got {}",
|
||||
avg_factor
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verify rejection when spectral radius >= 1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_neumann_spectral_radius_check() {
|
||||
// Build a matrix where off-diagonal entries dominate the diagonal,
|
||||
// giving spectral radius >= 1. The matrix:
|
||||
// [1 2]
|
||||
// [2 1]
|
||||
// has off-diag sum > diag for each row.
|
||||
let matrix = CsrMatrix::<f64>::from_coo(
|
||||
2,
|
||||
2,
|
||||
vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)],
|
||||
);
|
||||
let rhs = vec![1.0, 1.0];
|
||||
let budget = ComputeBudget::default();
|
||||
|
||||
let solver = NeumannSolver::new(1e-8, 100);
|
||||
let result = solve_via_trait(&solver, &matrix, &rhs, &budget);
|
||||
|
||||
match result {
|
||||
Err(SolverError::SpectralRadiusExceeded {
|
||||
spectral_radius,
|
||||
limit,
|
||||
algorithm,
|
||||
}) => {
|
||||
assert!(spectral_radius >= 1.0);
|
||||
assert_relative_eq!(limit, 1.0, epsilon = 1e-12);
|
||||
assert_eq!(algorithm, Algorithm::Neumann);
|
||||
}
|
||||
Err(SolverError::NumericalInstability { .. }) => {
|
||||
// Also acceptable: the solver might detect NaN divergence
|
||||
// before the spectral radius check catches it.
|
||||
}
|
||||
other => panic!(
|
||||
"expected SpectralRadiusExceeded or NumericalInstability, got {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity system: Ix = b should converge in 1 iteration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_neumann_identity_system() {
|
||||
for n in [1, 5, 20] {
|
||||
let matrix = CsrMatrix::<f64>::identity(n);
|
||||
let rhs: Vec<f64> = (0..n).map(|i| (i as f64 + 1.0) * 0.3).collect();
|
||||
let budget = ComputeBudget::default();
|
||||
|
||||
let solver = NeumannSolver::new(1e-10, 100);
|
||||
let result = solve_via_trait(&solver, &matrix, &rhs, &budget).unwrap();
|
||||
|
||||
// Solution should equal rhs for identity matrix.
|
||||
let x = f32_to_f64(&result.solution);
|
||||
for i in 0..n {
|
||||
assert_relative_eq!(x[i], rhs[i], epsilon = 1e-5);
|
||||
}
|
||||
|
||||
// Should converge in exactly 1 iteration for identity.
|
||||
assert_eq!(
|
||||
result.iterations, 1,
|
||||
"identity system should converge in 1 iteration, got {}",
|
||||
result.iterations
|
||||
);
|
||||
assert_eq!(result.algorithm, Algorithm::Neumann);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manually constructed system with known solution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_neumann_known_solution() {
|
||||
// System:
|
||||
// [3 -1 0] [x0] [2]
|
||||
// [-1 3 -1] [x1] = [0]
|
||||
// [0 -1 3] [x2] [2]
|
||||
//
|
||||
// By symmetry, x0 = x2. From row 0: 3*x0 - x1 = 2.
|
||||
// From row 1: -x0 + 3*x1 - x2 = 0 => -2*x0 + 3*x1 = 0 => x1 = 2*x0/3.
|
||||
// Substituting: 3*x0 - 2*x0/3 = 2 => (9 - 2)/3 * x0 = 2 => x0 = 6/7.
|
||||
// x1 = 4/7, x2 = 6/7.
|
||||
let matrix = CsrMatrix::<f64>::from_coo(
|
||||
3,
|
||||
3,
|
||||
vec![
|
||||
(0, 0, 3.0),
|
||||
(0, 1, -1.0),
|
||||
(1, 0, -1.0),
|
||||
(1, 1, 3.0),
|
||||
(1, 2, -1.0),
|
||||
(2, 1, -1.0),
|
||||
(2, 2, 3.0),
|
||||
],
|
||||
);
|
||||
let rhs = vec![2.0, 0.0, 2.0];
|
||||
let budget = ComputeBudget::default();
|
||||
|
||||
let solver = NeumannSolver::new(1e-10, 500);
|
||||
let result = solve_via_trait(&solver, &matrix, &rhs, &budget).unwrap();
|
||||
|
||||
let x = f32_to_f64(&result.solution);
|
||||
let expected = [6.0 / 7.0, 4.0 / 7.0, 6.0 / 7.0];
|
||||
|
||||
for i in 0..3 {
|
||||
assert_relative_eq!(x[i], expected[i], epsilon = 1e-4);
|
||||
}
|
||||
}
|
||||
281
crates/ruvector-solver/tests/test_push.rs
Normal file
281
crates/ruvector-solver/tests/test_push.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
//! Integration tests for Forward Push, Backward Push, and mass conservation.
|
||||
//!
|
||||
//! Tests cover PPR computation on small graphs, star/complete topologies,
|
||||
//! mass conservation invariants, and agreement between forward and backward
|
||||
//! push algorithms.
|
||||
|
||||
mod helpers;
|
||||
|
||||
use approx::assert_relative_eq;
|
||||
#[cfg(feature = "backward-push")]
|
||||
use ruvector_solver::backward_push::BackwardPushSolver;
|
||||
use ruvector_solver::forward_push::{forward_push_with_residuals, ForwardPushSolver};
|
||||
#[allow(unused_imports)]
|
||||
use ruvector_solver::traits::SublinearPageRank;
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
|
||||
use helpers::adjacency_from_edges;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build common graph topologies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 4-node graph: 0--1--2--3, 0--2 (bidirectional).
|
||||
fn simple_graph_4() -> CsrMatrix<f64> {
|
||||
adjacency_from_edges(4, &[(0, 1), (1, 2), (2, 3), (0, 2)])
|
||||
}
|
||||
|
||||
/// Star graph centred at 0 with k leaves (bidirectional edges).
|
||||
fn star_graph(k: usize) -> CsrMatrix<f64> {
|
||||
let n = k + 1;
|
||||
let edges: Vec<(usize, usize)> = (1..n).map(|i| (0, i)).collect();
|
||||
adjacency_from_edges(n, &edges)
|
||||
}
|
||||
|
||||
/// Complete graph on n vertices (bidirectional edges, no self-loops).
|
||||
fn complete_graph(n: usize) -> CsrMatrix<f64> {
|
||||
let mut edges = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
edges.push((i, j));
|
||||
}
|
||||
}
|
||||
adjacency_from_edges(n, &edges)
|
||||
}
|
||||
|
||||
/// Directed cycle: 0->1->2->...->n-1->0.
|
||||
fn directed_cycle(n: usize) -> CsrMatrix<f64> {
|
||||
let entries: Vec<(usize, usize, f64)> = (0..n).map(|i| (i, (i + 1) % n, 1.0f64)).collect();
|
||||
CsrMatrix::<f64>::from_coo(n, n, entries)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forward Push: 4-node graph
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_forward_push_simple_graph() {
|
||||
let graph = simple_graph_4();
|
||||
let solver = ForwardPushSolver::new(0.85, 1e-8);
|
||||
let result = solver.ppr_from_source(&graph, 0).unwrap();
|
||||
|
||||
// Source should have highest PPR.
|
||||
assert!(!result.is_empty());
|
||||
assert_eq!(result[0].0, 0, "source vertex should be ranked first");
|
||||
assert!(result[0].1 > 0.0);
|
||||
|
||||
// All returned scores should be positive and sorted descending.
|
||||
for w in result.windows(2) {
|
||||
assert!(
|
||||
w[0].1 >= w[1].1,
|
||||
"results should be sorted descending: {} < {}",
|
||||
w[0].1,
|
||||
w[1].1
|
||||
);
|
||||
}
|
||||
|
||||
// Verify all 4 nodes get some probability.
|
||||
let nodes: Vec<usize> = result.iter().map(|(v, _)| *v).collect();
|
||||
for v in 0..4 {
|
||||
assert!(
|
||||
nodes.contains(&v),
|
||||
"node {} should appear in PPR results",
|
||||
v
|
||||
);
|
||||
}
|
||||
|
||||
// Neighbours of source should have more PPR than non-neighbours.
|
||||
let ppr: Vec<f64> = {
|
||||
let mut dense = vec![0.0f64; 4];
|
||||
for &(v, s) in &result {
|
||||
dense[v] = s;
|
||||
}
|
||||
dense
|
||||
};
|
||||
// 0 is connected to 1 and 2. 3 is only reachable through 2.
|
||||
assert!(
|
||||
ppr[1] > ppr[3] || ppr[2] > ppr[3],
|
||||
"direct neighbours should have higher PPR than distant nodes"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forward Push: star graph — center should dominate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_forward_push_star_graph() {
|
||||
let graph = star_graph(5); // 6 nodes: center=0, leaves=1..5
|
||||
let solver = ForwardPushSolver::new(0.85, 1e-8);
|
||||
|
||||
// PPR from center: center should have highest score.
|
||||
let result = solver.ppr_from_source(&graph, 0).unwrap();
|
||||
assert_eq!(result[0].0, 0);
|
||||
|
||||
// All leaf scores should be approximately equal (by symmetry).
|
||||
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_relative_eq!(s, mean, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
// Center PPR should be strictly higher than any leaf.
|
||||
for &s in &leaf_scores {
|
||||
assert!(result[0].1 > s, "center PPR should exceed leaf PPR");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forward Push: complete graph — approximately uniform
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_forward_push_complete_graph() {
|
||||
let n = 5;
|
||||
let graph = complete_graph(n);
|
||||
let solver = ForwardPushSolver::new(0.85, 1e-8);
|
||||
|
||||
let result = solver.ppr_from_source(&graph, 0).unwrap();
|
||||
|
||||
// All n nodes should appear.
|
||||
assert_eq!(result.len(), n);
|
||||
|
||||
// Non-source nodes should have approximately equal PPR.
|
||||
let non_source: Vec<f64> = result
|
||||
.iter()
|
||||
.filter(|(v, _)| *v != 0)
|
||||
.map(|(_, s)| *s)
|
||||
.collect();
|
||||
let mean = non_source.iter().sum::<f64>() / non_source.len() as f64;
|
||||
|
||||
for &s in &non_source {
|
||||
assert_relative_eq!(s, mean, epsilon = 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Forward Push: mass conservation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_forward_push_mass_conservation() {
|
||||
let graph = simple_graph_4();
|
||||
let (p, r) = forward_push_with_residuals(&graph, 0, 0.85, 1e-8).unwrap();
|
||||
|
||||
let total_p: f64 = p.iter().sum();
|
||||
let total_r: f64 = r.iter().sum();
|
||||
let total = total_p + total_r;
|
||||
|
||||
assert_relative_eq!(total, 1.0, epsilon = 1e-6);
|
||||
|
||||
// Also verify on star graph.
|
||||
let star = star_graph(4);
|
||||
let (p2, r2) = forward_push_with_residuals(&star, 0, 0.85, 1e-6).unwrap();
|
||||
let total2 = p2.iter().sum::<f64>() + r2.iter().sum::<f64>();
|
||||
assert_relative_eq!(total2, 1.0, epsilon = 1e-5);
|
||||
|
||||
// And on directed cycle.
|
||||
let cycle = directed_cycle(6);
|
||||
let (p3, r3) = forward_push_with_residuals(&cycle, 0, 0.85, 1e-6).unwrap();
|
||||
let total3 = p3.iter().sum::<f64>() + r3.iter().sum::<f64>();
|
||||
assert_relative_eq!(total3, 1.0, epsilon = 1e-5);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward Push: simple verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "backward-push")]
|
||||
#[test]
|
||||
fn test_backward_push_simple() {
|
||||
let graph = directed_cycle(4); // 0->1->2->3->0
|
||||
let solver = BackwardPushSolver::new(0.15, 1e-6);
|
||||
|
||||
// Backward push to target 0: nodes that can reach 0 should have PPR.
|
||||
let result = solver.ppr_to_target(&graph, 0).unwrap();
|
||||
|
||||
assert!(!result.is_empty());
|
||||
|
||||
// The target node itself should have the highest PPR.
|
||||
let target_ppr = result
|
||||
.iter()
|
||||
.find(|&&(v, _)| v == 0)
|
||||
.map(|&(_, p)| p)
|
||||
.unwrap_or(0.0);
|
||||
assert!(target_ppr > 0.0, "target should have positive PPR");
|
||||
|
||||
// Total PPR should be <= 1.
|
||||
let total: f64 = result.iter().map(|(_, v)| v).sum();
|
||||
assert!(
|
||||
total <= 1.0 + 1e-6,
|
||||
"total PPR should be <= 1, got {}",
|
||||
total
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Random walk pairwise: forward and backward push should agree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "backward-push")]
|
||||
#[test]
|
||||
fn test_random_walk_pairwise() {
|
||||
// On a symmetric graph, forward push from s and backward push to s
|
||||
// should produce similar PPR distributions (up to algorithm variance).
|
||||
let graph = complete_graph(5);
|
||||
|
||||
let forward = ForwardPushSolver::new(0.15, 1e-8);
|
||||
let backward = BackwardPushSolver::new(0.15, 1e-8);
|
||||
|
||||
let source = 0;
|
||||
|
||||
// Forward push from source 0.
|
||||
let fwd_result = forward.ppr_from_source(&graph, source).unwrap();
|
||||
let mut fwd_ppr = vec![0.0f64; 5];
|
||||
for &(v, s) in &fwd_result {
|
||||
fwd_ppr[v] = s;
|
||||
}
|
||||
|
||||
// Backward push to target 0.
|
||||
let bwd_result = backward.ppr_to_target(&graph, source).unwrap();
|
||||
let mut bwd_ppr = vec![0.0f64; 5];
|
||||
for &(v, s) in &bwd_result {
|
||||
bwd_ppr[v] = s;
|
||||
}
|
||||
|
||||
// On a symmetric complete graph, forward PPR(0 -> v) should equal
|
||||
// backward PPR(v -> 0), which is what backward push computes.
|
||||
// The self-PPR (source=target=0) should match closely.
|
||||
let fwd_self = fwd_ppr[0];
|
||||
let bwd_self = bwd_ppr[0];
|
||||
|
||||
// They should agree to reasonable precision on a symmetric graph.
|
||||
let self_ppr_diff = (fwd_self - bwd_self).abs();
|
||||
assert!(
|
||||
self_ppr_diff < 0.1,
|
||||
"self-PPR should agree: forward={}, backward={}, diff={}",
|
||||
fwd_self,
|
||||
bwd_self,
|
||||
self_ppr_diff
|
||||
);
|
||||
|
||||
// Non-source nodes should have similar PPR in both directions
|
||||
// (by symmetry of the complete graph).
|
||||
for v in 1..5 {
|
||||
let diff = (fwd_ppr[v] - bwd_ppr[v]).abs();
|
||||
assert!(
|
||||
diff < 0.1,
|
||||
"PPR for node {} should agree: forward={}, backward={}, diff={}",
|
||||
v,
|
||||
fwd_ppr[v],
|
||||
bwd_ppr[v],
|
||||
diff
|
||||
);
|
||||
}
|
||||
}
|
||||
245
crates/ruvector-solver/tests/test_router.rs
Normal file
245
crates/ruvector-solver/tests/test_router.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
//! Integration tests for the algorithm router and solver orchestrator.
|
||||
//!
|
||||
//! Tests cover routing decisions (Neumann for diag-dominant, CG for general
|
||||
//! SPD, ForwardPush for PageRank), and the fallback chain behaviour.
|
||||
|
||||
mod helpers;
|
||||
|
||||
use ruvector_solver::router::{RouterConfig, SolverOrchestrator, SolverRouter};
|
||||
use ruvector_solver::types::{Algorithm, ComputeBudget, CsrMatrix, QueryType, SparsityProfile};
|
||||
|
||||
use helpers::{random_diag_dominant_csr, random_spd_csr, random_vector};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: default compute budget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn default_budget() -> ComputeBudget {
|
||||
ComputeBudget {
|
||||
max_time: std::time::Duration::from_secs(30),
|
||||
max_iterations: 10_000,
|
||||
tolerance: 1e-8,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router selects Neumann for diag-dominant + sparse + low spectral radius
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_router_selects_neumann_for_diag_dominant() {
|
||||
let router = SolverRouter::new(RouterConfig::default());
|
||||
|
||||
// Construct a profile that satisfies all Neumann conditions:
|
||||
// - diag-dominant
|
||||
// - density below sparsity_sublinear_threshold (0.05)
|
||||
// - spectral radius below neumann_spectral_radius_threshold (0.95)
|
||||
let profile = SparsityProfile {
|
||||
rows: 1000,
|
||||
cols: 1000,
|
||||
nnz: 3000,
|
||||
density: 0.003,
|
||||
is_diag_dominant: true,
|
||||
estimated_spectral_radius: 0.5,
|
||||
estimated_condition: 10.0,
|
||||
is_symmetric_structure: true,
|
||||
avg_nnz_per_row: 3.0,
|
||||
max_nnz_per_row: 5,
|
||||
};
|
||||
|
||||
let algo = router.select_algorithm(&profile, &QueryType::LinearSystem);
|
||||
assert_eq!(
|
||||
algo,
|
||||
Algorithm::Neumann,
|
||||
"diag-dominant, sparse, low spectral radius should route to Neumann"
|
||||
);
|
||||
|
||||
// Also verify with a real matrix: build a diag-dominant matrix and check
|
||||
// that the orchestrator's analyze_sparsity reports diag-dominance.
|
||||
let matrix = random_diag_dominant_csr(20, 0.2, 42);
|
||||
let real_profile = SolverOrchestrator::analyze_sparsity(&matrix);
|
||||
assert!(
|
||||
real_profile.is_diag_dominant,
|
||||
"random_diag_dominant_csr should produce a diag-dominant matrix"
|
||||
);
|
||||
assert!(
|
||||
real_profile.estimated_spectral_radius < 1.0,
|
||||
"spectral radius should be < 1 for diag-dominant matrix"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router selects CG for well-conditioned, non-diag-dominant systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_router_selects_cg_for_general_spd() {
|
||||
let router = SolverRouter::new(RouterConfig::default());
|
||||
|
||||
// Profile: not diag-dominant, but well-conditioned (condition < 100).
|
||||
let profile = SparsityProfile {
|
||||
rows: 500,
|
||||
cols: 500,
|
||||
nnz: 25_000,
|
||||
density: 0.10,
|
||||
is_diag_dominant: false,
|
||||
estimated_spectral_radius: 0.8,
|
||||
estimated_condition: 50.0,
|
||||
is_symmetric_structure: true,
|
||||
avg_nnz_per_row: 50.0,
|
||||
max_nnz_per_row: 80,
|
||||
};
|
||||
|
||||
let algo = router.select_algorithm(&profile, &QueryType::LinearSystem);
|
||||
assert_eq!(
|
||||
algo,
|
||||
Algorithm::CG,
|
||||
"well-conditioned, non-diag-dominant should route to CG"
|
||||
);
|
||||
|
||||
// When condition number exceeds the threshold, should route to BMSSP.
|
||||
let ill_conditioned = SparsityProfile {
|
||||
estimated_condition: 500.0,
|
||||
..profile.clone()
|
||||
};
|
||||
let algo_ill = router.select_algorithm(&ill_conditioned, &QueryType::LinearSystem);
|
||||
assert_eq!(
|
||||
algo_ill,
|
||||
Algorithm::BMSSP,
|
||||
"ill-conditioned should route to BMSSP"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router selects ForwardPush for PageRank queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_router_selects_push_for_pagerank() {
|
||||
let router = SolverRouter::new(RouterConfig::default());
|
||||
|
||||
let profile = SparsityProfile {
|
||||
rows: 5000,
|
||||
cols: 5000,
|
||||
nnz: 20_000,
|
||||
density: 0.0008,
|
||||
is_diag_dominant: false,
|
||||
estimated_spectral_radius: 0.85,
|
||||
estimated_condition: 100.0,
|
||||
is_symmetric_structure: false,
|
||||
avg_nnz_per_row: 4.0,
|
||||
max_nnz_per_row: 50,
|
||||
};
|
||||
|
||||
// Single-source PageRank always routes to ForwardPush.
|
||||
let algo_single = router.select_algorithm(&profile, &QueryType::PageRankSingle { source: 0 });
|
||||
assert_eq!(
|
||||
algo_single,
|
||||
Algorithm::ForwardPush,
|
||||
"single-source PageRank should route to ForwardPush"
|
||||
);
|
||||
|
||||
// Pairwise on a large graph (rows > push_graph_size_threshold = 1000)
|
||||
// routes to HybridRandomWalk.
|
||||
let algo_pairwise_large = router.select_algorithm(
|
||||
&profile,
|
||||
&QueryType::PageRankPairwise {
|
||||
source: 0,
|
||||
target: 100,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
algo_pairwise_large,
|
||||
Algorithm::HybridRandomWalk,
|
||||
"pairwise PageRank on large graph should route to HybridRandomWalk"
|
||||
);
|
||||
|
||||
// Pairwise on a small graph routes to ForwardPush.
|
||||
let small_profile = SparsityProfile {
|
||||
rows: 500,
|
||||
cols: 500,
|
||||
nnz: 2000,
|
||||
density: 0.008,
|
||||
..profile.clone()
|
||||
};
|
||||
let algo_pairwise_small = router.select_algorithm(
|
||||
&small_profile,
|
||||
&QueryType::PageRankPairwise {
|
||||
source: 0,
|
||||
target: 10,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
algo_pairwise_small,
|
||||
Algorithm::ForwardPush,
|
||||
"pairwise PageRank on small graph should route to ForwardPush"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback chain: if first algorithm fails, falls back to CG then Dense
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_router_fallback_chain() {
|
||||
let orchestrator = SolverOrchestrator::new(RouterConfig::default());
|
||||
|
||||
// Build a well-conditioned SPD system that is solvable.
|
||||
// Use a simple diag-dominant tridiagonal so all algorithms can solve it.
|
||||
let matrix = CsrMatrix::<f64>::from_coo(
|
||||
4,
|
||||
4,
|
||||
vec![
|
||||
(0, 0, 4.0),
|
||||
(0, 1, -1.0),
|
||||
(1, 0, -1.0),
|
||||
(1, 1, 4.0),
|
||||
(1, 2, -1.0),
|
||||
(2, 1, -1.0),
|
||||
(2, 2, 4.0),
|
||||
(2, 3, -1.0),
|
||||
(3, 2, -1.0),
|
||||
(3, 3, 4.0),
|
||||
],
|
||||
);
|
||||
let rhs = vec![1.0, 0.0, 0.0, 1.0];
|
||||
let budget = default_budget();
|
||||
|
||||
// solve_with_fallback should succeed regardless of which algorithm is
|
||||
// tried first (the fallback chain will eventually reach CG or Dense).
|
||||
let result = orchestrator
|
||||
.solve_with_fallback(&matrix, &rhs, QueryType::LinearSystem, &budget)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.residual_norm < 1e-4,
|
||||
"fallback chain should produce a good solution, residual={}",
|
||||
result.residual_norm
|
||||
);
|
||||
|
||||
// Verify the fallback chain deduplication: CG primary should give [CG, Dense].
|
||||
// Neumann primary should give [Neumann, CG, Dense].
|
||||
let profile = SolverOrchestrator::analyze_sparsity(&matrix);
|
||||
let selected = orchestrator
|
||||
.router()
|
||||
.select_algorithm(&profile, &QueryType::LinearSystem);
|
||||
|
||||
// The selected algorithm for a diag-dominant sparse low-rho matrix should
|
||||
// be Neumann, and the fallback chain should include CG and Dense.
|
||||
// Just verify the solve succeeded, which proves fallback works end-to-end.
|
||||
assert!(result.solution.len() == 4, "solution should have 4 entries");
|
||||
|
||||
// Test that solve_with_fallback also works on an SPD system that routes
|
||||
// to CG. The fallback chain [CG, Dense] should handle it.
|
||||
let spd = random_spd_csr(10, 0.3, 42);
|
||||
let rhs2 = random_vector(10, 43);
|
||||
let result2 = orchestrator
|
||||
.solve_with_fallback(&spd, &rhs2, QueryType::LinearSystem, &budget)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result2.residual_norm < 1e-3,
|
||||
"fallback on SPD should converge, residual={}",
|
||||
result2.residual_norm
|
||||
);
|
||||
}
|
||||
299
crates/ruvector-solver/tests/test_validation.rs
Normal file
299
crates/ruvector-solver/tests/test_validation.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
//! Integration tests for input validation.
|
||||
//!
|
||||
//! Tests cover rejection of NaN and Inf values, dimension mismatches,
|
||||
//! malformed CSR structures (non-monotonic row_ptrs), and oversized inputs.
|
||||
|
||||
use ruvector_solver::error::ValidationError;
|
||||
use ruvector_solver::types::CsrMatrix;
|
||||
use ruvector_solver::validation::{
|
||||
validate_csr_matrix, validate_rhs, validate_solver_input, MAX_NODES,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: build a valid f32 identity matrix
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn identity_f32(n: usize) -> CsrMatrix<f32> {
|
||||
let row_ptr: Vec<usize> = (0..=n).collect();
|
||||
let col_indices: Vec<usize> = (0..n).collect();
|
||||
let values = vec![1.0f32; n];
|
||||
CsrMatrix {
|
||||
row_ptr,
|
||||
col_indices,
|
||||
values,
|
||||
rows: n,
|
||||
cols: n,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a small valid 3x3 f32 CSR matrix for testing.
|
||||
fn valid_3x3_f32() -> CsrMatrix<f32> {
|
||||
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),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reject NaN in matrix values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_reject_nan_input() {
|
||||
// NaN in matrix values should be rejected.
|
||||
let mut mat = identity_f32(4);
|
||||
mat.values[2] = f32::NAN;
|
||||
|
||||
let err = validate_csr_matrix(&mat).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ValidationError::NonFiniteValue(_)),
|
||||
"expected NonFiniteValue for NaN in matrix, got {:?}",
|
||||
err
|
||||
);
|
||||
|
||||
// NaN in RHS should be rejected.
|
||||
let rhs = vec![1.0f32, f32::NAN, 3.0];
|
||||
let err_rhs = validate_rhs(&rhs, 3).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_rhs, ValidationError::NonFiniteValue(_)),
|
||||
"expected NonFiniteValue for NaN in RHS, got {:?}",
|
||||
err_rhs
|
||||
);
|
||||
|
||||
// Combined validation should also catch NaN in matrix.
|
||||
let mut mat2 = valid_3x3_f32();
|
||||
mat2.values[0] = f32::NAN;
|
||||
let err_combined = validate_solver_input(&mat2, &[1.0, 2.0, 3.0], 1e-6).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_combined, ValidationError::NonFiniteValue(_)),
|
||||
"combined validation should reject NaN matrix values"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reject Inf values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_reject_inf_input() {
|
||||
// Positive infinity in matrix values.
|
||||
let mut mat = identity_f32(3);
|
||||
mat.values[0] = f32::INFINITY;
|
||||
|
||||
let err = validate_csr_matrix(&mat).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ValidationError::NonFiniteValue(_)),
|
||||
"expected NonFiniteValue for +Inf in matrix, got {:?}",
|
||||
err
|
||||
);
|
||||
|
||||
// Negative infinity in matrix values.
|
||||
let mut mat2 = identity_f32(3);
|
||||
mat2.values[1] = f32::NEG_INFINITY;
|
||||
|
||||
let err2 = validate_csr_matrix(&mat2).unwrap_err();
|
||||
assert!(
|
||||
matches!(err2, ValidationError::NonFiniteValue(_)),
|
||||
"expected NonFiniteValue for -Inf in matrix, got {:?}",
|
||||
err2
|
||||
);
|
||||
|
||||
// Infinity in RHS.
|
||||
let rhs = vec![1.0f32, f32::INFINITY, 3.0];
|
||||
let err_rhs = validate_rhs(&rhs, 3).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_rhs, ValidationError::NonFiniteValue(_)),
|
||||
"expected NonFiniteValue for Inf in RHS, got {:?}",
|
||||
err_rhs
|
||||
);
|
||||
|
||||
// Negative infinity in RHS.
|
||||
let rhs_neg = vec![f32::NEG_INFINITY, 2.0, 3.0];
|
||||
let err_neg = validate_rhs(&rhs_neg, 3).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_neg, ValidationError::NonFiniteValue(_)),
|
||||
"expected NonFiniteValue for -Inf in RHS, got {:?}",
|
||||
err_neg
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reject dimension mismatch: rhs length != matrix rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_reject_dimension_mismatch() {
|
||||
// RHS too short.
|
||||
let err_short = validate_rhs(&[1.0f32, 2.0], 3).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_short, ValidationError::DimensionMismatch(_)),
|
||||
"expected DimensionMismatch for rhs length < expected, got {:?}",
|
||||
err_short
|
||||
);
|
||||
|
||||
// RHS too long.
|
||||
let err_long = validate_rhs(&[1.0f32, 2.0, 3.0, 4.0], 3).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_long, ValidationError::DimensionMismatch(_)),
|
||||
"expected DimensionMismatch for rhs length > expected, got {:?}",
|
||||
err_long
|
||||
);
|
||||
|
||||
// Combined solver input validation: rhs doesn't match matrix rows.
|
||||
let mat = valid_3x3_f32();
|
||||
let rhs_wrong = vec![1.0f32, 2.0]; // length 2, but matrix is 3x3
|
||||
let err_combined = validate_solver_input(&mat, &rhs_wrong, 1e-6).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_combined, ValidationError::DimensionMismatch(_)),
|
||||
"combined validation should reject dimension mismatch, got {:?}",
|
||||
err_combined
|
||||
);
|
||||
|
||||
// Non-square matrix should be rejected by validate_solver_input.
|
||||
let non_square = CsrMatrix::<f32> {
|
||||
row_ptr: vec![0, 0, 0],
|
||||
col_indices: vec![],
|
||||
values: vec![],
|
||||
rows: 2,
|
||||
cols: 3,
|
||||
};
|
||||
let rhs_ns = vec![1.0f32, 2.0];
|
||||
let err_ns = validate_solver_input(&non_square, &rhs_ns, 1e-6).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_ns, ValidationError::DimensionMismatch(_)),
|
||||
"non-square matrix should be rejected, got {:?}",
|
||||
err_ns
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reject invalid CSR: row_ptrs not monotonic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_reject_invalid_csr() {
|
||||
// Break monotonicity of row_ptr.
|
||||
let mut mat = identity_f32(4);
|
||||
// row_ptr is [0, 1, 2, 3, 4]. Set row_ptr[2] = 0 to break monotonicity.
|
||||
mat.row_ptr[2] = 0;
|
||||
|
||||
let err = validate_csr_matrix(&mat).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ValidationError::NonMonotonicRowPtrs { .. }),
|
||||
"expected NonMonotonicRowPtrs, got {:?}",
|
||||
err
|
||||
);
|
||||
|
||||
// Verify the position is reported.
|
||||
if let ValidationError::NonMonotonicRowPtrs { position } = err {
|
||||
assert_eq!(
|
||||
position, 2,
|
||||
"monotonicity violation should be at position 2"
|
||||
);
|
||||
}
|
||||
|
||||
// row_ptr[0] != 0 should also be rejected.
|
||||
let bad_start = CsrMatrix::<f32> {
|
||||
row_ptr: vec![1, 2],
|
||||
col_indices: vec![0],
|
||||
values: vec![1.0],
|
||||
rows: 1,
|
||||
cols: 1,
|
||||
};
|
||||
let err_start = validate_csr_matrix(&bad_start).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_start, ValidationError::DimensionMismatch(_)),
|
||||
"row_ptr[0] != 0 should be rejected, got {:?}",
|
||||
err_start
|
||||
);
|
||||
|
||||
// row_ptr length != rows + 1 should be rejected.
|
||||
let bad_len = CsrMatrix::<f32> {
|
||||
row_ptr: vec![0, 1], // length 2, but rows = 3 so expect length 4
|
||||
col_indices: vec![0],
|
||||
values: vec![1.0],
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
};
|
||||
let err_len = validate_csr_matrix(&bad_len).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_len, ValidationError::DimensionMismatch(_)),
|
||||
"wrong row_ptr length should be rejected, got {:?}",
|
||||
err_len
|
||||
);
|
||||
|
||||
// Column index out of bounds.
|
||||
let mut bad_col = identity_f32(3);
|
||||
bad_col.col_indices[1] = 99; // out of bounds
|
||||
let err_col = validate_csr_matrix(&bad_col).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_col, ValidationError::IndexOutOfBounds { .. }),
|
||||
"column index out of bounds should be rejected, got {:?}",
|
||||
err_col
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reject oversized input: exceeds MAX_NODES
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_reject_oversized_input() {
|
||||
// Matrix with rows > MAX_NODES.
|
||||
let oversized = CsrMatrix::<f32> {
|
||||
row_ptr: vec![0; MAX_NODES + 2], // length = (MAX_NODES + 1) + 1
|
||||
col_indices: vec![],
|
||||
values: vec![],
|
||||
rows: MAX_NODES + 1,
|
||||
cols: 1,
|
||||
};
|
||||
|
||||
let err = validate_csr_matrix(&oversized).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, ValidationError::MatrixTooLarge { .. }),
|
||||
"expected MatrixTooLarge for rows > MAX_NODES, got {:?}",
|
||||
err
|
||||
);
|
||||
|
||||
// Verify the reported dimensions.
|
||||
if let ValidationError::MatrixTooLarge {
|
||||
rows,
|
||||
cols,
|
||||
max_dim,
|
||||
} = err
|
||||
{
|
||||
assert_eq!(rows, MAX_NODES + 1);
|
||||
assert_eq!(cols, 1);
|
||||
assert_eq!(max_dim, MAX_NODES);
|
||||
}
|
||||
|
||||
// Matrix with cols > MAX_NODES.
|
||||
let oversized_cols = CsrMatrix::<f32> {
|
||||
row_ptr: vec![0, 0],
|
||||
col_indices: vec![],
|
||||
values: vec![],
|
||||
rows: 1,
|
||||
cols: MAX_NODES + 1,
|
||||
};
|
||||
|
||||
let err_cols = validate_csr_matrix(&oversized_cols).unwrap_err();
|
||||
assert!(
|
||||
matches!(err_cols, ValidationError::MatrixTooLarge { .. }),
|
||||
"expected MatrixTooLarge for cols > MAX_NODES, got {:?}",
|
||||
err_cols
|
||||
);
|
||||
|
||||
// A matrix at exactly MAX_NODES should be accepted (boundary check).
|
||||
// We cannot allocate MAX_NODES entries in a test, but verify the logic:
|
||||
// MAX_NODES rows with 0 nnz should be valid structurally.
|
||||
// (Skipping actual allocation of 10M-entry row_ptr for test speed.)
|
||||
}
|
||||
Reference in New Issue
Block a user