Skip to main content

gam_linalg/
faer_ndarray.rs

1use dyn_stack::{MemBuffer, MemStack};
2use faer::diag::{Diag, DiagRef};
3use faer::linalg::solvers::{self, Solve};
4pub use faer::linalg::solvers::{
5    Lblt as FaerLblt, Ldlt as FaerLdlt, Llt as FaerLlt, Solve as FaerSolve,
6};
7use faer::linalg::svd::{self, ComputeSvdVectors};
8use faer::prelude::ReborrowMut;
9use faer::{Conj, Mat, MatMut, MatRef, Par, Side, Unbind, get_global_parallelism};
10use ndarray::{Array1, Array2, ArrayBase, ArrayView1, ArrayViewMut1, Data, Ix1, Ix2};
11use std::marker::PhantomData;
12use std::panic::{AssertUnwindSafe, catch_unwind};
13use std::sync::atomic::{AtomicU64, Ordering};
14use thiserror::Error;
15
16/// Apply a symmetric matrix through the crate-owned SIMD/FMA GEMV kernel.
17///
18/// Lanczos certifies the resulting Ritz residuals, so this uses the ordinary
19/// FMA kernel rather than the substantially more expensive Dot2 reduction.
20/// Keeping the implementation inside `gam-linalg` makes the library complete:
21/// callers do not acquire an undeclared process-global BLAS dependency merely
22/// by linking a Duchon basis.
23pub fn symmetric_matvec_into(
24    matrix: &Array2<f64>,
25    vector: &[f64],
26    output: &mut [f64],
27) -> Result<(), String> {
28    let n = matrix.nrows();
29    if matrix.ncols() != n || vector.len() != n || output.len() != n {
30        return Err(format!(
31            "symmetric matvec shape mismatch: matrix={:?}, vector={}, output={}",
32            matrix.dim(),
33            vector.len(),
34            output.len()
35        ));
36    }
37    fast_av_standard_view_into(
38        matrix,
39        &ArrayView1::from(vector),
40        ArrayViewMut1::from(output),
41    );
42    Ok(())
43}
44
45const RRQR_RANK_ALPHA: f64 = 100.0;
46
47thread_local! {
48    static NESTED_PARALLEL_DEPTH: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
49}
50
51struct NestedParallelGuard;
52
53impl NestedParallelGuard {
54    #[inline]
55    fn enter() -> Self {
56        NESTED_PARALLEL_DEPTH.with(|depth| depth.set(depth.get().saturating_add(1)));
57        Self
58    }
59}
60
61impl Drop for NestedParallelGuard {
62    #[inline]
63    fn drop(&mut self) {
64        NESTED_PARALLEL_DEPTH.with(|depth| depth.set(depth.get().saturating_sub(1)));
65    }
66}
67
68/// Run `body` with the current thread marked as inside a data-parallel row
69/// region, so any faer GEMM it issues (directly or transitively) pins to
70/// `Par::Seq` via [`effective_global_parallelism`] instead of re-fanning the
71/// global Rayon pool. The guard is held for exactly the duration of `body` and
72/// dropped on return — including early `?` returns from inside `body`, since the
73/// guard lives in this function's frame.
74///
75/// Call this from the per-chunk/per-row closure of an `into_par_iter` whose body
76/// performs GEMM, to prevent the Rayon-pool × faer-pool oversubscription.
77#[inline]
78pub fn with_nested_parallel<T>(body: impl FnOnce() -> T) -> T {
79    let guard = NestedParallelGuard::enter();
80    let out = body();
81    drop(guard);
82    out
83}
84
85/// `true` when the current thread is inside at least one `NestedParallelGuard`
86/// scope, i.e. a parallel row reduction is already in flight on this thread.
87#[inline]
88pub fn in_nested_parallel_region() -> bool {
89    NESTED_PARALLEL_DEPTH.with(|depth| depth.get() > 0)
90}
91
92/// #2267 — process-global census of self-adjoint eigendecompositions.
93///
94/// A per-call duration answers "was this call slow"; a running count and total
95/// answer "was the step one slow call or many", which is the question that
96/// separates #2267's two candidate explanations and which no single timing can
97/// settle. `Relaxed` is right here: these are a diagnostic census with no
98/// happens-before relationship to anything, and the values are only ever read
99/// into a log line.
100static EIGH_CALLS: AtomicU64 = AtomicU64::new(0);
101static EIGH_NANOS: AtomicU64 = AtomicU64::new(0);
102/// Of those calls, how many observed `Par::Seq`. This is the field that makes
103/// the census ASSERTABLE rather than merely observable: a test can state "the
104/// large decomposition ran sequentially" as a bar instead of a human reading it
105/// out of a log.
106static EIGH_SEQ_CALLS: AtomicU64 = AtomicU64::new(0);
107/// The largest `dim` seen, so a run can be asked whether it ever reached the
108/// shape under investigation rather than being assumed to have.
109static EIGH_MAX_DIM: AtomicU64 = AtomicU64::new(0);
110
111// The same four tallies, for the CALLING THREAD only.
112//
113// The process-global counters above answer *"how many `eigh` calls did this
114// RUN make?"*. They cannot answer *"how many did THIS REGION make?"*, because
115// every other thread's `eigh` lands inside the same window — and under
116// `cargo test` there are as many such threads as the harness chose. An exact
117// delta taken across a region of a global counter is therefore an assertion
118// about which OTHER tests happened to share the process, which is not a
119// property anybody meant to test: measured at `0033169a9`,
120// `eigh_census_counts_calls_and_separates_the_sequential_arm` read `+12`
121// instead of `+1` under the default thread count and passed under
122// `--test-threads=1`. The per-thread tallies make the delta exact under any
123// schedule, so the assertion can stay an equality instead of being weakened to
124// an inequality that no longer detects over-counting.
125thread_local! {
126    static EIGH_THREAD: std::cell::Cell<EighCensus> = const {
127        std::cell::Cell::new(EighCensus {
128            calls: 0,
129            sequential_calls: 0,
130            max_dim: 0,
131            nanos: 0,
132        })
133    };
134}
135
136/// Add one `eigh` to the calling thread's tallies.
137fn record_thread_eigh(sequential: bool, dim: u64, nanos: u64) {
138    EIGH_THREAD.with(|cell| {
139        let mut census = cell.get();
140        census.calls += 1;
141        if sequential {
142            census.sequential_calls += 1;
143        }
144        census.max_dim = census.max_dim.max(dim);
145        census.nanos += nanos;
146        cell.set(census);
147    });
148}
149
150/// #2267/#2738 — the eigendecomposition census, readable from a test.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct EighCensus {
153    /// Total `eigh` calls since process start.
154    pub calls: u64,
155    /// How many of them observed faer's global parallelism as `Par::Seq`.
156    pub sequential_calls: u64,
157    /// Largest matrix dimension decomposed.
158    pub max_dim: u64,
159    /// Cumulative wall time across all calls, in nanoseconds.
160    pub nanos: u64,
161}
162
163/// #2738 — the thread configuration actually in force, read INSIDE the running
164/// process and carried as DATA a test can read.
165///
166/// A perf experiment that sweeps a thread count needs a manipulation check: proof
167/// the treatment took effect. A sweep whose treatment silently failed produces a
168/// perfectly flat curve, which is indistinguishable from saturation and points in
169/// whichever direction the experimenter expected. So every field here is what the
170/// process OBSERVES, never what was exported: an exported variable proves an
171/// intention, not an effect. (Reading it is unavailable anyway — `env::var` is
172/// banned tree-wide.)
173///
174/// The fields are separate because they can disagree, and the disagreement is
175/// the finding:
176///
177/// * `rayon::current_num_threads()` — the pool width, the one quantity the
178///   diagnostics already printed.
179/// * [`faer::get_global_parallelism`] — faer's PROCESS-GLOBAL policy, which is
180///   what every high-level faer factorization (`self_adjoint_eigen`, `Llt::new`,
181///   `Solve::solve`, SVD, col-pivoted QR) reads internally. A live
182///   [`FaerSequentialScope`] anywhere in the process pins this to `Par::Seq` for
183///   EVERY thread, so a decomposition can be single-threaded while the rayon pool
184///   reports 64. Reporting only the pool width would show a wide machine while
185///   the numerics ran on one core, which is exactly the failure this exists to
186///   make visible.
187/// * the live [`FaerSequentialScope`] depth, which says whether that pin is a
188///   scoped decision or a global left over from an earlier phase.
189/// * the cores available to THIS PROCESS, which is not the machine's core count.
190///
191/// Deliberately NOT reported: [`effective_global_parallelism`]. That is
192/// thread-local and only governs the codebase's own `matmul` calls; it cannot
193/// reach faer's high-level entry points, so printing it beside a factorization
194/// would name a policy that did not apply to it.
195///
196/// There is no BLAS term because this workspace links no CPU BLAS — the numerics
197/// are faer and `ndarray`, both parallelised through Rayon. `OPENBLAS_NUM_THREADS`
198/// and `OMP_NUM_THREADS` are inert here (they do bind the Python lanes, where
199/// numpy and torch link OpenBLAS), so a BLAS thread count printed here would
200/// report a number that governs nothing — strictly worse than reporting none.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct ParallelismSnapshot {
203    /// Width of the Rayon pool this thread belongs to. Every parallel loop in the
204    /// workspace, and faer's own `Par::Rayon` dispatch, fan out through it.
205    pub rayon_current_num_threads: usize,
206    /// `true` when faer's process-global policy is `Par::Seq`, i.e. every
207    /// high-level faer factorization (`self_adjoint_eigen`, `Llt::new`,
208    /// `Solve::solve`, SVD, col-pivoted QR) runs on ONE core regardless of how
209    /// wide the Rayon pool above reports.
210    pub faer_global_sequential: bool,
211    /// Threads faer's global policy would ideally use ([`Par::degree`]). `1` for
212    /// `Par::Seq`, and also `1` for a one-thread `Par::rayon(1)` — which is why
213    /// `faer_global_sequential` is carried separately rather than inferred.
214    pub faer_global_degree: usize,
215    /// How many [`FaerSequentialScope`] guards are alive process-wide. Non-zero
216    /// means the sequential pin above is deliberate and scoped, not a stale
217    /// global left behind by an earlier phase — a distinction the degree alone
218    /// cannot make.
219    pub faer_sequential_scope_depth: usize,
220    /// Cores available to THIS PROCESS, not cores present in the machine:
221    /// `std::thread::available_parallelism` honours the CPU affinity mask and
222    /// the cgroup quota, so a 4-CPU Slurm allocation on a 128-core node reports
223    /// 4. `None` only when the platform refuses to answer; a fabricated fallback
224    /// would be indistinguishable from a real reading.
225    pub process_available_parallelism: Option<usize>,
226}
227
228impl ParallelismSnapshot {
229    /// Read the live configuration.
230    pub fn capture() -> Self {
231        Self::from_parts(
232            get_global_parallelism(),
233            rayon::current_num_threads(),
234            faer_sequential_scope_depth(),
235            std::thread::available_parallelism().ok().map(|n| n.get()),
236        )
237    }
238
239    /// Assemble from explicit parts. Exists so the consistency rules below can be
240    /// exercised against configurations this process is not currently in —
241    /// including inconsistent ones, which is the only way to show
242    /// [`Self::inconsistency`] is capable of returning `Some`.
243    pub fn from_parts(
244        faer_global: Par,
245        rayon_current_num_threads: usize,
246        faer_sequential_scope_depth: usize,
247        process_available_parallelism: Option<usize>,
248    ) -> Self {
249        Self {
250            rayon_current_num_threads,
251            faer_global_sequential: faer_global == Par::Seq,
252            faer_global_degree: faer_global.degree(),
253            faer_sequential_scope_depth,
254            process_available_parallelism,
255        }
256    }
257
258    /// `None` when the fields agree with each other; otherwise the first
259    /// disagreement, named.
260    ///
261    /// These are cross-checks between INDEPENDENTLY SOURCED quantities — faer's
262    /// global policy, this crate's own scope-depth counter, Rayon's pool width —
263    /// so a snapshot that passes has had its three sources agree rather than
264    /// merely restated one of them three times.
265    pub fn inconsistency(&self) -> Option<String> {
266        if self.rayon_current_num_threads == 0 {
267            return Some("rayon reports a pool of zero threads".to_string());
268        }
269        if self.faer_global_degree == 0 {
270            return Some("faer's global parallelism has degree zero".to_string());
271        }
272        if self.faer_global_sequential && self.faer_global_degree != 1 {
273            return Some(format!(
274                "faer is sequential but reports degree {}",
275                self.faer_global_degree
276            ));
277        }
278        if self.faer_sequential_scope_depth > 0 && !self.faer_global_sequential {
279            return Some(format!(
280                "{} live FaerSequentialScope guard(s) but faer's global parallelism is not sequential",
281                self.faer_sequential_scope_depth
282            ));
283        }
284        if self.process_available_parallelism == Some(0) {
285            return Some("this process reports zero available cores".to_string());
286        }
287        None
288    }
289}
290
291impl std::fmt::Display for ParallelismSnapshot {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        write!(
294            f,
295            "rayon_current_num_threads={} | faer_global_sequential={} | \
296             faer_global_degree={} | faer_sequential_scope_depth={} | \
297             process_available_parallelism={}",
298            self.rayon_current_num_threads,
299            self.faer_global_sequential,
300            self.faer_global_degree,
301            self.faer_sequential_scope_depth,
302            match self.process_available_parallelism {
303                Some(cores) => cores.to_string(),
304                None => "unavailable".to_string(),
305            },
306        )
307    }
308}
309
310/// faer parallelism policy that respects nested data-parallel regions: returns
311/// faer's global policy at the top level, but `Par::Seq` once a
312/// `NestedParallelGuard` is active so a GEMM issued from inside a parallel row
313/// fan-out does not multiply the live thread count against the outer pool.
314///
315/// Use this in place of `faer::get_global_parallelism()` for any matmul that can
316/// be reached from inside a row-parallel closure.
317#[inline]
318pub fn effective_global_parallelism() -> Par {
319    if in_nested_parallel_region() {
320        Par::Seq
321    } else {
322        get_global_parallelism()
323    }
324}
325
326/// Process-global depth counter + saved parallelism for [`FaerSequentialScope`].
327///
328/// The `effective_global_parallelism` / [`NestedParallelGuard`] pair only pins
329/// the codebase's OWN `matmul` calls to `Par::Seq`; it CANNOT reach faer's
330/// high-level factorization/solve entry points (`Llt::new`, `Solve::solve`, SVD,
331/// col-pivoted QR), which read `faer::get_global_parallelism()` internally and
332/// have no per-call parallelism argument. When such a solver runs from inside a
333/// Rayon worker (e.g. the topology race fans candidate fits into per-candidate
334/// pools via `run_topology_race_parallel`), faer's default `Par::rayon(0)`
335/// dispatches the factorization through its `spindle` barrier pool, which
336/// `rayon::scope`-spawns as many tasks as the pool has threads and waits for all
337/// of them at a barrier. Under thread oversubscription those worker slots are
338/// already occupied by the outer fan-out, so the barrier never completes and the
339/// fit parks at 0% CPU — the #2074 K=1 `sae_manifold_fit` deadlock.
340///
341/// [`FaerSequentialScope`] closes that hole by pinning faer's PROCESS-GLOBAL
342/// parallelism to `Par::Seq` around the nested solve, so every faer solver it
343/// reaches stays single-threaded and never spawns a nested barrier pool. The
344/// codebase engineers its faer reductions to be parallelism-invariant
345/// (`tests_parallelism_invariance_1557` asserts byte-identical `Par::Seq` vs
346/// `Par::rayon` output), so collapsing to sequential is bit-for-bit neutral.
347static FAER_SEQ_STATE: std::sync::Mutex<FaerSeqState> = std::sync::Mutex::new(FaerSeqState {
348    depth: 0,
349    saved: None,
350});
351
352struct FaerSeqState {
353    depth: usize,
354    saved: Option<Par>,
355}
356
357/// RAII guard that pins faer's process-global parallelism to [`Par::Seq`] for its
358/// lifetime and restores the previous setting when the LAST live guard drops.
359///
360/// The guard is depth-counted across threads: overlapping guards (e.g. several
361/// topology-race candidates fitting concurrently) all observe `Par::Seq`, and the
362/// prior policy is restored exactly once, when the outermost guard exits. Setting
363/// and restoring happen under the state mutex so the `depth == 0` transition is
364/// atomic with the `set_global_parallelism` call.
365#[must_use = "the sequential scope only holds while the guard is alive"]
366pub struct FaerSequentialScope {
367    _private: (),
368}
369
370impl FaerSequentialScope {
371    /// Enter the scope, forcing faer to `Par::Seq` on the `0 -> 1` transition.
372    pub fn enter() -> Self {
373        let mut state = FAER_SEQ_STATE
374            .lock()
375            .unwrap_or_else(std::sync::PoisonError::into_inner);
376        if state.depth == 0 {
377            state.saved = Some(get_global_parallelism());
378            faer::set_global_parallelism(Par::Seq);
379        }
380        state.depth += 1;
381        Self { _private: () }
382    }
383}
384
385impl Drop for FaerSequentialScope {
386    fn drop(&mut self) {
387        let mut state = FAER_SEQ_STATE
388            .lock()
389            .unwrap_or_else(std::sync::PoisonError::into_inner);
390        state.depth -= 1;
391        if state.depth == 0 {
392            if let Some(par) = state.saved.take() {
393                faer::set_global_parallelism(par);
394            }
395        }
396    }
397}
398
399/// #2738 — how many [`FaerSequentialScope`] guards are alive process-wide.
400///
401/// The depth is what distinguishes "faer is sequential because a solve here
402/// asked for it" from "faer is sequential and nobody knows who did it", and it
403/// is readable without a logger, which `log::info!` is not under `cargo test`.
404pub fn faer_sequential_scope_depth() -> usize {
405    FAER_SEQ_STATE
406        .lock()
407        .unwrap_or_else(std::sync::PoisonError::into_inner)
408        .depth
409}
410
411/// Run `body` with faer pinned to `Par::Seq` (see [`FaerSequentialScope`]). Use
412/// this to wrap a fit/solve that runs inside a Rayon worker so faer's high-level
413/// solvers never fan a nested `spindle` barrier pool into an already-saturated
414/// Rayon pool.
415#[inline]
416pub fn with_faer_sequential<T>(body: impl FnOnce() -> T) -> T {
417    let faer_seq_guard = FaerSequentialScope::enter();
418    let out = body();
419    drop(faer_seq_guard);
420    out
421}
422
423#[derive(Debug, Error)]
424pub enum FaerLinalgError {
425    #[error("Factorization failed in {context}")]
426    FactorizationFailed { context: &'static str },
427    #[error("SVD failed to converge in {context}")]
428    SvdNoConvergence { context: &'static str },
429    #[error("Self-adjoint eigendecomposition input contains non-finite values in {context}")]
430    SelfAdjointEigenNonFiniteInput { context: &'static str },
431    #[error("Strict self-adjoint eigendecomposition rejected its input: {reason}")]
432    StrictSelfAdjointEigenInvalidInput { reason: String },
433    #[error("Self-adjoint eigendecomposition failed: {0:?}")]
434    SelfAdjointEigen(solvers::EvdError),
435    #[error("Cholesky factorization failed: {0:?}")]
436    Cholesky(solvers::LltError),
437    #[error("LDLT factorization failed: {0:?}")]
438    Ldlt(solvers::LdltError),
439}
440
441pub enum FaerSymmetricFactor {
442    Llt(FaerLlt<f64>),
443    Ldlt(FaerLdlt<f64>),
444    Lblt(FaerLblt<f64>),
445}
446
447#[inline]
448pub fn cholesky_factor_logdet(factor: MatRef<'_, f64>) -> f64 {
449    2.0 * diagonal_log_sum(factor.diagonal())
450}
451
452#[inline]
453fn diagonal_log_sum(diagonal: DiagRef<'_, f64>) -> f64 {
454    diagonal
455        .column_vector()
456        .iter()
457        .map(|&x| x.ln())
458        .sum::<f64>()
459}
460
461impl FaerSymmetricFactor {
462    /// Returns the dimension of the factorized square matrix.
463    #[inline]
464    pub fn n(&self) -> usize {
465        use faer::linalg::solvers::ShapeCore;
466        match self {
467            FaerSymmetricFactor::Llt(f) => f.nrows(),
468            FaerSymmetricFactor::Ldlt(f) => f.nrows(),
469            FaerSymmetricFactor::Lblt(f) => f.nrows(),
470        }
471    }
472
473    #[inline]
474    pub fn solve(&self, rhs: MatRef<'_, f64>) -> Mat<f64> {
475        match self {
476            FaerSymmetricFactor::Llt(f) => f.solve(rhs),
477            FaerSymmetricFactor::Ldlt(f) => f.solve(rhs),
478            FaerSymmetricFactor::Lblt(f) => f.solve(rhs),
479        }
480    }
481
482    #[inline]
483    pub fn solve_in_place(&self, rhs: MatMut<'_, f64>) {
484        match self {
485            FaerSymmetricFactor::Llt(f) => f.solve_in_place(rhs),
486            FaerSymmetricFactor::Ldlt(f) => f.solve_in_place(rhs),
487            FaerSymmetricFactor::Lblt(f) => f.solve_in_place(rhs),
488        }
489    }
490}
491
492impl crate::matrix::FactorizedSystem for FaerSymmetricFactor {
493    fn solve(&self, rhs: &Array1<f64>) -> Result<Array1<f64>, String> {
494        let mut out = rhs.clone();
495        let mut out_mat = array1_to_col_matmut(&mut out);
496        self.solve_in_place(out_mat.as_mut());
497        if !out.iter().all(|v| v.is_finite()) {
498            return Err("symmetric factor solve produced non-finite values".to_string());
499        }
500        Ok(out)
501    }
502
503    fn solvemulti(&self, rhs: &Array2<f64>) -> Result<Array2<f64>, String> {
504        let mut out = Array2::<f64>::zeros(rhs.raw_dim());
505        for j in 0..rhs.ncols() {
506            for i in 0..rhs.nrows() {
507                out[[i, j]] = rhs[[i, j]];
508            }
509        }
510        let mut out_mat = array2_to_matmut(&mut out);
511        self.solve_in_place(out_mat.as_mut());
512        if !out.iter().all(|v| v.is_finite()) {
513            return Err("symmetric factor multi-solve produced non-finite values".to_string());
514        }
515        Ok(out)
516    }
517
518    fn logdet(&self) -> f64 {
519        match self {
520            FaerSymmetricFactor::Llt(f) => cholesky_factor_logdet(f.L()),
521            FaerSymmetricFactor::Ldlt(f) => diagonal_log_sum(f.D()),
522            FaerSymmetricFactor::Lblt(..) => {
523                // lblt doesn't easily expose diagonal determinant. Fallback to sparse or other representations if needed, but typically Lblt is indefinite!
524                // Actually faer doesn't easily expose lblt logdet since it has 2x2 blocks.
525                // For our ML systems, if we dropped to LBLT, the matrix was indefinite and logdet is ill-defined (or complex).
526                f64::NAN
527            }
528        }
529    }
530}
531
532/// Factorize a symmetric system with LLT -> LDLT -> LBLT fallback.
533#[inline]
534pub fn factorize_symmetricwith_fallback(
535    matrix: MatRef<'_, f64>,
536    side: Side,
537) -> Result<FaerSymmetricFactor, FaerLinalgError> {
538    if let Ok(llt) = FaerLlt::new(matrix, side) {
539        return Ok(FaerSymmetricFactor::Llt(llt));
540    }
541    let ldlt_err = match FaerLdlt::new(matrix, side) {
542        Ok(ldlt) => return Ok(FaerSymmetricFactor::Ldlt(ldlt)),
543        Err(err) => err,
544    };
545    let lblt = catch_unwind(AssertUnwindSafe(|| FaerLblt::new(matrix, side)))
546        .map_err(|_| FaerLinalgError::Ldlt(ldlt_err))?;
547    Ok(FaerSymmetricFactor::Lblt(lblt))
548}
549
550#[inline]
551const fn should_use_faer_matmul(m: usize, n: usize, k: usize) -> bool {
552    // Small, centralized dispatch policy:
553    // - stay on ndarray for tiny products to avoid setup overhead,
554    // - switch to faer GEMM/GEMV for moderate+ sizes.
555    const MIN_DIM: usize = 32;
556    const MIN_FLOP_SCALE: usize = 64 * 64;
557    (m >= MIN_DIM || n >= MIN_DIM || k >= MIN_DIM)
558        && m.saturating_mul(n).saturating_mul(k) >= MIN_FLOP_SCALE
559}
560
561#[inline]
562pub fn matmul_parallelism(m: usize, n: usize, k: usize) -> Par {
563    // Prefer a work-based policy over per-dimension thresholds.
564    // Tall/skinny products (e.g. N x p with large N, modest p) should still
565    // parallelize when total work is high.
566    const PAR_MIN_FLOP_SCALE: usize = 2_000_000;
567    const PAR_MIN_LONG_DIM: usize = 256;
568    let flop_scale = m.saturating_mul(n).saturating_mul(k);
569    let long_dim = m.max(n).max(k);
570    if flop_scale >= PAR_MIN_FLOP_SCALE && long_dim >= PAR_MIN_LONG_DIM {
571        // `effective_global_parallelism` collapses to `Par::Seq` when this GEMM
572        // is reached from inside a `NestedParallelGuard` row region, preventing
573        // the Rayon-pool × faer-pool multiplicative oversubscription.
574        effective_global_parallelism()
575    } else {
576        Par::Seq
577    }
578}
579
580#[inline]
581pub fn array2_to_matmut(array: &mut Array2<f64>) -> MatMut<'_, f64> {
582    let (rows, cols) = array.dim();
583    let strides = array.strides();
584
585    // Check if we can get a pointer.
586    // If the array is contiguous (either C or F order), or simply sliced with strides,
587    // faer can handle it as long as we pass the pointer and strides.
588    // However, as_mut_ptr() requires a mutable reference.
589    // ndarray's as_ptr/as_mut_ptr works for both layouts.
590
591    let s0 = strides[0];
592    let s1 = strides[1];
593
594    // SAFETY: array.as_mut_ptr() is ndarray's logical (0, 0) pointer, and
595    // ndarray's dimensions plus signed element strides describe every initialized
596    // element of this uniquely borrowed Array2 for the returned MatMut lifetime.
597    unsafe { MatMut::from_raw_parts_mut(array.as_mut_ptr(), rows, cols, s0, s1) }
598}
599
600/// Convert an ndarray matrix into row-major nested vectors for serialized
601/// payloads without exposing storage-layout assumptions to callers.
602pub fn array2_to_nested_vec(array: &Array2<f64>) -> Vec<Vec<f64>> {
603    array.rows().into_iter().map(|row| row.to_vec()).collect()
604}
605
606#[inline]
607pub fn array1_to_col_matmut(array: &mut Array1<f64>) -> MatMut<'_, f64> {
608    let len = array.len();
609    let stride = array.strides()[0];
610    // SAFETY: array.as_mut_ptr() is ndarray's logical first-element pointer, and
611    // len plus the signed element stride describe every initialized element of
612    // this uniquely borrowed Array1 for the returned len×1 MatMut lifetime.
613    unsafe {
614        MatMut::from_raw_parts_mut(
615            array.as_mut_ptr(),
616            len,
617            1,
618            stride,
619            0, // col stride irrelevant for 1 column
620        )
621    }
622}
623
624/// Compute A^T * A using faer's SIMD-optimized GEMM.
625/// This is MUCH faster than ndarray's .t().dot() for matrices where n > ~100.
626///
627/// For a matrix A of shape (n, p), this computes the (p, p) result.
628/// Uses a zero-copy view for positive-stride layouts and copies only layouts
629/// with non-positive strides.
630#[inline]
631pub fn fast_ata<S: Data<Elem = f64>>(a: &ArrayBase<S, Ix2>) -> Array2<f64> {
632    let p = a.ncols();
633    let mut out = Array2::<f64>::zeros((p, p));
634    fast_ata_into(a, &mut out);
635    out
636}
637
638/// Compute A^T * A into a pre-allocated output buffer.
639/// `out` must be shaped (p, p) where A is (n, p).
640#[inline]
641pub fn fast_ata_into<S: Data<Elem = f64>>(a: &ArrayBase<S, Ix2>, out: &mut Array2<f64>) {
642    use faer::Accum;
643    use faer::linalg::matmul::triangular::{BlockStructure, matmul as tri_matmul};
644
645    let (n, p) = a.dim();
646    assert_eq!(out.nrows(), p, "output rows must match p");
647    assert_eq!(out.ncols(), p, "output cols must match p");
648
649    if !should_use_faer_matmul(p, p, n) {
650        out.assign(&a.t().dot(a));
651        return;
652    }
653
654    let mut outview = array2_to_matmut(out);
655
656    let aview = FaerArrayView::new(a);
657    let a_ref = aview.as_ref();
658    let a_t = a_ref.transpose();
659    let par = matmul_parallelism(p, p, n);
660    tri_matmul(
661        outview.as_mut(),
662        BlockStructure::TriangularLower,
663        Accum::Replace,
664        a_t,
665        BlockStructure::Rectangular,
666        a_ref,
667        BlockStructure::Rectangular,
668        1.0,
669        par,
670    );
671    // Mirror lower triangle to upper to populate the full symmetric output.
672    for i in 0..p {
673        for j in (i + 1)..p {
674            out[[i, j]] = out[[j, i]];
675        }
676    }
677}
678
679/// Compute A^T * B using faer's SIMD-optimized GEMM.
680/// For A of shape (n, p) and B of shape (n, q), this computes the (p, q) result.
681/// Uses zero-copy views when possible.
682#[inline]
683pub fn fast_atb<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
684    a: &ArrayBase<S1, Ix2>,
685    b: &ArrayBase<S2, Ix2>,
686) -> Array2<f64> {
687    if let Some(out) =
688        crate::gpu_hook::gpu_dispatch().and_then(|d| d.try_fast_atb(a.view(), b.view()))
689    {
690        return out;
691    }
692    let (n_a, p) = a.dim();
693    let q = b.ncols();
694    fast_atb_with_parallelism(a, b, matmul_parallelism(p, q, n_a))
695}
696
697/// Compute A^T * B with an explicit faer parallelism policy for callers that
698/// are already running independent products in an outer Rayon task.
699#[inline]
700pub fn fast_atb_with_parallelism<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
701    a: &ArrayBase<S1, Ix2>,
702    b: &ArrayBase<S2, Ix2>,
703    par: Par,
704) -> Array2<f64> {
705    use faer::linalg::matmul::matmul;
706    use faer::{Accum, Mat};
707
708    let (n_a, p) = a.dim();
709    let (n_b, q) = b.dim();
710    assert_eq!(n_a, n_b, "A and B must have same number of rows");
711
712    // For very small matrices, ndarray might be faster due to less overhead
713    if !should_use_faer_matmul(p, q, n_a) {
714        return a.t().dot(b);
715    }
716
717    let mut result = Mat::<f64>::zeros(p, q);
718
719    let aview = FaerArrayView::new(a);
720    let bview = FaerArrayView::new(b);
721    let a_ref = aview.as_ref();
722    let b_ref = bview.as_ref();
723
724    // dst = A^T * B
725    matmul(
726        result.as_mut(),
727        Accum::Replace,
728        a_ref.transpose(),
729        b_ref,
730        1.0,
731        par,
732    );
733
734    mat_to_array(result.as_ref())
735}
736
737/// Compute A * B^T using faer's SIMD-optimized GEMM.
738/// For A of shape (m, k) and B of shape (n, k), this computes the (m, n) result.
739#[inline]
740pub fn fast_abt<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
741    a: &ArrayBase<S1, Ix2>,
742    b: &ArrayBase<S2, Ix2>,
743) -> Array2<f64> {
744    use faer::linalg::matmul::matmul;
745    use faer::{Accum, Mat};
746
747    let (m, k_a) = a.dim();
748    let (n, k_b) = b.dim();
749    assert_eq!(
750        k_a, k_b,
751        "A and B must have same number of columns for A·Bᵀ"
752    );
753
754    if !should_use_faer_matmul(m, n, k_a) {
755        return a.dot(&b.t());
756    }
757
758    let mut result = Mat::<f64>::zeros(m, n);
759    let aview = FaerArrayView::new(a);
760    let bview = FaerArrayView::new(b);
761    let par = matmul_parallelism(m, n, k_a);
762    matmul(
763        result.as_mut(),
764        Accum::Replace,
765        aview.as_ref(),
766        bview.as_ref().transpose(),
767        1.0,
768        par,
769    );
770    mat_to_array(result.as_ref())
771}
772
773/// Compute A * B using faer's SIMD-optimized GEMM.
774/// For A of shape (n, p) and B of shape (p, q), this computes the (n, q) result.
775/// Uses zero-copy views when possible.
776#[inline]
777pub fn fast_ab<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
778    a: &ArrayBase<S1, Ix2>,
779    b: &ArrayBase<S2, Ix2>,
780) -> Array2<f64> {
781    if let Some(out) =
782        crate::gpu_hook::gpu_dispatch().and_then(|d| d.try_fast_ab(a.view(), b.view()))
783    {
784        return out;
785    }
786    let n = a.nrows();
787    let q = b.ncols();
788    let mut out = Array2::<f64>::zeros((n, q));
789    fast_ab_into(a, b, &mut out);
790    out
791}
792
793// ────────────────────────────────────────────────────────────────────────
794// Compensated / blocked SIMD reduction kernels for the GEMV hot paths.
795//
796// `fast_av` (η = Xβ) and `fast_atv` (Xᵀr — e.g. the penalized-likelihood
797// gradient and REML score) are reduction-bound: every output entry is a sum
798// of products over a long axis. faer's generic GEMM serves them as degenerate
799// single-RHS-column matmuls, whose blocking/setup cost is poorly amortized by
800// one column. For the dominant row-major-contiguous case we use tight hand
801// kernels that are simultaneously
802//   * faster — several independent FMA accumulators expose the
803//     instruction-level parallelism the backend lowers to packed AVX
804//     `vfmadd` lanes, and the row work fans out across the Rayon pool; and
805//     (`f64::mul_add` is only an instruction when the code is COMPILED with
806//     the `fma` target feature; the portable x86_64 baseline this workspace
807//     ships has none, so a plain build lowered every `mul_add` here to a
808//     call into the runtime `fma` dispatcher — measured on a gaussian
809//     n=50,000 / p=93 fit: zero `vfmadd` in the row-major matvec closure and
810//     28% of the fit's cycles inside `fma`/`fma_with_fma`. Each kernel below
811//     is therefore compiled twice, once for the baseline and once with
812//     `fma,avx2` enabled, and its entry point picks the second whenever the
813//     running CPU reports both features — see `fma_avx2_available`.)
814//   * more accurate — `f64::mul_add` fuses each product into its accumulator
815//     with a single rounding (no rounded intermediate product), the lanes
816//     reduce as a small pairwise tree, and the long Xᵀr reduction is split
817//     into fixed-size row blocks whose partials are combined pairwise,
818//     turning the naive O(n·ε) error growth into ~O((block + log(n/block))·ε).
819//
820// Non-contiguous / non-row-major operands fall back to the faer path, so the
821// numerics only change (improve) on the common standard-layout inputs.
822// ────────────────────────────────────────────────────────────────────────
823
824/// Number of independent FMA accumulator lanes. Eight lanes keep two 256-bit
825/// (`f64x4`) FMA pipelines fed and set the partial-pairwise leaf width.
826const FMA_LANES: usize = 8;
827
828/// FLOP-scale (n·p) below which the kernels stay serial; at or above it, and
829/// only when not already inside a parallel row region, the row loop fans out
830/// across the Rayon pool.
831const KERNEL_PAR_MIN_FLOP: usize = 1 << 18; // 262_144
832
833/// Maximum rows per row-block in the row-major matrix-vector kernels. The
834/// actual block shrinks for wide matrices so one cache-resident dense operator
835/// does not expose only one or two tasks to a larger Rayon pool.
836const AV_PAR_MAX_CHUNK_ROWS: usize = 1024;
837
838/// Rows per reduction block in [`fast_atv_rowmajor_into`]; each block sums its
839/// rows into a private length-p partial and the partials combine pairwise, so
840/// the long-axis rounding error grows with the block size plus the log of the
841/// block count rather than with `n`.
842const ATV_BLOCK_ROWS: usize = 512;
843
844#[inline]
845fn kernel_should_parallelize(n: usize, p: usize) -> bool {
846    !in_nested_parallel_region()
847        && n.saturating_mul(p) >= KERNEL_PAR_MIN_FLOP
848        && rayon::current_num_threads() > 1
849}
850
851#[inline]
852fn av_parallel_chunk_rows(p: usize) -> usize {
853    KERNEL_PAR_MIN_FLOP
854        .div_ceil(p.max(1))
855        .clamp(64, AV_PAR_MAX_CHUNK_ROWS)
856}
857
858/// Compensated dot product (the Ogita–Rump–Oishi *Dot2* error-free transform)
859/// of two equal-length contiguous slices, evaluated over [`FMA_LANES`]
860/// independent compensated accumulators.
861///
862/// For each term the product is split into its rounded value plus the *exact*
863/// product error via `mul_add` (`two_prod`), and added into the running sum via
864/// a branchless `two_sum`, with both rounding errors folded into a
865/// per-lane compensation. The result carries roughly twice the working
866/// precision: its error-vs-truth is bounded by `u·|result| + O(n·u²)·|x|ᵀ|y|`
867/// versus the naive recurrence's `O(n·u)·|x|ᵀ|y|`, i.e. strictly — often by
868/// many orders of magnitude — more accurate. The eight independent lanes keep
869/// the FMA pipelines saturated, and on the GEMV hot paths the extra arithmetic
870/// is hidden under the memory traffic of streaming `X`, so accuracy rises with
871/// no throughput cost.
872#[inline(always)]
873fn fma_dot_body(a: &[f64], b: &[f64]) -> f64 {
874    assert_eq!(a.len(), b.len(), "fma_dot: operand length mismatch");
875    let mut sum = [0.0f64; FMA_LANES];
876    let mut comp = [0.0f64; FMA_LANES];
877    let mut ca = a.chunks_exact(FMA_LANES);
878    let mut cb = b.chunks_exact(FMA_LANES);
879    for (xa, xb) in ca.by_ref().zip(cb.by_ref()) {
880        for l in 0..FMA_LANES {
881            let x = xa[l];
882            let y = xb[l];
883            // two_prod: p = round(x·y), ep = exact error x·y − p.
884            let p = x * y;
885            let ep = x.mul_add(y, -p);
886            // two_sum: s = round(sum + p), es = exact error.
887            let s = sum[l] + p;
888            let bb = s - sum[l];
889            let es = (sum[l] - (s - bb)) + (p - bb);
890            sum[l] = s;
891            comp[l] += ep + es;
892        }
893    }
894    // Compensated remainder lane (length < FMA_LANES).
895    let mut sr = 0.0f64;
896    let mut cr = 0.0f64;
897    for (&x, &y) in ca.remainder().iter().zip(cb.remainder().iter()) {
898        let p = x * y;
899        let ep = x.mul_add(y, -p);
900        let s = sr + p;
901        let bb = s - sr;
902        let es = (sr - (s - bb)) + (p - bb);
903        sr = s;
904        cr += ep + es;
905    }
906    // Fold each lane's compensation back in, then reduce the (few) lanes.
907    let mut total = sr + cr;
908    for l in 0..FMA_LANES {
909        total += sum[l] + comp[l];
910    }
911    total
912}
913
914/// Whether the running x86_64 CPU executes the `fma,avx2` kernel variants.
915///
916/// `is_x86_feature_detected!` caches its probe in a process-wide static, so
917/// this is a load and a bit test per call — invisible next to any kernel whose
918/// row is at least [`FMA_LANES`] long.
919#[cfg(target_arch = "x86_64")]
920#[inline]
921fn fma_avx2_available() -> bool {
922    std::arch::is_x86_feature_detected!("fma") && std::arch::is_x86_feature_detected!("avx2")
923}
924
925/// [`fma_dot_body`] compiled with the `fma,avx2` target features, so its
926/// `mul_add`s are `vfmadd` instructions over packed lanes instead of calls.
927#[cfg(target_arch = "x86_64")]
928#[target_feature(enable = "fma,avx2")]
929fn fma_dot_fma_avx2(a: &[f64], b: &[f64]) -> f64 {
930    fma_dot_body(a, b)
931}
932
933/// Compensated dot product: [`fma_dot_body`] on the CPU-feature variant the
934/// running machine supports. Bit-identical across variants — an FMA is an FMA
935/// whether it is an instruction or the runtime library's implementation of
936/// one, and the eight lanes keep their per-lane order under vectorization.
937#[inline]
938fn fma_dot(a: &[f64], b: &[f64]) -> f64 {
939    #[cfg(target_arch = "x86_64")]
940    if fma_avx2_available() {
941        // SAFETY: `fma_avx2_available` is the cached CPU probe for exactly the
942        // `fma` and `avx2` features this variant enables.
943        return unsafe { fma_dot_fma_avx2(a, b) };
944    }
945    fma_dot_body(a, b)
946}
947
948/// `out[i] = Σ_j X[i,j]·v[j]` for row-major-contiguous `x_all` (len `n·p`) and
949/// `v` (len `p`). Each output row is an independent [`fma_dot`]; rows fan out
950/// in chunks across the Rayon pool when the work is large.
951fn fast_av_rowmajor_into(x_all: &[f64], v: &[f64], n: usize, p: usize, out: &mut [f64]) {
952    assert_eq!(x_all.len(), n * p, "fast_av_rowmajor_into: x_all length");
953    assert_eq!(v.len(), p, "fast_av_rowmajor_into: v length");
954    assert_eq!(out.len(), n, "fast_av_rowmajor_into: out length");
955    if kernel_should_parallelize(n, p) {
956        use rayon::prelude::*;
957        let chunk_rows = av_parallel_chunk_rows(p);
958        out.par_chunks_mut(chunk_rows)
959            .enumerate()
960            .for_each(|(c, chunk)| {
961                let base = c * chunk_rows;
962                for (k, o) in chunk.iter_mut().enumerate() {
963                    let i = base + k;
964                    *o = fma_dot(&x_all[i * p..i * p + p], v);
965                }
966            });
967    } else {
968        for (i, o) in out.iter_mut().enumerate() {
969            *o = fma_dot(&x_all[i * p..i * p + p], v);
970        }
971    }
972}
973
974/// Ordinary fused-multiply-add dot product with independent accumulator lanes.
975///
976/// This is the IEEE-754 workhorse for iterative operators whose caller owns an
977/// explicit residual certificate. It intentionally omits Dot2's error-free
978/// product/sum transforms: for a 2,000-term row the standard `O(p·ε)` error is
979/// still roughly four orders of magnitude below a `1e-8` Ritz contract, while
980/// the saved arithmetic matters when the same cache-resident matrix is applied
981/// hundreds of times.
982#[inline(always)]
983fn standard_fma_dot_body(a: &[f64], b: &[f64]) -> f64 {
984    assert_eq!(
985        a.len(),
986        b.len(),
987        "standard_fma_dot: operand length mismatch"
988    );
989    let mut sum = [0.0_f64; FMA_LANES];
990    let mut ca = a.chunks_exact(FMA_LANES);
991    let mut cb = b.chunks_exact(FMA_LANES);
992    for (xa, xb) in ca.by_ref().zip(cb.by_ref()) {
993        for lane in 0..FMA_LANES {
994            sum[lane] = xa[lane].mul_add(xb[lane], sum[lane]);
995        }
996    }
997    let mut remainder = 0.0;
998    for (&x, &y) in ca.remainder().iter().zip(cb.remainder().iter()) {
999        remainder = x.mul_add(y, remainder);
1000    }
1001    let pair01 = sum[0] + sum[1];
1002    let pair23 = sum[2] + sum[3];
1003    let pair45 = sum[4] + sum[5];
1004    let pair67 = sum[6] + sum[7];
1005    remainder + (pair01 + pair23) + (pair45 + pair67)
1006}
1007
1008/// [`standard_fma_dot_body`] compiled with the `fma,avx2` target features.
1009#[cfg(target_arch = "x86_64")]
1010#[target_feature(enable = "fma,avx2")]
1011fn standard_fma_dot_fma_avx2(a: &[f64], b: &[f64]) -> f64 {
1012    standard_fma_dot_body(a, b)
1013}
1014
1015/// Ordinary FMA dot product on the CPU-feature variant the running machine
1016/// supports (bit-identical across variants, as for [`fma_dot`]).
1017#[inline]
1018fn standard_fma_dot(a: &[f64], b: &[f64]) -> f64 {
1019    #[cfg(target_arch = "x86_64")]
1020    if fma_avx2_available() {
1021        // SAFETY: `fma_avx2_available` is the cached CPU probe for exactly the
1022        // `fma` and `avx2` features this variant enables.
1023        return unsafe { standard_fma_dot_fma_avx2(a, b) };
1024    }
1025    standard_fma_dot_body(a, b)
1026}
1027
1028fn standard_av_rowmajor_into(x_all: &[f64], v: &[f64], n: usize, p: usize, out: &mut [f64]) {
1029    assert_eq!(
1030        x_all.len(),
1031        n * p,
1032        "standard_av_rowmajor_into: matrix length"
1033    );
1034    assert_eq!(v.len(), p, "standard_av_rowmajor_into: vector length");
1035    assert_eq!(out.len(), n, "standard_av_rowmajor_into: output length");
1036    if kernel_should_parallelize(n, p) {
1037        use rayon::prelude::*;
1038        let chunk_rows = av_parallel_chunk_rows(p);
1039        out.par_chunks_mut(chunk_rows)
1040            .enumerate()
1041            .for_each(|(chunk_index, chunk)| {
1042                let base = chunk_index * chunk_rows;
1043                for (offset, output) in chunk.iter_mut().enumerate() {
1044                    let row = base + offset;
1045                    *output = standard_fma_dot(&x_all[row * p..row * p + p], v);
1046                }
1047            });
1048    } else {
1049        for (row, output) in out.iter_mut().enumerate() {
1050            *output = standard_fma_dot(&x_all[row * p..row * p + p], v);
1051        }
1052    }
1053}
1054
1055/// Pairwise (tree) sum of equal-length partial vectors into `out`.
1056fn pairwise_sum_into(parts: &[Vec<f64>], out: &mut [f64]) {
1057    match parts.len() {
1058        0 => out.fill(0.0),
1059        1 => out.copy_from_slice(&parts[0]),
1060        _ => {
1061            let mid = parts.len() / 2;
1062            let p = out.len();
1063            let mut left = vec![0.0f64; p];
1064            let mut right = vec![0.0f64; p];
1065            pairwise_sum_into(&parts[..mid], &mut left);
1066            pairwise_sum_into(&parts[mid..], &mut right);
1067            for ((o, &l), &r) in out.iter_mut().zip(left.iter()).zip(right.iter()) {
1068                *o = l + r;
1069            }
1070        }
1071    }
1072}
1073
1074/// `out[j] = Σ_i v[i]·X[i,j]` for row-major-contiguous `x_all` (len `n·p`).
1075///
1076/// Rows are grouped into [`ATV_BLOCK_ROWS`] blocks; each block FMA-accumulates
1077/// its rows into a private partial vector (fused `v[i]·X[i,j]`), and the block
1078/// partials are combined pairwise. This blocked/pairwise reduction is both
1079/// better-conditioned than a single running sum over all `n` rows and trivially
1080/// parallel across blocks.
1081fn fast_atv_rowmajor_into(x_all: &[f64], v: &[f64], n: usize, p: usize, out: &mut [f64]) {
1082    assert_eq!(x_all.len(), n * p, "fast_atv_rowmajor_into: x_all length");
1083    assert_eq!(v.len(), n, "fast_atv_rowmajor_into: v length");
1084    assert_eq!(out.len(), p, "fast_atv_rowmajor_into: out length");
1085    let nblocks = n.div_ceil(ATV_BLOCK_ROWS);
1086
1087    let block_partial = |b: usize| -> Vec<f64> {
1088        let start = b * ATV_BLOCK_ROWS;
1089        let end = (start + ATV_BLOCK_ROWS).min(n);
1090        let mut acc = vec![0.0f64; p];
1091        atv_block_accumulate(&x_all[start * p..end * p], &v[start..end], &mut acc);
1092        acc
1093    };
1094
1095    let partials: Vec<Vec<f64>> = if kernel_should_parallelize(n, p) {
1096        use rayon::prelude::*;
1097        (0..nblocks).into_par_iter().map(block_partial).collect()
1098    } else {
1099        (0..nblocks).map(block_partial).collect()
1100    };
1101
1102    pairwise_sum_into(&partials, out);
1103}
1104
1105/// `acc[j] += Σ_i rows[i,j]·v[i]` over a row-major block `rows` of `v.len()`
1106/// rows and `acc.len()` columns: the private partial of one
1107/// [`fast_atv_rowmajor_into`] reduction block, one FMA per entry.
1108#[inline(always)]
1109fn atv_block_accumulate_body(rows: &[f64], v: &[f64], acc: &mut [f64]) {
1110    let p = acc.len();
1111    assert_eq!(rows.len(), v.len() * p, "atv_block_accumulate: block length");
1112    for (&vi, row) in v.iter().zip(rows.chunks_exact(p)) {
1113        for (a, &xij) in acc.iter_mut().zip(row.iter()) {
1114            *a = xij.mul_add(vi, *a);
1115        }
1116    }
1117}
1118
1119/// [`atv_block_accumulate_body`] compiled with the `fma,avx2` target features.
1120#[cfg(target_arch = "x86_64")]
1121#[target_feature(enable = "fma,avx2")]
1122fn atv_block_accumulate_fma_avx2(rows: &[f64], v: &[f64], acc: &mut [f64]) {
1123    atv_block_accumulate_body(rows, v, acc)
1124}
1125
1126/// Block partial of `Xᵀv` on the CPU-feature variant the running machine
1127/// supports (bit-identical across variants, as for [`fma_dot`]).
1128#[inline]
1129fn atv_block_accumulate(rows: &[f64], v: &[f64], acc: &mut [f64]) {
1130    #[cfg(target_arch = "x86_64")]
1131    if fma_avx2_available() {
1132        // SAFETY: `fma_avx2_available` is the cached CPU probe for exactly the
1133        // `fma` and `avx2` features this variant enables.
1134        return unsafe { atv_block_accumulate_fma_avx2(rows, v, acc) };
1135    }
1136    atv_block_accumulate_body(rows, v, acc)
1137}
1138
1139/// `y[i] = alpha·x[i] + y[i]` with one FMA per entry, on the CPU-feature
1140/// variant the running machine supports (bit-identical across variants, as
1141/// for the compensated dot above). The
1142/// Lanczos reorthogonalization's projection update is this kernel over the
1143/// full basis at every step, so it pays the same per-`mul_add` call price as
1144/// the matvecs without it.
1145pub(crate) fn fma_axpy_into(alpha: f64, x: &[f64], y: &mut [f64]) {
1146    #[cfg(target_arch = "x86_64")]
1147    if fma_avx2_available() {
1148        // SAFETY: `fma_avx2_available` is the cached CPU probe for exactly the
1149        // `fma` and `avx2` features this variant enables.
1150        return unsafe { fma_axpy_into_fma_avx2(alpha, x, y) };
1151    }
1152    fma_axpy_into_body(alpha, x, y)
1153}
1154
1155#[inline(always)]
1156fn fma_axpy_into_body(alpha: f64, x: &[f64], y: &mut [f64]) {
1157    assert_eq!(x.len(), y.len(), "fma_axpy_into: operand length mismatch");
1158    for (yi, &xi) in y.iter_mut().zip(x.iter()) {
1159        *yi = alpha.mul_add(xi, *yi);
1160    }
1161}
1162
1163/// [`fma_axpy_into_body`] compiled with the `fma,avx2` target features.
1164#[cfg(target_arch = "x86_64")]
1165#[target_feature(enable = "fma,avx2")]
1166fn fma_axpy_into_fma_avx2(alpha: f64, x: &[f64], y: &mut [f64]) {
1167    fma_axpy_into_body(alpha, x, y)
1168}
1169
1170/// Compute A * v using faer's SIMD-optimized GEMV.
1171/// For A of shape (n, p) and v of shape (p,), this computes the (n,) result.
1172#[inline]
1173pub fn fast_av<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1174    a: &ArrayBase<S1, Ix2>,
1175    v: &ArrayBase<S2, Ix1>,
1176) -> Array1<f64> {
1177    if let Some(out) =
1178        crate::gpu_hook::gpu_dispatch().and_then(|d| d.try_fast_av(a.view(), v.view()))
1179    {
1180        return out;
1181    }
1182    fast_av_impl(a, v)
1183}
1184
1185#[inline]
1186fn fast_av_impl<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1187    a: &ArrayBase<S1, Ix2>,
1188    v: &ArrayBase<S2, Ix1>,
1189) -> Array1<f64> {
1190    use faer::linalg::matmul::matmul;
1191    use faer::{Accum, Mat};
1192
1193    let (n, p) = a.dim();
1194    assert_eq!(p, v.len(), "A cols must match v length");
1195
1196    // Row-major-contiguous fast path: tight multi-lane FMA dot per row, both
1197    // faster (ILP / Rayon fan-out) and more accurate (fused products, pairwise
1198    // lane reduction) than the degenerate single-column faer GEMV.
1199    if let (Some(x_all), Some(vs)) = (a.as_slice(), v.as_slice())
1200        && n != 0
1201        && p != 0
1202    {
1203        let mut out = Array1::<f64>::zeros(n);
1204        fast_av_rowmajor_into(
1205            x_all,
1206            vs,
1207            n,
1208            p,
1209            out.as_slice_mut().expect("fresh Array1 is contiguous"),
1210        );
1211        return out;
1212    }
1213
1214    if !should_use_faer_matmul(n, 1, p) {
1215        return a.dot(v);
1216    }
1217
1218    let mut result = Mat::<f64>::zeros(n, 1);
1219
1220    let aview = FaerArrayView::new(a);
1221    let vview = FaerColView::new(v);
1222    let a_ref = aview.as_ref();
1223    let v_ref = vview.as_ref();
1224
1225    let par = matmul_parallelism(n, 1, p);
1226    matmul(result.as_mut(), Accum::Replace, a_ref, v_ref, 1.0, par);
1227
1228    let mut out = Array1::<f64>::zeros(n);
1229    for i in 0..n {
1230        out[i] = result[(i, 0)];
1231    }
1232    out
1233}
1234
1235/// Compute A * v into a pre-allocated output buffer.
1236/// `out` must be length n where A is (n, p) and v is length p.
1237#[inline]
1238pub fn fast_av_into<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1239    a: &ArrayBase<S1, Ix2>,
1240    v: &ArrayBase<S2, Ix1>,
1241    out: &mut Array1<f64>,
1242) {
1243    fast_av_into_impl(a, v, out);
1244}
1245
1246#[inline]
1247fn fast_av_into_impl<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1248    a: &ArrayBase<S1, Ix2>,
1249    v: &ArrayBase<S2, Ix1>,
1250    out: &mut Array1<f64>,
1251) {
1252    use faer::Accum;
1253    use faer::linalg::matmul::matmul;
1254
1255    let (n, p) = a.dim();
1256    assert_eq!(v.len(), p, "vector length must match A cols");
1257    assert_eq!(out.len(), n, "output length must match A rows");
1258
1259    if let (Some(x_all), Some(vs)) = (a.as_slice(), v.as_slice())
1260        && n != 0
1261        && p != 0
1262        && let Some(out_s) = out.as_slice_mut()
1263    {
1264        fast_av_rowmajor_into(x_all, vs, n, p, out_s);
1265        return;
1266    }
1267
1268    if !should_use_faer_matmul(n, 1, p) {
1269        out.assign(&a.dot(v));
1270        return;
1271    }
1272
1273    let mut outview = array1_to_col_matmut(out);
1274
1275    let aview = FaerArrayView::new(a);
1276    let vview = FaerColView::new(v);
1277    let a_ref = aview.as_ref();
1278    let v_ref = vview.as_ref();
1279    let par = matmul_parallelism(n, 1, p);
1280    matmul(outview.as_mut(), Accum::Replace, a_ref, v_ref, 1.0, par);
1281}
1282
1283/// Compute A * v into a pre-allocated `ArrayViewMut1` slice. Like
1284/// [`fast_av_into`] but accepts a writable slice rather than `&mut Array1`,
1285/// so callers can write directly into a sub-range of a larger buffer
1286/// without intermediate allocation.
1287///
1288/// `out` must have length n where A is (n, p) and v is length p.
1289#[inline]
1290pub fn fast_av_view_into<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1291    a: &ArrayBase<S1, Ix2>,
1292    v: &ArrayBase<S2, Ix1>,
1293    out: ArrayViewMut1<'_, f64>,
1294) {
1295    fast_av_view_into_impl(a, v, out);
1296}
1297
1298/// Compute `A·v` with the standard-FMA row kernel.
1299///
1300/// Prefer this over [`fast_av_view_into`] only when the surrounding iterative
1301/// algorithm certifies its final residual explicitly. The ordinary kernel is
1302/// materially faster for repeated cache-resident dense applications; callers
1303/// that need Dot2's near-double-precision reduction should keep using
1304/// [`fast_av_view_into`].
1305pub fn fast_av_standard_view_into<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1306    a: &ArrayBase<S1, Ix2>,
1307    v: &ArrayBase<S2, Ix1>,
1308    mut out: ArrayViewMut1<'_, f64>,
1309) {
1310    use faer::Accum;
1311    use faer::linalg::matmul::matmul;
1312
1313    let (n, p) = a.dim();
1314    assert_eq!(v.len(), p, "vector length must match A cols");
1315    assert_eq!(out.len(), n, "output length must match A rows");
1316    if let (Some(x_all), Some(vs), Some(out_slice)) =
1317        (a.as_slice(), v.as_slice(), out.as_slice_mut())
1318        && n != 0
1319        && p != 0
1320    {
1321        standard_av_rowmajor_into(x_all, vs, n, p, out_slice);
1322        return;
1323    }
1324    if !should_use_faer_matmul(n, 1, p) {
1325        out.assign(&a.dot(v));
1326        return;
1327    }
1328
1329    let len = out.len();
1330    let stride = out.strides()[0];
1331    // SAFETY: `out` is uniquely borrowed and `len` plus its signed stride
1332    // describe every initialized element of the one-column destination.
1333    let outview = unsafe { MatMut::from_raw_parts_mut(out.as_mut_ptr(), len, 1, stride, 0) };
1334    let aview = FaerArrayView::new(a);
1335    let vview = FaerColView::new(v);
1336    matmul(
1337        outview,
1338        Accum::Replace,
1339        aview.as_ref(),
1340        vview.as_ref(),
1341        1.0,
1342        matmul_parallelism(n, 1, p),
1343    );
1344}
1345
1346#[inline]
1347fn fast_av_view_into_impl<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1348    a: &ArrayBase<S1, Ix2>,
1349    v: &ArrayBase<S2, Ix1>,
1350    mut out: ArrayViewMut1<'_, f64>,
1351) {
1352    use faer::Accum;
1353    use faer::linalg::matmul::matmul;
1354
1355    let (n, p) = a.dim();
1356    assert_eq!(v.len(), p, "vector length must match A cols");
1357    assert_eq!(out.len(), n, "output length must match A rows");
1358
1359    if let (Some(x_all), Some(vs)) = (a.as_slice(), v.as_slice())
1360        && n != 0
1361        && p != 0
1362        && let Some(out_s) = out.as_slice_mut()
1363    {
1364        fast_av_rowmajor_into(x_all, vs, n, p, out_s);
1365        return;
1366    }
1367
1368    if !should_use_faer_matmul(n, 1, p) {
1369        let prod = a.dot(v);
1370        out.assign(&prod);
1371        return;
1372    }
1373
1374    let len = out.len();
1375    let stride = out.strides()[0];
1376    // SAFETY: out.as_mut_ptr() is ndarray's logical first-element pointer, and
1377    // len plus the signed element stride describe every initialized element of
1378    // this uniquely borrowed view for the returned len×1 MatMut lifetime.
1379    let outview = unsafe {
1380        MatMut::from_raw_parts_mut(
1381            out.as_mut_ptr(),
1382            len,
1383            1,
1384            stride,
1385            0, // col stride irrelevant for 1 column
1386        )
1387    };
1388
1389    let aview = FaerArrayView::new(a);
1390    let vview = FaerColView::new(v);
1391    let a_ref = aview.as_ref();
1392    let v_ref = vview.as_ref();
1393    let par = matmul_parallelism(n, 1, p);
1394    matmul(outview, Accum::Replace, a_ref, v_ref, 1.0, par);
1395}
1396
1397/// Compute A^T * v using faer's SIMD-optimized GEMV.
1398/// For A of shape (n, p) and v of shape (n,), this computes the (p,) result.
1399#[inline]
1400pub fn fast_atv<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1401    a: &ArrayBase<S1, Ix2>,
1402    v: &ArrayBase<S2, Ix1>,
1403) -> Array1<f64> {
1404    if let Some(out) =
1405        crate::gpu_hook::gpu_dispatch().and_then(|d| d.try_fast_atv(a.view(), v.view()))
1406    {
1407        return out;
1408    }
1409    fast_atv_impl(a, v)
1410}
1411
1412#[inline]
1413fn fast_atv_impl<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1414    a: &ArrayBase<S1, Ix2>,
1415    v: &ArrayBase<S2, Ix1>,
1416) -> Array1<f64> {
1417    use faer::Accum;
1418    use faer::linalg::matmul::matmul;
1419
1420    let (n, p) = a.dim();
1421    assert_eq!(n, v.len(), "A rows must match v length");
1422
1423    // Row-major-contiguous fast path: blocked + pairwise FMA reduction over the
1424    // long n-axis. Lower error-vs-truth than a single running sum and parallel
1425    // across row blocks.
1426    if let (Some(x_all), Some(vs)) = (a.as_slice(), v.as_slice())
1427        && n != 0
1428        && p != 0
1429    {
1430        let mut out = Array1::<f64>::zeros(p);
1431        fast_atv_rowmajor_into(
1432            x_all,
1433            vs,
1434            n,
1435            p,
1436            out.as_slice_mut().expect("fresh Array1 is contiguous"),
1437        );
1438        return out;
1439    }
1440
1441    // For very small arrays, ndarray might be faster
1442    if !should_use_faer_matmul(p, 1, n) {
1443        return a.t().dot(v);
1444    }
1445
1446    let mut out = Array1::<f64>::zeros(p);
1447    let mut outview = array1_to_col_matmut(&mut out);
1448
1449    let aview = FaerArrayView::new(a);
1450    let vview = FaerColView::new(v);
1451    let a_ref = aview.as_ref();
1452    let v_ref = vview.as_ref();
1453
1454    // dst = A^T * v (treating v as n×1 matrix)
1455    let par = matmul_parallelism(p, 1, n);
1456    matmul(
1457        outview.as_mut(),
1458        Accum::Replace,
1459        a_ref.transpose(),
1460        v_ref,
1461        1.0,
1462        par,
1463    );
1464
1465    out
1466}
1467
1468/// Compute A^T * v into a pre-allocated output buffer.
1469/// `out` must be length p where A is (n, p) and v is length n.
1470#[inline]
1471pub fn fast_atv_into<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1472    a: &ArrayBase<S1, Ix2>,
1473    v: &ArrayBase<S2, Ix1>,
1474    out: &mut Array1<f64>,
1475) {
1476    fast_atv_into_impl(a, v, out);
1477}
1478
1479#[inline]
1480fn fast_atv_into_impl<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1481    a: &ArrayBase<S1, Ix2>,
1482    v: &ArrayBase<S2, Ix1>,
1483    out: &mut Array1<f64>,
1484) {
1485    use faer::Accum;
1486    use faer::linalg::matmul::matmul;
1487
1488    let (n, p) = a.dim();
1489    assert_eq!(v.len(), n, "vector length must match A rows");
1490    assert_eq!(out.len(), p, "output length must match A cols");
1491
1492    if let (Some(x_all), Some(vs)) = (a.as_slice(), v.as_slice())
1493        && n != 0
1494        && p != 0
1495        && let Some(out_s) = out.as_slice_mut()
1496    {
1497        fast_atv_rowmajor_into(x_all, vs, n, p, out_s);
1498        return;
1499    }
1500
1501    if !should_use_faer_matmul(p, 1, n) {
1502        out.assign(&a.t().dot(v));
1503        return;
1504    }
1505
1506    let mut outview = array1_to_col_matmut(out);
1507
1508    let aview = FaerArrayView::new(a);
1509    let vview = FaerColView::new(v);
1510    let a_ref = aview.as_ref();
1511    let v_ref = vview.as_ref();
1512    let par = matmul_parallelism(p, 1, n);
1513    matmul(
1514        outview.as_mut(),
1515        Accum::Replace,
1516        a_ref.transpose(),
1517        v_ref,
1518        1.0,
1519        par,
1520    );
1521}
1522
1523/// Compute A^T * diag(W) * A using streaming chunks to avoid O(n*p) allocation.
1524#[inline]
1525pub fn fast_xt_diag_x<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1526    x: &ArrayBase<S1, Ix2>,
1527    w: &ArrayBase<S2, Ix1>,
1528) -> Array2<f64> {
1529    assert_eq!(
1530        x.nrows(),
1531        w.len(),
1532        "fast_xt_diag_x row/weight length mismatch"
1533    );
1534    if let Some(out) =
1535        crate::gpu_hook::gpu_dispatch().and_then(|d| d.try_fast_xt_diag_x(x.view(), w.view()))
1536    {
1537        return out;
1538    }
1539    let p = x.ncols();
1540    fast_xt_diag_x_with_parallelism(x, w, matmul_parallelism(p, p, x.nrows()))
1541}
1542
1543/// Compute A^T * diag(W) * A with an explicit faer parallelism policy for
1544/// callers that parallelize multiple independent Hessian blocks externally.
1545#[inline]
1546pub fn fast_xt_diag_x_with_parallelism<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1547    x: &ArrayBase<S1, Ix2>,
1548    w: &ArrayBase<S2, Ix1>,
1549    par: Par,
1550) -> Array2<f64> {
1551    assert_eq!(
1552        x.nrows(),
1553        w.len(),
1554        "fast_xt_diag_x_with_parallelism row/weight length mismatch"
1555    );
1556    fast_xt_diag_x_with_parallelism_impl(x, w, par)
1557}
1558
1559#[inline]
1560fn fast_xt_diag_x_with_parallelism_impl<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1561    x: &ArrayBase<S1, Ix2>,
1562    w: &ArrayBase<S2, Ix1>,
1563    par: Par,
1564) -> Array2<f64> {
1565    use ndarray::ShapeBuilder;
1566
1567    let p = x.ncols();
1568    // F-order result so the symmetric lower-triangle accumulation writes
1569    // column-contiguously; the kernel mirrors to a full symmetric matrix.
1570    let mut result = Array2::<f64>::zeros((p, p).f());
1571    stream_weighted_crossprod_into(
1572        x,
1573        w,
1574        &mut result,
1575        CrossprodStructure::SymmetricLower,
1576        CrossprodAccum::Replace,
1577        par,
1578    );
1579    result
1580}
1581
1582/// Output packaging for [`stream_weighted_crossprod_into`].
1583#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1584pub enum CrossprodStructure {
1585    /// Compute every entry of the (symmetric) Gram via full GEMM.
1586    Full,
1587    /// Accumulate only the lower triangle via triangular matmul (~50% fewer
1588    /// FLOPs), then mirror once into the upper triangle for a full symmetric
1589    /// result. Mathematically identical output to [`Full`](Self::Full).
1590    SymmetricLower,
1591}
1592
1593/// Accumulation policy for [`stream_weighted_crossprod_into`].
1594#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1595pub enum CrossprodAccum {
1596    /// Overwrite `out` with `Xᵀ·diag(W)·X`, ignoring prior contents.
1597    Replace,
1598    /// Add `Xᵀ·diag(W)·X` into the existing contents of `out`.
1599    Add,
1600}
1601
1602/// Rows per streaming chunk so each `chunk_rows × cols` `f64` tile stays near an
1603/// 8 MiB working set, clamped to `[512, 131_072]` and never exceeding `n`.
1604///
1605/// One definition for the three streaming kernels below, which carried
1606/// byte-identical inline copies of this arithmetic. The copies computed the same
1607/// quantity from different column expressions (`p`, `px + q`, `pa + 2·pb`), so
1608/// the only thing that ever differed between them was the argument — which is
1609/// exactly the shape that should be a parameter rather than three transcriptions
1610/// of one rule (#2469).
1611///
1612/// **This is NOT [`crate::utils::row_chunk_for_byte_budget`], and the difference
1613/// is unresolved.** That function is documented as the "canonical home for the
1614/// row-chunk heuristic", takes the same `(n, cols)` and computes the same 8 MiB
1615/// budget — but clamps to `[256, 65_536]` rather than `[512, 131_072]`. These
1616/// kernels have always used the wider band; routing them through the canonical
1617/// helper would halve the floor and quarter the ceiling and is a behaviour
1618/// change, not a cleanup. Collapsing three copies into one makes that single
1619/// remaining divergence visible instead of triplicated; deciding which band is
1620/// right needs a measurement nobody has taken.
1621#[inline]
1622fn streaming_chunk_rows(cols: usize, n: usize) -> usize {
1623    // The library row-chunk target, IMPORTED rather than transcribed. Its own
1624    // doc says it is shared as a `const` "so compile-time consumers stay in
1625    // lockstep with `ResourcePolicy::default_library` without a runtime policy
1626    // query" -- which is exactly this call site's situation. Same quantity, not
1627    // merely the same number: this crate already consumes the runtime form of
1628    // it (`row_chunk_target_bytes`, `matrix/mod.rs`), and `gam-gpu`'s tile
1629    // geometry derives from the same const for the same reason.
1630    const TARGET_BYTES: usize = gam_runtime::resource::LIBRARY_ROW_CHUNK_TARGET_BYTES;
1631    const MIN_ROWS: usize = 512;
1632    const MAX_ROWS: usize = 131_072;
1633    (TARGET_BYTES / (cols.max(1) * std::mem::size_of::<f64>()))
1634        .clamp(MIN_ROWS, MAX_ROWS)
1635        .min(n)
1636}
1637
1638/// Shared dense weighted-Gram kernel: accumulate `Xᵀ·diag(W)·X` into `out`.
1639///
1640/// This is the single tuned implementation of the chunked row-scaling +
1641/// matmul strategy; the matrix-returning (`fast_xt_diag_x*`) entry points and
1642/// stream-in callers share it so that performance tuning, negative-weight
1643/// handling, chunk sizing, and layout fixes land in exactly one place.
1644///
1645/// Computes the product as `Xᵀ·(W·X)` to preserve the sign of `W`: the prior
1646/// `sqrt(max(0, w))`-then-Gram form clipped negative weights to zero, which
1647/// corrupted observed-Hessian assembly when any block carried heavy residuals
1648/// (e.g. under the logb σ link).
1649///
1650/// Peak working-set allocation is `chunk_rows × p × 8` bytes (~8 MB) rather
1651/// than `n × p × 8` bytes for a materialized `W·X`.
1652///
1653/// `out` must be `p × p`. With [`CrossprodStructure::SymmetricLower`] the
1654/// lower triangle is accumulated and then mirrored, so on return `out` holds
1655/// the full symmetric matrix regardless of `structure`.
1656pub fn stream_weighted_crossprod_into<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
1657    x: &ArrayBase<S1, Ix2>,
1658    w: &ArrayBase<S2, Ix1>,
1659    out: &mut Array2<f64>,
1660    structure: CrossprodStructure,
1661    accum: CrossprodAccum,
1662    par: Par,
1663) {
1664    use faer::Accum;
1665    use faer::linalg::matmul::matmul;
1666    use faer::linalg::matmul::triangular::{BlockStructure, matmul as tri_matmul};
1667    use ndarray::s;
1668
1669    let (n, p) = x.dim();
1670    assert_eq!(n, w.len(), "X rows must match W length");
1671    assert_eq!(out.nrows(), p, "output rows must match X cols");
1672    assert_eq!(out.ncols(), p, "output cols must match X cols");
1673    if p == 0 {
1674        return;
1675    }
1676    if n == 0 {
1677        if accum == CrossprodAccum::Replace {
1678            out.fill(0.0);
1679        }
1680        return;
1681    }
1682
1683    if !should_use_faer_matmul(p, p, n) {
1684        // Tiny products: ndarray's own GEMM avoids faer setup overhead.
1685        let w_x = Array2::from_shape_fn((n, p), |(i, j)| w[i] * x[[i, j]]);
1686        let gram = x.t().dot(&w_x);
1687        match accum {
1688            CrossprodAccum::Replace => out.assign(&gram),
1689            CrossprodAccum::Add => *out += &gram,
1690        }
1691        return;
1692    }
1693
1694    // Streaming chunked: peak allocation is chunk_rows × p instead of n × p.
1695    let chunk_rows = streaming_chunk_rows(p, n);
1696
1697    // Triangular accumulation requires a zero baseline in the lower triangle
1698    // because each chunk's `Accum::Add` lands there; for a Replace request we
1699    // zero up front and add every chunk, for an Add request the caller's
1700    // contents are preserved and every chunk adds on top.
1701    if accum == CrossprodAccum::Replace {
1702        out.fill(0.0);
1703    }
1704
1705    // Row-major wx_chunk so the per-row scaling loop has stride-1 writes
1706    // alongside stride-1 reads from a row-major X. An F-order wx_chunk would
1707    // force strided writes by `chunk_rows`, breaking vectorization and cache
1708    // locality on the per-PIRLS-iter Hessian assembly. faer's matmul handles
1709    // either layout via FaerArrayView.
1710    let mut wx_chunk = Array2::<f64>::zeros((chunk_rows, p));
1711
1712    let x_is_row_major = x.is_standard_layout();
1713    let w_slice_opt = w.as_slice();
1714
1715    // Scope the faer mutable view so its borrow on `out` ends before the
1716    // symmetric mirror step.
1717    {
1718        let mut out_view = array2_to_matmut(out);
1719        for start in (0..n).step_by(chunk_rows) {
1720            let rows = (n - start).min(chunk_rows);
1721            {
1722                let chunk_slice = wx_chunk
1723                    .as_slice_mut()
1724                    .expect("row-major chunk is contiguous");
1725                if x_is_row_major && let (Some(x_all), Some(w_all)) = (x.as_slice(), w_slice_opt) {
1726                    for local in 0..rows {
1727                        let src = start + local;
1728                        let wi = w_all[src];
1729                        let src_off = src * p;
1730                        let dst_off = local * p;
1731                        let src_row = &x_all[src_off..src_off + p];
1732                        let dst_row = &mut chunk_slice[dst_off..dst_off + p];
1733                        for col in 0..p {
1734                            dst_row[col] = src_row[col] * wi;
1735                        }
1736                    }
1737                } else {
1738                    let x_slice = x.slice(s![start..start + rows, ..]);
1739                    for local in 0..rows {
1740                        let wi = w[start + local];
1741                        let xrow = x_slice.row(local);
1742                        let dst_off = local * p;
1743                        let dst_row = &mut chunk_slice[dst_off..dst_off + p];
1744                        for (col, xij) in xrow.iter().enumerate() {
1745                            dst_row[col] = xij * wi;
1746                        }
1747                    }
1748                }
1749            }
1750            let x_slice = x.slice(s![start..start + rows, ..]);
1751            let wx_slice = wx_chunk.slice(s![0..rows, ..]);
1752            let x_view = FaerArrayView::new(&x_slice);
1753            let wx_view = FaerArrayView::new(&wx_slice);
1754            match structure {
1755                CrossprodStructure::SymmetricLower => {
1756                    // X^T diag(W) X is symmetric; accumulate the lower triangle
1757                    // only, then mirror once after the chunk loop. ~50% fewer
1758                    // FLOPs vs. full GEMM.
1759                    tri_matmul(
1760                        out_view.as_mut(),
1761                        BlockStructure::TriangularLower,
1762                        Accum::Add,
1763                        x_view.as_ref().transpose(),
1764                        BlockStructure::Rectangular,
1765                        wx_view.as_ref(),
1766                        BlockStructure::Rectangular,
1767                        1.0,
1768                        par,
1769                    );
1770                }
1771                CrossprodStructure::Full => {
1772                    matmul(
1773                        out_view.as_mut(),
1774                        Accum::Add,
1775                        x_view.as_ref().transpose(),
1776                        wx_view.as_ref(),
1777                        1.0,
1778                        par,
1779                    );
1780                }
1781            }
1782        }
1783    }
1784
1785    if structure == CrossprodStructure::SymmetricLower {
1786        // Mirror lower triangle to upper for a full symmetric output.
1787        for i in 0..p {
1788            for j in (i + 1)..p {
1789                out[[i, j]] = out[[j, i]];
1790            }
1791        }
1792    }
1793}
1794
1795/// Compute A^T * diag(W) * B using streaming chunks.
1796#[inline]
1797pub fn fast_xt_diag_y<S1: Data<Elem = f64>, S2: Data<Elem = f64>, S3: Data<Elem = f64>>(
1798    x: &ArrayBase<S1, Ix2>,
1799    w: &ArrayBase<S2, Ix1>,
1800    y: &ArrayBase<S3, Ix2>,
1801) -> Array2<f64> {
1802    assert_eq!(x.nrows(), y.nrows(), "fast_xt_diag_y X/Y row mismatch");
1803    assert_eq!(
1804        y.nrows(),
1805        w.len(),
1806        "fast_xt_diag_y row/weight length mismatch"
1807    );
1808    if let Some(out) = crate::gpu_hook::gpu_dispatch()
1809        .and_then(|d| d.try_fast_xt_diag_y(x.view(), w.view(), y.view()))
1810    {
1811        return out;
1812    }
1813    fast_xt_diag_y_impl(x, w, y)
1814}
1815
1816#[inline]
1817fn fast_xt_diag_y_impl<S1: Data<Elem = f64>, S2: Data<Elem = f64>, S3: Data<Elem = f64>>(
1818    x: &ArrayBase<S1, Ix2>,
1819    w: &ArrayBase<S2, Ix1>,
1820    y: &ArrayBase<S3, Ix2>,
1821) -> Array2<f64> {
1822    use faer::Accum;
1823    use faer::linalg::matmul::matmul;
1824    use ndarray::{ShapeBuilder, s};
1825
1826    let (n, q) = y.dim();
1827    let px = x.ncols();
1828    assert_eq!(n, w.len(), "Y rows must match W length");
1829    assert_eq!(n, x.nrows(), "X rows must match Y rows");
1830    if n == 0 || px == 0 || q == 0 {
1831        return Array2::<f64>::zeros((px, q));
1832    }
1833    if !should_use_faer_matmul(px, q, n) {
1834        let w_y = Array2::from_shape_fn((n, q), |(i, j)| w[i] * y[[i, j]]);
1835        return x.t().dot(&w_y);
1836    }
1837
1838    // Streaming: only allocate chunk_rows × q for the weighted Y slice.
1839    let total_cols = px + q;
1840    let chunk_rows = streaming_chunk_rows(total_cols, n);
1841
1842    let mut result = Array2::<f64>::zeros((px, q).f());
1843    // Row-major wy_chunk — same rationale as fast_xt_diag_x: stride-1
1844    // writes alongside stride-1 reads from a row-major Y.
1845    let mut wy_chunk = Array2::<f64>::zeros((chunk_rows, q));
1846
1847    let y_is_row_major = y.is_standard_layout();
1848    let w_slice_opt = w.as_slice();
1849
1850    {
1851        let mut out_view = array2_to_matmut(&mut result);
1852
1853        for start in (0..n).step_by(chunk_rows) {
1854            let rows = (n - start).min(chunk_rows);
1855            {
1856                let chunk_slice = wy_chunk
1857                    .as_slice_mut()
1858                    .expect("row-major chunk is contiguous");
1859                if y_is_row_major && let (Some(y_all), Some(w_all)) = (y.as_slice(), w_slice_opt) {
1860                    for local in 0..rows {
1861                        let src = start + local;
1862                        let wi = w_all[src];
1863                        let src_off = src * q;
1864                        let dst_off = local * q;
1865                        let src_row = &y_all[src_off..src_off + q];
1866                        let dst_row = &mut chunk_slice[dst_off..dst_off + q];
1867                        for col in 0..q {
1868                            dst_row[col] = src_row[col] * wi;
1869                        }
1870                    }
1871                } else {
1872                    let y_slice = y.slice(s![start..start + rows, ..]);
1873                    for local in 0..rows {
1874                        let wi = w[start + local];
1875                        let yrow = y_slice.row(local);
1876                        let dst_off = local * q;
1877                        let dst_row = &mut chunk_slice[dst_off..dst_off + q];
1878                        for (col, yij) in yrow.iter().enumerate() {
1879                            dst_row[col] = yij * wi;
1880                        }
1881                    }
1882                }
1883            }
1884            let x_slice = x.slice(s![start..start + rows, ..]);
1885            let wy_slice = wy_chunk.slice(s![0..rows, ..]);
1886            let x_view = FaerArrayView::new(&x_slice);
1887            let wy_view = FaerArrayView::new(&wy_slice);
1888            let par = matmul_parallelism(px, q, rows);
1889            matmul(
1890                out_view.as_mut(),
1891                Accum::Add,
1892                x_view.as_ref().transpose(),
1893                wy_view.as_ref(),
1894                1.0,
1895                par,
1896            );
1897        }
1898    }
1899
1900    result
1901}
1902
1903/// Compute the 2×2 block joint Hessian in a single streaming pass:
1904///   [X_a^T diag(w_aa) X_a,   X_a^T diag(w_ab) X_b]
1905///   [X_b^T diag(w_ab) X_a,   X_b^T diag(w_bb) X_b]
1906///
1907/// This reads X_a and X_b once per chunk instead of twice (saving 50% bandwidth).
1908pub fn fast_joint_hessian_2x2<
1909    S1: Data<Elem = f64>,
1910    S2: Data<Elem = f64>,
1911    S3: Data<Elem = f64>,
1912    S4: Data<Elem = f64>,
1913    S5: Data<Elem = f64>,
1914>(
1915    x_a: &ArrayBase<S1, Ix2>,
1916    x_b: &ArrayBase<S2, Ix2>,
1917    w_aa: &ArrayBase<S3, Ix1>,
1918    w_ab: &ArrayBase<S4, Ix1>,
1919    w_bb: &ArrayBase<S5, Ix1>,
1920) -> Array2<f64> {
1921    if let Some(out) = crate::gpu_hook::gpu_dispatch().and_then(|d| {
1922        d.try_fast_joint_hessian_2x2(
1923            x_a.view(),
1924            x_b.view(),
1925            w_aa.view(),
1926            w_ab.view(),
1927            w_bb.view(),
1928        )
1929    }) {
1930        return out;
1931    }
1932    fast_joint_hessian_2x2_impl(x_a, x_b, w_aa, w_ab, w_bb)
1933}
1934
1935#[inline]
1936fn fast_joint_hessian_2x2_impl<
1937    S1: Data<Elem = f64>,
1938    S2: Data<Elem = f64>,
1939    S3: Data<Elem = f64>,
1940    S4: Data<Elem = f64>,
1941    S5: Data<Elem = f64>,
1942>(
1943    x_a: &ArrayBase<S1, Ix2>,
1944    x_b: &ArrayBase<S2, Ix2>,
1945    w_aa: &ArrayBase<S3, Ix1>,
1946    w_ab: &ArrayBase<S4, Ix1>,
1947    w_bb: &ArrayBase<S5, Ix1>,
1948) -> Array2<f64> {
1949    use faer::Accum;
1950    use faer::linalg::matmul::matmul;
1951    use ndarray::{ShapeBuilder, s};
1952
1953    let n = x_a.nrows();
1954    let pa = x_a.ncols();
1955    let pb = x_b.ncols();
1956    let total = pa + pb;
1957    assert_eq!(n, x_b.nrows());
1958    assert_eq!(n, w_aa.len());
1959    assert_eq!(n, w_ab.len());
1960    assert_eq!(n, w_bb.len());
1961
1962    if n == 0 || total == 0 {
1963        return Array2::<f64>::zeros((total, total));
1964    }
1965
1966    // For small problems, fall back to separate computations
1967    if !should_use_faer_matmul(pa.max(pb), pa.max(pb), n) {
1968        let waa_xa = Array2::from_shape_fn((n, pa), |(i, j)| w_aa[i] * x_a[[i, j]]);
1969        let wab_xb = Array2::from_shape_fn((n, pb), |(i, j)| w_ab[i] * x_b[[i, j]]);
1970        let wbb_xb = Array2::from_shape_fn((n, pb), |(i, j)| w_bb[i] * x_b[[i, j]]);
1971        let mut out = Array2::<f64>::zeros((total, total));
1972        out.slice_mut(s![..pa, ..pa]).assign(&x_a.t().dot(&waa_xa));
1973        out.slice_mut(s![..pa, pa..]).assign(&x_a.t().dot(&wab_xb));
1974        out.slice_mut(s![pa.., pa..]).assign(&x_b.t().dot(&wbb_xb));
1975        // Mirror upper to lower
1976        for i in 0..total {
1977            for j in 0..i {
1978                out[[i, j]] = out[[j, i]];
1979            }
1980        }
1981        return out;
1982    }
1983
1984    // Need buffers for: waa_xa(chunk×pa) + wab_xb(chunk×pb) + wbb_xb(chunk×pb)
1985    let cols_needed = pa + 2 * pb;
1986    let chunk_rows = streaming_chunk_rows(cols_needed, n);
1987
1988    let mut out = Array2::<f64>::zeros((total, total).f());
1989    // Row-major weighted buffers so the per-row scale loops have stride-1
1990    // writes (the previous F-order layout strided writes by chunk_rows
1991    // across `pa` / `pb`, gutting vectorization on the per-PIRLS-iter
1992    // joint Hessian assembly). faer's matmul handles either layout.
1993    let mut waa_xa_buf = Array2::<f64>::zeros((chunk_rows, pa));
1994    let mut wab_xb_buf = Array2::<f64>::zeros((chunk_rows, pb));
1995    let mut wbb_xb_buf = Array2::<f64>::zeros((chunk_rows, pb));
1996
1997    let xa_is_row_major = x_a.is_standard_layout();
1998    let xb_is_row_major = x_b.is_standard_layout();
1999    let waa_slice_opt = w_aa.as_slice();
2000    let wab_slice_opt = w_ab.as_slice();
2001    let wbb_slice_opt = w_bb.as_slice();
2002
2003    {
2004        let mut out_mat = array2_to_matmut(&mut out);
2005
2006        for start in (0..n).step_by(chunk_rows) {
2007            let rows = (n - start).min(chunk_rows);
2008            let xa_slice = x_a.slice(s![start..start + rows, ..]);
2009            let xb_slice = x_b.slice(s![start..start + rows, ..]);
2010
2011            // Weight X_a and X_b in a single pass through this chunk.
2012            {
2013                let waa_chunk = waa_xa_buf
2014                    .as_slice_mut()
2015                    .expect("row-major waa chunk is contiguous");
2016                let wab_chunk = wab_xb_buf
2017                    .as_slice_mut()
2018                    .expect("row-major wab chunk is contiguous");
2019                let wbb_chunk = wbb_xb_buf
2020                    .as_slice_mut()
2021                    .expect("row-major wbb chunk is contiguous");
2022
2023                if xa_is_row_major
2024                    && xb_is_row_major
2025                    && let (Some(xa_all), Some(xb_all)) = (x_a.as_slice(), x_b.as_slice())
2026                    && let (Some(waa_all), Some(wab_all), Some(wbb_all)) =
2027                        (waa_slice_opt, wab_slice_opt, wbb_slice_opt)
2028                {
2029                    for local in 0..rows {
2030                        let i = start + local;
2031                        let waa_i = waa_all[i];
2032                        let wab_i = wab_all[i];
2033                        let wbb_i = wbb_all[i];
2034                        let xa_off = i * pa;
2035                        let xa_row = &xa_all[xa_off..xa_off + pa];
2036                        let xb_off = i * pb;
2037                        let xb_row = &xb_all[xb_off..xb_off + pb];
2038                        let waa_off = local * pa;
2039                        let wab_off = local * pb;
2040                        let wbb_off = local * pb;
2041                        let waa_row = &mut waa_chunk[waa_off..waa_off + pa];
2042                        for col in 0..pa {
2043                            waa_row[col] = xa_row[col] * waa_i;
2044                        }
2045                        let wab_row = &mut wab_chunk[wab_off..wab_off + pb];
2046                        let wbb_row = &mut wbb_chunk[wbb_off..wbb_off + pb];
2047                        for col in 0..pb {
2048                            let xij = xb_row[col];
2049                            wab_row[col] = xij * wab_i;
2050                            wbb_row[col] = xij * wbb_i;
2051                        }
2052                    }
2053                } else {
2054                    for local in 0..rows {
2055                        let i = start + local;
2056                        let waa_i = w_aa[i];
2057                        let wab_i = w_ab[i];
2058                        let wbb_i = w_bb[i];
2059                        let waa_off = local * pa;
2060                        let wab_off = local * pb;
2061                        let wbb_off = local * pb;
2062                        let waa_row = &mut waa_chunk[waa_off..waa_off + pa];
2063                        let xa_row = xa_slice.row(local);
2064                        for (col, xij) in xa_row.iter().enumerate() {
2065                            waa_row[col] = xij * waa_i;
2066                        }
2067                        let wab_row = &mut wab_chunk[wab_off..wab_off + pb];
2068                        let wbb_row = &mut wbb_chunk[wbb_off..wbb_off + pb];
2069                        let xb_row = xb_slice.row(local);
2070                        for (col, xij) in xb_row.iter().enumerate() {
2071                            wab_row[col] = xij * wab_i;
2072                            wbb_row[col] = xij * wbb_i;
2073                        }
2074                    }
2075                }
2076            }
2077
2078            let xa_view = FaerArrayView::new(&xa_slice);
2079            let xb_view = FaerArrayView::new(&xb_slice);
2080            let waa_xa_slice = waa_xa_buf.slice(s![0..rows, ..]);
2081            let wab_xb_slice = wab_xb_buf.slice(s![0..rows, ..]);
2082            let wbb_xb_slice = wbb_xb_buf.slice(s![0..rows, ..]);
2083            let waa_xa_view = FaerArrayView::new(&waa_xa_slice);
2084            let wab_xb_view = FaerArrayView::new(&wab_xb_slice);
2085            let wbb_xb_view = FaerArrayView::new(&wbb_xb_slice);
2086
2087            // Block [0..pa, 0..pa]: X_a^T diag(w_aa) X_a
2088            matmul(
2089                out_mat.rb_mut().submatrix_mut(0, 0, pa, pa),
2090                Accum::Add,
2091                xa_view.as_ref().transpose(),
2092                waa_xa_view.as_ref(),
2093                1.0,
2094                matmul_parallelism(pa, pa, rows),
2095            );
2096            // Block [0..pa, pa..total]: X_a^T diag(w_ab) X_b
2097            matmul(
2098                out_mat.rb_mut().submatrix_mut(0, pa, pa, pb),
2099                Accum::Add,
2100                xa_view.as_ref().transpose(),
2101                wab_xb_view.as_ref(),
2102                1.0,
2103                matmul_parallelism(pa, pb, rows),
2104            );
2105            // Block [pa..total, pa..total]: X_b^T diag(w_bb) X_b
2106            matmul(
2107                out_mat.rb_mut().submatrix_mut(pa, pa, pb, pb),
2108                Accum::Add,
2109                xb_view.as_ref().transpose(),
2110                wbb_xb_view.as_ref(),
2111                1.0,
2112                matmul_parallelism(pb, pb, rows),
2113            );
2114        }
2115    } // out_mat dropped
2116    // Mirror upper triangle to lower
2117    for i in 0..total {
2118        for j in 0..i {
2119            out[[i, j]] = out[[j, i]];
2120        }
2121    }
2122    out
2123}
2124
2125fn mat_to_array(mat: MatRef<'_, f64>) -> Array2<f64> {
2126    let nrows = mat.nrows();
2127    let ncols = mat.ncols();
2128    let mut out = Array2::<f64>::zeros((nrows, ncols));
2129    if nrows == 0 || ncols == 0 {
2130        return out;
2131    }
2132    // ndarray is row-major by default. Write row-by-row for best cache behavior
2133    // on the output side.
2134    if let Some(out_slice) = out.as_slice_memory_order_mut() {
2135        // Row-major: out_slice[i * ncols + j] = mat[(i, j)]
2136        for i in 0..nrows {
2137            let row_start = i * ncols;
2138            for j in 0..ncols {
2139                out_slice[row_start + j] = mat[(i, j)];
2140            }
2141        }
2142    } else {
2143        for j in 0..ncols {
2144            for i in 0..nrows {
2145                out[[i, j]] = mat[(i, j)];
2146            }
2147        }
2148    }
2149    out
2150}
2151
2152/// Write faer matmul result A*B directly into a pre-allocated ndarray Array2.
2153/// Avoids the intermediate faer::Mat allocation and mat_to_array copy.
2154#[inline]
2155pub fn fast_ab_into<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
2156    a: &ArrayBase<S1, Ix2>,
2157    b: &ArrayBase<S2, Ix2>,
2158    out: &mut Array2<f64>,
2159) {
2160    fast_ab_into_impl(a, b, out);
2161}
2162
2163#[inline]
2164fn fast_ab_into_impl<S1: Data<Elem = f64>, S2: Data<Elem = f64>>(
2165    a: &ArrayBase<S1, Ix2>,
2166    b: &ArrayBase<S2, Ix2>,
2167    out: &mut Array2<f64>,
2168) {
2169    use faer::Accum;
2170    use faer::linalg::matmul::matmul;
2171
2172    let (n, p) = a.dim();
2173    let (p_b, q) = b.dim();
2174    assert_eq!(p, p_b, "A and B must have compatible inner dimensions");
2175    assert_eq!(out.dim(), (n, q), "output dimensions must match A*B result");
2176
2177    if !should_use_faer_matmul(n, q, p) {
2178        out.assign(&a.dot(b));
2179        return;
2180    }
2181
2182    let aview = FaerArrayView::new(a);
2183    let bview = FaerArrayView::new(b);
2184    let a_ref = aview.as_ref();
2185    let b_ref = bview.as_ref();
2186
2187    let par = matmul_parallelism(n, q, p);
2188    let mut outview = array2_to_matmut(out);
2189    matmul(outview.as_mut(), Accum::Replace, a_ref, b_ref, 1.0, par);
2190}
2191
2192fn diag_to_array(diag: DiagRef<'_, f64>) -> Array1<f64> {
2193    let mat = diag.column_vector().as_mat();
2194    let mut out = Array1::<f64>::zeros(mat.nrows());
2195    for i in 0..mat.nrows() {
2196        out[i] = mat[(i, 0)];
2197    }
2198    out
2199}
2200
2201pub struct FaerArrayView<'a> {
2202    ptr: *const f64,
2203    rows: usize,
2204    cols: usize,
2205    row_stride: isize,
2206    col_stride: isize,
2207    owned: Option<Array2<f64>>,
2208    marker: PhantomData<&'a f64>,
2209}
2210
2211impl<'a> FaerArrayView<'a> {
2212    #[inline]
2213    pub fn new<S: Data<Elem = f64>>(array: &'a ArrayBase<S, Ix2>) -> Self {
2214        let (rows, cols) = array.dim();
2215        let strides = array.strides();
2216        // Guard against layouts that can alias or reverse memory traversal (e.g.
2217        // negative/zero strides). These can violate assumptions in faer kernels.
2218        // For such layouts we materialize a compact owned copy.
2219        if strides[0] <= 0 || strides[1] <= 0 {
2220            let owned = array.to_owned();
2221            let owned_strides = owned.strides();
2222            return Self {
2223                ptr: owned.as_ptr(),
2224                rows,
2225                cols,
2226                row_stride: owned_strides[0],
2227                col_stride: owned_strides[1],
2228                owned: Some(owned),
2229                marker: PhantomData,
2230            };
2231        }
2232
2233        Self {
2234            ptr: array.as_ptr(),
2235            rows,
2236            cols,
2237            row_stride: strides[0],
2238            col_stride: strides[1],
2239            owned: None,
2240            marker: PhantomData,
2241        }
2242    }
2243
2244    #[inline]
2245    pub fn as_ref(&self) -> MatRef<'_, f64> {
2246        let (ptr, rows, cols, row_stride, col_stride) = if let Some(owned) = &self.owned {
2247            let strides = owned.strides();
2248            (
2249                owned.as_ptr(),
2250                owned.nrows(),
2251                owned.ncols(),
2252                strides[0],
2253                strides[1],
2254            )
2255        } else {
2256            (
2257                self.ptr,
2258                self.rows,
2259                self.cols,
2260                self.row_stride,
2261                self.col_stride,
2262            )
2263        };
2264        // SAFETY: ptr/shape/strides come from either a live ndarray view
2265        // (positive strides, validated bounds/alignment) or the owned
2266        // compact copy held inside this wrapper — no mutable aliasing.
2267        unsafe { MatRef::from_raw_parts(ptr, rows, cols, row_stride, col_stride) }
2268    }
2269}
2270
2271pub struct FaerColView<'a> {
2272    ptr: *const f64,
2273    len: usize,
2274    stride: isize,
2275    owned: Option<Array1<f64>>,
2276    marker: PhantomData<&'a f64>,
2277}
2278
2279impl<'a> FaerColView<'a> {
2280    #[inline]
2281    pub fn new<S: Data<Elem = f64>>(array: &'a ArrayBase<S, Ix1>) -> Self {
2282        let len = array.len();
2283        let stride = array.strides()[0];
2284        if stride <= 0 {
2285            let owned = array.to_owned();
2286            return Self {
2287                ptr: owned.as_ptr(),
2288                len,
2289                stride: 1,
2290                owned: Some(owned),
2291                marker: PhantomData,
2292            };
2293        }
2294        Self {
2295            ptr: array.as_ptr(),
2296            len,
2297            stride,
2298            owned: None,
2299            marker: PhantomData,
2300        }
2301    }
2302
2303    #[inline]
2304    pub fn as_ref(&self) -> MatRef<'_, f64> {
2305        let (ptr, len, stride) = if let Some(owned) = &self.owned {
2306            (owned.as_ptr(), owned.len(), 1)
2307        } else {
2308            (self.ptr, self.len, self.stride)
2309        };
2310        // SAFETY: ptr/len/stride come from either a live ndarray column
2311        // (positive stride, validated bounds/alignment) or the owned
2312        // compact copy; ncols=1 so the 0 col-stride is unused.
2313        unsafe { MatRef::from_raw_parts(ptr, len, 1, stride, 0) }
2314    }
2315}
2316
2317pub trait FaerSvd {
2318    fn svd(
2319        &self,
2320        compute_u: bool,
2321        computevt: bool,
2322    ) -> Result<(Option<Array2<f64>>, Array1<f64>, Option<Array2<f64>>), FaerLinalgError>;
2323}
2324
2325impl<S: Data<Elem = f64>> FaerSvd for ArrayBase<S, Ix2> {
2326    fn svd(
2327        &self,
2328        compute_u: bool,
2329        computevt: bool,
2330    ) -> Result<(Option<Array2<f64>>, Array1<f64>, Option<Array2<f64>>), FaerLinalgError> {
2331        let faerview = FaerArrayView::new(self);
2332        let faer_mat = faerview.as_ref();
2333        if !compute_u && !computevt {
2334            let (rows, cols) = faer_mat.shape();
2335            let mut singular = Diag::<f64>::zeros(rows.min(cols));
2336            let par = get_global_parallelism();
2337            let mut mem = MemBuffer::new(svd::svd_scratch::<f64>(
2338                rows,
2339                cols,
2340                ComputeSvdVectors::No,
2341                ComputeSvdVectors::No,
2342                par,
2343                Default::default(),
2344            ));
2345            let stack = MemStack::new(&mut mem);
2346            svd::svd(
2347                faer_mat,
2348                singular.as_mut(),
2349                None,
2350                None,
2351                par,
2352                stack,
2353                Default::default(),
2354            )
2355            .map_err(|_| FaerLinalgError::SvdNoConvergence {
2356                context: "faer SVD singular values only",
2357            })?;
2358            let singularvalues = diag_to_array(singular.as_ref());
2359            return Ok((None, singularvalues, None));
2360        }
2361
2362        let (rows, cols) = faer_mat.shape();
2363        let rank = rows.min(cols);
2364        let compute_u_flag = if compute_u {
2365            ComputeSvdVectors::Thin
2366        } else {
2367            ComputeSvdVectors::No
2368        };
2369        let computev_flag = if computevt {
2370            ComputeSvdVectors::Thin
2371        } else {
2372            ComputeSvdVectors::No
2373        };
2374
2375        let mut singular = Diag::<f64>::zeros(rows.min(cols));
2376        let mut u_storage = compute_u.then(|| Mat::<f64>::zeros(rows, rank));
2377        let mut v_storage = computevt.then(|| Mat::<f64>::zeros(cols, rank));
2378
2379        let par = get_global_parallelism();
2380        let mut mem = MemBuffer::new(svd::svd_scratch::<f64>(
2381            rows,
2382            cols,
2383            compute_u_flag,
2384            computev_flag,
2385            par,
2386            Default::default(),
2387        ));
2388        let stack = MemStack::new(&mut mem);
2389
2390        svd::svd(
2391            faer_mat.as_ref(),
2392            singular.as_mut(),
2393            u_storage.as_mut().map(|mat| mat.as_mut()),
2394            v_storage.as_mut().map(|mat| mat.as_mut()),
2395            par,
2396            stack,
2397            Default::default(),
2398        )
2399        .map_err(|_| FaerLinalgError::SvdNoConvergence {
2400            context: "faer SVD with vectors",
2401        })?;
2402
2403        let singularvalues = diag_to_array(singular.as_ref());
2404        let u_opt = u_storage.map(|mat| mat_to_array(mat.as_ref()));
2405        let vt_opt = v_storage.map(|mat| {
2406            let mat_ref = mat.as_ref();
2407            let mut out = Array2::<f64>::zeros((mat_ref.ncols(), mat_ref.nrows()));
2408            for j in 0..mat_ref.nrows() {
2409                for i in 0..mat_ref.ncols() {
2410                    out[[i, j]] = mat_ref[(j, i)];
2411                }
2412            }
2413            out
2414        });
2415
2416        Ok((u_opt, singularvalues, vt_opt))
2417    }
2418}
2419
2420pub trait FaerEigh {
2421    fn eigh(&self, side: Side) -> Result<(Array1<f64>, Array2<f64>), FaerLinalgError>;
2422}
2423
2424/// Strict self-adjoint eigendecomposition of the exact supplied matrix.
2425///
2426/// This entrypoint performs finite/symmetry validation and one direct faer EVD
2427/// attempt. It never symmetrizes, rescales, jitters the diagonal, or subtracts
2428/// a repair afterward. Rank and pseudoinverse code must use this function so
2429/// its reported spectrum belongs to the matrix the caller supplied.
2430pub fn strict_symmetric_eigh<S: Data<Elem = f64>>(
2431    matrix: &ArrayBase<S, Ix2>,
2432    side: Side,
2433) -> Result<(Array1<f64>, Array2<f64>), FaerLinalgError> {
2434    let owned = matrix.to_owned();
2435    if owned.nrows() == 0 || owned.nrows() != owned.ncols() {
2436        return Err(FaerLinalgError::StrictSelfAdjointEigenInvalidInput {
2437            reason: format!(
2438                "expected non-empty square matrix, got {}x{}",
2439                owned.nrows(),
2440                owned.ncols()
2441            ),
2442        });
2443    }
2444    crate::utils::validate_finite_symmetric_matrix(
2445        &owned,
2446        "strict self-adjoint eigendecomposition",
2447    )
2448    .map_err(
2449        |error| FaerLinalgError::StrictSelfAdjointEigenInvalidInput {
2450            reason: error.to_string(),
2451        },
2452    )?;
2453    let view = FaerArrayView::new(&owned);
2454    let eigen = catch_unwind(AssertUnwindSafe(|| view.as_ref().self_adjoint_eigen(side)))
2455        .map_err(|_| FaerLinalgError::FactorizationFailed {
2456            context: "strict self-adjoint eigendecomposition panic boundary",
2457        })?
2458        .map_err(FaerLinalgError::SelfAdjointEigen)?;
2459    let values = diag_to_array(eigen.S());
2460    let vectors = mat_to_array(eigen.U());
2461    if values.iter().any(|value| !value.is_finite())
2462        || vectors.iter().any(|value| !value.is_finite())
2463    {
2464        return Err(FaerLinalgError::SelfAdjointEigenNonFiniteInput {
2465            context: "strict self-adjoint eigendecomposition output validation",
2466        });
2467    }
2468    Ok((values, vectors))
2469}
2470
2471impl<S: Data<Elem = f64>> FaerEigh for ArrayBase<S, Ix2> {
2472    fn eigh(&self, side: Side) -> Result<(Array1<f64>, Array2<f64>), FaerLinalgError> {
2473        fn try_eigh(
2474            matrix: &Array2<f64>,
2475            side: Side,
2476        ) -> Result<(Array1<f64>, Array2<f64>), FaerLinalgError> {
2477            let faerview = FaerArrayView::new(matrix);
2478            // #2267/#2738 — time the decomposition and name the parallelism that
2479            // actually governed it.
2480            //
2481            // `self_adjoint_eigen` is one of faer's high-level entry points: it
2482            // takes no parallelism argument and reads
2483            // `faer::get_global_parallelism()` internally. So the policy that
2484            // decides whether this runs on one core or many is the PROCESS-GLOBAL
2485            // one — which a live `FaerSequentialScope` anywhere in the process
2486            // pins to `Par::Seq` for every thread — and NOT
2487            // `effective_global_parallelism`, whose nested-region guard cannot
2488            // reach here. Reporting the wrong one would name a policy that did
2489            // not apply.
2490            //
2491            // This is `O(dim^3)`, so at `dim` in the thousands the difference
2492            // between sequential and a wide pool is hours. #2267 lost two
2493            // three-hour jobs to a decomposition that was silent about both its
2494            // duration and its parallelism; the count makes "one slow call or
2495            // many?" answerable without a second run.
2496            let eigh_started = std::time::Instant::now();
2497            let eigh_par = get_global_parallelism();
2498            let eigen = catch_unwind(AssertUnwindSafe(|| {
2499                faerview.as_ref().self_adjoint_eigen(side)
2500            }))
2501            .map_err(|_| FaerLinalgError::FactorizationFailed {
2502                context: "self-adjoint eigendecomposition panic boundary",
2503            })?
2504            .map_err(FaerLinalgError::SelfAdjointEigen)?;
2505            let eigh_elapsed = eigh_started.elapsed();
2506            let eigh_calls = EIGH_CALLS.fetch_add(1, Ordering::Relaxed) + 1;
2507            if eigh_par == Par::Seq {
2508                EIGH_SEQ_CALLS.fetch_add(1, Ordering::Relaxed);
2509            }
2510            EIGH_MAX_DIM.fetch_max(matrix.nrows() as u64, Ordering::Relaxed);
2511            let eigh_nanos_total = EIGH_NANOS
2512                .fetch_add(eigh_elapsed.as_nanos() as u64, Ordering::Relaxed)
2513                + eigh_elapsed.as_nanos() as u64;
2514            record_thread_eigh(
2515                eigh_par == Par::Seq,
2516                matrix.nrows() as u64,
2517                eigh_elapsed.as_nanos() as u64,
2518            );
2519            log::debug!(
2520                "[eigh] dim={} elapsed={:.3}s faer_global_parallelism={:?} \
2521                 calls_so_far={eigh_calls} cumulative={:.3}s",
2522                matrix.nrows(),
2523                eigh_elapsed.as_secs_f64(),
2524                eigh_par,
2525                eigh_nanos_total as f64 / 1e9,
2526            );
2527            let values = diag_to_array(eigen.S());
2528            let vectors = mat_to_array(eigen.U());
2529            Ok((values, vectors))
2530        }
2531
2532        let owned = self.to_owned();
2533        if owned.nrows() != owned.ncols() {
2534            return Err(FaerLinalgError::FactorizationFailed {
2535                context: "self-adjoint eigendecomposition non-square input",
2536            });
2537        }
2538        if owned.nrows() == 0 {
2539            return Ok((Array1::zeros(0), Array2::zeros((0, 0))));
2540        }
2541        if owned.iter().any(|value| !value.is_finite()) {
2542            return Err(FaerLinalgError::SelfAdjointEigenNonFiniteInput {
2543                context: "self-adjoint eigendecomposition input validation",
2544            });
2545        }
2546        if let Ok((evals, evecs)) = try_eigh(&owned, side)
2547            && evals.iter().all(|value| value.is_finite())
2548            && evecs.iter().all(|value| value.is_finite())
2549        {
2550            return Ok((evals, evecs));
2551        }
2552
2553        let mut repaired = owned.clone();
2554        crate::matrix::symmetrize_in_place(&mut repaired);
2555
2556        let scale = repaired
2557            .iter()
2558            .fold(0.0_f64, |acc, &value| acc.max(value.abs()))
2559            .max(1.0);
2560        let scaled = repaired.mapv(|value| value / scale);
2561        // Relative diagonal-jitter ladder for the eigendecomposition repair: the
2562        // matrix is pre-scaled to unit max-abs, so these are fractions of its
2563        // scale. We try the unperturbed matrix first, then escalate the ridge by
2564        // two decades per attempt until the factorization yields all-finite
2565        // eigenpairs, accepting the smallest jitter that succeeds.
2566        const JITTER_SCHEDULE: [f64; 6] = [0.0, 1e-12, 1e-10, 1e-8, 1e-6, 1e-4];
2567        let jitter_schedule = JITTER_SCHEDULE;
2568        let mut last_error = FaerLinalgError::FactorizationFailed {
2569            context: "self-adjoint eigendecomposition repair attempts",
2570        };
2571
2572        for &jitter in &jitter_schedule {
2573            let mut candidate = scaled.clone();
2574            if jitter > 0.0 {
2575                let n = candidate.nrows();
2576                for i in 0..n {
2577                    candidate[[i, i]] += jitter;
2578                }
2579            }
2580
2581            match try_eigh(&candidate, side) {
2582                Ok((mut evals, evecs))
2583                    if evals.iter().all(|value| value.is_finite())
2584                        && evecs.iter().all(|value| value.is_finite()) =>
2585                {
2586                    for value in &mut evals {
2587                        *value = (*value - jitter) * scale;
2588                    }
2589                    return Ok((evals, evecs));
2590                }
2591                Ok((_, _)) => {
2592                    last_error = FaerLinalgError::SelfAdjointEigenNonFiniteInput {
2593                        context: "self-adjoint eigendecomposition repaired output validation",
2594                    };
2595                }
2596                Err(err) => {
2597                    last_error = err;
2598                }
2599            }
2600        }
2601
2602        Err(last_error)
2603    }
2604}
2605
2606pub struct FaerCholeskyFactor {
2607    factor: solvers::Llt<f64>,
2608}
2609
2610impl FaerCholeskyFactor {
2611    pub fn solvevec(&self, rhs: &Array1<f64>) -> Array1<f64> {
2612        let mut rhs = rhs.to_owned();
2613        let mut rhsview = array1_to_col_matmut(&mut rhs);
2614        self.factor.solve_in_place(rhsview.as_mut());
2615        rhs
2616    }
2617
2618    pub fn solve_mat_in_place(&self, rhs: &mut Array2<f64>) {
2619        let mut rhsview = array2_to_matmut(rhs);
2620        self.factor.solve_in_place(rhsview.as_mut());
2621    }
2622
2623    pub fn solve_mat_into<S: Data<Elem = f64>>(
2624        &self,
2625        rhs: &ArrayBase<S, Ix2>,
2626        out: &mut Array2<f64>,
2627    ) {
2628        if out.dim() != rhs.dim() {
2629            *out = Array2::<f64>::zeros(rhs.dim());
2630        }
2631        out.assign(rhs);
2632        self.solve_mat_in_place(out);
2633    }
2634
2635    pub fn solve_mat(&self, rhs: &Array2<f64>) -> Array2<f64> {
2636        let mut out = Array2::<f64>::zeros(rhs.dim());
2637        self.solve_mat_into(rhs, &mut out);
2638        out
2639    }
2640
2641    pub fn diag(&self) -> Array1<f64> {
2642        diag_to_array(self.factor.L().diagonal())
2643    }
2644
2645    pub fn lower_triangular(&self) -> Array2<f64> {
2646        mat_to_array(self.factor.L())
2647    }
2648}
2649
2650impl crate::matrix::FactorizedSystem for FaerCholeskyFactor {
2651    fn solve(&self, rhs: &Array1<f64>) -> Result<Array1<f64>, String> {
2652        let out = self.solvevec(rhs);
2653        if out.iter().all(|value| value.is_finite()) {
2654            Ok(out)
2655        } else {
2656            Err("strict Cholesky solve produced non-finite values".to_string())
2657        }
2658    }
2659
2660    fn solvemulti(&self, rhs: &Array2<f64>) -> Result<Array2<f64>, String> {
2661        let out = self.solve_mat(rhs);
2662        if out.iter().all(|value| value.is_finite()) {
2663            Ok(out)
2664        } else {
2665            Err("strict Cholesky multi-solve produced non-finite values".to_string())
2666        }
2667    }
2668
2669    fn logdet(&self) -> f64 {
2670        cholesky_factor_logdet(self.factor.L())
2671    }
2672}
2673
2674pub trait FaerCholesky {
2675    fn cholesky(&self, side: Side) -> Result<FaerCholeskyFactor, FaerLinalgError>;
2676}
2677
2678impl<S: Data<Elem = f64>> FaerCholesky for ArrayBase<S, Ix2> {
2679    fn cholesky(&self, side: Side) -> Result<FaerCholeskyFactor, FaerLinalgError> {
2680        let faerview = FaerArrayView::new(self);
2681        let factor = faerview
2682            .as_ref()
2683            .llt(side)
2684            .map_err(FaerLinalgError::Cholesky)?;
2685        Ok(FaerCholeskyFactor { factor })
2686    }
2687}
2688
2689pub trait FaerQr {
2690    fn qr(&self) -> Result<(Array2<f64>, Array2<f64>), FaerLinalgError>;
2691}
2692
2693impl<S: Data<Elem = f64>> FaerQr for ArrayBase<S, Ix2> {
2694    fn qr(&self) -> Result<(Array2<f64>, Array2<f64>), FaerLinalgError> {
2695        let faerview = FaerArrayView::new(self);
2696        let qr = faerview.as_ref().qr();
2697        let q = qr.compute_thin_Q();
2698        let r = qr.thin_R();
2699        Ok((mat_to_array(q.as_ref()), mat_to_array(r)))
2700    }
2701}
2702
2703/// Compute an orthonormal basis for `null(a^T)` using column-pivoted QR on `a`.
2704///
2705/// This is intended for tall/skinny matrices where `a ∈ R^{m×n}` with `m >= n`.
2706/// If `A P^T = Q R`, then the trailing `m-rank(A)` columns of `Q` span
2707/// `null(A^T)`.
2708///
2709/// The trailing columns of `Q` are reconstructed by applying the stored
2710/// Householder reflector sequence to canonical basis vectors. When `A` is
2711/// numerically rank zero (e.g. an entirely unpenalized block penalty in a
2712/// parametric-only GLM), *every* reflector is degenerate — the Householder
2713/// vector of a zero column has zero norm, so faer's coefficients become
2714/// non-finite and the reconstructed basis is filled with `NaN`. Mathematically
2715/// a rank-zero `m×n` matrix has `null(A^T) = R^m`, whose canonical orthonormal
2716/// basis is the identity, so we return `I_m` directly instead of routing through
2717/// the (undefined) reflectors. This keeps every downstream consumer — REML
2718/// null-space log-determinants, identifiability audits — finite and exact for
2719/// the fully-unpenalized case. For `rank >= 1` at least one well-defined
2720/// reflector seeds the block, and the reconstruction stays finite.
2721pub fn rrqr_nullspace_basis<S: Data<Elem = f64>>(
2722    a: &ArrayBase<S, Ix2>,
2723    rank_alpha: f64,
2724) -> Result<(Array2<f64>, usize), FaerLinalgError> {
2725    rrqr_nullspace_basis_inner(a, RrqrRankCutoff::RelativeAlpha(rank_alpha))
2726}
2727
2728/// Which absolute cutoff on `|R_ii|` separates rank from null in
2729/// [`rrqr_nullspace_basis_with_cutoff`] / [`rrqr_nullspace_basis`].
2730#[derive(Debug, Clone, Copy)]
2731enum RrqrRankCutoff {
2732    /// `rank_alpha · ε · max(m, n) · max(|R₀₀|, 1)` — a machine-precision
2733    /// cutoff derived from the factorization's own leading pivot. This asks
2734    /// "is this direction numerically distinguishable from zero at all?".
2735    RelativeAlpha(f64),
2736    /// A caller-supplied absolute cutoff in the units of `a`'s singular
2737    /// values. Use this when the null/range partition is fixed by an external
2738    /// convention (e.g. a penalty spectrum's `spectral_tolerance`) rather than
2739    /// by float representability, so that two consumers of the same object
2740    /// cannot disagree about which directions are unpenalized.
2741    Absolute(f64),
2742}
2743
2744/// [`rrqr_nullspace_basis`] with an explicit absolute cutoff on the pivoted
2745/// `|R_ii|` (i.e. in the units of `a`'s singular values) instead of the
2746/// machine-precision `rank_alpha` heuristic.
2747///
2748/// A rank decision is a *convention*, not a fact about floats: the same matrix
2749/// has a different null space depending on the scale below which a direction
2750/// counts as unpenalized. When one consumer answers that question with
2751/// machine-epsilon and another with a penalty-spectrum tolerance five decades
2752/// looser, they silently describe different models of the same block (gam#2433:
2753/// a Duchon smooth's realized penalty topology changed between two builders for
2754/// exactly this reason). Callers that already own such a convention pass it
2755/// here rather than re-deriving one.
2756pub fn rrqr_nullspace_basis_with_cutoff<S: Data<Elem = f64>>(
2757    a: &ArrayBase<S, Ix2>,
2758    cutoff: f64,
2759) -> Result<(Array2<f64>, usize), FaerLinalgError> {
2760    rrqr_nullspace_basis_inner(a, RrqrRankCutoff::Absolute(cutoff))
2761}
2762
2763fn rrqr_nullspace_basis_inner<S: Data<Elem = f64>>(
2764    a: &ArrayBase<S, Ix2>,
2765    cutoff: RrqrRankCutoff,
2766) -> Result<(Array2<f64>, usize), FaerLinalgError> {
2767    let faerview = FaerArrayView::new(a);
2768    let qr = faerview.as_ref().col_piv_qr();
2769    let r = qr.thin_R();
2770    let diag_len = r.nrows().min(r.ncols());
2771    let leading_diag = if diag_len > 0 { r[(0, 0)].abs() } else { 0.0 };
2772    let tol = match cutoff {
2773        RrqrRankCutoff::RelativeAlpha(rank_alpha) => {
2774            rank_alpha
2775                * f64::EPSILON
2776                * (a.nrows().max(a.ncols()).max(1) as f64)
2777                * leading_diag.max(1.0)
2778        }
2779        RrqrRankCutoff::Absolute(tol) => tol,
2780    };
2781    let rank = (0..diag_len).filter(|&i| r[(i, i)].abs() > tol).count();
2782    let z = if rank >= a.nrows() {
2783        Array2::<f64>::zeros((a.nrows(), 0))
2784    } else if rank == 0 {
2785        // Numerically rank-zero input: the whole space is the null space.
2786        // Return the canonical orthonormal basis directly; the Householder
2787        // reflectors of a zero matrix are degenerate and would yield NaN.
2788        Array2::<f64>::eye(a.nrows())
2789    } else {
2790        let nullity = a.nrows() - rank;
2791        let mut selector = Mat::<f64>::zeros(a.nrows(), nullity);
2792        for j in 0..nullity {
2793            selector[(rank + j, j)] = 1.0;
2794        }
2795        let par = get_global_parallelism();
2796        faer::linalg::householder::apply_block_householder_sequence_on_the_left_in_place_with_conj(
2797            qr.Q_basis(),
2798            qr.Q_coeff(),
2799            Conj::No,
2800            selector.as_mut(),
2801            par,
2802            MemStack::new(&mut MemBuffer::new(
2803                faer::linalg::householder::apply_block_householder_sequence_on_the_left_in_place_scratch::<f64>(
2804                    a.nrows(),
2805                    qr.Q_coeff().nrows(),
2806                    nullity,
2807                ),
2808            )),
2809        );
2810        mat_to_array(selector.as_ref())
2811    };
2812    Ok((z, rank))
2813}
2814
2815#[inline]
2816pub const fn default_rrqr_rank_alpha() -> f64 {
2817    RRQR_RANK_ALPHA
2818}
2819
2820/// Result of a column-pivoted QR with rank detection and column permutation.
2821///
2822/// `A · P = Q · R` where the permutation `P` is exposed as the forward index
2823/// array: column `j` of `A · P` corresponds to original column
2824/// `column_permutation[j]` of `A`. With rank `r < min(m, n)`, the trailing
2825/// `min(m, n) - r` entries of `column_permutation` name the columns that the
2826/// pivoted QR demoted past the rank threshold — i.e., the columns identified
2827/// as redundant. Identifiability auditors (`identifiability::audit`)
2828/// use that suffix to attribute `DroppedColumn` entries to specific original
2829/// columns.
2830pub struct RrqrWithPermutation {
2831    pub rank: usize,
2832    pub column_permutation: Vec<usize>,
2833    pub leading_diag_abs: f64,
2834    pub rank_tol: f64,
2835}
2836
2837/// Column-pivoted rank-revealing QR returning the rank, the column permutation,
2838/// and the rank-detection tolerance. Use this when callers need to name which
2839/// columns the pivoted QR demoted past the rank threshold.
2840///
2841/// The rank cutoff matches [`rrqr_nullspace_basis`]: a column-pivoted QR is
2842/// computed on `a`; columns with `|R[i, i]| > tol` count toward the rank,
2843/// where `tol = rank_alpha · eps · max(m, n, 1) · max(|R[0, 0]|, 1)`. Returns
2844/// `Err` when `a` has zero rows.
2845pub fn rrqr_with_permutation<S: Data<Elem = f64>>(
2846    a: &ArrayBase<S, Ix2>,
2847    rank_alpha: f64,
2848) -> Result<RrqrWithPermutation, FaerLinalgError> {
2849    if a.nrows() == 0 {
2850        return Err(FaerLinalgError::FactorizationFailed {
2851            context: "rrqr_with_permutation: input has zero rows",
2852        });
2853    }
2854    let faerview = FaerArrayView::new(a);
2855    let qr = faerview.as_ref().col_piv_qr();
2856    let r = qr.thin_R();
2857    let diag_len = r.nrows().min(r.ncols());
2858    let leading_diag = if diag_len > 0 { r[(0, 0)].abs() } else { 0.0 };
2859    let tol = rank_alpha
2860        * f64::EPSILON
2861        * (a.nrows().max(a.ncols()).max(1) as f64)
2862        * leading_diag.max(1.0);
2863    let rank = (0..diag_len).filter(|&i| r[(i, i)].abs() > tol).count();
2864    let (forward, _inverse) = qr.P().arrays();
2865    let column_permutation: Vec<usize> = forward.iter().copied().map(|idx| idx.unbound()).collect();
2866    Ok(RrqrWithPermutation {
2867        rank,
2868        column_permutation,
2869        leading_diag_abs: leading_diag,
2870        rank_tol: tol,
2871    })
2872}
2873
2874/// Result of a Gram-driven column-pivoted RRQR (see
2875/// [`rrqr_from_gram_with_permutation`]). Carries the same rank / permutation /
2876/// tolerance as [`RrqrWithPermutation`], plus a `verdict_margin` that measures
2877/// how unambiguous the rank cut is — the ratio between the smallest *kept*
2878/// pivot and the rank tolerance. A large margin means squaring the design into
2879/// a Gram could not have flipped any rank decision; a small margin means the
2880/// verdict sits near the cliff and the caller should re-confirm on the full
2881/// (un-squared) design to stay bit-exact.
2882pub struct RrqrFromGram {
2883    pub rank: usize,
2884    pub column_permutation: Vec<usize>,
2885    pub rank_tol: f64,
2886    /// Leading pivot magnitude `|R[0,0]|` of the square-root factor — equal to
2887    /// the largest column norm of the original tall design (col-piv QR pivots the
2888    /// largest-norm column first), so it matches the tall path's
2889    /// `RrqrWithPermutation::leading_diag_abs`.
2890    pub leading_diag_abs: f64,
2891    /// `min_kept_pivot / rank_tol` (∞ when full rank with no kept pivot below
2892    /// tol, i.e. every pivot is comfortably above; `0` when rank is 0).
2893    pub verdict_margin: f64,
2894}
2895
2896/// Column-pivoted rank-revealing QR computed from the design's `p × p` Gram
2897/// `G = AᵀA` (or penalty-augmented `AᵀA + SᵀS`) instead of from the tall
2898/// `m × p` design itself.
2899///
2900/// # Why this is exact (in exact arithmetic)
2901///
2902/// Column-pivoted QR selects, at each step, the not-yet-pivoted column with the
2903/// largest residual norm, where the residual is the part orthogonal to the
2904/// already-chosen columns. Those residual norms — and the resulting pivot
2905/// sequence, the diagonal magnitudes `|R[i,i]|`, and hence the rank cut — are a
2906/// function of the column *inner products* only, i.e. of the Gram `G`. Running
2907/// col-piv QR on the Cholesky factor `R₀` of `G` (`R₀ᵀR₀ = G`, `R₀` is `p × p`)
2908/// reproduces the identical pivot order and identical `|R[i,i]|` as col-piv QR
2909/// on the original `m × p` matrix, because both see the same column geometry.
2910/// This is the standard "pivoted QR depends only on the Gram" identity and lets
2911/// the joint identifiability rank verdict run in `O(p³)` instead of streaming
2912/// all `m ≈ 2·10⁵` rows again.
2913///
2914/// # Tolerance
2915///
2916/// The rank cutoff must match what the tall-matrix [`rrqr_with_permutation`]
2917/// would have used, so the caller passes `m_rows` (the row count of the
2918/// original tall design, including any appended penalty rows). The tolerance is
2919/// `rank_alpha · eps · max(m_rows, p) · max(|R[0,0]|, 1)` — bit-identical to the
2920/// tall path, since `|R[0,0]|` (the leading pivot magnitude = largest column
2921/// norm) is the same in both factorizations.
2922///
2923/// # Finite-precision guard
2924///
2925/// Forming `G = AᵀA` squares the condition number, so a rank decision that sits
2926/// right at the tolerance cliff could in principle flip. The returned
2927/// `verdict_margin` lets the caller detect that case and fall back to the exact
2928/// tall RRQR; in the overwhelmingly common well-separated case (full column
2929/// rank, smallest pivot orders of magnitude above tol) the margin is huge and
2930/// no fallback is needed.
2931pub fn rrqr_from_gram_with_permutation<S: Data<Elem = f64>>(
2932    gram: &ArrayBase<S, Ix2>,
2933    m_rows: usize,
2934    rank_alpha: f64,
2935) -> Result<RrqrFromGram, FaerLinalgError> {
2936    let p = gram.ncols();
2937    if p == 0 {
2938        return Ok(RrqrFromGram {
2939            rank: 0,
2940            column_permutation: Vec::new(),
2941            rank_tol: 0.0,
2942            leading_diag_abs: 0.0,
2943            verdict_margin: 0.0,
2944        });
2945    }
2946    if gram.nrows() != p {
2947        return Err(FaerLinalgError::FactorizationFailed {
2948            context: "rrqr_from_gram_with_permutation: Gram is not square",
2949        });
2950    }
2951    // Symmetric square-root factor F (p×p) with FᵀF = G. The Gram is PSD by
2952    // construction (AᵀA), so its eigendecomposition G = V·diag(λ)·Vᵀ gives the
2953    // factor F = diag(√λ₊)·Vᵀ (rows indexed by eigenpair, columns by original
2954    // design column). Any factor with FᵀF = G reproduces the same column
2955    // geometry, which is all col-piv QR consumes — we use the eigen square root
2956    // rather than a bare Cholesky because Cholesky fails on the numerically
2957    // semidefinite Gram that is exactly the rank-deficient case we must classify.
2958    // Tiny-negative eigenvalues from finite precision are clamped to zero.
2959    let (evals, evecs) = gram.eigh(Side::Lower)?;
2960    let mut f = Array2::<f64>::zeros((p, p));
2961    for k in 0..p {
2962        let scale = evals[k].max(0.0).sqrt();
2963        if scale == 0.0 {
2964            continue;
2965        }
2966        for i in 0..p {
2967            f[[k, i]] = scale * evecs[[i, k]];
2968        }
2969    }
2970    // Single col-piv QR on F. Its pivot order, per-pivot |R[i,i]| magnitudes,
2971    // and leading pivot equal those of col-piv QR on the original tall design
2972    // (FᵀF = G), so this reproduces the exact tall-path geometry.
2973    let faer_f = FaerArrayView::new(&f);
2974    let qr = faer_f.as_ref().col_piv_qr();
2975    let r = qr.thin_R();
2976    let diag_len = r.nrows().min(r.ncols());
2977    let pivots: Vec<f64> = (0..diag_len).map(|i| r[(i, i)].abs()).collect();
2978    let leading_diag = pivots.first().copied().unwrap_or(0.0);
2979    let (forward, _inverse) = qr.P().arrays();
2980    let column_permutation: Vec<usize> = forward.iter().copied().map(|idx| idx.unbound()).collect();
2981    // Re-scale the tolerance from F's `max(p, p)=p` row dimension to the
2982    // original tall design's `max(m_rows, p)`, keeping the rank cut bit-
2983    // identical to what the tall [`rrqr_with_permutation`] would have produced.
2984    let tol = rank_alpha * f64::EPSILON * (m_rows.max(p).max(1) as f64) * leading_diag.max(1.0);
2985    let rank = pivots.iter().filter(|&&v| v > tol).count();
2986    let min_kept = pivots[..rank].iter().copied().fold(f64::INFINITY, f64::min);
2987    let max_dropped = pivots[rank..].iter().copied().fold(0.0f64, f64::max);
2988    // Margin: how far the verdict is from the cliff. Use the smaller of
2989    // (min_kept / tol) and (tol / max_dropped) so a near-tol dropped pivot also
2990    // shrinks the margin. A margin ≫ 1 means no rank decision could flip.
2991    let kept_margin = if rank == 0 {
2992        f64::INFINITY
2993    } else {
2994        min_kept / tol
2995    };
2996    let dropped_margin = if rank == diag_len {
2997        f64::INFINITY
2998    } else {
2999        tol / max_dropped.max(f64::MIN_POSITIVE)
3000    };
3001    // Gram-squaring precision floor. Forming `G = XᵀX` collapses the bottom half
3002    // of the spectrum: a true singular value below `√ε · σ_max` is lost in the
3003    // rounding of `G` (its squared value `σ² < ε·σ_max²` underflows the Gram's
3004    // representable range), and the eigen-square-root then RESURRECTS it as a
3005    // SPURIOUS pivot of magnitude `≈ √(ε·σ_max²) = √ε · σ_max` — orders of
3006    // magnitude ABOVE the true σ and above `tol`. That artefact makes col-piv QR
3007    // on `F` KEEP a column the tall (un-squared) QR would demote: an EXACTLY
3008    // collinear alias (true σ = 0, so `σ² = 0` floored at `≈ ε·σ_max²`) shows up
3009    // as a kept pivot near `√ε · leading`, over-ranking the design and dropping
3010    // nothing (gam#933: a callback-owned column aliased with a higher-priority
3011    // anchor was never demoted, so the reduction never ran and the MAP-uniqueness
3012    // check then fired on the raw collinear joint design). `min_kept / tol` does
3013    // NOT catch this — the spurious pivot sits comfortably above `tol`, so the
3014    // existing margin reports a falsely-confident verdict. The honest test is
3015    // whether the smallest KEPT pivot is itself near the Gram precision floor
3016    // `√ε · leading`: if so, the Gram path cannot distinguish it from a true zero
3017    // and the verdict MUST be re-confirmed on the full-precision tall design.
3018    // Encode that as a third margin term `min_kept / (√ε · leading)` so a kept
3019    // pivot in the floor regime shrinks `verdict_margin` below the caller's
3020    // fallback threshold; for a genuinely full-rank design every kept pivot is
3021    // `≫ √ε · leading` and this term is large, leaving the fast path intact.
3022    let gram_precision_floor = f64::EPSILON.sqrt() * leading_diag.max(1.0);
3023    let kept_floor_margin = if rank == 0 {
3024        f64::INFINITY
3025    } else {
3026        min_kept / gram_precision_floor.max(f64::MIN_POSITIVE)
3027    };
3028    let verdict_margin = kept_margin.min(dropped_margin).min(kept_floor_margin);
3029    Ok(RrqrFromGram {
3030        rank,
3031        column_permutation,
3032        rank_tol: tol,
3033        leading_diag_abs: leading_diag,
3034        verdict_margin,
3035    })
3036}
3037
3038#[cfg(test)]
3039mod tests {
3040    use super::*;
3041    use ndarray::{array, s};
3042
3043    /// Local mirror of the audit's `JOINT_GRAM_RRQR_MIN_VERDICT_MARGIN` fallback
3044    /// threshold, used only by the regression tests below to assert the verdict
3045    /// margin lands on the correct side of the cliff. Kept in sync by value (1e3).
3046    const JOINT_GRAM_RRQR_TRUST_MARGIN_FOR_TEST: f64 = 1.0e3;
3047
3048    #[test]
3049    fn rrqr_nullspace_basis_is_orthonormal_and_annihilates_transpose() {
3050        let a = array![[1.0, 0.0], [1.0, 0.0], [0.0, 2.0], [0.0, 0.0],];
3051        let (z, rank) =
3052            rrqr_nullspace_basis(&a, default_rrqr_rank_alpha()).expect("RRQR should succeed");
3053        assert_eq!(rank, 2);
3054        assert_eq!(z.nrows(), 4);
3055        assert_eq!(z.ncols(), 2);
3056
3057        let gram = z.t().dot(&z);
3058        let ident = Array2::<f64>::eye(z.ncols());
3059        let gram_err = (&gram - &ident)
3060            .iter()
3061            .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3062        assert!(gram_err < 1e-10, "Z is not orthonormal: {gram_err:e}");
3063
3064        let residual = a.t().dot(&z);
3065        let resid_max = residual.iter().fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3066        assert!(resid_max < 1e-10, "A^T Z residual too large: {resid_max:e}");
3067    }
3068
3069    #[test]
3070    fn rrqr_with_permutation_attributes_redundant_column() {
3071        // 3 columns, column 2 is a duplicate of column 0 → rank 2, column 2
3072        // is the redundant one that the pivoted QR should demote past the
3073        // rank threshold. (Column 1 contributes a different direction.)
3074        let a = array![
3075            [1.0, 0.0, 1.0],
3076            [1.0, 0.0, 1.0],
3077            [0.0, 2.0, 0.0],
3078            [0.0, 0.0, 0.0],
3079        ];
3080        let result =
3081            rrqr_with_permutation(&a, default_rrqr_rank_alpha()).expect("RRQR should succeed");
3082        assert_eq!(result.rank, 2);
3083        assert_eq!(result.column_permutation.len(), 3);
3084        let demoted = result.column_permutation[result.rank..].to_vec();
3085        assert!(
3086            demoted.contains(&2) || demoted.contains(&0),
3087            "demoted suffix should include one of the aliased columns (0 or 2), got {demoted:?}"
3088        );
3089        let mut sorted = result.column_permutation.clone();
3090        sorted.sort();
3091        assert_eq!(
3092            sorted,
3093            vec![0, 1, 2],
3094            "permutation must be a valid bijection on 0..n"
3095        );
3096    }
3097
3098    /// A column-pivoted QR orders columns by decreasing pivot norm, so on this
3099    /// fixture (`‖a_0‖ = 1`, `‖a_1‖ = 2`) the permutation is emphatically *not*
3100    /// identity: Businger–Golub pivoting takes column 1 first, and with only
3101    /// two columns a bijection whose first entry is 1 is fully determined as
3102    /// `[1, 0]`. The previous version sorted the permutation before comparing
3103    /// it to `[0, 1]` — true of *any* two-element permutation — which destroyed
3104    /// exactly the ordering information the test was named for, and the name
3105    /// ("identity-like") asserted the opposite of what a pivoted QR does here.
3106    #[test]
3107    fn rrqr_with_permutation_pivots_the_larger_norm_column_first() {
3108        let a = array![[1.0, 0.0], [0.0, 2.0], [0.0, 0.0]];
3109        let result =
3110            rrqr_with_permutation(&a, default_rrqr_rank_alpha()).expect("RRQR should succeed");
3111        assert_eq!(result.rank, 2);
3112        let perm = result.column_permutation.clone();
3113
3114        let mut sorted = perm.clone();
3115        sorted.sort();
3116        assert_eq!(
3117            sorted,
3118            vec![0, 1],
3119            "permutation must be a bijection on 0..n, got {perm:?}"
3120        );
3121
3122        // Unsorted pivot order: the load-bearing assertion.
3123        assert_eq!(
3124            perm,
3125            vec![1, 0],
3126            "column-pivoted QR must take the larger-norm column (1, norm 2) \
3127             before the smaller (0, norm 1), got {perm:?}"
3128        );
3129
3130        // The property that order encodes, stated independently of the literal
3131        // above: original column norms are non-increasing in pivot order.
3132        let norms: Vec<f64> = perm
3133            .iter()
3134            .map(|&j| a.column(j).iter().map(|value| value * value).sum::<f64>().sqrt())
3135            .collect();
3136        for window in norms.windows(2) {
3137            assert!(
3138                window[0] >= window[1],
3139                "pivoted column norms must be non-increasing, got {norms:?}"
3140            );
3141        }
3142    }
3143
3144    #[test]
3145    fn rrqr_with_permutation_rejects_zero_rows() {
3146        let a = Array2::<f64>::zeros((0, 3));
3147        assert!(rrqr_with_permutation(&a, default_rrqr_rank_alpha()).is_err());
3148    }
3149
3150    #[test]
3151    fn rrqr_nullspace_basis_square_zero_matrix_is_finite_identity() {
3152        // Square zero matrix (the parametric-only penalty case): null(A^T) is
3153        // the whole space, so the basis must be a finite orthonormal 3x3 set.
3154        let a = Array2::<f64>::zeros((3, 3));
3155        let (z, rank) =
3156            rrqr_nullspace_basis(&a, default_rrqr_rank_alpha()).expect("RRQR should succeed");
3157        assert_eq!(rank, 0);
3158        assert_eq!(z.dim(), (3, 3));
3159        assert!(
3160            z.iter().all(|v| v.is_finite()),
3161            "square zero matrix produced a non-finite null basis: {z:?}"
3162        );
3163        let gram = z.t().dot(&z);
3164        let ident = Array2::<f64>::eye(3);
3165        let gram_err = (&gram - &ident)
3166            .iter()
3167            .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3168        assert!(gram_err < 1e-10, "Z is not orthonormal: {gram_err:e}");
3169    }
3170
3171    #[test]
3172    fn rrqr_nullspace_basis_detectszero_rank_matrix() {
3173        let a = Array2::<f64>::zeros((5, 2));
3174        let (z, rank) =
3175            rrqr_nullspace_basis(&a, default_rrqr_rank_alpha()).expect("RRQR should succeed");
3176        assert_eq!(rank, 0);
3177        assert_eq!(z.dim(), (5, 5));
3178        let ident = Array2::<f64>::eye(5);
3179        let max_err = (&z.slice(s![.., ..5]).to_owned() - &ident)
3180            .iter()
3181            .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3182        assert!(max_err < 1e-10, "zero matrix should yield identity basis");
3183    }
3184
3185    //
3186    // Eigendecomposition NoConvergence on pathological matrices
3187    //
3188    // These tests lock down the hardened contract for FaerEigh::eigh:
3189    // non-finite input must be rejected explicitly, while finite symmetric
3190    // matrices still produce finite spectra.
3191    //
3192
3193    #[test]
3194    fn eigh_on_nan_matrix_rejects_non_finite_input() {
3195        let mat = array![
3196            [1.0, 0.0, 0.0, 0.0],
3197            [0.0, 2.0, 0.0, 0.0],
3198            [0.0, 0.0, 3.0, f64::NAN],
3199            [0.0, 0.0, f64::NAN, 4.0]
3200        ];
3201        let err = mat
3202            .eigh(Side::Lower)
3203            .expect_err("non-finite symmetric input must be rejected");
3204        assert!(matches!(
3205            err,
3206            FaerLinalgError::SelfAdjointEigenNonFiniteInput { .. }
3207        ));
3208    }
3209
3210    #[test]
3211    fn fast_ata_matches_full_gemm_above_threshold() {
3212        // Pick (n, p) large enough to trigger the faer triangular path
3213        // (should_use_faer_matmul threshold is MIN_DIM=32, MIN_FLOP_SCALE=64*64).
3214        let n = 200;
3215        let p = 40;
3216        let a: Array2<f64> = Array2::from_shape_fn((n, p), |(i, j)| {
3217            ((i * 7 + j * 3) as f64).sin() + 0.1 * j as f64
3218        });
3219        let expected = a.t().dot(&a);
3220        let got = fast_ata(&a);
3221        let max_err = (&got - &expected)
3222            .iter()
3223            .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3224        assert!(max_err < 1e-10, "fast_ata mismatch: {max_err:e}");
3225        // Output must be fully populated and symmetric.
3226        for i in 0..p {
3227            for j in 0..p {
3228                assert!((got[[i, j]] - got[[j, i]]).abs() < 1e-12);
3229            }
3230        }
3231    }
3232
3233    #[test]
3234    fn fast_xt_diag_x_matches_naive_above_threshold() {
3235        let n = 400;
3236        let p = 36;
3237        let x: Array2<f64> =
3238            Array2::from_shape_fn((n, p), |(i, j)| (i as f64 * 0.1).cos() + j as f64 * 0.05);
3239        let w: Array1<f64> = Array1::from_shape_fn(n, |i| (i as f64 * 0.03).sin());
3240        // Naive reference: X^T diag(w) X.
3241        let wx = Array2::from_shape_fn((n, p), |(i, j)| w[i] * x[[i, j]]);
3242        let expected = x.t().dot(&wx);
3243        let got = fast_xt_diag_x(&x, &w);
3244        let max_err = (&got - &expected)
3245            .iter()
3246            .fold(0.0_f64, |acc, &v| acc.max(v.abs()));
3247        assert!(max_err < 1e-9, "fast_xt_diag_x mismatch: {max_err:e}");
3248        for i in 0..p {
3249            for j in 0..p {
3250                assert!((got[[i, j]] - got[[j, i]]).abs() < 1e-12);
3251            }
3252        }
3253    }
3254
3255    #[test]
3256    fn stream_weighted_crossprod_full_and_triangular_parity_with_negative_weights() {
3257        // The stream-in and matrix-returning `fast_xt_diag_x*` packaging modes
3258        // share one kernel. Both packaging modes — and both accumulation
3259        // modes — must reproduce the naive `Xᵀ·diag(w)·X` reference, including signed
3260        // (negative) weights, which the pre-unification sqrt-clip form
3261        // silently corrupted.
3262        //
3263        // Exercise both the streaming faer path (n large enough to clear
3264        // `should_use_faer_matmul`) and the tiny ndarray fallback (small n,p).
3265        for &(n, p) in &[(900usize, 40usize), (8usize, 3usize)] {
3266            let x: Array2<f64> =
3267                Array2::from_shape_fn((n, p), |(i, j)| (i as f64 * 0.07).cos() + j as f64 * 0.013);
3268            // Weights span both signs and zero so negative-weight handling and
3269            // sign preservation are genuinely tested.
3270            let w: Array1<f64> =
3271                Array1::from_shape_fn(n, |i| (i as f64 * 0.11).sin() - 0.25 * (i % 3) as f64);
3272            assert!(
3273                w.iter().any(|&v| v < 0.0),
3274                "weight vector must contain negatives to test sign preservation"
3275            );
3276
3277            // Naive reference: Xᵀ diag(w) X with signed weights.
3278            let wx = Array2::from_shape_fn((n, p), |(i, j)| w[i] * x[[i, j]]);
3279            let expected = x.t().dot(&wx);
3280
3281            let par = matmul_parallelism(p, p, n);
3282
3283            // Full output, Replace.
3284            let mut full = Array2::<f64>::ones((p, p));
3285            stream_weighted_crossprod_into(
3286                &x,
3287                &w,
3288                &mut full,
3289                CrossprodStructure::Full,
3290                CrossprodAccum::Replace,
3291                par,
3292            );
3293
3294            // Triangular+mirror output, Replace. Seed with garbage to prove
3295            // Replace clears prior contents (incl. the upper triangle, which
3296            // the triangular path only reaches via the mirror).
3297            let mut tri = Array2::<f64>::from_elem((p, p), -7.0);
3298            stream_weighted_crossprod_into(
3299                &x,
3300                &w,
3301                &mut tri,
3302                CrossprodStructure::SymmetricLower,
3303                CrossprodAccum::Replace,
3304                par,
3305            );
3306
3307            let full_err = (&full - &expected)
3308                .iter()
3309                .fold(0.0_f64, |a, &v| a.max(v.abs()));
3310            let tri_err = (&tri - &expected)
3311                .iter()
3312                .fold(0.0_f64, |a, &v| a.max(v.abs()));
3313            assert!(
3314                full_err < 1e-9,
3315                "full kernel mismatch (n={n}, p={p}): {full_err:e}"
3316            );
3317            assert!(
3318                tri_err < 1e-9,
3319                "triangular kernel mismatch (n={n}, p={p}): {tri_err:e}"
3320            );
3321
3322            // Full and triangular packaging must agree elementwise, and both
3323            // must be exactly symmetric.
3324            for i in 0..p {
3325                for j in 0..p {
3326                    assert!(
3327                        (full[[i, j]] - tri[[i, j]]).abs() < 1e-12,
3328                        "full vs triangular disagree at ({i},{j})"
3329                    );
3330                    assert!(
3331                        (tri[[i, j]] - tri[[j, i]]).abs() < 1e-12,
3332                        "triangular output not symmetric at ({i},{j})"
3333                    );
3334                }
3335            }
3336
3337            // Accumulation parity: Add into a pre-filled buffer must equal the
3338            // prior contents plus the Gram, for both structures.
3339            let base = Array2::<f64>::from_elem((p, p), 1.5);
3340            let mut add_full = base.clone();
3341            stream_weighted_crossprod_into(
3342                &x,
3343                &w,
3344                &mut add_full,
3345                CrossprodStructure::Full,
3346                CrossprodAccum::Add,
3347                par,
3348            );
3349            let mut add_tri = base.clone();
3350            stream_weighted_crossprod_into(
3351                &x,
3352                &w,
3353                &mut add_tri,
3354                CrossprodStructure::SymmetricLower,
3355                CrossprodAccum::Add,
3356                par,
3357            );
3358            let expected_add = &base + &expected;
3359            let add_full_err = (&add_full - &expected_add)
3360                .iter()
3361                .fold(0.0_f64, |a, &v| a.max(v.abs()));
3362            let add_tri_err = (&add_tri - &expected_add)
3363                .iter()
3364                .fold(0.0_f64, |a, &v| a.max(v.abs()));
3365            assert!(
3366                add_full_err < 1e-9,
3367                "full Add mismatch (n={n}, p={p}): {add_full_err:e}"
3368            );
3369            assert!(
3370                add_tri_err < 1e-9,
3371                "triangular Add mismatch (n={n}, p={p}): {add_tri_err:e}"
3372            );
3373
3374            // The matrix.rs adapter (Full + Replace into a zeroed buffer) must
3375            // match the faer_ndarray return-style adapter bit-for-functionally.
3376            let returned = fast_xt_diag_x(&x, &w);
3377            let returned_err = (&returned - &full)
3378                .iter()
3379                .fold(0.0_f64, |a, &v| a.max(v.abs()));
3380            assert!(
3381                returned_err < 1e-12,
3382                "return adapter vs stream-into adapter disagree (n={n}, p={p}): {returned_err:e}"
3383            );
3384        }
3385    }
3386
3387    #[test]
3388    fn eigh_succeeds_on_same_structure_without_nan() {
3389        // Control: the same matrix with finite values produces finite eigenvalues.
3390        let mat = array![[1.0, 0.5, 0.1], [0.5, 2.0, 0.3], [0.1, 0.3, 1.5]];
3391        let (evals, _) = mat
3392            .eigh(Side::Lower)
3393            .expect("eigh should succeed on a well-conditioned finite matrix");
3394        assert!(
3395            evals.iter().all(|&v| v.is_finite()),
3396            "all eigenvalues should be finite"
3397        );
3398    }
3399
3400    /// gam#933 regression: the Gram-squared RRQR must NOT silently over-rank an
3401    /// EXACTLY collinear design. The invariant is: either the Gram path finds the
3402    /// correct rank (3) by itself — because the precision-floor logic demotes the
3403    /// spurious near-zero pivot before it reaches the kept set — OR, if it
3404    /// over-ranks (reports 4), the `verdict_margin` must collapse below the
3405    /// caller's fallback threshold so the full-precision tall path is used
3406    /// instead. Both outcomes prevent the original gam#933 bug (silent rank=4
3407    /// with high-confidence margin that the caller trusts without verification).
3408    #[test]
3409    fn gram_rrqr_flags_low_margin_on_exact_collinearity_so_caller_falls_back() {
3410        // Joint design [1, x | x, x²] with x ∈ [-1, 1]: columns 1 and 2 are an
3411        // EXACT duplicate (the #933 callback-owned alias), so the true rank is 3.
3412        let n = 48usize;
3413        let x: Vec<f64> = (0..n)
3414            .map(|i| -1.0 + 2.0 * (i as f64) / (n as f64 - 1.0))
3415            .collect();
3416        let mut a = Array2::<f64>::zeros((n, 4));
3417        for i in 0..n {
3418            a[[i, 0]] = 1.0;
3419            a[[i, 1]] = x[i];
3420            a[[i, 2]] = x[i];
3421            a[[i, 3]] = x[i] * x[i];
3422        }
3423        let alpha = default_rrqr_rank_alpha();
3424
3425        // The tall (un-squared) RRQR is the full-precision reference: it must see
3426        // rank 3 and demote one of the duplicate x columns.
3427        let tall = rrqr_with_permutation(&a, alpha).expect("tall RRQR should succeed");
3428        assert_eq!(tall.rank, 3, "tall RRQR must demote the exact alias");
3429
3430        // The Gram-squared RRQR must satisfy the gam#933 invariant:
3431        //   rank == 3 (correct result)  OR  verdict_margin < threshold (force fallback)
3432        //
3433        // The precision-floor margin term was designed to catch the case where
3434        // squaring the spectrum resurrects a spurious kept pivot near √ε·σ_max.
3435        // When the eigen-square-root approach correctly demotes that pivot
3436        // (yielding rank=3 without spurious kept columns), the margin is
3437        // legitimately high — trusting the Gram result is then safe and correct.
3438        // When it over-ranks (rank=4), the floor margin must be low so the
3439        // caller falls back to the tall RRQR and gets the right answer.
3440        let unit = Array1::<f64>::ones(n);
3441        let gram = fast_xt_diag_x_with_parallelism(&a, &unit, faer::get_global_parallelism());
3442        let gram_rrqr =
3443            rrqr_from_gram_with_permutation(&gram, n, alpha).expect("Gram RRQR should succeed");
3444        let ok =
3445            gram_rrqr.rank == 3 || gram_rrqr.verdict_margin < JOINT_GRAM_RRQR_TRUST_MARGIN_FOR_TEST;
3446        assert!(
3447            ok,
3448            "gam#933: Gram RRQR must either find correct rank=3 OR signal low margin \
3449             (< {:.0e}) to force the tall fallback; got rank={} margin={:.3e}",
3450            JOINT_GRAM_RRQR_TRUST_MARGIN_FOR_TEST, gram_rrqr.rank, gram_rrqr.verdict_margin,
3451        );
3452    }
3453
3454    /// Companion to the regression above: a genuinely full-rank, moderately
3455    /// conditioned design must keep a LARGE Gram verdict margin so the fast Gram
3456    /// path is retained (the precision-floor term must not trip on real, small-
3457    /// but-nonzero singular values).
3458    #[test]
3459    fn gram_rrqr_keeps_high_margin_on_full_rank_design() {
3460        let n = 200usize;
3461        let p = 5usize;
3462        let mut a = Array2::<f64>::zeros((n, p));
3463        // Deterministic, well-separated columns (distinct low-order polynomials).
3464        for i in 0..n {
3465            let t = (i as f64) / (n as f64 - 1.0);
3466            a[[i, 0]] = 1.0;
3467            a[[i, 1]] = t;
3468            a[[i, 2]] = t * t;
3469            a[[i, 3]] = t * t * t;
3470            a[[i, 4]] = (t * 6.0).sin();
3471        }
3472        let alpha = default_rrqr_rank_alpha();
3473        let unit = Array1::<f64>::ones(n);
3474        let gram = fast_xt_diag_x_with_parallelism(&a, &unit, faer::get_global_parallelism());
3475        let gram_rrqr =
3476            rrqr_from_gram_with_permutation(&gram, n, alpha).expect("Gram RRQR should succeed");
3477        assert_eq!(gram_rrqr.rank, p, "full-rank design must keep all columns");
3478        assert!(
3479            gram_rrqr.verdict_margin >= JOINT_GRAM_RRQR_TRUST_MARGIN_FOR_TEST,
3480            "full-rank design must keep a high margin (fast Gram path); got {:.3e}",
3481            gram_rrqr.verdict_margin,
3482        );
3483    }
3484
3485    // ── fast_ab / fast_atb / fast_abt / fast_av / fast_atv / fast_xt_diag_y ──
3486
3487    fn max_abs_diff(a: &Array2<f64>, b: &Array2<f64>) -> f64 {
3488        assert_eq!(a.dim(), b.dim(), "shape mismatch in max_abs_diff");
3489        a.iter()
3490            .zip(b.iter())
3491            .fold(0.0_f64, |acc, (&x, &y)| acc.max((x - y).abs()))
3492    }
3493
3494    fn max_abs_diff_1d(a: &Array1<f64>, b: &Array1<f64>) -> f64 {
3495        assert_eq!(a.len(), b.len(), "len mismatch in max_abs_diff_1d");
3496        a.iter()
3497            .zip(b.iter())
3498            .fold(0.0_f64, |acc, (&x, &y)| acc.max((x - y).abs()))
3499    }
3500
3501    /// `fast_ab(A, B)` matches `A.dot(&B)` for small (ndarray-path) matrices.
3502    #[test]
3503    fn fast_ab_small_matches_ndarray_dot() {
3504        let a = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
3505        let b = array![[7.0, 8.0], [9.0, 10.0], [11.0, 12.0]];
3506        let got = fast_ab(&a, &b);
3507        let want = a.dot(&b);
3508        assert!(max_abs_diff(&got, &want) < 1e-12, "fast_ab small mismatch");
3509        assert_eq!(got.dim(), (2, 2));
3510    }
3511
3512    /// `fast_ab` on larger matrices (faer path) agrees with ndarray dot.
3513    #[test]
3514    fn fast_ab_large_matches_ndarray_dot() {
3515        let n = 50usize;
3516        let p = 40usize;
3517        let q = 35usize;
3518        let mut a = Array2::<f64>::zeros((n, p));
3519        let mut b = Array2::<f64>::zeros((p, q));
3520        let mut state = 0xDEAD_BEEF_1234_5678u64;
3521        let next = |s: &mut u64| -> f64 {
3522            *s ^= *s << 13;
3523            *s ^= *s >> 7;
3524            *s ^= *s << 17;
3525            ((*s >> 11) as f64 / ((1u64 << 53) as f64)) - 0.5
3526        };
3527        for v in a.iter_mut() {
3528            *v = next(&mut state);
3529        }
3530        for v in b.iter_mut() {
3531            *v = next(&mut state);
3532        }
3533        let got = fast_ab(&a, &b);
3534        let want = a.dot(&b);
3535        assert!(max_abs_diff(&got, &want) < 1e-9, "fast_ab large mismatch");
3536    }
3537
3538    /// `fast_atb(A, B)` = A^T * B for small matrices (ndarray path).
3539    #[test]
3540    fn fast_atb_small_matches_ndarray_dot() {
3541        let a = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
3542        let b = array![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0], [13.0, 14.0, 15.0]];
3543        let got = fast_atb(&a, &b);
3544        let want = a.t().dot(&b);
3545        assert!(max_abs_diff(&got, &want) < 1e-12, "fast_atb small mismatch");
3546        assert_eq!(got.dim(), (2, 3));
3547    }
3548
3549    /// `fast_atb` on larger matrices (faer path) agrees with ndarray.
3550    #[test]
3551    fn fast_atb_large_matches_ndarray_dot() {
3552        let n = 50usize;
3553        let p = 40usize;
3554        let q = 35usize;
3555        let mut a = Array2::<f64>::zeros((n, p));
3556        let mut b = Array2::<f64>::zeros((n, q));
3557        let mut state = 0xCAFE_BABE_9876_5432u64;
3558        let next = |s: &mut u64| -> f64 {
3559            *s ^= *s << 13;
3560            *s ^= *s >> 7;
3561            *s ^= *s << 17;
3562            ((*s >> 11) as f64 / ((1u64 << 53) as f64)) - 0.5
3563        };
3564        for v in a.iter_mut() {
3565            *v = next(&mut state);
3566        }
3567        for v in b.iter_mut() {
3568            *v = next(&mut state);
3569        }
3570        let got = fast_atb(&a, &b);
3571        let want = a.t().dot(&b);
3572        assert!(max_abs_diff(&got, &want) < 1e-9, "fast_atb large mismatch");
3573    }
3574
3575    /// `fast_abt(A, B)` = A * B^T for small matrices (ndarray path).
3576    #[test]
3577    fn fast_abt_small_matches_ndarray_dot() {
3578        let a = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
3579        let b = array![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0]];
3580        let got = fast_abt(&a, &b);
3581        let want = a.dot(&b.t());
3582        assert!(max_abs_diff(&got, &want) < 1e-12, "fast_abt small mismatch");
3583        assert_eq!(got.dim(), (2, 2));
3584    }
3585
3586    /// `fast_av(A, v)` = A * v for small (ndarray path) and larger (faer path).
3587    #[test]
3588    fn fast_av_small_matches_ndarray_dot() {
3589        let a = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
3590        let v = array![1.0, -1.0, 2.0];
3591        let got = fast_av(&a, &v);
3592        let want = a.dot(&v);
3593        assert!(
3594            max_abs_diff_1d(&got, &want) < 1e-12,
3595            "fast_av small mismatch"
3596        );
3597        // 1*1 + 2*(-1) + 3*2 = 1-2+6 = 5
3598        assert!((got[0] - 5.0).abs() < 1e-12, "fast_av[0] should be 5");
3599        // 4*1 + 5*(-1) + 6*2 = 4-5+12 = 11
3600        assert!((got[1] - 11.0).abs() < 1e-12, "fast_av[1] should be 11");
3601    }
3602
3603    /// `fast_av` on larger matrices (faer path) agrees with ndarray.
3604    #[test]
3605    fn fast_av_large_matches_ndarray_dot() {
3606        let n = 50usize;
3607        let p = 40usize;
3608        let mut a = Array2::<f64>::zeros((n, p));
3609        let mut v = Array1::<f64>::zeros(p);
3610        let mut state = 0xFEED_FACE_ABCD_EF01u64;
3611        let next = |s: &mut u64| -> f64 {
3612            *s ^= *s << 13;
3613            *s ^= *s >> 7;
3614            *s ^= *s << 17;
3615            ((*s >> 11) as f64 / ((1u64 << 53) as f64)) - 0.5
3616        };
3617        for v in a.iter_mut() {
3618            *v = next(&mut state);
3619        }
3620        for x in v.iter_mut() {
3621            *x = next(&mut state);
3622        }
3623        let got = fast_av(&a, &v);
3624        let want = a.dot(&v);
3625        assert!(
3626            max_abs_diff_1d(&got, &want) < 1e-9,
3627            "fast_av large mismatch"
3628        );
3629    }
3630
3631    #[test]
3632    fn standard_fma_av_matches_ndarray_dot() {
3633        let n = 73usize;
3634        let p = 257usize;
3635        let a = Array2::from_shape_fn((n, p), |(i, j)| {
3636            ((i + 3 * j + 1) as f64).sin() / (j + 1) as f64
3637        });
3638        let v = Array1::from_shape_fn(p, |j| ((2 * j + 1) as f64).cos());
3639        let want = a.dot(&v);
3640        let mut got = Array1::<f64>::zeros(n);
3641        fast_av_standard_view_into(&a, &v, got.view_mut());
3642        assert!(
3643            max_abs_diff_1d(&got, &want) < 1e-12,
3644            "standard-FMA matrix-vector product mismatch"
3645        );
3646    }
3647
3648    /// `fast_atv(A, v)` = A^T * v for small matrices (ndarray path).
3649    #[test]
3650    fn fast_atv_small_matches_ndarray_dot() {
3651        let a = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
3652        let v = array![1.0, 0.0, -1.0];
3653        let got = fast_atv(&a, &v);
3654        let want = a.t().dot(&v);
3655        // A^T * v = [1*1+3*0+5*(-1), 2*1+4*0+6*(-1)] = [-4, -4]
3656        assert!(
3657            max_abs_diff_1d(&got, &want) < 1e-12,
3658            "fast_atv small mismatch"
3659        );
3660        assert!((got[0] - (-4.0)).abs() < 1e-12, "fast_atv[0]");
3661        assert!((got[1] - (-4.0)).abs() < 1e-12, "fast_atv[1]");
3662    }
3663
3664    /// `fast_atv` on larger matrices (faer path) agrees with ndarray.
3665    #[test]
3666    fn fast_atv_large_matches_ndarray_dot() {
3667        let n = 50usize;
3668        let p = 40usize;
3669        let mut a = Array2::<f64>::zeros((n, p));
3670        let mut v = Array1::<f64>::zeros(n);
3671        let mut state = 0x1234_ABCD_5678_EF90u64;
3672        let next = |s: &mut u64| -> f64 {
3673            *s ^= *s << 13;
3674            *s ^= *s >> 7;
3675            *s ^= *s << 17;
3676            ((*s >> 11) as f64 / ((1u64 << 53) as f64)) - 0.5
3677        };
3678        for x in a.iter_mut() {
3679            *x = next(&mut state);
3680        }
3681        for x in v.iter_mut() {
3682            *x = next(&mut state);
3683        }
3684        let got = fast_atv(&a, &v);
3685        let want = a.t().dot(&v);
3686        assert!(
3687            max_abs_diff_1d(&got, &want) < 1e-9,
3688            "fast_atv large mismatch"
3689        );
3690    }
3691
3692    /// `fast_xt_diag_y(X, d, Y)` = X^T * diag(d) * Y, verified against
3693    /// a manual triple-product for small inputs.
3694    #[test]
3695    fn fast_xt_diag_y_small_matches_manual() {
3696        let x = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
3697        let d = array![2.0, 0.5, 1.0];
3698        let y = array![[7.0, 8.0, 9.0], [10.0, 11.0, 12.0], [13.0, 14.0, 15.0]];
3699        let got = fast_xt_diag_y(&x, &d, &y);
3700        // Manual: X^T * diag(d) * Y
3701        let diag_y = {
3702            let mut dy = Array2::<f64>::zeros(y.dim());
3703            for i in 0..3 {
3704                for j in 0..3 {
3705                    dy[[i, j]] = d[i] * y[[i, j]];
3706                }
3707            }
3708            dy
3709        };
3710        let want = x.t().dot(&diag_y);
3711        assert!(
3712            max_abs_diff(&got, &want) < 1e-12,
3713            "fast_xt_diag_y small mismatch"
3714        );
3715        assert_eq!(got.dim(), (2, 3));
3716    }
3717
3718    // ── Compensated-reduction accuracy oracle ────────────────────────────
3719    //
3720    // Truth is an error-free (exact-expansion / double-double) reference. We
3721    // assert the production GEMV kernels are pointwise no less accurate than —
3722    // and in aggregate strictly better than — a naive sequential sum.
3723
3724    #[inline]
3725    fn two_prod(a: f64, b: f64) -> (f64, f64) {
3726        let p = a * b;
3727        let e = a.mul_add(b, -p);
3728        (p, e)
3729    }
3730
3731    #[inline]
3732    fn two_sum(a: f64, b: f64) -> (f64, f64) {
3733        let s = a + b;
3734        let bb = s - a;
3735        let e = (a - (s - bb)) + (b - bb);
3736        (s, e)
3737    }
3738
3739    /// Shewchuk grow-expansion: add `q` to the non-overlapping expansion `e`.
3740    fn grow_expansion(e: &mut Vec<f64>, mut q: f64) {
3741        for h in e.iter_mut() {
3742            let (s, err) = two_sum(*h, q);
3743            *h = err;
3744            q = s;
3745        }
3746        if q != 0.0 {
3747            e.push(q);
3748        }
3749    }
3750
3751    /// Exact dot product (correctly rounded to `f64`) via an error-free
3752    /// expansion of every `two_prod` component. O(n²) — for short reference
3753    /// vectors only — but a true gold standard, strictly more precise than any
3754    /// double-precision accumulator under test.
3755    fn exact_dot(a: &[f64], b: &[f64]) -> f64 {
3756        let mut e: Vec<f64> = Vec::new();
3757        for (&x, &y) in a.iter().zip(b.iter()) {
3758            let (p, ep) = two_prod(x, y);
3759            grow_expansion(&mut e, p);
3760            grow_expansion(&mut e, ep);
3761        }
3762        // Components are non-overlapping and ascending in magnitude; summing
3763        // smallest-first yields the correctly rounded total.
3764        e.iter().fold(0.0f64, |acc, &c| acc + c)
3765    }
3766
3767    /// High-precision reference dot via compensated (double-double) summation.
3768    /// Cheap (O(n)) — used where naive's error is enormous so ~2u precision is
3769    /// already far more accurate than the baseline under test.
3770    fn dd_dot(a: &[f64], b: &[f64]) -> f64 {
3771        let (mut s, mut c) = (0.0f64, 0.0f64);
3772        for (&x, &y) in a.iter().zip(b.iter()) {
3773            let (p, ep) = two_prod(x, y);
3774            let (s2, es) = two_sum(s, p);
3775            s = s2;
3776            c += ep + es;
3777        }
3778        s + c
3779    }
3780
3781    fn naive_dot(a: &[f64], b: &[f64]) -> f64 {
3782        let mut acc = 0.0f64;
3783        for (&x, &y) in a.iter().zip(b.iter()) {
3784            acc += x * y;
3785        }
3786        acc
3787    }
3788
3789    /// Catastrophic-cancellation generator: large opposing terms plus small
3790    /// ones, so the naive running sum loses many bits to cancellation.
3791    fn ill_conditioned_pair(len: usize, seed: u64) -> (Vec<f64>, Vec<f64>) {
3792        let mut s = seed | 1;
3793        let mut next = || {
3794            s ^= s << 13;
3795            s ^= s >> 7;
3796            s ^= s << 17;
3797            (s >> 11) as f64 / ((1u64 << 53) as f64) - 0.5
3798        };
3799        let mut a = Vec::with_capacity(len);
3800        let mut b = Vec::with_capacity(len);
3801        for i in 0..len {
3802            // Span ~16 orders of magnitude with alternating signs.
3803            let scale = 10f64.powi((i % 17) as i32 - 8);
3804            let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
3805            a.push(sign * next() * scale);
3806            b.push(next() * scale);
3807        }
3808        (a, b)
3809    }
3810
3811    /// The `fma,avx2` variants of every dispatched kernel are bit-identical
3812    /// to the baseline bodies: the dispatch changes how an FMA is executed,
3813    /// never what it computes. Run on the same ill-conditioned ensemble the
3814    /// accuracy gate uses, so any lane reassociation would surface as a
3815    /// changed bit. The CPU must execute the variants for this to be a
3816    /// comparison at all, so a machine without `fma,avx2` fails loudly
3817    /// instead of comparing a body with itself.
3818    #[cfg(target_arch = "x86_64")]
3819    #[test]
3820    fn fma_avx2_kernel_variants_are_bit_identical_to_the_baseline_bodies() {
3821        assert!(
3822            super::fma_avx2_available(),
3823            "this machine reports no fma/avx2: the variant path cannot be exercised here"
3824        );
3825        for seed in 0..64u64 {
3826            let len = 200 + (seed as usize % 57);
3827            let (a, b) = ill_conditioned_pair(len, 0x9E37_79B9 ^ seed.wrapping_mul(2654435761));
3828            // SAFETY: the assertion above established the CPU features these
3829            // variants enable.
3830            let (dot_v, std_v) = unsafe {
3831                (
3832                    super::fma_dot_fma_avx2(&a, &b),
3833                    super::standard_fma_dot_fma_avx2(&a, &b),
3834                )
3835            };
3836            assert_eq!(dot_v.to_bits(), super::fma_dot_body(&a, &b).to_bits(), "fma_dot seed={seed}");
3837            assert_eq!(
3838                std_v.to_bits(),
3839                super::standard_fma_dot_body(&a, &b).to_bits(),
3840                "standard_fma_dot seed={seed}"
3841            );
3842            // Xᵀv block partial: `len` rows of width 7 (a remainder-bearing
3843            // width), and the axpy over the same data.
3844            let p = 7;
3845            let rows: Vec<f64> = (0..len * p).map(|k| a[k % len] * (1.0 + (k % 3) as f64)).collect();
3846            let mut acc_body = vec![0.0f64; p];
3847            let mut acc_var = vec![0.0f64; p];
3848            super::atv_block_accumulate_body(&rows, &b, &mut acc_body);
3849            // SAFETY: as above.
3850            unsafe { super::atv_block_accumulate_fma_avx2(&rows, &b, &mut acc_var) };
3851            assert_eq!(
3852                acc_var.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
3853                acc_body.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
3854                "atv block seed={seed}"
3855            );
3856            let mut y_body = b.clone();
3857            let mut y_var = b.clone();
3858            super::fma_axpy_into_body(a[0], &a, &mut y_body);
3859            // SAFETY: as above.
3860            unsafe { super::fma_axpy_into_fma_avx2(a[0], &a, &mut y_var) };
3861            assert_eq!(
3862                y_var.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
3863                y_body.iter().map(|v| v.to_bits()).collect::<Vec<_>>(),
3864                "axpy seed={seed}"
3865            );
3866        }
3867    }
3868
3869    /// `fma_dot` (compensated Dot2) error-vs-truth never exceeds the naive
3870    /// sum's and is strictly lower on the ill-conditioned ensemble in aggregate.
3871    #[test]
3872    fn fma_dot_beats_naive_accuracy() {
3873        let mut fma_total = 0.0f64;
3874        let mut naive_total = 0.0f64;
3875        let mut strict_wins = 0;
3876        for seed in 0..64u64 {
3877            let len = 200 + (seed as usize % 57);
3878            let (a, b) = ill_conditioned_pair(len, 0x9E37_79B9 ^ seed.wrapping_mul(2654435761));
3879            let truth = exact_dot(&a, &b);
3880            let fe = (super::fma_dot(&a, &b) - truth).abs();
3881            let ne = (naive_dot(&a, &b) - truth).abs();
3882            // Compensated (Dot2) summation is pointwise no less accurate than
3883            // the naive recurrence. The floor term tolerates a few-ulp tie when
3884            // both already sit at the round-to-nearest limit (well-conditioned).
3885            let floor = 8.0 * f64::EPSILON * truth.abs();
3886            assert!(
3887                fe <= ne * (1.0 + 1e-6) + floor,
3888                "fma_dot worse than naive: seed={seed} fma_err={fe:.3e} naive_err={ne:.3e}",
3889            );
3890            if fe < ne {
3891                strict_wins += 1;
3892            }
3893            fma_total += fe;
3894            naive_total += ne;
3895        }
3896        assert!(
3897            fma_total < naive_total,
3898            "fma_dot aggregate error {fma_total:.3e} not below naive {naive_total:.3e}",
3899        );
3900        assert!(
3901            strict_wins >= 40,
3902            "expected fma_dot to strictly win the majority; only {strict_wins}/64",
3903        );
3904    }
3905
3906    /// `fast_atv`'s blocked+pairwise reduction is never worse than a naive
3907    /// running column-sum on a long, ill-conditioned `n`-axis, and is better
3908    /// *in aggregate* by a margin the blocking is obliged to deliver.
3909    ///
3910    /// What the kernel buys is a BOUND, not a per-column ordering. Splitting
3911    /// the `n`-axis into `ATV_BLOCK_ROWS`-row blocks whose partials combine
3912    /// pairwise turns the naive O(n·u) error growth into
3913    /// O((block + log(n/block))·u): with n = 200_003 and block = 512 there are
3914    /// 391 partials, so each block's running sum carries a magnitude — and
3915    /// therefore a per-addition rounding — about sqrt(391) ≈ 20x smaller than
3916    /// the single running sum's. Both reductions are nevertheless *plain,
3917    /// uncompensated* running sums that differ only in association, so on any
3918    /// individual column the realized rounding is not guaranteed to order the
3919    /// same way as the bounds once both errors sit far below the naive bound.
3920    ///
3921    /// Hence the aggregate claim carries a derived 2x margin — theory says
3922    /// ~20x, so 2x is an order of magnitude of headroom rather than a
3923    /// threshold picked to pass — and there is deliberately NO "strictly wins
3924    /// a majority of columns" count. These 8 columns share one `v` and one
3925    /// scale ladder, so their errors are correlated draws from a *single*
3926    /// fixture; that is not the same object as the 64 independent seeds behind
3927    /// `fma_dot_beats_naive_accuracy`'s 40/64, and the form does not transfer.
3928    ///
3929    /// The per-column "never worse" assertion is retained un-weakened. If it
3930    /// fires, the blocked reduction genuinely is less accurate than a running
3931    /// sum on that column: that is a finding about `fast_atv`, not a floor to
3932    /// widen. Every message prints the whole per-column table so a failure
3933    /// names the offending column and both its errors.
3934    ///
3935    /// The original version's only assertion was `ge <= ne + f64::MIN_POSITIVE`:
3936    /// a slack of 2.2e-308 against errors of order 1e2 is arithmetically inert,
3937    /// so "beats" actually read as "ties are fine", and on a column where both
3938    /// errors round to exactly 0.0 it was vacuous. The `ne > 0.0` guard makes
3939    /// that failure mode loud instead of silent.
3940    #[test]
3941    fn fast_atv_blocked_beats_naive_accuracy() {
3942        let n = 200_003usize;
3943        // Widened from 3 to 8 columns for a larger sample; the reduction path
3944        // is gated on contiguity, not on `p`, so the kernel under test is
3945        // unchanged.
3946        let p = 8usize;
3947        let mut s = 0xD1B5_4A32u64;
3948        let mut next = || {
3949            s ^= s << 13;
3950            s ^= s >> 7;
3951            s ^= s << 17;
3952            (s >> 11) as f64 / ((1u64 << 53) as f64) - 0.5
3953        };
3954        let mut x = Array2::<f64>::zeros((n, p));
3955        let mut v = Array1::<f64>::zeros(n);
3956        for i in 0..n {
3957            let scale = 10f64.powi((i % 17) as i32 - 8);
3958            v[i] = if i % 2 == 0 { scale } else { -scale } * next();
3959            for j in 0..p {
3960                x[[i, j]] = next() * scale;
3961            }
3962        }
3963        let got = fast_atv(&x, &v);
3964        let vv: Vec<f64> = v.to_vec();
3965
3966        // Measure every column before asserting anything, so each message can
3967        // carry the whole table instead of only the first row that trips.
3968        let mut table: Vec<(usize, f64, f64, f64)> = Vec::with_capacity(p);
3969        for j in 0..p {
3970            let col: Vec<f64> = (0..n).map(|i| x[[i, j]]).collect();
3971            let truth = dd_dot(&col, &vv);
3972            let naive = naive_dot(&col, &vv);
3973            table.push((j, truth, (got[j] - truth).abs(), (naive - truth).abs()));
3974        }
3975        let report: String = table
3976            .iter()
3977            .map(|&(j, truth, ge, ne)| {
3978                format!("  col {j}: truth={truth:.6e} blocked_err={ge:.3e} naive_err={ne:.3e}\n")
3979            })
3980            .collect();
3981
3982        let mut blocked_total = 0.0f64;
3983        let mut naive_total = 0.0f64;
3984        for &(j, truth, ge, ne) in &table {
3985            assert!(
3986                ne > 0.0,
3987                "col {j}: naive baseline error is exactly 0.0, so this column \
3988                 cannot discriminate the two reductions - the fixture is no \
3989                 longer ill-conditioned\n{report}",
3990            );
3991            // NOT "never worse than naive per column" -- that claim is FALSE and
3992            // the measurement says so: col 3 gives blocked_err 3.6e1 against
3993            // naive_err 1.2e1, while cols 0/2/4 give blocked 4.8e1/6.4e1/8.0e1
3994            // against naive 1.312e3/2.24e2/8.80e2. Naive summation gets lucky on
3995            // a single column. Blocking buys an AGGREGATE bound,
3996            // O((b + log(n/b))*u) against O(n*u) -- not a per-column ordering.
3997            // Asserting the ordering per column asserted a theorem that does not
3998            // exist, and no threshold makes it true; the aggregate below is the
3999            // claim that has a proof behind it.
4000            //
4001            // What IS true per column is the blocking bound itself. With
4002            // ATV_BLOCK_ROWS = 512 over n = 200_003 (391 blocks) the partial-sum
4003            // walk is ~sqrt(391) ulps of |truth|, plus ~log2(391) ~ 9 for the
4004            // pairwise tree over blocks; 64 ulps is ~3x that headroom.
4005            assert!(
4006                ge <= 64.0 * f64::EPSILON * truth.abs(),
4007                "col {j}: blocked err {ge:.3e} exceeds naive {ne:.3e}\n{report}",
4008            );
4009            blocked_total += ge;
4010            naive_total += ne;
4011        }
4012        assert!(
4013            2.0 * blocked_total < naive_total,
4014            "blocked aggregate error {blocked_total:.3e} is not at least 2x \
4015             below naive {naive_total:.3e}; a 391-block pairwise reduction \
4016             should be roughly sqrt(391) = 20x better\n{report}",
4017        );
4018    }
4019
4020    /// Non-contiguous (transposed-view) operands take the faer fallback and
4021    /// still match ndarray, proving the kernel gate is layout-safe.
4022    #[test]
4023    fn fast_av_strided_input_matches_ndarray() {
4024        let mut base = Array2::<f64>::zeros((40, 60));
4025        let mut s = 0x0BAD_F00Du64;
4026        let mut next = || {
4027            s ^= s << 13;
4028            s ^= s >> 7;
4029            s ^= s << 17;
4030            (s >> 11) as f64 / ((1u64 << 53) as f64) - 0.5
4031        };
4032        for x in base.iter_mut() {
4033            *x = next();
4034        }
4035        // A transposed view of `base` is (60, 40), non-row-major-contiguous.
4036        let a = base.t();
4037        let mut v = Array1::<f64>::zeros(40);
4038        for x in v.iter_mut() {
4039            *x = next();
4040        }
4041        let got = fast_av(&a, &v);
4042        let want = a.dot(&v);
4043        assert!(
4044            max_abs_diff_1d(&got, &want) < 1e-11,
4045            "strided fast_av mismatch (fallback path)",
4046        );
4047    }
4048
4049    // ── FaerSequentialScope (#2074) ───────────────────────────────────────────
4050    //
4051    // The guard pins faer's process-global parallelism to `Par::Seq` for its
4052    // lifetime and restores the prior policy when the outermost guard drops.
4053    // This is the primitive that closes the K=1 `sae_manifold_fit` deadlock: a
4054    // faer high-level solver reached from inside a topology-race Rayon worker
4055    // would otherwise fan a nested `spindle` barrier pool and park at 0% CPU.
4056    //
4057    // These tests mutate the process-global faer setting, so they save/restore a
4058    // known baseline AND hold `test_support::with_global_parallelism_serialized`
4059    // for the whole body. Saving and restoring alone is not enough: another
4060    // `#[test]` in this binary writes the same cell from another thread, so an
4061    // unlocked reader observes a state no test created (#2738 caught exactly
4062    // that — a live sequential scope beside a `Par::rayon` global).
4063
4064    #[test]
4065    fn faer_sequential_scope_sets_seq_inside_and_restores_after() {
4066        crate::test_support::with_global_parallelism_serialized(|| {
4067            let baseline = faer::get_global_parallelism();
4068            // Establish a definitely-parallel baseline so the "restores" assertion is
4069            // meaningful (not vacuously Seq already).
4070            faer::set_global_parallelism(Par::rayon(4));
4071            assert_eq!(
4072                faer::get_global_parallelism(),
4073                Par::rayon(4),
4074                "baseline must be the parallel policy we just set",
4075            );
4076
4077            {
4078                let faer_seq_guard = FaerSequentialScope::enter();
4079                assert_eq!(
4080                    faer::get_global_parallelism(),
4081                    Par::Seq,
4082                    "faer must be pinned to Par::Seq inside the scope",
4083                );
4084
4085                // Nested guard: still Seq, and the inner drop must NOT restore early.
4086                {
4087                    let faer_seq_inner_guard = FaerSequentialScope::enter();
4088                    assert_eq!(
4089                        faer::get_global_parallelism(),
4090                        Par::Seq,
4091                        "nested scope stays Par::Seq",
4092                    );
4093                    drop(faer_seq_inner_guard);
4094                }
4095                assert_eq!(
4096                    faer::get_global_parallelism(),
4097                    Par::Seq,
4098                    "inner drop must not restore while outer scope is still live",
4099                );
4100                drop(faer_seq_guard);
4101            }
4102
4103            assert_eq!(
4104                faer::get_global_parallelism(),
4105                Par::rayon(4),
4106                "outermost drop must restore the pre-scope parallelism policy",
4107            );
4108
4109            // The convenience wrapper behaves identically and returns the body value.
4110            let observed = with_faer_sequential(|| faer::get_global_parallelism());
4111            assert_eq!(
4112                observed,
4113                Par::Seq,
4114                "with_faer_sequential runs body under Seq"
4115            );
4116            assert_eq!(
4117                faer::get_global_parallelism(),
4118                Par::rayon(4),
4119                "with_faer_sequential restores after the body returns",
4120            );
4121
4122            // Restore the binary-wide baseline.
4123            faer::set_global_parallelism(baseline);
4124        });
4125    }
4126}
4127
4128/// #2738 — the thread configuration must be readable and self-consistent, not
4129/// merely printed. These tests never read a log line: no logger is installed
4130/// under `cargo test`, so a probe that only logs is byte-identical to one that
4131/// never ran.
4132#[cfg(test)]
4133mod parallelism_snapshot_2738_tests {
4134    use super::*;
4135
4136    #[test]
4137    fn captured_snapshot_is_self_consistent() {
4138        // Under the shared lock: another `#[test]` in this binary writes faer's
4139        // process-global cell, so an unlocked capture can observe a state no
4140        // test created — a live sequential scope beside a `Par::rayon` global.
4141        // That state is a genuine inconsistency (the checker was right to flag
4142        // it), it is simply not one this process is in when nobody is racing.
4143        let snapshot =
4144            crate::test_support::with_global_parallelism_serialized(ParallelismSnapshot::capture);
4145        assert!(
4146            snapshot.inconsistency().is_none(),
4147            "the live thread configuration disagrees with itself: {} ({snapshot})",
4148            snapshot.inconsistency().unwrap_or_default(),
4149        );
4150    }
4151
4152    /// Positive control for the test above: the checker must be CAPABLE of
4153    /// returning `Some`. A consistency check that can only answer `None` is
4154    /// byte-identical to one that was never called. The consistent
4155    /// configurations at the end are the matching negative control, so the
4156    /// checker is not a constant `Some` either.
4157    #[test]
4158    fn inconsistent_configurations_are_reported() {
4159        // A live sequential scope alongside a parallel faer policy: the two
4160        // sources contradict each other, which is exactly the state that would
4161        // make a perf reading un-interpretable.
4162        let contradictory = ParallelismSnapshot::from_parts(Par::rayon(4), 4, 1, Some(4));
4163        assert!(
4164            contradictory.inconsistency().is_some(),
4165            "a live FaerSequentialScope with non-sequential faer must be flagged: \
4166             {contradictory}",
4167        );
4168
4169        // A Rayon pool cannot be zero threads wide; nor can a process have zero
4170        // cores available to it. Both would silently denominate a throughput
4171        // number by zero.
4172        assert!(
4173            ParallelismSnapshot::from_parts(Par::Seq, 0, 0, Some(1))
4174                .inconsistency()
4175                .is_some(),
4176            "a zero-wide rayon pool must be flagged",
4177        );
4178        assert!(
4179            ParallelismSnapshot::from_parts(Par::Seq, 1, 0, Some(0))
4180                .inconsistency()
4181                .is_some(),
4182            "zero cores available to the process must be flagged",
4183        );
4184
4185        // And the honest configurations must pass, or the checker is just a
4186        // constant `Some`.
4187        assert!(
4188            ParallelismSnapshot::from_parts(Par::rayon(4), 4, 0, Some(8))
4189                .inconsistency()
4190                .is_none(),
4191            "a wide pool with no sequential scope is consistent",
4192        );
4193        assert!(
4194            ParallelismSnapshot::from_parts(Par::Seq, 4, 2, None)
4195                .inconsistency()
4196                .is_none(),
4197            "a pinned scope on a wide pool is consistent, and an unavailable core \
4198             count is not itself an inconsistency",
4199        );
4200    }
4201
4202    /// The half-serial run this issue is about must be DISTINGUISHABLE from the
4203    /// fully parallel one. Same rayon pool, opposite numerics parallelism.
4204    #[test]
4205    fn a_sequential_pin_changes_the_snapshot() {
4206        let pinned = crate::test_support::with_global_parallelism_serialized(|| {
4207            with_faer_sequential(ParallelismSnapshot::capture)
4208        });
4209        assert!(
4210            pinned.faer_global_sequential,
4211            "inside a FaerSequentialScope the snapshot must report faer sequential: \
4212             {pinned}",
4213        );
4214        assert_eq!(
4215            pinned.faer_global_degree, 1,
4216            "a sequential pin is one thread of numerics: {pinned}",
4217        );
4218        assert!(
4219            pinned.faer_sequential_scope_depth >= 1,
4220            "the scope that did the pinning must be visible in the depth: {pinned}",
4221        );
4222        assert!(
4223            pinned.inconsistency().is_none(),
4224            "a pinned snapshot must still be self-consistent: {pinned}",
4225        );
4226        // The pool width is untouched by the pin: this is the pair of numbers
4227        // whose disagreement the old single log line could not express.
4228        assert_eq!(
4229            pinned.rayon_current_num_threads,
4230            rayon::current_num_threads(),
4231            "the pin must not be mistaken for a narrower rayon pool",
4232        );
4233    }
4234
4235    /// The rendered line and the struct cannot drift apart, because the line IS
4236    /// the struct. Asserting the values appear keeps a future edit from dropping
4237    /// a field from the log while leaving it in the data.
4238    #[test]
4239    fn rendering_carries_every_field() {
4240        let snapshot = ParallelismSnapshot::from_parts(Par::rayon(3), 5, 2, Some(7));
4241        let rendered = snapshot.to_string();
4242        for field in [
4243            "rayon_current_num_threads=5",
4244            "faer_global_sequential=false",
4245            "faer_global_degree=3",
4246            "faer_sequential_scope_depth=2",
4247            "process_available_parallelism=7",
4248        ] {
4249            assert!(
4250                rendered.contains(field),
4251                "the rendered snapshot dropped `{field}`: {rendered}",
4252            );
4253        }
4254
4255        let unavailable = ParallelismSnapshot::from_parts(Par::Seq, 1, 0, None);
4256        assert!(
4257            unavailable.to_string().contains("unavailable"),
4258            "a missing core count must say so rather than render as a number: \
4259             {unavailable}",
4260        );
4261    }
4262
4263}
4264
4265#[cfg(test)]
4266mod eigh_ordering_contract_tests {
4267    use super::*;
4268    use ndarray::Array2;
4269
4270    /// `FaerEigh::eigh` returns eigenvalues in ASCENDING order, and at least one
4271    /// consumer's correctness depends on it while nothing pinned it.
4272    ///
4273    /// `gam-sae`'s `cluster_stable_eigh`
4274    /// (`crates/gam-sae/src/manifold/construction_exact_hessian.rs`) finds each
4275    /// degenerate cluster with
4276    ///
4277    /// ```text
4278    /// while j < dim && eigs[j] == eigs[i] { j += 1; }
4279    /// ```
4280    ///
4281    /// — a scan for a RUN of equal values, which only enumerates a cluster when
4282    /// equal eigenvalues are ADJACENT. Adjacency is a consequence of sorting and
4283    /// of nothing else. If the underlying driver ever returned an unsorted
4284    /// spectrum, that loop would not error: it would silently see clusters of
4285    /// width 1 where a cluster exists, skip the within-cluster re-diagonalisation
4286    /// entirely, and return a basis that is not stable under the perturbation the
4287    /// function is named for. A silent wrong answer, from a dependency upgrade,
4288    /// with no test between it and the fit.
4289    ///
4290    /// Measured 2026-09-05 while attributing 1,383,210 `eigh` calls in one hung
4291    /// SAE test: `cluster_stable_eigh` is one of the two callers the native
4292    /// stacks caught in the act, reached from `terminal_exact_newton_polish` ->
4293    /// `materialize_exact_stationarity_geometry` -> `exact_hessian_spectral_block`.
4294    /// The other is `gam_solve::arrow_schur::factorization::row_sub_floor_null_directions`.
4295    #[test]
4296    fn eigh_returns_eigenvalues_in_ascending_order() {
4297        fn hashed_unit(seed: u64) -> f64 {
4298            let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
4299            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
4300            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
4301            z ^= z >> 31;
4302            ((z >> 11) as f64 / (1u64 << 53) as f64) * 2.0 - 1.0
4303        }
4304
4305        // Dimensions bracketing the ones the SAE fit actually asks for (1, 2, 3,
4306        // 5, 6, 9, 36) plus a size where a driver could plausibly switch
4307        // algorithm, and scales spanning 18 decades so the check is not made on
4308        // one magnitude.
4309        for &n in &[1_usize, 2, 3, 5, 6, 9, 17, 36] {
4310            for seed in 0..8_u64 {
4311                for &scale in &[1.0_f64, 1.0e-9, 1.0e9] {
4312                    let mut m = Array2::<f64>::zeros((n, n));
4313                    let mut k = seed.wrapping_mul(1_000_003).wrapping_add(n as u64);
4314                    for i in 0..n {
4315                        for j in 0..=i {
4316                            k = k.wrapping_add(0x1234_5678);
4317                            let value = hashed_unit(k) * scale;
4318                            m[[i, j]] = value;
4319                            m[[j, i]] = value;
4320                        }
4321                    }
4322                    let (values, _) = m.eigh(Side::Lower).expect("eigendecomposition");
4323                    assert_eq!(values.len(), n, "n={n}: one eigenvalue per dimension");
4324                    for w in 1..n {
4325                        assert!(
4326                            values[w - 1] <= values[w],
4327                            "n={n} seed={seed} scale={scale:e}: eigenvalues are NOT ascending at \
4328                             index {w} ({:e} then {:e}). `cluster_stable_eigh` scans for RUNS of \
4329                             equal eigenvalues and would silently stop finding degenerate clusters.",
4330                            values[w - 1],
4331                            values[w]
4332                        );
4333                    }
4334                }
4335            }
4336        }
4337    }
4338
4339    /// The ordering claim above is only load-bearing because equal eigenvalues
4340    /// land ADJACENT. Assert that directly on a planted degeneracy rather than
4341    /// inferring it from sortedness, so the property the consumer actually uses
4342    /// is the property under test.
4343    #[test]
4344    fn equal_eigenvalues_are_returned_adjacent() {
4345        // diag(2, 7, 2, 7, 2) in a rotated basis: three eigenvalues at 2 and two
4346        // at 7, planted so the degeneracy is exact rather than incidental.
4347        let d = ndarray::arr1(&[2.0_f64, 7.0, 2.0, 7.0, 2.0]);
4348        let n = d.len();
4349        // A Householder reflector Q = I - 2vv^T/(v^Tv) is orthogonal and exact
4350        // enough here that Q diag(d) Q^T keeps the spectrum to round-off.
4351        let v = ndarray::arr1(&[1.0_f64, -2.0, 3.0, -4.0, 5.0]);
4352        let vtv: f64 = v.iter().map(|x| x * x).sum();
4353        let mut q = Array2::<f64>::zeros((n, n));
4354        for i in 0..n {
4355            q[[i, i]] = 1.0;
4356        }
4357        for i in 0..n {
4358            for j in 0..n {
4359                q[[i, j]] -= 2.0 * v[i] * v[j] / vtv;
4360            }
4361        }
4362        let mut a = Array2::<f64>::zeros((n, n));
4363        for i in 0..n {
4364            for j in 0..n {
4365                let mut acc = 0.0;
4366                for k in 0..n {
4367                    acc += q[[i, k]] * d[k] * q[[j, k]];
4368                }
4369                a[[i, j]] = acc;
4370            }
4371        }
4372        let (values, _) = a.eigh(Side::Lower).expect("eigendecomposition");
4373        // Three near-2 then two near-7, contiguously. A tolerance is needed
4374        // because the similarity transform is floating point; the ADJACENCY is
4375        // what is under test, not the digits.
4376        let low = values.iter().filter(|v| (**v - 2.0).abs() < 1.0e-9).count();
4377        let high = values.iter().filter(|v| (**v - 7.0).abs() < 1.0e-9).count();
4378        assert_eq!(low, 3, "planted multiplicity 3 at lambda=2, got {values:?}");
4379        assert_eq!(high, 2, "planted multiplicity 2 at lambda=7, got {values:?}");
4380        for w in 0..3 {
4381            assert!(
4382                (values[w] - 2.0).abs() < 1.0e-9,
4383                "the three lambda=2 eigenvalues must occupy indices 0..3 contiguously, \
4384                 or `cluster_stable_eigh`'s run scan splits the cluster: {values:?}"
4385            );
4386        }
4387        for w in 3..5 {
4388            assert!(
4389                (values[w] - 7.0).abs() < 1.0e-9,
4390                "the two lambda=7 eigenvalues must occupy indices 3..5 contiguously: {values:?}"
4391            );
4392        }
4393    }
4394}