Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions src/coherence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,107 @@ pub fn coherence_score(matrix: &dyn Matrix) -> Precision {
worst
}

/// Upper bound on `‖A⁻¹ · δ‖_∞`, derived from the coherence margin.
///
/// For a strictly diagonally dominant matrix `A = D - O` with coherence
/// margin `c = min_i (|A[i,i]| - Σ_{j≠i}|A[i,j]|) / |A[i,i]|`, we have
/// `‖A⁻¹ δ‖_∞ ≤ ‖δ‖_∞ / (min_i |A[i,i]| · c)`. This is a Neumann-series
/// envelope bound — never tight, but always safe.
///
/// Returns `None` if the matrix is not strictly DD (`coherence_score
/// <= 0`); the caller must fall back to an actual solve in that case.
///
/// Cost: one `coherence_score` pass + one min-diagonal pass — Linear in
/// `nnz(A)`. **But the *point* of this primitive is to amortise the
/// score across many event-handling cycles**: callers cache the
/// `(coherence, min_diag)` pair once at matrix-build time, then ask
/// this function `Option<Precision>` on every event for an `O(|δ|)`
/// envelope check.
///
/// Use [`delta_below_solve_threshold`] for the cached-input fast path.
pub fn delta_inf_bound(matrix: &dyn Matrix, delta_values: &[Precision]) -> Option<Precision> {
let c = coherence_score(matrix);
if !c.is_finite() || c <= 0.0 {
return None;
}
let min_diag = (0..matrix.rows())
.map(|i| matrix.get(i, i).unwrap_or(0.0).abs())
.filter(|x| *x > 0.0)
.fold(Precision::INFINITY, |a, b| if a < b { a } else { b });
if !min_diag.is_finite() || min_diag <= 0.0 {
return None;
}
let delta_inf = delta_values
.iter()
.map(|v| v.abs())
.fold(0.0_f64, |a, b| if a > b { a } else { b });
Some(delta_inf / (min_diag * c))
}

/// Fast-path coherence-gated event filter. Returns `true` iff the
/// supplied `delta` is small enough that, given the matrix's
/// `(coherence, min_diag)` pair, the induced change in `x` is
/// guaranteed below `tolerance` — so a downstream solve can safely
/// be skipped.
///
/// **This is the "no event, no work" gate from the ADR-001 thesis.**
/// Cost is `O(|δ|)` — independent of `n`, independent of `nnz(A)`. The
/// `(coherence, min_diag)` pair is computed once per matrix at build
/// time and reused across every event.
///
/// Returns `false` when:
/// - `tolerance <= 0` (gate disabled)
/// - `coherence <= 0` (not strict-DD — bound doesn't hold; can't skip)
/// - `min_diag <= 0`
/// - the bound `‖δ‖_∞ / (min_diag · coherence)` exceeds `tolerance`
/// (meaningful change may have happened — don't skip)
///
/// # Examples
///
/// ```rust,no_run
/// # use sublinear_solver::{Matrix, coherence::{coherence_score, delta_below_solve_threshold}};
/// # fn demo<M: Matrix>(a: &M, deltas: impl Iterator<Item = Vec<f64>>) {
/// // Cache once.
/// let c = coherence_score(a);
/// let min_diag = (0..a.rows())
/// .map(|i| a.get(i, i).unwrap_or(0.0).abs())
/// .filter(|x| *x > 0.0)
/// .fold(f64::INFINITY, |a, b| a.min(b));
///
/// // O(|delta|) check per event.
/// let tolerance = 1e-6;
/// for delta in deltas {
/// if delta_below_solve_threshold(c, min_diag, &delta, tolerance) {
/// // Skip the solve; the world didn't meaningfully change.
/// continue;
/// }
/// // Otherwise: dispatch to solve_on_change_sublinear / contrastive / …
/// }
/// # }
/// ```
pub fn delta_below_solve_threshold(
coherence: Precision,
min_diag: Precision,
delta_values: &[Precision],
tolerance: Precision,
) -> bool {
if tolerance <= 0.0 {
return false;
}
if !coherence.is_finite() || coherence <= 0.0 {
return false;
}
if !min_diag.is_finite() || min_diag <= 0.0 {
return false;
}
let delta_inf = delta_values
.iter()
.map(|v| v.abs())
.fold(0.0_f64, |a, b| if a > b { a } else { b });
let bound = delta_inf / (min_diag * coherence);
bound < tolerance
}

/// Verify that a matrix's coherence meets or exceeds the configured
/// threshold; otherwise return `SolverError::Incoherent`.
///
Expand Down Expand Up @@ -217,4 +318,71 @@ mod tests {
let score = r.unwrap();
assert!((score - 0.8).abs() < 1e-12, "expected 0.8, got {score}");
}

// ── delta_inf_bound / delta_below_solve_threshold tests ────────────

#[test]
fn delta_bound_on_strict_dd_matrix_is_finite() {
// 5/1 dominant: coherence = 0.8, min_diag = 5.
// delta_inf = 0.1 → bound = 0.1 / (5 · 0.8) = 0.025.
let m = build(
vec![
(0, 0, 5.0), (0, 1, 1.0),
(1, 0, 1.0), (1, 1, 5.0),
],
2,
);
let bound = delta_inf_bound(&m, &[0.1, 0.0]).unwrap();
assert!((bound - 0.025).abs() < 1e-12, "expected 0.025, got {bound}");
}

#[test]
fn delta_bound_on_non_dd_matrix_is_none() {
// Non-DD: bound doesn't hold. Caller must fall back to a solve.
let m = build(
vec![(0, 0, 1.0), (0, 1, 2.0), (1, 0, 2.0), (1, 1, 1.0)],
2,
);
assert!(delta_inf_bound(&m, &[1.0, 1.0]).is_none());
}

#[test]
fn delta_below_threshold_skips_tiny_delta() {
// coherence = 0.8, min_diag = 5, delta = 1e-9.
// bound = 1e-9 / (5 · 0.8) = 2.5e-10 < tolerance = 1e-8 → skip.
assert!(delta_below_solve_threshold(
/*coherence=*/ 0.8,
/*min_diag=*/ 5.0,
/*delta=*/ &[1e-9, 0.0],
/*tolerance=*/ 1e-8,
));
}

#[test]
fn delta_above_threshold_does_not_skip() {
// coherence = 0.8, min_diag = 5, delta = 1.0.
// bound = 1.0 / 4.0 = 0.25 > tolerance = 1e-8 → must solve.
assert!(!delta_below_solve_threshold(0.8, 5.0, &[1.0, 0.0], 1e-8));
}

#[test]
fn delta_below_threshold_with_disabled_tolerance_never_skips() {
// tolerance <= 0 disables the gate.
assert!(!delta_below_solve_threshold(0.8, 5.0, &[1e-12, 0.0], 0.0));
assert!(!delta_below_solve_threshold(0.8, 5.0, &[1e-12, 0.0], -1.0));
}

#[test]
fn delta_below_threshold_refuses_to_skip_on_non_dd_input() {
// coherence <= 0 means the bound doesn't hold. Refuse to skip
// regardless of how small the delta is — safety first.
assert!(!delta_below_solve_threshold(-0.1, 5.0, &[1e-12], 1e-8));
assert!(!delta_below_solve_threshold(0.0, 5.0, &[1e-12], 1e-8));
}

#[test]
fn delta_below_threshold_on_empty_delta_skips() {
// Empty delta has inf-norm 0, which is below any positive tolerance.
assert!(delta_below_solve_threshold(0.8, 5.0, &[], 1e-8));
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ pub use incremental::{
solve_on_change_sublinear, IncrementalConfig, IncrementalSolver, SolveOnChangeSublinearOp,
SparseDelta,
};
pub use coherence::{delta_below_solve_threshold, delta_inf_bound};

// Contrastive search (ADR-001 roadmap item #6). Boundary-crossing primitive
// for change-driven activation: which rows of the current solution diverged
Expand Down
Loading