use crate::complexity::{Complexity, ComplexityClass};
use crate::types::Precision;
use alloc::collections::BinaryHeap;
use alloc::vec::Vec;
use core::cmp::Ordering;
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct AnomalyRow {
pub row: usize,
pub baseline: Precision,
pub current: Precision,
pub anomaly: Precision,
}
#[derive(Debug, Clone)]
struct MinHeapEntry(AnomalyRow);
impl PartialEq for MinHeapEntry {
fn eq(&self, other: &Self) -> bool {
self.0.anomaly == other.0.anomaly && self.0.row == other.0.row
}
}
impl Eq for MinHeapEntry {}
impl PartialOrd for MinHeapEntry {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for MinHeapEntry {
fn cmp(&self, other: &Self) -> Ordering {
other
.0
.anomaly
.partial_cmp(&self.0.anomaly)
.unwrap_or(Ordering::Equal)
.then_with(|| other.0.row.cmp(&self.0.row))
}
}
pub fn find_anomalous_rows(
baseline: &[Precision],
current: &[Precision],
k: usize,
) -> Vec<AnomalyRow> {
assert_eq!(
baseline.len(),
current.len(),
"find_anomalous_rows: baseline.len()={} != current.len()={}",
baseline.len(),
current.len(),
);
if k == 0 || baseline.is_empty() {
return Vec::new();
}
let mut heap: BinaryHeap<MinHeapEntry> = BinaryHeap::with_capacity(k.min(baseline.len()) + 1);
for (row, (&b, &c)) in baseline.iter().zip(current.iter()).enumerate() {
let anomaly = (c - b).abs();
let entry = MinHeapEntry(AnomalyRow {
row,
baseline: b,
current: c,
anomaly,
});
if heap.len() < k {
heap.push(entry);
} else if let Some(smallest) = heap.peek() {
if anomaly > smallest.0.anomaly {
heap.pop();
heap.push(entry);
}
}
}
let mut out: Vec<AnomalyRow> = heap.into_iter().map(|e| e.0).collect();
out.sort_by(|a, b| {
b.anomaly
.partial_cmp(&a.anomaly)
.unwrap_or(Ordering::Equal)
.then_with(|| a.row.cmp(&b.row))
});
out
}
pub fn find_anomalous_rows_in_subset(
baseline: &[Precision],
current: &[Precision],
candidates: &[usize],
k: usize,
) -> Vec<AnomalyRow> {
assert_eq!(
baseline.len(),
current.len(),
"find_anomalous_rows_in_subset: dim mismatch {} vs {}",
baseline.len(),
current.len(),
);
if k == 0 || candidates.is_empty() || baseline.is_empty() {
return Vec::new();
}
let mut heap: BinaryHeap<MinHeapEntry> = BinaryHeap::with_capacity(k.min(candidates.len()) + 1);
for &row in candidates {
if row >= baseline.len() {
continue;
}
let anomaly = (current[row] - baseline[row]).abs();
let entry = MinHeapEntry(AnomalyRow {
row,
baseline: baseline[row],
current: current[row],
anomaly,
});
if heap.len() < k {
heap.push(entry);
} else if let Some(smallest) = heap.peek() {
if anomaly > smallest.0.anomaly {
heap.pop();
heap.push(entry);
}
}
}
let mut out: Vec<AnomalyRow> = heap.into_iter().map(|e| e.0).collect();
out.sort_by(|a, b| {
b.anomaly
.partial_cmp(&a.anomaly)
.unwrap_or(Ordering::Equal)
.then_with(|| a.row.cmp(&b.row))
});
out
}
pub fn find_rows_above_threshold(
baseline: &[Precision],
current: &[Precision],
threshold: Precision,
) -> Vec<AnomalyRow> {
assert_eq!(
baseline.len(),
current.len(),
"find_rows_above_threshold: dim mismatch {} vs {}",
baseline.len(),
current.len(),
);
baseline
.iter()
.zip(current.iter())
.enumerate()
.filter_map(|(row, (&b, &c))| {
let anomaly = (c - b).abs();
if anomaly > threshold {
Some(AnomalyRow {
row,
baseline: b,
current: c,
anomaly,
})
} else {
None
}
})
.collect()
}
pub struct FindAnomalousRowsOp;
impl Complexity for FindAnomalousRowsOp {
const CLASS: ComplexityClass = ComplexityClass::Adaptive {
default: &ComplexityClass::Linear,
worst: &ComplexityClass::Linear,
};
const DETAIL: &'static str =
"O(n log k) full-scan baseline today; phase-2 lowers to O(k · log n) via the \
sublinear-Neumann single-entry primitive.";
}
pub fn contrastive_solve_on_change<S>(
solver: &S,
matrix: &dyn crate::matrix::Matrix,
prev_solution: &[Precision],
delta: &crate::incremental::SparseDelta,
depth: usize,
k: usize,
options: &crate::solver::SolverOptions,
) -> crate::error::Result<Vec<AnomalyRow>>
where
S: crate::incremental::IncrementalSolver + ?Sized,
{
let candidates = crate::closure::closure_indices(matrix, &delta.indices, depth);
if candidates.is_empty() || k == 0 {
return Ok(Vec::new());
}
let result = solver.solve_on_change(matrix, prev_solution, delta, options)?;
Ok(find_anomalous_rows_in_subset(
prev_solution,
&result.solution,
&candidates,
k,
))
}
pub fn contrastive_solve_on_change_sublinear(
matrix: &dyn crate::matrix::Matrix,
prev_solution: &[Precision],
b_new: &[Precision],
delta: &crate::incremental::SparseDelta,
closure_depth: usize,
max_terms: usize,
tolerance: Precision,
k: usize,
) -> crate::error::Result<Vec<AnomalyRow>> {
let candidates = crate::closure::closure_indices(matrix, &delta.indices, closure_depth);
if candidates.is_empty() || k == 0 {
return Ok(Vec::new());
}
let entries = crate::entry::solve_single_entries_neumann(
matrix,
b_new,
&candidates,
max_terms,
tolerance,
)?;
let n = matrix.rows();
let mut current = alloc::vec![0.0 as Precision; n];
for &(i, v) in &entries {
if i < n {
current[i] = v;
}
}
Ok(find_anomalous_rows_in_subset(
prev_solution,
¤t,
&candidates,
k,
))
}
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,
auto_terms,
auto_terms,
tolerance,
k,
)
}
pub fn contrastive_solve_on_change_sublinear_auto_with_rho(
matrix: &dyn crate::matrix::Matrix,
prev_solution: &[Precision],
b_new: &[Precision],
delta: &crate::incremental::SparseDelta,
tolerance: Precision,
k: usize,
rho: Precision,
) -> crate::error::Result<Vec<AnomalyRow>> {
if delta.is_empty() || k == 0 {
return Ok(Vec::new());
}
if !rho.is_finite() || rho <= 0.0 || rho >= 1.0 {
return Err(crate::error::SolverError::InvalidInput {
message: alloc::format!(
"contrastive_solve_on_change_sublinear_auto_with_rho: rho={} must lie in (0, 1)",
rho
),
parameter: Some(alloc::string::String::from("rho")),
});
}
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 Err(crate::error::SolverError::InvalidInput {
message: alloc::string::String::from(
"contrastive_solve_on_change_sublinear_auto_with_rho: non-positive min_diag",
),
parameter: Some(alloc::string::String::from("matrix")),
});
}
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_with_rho(rho, b_inf, min_diag, tolerance)
.unwrap_or(32);
contrastive_solve_on_change_sublinear(
matrix,
prev_solution,
b_new,
delta,
auto_terms,
auto_terms,
tolerance,
k,
)
}
pub struct ContrastiveSolveOnChangeSublinearOp;
impl Complexity for ContrastiveSolveOnChangeSublinearOp {
const CLASS: ComplexityClass = ComplexityClass::SubLinear;
const DETAIL: &'static str =
"Phase-2B: closure (SubLinear) + per-entry sublinear-Neumann (SubLinear) + top-k-in-subset \
(SubLinear). End-to-end SubLinear in n for sparse DD matrices with bounded depth.";
}
pub struct ContrastiveSolveOnChangeOp;
impl Complexity for ContrastiveSolveOnChangeOp {
const CLASS: ComplexityClass = ComplexityClass::Adaptive {
default: &ComplexityClass::Linear,
worst: &ComplexityClass::Linear,
};
const DETAIL: &'static str =
"Phase-2A: closure (SubLinear) + warm-start solve (Linear) + top-k-in-subset \
(SubLinear). Phase-2B replaces the inner solve with per-entry sublinear-Neumann \
queries scoped to the closure, dropping the orchestrator end-to-end to SubLinear.";
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_inputs_return_empty() {
let v: Vec<Precision> = alloc::vec![];
assert_eq!(find_anomalous_rows(&v, &v, 5), alloc::vec![]);
assert_eq!(find_anomalous_rows(&v, &v, 0), alloc::vec![]);
}
#[test]
fn k_zero_returns_empty() {
let b = alloc::vec![1.0, 2.0, 3.0];
let c = alloc::vec![10.0, 20.0, 30.0];
assert_eq!(find_anomalous_rows(&b, &c, 0), alloc::vec![]);
}
#[test]
fn top_k_is_correct_for_small_case() {
let b = alloc::vec![1.0, 1.0, 1.0, 1.0, 1.0];
let c = alloc::vec![1.0, 5.0, 1.0, 9.0, 2.0];
let top = find_anomalous_rows(&b, &c, 3);
assert_eq!(top.len(), 3);
assert_eq!(top[0].row, 3);
assert_eq!(top[0].anomaly, 8.0);
assert_eq!(top[1].row, 1);
assert_eq!(top[1].anomaly, 4.0);
assert_eq!(top[2].row, 4);
assert_eq!(top[2].anomaly, 1.0);
}
#[test]
fn k_larger_than_n_returns_all_sorted() {
let b = alloc::vec![0.0, 0.0, 0.0];
let c = alloc::vec![3.0, 1.0, 2.0];
let top = find_anomalous_rows(&b, &c, 10);
assert_eq!(top.len(), 3);
assert!(top[0].anomaly >= top[1].anomaly);
assert!(top[1].anomaly >= top[2].anomaly);
}
#[test]
fn tie_breaks_on_row_index_ascending() {
let b = alloc::vec![0.0, 0.0, 0.0];
let c = alloc::vec![5.0, 5.0, 5.0]; let top = find_anomalous_rows(&b, &c, 2);
assert_eq!(top.len(), 2);
assert_eq!(top[0].row, 0);
assert_eq!(top[1].row, 1);
}
#[test]
fn anomaly_is_absolute_value() {
let b = alloc::vec![0.0, 10.0];
let c = alloc::vec![-7.0, 3.0];
let top = find_anomalous_rows(&b, &c, 2);
assert_eq!(top[0].anomaly, 7.0);
assert_eq!(top[1].anomaly, 7.0);
assert_eq!(top[0].row, 0);
}
#[test]
#[should_panic(expected = "dim mismatch")]
fn threshold_panics_on_dim_mismatch() {
let b = alloc::vec![1.0, 2.0];
let c = alloc::vec![1.0];
let _ = find_rows_above_threshold(&b, &c, 0.5);
}
#[test]
fn threshold_filters_correctly() {
let b = alloc::vec![0.0, 0.0, 0.0, 0.0];
let c = alloc::vec![0.1, 0.5, 2.0, 0.05];
let above = find_rows_above_threshold(&b, &c, 0.3);
assert_eq!(above.len(), 2);
assert_eq!(above[0].row, 1);
assert_eq!(above[1].row, 2);
}
#[test]
fn threshold_returns_empty_when_nothing_crosses() {
let b = alloc::vec![0.0; 5];
let c = alloc::vec![0.01, 0.02, 0.03, 0.04, 0.05];
let above = find_rows_above_threshold(&b, &c, 1.0);
assert!(
above.is_empty(),
"no entry above threshold should return empty"
);
}
#[test]
fn subset_returns_only_candidates() {
let baseline = alloc::vec![0.0; 10];
let mut current = alloc::vec![0.0; 10];
current[7] = 999.0;
current[2] = 5.0;
current[5] = 3.0;
let candidates = alloc::vec![2usize, 5];
let top = find_anomalous_rows_in_subset(&baseline, ¤t, &candidates, 5);
assert_eq!(top.len(), 2);
assert_eq!(top[0].row, 2);
assert_eq!(top[0].anomaly, 5.0);
assert_eq!(top[1].row, 5);
assert_eq!(top[1].anomaly, 3.0);
assert!(top.iter().all(|r| r.row != 7));
}
#[test]
fn subset_k_limit_works() {
let baseline = alloc::vec![0.0; 100];
let mut current = alloc::vec![0.0; 100];
for &i in &[10, 20, 30, 40, 50] {
current[i] = i as Precision;
}
let candidates = alloc::vec![10usize, 20, 30, 40, 50];
let top = find_anomalous_rows_in_subset(&baseline, ¤t, &candidates, 3);
assert_eq!(top.len(), 3);
assert_eq!(top[0].row, 50);
assert_eq!(top[1].row, 40);
assert_eq!(top[2].row, 30);
}
#[test]
fn subset_ignores_out_of_bounds_indices() {
let baseline = alloc::vec![0.0; 5];
let mut current = alloc::vec![0.0; 5];
current[2] = 10.0;
let candidates = alloc::vec![2usize, 100, 200];
let top = find_anomalous_rows_in_subset(&baseline, ¤t, &candidates, 5);
assert_eq!(top.len(), 1);
assert_eq!(top[0].row, 2);
}
#[test]
fn subset_empty_candidates_returns_empty() {
let baseline = alloc::vec![0.0; 5];
let current = alloc::vec![1.0, 2.0, 3.0, 4.0, 5.0];
let candidates: Vec<usize> = alloc::vec![];
let top = find_anomalous_rows_in_subset(&baseline, ¤t, &candidates, 10);
assert!(top.is_empty());
}
#[test]
fn subset_matches_full_scan_when_candidates_cover_all_rows() {
let baseline = alloc::vec![0.0; 8];
let current = alloc::vec![1.0, 5.0, 1.0, 9.0, 2.0, 7.0, 3.0, 6.0];
let full = find_anomalous_rows(&baseline, ¤t, 4);
let candidates: Vec<usize> = (0..8).collect();
let subset = find_anomalous_rows_in_subset(&baseline, ¤t, &candidates, 4);
assert_eq!(full, subset);
}
#[test]
fn orchestrator_op_complexity_class_compile_time() {
const _: () = assert!(matches!(
<ContrastiveSolveOnChangeOp as Complexity>::CLASS,
ComplexityClass::Adaptive { .. }
));
}
#[test]
fn orchestrator_op_detail_mentions_phase_2b() {
let detail = <ContrastiveSolveOnChangeOp as Complexity>::DETAIL;
assert!(detail.contains("Phase-2A"));
assert!(detail.contains("Phase-2B"));
assert!(detail.contains("SubLinear"));
}
#[test]
fn orchestrator_with_empty_delta_returns_empty_top_k() {
use crate::incremental::SparseDelta;
use crate::matrix::SparseMatrix;
use crate::solver::neumann::NeumannSolver;
use crate::solver::SolverOptions;
let n = 4;
let triplets: Vec<(usize, usize, Precision)> = (0..n).map(|i| (i, i, 2.0)).collect();
let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
let prev = alloc::vec![0.0; n];
let delta = SparseDelta::empty();
let solver = NeumannSolver::new(64, 1e-10);
let opts = SolverOptions::default();
let top = contrastive_solve_on_change(&solver, &a, &prev, &delta, 3, 5, &opts)
.expect("empty-delta path must succeed without invoking the inner solver");
assert!(top.is_empty(), "empty delta should yield empty top-k");
}
#[test]
fn orchestrator_zero_k_returns_empty_without_solving() {
use crate::incremental::SparseDelta;
use crate::matrix::SparseMatrix;
use crate::solver::neumann::NeumannSolver;
use crate::solver::SolverOptions;
let n = 4;
let triplets: Vec<(usize, usize, Precision)> = (0..n).map(|i| (i, i, 2.0)).collect();
let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
let prev = alloc::vec![0.0; n];
let delta = SparseDelta::new(alloc::vec![1], alloc::vec![1.0]).unwrap();
let solver = NeumannSolver::new(64, 1e-10);
let opts = SolverOptions::default();
let top = contrastive_solve_on_change(&solver, &a, &prev, &delta, 3, 0, &opts).unwrap();
assert!(top.is_empty());
}
#[test]
fn sublinear_orchestrator_op_is_sublinear() {
const _: () = assert!(matches!(
<ContrastiveSolveOnChangeSublinearOp as Complexity>::CLASS,
ComplexityClass::SubLinear
));
assert!(<ContrastiveSolveOnChangeSublinearOp as Complexity>::DETAIL.contains("Phase-2B"));
}
#[test]
fn sublinear_orchestrator_empty_delta_returns_empty() {
use crate::incremental::SparseDelta;
use crate::matrix::SparseMatrix;
let n = 4;
let triplets: Vec<(usize, usize, Precision)> = (0..n).map(|i| (i, i, 2.0)).collect();
let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
let prev = alloc::vec![0.0; n];
let b_new = alloc::vec![1.0; n];
let delta = SparseDelta::empty();
let top = contrastive_solve_on_change_sublinear(&a, &prev, &b_new, &delta, 3, 16, 1e-10, 5)
.unwrap();
assert!(top.is_empty());
}
#[test]
fn sublinear_orchestrator_finds_changed_rows_on_chain() {
use crate::incremental::SparseDelta;
use crate::matrix::SparseMatrix;
use crate::solver::neumann::NeumannSolver;
use crate::solver::{SolverAlgorithm, SolverOptions};
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 a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
let b_prev = alloc::vec![1.0 as Precision; n];
let full = NeumannSolver::new(64, 1e-12);
let opts = SolverOptions::default();
let prev_solution = full.solve(&a, &b_prev, &opts).unwrap().solution;
let mut b_new = b_prev.clone();
b_new[2] += 1.0;
let delta = SparseDelta::new(alloc::vec![2usize], alloc::vec![1.0 as Precision]).unwrap();
let top = contrastive_solve_on_change_sublinear(
&a,
&prev_solution,
&b_new,
&delta,
4,
32,
1e-10,
3,
)
.unwrap();
assert_eq!(top.len(), 3, "expected k=3 results");
let contains_row_2 = top.iter().any(|r| r.row == 2);
assert!(
contains_row_2,
"top-3 should include the perturbed row 2; got: {:?}",
top
);
for w in top.windows(2) {
assert!(w[0].anomaly >= w[1].anomaly);
}
}
}