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, ¤t, 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 ¤t,
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, ¤t, &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, ¤t, &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, ¤t, &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, ¤t, &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, ¤t, 4);
791 let candidates: Vec<usize> = (0..8).collect();
792 let subset = find_anomalous_rows_in_subset(&baseline, ¤t, &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}