ruvector-solver 2.0.6

Sublinear-time solver for RuVector: O(log n) to O(√n) algorithms for sparse linear systems, PageRank, and spectral methods
Documentation
//! 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
    );
}