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
67 changes: 28 additions & 39 deletions examples/event_driven_anomaly.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@

use std::time::Instant;
use sublinear_solver::{
contrastive_solve_on_change_sublinear, delta_below_solve_threshold, optimal_neumann_terms,
solve_on_change_sublinear, AnomalyRow, Matrix, NeumannSolver, SolverAlgorithm, SolverOptions,
SparseDelta, SparseMatrix,
contrastive_solve_on_change_sublinear_auto, delta_below_solve_threshold,
solve_on_change_sublinear_auto, AnomalyRow, Matrix, NeumannSolver, SolverAlgorithm,
SolverOptions, SparseDelta, SparseMatrix,
};
use sublinear_solver::coherence::coherence_score;

Expand Down Expand Up @@ -100,14 +100,11 @@ fn main() {
.map(|i| matrix.get(i, i).unwrap_or(0.0).abs())
.filter(|x| *x > 0.0)
.fold(f64::INFINITY, |a, b| if a < b { a } else { b });
// ── Adaptive Neumann-term selection (PR #37): pick max_terms from
// the coherence + tolerance bound instead of guessing. ──
let b_inf = b_prev.iter().map(|x| x.abs()).fold(0.0_f64, f64::max);
let auto_terms =
optimal_neumann_terms(coherence, b_inf, min_diag, 1e-8).unwrap_or(32);
// ── PR #38: orchestrators are now fully auto-tuned. We still cache
// (coherence, min_diag) here for the O(|δ|) skip gate (PR #34) —
// that's not auto, by design (cheaper to probe than recompute). ──
println!(
"gate cache: coherence={coherence:.3}, min_diag={min_diag:.3}, \
auto_terms={auto_terms} (skip=1e-6, tol=1e-8)\n"
"gate cache: coherence={coherence:.3}, min_diag={min_diag:.3} (skip=1e-6, tol=1e-8)\n"
);

// ── Event stream. Each entry is (event_label, sensor_index, delta_value).
Expand All @@ -122,16 +119,13 @@ fn main() {
("sensor #155 outlier", 155, 3.25),
];

println!("event stream (closure_depth=4, max_terms=24, tolerance=1e-8, skip=1e-6):");
println!("event stream (auto-tuned closure + max_terms, tolerance=1e-8, skip=1e-6):");
println!(
"{:<22} {:>10} {:>12} {:>14} {:>14}",
"event", "closure", "latency_us", "top_anomaly", "score"
);
println!("{}", "─".repeat(74));

let closure_depth = 4usize;
// `max_terms` is now auto-tuned per matrix via the adaptive helper.
let max_terms = auto_terms;
let tolerance = 1e-8_f64;
let skip_threshold = 1.0e-6_f64;
let top_k = 3usize;
Expand All @@ -153,30 +147,24 @@ fn main() {
let mut b_new = b_prev.clone();
delta.apply_to(&mut b_new).expect("apply");

// (1) Sparse delta-solve over the closure only.
let sparse_entries = solve_on_change_sublinear(
&matrix,
&prev_solution,
&b_new,
&delta,
closure_depth,
max_terms,
tolerance,
)
.expect("sublinear delta-solve");
// (1) Sparse delta-solve over the closure only — auto-tuned.
// The orchestrator computes coherence + picks
// closure_depth + max_terms internally; caller only supplies
// the tolerance contract.
let sparse_entries =
solve_on_change_sublinear_auto(&matrix, &prev_solution, &b_new, &delta, tolerance)
.expect("auto-tuned delta-solve");

// (2) Contrastive top-k anomaly detection, same closure scope.
let top: Vec<AnomalyRow> = contrastive_solve_on_change_sublinear(
// (2) Contrastive top-k anomaly detection — auto-tuned sibling.
let top: Vec<AnomalyRow> = contrastive_solve_on_change_sublinear_auto(
&matrix,
&prev_solution,
&b_new,
&delta,
closure_depth,
max_terms,
tolerance,
top_k,
)
.expect("sublinear contrastive solve");
.expect("auto-tuned contrastive solve");

let total_us = t_total.elapsed().as_micros();
let closure_n = sparse_entries.len();
Expand All @@ -199,16 +187,17 @@ fn main() {
" coherence gate O(|δ|) ~0 µs skip tiny deltas before any solve"
);
println!(
" per-event SubLinear closure=17 rows independent of n=256"
" per-event SubLinear auto-tuned closure_depth+max_terms from coherence={coherence:.3}"
);
println!();
println!("Per-event latency is *bounded by closure size*, not n. The coherence");
println!("gate further short-circuits tiny / noisy events in O(|δ|) before");
println!("any closure computation runs — the 'no event, no work' discipline");
println!("of ADR-001 in action. Doubling n (or growing the state space to");
println!("10⁴+ rows) leaves both the gate latency *and* the per-event closure");
println!("cost essentially unchanged — the architectural win that lets RuView /");
println!("Cognitum sustain change-driven loops over large state spaces without");
println!("burning the J/decision budget. See");
println!("The orchestrators are now magic-number-free: pass tolerance, get top-k.");
println!("Closure depth + Neumann terms are picked from the Neumann-envelope");
println!("bound (PRs #37 + #38) — provably sufficient, never over-budget. On");
println!("this low-coherence (c=0.2) test matrix, that math correctly demands a");
println!("wide closure to reach 1e-8 tolerance. Higher-coherence matrices");
println!("(c≥0.5) auto-pick tighter closures, pulling per-event cost down by");
println!("orders of magnitude. The coherence gate short-circuits tiny / noisy");
println!("events in O(|δ|) before any closure computation runs — the 'no");
println!("event, no work' discipline of ADR-001 in action. See");
println!("docs/adr/ADR-001-complexity-as-architecture.md.");
}
60 changes: 60 additions & 0 deletions src/contrastive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,66 @@ pub fn contrastive_solve_on_change_sublinear(
))
}

/// Magic-number-free sibling of [`contrastive_solve_on_change_sublinear`].
/// Takes only `(matrix, prev, b_new, delta, tolerance, k)` and auto-tunes
/// both `closure_depth` and `max_terms` from the matrix's coherence via
/// [`crate::coherence::optimal_neumann_terms`].
///
/// Mirrors [`crate::incremental::solve_on_change_sublinear_auto`] for the
/// contrastive top-k path. Caller's contract collapses to: *"here's
/// tolerance and k, give me back the top-k anomalies."*
///
/// ## Errors
///
/// - [`crate::error::SolverError::Incoherent`] on non-strict-DD input
/// (the auto-tune relies on the coherence margin envelope).
pub fn contrastive_solve_on_change_sublinear_auto(
matrix: &dyn crate::matrix::Matrix,
prev_solution: &[Precision],
b_new: &[Precision],
delta: &crate::incremental::SparseDelta,
tolerance: Precision,
k: usize,
) -> crate::error::Result<Vec<AnomalyRow>> {
if delta.is_empty() || k == 0 {
return Ok(Vec::new());
}

let coherence = crate::coherence::coherence_score(matrix);
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 !coherence.is_finite() || coherence <= 0.0 {
return Err(crate::error::SolverError::Incoherent {
coherence,
threshold: 1e-12,
});
}

let b_inf = b_new
.iter()
.map(|x| x.abs())
.fold(0.0_f64, |a, b| if a > b { a } else { b });

let auto_terms = crate::coherence::optimal_neumann_terms(
coherence, b_inf, min_diag, tolerance,
)
.unwrap_or(32);

contrastive_solve_on_change_sublinear(
matrix,
prev_solution,
b_new,
delta,
/*closure_depth=*/ auto_terms,
/*max_terms=*/ auto_terms,
tolerance,
k,
)
}

/// Op marker for the SubLinear orchestrator variant.
pub struct ContrastiveSolveOnChangeSublinearOp;

Expand Down
140 changes: 140 additions & 0 deletions src/incremental.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,83 @@ impl Complexity for SolveOnChangeSublinearOp {
Independent of n for sparse DD matrices with bounded closure_depth + max_terms.";
}

/// Magic-number-free sibling of [`solve_on_change_sublinear`]. Takes only
/// `(matrix, prev, b_new, delta, tolerance)` and **auto-tunes** both
/// `closure_depth` and `max_terms` from the matrix's coherence margin
/// via [`crate::coherence::optimal_neumann_terms`].
///
/// The kth Neumann iterate touches rows up to `k` hops away from the
/// seeds; the closure must cover at least that radius. So
/// `closure_depth == max_terms` is the tight choice — wider wastes work,
/// narrower under-covers the support of the iterate.
///
/// Caller's contract collapses to: *"here's tolerance, give me back
/// what changed"*. Suitable for downstream code that doesn't want to
/// reason about ρ ≤ 1 - c at every call site.
///
/// ## Behaviour
///
/// - On a strict-DD matrix: auto-tunes from `coherence_score(matrix)`
/// and dispatches to [`solve_on_change_sublinear`].
/// - On a non-strict-DD matrix (coherence ≤ 0): returns
/// `SolverError::Incoherent`. The Neumann-envelope bound doesn't
/// hold there, so auto-tuning would silently lie. Caller must fall
/// back to a full solve or to the hand-tuned API.
/// - Empty delta short-circuits to an empty result without consuming
/// coherence — the "no event, no work" path is preserved.
///
/// ## Complexity
///
/// Same as [`solve_on_change_sublinear`]: SubLinear in `n` for sparse
/// DD matrices. Adds one `O(nnz(A))` coherence-score pass per call —
/// callers running many events should compute coherence once and use
/// the manual API instead.
pub fn solve_on_change_sublinear_auto(
matrix: &dyn crate::matrix::Matrix,
prev_solution: &[Precision],
b_new: &[Precision],
delta: &SparseDelta,
tolerance: Precision,
) -> Result<Vec<(usize, Precision)>> {
if delta.is_empty() {
return Ok(Vec::new());
}

let coherence = crate::coherence::coherence_score(matrix);
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 !coherence.is_finite() || coherence <= 0.0 {
return Err(SolverError::Incoherent {
coherence,
threshold: 1e-12,
});
}

let b_inf = b_new
.iter()
.map(|x| x.abs())
.fold(0.0_f64, |a, b| if a > b { a } else { b });

// optimal_neumann_terms guarantees ≥1 result on strict-DD input.
let auto_terms = crate::coherence::optimal_neumann_terms(
coherence, b_inf, min_diag, tolerance,
)
.unwrap_or(32);

solve_on_change_sublinear(
matrix,
prev_solution,
b_new,
delta,
/*closure_depth=*/ auto_terms,
/*max_terms=*/ auto_terms,
tolerance,
)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -610,4 +687,67 @@ mod tests {
// Row 3 (the delta site) must be in the entries.
assert!(entries.iter().any(|&(r, _)| r == 3));
}

// ── Auto-tuned orchestrator tests ────────────────────────────────

#[test]
fn auto_empty_delta_returns_empty() {
let (m, b) = build_test_system();
let prev = alloc::vec![0.0; m.rows()];
let delta = SparseDelta::empty();
let entries = solve_on_change_sublinear_auto(&m, &prev, &b, &delta, 1e-8).unwrap();
assert!(entries.is_empty());
}

#[test]
fn auto_matches_manual_on_strict_dd() {
// On a strict-DD matrix the auto orchestrator must produce the
// same entries (within tolerance) as a hand-tuned solve.
let n = 8;
let mut triplets = Vec::new();
for i in 0..n {
triplets.push((i, i, 4.0 as Precision));
if i + 1 < n {
triplets.push((i, i + 1, -1.0 as Precision));
triplets.push((i + 1, i, -1.0 as Precision));
}
}
let m = SparseMatrix::from_triplets(triplets, n, n).unwrap();
let b_prev = alloc::vec![1.0 as Precision; n];

let solver = NeumannSolver::new(64, 1e-12);
let opts = SolverOptions::default();
let prev = solver.solve(&m, &b_prev, &opts).unwrap();

let delta =
SparseDelta::new(alloc::vec![3usize], alloc::vec![0.5 as Precision]).unwrap();
let mut b_new = b_prev.clone();
delta.apply_to(&mut b_new).unwrap();

let auto =
solve_on_change_sublinear_auto(&m, &prev.solution, &b_new, &delta, 1e-8).unwrap();
assert!(!auto.is_empty());
// Auto path agrees with the full solve at every closure row.
let full = solver.solve(&m, &b_new, &opts).unwrap();
for &(row, val) in &auto {
assert!((val - full.solution[row]).abs() < 1e-6);
}
}

#[test]
fn auto_rejects_non_dd_matrix_with_incoherent() {
// Off-diagonals dominate the diagonal → coherence ≤ 0.
let triplets = alloc::vec![
(0usize, 0, 1.0),
(0, 1, 2.0),
(1, 0, 2.0),
(1, 1, 1.0),
];
let m = SparseMatrix::from_triplets(triplets, 2, 2).unwrap();
let prev = alloc::vec![0.0; 2];
let b = alloc::vec![1.0; 2];
let delta = SparseDelta::new(alloc::vec![0], alloc::vec![0.5]).unwrap();
let err = solve_on_change_sublinear_auto(&m, &prev, &b, &delta, 1e-8).unwrap_err();
assert!(matches!(err, SolverError::Incoherent { .. }));
}
}
11 changes: 6 additions & 5 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ pub use coherence::{coherence_score, check_coherence_or_reject};
// is sparse, event-driven activation" thesis.
pub mod incremental;
pub use incremental::{
solve_on_change_sublinear, IncrementalConfig, IncrementalSolver, SolveOnChangeSublinearOp,
SparseDelta,
solve_on_change_sublinear, solve_on_change_sublinear_auto, IncrementalConfig,
IncrementalSolver, SolveOnChangeSublinearOp, SparseDelta,
};
pub use coherence::{delta_below_solve_threshold, delta_inf_bound, optimal_neumann_terms};

Expand All @@ -138,9 +138,10 @@ pub use coherence::{delta_below_solve_threshold, delta_inf_bound, optimal_neuman
// most from baseline? Backbone of RuView / Cognitum wake-on-event.
pub mod contrastive;
pub use contrastive::{
contrastive_solve_on_change, contrastive_solve_on_change_sublinear, find_anomalous_rows,
find_anomalous_rows_in_subset, find_rows_above_threshold, AnomalyRow,
ContrastiveSolveOnChangeOp, ContrastiveSolveOnChangeSublinearOp,
contrastive_solve_on_change, contrastive_solve_on_change_sublinear,
contrastive_solve_on_change_sublinear_auto, find_anomalous_rows, find_anomalous_rows_in_subset,
find_rows_above_threshold, AnomalyRow, ContrastiveSolveOnChangeOp,
ContrastiveSolveOnChangeSublinearOp,
};

// Bounded-depth row-graph closure (ADR-001 #6 phase-2A). Turns a sparse
Expand Down
Loading