Skip to main content

sublinear_solver/
contrastive.rs

1//! Contrastive search — find the rows whose solution diverged most from a
2//! baseline. ADR-001 roadmap item #6.
3//!
4//! The architectural shape RuView, Cognitum, and Ruflo's inner loops
5//! actually want: not "give me the whole solution vector", but "tell me
6//! which entries crossed a boundary big enough to wake an agent". This
7//! is the change-driven activation primitive the ADR's thesis turns on.
8//!
9//! ## API
10//!
11//! ```rust,no_run
12//! # use sublinear_solver::contrastive::{find_anomalous_rows, AnomalyRow};
13//! # let baseline: Vec<f64> = vec![];
14//! # let current: Vec<f64> = vec![];
15//! let top_k = find_anomalous_rows(&baseline, &current, 5);
16//! for AnomalyRow { row, baseline, current, anomaly } in top_k {
17//!     println!("row {row}: was {baseline}, now {current} (Δ={anomaly})");
18//! }
19//! ```
20//!
21//! ## Complexity
22//!
23//! The current implementation is `O(n log k)` — one pass over the
24//! solution vectors with a `k`-sized min-heap. That's already useful
25//! (avoids `O(n log n)` of a full sort) but not yet the `O(k · log n)`
26//! the ADR promised. The follow-up will land a *direct* path that
27//! computes only the top-k entries of the new solution via the sublinear-
28//! Neumann single-entry primitive, without ever materialising the full
29//! current solution. Tracked as a `// TODO(ADR-001 #6 phase 2):` in the
30//! source.
31
32use crate::complexity::{Complexity, ComplexityClass};
33use crate::types::Precision;
34use alloc::collections::BinaryHeap;
35use alloc::vec::Vec;
36use core::cmp::Ordering;
37
38/// One row's anomaly report.
39#[derive(Debug, Clone, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct AnomalyRow {
42    /// Row index in the solution vector.
43    pub row: usize,
44    /// The baseline value at this row.
45    pub baseline: Precision,
46    /// The current value at this row.
47    pub current: Precision,
48    /// `|current - baseline|`. The score used for ranking. Higher = more
49    /// anomalous.
50    pub anomaly: Precision,
51}
52
53// Min-heap helper: we want to keep the k *largest* anomalies, so we use a
54// max-of-min wrapper that orders by inverted anomaly score (smallest at the
55// top), and evict the smallest whenever a new entry beats it.
56#[derive(Debug, Clone)]
57struct MinHeapEntry(AnomalyRow);
58
59impl PartialEq for MinHeapEntry {
60    fn eq(&self, other: &Self) -> bool {
61        self.0.anomaly == other.0.anomaly && self.0.row == other.0.row
62    }
63}
64impl Eq for MinHeapEntry {}
65impl PartialOrd for MinHeapEntry {
66    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
67        Some(self.cmp(other))
68    }
69}
70impl Ord for MinHeapEntry {
71    fn cmp(&self, other: &Self) -> Ordering {
72        // Invert so BinaryHeap (max-heap) acts as a min-heap on anomaly.
73        // Tie-break on row index ascending so the API is deterministic
74        // (same inputs always yield the same top-k ordering).
75        other
76            .0
77            .anomaly
78            .partial_cmp(&self.0.anomaly)
79            .unwrap_or(Ordering::Equal)
80            .then_with(|| other.0.row.cmp(&self.0.row))
81    }
82}
83
84/// Return the `k` rows whose `current` value diverged most from `baseline`.
85///
86/// Result is sorted by descending anomaly score (largest first). Ties are
87/// broken by row index ascending so the API is deterministic.
88///
89/// - `O(n log k)` time, `O(k)` space.
90/// - If `k >= baseline.len()`, returns *all* rows sorted by anomaly.
91/// - If `k == 0`, returns an empty vector.
92///
93/// Panics if `baseline.len() != current.len()`.
94pub fn find_anomalous_rows(
95    baseline: &[Precision],
96    current: &[Precision],
97    k: usize,
98) -> Vec<AnomalyRow> {
99    assert_eq!(
100        baseline.len(),
101        current.len(),
102        "find_anomalous_rows: baseline.len()={} != current.len()={}",
103        baseline.len(),
104        current.len(),
105    );
106
107    if k == 0 || baseline.is_empty() {
108        return Vec::new();
109    }
110
111    // TODO(ADR-001 #6 phase 2): replace the O(n) full scan with a direct
112    // top-k path that computes individual entries of `current` via the
113    // sublinear-Neumann single-entry primitive, giving O(k · log n)
114    // total. Today this is the cheap O(n log k) baseline so RuView /
115    // Cognitum have something callable while phase 2 lands.
116
117    let mut heap: BinaryHeap<MinHeapEntry> = BinaryHeap::with_capacity(k.min(baseline.len()) + 1);
118    for (row, (&b, &c)) in baseline.iter().zip(current.iter()).enumerate() {
119        let anomaly = (c - b).abs();
120        let entry = MinHeapEntry(AnomalyRow {
121            row,
122            baseline: b,
123            current: c,
124            anomaly,
125        });
126
127        if heap.len() < k {
128            heap.push(entry);
129        } else if let Some(smallest) = heap.peek() {
130            // Smallest is at the top because of the inverted Ord.
131            if anomaly > smallest.0.anomaly {
132                heap.pop();
133                heap.push(entry);
134            }
135        }
136    }
137
138    // Drain into a sorted-descending vector.
139    let mut out: Vec<AnomalyRow> = heap.into_iter().map(|e| e.0).collect();
140    out.sort_by(|a, b| {
141        b.anomaly
142            .partial_cmp(&a.anomaly)
143            .unwrap_or(Ordering::Equal)
144            .then_with(|| a.row.cmp(&b.row))
145    });
146    out
147}
148
149/// Top-k variant constrained to a caller-supplied **candidate set**. Skips
150/// the rest of the solution vector entirely.
151///
152/// This is the building block for the genuinely sub-linear contrastive
153/// recipe outlined in [ADR-001 §Roadmap item #6 phase-2]. The caller
154/// computes the candidate set once — typically the rows reachable from
155/// the support of a sparse RHS delta in a few hops of `A` — and passes
156/// it here. Cost is `O(|candidates| log k)` instead of `O(n log k)`,
157/// so when `|candidates| ≪ n` the call drops to true sub-linear in n.
158///
159/// Composes with `incremental::SparseDelta` and a per-entry solver like
160/// `SublinearNeumannSolver`:
161///
162/// ```text
163///   1. candidates = closure(delta.indices, A, depth)
164///   2. current[i] = sublinear_neumann.solve_single_entry(A, b_new, i)
165///                                                 for i in candidates
166///   3. top_k    = find_anomalous_rows_in_subset(baseline, current,
167///                                                candidates, k)
168/// ```
169///
170/// `current` must be a length-`n` vector that has the correct values at
171/// the candidate indices; entries at non-candidate indices are ignored.
172/// (We don't require sparsity — callers can pass a dense vector with
173/// stale values everywhere except the candidates.)
174///
175/// Result is sorted by descending anomaly score; ties broken by row
176/// index ascending. Returns an empty vec if `k == 0` or `candidates` is
177/// empty. Panics on `baseline.len() != current.len()`.
178pub fn find_anomalous_rows_in_subset(
179    baseline: &[Precision],
180    current: &[Precision],
181    candidates: &[usize],
182    k: usize,
183) -> Vec<AnomalyRow> {
184    assert_eq!(
185        baseline.len(),
186        current.len(),
187        "find_anomalous_rows_in_subset: dim mismatch {} vs {}",
188        baseline.len(),
189        current.len(),
190    );
191
192    if k == 0 || candidates.is_empty() || baseline.is_empty() {
193        return Vec::new();
194    }
195
196    let mut heap: BinaryHeap<MinHeapEntry> = BinaryHeap::with_capacity(k.min(candidates.len()) + 1);
197
198    for &row in candidates {
199        // Skip out-of-bounds indices silently — caller may supply a
200        // closure that overshoots the matrix dimension (e.g. via a
201        // wrap-around graph traversal). Treating it as a no-op is more
202        // useful than panicking.
203        if row >= baseline.len() {
204            continue;
205        }
206        let anomaly = (current[row] - baseline[row]).abs();
207        let entry = MinHeapEntry(AnomalyRow {
208            row,
209            baseline: baseline[row],
210            current: current[row],
211            anomaly,
212        });
213        if heap.len() < k {
214            heap.push(entry);
215        } else if let Some(smallest) = heap.peek() {
216            if anomaly > smallest.0.anomaly {
217                heap.pop();
218                heap.push(entry);
219            }
220        }
221    }
222
223    let mut out: Vec<AnomalyRow> = heap.into_iter().map(|e| e.0).collect();
224    out.sort_by(|a, b| {
225        b.anomaly
226            .partial_cmp(&a.anomaly)
227            .unwrap_or(Ordering::Equal)
228            .then_with(|| a.row.cmp(&b.row))
229    });
230    out
231}
232
233/// Return only the rows whose anomaly score exceeds `threshold`. Useful as
234/// the boundary-crossing primitive for change-driven activation: an agent
235/// stays asleep until at least one entry crosses the threshold.
236///
237/// - `O(n)` time, `O(matches)` space.
238///
239/// Panics if `baseline.len() != current.len()`.
240pub fn find_rows_above_threshold(
241    baseline: &[Precision],
242    current: &[Precision],
243    threshold: Precision,
244) -> Vec<AnomalyRow> {
245    assert_eq!(
246        baseline.len(),
247        current.len(),
248        "find_rows_above_threshold: dim mismatch {} vs {}",
249        baseline.len(),
250        current.len(),
251    );
252
253    baseline
254        .iter()
255        .zip(current.iter())
256        .enumerate()
257        .filter_map(|(row, (&b, &c))| {
258            let anomaly = (c - b).abs();
259            if anomaly > threshold {
260                Some(AnomalyRow {
261                    row,
262                    baseline: b,
263                    current: c,
264                    anomaly,
265                })
266            } else {
267                None
268            }
269        })
270        .collect()
271}
272
273// ─────────────────────────────────────────────────────────────────────────
274// Complexity declaration. The current path is Linear; phase-2 will drop
275// to SubLinear (O(k · log n)). Declared as Adaptive { Linear, Linear } for
276// now so the contract is honest about today's bound.
277// ─────────────────────────────────────────────────────────────────────────
278
279/// Marker type with a `Complexity` impl for `find_anomalous_rows`.
280pub struct FindAnomalousRowsOp;
281
282impl Complexity for FindAnomalousRowsOp {
283    const CLASS: ComplexityClass = ComplexityClass::Adaptive {
284        default: &ComplexityClass::Linear,
285        worst: &ComplexityClass::Linear,
286    };
287    const DETAIL: &'static str =
288        "O(n log k) full-scan baseline today; phase-2 lowers to O(k · log n) via the \
289         sublinear-Neumann single-entry primitive.";
290}
291
292// ─────────────────────────────────────────────────────────────────────────
293// Phase-2A orchestrator: closure → solve_on_change → top-k-in-subset.
294// ─────────────────────────────────────────────────────────────────────────
295
296/// One-shot contrastive solve: given a previous solution + a sparse RHS
297/// delta, return the top-`k` rows whose value diverged most under the
298/// new RHS, **without scanning the full solution vector**.
299///
300/// This is the composition the ADR-001 thesis turns on: the inner-loop
301/// version of "solve under change", restricted to the rows that *could*
302/// have changed.
303///
304/// ## Wiring
305///
306/// 1. `candidates = closure::closure_indices(matrix, &delta.indices, depth)`
307///    — bounded-depth row-graph closure of the delta's support. For DD
308///    matrices with spectral radius ρ, choose `depth ≈ log_{1/ρ}(1/ε)`
309///    so the closure covers every row whose true change exceeds ε.
310/// 2. `current = solver.solve_on_change(matrix, prev, delta, opts)?`
311///    — warm-started solve produces the new solution (today: the full
312///    vector). Phase-2B will replace this with per-entry sublinear-
313///    Neumann calls scoped to `candidates`, dropping the inner cost
314///    from `O(n · κ-iters)` to `O(|candidates| · log(1/ε))`.
315/// 3. `top_k = find_anomalous_rows_in_subset(prev, current, candidates, k)`
316///    — top-k restricted to the candidate set.
317///
318/// ## Complexity
319///
320/// Today (phase-2A):
321///   * closure:      O(depth · branch · |closure|)         SubLinear in n
322///   * inner solve:  O(n · κ-iters · ‖delta‖_residual)      Linear in n
323///   * top-k:        O(|candidates| · log k)               SubLinear in n
324///
325/// Net: bounded by the inner solve at Linear. The closure already pays
326/// off when *callers reuse it across multiple deltas of the same shape*
327/// (RuView pipelines do exactly this), and is the API contract that
328/// phase-2B then drops to the SubLinear target.
329///
330/// Phase-2B (planned):
331///   * closure:      unchanged
332///   * inner solve:  O(|candidates| · log(1/ε))           SubLinear in n
333///   * top-k:        unchanged
334///   * Net:          SubLinear in n end-to-end.
335///
336/// ## Errors
337///
338/// Returns `SolverError` from the inner `solve_on_change` call (e.g.
339/// `Incoherent`, `Convergence`, `DimensionMismatch`). Panics only on
340/// caller bug — `prev_solution.len() != matrix.rows()`.
341///
342/// # Examples
343///
344/// ```rust,no_run
345/// # use sublinear_solver::{Matrix, SparseDelta, SolverOptions, AnomalyRow};
346/// # use sublinear_solver::contrastive::contrastive_solve_on_change;
347/// # use sublinear_solver::NeumannSolver;
348/// # fn demo(a: &dyn Matrix, prev: &[f64], delta: &SparseDelta) {
349/// let solver = NeumannSolver::new(64, 1e-10);
350/// let opts = SolverOptions::default();
351/// let top = contrastive_solve_on_change(
352///     &solver, a, prev, delta,
353///     /* depth = */ 4,
354///     /* k     = */ 8,
355///     &opts,
356/// ).unwrap();
357/// for AnomalyRow { row, baseline, current, anomaly } in top {
358///     // wake an agent for `row`
359/// }
360/// # }
361/// ```
362pub fn contrastive_solve_on_change<S>(
363    solver: &S,
364    matrix: &dyn crate::matrix::Matrix,
365    prev_solution: &[Precision],
366    delta: &crate::incremental::SparseDelta,
367    depth: usize,
368    k: usize,
369    options: &crate::solver::SolverOptions,
370) -> crate::error::Result<Vec<AnomalyRow>>
371where
372    S: crate::incremental::IncrementalSolver + ?Sized,
373{
374    // (1) Closure: which rows might have changed?
375    let candidates = crate::closure::closure_indices(matrix, &delta.indices, depth);
376    if candidates.is_empty() || k == 0 {
377        return Ok(Vec::new());
378    }
379
380    // (2) Inner solve. Today the warm-started incremental path; phase-2B
381    //     will swap this for per-entry sublinear-Neumann scoped to
382    //     `candidates`.
383    let result = solver.solve_on_change(matrix, prev_solution, delta, options)?;
384
385    // (3) Top-k restricted to the candidate set. Skip the rest of the
386    //     vector entirely.
387    Ok(find_anomalous_rows_in_subset(
388        prev_solution,
389        &result.solution,
390        &candidates,
391        k,
392    ))
393}
394
395/// SubLinear sibling of [`contrastive_solve_on_change`]: skips the inner
396/// `solve_on_change` and uses per-entry sublinear Neumann queries scoped
397/// to the closure of the delta's support. End-to-end `SubLinear` in `n`
398/// for sparse DD matrices with bounded depth — the phase-2B realisation
399/// of the ADR-001 contract.
400///
401/// ## Wiring
402///
403/// 1. `b_new = b_prev + delta` is implicit: callers pass `b_new` directly.
404/// 2. `candidates = closure::closure_indices(matrix, &delta.indices, depth)`
405///    — same bounded-depth closure as the phase-2A orchestrator.
406/// 3. For each `i ∈ candidates`:
407///        `current[i] = entry::solve_single_entry_neumann(matrix, b_new, i, max_terms, tolerance)`
408///    Never materialises the full new-solution vector.
409/// 4. `top_k = find_anomalous_rows_in_subset(prev, current_dense, candidates, k)`
410///    where `current_dense` carries the per-entry estimates at the
411///    candidate indices and stale values everywhere else.
412///
413/// ## Complexity
414///
415///   * closure:       O(depth · branch · |closure|)              SubLinear
416///   * per-entry:     O(|closure| · max_terms · |closure_max| · branch)
417///                                                                SubLinear
418///   * top-k subset:  O(|candidates| · log k)                     SubLinear
419///
420/// Net: **SubLinear in `n`** when the closure is bounded.
421///
422/// ## When to choose this over [`contrastive_solve_on_change`]
423///
424/// - **Use this** when callers have a *spectral-radius bound* `ρ < 1`
425///   handy and can pick `max_terms ≈ log_{1/ρ}(1/ε)` confidently. The
426///   closure shrinks to `≪ n` and the SubLinear advantage materialises.
427/// - **Use phase-2A** when the matrix is harder to bound and a
428///   warm-started full solve is cheaper than tuning the Neumann depth.
429pub fn contrastive_solve_on_change_sublinear(
430    matrix: &dyn crate::matrix::Matrix,
431    prev_solution: &[Precision],
432    b_new: &[Precision],
433    delta: &crate::incremental::SparseDelta,
434    closure_depth: usize,
435    max_terms: usize,
436    tolerance: Precision,
437    k: usize,
438) -> crate::error::Result<Vec<AnomalyRow>> {
439    // (1) Closure: which rows might have changed?
440    let candidates = crate::closure::closure_indices(matrix, &delta.indices, closure_depth);
441    if candidates.is_empty() || k == 0 {
442        return Ok(Vec::new());
443    }
444
445    // (2) Per-entry sublinear Neumann at each candidate index. We never
446    //     touch the full `n`-sized solution vector — only `|candidates|`
447    //     scalars are computed.
448    let entries = crate::entry::solve_single_entries_neumann(
449        matrix,
450        b_new,
451        &candidates,
452        max_terms,
453        tolerance,
454    )?;
455
456    // (3) Materialise a dense `current` vector with stale values
457    //     everywhere except the candidate indices. `find_anomalous_rows_in_subset`
458    //     reads only the candidate rows, so the stale values are never
459    //     observed.
460    let n = matrix.rows();
461    let mut current = alloc::vec![0.0 as Precision; n];
462    for &(i, v) in &entries {
463        if i < n {
464            current[i] = v;
465        }
466    }
467
468    // (4) Top-k restricted to the candidate set.
469    Ok(find_anomalous_rows_in_subset(
470        prev_solution,
471        &current,
472        &candidates,
473        k,
474    ))
475}
476
477/// Magic-number-free sibling of [`contrastive_solve_on_change_sublinear`].
478/// Takes only `(matrix, prev, b_new, delta, tolerance, k)` and auto-tunes
479/// both `closure_depth` and `max_terms` from the matrix's coherence via
480/// [`crate::coherence::optimal_neumann_terms`].
481///
482/// Mirrors [`crate::incremental::solve_on_change_sublinear_auto`] for the
483/// contrastive top-k path. Caller's contract collapses to: *"here's
484/// tolerance and k, give me back the top-k anomalies."*
485///
486/// ## Errors
487///
488/// - [`crate::error::SolverError::Incoherent`] on non-strict-DD input
489///   (the auto-tune relies on the coherence margin envelope).
490pub fn contrastive_solve_on_change_sublinear_auto(
491    matrix: &dyn crate::matrix::Matrix,
492    prev_solution: &[Precision],
493    b_new: &[Precision],
494    delta: &crate::incremental::SparseDelta,
495    tolerance: Precision,
496    k: usize,
497) -> crate::error::Result<Vec<AnomalyRow>> {
498    if delta.is_empty() || k == 0 {
499        return Ok(Vec::new());
500    }
501
502    let coherence = crate::coherence::coherence_score(matrix);
503    let min_diag = (0..matrix.rows())
504        .map(|i| matrix.get(i, i).unwrap_or(0.0).abs())
505        .filter(|x| *x > 0.0)
506        .fold(Precision::INFINITY, |a, b| if a < b { a } else { b });
507
508    if !coherence.is_finite() || coherence <= 0.0 {
509        return Err(crate::error::SolverError::Incoherent {
510            coherence,
511            threshold: 1e-12,
512        });
513    }
514
515    let b_inf = b_new
516        .iter()
517        .map(|x| x.abs())
518        .fold(0.0_f64, |a, b| if a > b { a } else { b });
519
520    let auto_terms = crate::coherence::optimal_neumann_terms(coherence, b_inf, min_diag, tolerance)
521        .unwrap_or(32);
522
523    contrastive_solve_on_change_sublinear(
524        matrix,
525        prev_solution,
526        b_new,
527        delta,
528        /*closure_depth=*/ auto_terms,
529        /*max_terms=*/ auto_terms,
530        tolerance,
531        k,
532    )
533}
534
535/// Tightest-bound contrastive sibling: takes a caller-supplied
536/// spectral-radius `rho` (e.g. from
537/// [`crate::coherence::approximate_spectral_radius`]) and uses it
538/// for tighter Neumann-depth tuning than the loose `(1 - coherence)`
539/// bound. See [`crate::incremental::solve_on_change_sublinear_auto_with_rho`]
540/// for the non-contrastive sibling.
541pub fn contrastive_solve_on_change_sublinear_auto_with_rho(
542    matrix: &dyn crate::matrix::Matrix,
543    prev_solution: &[Precision],
544    b_new: &[Precision],
545    delta: &crate::incremental::SparseDelta,
546    tolerance: Precision,
547    k: usize,
548    rho: Precision,
549) -> crate::error::Result<Vec<AnomalyRow>> {
550    if delta.is_empty() || k == 0 {
551        return Ok(Vec::new());
552    }
553    if !rho.is_finite() || rho <= 0.0 || rho >= 1.0 {
554        return Err(crate::error::SolverError::InvalidInput {
555            message: alloc::format!(
556                "contrastive_solve_on_change_sublinear_auto_with_rho: rho={} must lie in (0, 1)",
557                rho
558            ),
559            parameter: Some(alloc::string::String::from("rho")),
560        });
561    }
562
563    let min_diag = (0..matrix.rows())
564        .map(|i| matrix.get(i, i).unwrap_or(0.0).abs())
565        .filter(|x| *x > 0.0)
566        .fold(Precision::INFINITY, |a, b| if a < b { a } else { b });
567    if !min_diag.is_finite() || min_diag <= 0.0 {
568        return Err(crate::error::SolverError::InvalidInput {
569            message: alloc::string::String::from(
570                "contrastive_solve_on_change_sublinear_auto_with_rho: non-positive min_diag",
571            ),
572            parameter: Some(alloc::string::String::from("matrix")),
573        });
574    }
575
576    let b_inf = b_new
577        .iter()
578        .map(|x| x.abs())
579        .fold(0.0_f64, |a, b| if a > b { a } else { b });
580
581    let auto_terms =
582        crate::coherence::optimal_neumann_terms_with_rho(rho, b_inf, min_diag, tolerance)
583            .unwrap_or(32);
584
585    contrastive_solve_on_change_sublinear(
586        matrix,
587        prev_solution,
588        b_new,
589        delta,
590        /*closure_depth=*/ auto_terms,
591        /*max_terms=*/ auto_terms,
592        tolerance,
593        k,
594    )
595}
596
597/// Op marker for the SubLinear orchestrator variant.
598pub struct ContrastiveSolveOnChangeSublinearOp;
599
600impl Complexity for ContrastiveSolveOnChangeSublinearOp {
601    const CLASS: ComplexityClass = ComplexityClass::SubLinear;
602    const DETAIL: &'static str =
603        "Phase-2B: closure (SubLinear) + per-entry sublinear-Neumann (SubLinear) + top-k-in-subset \
604         (SubLinear). End-to-end SubLinear in n for sparse DD matrices with bounded depth.";
605}
606
607/// Marker type with a `Complexity` impl for `contrastive_solve_on_change`.
608///
609/// The phase-2A orchestrator's worst-case bound is dominated by the inner
610/// `solve_on_change` call (Linear). For the SubLinear path use
611/// [`contrastive_solve_on_change_sublinear`] +
612/// [`ContrastiveSolveOnChangeSublinearOp`].
613pub struct ContrastiveSolveOnChangeOp;
614
615impl Complexity for ContrastiveSolveOnChangeOp {
616    const CLASS: ComplexityClass = ComplexityClass::Adaptive {
617        default: &ComplexityClass::Linear,
618        worst: &ComplexityClass::Linear,
619    };
620    const DETAIL: &'static str =
621        "Phase-2A: closure (SubLinear) + warm-start solve (Linear) + top-k-in-subset \
622         (SubLinear). Phase-2B replaces the inner solve with per-entry sublinear-Neumann \
623         queries scoped to the closure, dropping the orchestrator end-to-end to SubLinear.";
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    #[test]
631    fn empty_inputs_return_empty() {
632        let v: Vec<Precision> = alloc::vec![];
633        assert_eq!(find_anomalous_rows(&v, &v, 5), alloc::vec![]);
634        assert_eq!(find_anomalous_rows(&v, &v, 0), alloc::vec![]);
635    }
636
637    #[test]
638    fn k_zero_returns_empty() {
639        let b = alloc::vec![1.0, 2.0, 3.0];
640        let c = alloc::vec![10.0, 20.0, 30.0];
641        assert_eq!(find_anomalous_rows(&b, &c, 0), alloc::vec![]);
642    }
643
644    #[test]
645    fn top_k_is_correct_for_small_case() {
646        let b = alloc::vec![1.0, 1.0, 1.0, 1.0, 1.0];
647        let c = alloc::vec![1.0, 5.0, 1.0, 9.0, 2.0];
648        // anomalies: 0, 4, 0, 8, 1 — sorted desc: 8 (row 3), 4 (row 1), 1 (row 4).
649        let top = find_anomalous_rows(&b, &c, 3);
650        assert_eq!(top.len(), 3);
651        assert_eq!(top[0].row, 3);
652        assert_eq!(top[0].anomaly, 8.0);
653        assert_eq!(top[1].row, 1);
654        assert_eq!(top[1].anomaly, 4.0);
655        assert_eq!(top[2].row, 4);
656        assert_eq!(top[2].anomaly, 1.0);
657    }
658
659    #[test]
660    fn k_larger_than_n_returns_all_sorted() {
661        let b = alloc::vec![0.0, 0.0, 0.0];
662        let c = alloc::vec![3.0, 1.0, 2.0];
663        let top = find_anomalous_rows(&b, &c, 10);
664        assert_eq!(top.len(), 3);
665        // Sorted desc by anomaly.
666        assert!(top[0].anomaly >= top[1].anomaly);
667        assert!(top[1].anomaly >= top[2].anomaly);
668    }
669
670    #[test]
671    fn tie_breaks_on_row_index_ascending() {
672        let b = alloc::vec![0.0, 0.0, 0.0];
673        let c = alloc::vec![5.0, 5.0, 5.0]; // all tied
674        let top = find_anomalous_rows(&b, &c, 2);
675        assert_eq!(top.len(), 2);
676        assert_eq!(top[0].row, 0);
677        assert_eq!(top[1].row, 1);
678    }
679
680    #[test]
681    fn anomaly_is_absolute_value() {
682        let b = alloc::vec![0.0, 10.0];
683        let c = alloc::vec![-7.0, 3.0];
684        // anomalies: 7, 7 — both tied. Tie-break: row 0 before row 1.
685        let top = find_anomalous_rows(&b, &c, 2);
686        assert_eq!(top[0].anomaly, 7.0);
687        assert_eq!(top[1].anomaly, 7.0);
688        assert_eq!(top[0].row, 0);
689    }
690
691    #[test]
692    #[should_panic(expected = "dim mismatch")]
693    fn threshold_panics_on_dim_mismatch() {
694        let b = alloc::vec![1.0, 2.0];
695        let c = alloc::vec![1.0];
696        let _ = find_rows_above_threshold(&b, &c, 0.5);
697    }
698
699    #[test]
700    fn threshold_filters_correctly() {
701        let b = alloc::vec![0.0, 0.0, 0.0, 0.0];
702        let c = alloc::vec![0.1, 0.5, 2.0, 0.05];
703        let above = find_rows_above_threshold(&b, &c, 0.3);
704        // 0.5 and 2.0 pass; 0.1 and 0.05 don't.
705        assert_eq!(above.len(), 2);
706        assert_eq!(above[0].row, 1);
707        assert_eq!(above[1].row, 2);
708    }
709
710    #[test]
711    fn threshold_returns_empty_when_nothing_crosses() {
712        let b = alloc::vec![0.0; 5];
713        let c = alloc::vec![0.01, 0.02, 0.03, 0.04, 0.05];
714        let above = find_rows_above_threshold(&b, &c, 1.0);
715        assert!(
716            above.is_empty(),
717            "no entry above threshold should return empty"
718        );
719    }
720
721    // ─────────────────────────────────────────────────────────────────
722    // find_anomalous_rows_in_subset (ADR-001 #6 phase-2)
723    // ─────────────────────────────────────────────────────────────────
724
725    #[test]
726    fn subset_returns_only_candidates() {
727        let baseline = alloc::vec![0.0; 10];
728        let mut current = alloc::vec![0.0; 10];
729        // Put a huge anomaly at row 7 that ISN'T in the candidate set —
730        // we expect it to be ignored.
731        current[7] = 999.0;
732        // Real candidates with smaller anomalies.
733        current[2] = 5.0;
734        current[5] = 3.0;
735        let candidates = alloc::vec![2usize, 5];
736        let top = find_anomalous_rows_in_subset(&baseline, &current, &candidates, 5);
737        assert_eq!(top.len(), 2);
738        assert_eq!(top[0].row, 2);
739        assert_eq!(top[0].anomaly, 5.0);
740        assert_eq!(top[1].row, 5);
741        assert_eq!(top[1].anomaly, 3.0);
742        // 999.0 at row 7 is OUTSIDE the candidates — must not appear.
743        assert!(top.iter().all(|r| r.row != 7));
744    }
745
746    #[test]
747    fn subset_k_limit_works() {
748        let baseline = alloc::vec![0.0; 100];
749        let mut current = alloc::vec![0.0; 100];
750        for &i in &[10, 20, 30, 40, 50] {
751            current[i] = i as Precision;
752        }
753        let candidates = alloc::vec![10usize, 20, 30, 40, 50];
754        // Ask for top-3; should get the 3 biggest anomalies (rows 50, 40, 30).
755        let top = find_anomalous_rows_in_subset(&baseline, &current, &candidates, 3);
756        assert_eq!(top.len(), 3);
757        assert_eq!(top[0].row, 50);
758        assert_eq!(top[1].row, 40);
759        assert_eq!(top[2].row, 30);
760    }
761
762    #[test]
763    fn subset_ignores_out_of_bounds_indices() {
764        let baseline = alloc::vec![0.0; 5];
765        let mut current = alloc::vec![0.0; 5];
766        current[2] = 10.0;
767        // Caller's candidate closure overshoots — out-of-bound indices
768        // must be skipped silently, not panic.
769        let candidates = alloc::vec![2usize, 100, 200];
770        let top = find_anomalous_rows_in_subset(&baseline, &current, &candidates, 5);
771        assert_eq!(top.len(), 1);
772        assert_eq!(top[0].row, 2);
773    }
774
775    #[test]
776    fn subset_empty_candidates_returns_empty() {
777        let baseline = alloc::vec![0.0; 5];
778        let current = alloc::vec![1.0, 2.0, 3.0, 4.0, 5.0];
779        let candidates: Vec<usize> = alloc::vec![];
780        let top = find_anomalous_rows_in_subset(&baseline, &current, &candidates, 10);
781        assert!(top.is_empty());
782    }
783
784    #[test]
785    fn subset_matches_full_scan_when_candidates_cover_all_rows() {
786        // Sanity check: when the candidate set IS the full row range,
787        // the result should match find_anomalous_rows.
788        let baseline = alloc::vec![0.0; 8];
789        let current = alloc::vec![1.0, 5.0, 1.0, 9.0, 2.0, 7.0, 3.0, 6.0];
790        let full = find_anomalous_rows(&baseline, &current, 4);
791        let candidates: Vec<usize> = (0..8).collect();
792        let subset = find_anomalous_rows_in_subset(&baseline, &current, &candidates, 4);
793        assert_eq!(full, subset);
794    }
795
796    // ── Phase-2A orchestrator tests ──────────────────────────────────
797
798    #[test]
799    fn orchestrator_op_complexity_class_compile_time() {
800        // The op marker must declare the staged Adaptive { Linear, Linear }
801        // class so callers can budget against the worst case.
802        const _: () = assert!(matches!(
803            <ContrastiveSolveOnChangeOp as Complexity>::CLASS,
804            ComplexityClass::Adaptive { .. }
805        ));
806    }
807
808    #[test]
809    fn orchestrator_op_detail_mentions_phase_2b() {
810        // Docs contract: the DETAIL string must call out the phase-2B
811        // SubLinear target so future readers know this is staged work,
812        // not the terminal bound.
813        let detail = <ContrastiveSolveOnChangeOp as Complexity>::DETAIL;
814        assert!(detail.contains("Phase-2A"));
815        assert!(detail.contains("Phase-2B"));
816        assert!(detail.contains("SubLinear"));
817    }
818
819    #[test]
820    fn orchestrator_with_empty_delta_returns_empty_top_k() {
821        // Empty delta → closure is empty → top-k is empty without ever
822        // touching the solver. This is the "no event, no work" path —
823        // the core gating discipline of the ADR.
824        use crate::incremental::SparseDelta;
825        use crate::matrix::SparseMatrix;
826        use crate::solver::neumann::NeumannSolver;
827        use crate::solver::SolverOptions;
828
829        let n = 4;
830        let triplets: Vec<(usize, usize, Precision)> = (0..n).map(|i| (i, i, 2.0)).collect();
831        let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
832        let prev = alloc::vec![0.0; n];
833        let delta = SparseDelta::empty();
834
835        let solver = NeumannSolver::new(64, 1e-10);
836        let opts = SolverOptions::default();
837
838        let top = contrastive_solve_on_change(&solver, &a, &prev, &delta, 3, 5, &opts)
839            .expect("empty-delta path must succeed without invoking the inner solver");
840        assert!(top.is_empty(), "empty delta should yield empty top-k");
841    }
842
843    #[test]
844    fn orchestrator_zero_k_returns_empty_without_solving() {
845        // k = 0 is the "tell me nothing" path; must be a fast no-op
846        // before the inner solve.
847        use crate::incremental::SparseDelta;
848        use crate::matrix::SparseMatrix;
849        use crate::solver::neumann::NeumannSolver;
850        use crate::solver::SolverOptions;
851
852        let n = 4;
853        let triplets: Vec<(usize, usize, Precision)> = (0..n).map(|i| (i, i, 2.0)).collect();
854        let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
855        let prev = alloc::vec![0.0; n];
856        let delta = SparseDelta::new(alloc::vec![1], alloc::vec![1.0]).unwrap();
857
858        let solver = NeumannSolver::new(64, 1e-10);
859        let opts = SolverOptions::default();
860
861        let top = contrastive_solve_on_change(&solver, &a, &prev, &delta, 3, 0, &opts).unwrap();
862        assert!(top.is_empty());
863    }
864
865    // ── Phase-2B SubLinear orchestrator tests ─────────────────────────
866
867    #[test]
868    fn sublinear_orchestrator_op_is_sublinear() {
869        // The phase-2B op marker MUST declare end-to-end SubLinear —
870        // that's the entire promise of this code path.
871        const _: () = assert!(matches!(
872            <ContrastiveSolveOnChangeSublinearOp as Complexity>::CLASS,
873            ComplexityClass::SubLinear
874        ));
875        assert!(<ContrastiveSolveOnChangeSublinearOp as Complexity>::DETAIL.contains("Phase-2B"));
876    }
877
878    #[test]
879    fn sublinear_orchestrator_empty_delta_returns_empty() {
880        use crate::incremental::SparseDelta;
881        use crate::matrix::SparseMatrix;
882
883        let n = 4;
884        let triplets: Vec<(usize, usize, Precision)> = (0..n).map(|i| (i, i, 2.0)).collect();
885        let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
886        let prev = alloc::vec![0.0; n];
887        let b_new = alloc::vec![1.0; n];
888        let delta = SparseDelta::empty();
889        let top = contrastive_solve_on_change_sublinear(&a, &prev, &b_new, &delta, 3, 16, 1e-10, 5)
890            .unwrap();
891        assert!(top.is_empty());
892    }
893
894    #[test]
895    fn sublinear_orchestrator_finds_changed_rows_on_chain() {
896        // Build a strict-DD chain. Perturb b[2]; the rows whose solution
897        // entries change most should be in a neighbourhood of row 2.
898        use crate::incremental::SparseDelta;
899        use crate::matrix::SparseMatrix;
900        use crate::solver::neumann::NeumannSolver;
901        use crate::solver::{SolverAlgorithm, SolverOptions};
902
903        let n = 8;
904        let mut triplets = Vec::new();
905        for i in 0..n {
906            triplets.push((i, i, 4.0 as Precision));
907            if i + 1 < n {
908                triplets.push((i, i + 1, -1.0 as Precision));
909                triplets.push((i + 1, i, -1.0 as Precision));
910            }
911        }
912        let a = SparseMatrix::from_triplets(triplets, n, n).unwrap();
913        let b_prev = alloc::vec![1.0 as Precision; n];
914
915        // Compute the "true" previous solution with the full solver so
916        // the test's baseline matches the matrix's actual A⁻¹·b_prev.
917        let full = NeumannSolver::new(64, 1e-12);
918        let opts = SolverOptions::default();
919        let prev_solution = full.solve(&a, &b_prev, &opts).unwrap().solution;
920
921        // Perturb b[2] by +1.0 → row 2 is the change epicentre.
922        let mut b_new = b_prev.clone();
923        b_new[2] += 1.0;
924        let delta = SparseDelta::new(alloc::vec![2usize], alloc::vec![1.0 as Precision]).unwrap();
925
926        let top = contrastive_solve_on_change_sublinear(
927            &a,
928            &prev_solution,
929            &b_new,
930            &delta,
931            /*closure_depth=*/ 4,
932            /*max_terms=*/ 32,
933            /*tolerance=*/ 1e-10,
934            /*k=*/ 3,
935        )
936        .unwrap();
937
938        // Sanity: we got a non-empty top-k.
939        assert_eq!(top.len(), 3, "expected k=3 results");
940        // Sanity: row 2 must be in the top-3 (it's where the delta landed).
941        let contains_row_2 = top.iter().any(|r| r.row == 2);
942        assert!(
943            contains_row_2,
944            "top-3 should include the perturbed row 2; got: {:?}",
945            top
946        );
947        // Sanity: anomaly scores are ordered descending.
948        for w in top.windows(2) {
949            assert!(w[0].anomaly >= w[1].anomaly);
950        }
951    }
952}