Skip to main content

gam_linalg/matrix/
mod.rs

1use crate::faer_ndarray::{CrossprodAccum, CrossprodStructure, FaerArrayView, array2_to_matmut, effective_global_parallelism, fast_ab, fast_atb, fast_atv, fast_atv_into, fast_av, fast_av_into, stream_weighted_crossprod_into};
2use crate::types::RidgePolicy;
3use faer::Accum;
4use faer::linalg::matmul::matmul;
5use faer::sparse::{SparseColMat, SparseRowMat, Triplet};
6use gam_runtime::resource::{
7    Governed, MaterializationPolicy, MatrixMaterializationError, MemoryGovernor, MemoryReservation,
8    ResourcePolicy, dense_f64_bytes, rows_for_target_bytes,
9};
10use ndarray::{
11    Array1, Array2, ArrayView1, ArrayView2, ArrayViewMut1, ArrayViewMut2, Axis, ShapeBuilder, s,
12};
13use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator};
14use std::borrow::Cow;
15use std::collections::BTreeMap;
16use std::ops::Deref;
17use std::ops::Range;
18use std::sync::{Arc, OnceLock};
19use crate::faer_ndarray::fast_xt_diag_x;
20
21const MATRIX_FREE_PCG_MIN_P: usize = 2048;
22const MATRIX_FREE_PCG_REL_TOL: f64 = 1e-8;
23/// Minimum numerical ridge added to the (penalized) normal matrix before an SPD
24/// solve. Near `f64` precision: large enough to lift an exactly-singular system
25/// off zero so the factorization succeeds, small enough not to bias a
26/// well-conditioned solve. Acts as a floor on any caller-supplied `ridge_floor`.
27const MATRIX_FREE_PCG_MAX_ITER: usize = 2000;
28/// Dense-materialization row-chunk working-set target. The same quantity as
29/// the library row-chunk target, imported rather than transcribed (#2704).
30const CHUNKED_DENSE_MATERIALIZATION_BYTES: usize =
31    gam_runtime::resource::LIBRARY_ROW_CHUNK_TARGET_BYTES;
32const OPERATOR_ROW_CHUNK_SIZE: usize = 256;
33/// Minimum n*p product for the dense-row parallel fold/reduce paths
34/// (`diag_gram`, `apply_weighted_normal`, dense transpose reductions).
35/// Below this, the sequential row loop wins on overhead.
36const DENSE_ROW_PARALLEL_MIN_NP: u64 = 200_000;
37const WEIGHTED_CROSSPROD_PARALLEL_MIN_FLOPS: u64 = 500_000;
38const SPARSE_ROW_PARALLEL_MIN_FLOPS: u64 = 100_000;
39/// Maximum bytes for the (n, tail_total) intermediate in GEMM-batched tensor
40/// product matvecs.  Beyond this threshold, fall back to per-column GEMV.
41const TENSOR_GEMM_MAX_INTERMEDIATE_BYTES: usize = 128 * 1024 * 1024; // 128 MB
42
43pub use crate::utils::PcgSolveInfo;
44
45mod sparse_hessian;
46pub use sparse_hessian::{SparseHessianAccumulator, SparseHessianSymbolic};
47
48mod weights;
49pub use weights::{FiniteSignedWeightsView, PsdWeightsView, SignedWeightsArc, SignedWeightsView};
50
51/// Typed error for `src/linalg/matrix.rs` operations.  All error sites in this
52/// module construct a `MatrixError` variant; trait method bodies that still
53/// return `Result<_, String>` convert via `From<MatrixError> for String` (which
54/// is byte-equivalent to the prior `format!` / `to_string` payloads).
55#[derive(Debug, Clone)]
56pub enum MatrixError {
57    /// Operand shapes (rows, columns, lengths) do not satisfy the operation's
58    /// dimension contract.  Also covers integer-overflow in dimension products.
59    DimensionMismatch { reason: String },
60    /// Refused to materialize an operator-backed or sparse design to a dense
61    /// `Array2<f64>` because the active `ResourcePolicy` (size cap or strict
62    /// operator-only mode) forbids it.
63    DensificationRefused { reason: String },
64}
65
66crate::impl_reason_error_boilerplate! {
67    MatrixError {
68        DimensionMismatch,
69        DensificationRefused,
70    }
71}
72
73#[inline]
74fn dense_materialization_chunk_rows(nrows: usize, ncols: usize) -> usize {
75    rows_for_target_bytes(CHUNKED_DENSE_MATERIALIZATION_BYTES, ncols)
76        .max(1)
77        .min(nrows.max(1))
78}
79
80fn dense_operator_to_dense_by_chunks<O: DenseDesignOperator + ?Sized>(
81    op: &O,
82) -> Result<Array2<f64>, MatrixMaterializationError> {
83    let n = op.nrows();
84    let p = op.ncols();
85    let chunk_rows = dense_materialization_chunk_rows(n, p);
86    let mut out = Array2::<f64>::zeros((n, p));
87    for start in (0..n).step_by(chunk_rows) {
88        let end = (start + chunk_rows).min(n);
89        let slice = out.slice_mut(s![start..end, ..]);
90        op.row_chunk_into(start..end, slice)?;
91    }
92    Ok(out)
93}
94
95/// Fallible full materialization whose process-wide reservation lives exactly
96/// as long as the returned matrix.
97fn governed_dense_operator_to_dense_by_chunks<O: DenseDesignOperator + ?Sized>(
98    op: &O,
99    policy: &MaterializationPolicy,
100    context: &'static str,
101) -> Result<Governed<Array2<f64>>, MatrixMaterializationError> {
102    let effective_policy =
103        merge_operator_materialization_policies(Some(policy.clone()), op.materialization_policy())
104            .expect("caller policy is always present");
105    if !effective_policy.allow_operator_materialization {
106        crate::governed_capture::record_governed_decision(
107            context,
108            op.nrows(),
109            op.ncols(),
110            None,
111            crate::governed_capture::GovernedArm::Ineligible,
112        );
113        return Err(MatrixMaterializationError::Forbidden {
114            context,
115            mode: gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired,
116        });
117    }
118    let bytes = dense_f64_bytes(op.nrows(), op.ncols()).unwrap_or(usize::MAX);
119    if bytes > effective_policy.max_single_dense_bytes {
120        crate::governed_capture::record_governed_decision(
121            context,
122            op.nrows(),
123            op.ncols(),
124            Some(bytes),
125            crate::governed_capture::GovernedArm::Ineligible,
126        );
127        return Err(MatrixMaterializationError::TooLarge {
128            context,
129            nrows: op.nrows(),
130            ncols: op.ncols(),
131            bytes,
132            limit_bytes: effective_policy.max_single_dense_bytes,
133        });
134    }
135    let reservation =
136        match MemoryGovernor::global().try_reserve_dense_f64(op.nrows(), op.ncols(), context) {
137            Ok(reservation) => reservation,
138            Err(err) => {
139                crate::governed_capture::record_governed_decision(
140                    context,
141                    op.nrows(),
142                    op.ncols(),
143                    Some(bytes),
144                    crate::governed_capture::GovernedArm::Refused,
145                );
146                return Err(err.into());
147            }
148        };
149    crate::governed_capture::record_governed_decision(
150        context,
151        op.nrows(),
152        op.ncols(),
153        Some(bytes),
154        crate::governed_capture::GovernedArm::Admitted,
155    );
156    dense_operator_to_dense_by_chunks(op).map(|matrix| reservation.bind(matrix))
157}
158
159pub fn checked_dense_nbytes(nrows: usize, ncols: usize, context: &str) -> Result<usize, String> {
160    nrows
161        .checked_mul(ncols)
162        .and_then(|cells| cells.checked_mul(std::mem::size_of::<f64>()))
163        .ok_or_else(|| {
164            MatrixError::DimensionMismatch {
165                reason: format!("{context}: dense size overflow for {nrows}x{ncols}"),
166            }
167            .into()
168        })
169}
170
171pub fn panic_or_error_if_large_scale_mode_and_to_dense_called_with_policy(
172    context: &str,
173    n: usize,
174    p: usize,
175    policy: &ResourcePolicy,
176) -> Result<(), String> {
177    // Strict-operator mode: refuse any dense materialization, regardless of
178    // size.  Callers in this mode have committed to operator-only math; any
179    // dense fallback (cache or otherwise) violates that contract and would
180    // silently turn an analytic-operator path into a hidden dense path at
181    // large scale.
182    if matches!(
183        policy.derivative_storage_mode,
184        gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired
185    ) {
186        return Err(MatrixError::DensificationRefused {
187            reason: format!(
188                "{context}: refusing to densify operator-backed design {n}x{p} under \
189             AnalyticOperatorRequired policy; provide an operator-form path"
190            ),
191        }
192        .into());
193    }
194    let dense_bytes = checked_dense_nbytes(n, p, context)?;
195    let limit = policy.max_single_materialization_bytes;
196    if dense_bytes > limit {
197        // Report BOTH operands, in exact bytes as well as GiB. The comparison
198        // that produced this refusal is `dense_bytes > limit`, and the old
199        // message printed only `dense_bytes` — the innocent half. A reader who
200        // saw `300x12 (~0.00 GiB); use matrix-free or chunked code` could not
201        // tell whether a real ceiling had been reached or the ceiling was
202        // itself ~0, which are opposite problems with opposite fixes (gam#2684).
203        //
204        // The GiB rendering is kept because it is what makes a genuinely large
205        // refusal readable, but it is no longer alone: at 28,800 bytes it
206        // rounds to `0.00 GiB` and hides the very number that would have made
207        // the refusal obviously wrong.
208        //
209        // A `limit` at or near zero is not necessarily a defect here. The
210        // governor deliberately fails closed to a zero budget when an active
211        // cgroup controller cannot be parsed exactly — "it never silently
212        // inherits host capacity" — so a zero ceiling is a correct, typed
213        // outcome of an unreadable environment, and the fix for it lives at the
214        // probe, not at this call site. Naming the limit is what lets the next
215        // reader tell those cases apart instead of guessing.
216        let gib = dense_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
217        let limit_gib = limit as f64 / (1024.0 * 1024.0 * 1024.0);
218        return Err(MatrixError::DensificationRefused {
219            reason: format!(
220                "{context}: refusing to densify operator-backed design {n}x{p} \
221                 (~{gib:.2} GiB, {dense_bytes} bytes) because it exceeds the single-materialization \
222                 cap (~{limit_gib:.2} GiB, {limit} bytes); use matrix-free or chunked code. \
223                 A cap at or near zero means the process could not size a memory budget — \
224                 check the governor's detected availability rather than this allocation"
225            ),
226        }
227        .into());
228    }
229    Ok(())
230}
231
232fn merge_operator_materialization_policies(
233    left: Option<MaterializationPolicy>,
234    right: Option<MaterializationPolicy>,
235) -> Option<MaterializationPolicy> {
236    match (left, right) {
237        (None, policy) | (policy, None) => policy,
238        (Some(left), Some(right)) => Some(MaterializationPolicy {
239            max_single_dense_bytes: left
240                .max_single_dense_bytes
241                .min(right.max_single_dense_bytes),
242            max_cached_dense_bytes: left
243                .max_cached_dense_bytes
244                .min(right.max_cached_dense_bytes),
245            row_chunk_target_bytes: left
246                .row_chunk_target_bytes
247                .min(right.row_chunk_target_bytes),
248            allow_operator_materialization: left.allow_operator_materialization
249                && right.allow_operator_materialization,
250            allow_diagnostic_materialization: left.allow_diagnostic_materialization
251                && right.allow_diagnostic_materialization,
252        }),
253    }
254}
255
256fn enforce_operator_materialization_policy(
257    op: &dyn DenseDesignOperator,
258    context: &str,
259) -> Result<(), String> {
260    let Some(policy) = op.materialization_policy() else {
261        return Ok(());
262    };
263    if !policy.allow_operator_materialization {
264        return Err(MatrixError::DensificationRefused {
265            reason: format!(
266                "{context}: refusing to densify {}x{} operator-backed design because its \
267                 construction policy requires streamed storage",
268                op.nrows(),
269                op.ncols(),
270            ),
271        }
272        .into());
273    }
274    let bytes = checked_dense_nbytes(op.nrows(), op.ncols(), context)?;
275    if bytes > policy.max_single_dense_bytes {
276        return Err(MatrixError::DensificationRefused {
277            reason: format!(
278                "{context}: refusing to densify {}x{} operator-backed design ({bytes} bytes); \
279                 its construction-policy limit is {} bytes",
280                op.nrows(),
281                op.ncols(),
282                policy.max_single_dense_bytes,
283            ),
284        }
285        .into());
286    }
287    Ok(())
288}
289
290/// Validate a row-weight diagonal before any output buffer is allocated or
291/// mutated.  The linear weighted operators in this module are defined for all
292/// finite signed weights; `NaN` and infinities have no linear-operator meaning
293/// and are rejected at the smallest offending row.
294#[inline]
295fn certify_signed_weights<'a>(
296    context: &str,
297    weights: &'a Array1<f64>,
298    expected_len: usize,
299) -> Result<FiniteSignedWeightsView<'a>, String> {
300    if weights.len() != expected_len {
301        return Err(MatrixError::DimensionMismatch {
302            reason: format!(
303                "{context} weight length mismatch: weights={}, nrows={expected_len}",
304                weights.len()
305            ),
306        }
307        .into());
308    }
309    FiniteSignedWeightsView::try_from_array(weights)
310        .map_err(|reason| format!("{context}: {reason}"))
311}
312
313fn weighted_crossprod_dense(
314    left: &Array2<f64>,
315    weights: &Array1<f64>,
316    right: &Array2<f64>,
317) -> Result<Array2<f64>, String> {
318    if left.nrows() != weights.len() || right.nrows() != weights.len() {
319        return Err(MatrixError::DimensionMismatch {
320            reason: format!(
321                "weighted_crossprod_dense row mismatch: left={}, weights={}, right={}",
322                left.nrows(),
323                weights.len(),
324                right.nrows()
325            ),
326        }
327        .into());
328    }
329    certify_signed_weights("weighted_crossprod_dense", weights, left.nrows())?;
330    Ok(weighted_crossprod_dense_view(left, weights.view(), right))
331}
332
333fn weighted_crossprod_dense_view(
334    left: &Array2<f64>,
335    weights: ArrayView1<'_, f64>,
336    right: &Array2<f64>,
337) -> Array2<f64> {
338    let n = weights.len();
339    let p_left = left.ncols();
340    let p_right = right.ncols();
341    let work = (n as u64)
342        .saturating_mul(p_left as u64)
343        .saturating_mul(p_right as u64);
344    if rayon::current_num_threads() <= 1 || work < WEIGHTED_CROSSPROD_PARALLEL_MIN_FLOPS {
345        return weighted_crossprod_dense_rows(left, weights, right, 0..n);
346    }
347
348    let min_parallel_work = WEIGHTED_CROSSPROD_PARALLEL_MIN_FLOPS.min(usize::MAX as u64) as usize;
349    let Some(chunk_rows) = crate::parallel::row_reduction_chunk_rows(
350        n,
351        p_left.saturating_mul(p_right),
352        p_left.saturating_mul(p_right),
353        min_parallel_work,
354    ) else {
355        return weighted_crossprod_dense_rows(left, weights, right, 0..n);
356    };
357    let starts: Vec<usize> = (0..n).step_by(chunk_rows).collect();
358    let partials: Vec<Array2<f64>> = starts
359        .into_par_iter()
360        .map(|start| {
361            weighted_crossprod_dense_rows(left, weights, right, start..(start + chunk_rows).min(n))
362        })
363        .collect();
364    let mut out = Array2::<f64>::zeros((p_left, p_right));
365    for partial in &partials {
366        out += partial;
367    }
368    out
369}
370
371fn weighted_crossprod_dense_rows(
372    left: &Array2<f64>,
373    weights: ArrayView1<'_, f64>,
374    right: &Array2<f64>,
375    rows: Range<usize>,
376) -> Array2<f64> {
377    // The per-row body below is `Σᵢ wᵢ · leftᵢᵀ · rightᵢ`, which is linear in
378    // `wᵢ` and therefore sign-correct without any PSD assumption. The PSD
379    // precondition belongs at the symmetric `Xᵀ W X` caller (`weighted_crossprod_dense_view`),
380    // not at this kernel: `BlockDesignOperator::cross_block` legitimately uses
381    // the asymmetric form `X_iᵀ W X_j` with signed `c·Xv` weights from the outer
382    // REML Hessian-derivative correction, which is not PSD even when `w ≥ 0`.
383    // The prior assert here turned that legitimate signed use into a panic.
384    let p_left = left.ncols();
385    let p_right = right.ncols();
386    let mut out = Array2::<f64>::zeros((p_left, p_right));
387    if left.is_standard_layout()
388        && right.is_standard_layout()
389        && let (Some(lx), Some(rx), Some(w)) =
390            (left.as_slice(), right.as_slice(), weights.as_slice())
391    {
392        let out_slice = out.as_slice_mut().expect("zeros are contiguous");
393        for i in rows {
394            let wi = w[i];
395            if wi == 0.0 {
396                continue;
397            }
398            let l_row = &lx[i * p_left..i * p_left + p_left];
399            let r_row = &rx[i * p_right..i * p_right + p_right];
400            for a in 0..p_left {
401                let scaled = wi * l_row[a];
402                if scaled == 0.0 {
403                    continue;
404                }
405                let out_row = &mut out_slice[a * p_right..a * p_right + p_right];
406                for b in 0..p_right {
407                    out_row[b] += scaled * r_row[b];
408                }
409            }
410        }
411        return out;
412    }
413    for i in rows {
414        let wi = weights[i];
415        if wi == 0.0 {
416            continue;
417        }
418        for a in 0..p_left {
419            let scaled = wi * left[[i, a]];
420            if scaled == 0.0 {
421                continue;
422            }
423            for b in 0..p_right {
424                out[[a, b]] += scaled * right[[i, b]];
425            }
426        }
427    }
428    out
429}
430
431pub struct DenseRightProductView<'a> {
432    base: &'a Array2<f64>,
433    first: Option<&'a Array2<f64>>,
434    second: Option<&'a Array2<f64>>,
435}
436
437impl<'a> DenseRightProductView<'a> {
438    pub fn new(base: &'a Array2<f64>) -> Self {
439        Self {
440            base,
441            first: None,
442            second: None,
443        }
444    }
445
446    #[must_use]
447    pub fn with_factor(mut self, factor: &'a Array2<f64>) -> Self {
448        if self.first.is_none() {
449            self.first = Some(factor);
450        } else if self.second.is_none() {
451            self.second = Some(factor);
452        } else {
453            // SAFETY: DenseRightProductView statically carries exactly two optional
454            // factor slots (`first` and `second`); reaching this branch means a
455            // caller invoked `with_factor` a third time, which violates the
456            // type's documented contract of at most two right factors.
457            // SAFETY: third `with_factor` call violates the type's two-factor invariant.
458            std::panic::panic_any("DenseRightProductView supports at most two right factors");
459        }
460        self
461    }
462
463    #[must_use]
464    pub fn with_optional_factor(self, factor: Option<&'a Array2<f64>>) -> Self {
465        match factor {
466            Some(factor) => self.with_factor(factor),
467            None => self,
468        }
469    }
470
471    pub fn materialize(&self) -> Array2<f64> {
472        let mut out = self.base.clone();
473        if let Some(factor) = self.first {
474            out = fast_ab(&out, factor);
475        }
476        if let Some(factor) = self.second {
477            out = fast_ab(&out, factor);
478        }
479        out
480    }
481
482    fn transformed_ncols(&self) -> usize {
483        if let Some(factor) = self.second {
484            factor.ncols()
485        } else if let Some(factor) = self.first {
486            factor.ncols()
487        } else {
488            self.base.ncols()
489        }
490    }
491}
492
493pub struct EmbeddedColumnBlock<'a> {
494    local: &'a Array2<f64>,
495    global_range: Range<usize>,
496    total_cols: usize,
497}
498
499impl<'a> EmbeddedColumnBlock<'a> {
500    pub fn new(local: &'a Array2<f64>, global_range: Range<usize>, total_cols: usize) -> Self {
501        Self {
502            local,
503            global_range,
504            total_cols,
505        }
506    }
507
508    pub fn materialize(&self) -> Array2<f64> {
509        if self.local.nrows() == 0 {
510            return Array2::<f64>::zeros((0, self.total_cols));
511        }
512        assert_eq!(
513            self.local.ncols(),
514            self.global_range.len(),
515            "embedded column block width mismatch"
516        );
517        let mut out = Array2::<f64>::zeros((self.local.nrows(), self.total_cols));
518        out.slice_mut(ndarray::s![.., self.global_range.clone()])
519            .assign(self.local);
520        out
521    }
522}
523
524pub struct EmbeddedSquareBlock<'a> {
525    local: &'a Array2<f64>,
526    global_range: Range<usize>,
527    total_dim: usize,
528}
529
530impl<'a> EmbeddedSquareBlock<'a> {
531    pub fn new(local: &'a Array2<f64>, global_range: Range<usize>, total_dim: usize) -> Self {
532        Self {
533            local,
534            global_range,
535            total_dim,
536        }
537    }
538
539    pub fn materialize(&self) -> Array2<f64> {
540        let mut out = Array2::<f64>::zeros((self.total_dim, self.total_dim));
541        out.slice_mut(ndarray::s![
542            self.global_range.clone(),
543            self.global_range.clone()
544        ])
545        .assign(self.local);
546        out
547    }
548}
549
550struct PenalizedWeightedNormalOperator<'a, O: LinearOperator + ?Sized> {
551    operator: &'a O,
552    weights: &'a Array1<f64>,
553    finite_weights: FiniteSignedWeightsView<'a>,
554    penalty: Option<&'a Array2<f64>>,
555    ridge: f64,
556}
557
558impl<'a, O: LinearOperator + ?Sized> PenalizedWeightedNormalOperator<'a, O> {
559    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
560        self.operator
561            .apply_weighted_normal(self.finite_weights, vector, self.penalty, self.ridge)
562    }
563
564    fn jacobi_preconditioner(&self) -> Result<Array1<f64>, String> {
565        let mut diag = self.operator.diag_gram(self.weights)?;
566        if let Some(pen) = self.penalty {
567            for i in 0..diag.len() {
568                diag[i] += pen[[i, i]];
569            }
570        }
571        if self.ridge > 0.0 {
572            for i in 0..diag.len() {
573                diag[i] += self.ridge;
574            }
575        }
576        Ok(diag)
577    }
578}
579
580#[inline]
581fn dense_diag_gram_view(matrix: &Array2<f64>, weights: ArrayView1<'_, f64>) -> Array1<f64> {
582    // Exact diagonal of Xᵀdiag(w)X.  It is linear in w and therefore retains
583    // signed observed curvature; solver-level stabilization decides whether a
584    // resulting global system is suitable for Cholesky/PCG.
585    let p = matrix.ncols();
586    let n = matrix.nrows();
587    let large = (n as u64) * (p as u64) >= DENSE_ROW_PARALLEL_MIN_NP;
588    let parallel = large && rayon::current_thread_index().is_none();
589    // Fast path: if the matrix is row-major contiguous, read each row as a
590    // slice and avoid n*p bounds-checked indexing.
591    if matrix.is_standard_layout()
592        && let (Some(x), Some(w)) = (matrix.as_slice(), weights.as_slice())
593    {
594        if parallel {
595            // Deterministic parallel row reduction: length-only pairwise tree
596            // so the accumulated float result never depends on thread count or
597            // rayon's demand-driven fold/reduce grouping (#2228).
598            return crate::pairwise_reduce::par_deterministic_block_fold(
599                n,
600                |range: core::ops::Range<usize>| {
601                    let mut acc = vec![0.0_f64; p];
602                    for i in range {
603                        let wi = w[i];
604                        if wi != 0.0 {
605                            let row = &x[i * p..i * p + p];
606                            for j in 0..p {
607                                let xij = row[j];
608                                acc[j] += wi * xij * xij;
609                            }
610                        }
611                    }
612                    acc
613                },
614                |mut a, b| {
615                    for (av, bv) in a.iter_mut().zip(b) {
616                        *av += bv;
617                    }
618                    a
619                },
620            )
621            .unwrap_or_else(|| vec![0.0_f64; p])
622            .into();
623        }
624        let mut diag = Array1::<f64>::zeros(p);
625        let diag_slice = diag.as_slice_mut().expect("zeros are contiguous");
626        for i in 0..n {
627            let wi = w[i];
628            if wi == 0.0 {
629                continue;
630            }
631            let row = &x[i * p..i * p + p];
632            for j in 0..p {
633                let xij = row[j];
634                diag_slice[j] += wi * xij * xij;
635            }
636        }
637        return diag;
638    }
639    let mut diag = Array1::<f64>::zeros(p);
640    for i in 0..n {
641        let wi = weights[i];
642        if wi == 0.0 {
643            continue;
644        }
645        for j in 0..p {
646            let xij = matrix[[i, j]];
647            diag[j] += wi * xij * xij;
648        }
649    }
650    diag
651}
652
653fn sparse_csr_weighted_xtwx(
654    row_ptr: &[usize],
655    col_idx: &[usize],
656    vals: &[f64],
657    n: usize,
658    p: usize,
659    weights: ArrayView1<'_, f64>,
660) -> Array2<f64> {
661    let nnz = vals.len() as u64;
662    let avg = nnz.checked_div(n.max(1) as u64).unwrap_or(0);
663    let work = (n as u64).saturating_mul(avg.saturating_mul(avg));
664    if rayon::current_num_threads() <= 1 || work < SPARSE_ROW_PARALLEL_MIN_FLOPS {
665        return sparse_csr_weighted_xtwx_rows(row_ptr, col_idx, vals, p, weights, 0..n);
666    }
667
668    let min_parallel_work = SPARSE_ROW_PARALLEL_MIN_FLOPS.min(usize::MAX as u64) as usize;
669    let Some(chunk_rows) = crate::parallel::row_reduction_chunk_rows(
670        n,
671        avg.min(usize::MAX as u64) as usize,
672        p.saturating_mul(p),
673        min_parallel_work,
674    ) else {
675        return sparse_csr_weighted_xtwx_rows(row_ptr, col_idx, vals, p, weights, 0..n);
676    };
677    let starts: Vec<usize> = (0..n).step_by(chunk_rows).collect();
678    let partials: Vec<Array2<f64>> = starts
679        .into_par_iter()
680        .map(|start| {
681            sparse_csr_weighted_xtwx_rows(
682                row_ptr,
683                col_idx,
684                vals,
685                p,
686                weights,
687                start..(start + chunk_rows).min(n),
688            )
689        })
690        .collect();
691    let mut xtwx = Array2::<f64>::zeros((p, p));
692    for partial in &partials {
693        xtwx += partial;
694    }
695    xtwx
696}
697
698fn sparse_csr_weighted_xtwx_rows(
699    row_ptr: &[usize],
700    col_idx: &[usize],
701    vals: &[f64],
702    p: usize,
703    weights: ArrayView1<'_, f64>,
704    rows: Range<usize>,
705) -> Array2<f64> {
706    // PSD precondition is discharged at the typed boundary
707    // (`PsdWeightsView::try_new` inside callers of `xt_diag_x_psd_op`). The CSC
708    // counterpart (`streaming_sparse_csc_xt_diag_x`) accepts signed weights and
709    // is the right path for observed-Hessian assembly; this CSR-row kernel is
710    // reserved for Fisher-scoring Gram builds where the working weights are
711    // guaranteed nonneg by typed construction.
712    let mut xtwx = Array2::<f64>::zeros((p, p));
713    for i in rows {
714        let wi = weights[i];
715        if wi == 0.0 {
716            continue;
717        }
718        let start = row_ptr[i];
719        let end = row_ptr[i + 1];
720        for a_ptr in start..end {
721            let a = col_idx[a_ptr];
722            let wxa = wi * vals[a_ptr];
723            for b_ptr in a_ptr..end {
724                let b = col_idx[b_ptr];
725                let v = wxa * vals[b_ptr];
726                xtwx[[a, b]] += v;
727                if a != b {
728                    xtwx[[b, a]] += v;
729                }
730            }
731        }
732    }
733    xtwx
734}
735
736pub fn streaming_sparse_csc_xt_diag_x(
737    col_ptr: &[usize],
738    row_idx: &[usize],
739    vals: &[f64],
740    n: usize,
741    p: usize,
742    weights: ArrayView1<'_, f64>,
743    out: &mut Array2<f64>,
744) {
745    if n == 0 || p == 0 {
746        return;
747    }
748
749    let chunk_rows = dense_materialization_chunk_rows(n, p);
750    let par = effective_global_parallelism();
751    let mut x_chunk = Array2::<f64>::zeros((chunk_rows, p).f());
752    let mut wx_chunk = Array2::<f64>::zeros((chunk_rows, p).f());
753
754    {
755        let mut out_view = array2_to_matmut(out);
756
757        for start in (0..n).step_by(chunk_rows) {
758            let rows = (n - start).min(chunk_rows);
759            {
760                let mut x_slice = x_chunk.slice_mut(s![0..rows, ..]);
761                let mut wx_slice = wx_chunk.slice_mut(s![0..rows, ..]);
762                x_slice.fill(0.0);
763                wx_slice.fill(0.0);
764                let end = start + rows;
765                for col in 0..p {
766                    let col_start = col_ptr[col];
767                    let col_end = col_ptr[col + 1];
768                    let rows_for_col = &row_idx[col_start..col_end];
769                    let local_start = rows_for_col.partition_point(|&row| row < start);
770                    let local_end = rows_for_col.partition_point(|&row| row < end);
771                    for local_ptr in local_start..local_end {
772                        let ptr = col_start + local_ptr;
773                        let row = row_idx[ptr];
774                        let local = row - start;
775                        let wi = weights[row];
776                        let value = vals[ptr];
777                        x_slice[[local, col]] += value;
778                        wx_slice[[local, col]] += wi * value;
779                    }
780                }
781            }
782            let x_slice = x_chunk.slice(s![0..rows, ..]);
783            let wx_slice = wx_chunk.slice(s![0..rows, ..]);
784            let x_view = FaerArrayView::new(&x_slice);
785            let wx_view = FaerArrayView::new(&wx_slice);
786            matmul(
787                out_view.as_mut(),
788                Accum::Add,
789                x_view.as_ref().transpose(),
790                wx_view.as_ref(),
791                1.0,
792                par,
793            );
794        }
795    }
796}
797
798fn sparse_csr_diag_gram(
799    row_ptr: &[usize],
800    col_idx: &[usize],
801    vals: &[f64],
802    n: usize,
803    p: usize,
804    weights: ArrayView1<'_, f64>,
805) -> Array1<f64> {
806    let work = vals.len() as u64;
807    if rayon::current_num_threads() <= 1 || work < SPARSE_ROW_PARALLEL_MIN_FLOPS {
808        return sparse_csr_diag_gram_rows(row_ptr, col_idx, vals, p, weights, 0..n);
809    }
810    let min_parallel_work = SPARSE_ROW_PARALLEL_MIN_FLOPS.min(usize::MAX as u64) as usize;
811    let Some(chunk_rows) = crate::parallel::row_reduction_chunk_rows(n, 1, p, min_parallel_work)
812    else {
813        return sparse_csr_diag_gram_rows(row_ptr, col_idx, vals, p, weights, 0..n);
814    };
815    let starts: Vec<usize> = (0..n).step_by(chunk_rows).collect();
816    let partials: Vec<Array1<f64>> = starts
817        .into_par_iter()
818        .map(|start| {
819            sparse_csr_diag_gram_rows(
820                row_ptr,
821                col_idx,
822                vals,
823                p,
824                weights,
825                start..(start + chunk_rows).min(n),
826            )
827        })
828        .collect();
829    let mut diag = Array1::<f64>::zeros(p);
830    for partial in &partials {
831        diag += partial;
832    }
833    diag
834}
835
836fn sparse_csr_diag_gram_rows(
837    row_ptr: &[usize],
838    col_idx: &[usize],
839    vals: &[f64],
840    p: usize,
841    weights: ArrayView1<'_, f64>,
842    rows: Range<usize>,
843) -> Array1<f64> {
844    // PSD precondition discharged at the typed boundary
845    // (`PsdWeightsView::try_new` inside callers of `xt_diag_x_psd_op`).
846    // Signed observed-Hessian assembly uses the signed Gram path
847    // (xt_diag_x_signed → streaming kernels) and never reaches this routine.
848    let mut diag = Array1::<f64>::zeros(p);
849    for i in rows {
850        let wi = weights[i];
851        if wi == 0.0 {
852            continue;
853        }
854        for idx in row_ptr[i]..row_ptr[i + 1] {
855            let j = col_idx[idx];
856            let xij = vals[idx];
857            diag[j] += wi * xij * xij;
858        }
859    }
860    diag
861}
862
863#[inline]
864fn dense_transpose_weighted_response(
865    matrix: &Array2<f64>,
866    weights: &Array1<f64>,
867    y: &Array1<f64>,
868    row_scale: Option<&Array1<f64>>,
869) -> Array1<f64> {
870    // Signed-safe: XᵀWy is linear in W, so observed-Hessian / non-canonical-link
871    // IRLS sites that drive signed working weights through this kernel must be
872    // preserved end-to-end. Clipping negative weights here silently biases the
873    // pseudo-response and was the source of the Gram-cleanup mismatch.
874    let p = matrix.ncols();
875    let n = matrix.nrows();
876    let mut out = Array1::<f64>::zeros(p);
877    if matrix.is_standard_layout()
878        && let (Some(x), Some(w), Some(yslice)) =
879            (matrix.as_slice(), weights.as_slice(), y.as_slice())
880    {
881        let scale_slice = row_scale.and_then(|s| s.as_slice());
882        let out_slice = out.as_slice_mut().expect("zeros are contiguous");
883        for i in 0..n {
884            let mut scaled = yslice[i] * w[i];
885            if let Some(s) = scale_slice {
886                scaled *= s[i];
887            } else if let Some(scale) = row_scale {
888                scaled *= scale[i];
889            }
890            if scaled == 0.0 {
891                continue;
892            }
893            let row = &x[i * p..i * p + p];
894            for j in 0..p {
895                out_slice[j] += row[j] * scaled;
896            }
897        }
898        return out;
899    }
900    for i in 0..n {
901        let mut scaled = y[i] * weights[i];
902        if let Some(scale) = row_scale {
903            scaled *= scale[i];
904        }
905        if scaled == 0.0 {
906            continue;
907        }
908        for j in 0..p {
909            out[j] += matrix[[i, j]] * scaled;
910        }
911    }
912    out
913}
914
915#[inline]
916fn dense_transpose_weighted_response_view(
917    matrix: &Array2<f64>,
918    weights: ArrayView1<'_, f64>,
919    y: ArrayView1<'_, f64>,
920) -> Array1<f64> {
921    // Signed-safe view variant of dense_transpose_weighted_response; see that
922    // function for the rationale on preserving sign through XᵀWy.
923    let p = matrix.ncols();
924    let n = matrix.nrows();
925    let mut out = Array1::<f64>::zeros(p);
926    if matrix.is_standard_layout()
927        && let (Some(x), Some(w), Some(yslice)) =
928            (matrix.as_slice(), weights.as_slice(), y.as_slice())
929    {
930        let out_slice = out.as_slice_mut().expect("zeros are contiguous");
931        for i in 0..n {
932            let scaled = yslice[i] * w[i];
933            if scaled == 0.0 {
934                continue;
935            }
936            let row = &x[i * p..i * p + p];
937            for j in 0..p {
938                out_slice[j] += row[j] * scaled;
939            }
940        }
941        return out;
942    }
943    for i in 0..n {
944        let scaled = y[i] * weights[i];
945        if scaled == 0.0 {
946            continue;
947        }
948        for j in 0..p {
949            out[j] += matrix[[i, j]] * scaled;
950        }
951    }
952    out
953}
954
955#[derive(Clone)]
956pub struct SparseDesignMatrix {
957    matrix: SparseColMat<usize, f64>,
958    /// Memoized dense copy plus the process-wide ledger reservation that keeps
959    /// its bytes accounted for as long as the cache entry is alive.
960    dense_cache: Arc<OnceLock<(Arc<Array2<f64>>, MemoryReservation)>>,
961    csr_cache: Arc<OnceLock<Arc<SparseRowMat<usize, f64>>>>,
962    /// Memoized symbolic upper-triangle pattern of `XᵀWX`. The pattern depends
963    /// only on this matrix's sparsity, never on the weights, so it is an
964    /// immutable property of the design and belongs with it.
965    hessian_pattern_cache: Arc<OnceLock<Arc<SparseHessianSymbolic>>>,
966}
967
968impl SparseDesignMatrix {
969    pub fn new(matrix: SparseColMat<usize, f64>) -> Self {
970        Self {
971            matrix,
972            dense_cache: Arc::new(OnceLock::new()),
973            csr_cache: Arc::new(OnceLock::new()),
974            hessian_pattern_cache: Arc::new(OnceLock::new()),
975        }
976    }
977
978    /// A zero-valued accumulator over this design's `XᵀWX` sparsity pattern,
979    /// building that pattern at most once per design.
980    ///
981    /// Every IRLS/Newton iteration reassembles `Xᵀ diag(w) X` with new weights
982    /// but the SAME `X`, and the symbolic pattern is a function of `X` alone.
983    /// Rebuilding it per assembly cost `O(Σ_i nnz_i² · log)` in `BTreeSet`
984    /// insertions and showed up as the second-largest symbol in a fit profile.
985    /// Caching it here — rather than in a process-global keyed by address —
986    /// ties the pattern's lifetime to the data that determines it, so a
987    /// dropped-and-reallocated design can never inherit a stale neighbour's
988    /// pattern (#2416).
989    pub fn hessian_accumulator_template(&self) -> Option<SparseHessianAccumulator> {
990        if let Some(sym) = self.hessian_pattern_cache.get() {
991            return Some(SparseHessianAccumulator::from_symbolic(Arc::clone(sym)));
992        }
993        let csr = self.to_csr_arc()?;
994        // First writer wins; a racing writer built from the same immutable
995        // matrix, so either pattern is correct and identical.
996        let sym = self
997            .hessian_pattern_cache
998            .get_or_init(|| SparseHessianAccumulator::build_symbolic(&[&csr], self.matrix.ncols()));
999        Some(SparseHessianAccumulator::from_symbolic(Arc::clone(sym)))
1000    }
1001
1002    fn dense_nbytes(&self) -> Result<usize, String> {
1003        self.matrix
1004            .nrows()
1005            .checked_mul(self.matrix.ncols())
1006            .and_then(|cells| cells.checked_mul(std::mem::size_of::<f64>()))
1007            .ok_or_else(|| {
1008                format!(
1009                    "dense size overflow for sparse design {}x{}",
1010                    self.matrix.nrows(),
1011                    self.matrix.ncols()
1012                )
1013            })
1014    }
1015
1016    fn materialize_dense_arc(&self) -> Arc<Array2<f64>> {
1017        let mut out = Array2::<f64>::zeros((self.matrix.nrows(), self.matrix.ncols()));
1018        let (symbolic, values) = self.matrix.parts();
1019        let col_ptr = symbolic.col_ptr();
1020        let row_idx = symbolic.row_idx();
1021        for col in 0..self.matrix.ncols() {
1022            let start = col_ptr[col];
1023            let end = col_ptr[col + 1];
1024            for idx in start..end {
1025                out[[row_idx[idx], col]] += values[idx];
1026            }
1027        }
1028        Arc::new(out)
1029    }
1030
1031    pub fn try_to_dense_arc(&self, context: &str) -> Result<Arc<Array2<f64>>, String> {
1032        if let Some((cached, _)) = self.dense_cache.get() {
1033            return Ok(cached.clone());
1034        }
1035        let dense_bytes = self.dense_nbytes()?;
1036        let governor = MemoryGovernor::global();
1037        if dense_bytes > governor.single_materialization_cap_bytes() {
1038            let gib = dense_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
1039            return Err(MatrixError::DensificationRefused {
1040                reason: format!(
1041                    "{context}: refusing to densify sparse design {}x{} (~{gib:.2} GiB, over the process memory budget); use sparse or matrix-free code",
1042                    self.matrix.nrows(),
1043                    self.matrix.ncols(),
1044                ),
1045            }
1046            .into());
1047        }
1048        // Memoization is governed: the cache entry holds its ledger charge for
1049        // the design's lifetime. Every dense materialization must be accounted
1050        // on the joint ledger for the buffer's lifetime — an unreserved
1051        // fallthrough here would let simultaneous sparse densifications that
1052        // each individually pass the cap jointly exceed the process budget
1053        // (the SPEC 10 failure mode). A refusal is typed evidence to route to
1054        // a sparse/matrix-free strategy, not permission to allocate anyway.
1055        let reservation = governor.try_reserve(dense_bytes, context).map_err(|err| {
1056            String::from(MatrixError::DensificationRefused {
1057                reason: format!(
1058                    "{context}: refusing to densify sparse design {}x{}: {err}",
1059                    self.matrix.nrows(),
1060                    self.matrix.ncols(),
1061                ),
1062            })
1063        })?;
1064        Ok(self
1065            .dense_cache
1066            .get_or_init(|| (self.materialize_dense_arc(), reservation))
1067            .0
1068            .clone())
1069    }
1070
1071    /// An owner for a dense copy the cache already holds the ledger charge
1072    /// for. Both arms of [`Self::try_to_dense_governed`] end here, so nothing
1073    /// downstream can tell from its `Governed` whether it materialized the
1074    /// copy or found one.
1075    fn cached_dense_owner(context: &str) -> MemoryReservation {
1076        MemoryGovernor::global()
1077            .try_reserve(0, context)
1078            .expect("zero-byte reservation cannot exceed any budget")
1079    }
1080
1081    /// Densify under the process-wide byte governor, coupling the returned
1082    /// dense copy to its RAII reservation. A refusal is typed evidence that
1083    /// the dense footprint does not fit the joint ledger right now — callers
1084    /// route to a streaming / sparse strategy instead of allocating.
1085    ///
1086    /// An admitted copy is memoized on `dense_cache` and the ledger charge
1087    /// lives exactly as long as that memo — the contract the operator-backed
1088    /// sibling already states on [`LazyDense`]. `matrix` is immutable after
1089    /// construction, so a memoized image can never go stale, and every clone
1090    /// of the design shares one dense copy. Materializing a fresh copy per
1091    /// call instead made the two dense-regime `XᵀWX` routes pay an O(n·p)
1092    /// allocate-and-scatter over an unchanging design on every IRLS/Newton
1093    /// reassembly, which is an n-proportional term in work the outer loop is
1094    /// contracted to keep n-free (gh#2449).
1095    pub fn try_to_dense_governed(
1096        &self,
1097        context: &str,
1098    ) -> Result<Governed<Arc<Array2<f64>>>, String> {
1099        let governor = MemoryGovernor::global();
1100        let (nrows, ncols) = (self.matrix.nrows(), self.matrix.ncols());
1101        if let Some((cached, _)) = self.dense_cache.get() {
1102            crate::governed_capture::record_governed_decision(
1103                context,
1104                nrows,
1105                ncols,
1106                Some(0),
1107                crate::governed_capture::GovernedArm::CacheHit,
1108            );
1109            return Ok(Self::cached_dense_owner(context).bind(cached.clone()));
1110        }
1111        let dense_bytes = match self.dense_nbytes() {
1112            Ok(bytes) => bytes,
1113            Err(err) => {
1114                crate::governed_capture::record_governed_decision(
1115                    context,
1116                    nrows,
1117                    ncols,
1118                    None,
1119                    crate::governed_capture::GovernedArm::Ineligible,
1120                );
1121                return Err(err);
1122            }
1123        };
1124        let reservation = match governor.try_reserve(dense_bytes, context) {
1125            Ok(reservation) => reservation,
1126            Err(err) => {
1127                // gh#2486: this refusal sends the caller down a numerically
1128                // different route, so it is the decision an investigator needs
1129                // recorded — not merely the fact that the call happened.
1130                crate::governed_capture::record_governed_decision(
1131                    context,
1132                    nrows,
1133                    ncols,
1134                    Some(dense_bytes),
1135                    crate::governed_capture::GovernedArm::Refused,
1136                );
1137                return Err(String::from(MatrixError::DensificationRefused {
1138                    reason: format!(
1139                        "{context}: refusing to densify sparse design {nrows}x{ncols}: {err}"
1140                    ),
1141                }));
1142            }
1143        };
1144        crate::governed_capture::record_governed_decision(
1145            context,
1146            nrows,
1147            ncols,
1148            Some(dense_bytes),
1149            crate::governed_capture::GovernedArm::Admitted,
1150        );
1151        // The admitted bytes are handed to the memo, which holds them for the
1152        // design's lifetime; a racing initializer's copy wins and this
1153        // reservation drops with the losing closure. Either way the owner
1154        // returned below charges nothing further, exactly as on a hit.
1155        let cached = self
1156            .dense_cache
1157            .get_or_init(|| (self.materialize_dense_arc(), reservation))
1158            .0
1159            .clone();
1160        Ok(Self::cached_dense_owner(context).bind(cached))
1161    }
1162
1163    pub fn to_dense_arc(&self) -> Arc<Array2<f64>> {
1164        self.try_to_dense_arc("SparseDesignMatrix::to_dense_arc")
1165            .unwrap_or_else(|msg| {
1166                let bt = std::backtrace::Backtrace::force_capture();
1167                // SAFETY: infallible-style accessor used at sites where the
1168                // caller has already established that densifying this sparse
1169                // matrix is permitted (size below the densification guard); a
1170                // failure here means the caller broke that contract, which
1171                // warrants an immediate abort with backtrace for diagnosis.
1172                // SAFETY: infallible accessor; densification refusal here is a caller contract violation.
1173                std::panic::panic_any(format!("{msg}\nbacktrace:\n{bt}"))
1174            })
1175    }
1176
1177    pub fn to_csr_arc(&self) -> Option<Arc<SparseRowMat<usize, f64>>> {
1178        if let Some(cached) = self.csr_cache.get() {
1179            return Some(cached.clone());
1180        }
1181        let csr = self.matrix.as_ref().to_row_major().ok()?;
1182        let arc = Arc::new(csr);
1183        if self.csr_cache.set(arc.clone()).is_err() {
1184            // Another thread installed its CSR first. The cache is write-once, so
1185            // the winner is authoritative; hand back that one rather than a private
1186            // copy, so every caller observes the same cached matrix.
1187            return self.csr_cache.get().cloned();
1188        }
1189        Some(arc)
1190    }
1191
1192    fn row_chunk_into(
1193        &self,
1194        rows: Range<usize>,
1195        mut out: ArrayViewMut2<'_, f64>,
1196    ) -> Result<(), MatrixMaterializationError> {
1197        if out.nrows() != rows.end - rows.start || out.ncols() != self.ncols() {
1198            return Err(MatrixMaterializationError::MissingRowChunk {
1199                context: "SparseDesignMatrix::row_chunk_into shape mismatch",
1200            });
1201        }
1202        out.fill(0.0);
1203        let csr = self
1204            .to_csr_arc()
1205            .ok_or(MatrixMaterializationError::MissingRowChunk {
1206                context: "SparseDesignMatrix::row_chunk_into: failed to obtain CSR view",
1207            })?;
1208        let symbolic = csr.symbolic();
1209        let row_ptr = symbolic.row_ptr();
1210        let col_idx = symbolic.col_idx();
1211        let values = csr.val();
1212        for (local_row, row) in rows.enumerate() {
1213            for ptr in row_ptr[row]..row_ptr[row + 1] {
1214                out[[local_row, col_idx[ptr]]] = values[ptr];
1215            }
1216        }
1217        Ok(())
1218    }
1219}
1220
1221impl Deref for SparseDesignMatrix {
1222    type Target = SparseColMat<usize, f64>;
1223    fn deref(&self) -> &Self::Target {
1224        &self.matrix
1225    }
1226}
1227
1228impl AsRef<SparseColMat<usize, f64>> for SparseDesignMatrix {
1229    fn as_ref(&self) -> &SparseColMat<usize, f64> {
1230        &self.matrix
1231    }
1232}
1233
1234/// Trait for dense-backed design operators that avoid eager materialization.
1235///
1236/// Implement this trait for structured designs (multi-channel, rowwise-Kronecker,
1237/// etc.) that can perform matvecs and Gram-matrix assembly without forming the
1238/// full dense matrix. Wrap implementations in `DenseDesignMatrix::Lazy(Arc<..>)`
1239/// to integrate them with the rest of the codebase while keeping the top-level
1240/// `DesignMatrix` split strictly `Dense | Sparse`.
1241pub trait DenseDesignOperator: LinearOperator + Send + Sync {
1242    fn compute_xtwy(&self, weights: &Array1<f64>, y: &Array1<f64>) -> Result<Array1<f64>, String> {
1243        // Default: X'(w ⊙ y) via apply_transpose.
1244        let n = self.nrows();
1245        if weights.len() != n || y.len() != n {
1246            return Err(format!(
1247                "DenseDesignOperator::compute_xtwy dimension mismatch: weights={}, y={}, nrows={}",
1248                weights.len(),
1249                y.len(),
1250                n
1251            ));
1252        }
1253        certify_signed_weights("DenseDesignOperator::compute_xtwy", weights, n)?;
1254        // Signed-safe XᵀWy: linear in w, so observed-Hessian / non-canonical
1255        // working weights must flow through unclipped.
1256        let mut wy = Array1::<f64>::zeros(n);
1257        ndarray::Zip::from(&mut wy)
1258            .and(weights)
1259            .and(y)
1260            .par_for_each(|o, &w, &yi| *o = w * yi);
1261        Ok(self.apply_transpose(&wy))
1262    }
1263
1264    fn quadratic_form_diag(&self, middle: &Array2<f64>) -> Result<Array1<f64>, String> {
1265        // Default: diag(X M X') computed in chunks via row_chunk — avoids
1266        // materializing the full n×p dense matrix at once.
1267        if middle.nrows() != self.ncols() || middle.ncols() != self.ncols() {
1268            return Err(format!(
1269                "DenseDesignOperator::quadratic_form_diag dimension mismatch: {}x{} vs expected {}x{}",
1270                middle.nrows(),
1271                middle.ncols(),
1272                self.ncols(),
1273                self.ncols()
1274            ));
1275        }
1276        let n = self.nrows();
1277        let mut out = Array1::<f64>::zeros(n);
1278        // Process in chunks to bound memory: ~8 MB working set.
1279        // Two live buffers per chunk (`x_chunk` and `xm_chunk`), so the byte
1280        // target is split in half. Uses this module's own constant rather
1281        // than re-typing the literal it already holds (#2704).
1282        let chunk_size = (CHUNKED_DENSE_MATERIALIZATION_BYTES / (self.ncols().max(1) * 8 * 2))
1283            .max(16)
1284            .min(n.max(1));
1285        let mut start = 0;
1286        while start < n {
1287            let end = (start + chunk_size).min(n);
1288            let x_chunk = self.try_row_chunk(start..end).map_err(|e| e.to_string())?;
1289            let xm_chunk = fast_ab(&x_chunk, middle);
1290            let mut chunk_out = out.slice_mut(ndarray::s![start..end]);
1291            ndarray::Zip::from(&mut chunk_out)
1292                .and(x_chunk.rows())
1293                .and(xm_chunk.rows())
1294                // clamp tiny-negative fp drift on diag(X M Xᵀ) when M is a
1295                // PSD covariance/precision matrix; not a weight clip.
1296                .par_for_each(|o, xr, xmr| *o = xr.dot(&xmr).max(0.0));
1297            start = end;
1298        }
1299        Ok(out)
1300    }
1301
1302    /// Fill a dense row chunk without materializing the full matrix.
1303    /// Required: every implementor must provide row-local access here.
1304    fn row_chunk_into(
1305        &self,
1306        rows: Range<usize>,
1307        out: ArrayViewMut2<'_, f64>,
1308    ) -> Result<(), MatrixMaterializationError>;
1309
1310    /// Extract a dense row chunk without materializing the full matrix.
1311    /// Non-panicking owned-chunk API built on top of `row_chunk_into`.
1312    fn try_row_chunk(&self, rows: Range<usize>) -> Result<Array2<f64>, MatrixMaterializationError> {
1313        let mut out = Array2::<f64>::zeros((rows.end - rows.start, self.ncols()));
1314        self.row_chunk_into(rows, out.view_mut())?;
1315        Ok(out)
1316    }
1317
1318    /// Borrow dense storage when this operator already owns it.
1319    fn as_dense_ref(&self) -> Option<&Array2<f64>> {
1320        None
1321    }
1322
1323    /// Materialization contract captured when this operator-backed design was
1324    /// selected. Composite operators propagate the strictest contract of their
1325    /// inputs so a later caller using a more permissive default cannot reverse
1326    /// an upstream streamed-storage decision.
1327    fn materialization_policy(&self) -> Option<MaterializationPolicy> {
1328        None
1329    }
1330
1331    /// Batched column extraction: returns an `nrows × cols.len()` dense block
1332    /// whose k-th column is `apply(e_{cols[k]})`.
1333    ///
1334    /// Default impl loops over columns and applies a unit vector per call. Operator
1335    /// types like `ReparamOperator` that can express the batch as a single GEMM
1336    /// (`X · Qs[:, cols]`) should override this — it avoids re-walking the inner
1337    /// matvec for every column.
1338    fn apply_columns(&self, cols: &[usize]) -> Array2<f64> {
1339        let n = self.nrows();
1340        let p = self.ncols();
1341        let mut out = Array2::<f64>::zeros((n, cols.len()));
1342        let mut e = Array1::<f64>::zeros(p);
1343        for (k, &j) in cols.iter().enumerate() {
1344            assert!(
1345                j < p,
1346                "DenseDesignOperator::apply_columns: column index {j} out of bounds (ncols={p})"
1347            );
1348            e[j] = 1.0;
1349            let col = self.apply(&e);
1350            e[j] = 0.0;
1351            out.column_mut(k).assign(&col);
1352        }
1353        out
1354    }
1355
1356    /// Materialize the full dense matrix. Operators that exist precisely to
1357    /// avoid materialization should still support this for fallback paths,
1358    /// diagnostics, and prediction.
1359    fn to_dense(&self) -> Array2<f64>;
1360
1361    fn estimated_dense_bytes(&self) -> usize {
1362        self.nrows()
1363            .saturating_mul(self.ncols())
1364            .saturating_mul(std::mem::size_of::<f64>())
1365    }
1366
1367    fn try_to_dense_with_policy(
1368        &self,
1369        policy: &MaterializationPolicy,
1370        context: &'static str,
1371    ) -> Result<Arc<Array2<f64>>, MatrixMaterializationError> {
1372        let effective_policy = merge_operator_materialization_policies(
1373            Some(policy.clone()),
1374            self.materialization_policy(),
1375        )
1376        .expect("caller policy is always present");
1377        let bytes = self.estimated_dense_bytes();
1378        if !effective_policy.allow_operator_materialization {
1379            return Err(MatrixMaterializationError::Forbidden {
1380                context,
1381                mode: gam_runtime::resource::DerivativeStorageMode::AnalyticOperatorRequired,
1382            });
1383        }
1384        if bytes > effective_policy.max_single_dense_bytes {
1385            return Err(MatrixMaterializationError::TooLarge {
1386                context,
1387                nrows: self.nrows(),
1388                ncols: self.ncols(),
1389                bytes,
1390                limit_bytes: effective_policy.max_single_dense_bytes,
1391            });
1392        }
1393        dense_operator_to_dense_by_chunks(self).map(Arc::new)
1394    }
1395
1396    /// Materialize through the process-wide governor and couple the returned
1397    /// matrix to its reservation. Unlike the older Arc-returning helper, this
1398    /// cannot release its ledger charge while the dense allocation is live.
1399    fn try_to_dense_governed_with_policy(
1400        &self,
1401        policy: &MaterializationPolicy,
1402        context: &'static str,
1403    ) -> Result<Governed<Array2<f64>>, MatrixMaterializationError> {
1404        governed_dense_operator_to_dense_by_chunks(self, policy, context)
1405    }
1406
1407    /// Shared dense materialization via the required row-chunk API.
1408    ///
1409    /// This deliberately does not fall back through `to_dense()`: operator-backed
1410    /// designs can be large-scale, and their chunked row path is the bounded
1411    /// memory materialization contract. Implementations that already own an
1412    /// `Arc<Array2<_>>` should override this to return it directly.
1413    fn to_dense_arc(&self) -> Arc<Array2<f64>> {
1414        Arc::new(
1415            dense_operator_to_dense_by_chunks(self)
1416                .expect("DenseDesignOperator::to_dense_arc: row-chunk materialization failed"),
1417        )
1418    }
1419}
1420
1421/// Operator-backed design plus its governed dense memo.
1422///
1423/// Every dense materialization of a lazy design must hold a process-wide
1424/// [`MemoryGovernor`] ledger charge for the buffer's lifetime (the #2247 F6
1425/// contract: individually-acceptable materializations must not be able to
1426/// *jointly* exceed the budget). The memo owns exactly one governed dense
1427/// copy shared by every clone of this design — repeated `to_dense_arc`/
1428/// `to_dense_cow` calls reuse it instead of re-streaming the operator, and
1429/// the ledger charge lives exactly as long as the memo (= the design and all
1430/// its clones). Derefs to the inner operator so match arms binding `Lazy(op)`
1431/// keep calling trait methods unchanged.
1432#[derive(Clone)]
1433pub struct LazyDense {
1434    op: Arc<dyn DenseDesignOperator>,
1435    dense_memo: Arc<OnceLock<Governed<Arc<Array2<f64>>>>>,
1436}
1437
1438impl LazyDense {
1439    fn new(op: Arc<dyn DenseDesignOperator>) -> Self {
1440        Self {
1441            op,
1442            dense_memo: Arc::new(OnceLock::new()),
1443        }
1444    }
1445
1446    fn operator_arc_identity(&self) -> usize {
1447        Arc::as_ptr(&self.op) as *const () as usize
1448    }
1449
1450    /// Governed, memoized dense materialization. On a memo hit the bytes are
1451    /// already charged by the memo's own reservation; on a miss the footprint
1452    /// is admitted against the joint ledger BEFORE streaming the operator. A
1453    /// refusal is typed evidence the dense copy does not fit jointly right
1454    /// now — fallible callers route to chunked/matrix-free strategies.
1455    fn try_governed_dense_arc(&self, context: &str) -> Result<Arc<Array2<f64>>, String> {
1456        enforce_operator_materialization_policy(self.op.as_ref(), context)?;
1457        if let Some(governed) = self.dense_memo.get() {
1458            return Ok(Arc::clone(governed.as_ref()));
1459        }
1460        let reservation = MemoryGovernor::global()
1461            .try_reserve_dense_f64(self.op.nrows(), self.op.ncols(), context)
1462            .map_err(|err| {
1463                format!(
1464                    "{context}: refusing to densify {}x{} operator-backed design: {err}",
1465                    self.op.nrows(),
1466                    self.op.ncols(),
1467                )
1468            })?;
1469        let dense = dense_operator_to_dense_by_chunks(self.op.as_ref()).map_err(|err| {
1470            format!(
1471                "{context}: failed to materialize {}x{} operator-backed design via row chunks: {err}",
1472                self.op.nrows(),
1473                self.op.ncols(),
1474            )
1475        })?;
1476        // A concurrent winner's memo (and charge) stands; the loser's copy and
1477        // reservation drop together here.
1478        Ok(Arc::clone(
1479            self.dense_memo
1480                .get_or_init(|| reservation.bind(Arc::new(dense)))
1481                .as_ref(),
1482        ))
1483    }
1484}
1485
1486impl std::ops::Deref for LazyDense {
1487    type Target = Arc<dyn DenseDesignOperator>;
1488
1489    fn deref(&self) -> &Self::Target {
1490        &self.op
1491    }
1492}
1493
1494#[derive(Clone)]
1495pub enum DenseDesignMatrix {
1496    Materialized(Arc<Array2<f64>>),
1497    Lazy(LazyDense),
1498}
1499
1500impl std::fmt::Debug for DenseDesignMatrix {
1501    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1502        match self {
1503            Self::Materialized(matrix) => {
1504                write!(
1505                    f,
1506                    "DenseDesignMatrix::Materialized({}x{})",
1507                    matrix.nrows(),
1508                    matrix.ncols()
1509                )
1510            }
1511            Self::Lazy(op) => write!(f, "DenseDesignMatrix::Lazy({}x{})", op.nrows(), op.ncols()),
1512        }
1513    }
1514}
1515
1516impl From<Arc<Array2<f64>>> for DenseDesignMatrix {
1517    fn from(value: Arc<Array2<f64>>) -> Self {
1518        Self::Materialized(value)
1519    }
1520}
1521
1522impl From<Array2<f64>> for DenseDesignMatrix {
1523    fn from(value: Array2<f64>) -> Self {
1524        Self::Materialized(Arc::new(value))
1525    }
1526}
1527
1528impl<T> From<Arc<T>> for DenseDesignMatrix
1529where
1530    T: DenseDesignOperator + 'static,
1531{
1532    fn from(value: Arc<T>) -> Self {
1533        Self::Lazy(LazyDense::new(value))
1534    }
1535}
1536
1537impl DenseDesignMatrix {
1538    /// Stable identity for cache keying.
1539    ///
1540    /// Returns the address of the inner shared `Arc`, which `Clone` shares by
1541    /// reference. Two `DenseDesignMatrix` values produced by cloning the same
1542    /// origin (e.g. the `k` per-coordinate `GlmCurvatureCorrectionOperator`s all
1543    /// built from one converged design) report the same identity, so a `X·F`
1544    /// projection memoized under this id is reused across them within a single
1545    /// outer REML evaluation instead of being re-streamed once per coordinate.
1546    pub fn cache_identity(&self) -> usize {
1547        match self {
1548            Self::Materialized(matrix) => Arc::as_ptr(matrix) as *const () as usize,
1549            Self::Lazy(lazy) => lazy.operator_arc_identity(),
1550        }
1551    }
1552
1553    pub fn nrows(&self) -> usize {
1554        match self {
1555            Self::Materialized(matrix) => matrix.nrows(),
1556            Self::Lazy(op) => op.nrows(),
1557        }
1558    }
1559
1560    pub fn ncols(&self) -> usize {
1561        match self {
1562            Self::Materialized(matrix) => matrix.ncols(),
1563            Self::Lazy(op) => op.ncols(),
1564        }
1565    }
1566
1567    pub fn as_dense_ref(&self) -> Option<&Array2<f64>> {
1568        match self {
1569            Self::Materialized(matrix) => Some(matrix.as_ref()),
1570            Self::Lazy(lazy) => lazy
1571                .dense_memo
1572                .get()
1573                .map(|governed| governed.as_ref().as_ref())
1574                .or_else(|| lazy.op.as_dense_ref()),
1575        }
1576    }
1577
1578    pub const fn is_materialized_dense(&self) -> bool {
1579        matches!(self, Self::Materialized(_))
1580    }
1581
1582    pub const fn is_operator_backed(&self) -> bool {
1583        matches!(self, Self::Lazy(_))
1584    }
1585
1586    pub fn to_dense(&self) -> Array2<f64> {
1587        match self {
1588            Self::Materialized(matrix) => matrix.as_ref().clone(),
1589            Self::Lazy(lazy) => {
1590                let policy = ResourcePolicy::default_library();
1591                panic_or_error_if_large_scale_mode_and_to_dense_called_with_policy(
1592                    "DenseDesignMatrix::to_dense",
1593                    lazy.nrows(),
1594                    lazy.ncols(),
1595                    &policy,
1596                )
1597                .unwrap_or_else(|reason| std::panic::panic_any(reason));
1598                enforce_operator_materialization_policy(
1599                    lazy.op.as_ref(),
1600                    "DenseDesignMatrix::to_dense",
1601                )
1602                .unwrap_or_else(|reason| std::panic::panic_any(reason));
1603                if let Some(governed) = lazy.dense_memo.get() {
1604                    // Already materialized (and ledger-charged) once — reuse
1605                    // it instead of re-streaming the operator.
1606                    return governed.as_ref().as_ref().clone();
1607                }
1608                // Owned-return variant: the escaping buffer cannot carry an
1609                // RAII charge, so account at least the construction window on
1610                // the joint ledger and refuse loudly under joint pressure.
1611                // Callers that can hold the charge for the buffer's lifetime
1612                // must use `try_to_dense_governed`.
1613                let construction_charge = MemoryGovernor::global()
1614                    .try_reserve_dense_f64(
1615                        lazy.nrows(),
1616                        lazy.ncols(),
1617                        "DenseDesignMatrix::to_dense",
1618                    )
1619                    // SAFETY: infallible accessor; a joint-ledger refusal here means the caller broke the densification contract.
1620                    .unwrap_or_else(|err| std::panic::panic_any(err.to_string()));
1621                let dense =
1622                    dense_operator_to_dense_by_chunks(lazy.op.as_ref()).unwrap_or_else(|err| {
1623                        std::panic::panic_any(format!(
1624                            "DenseDesignMatrix::to_dense: failed to materialize {}x{} \
1625                             operator-backed design via row chunks: {err}",
1626                            lazy.nrows(),
1627                            lazy.ncols(),
1628                        ))
1629                    });
1630                // The charge covers exactly the construction window; the escaping
1631                // buffer itself cannot carry an RAII charge (doc above).
1632                drop(construction_charge);
1633                dense
1634            }
1635        }
1636    }
1637
1638    pub fn to_dense_arc(&self) -> Arc<Array2<f64>> {
1639        match self {
1640            Self::Materialized(matrix) => Arc::clone(matrix),
1641            Self::Lazy(lazy) => {
1642                let policy = ResourcePolicy::default_library();
1643                panic_or_error_if_large_scale_mode_and_to_dense_called_with_policy(
1644                    "DenseDesignMatrix::to_dense_arc",
1645                    lazy.nrows(),
1646                    lazy.ncols(),
1647                    &policy,
1648                )
1649                .unwrap_or_else(|reason| std::panic::panic_any(reason));
1650                lazy.try_governed_dense_arc("DenseDesignMatrix::to_dense_arc")
1651                    // SAFETY: infallible accessor; refusal here is a caller contract violation, abort with the ledger evidence.
1652                    .unwrap_or_else(|msg| std::panic::panic_any(msg))
1653            }
1654        }
1655    }
1656
1657    pub fn try_to_dense_arc(&self, context: &str) -> Result<Arc<Array2<f64>>, String> {
1658        // Auto-policy from the design's own dense footprint. The earlier
1659        // shape-based pick reused `for_problem(nrows, ncols, _)`, which is
1660        // intended for classifying the *whole fitting problem* — it flips to
1661        // `AnalyticOperatorRequired` at `nrows >= 100_000` regardless of
1662        // column count. That was wrong for an individual design: a 102052x4
1663        // operator-backed block dense-materializes to only ~3 MiB and is
1664        // genuinely safe. We now pick the permissive policy and let the
1665        // byte-cap inside the materialization guard reject anything that
1666        // would actually blow the default 1 GiB single-materialization budget.
1667        // Callers that need strict refusal still get it by calling
1668        // `try_to_dense_arc_with_policy(ctx, &analytic_operator_required())`.
1669        let policy = ResourcePolicy::default_library();
1670        self.try_to_dense_arc_with_policy(context, &policy)
1671    }
1672
1673    /// Policy-aware variant of [`Self::try_to_dense_arc`].
1674    ///
1675    /// Uses the supplied policy's `max_single_materialization_bytes` cap when
1676    /// deciding whether to densify a lazy operator-backed design.  The default
1677    /// `try_to_dense_arc` always uses `ResourcePolicy::default_library()` (the
1678    /// 1 GiB cap suitable for ad-hoc dense conversions, matching the
1679    /// `CoefficientTransformOperator::MATERIALIZE_MAX_BYTES` ceiling); cache
1680    /// layers that have their own larger cap (e.g.
1681    /// `CoefficientTransformOperator::MATERIALIZE_MAX_BYTES`) can call this
1682    /// method to consume the inner under their own threshold without forcing
1683    /// the conservative default on every consumer.
1684    pub fn try_to_dense_arc_with_policy(
1685        &self,
1686        context: &str,
1687        policy: &ResourcePolicy,
1688    ) -> Result<Arc<Array2<f64>>, String> {
1689        match self {
1690            Self::Materialized(matrix) => Ok(Arc::clone(matrix)),
1691            Self::Lazy(lazy) => {
1692                panic_or_error_if_large_scale_mode_and_to_dense_called_with_policy(
1693                    context,
1694                    lazy.nrows(),
1695                    lazy.ncols(),
1696                    policy,
1697                )?;
1698                lazy.try_governed_dense_arc(context)
1699            }
1700        }
1701    }
1702
1703    pub fn try_row_chunk(
1704        &self,
1705        rows: Range<usize>,
1706    ) -> Result<Array2<f64>, MatrixMaterializationError> {
1707        match self {
1708            Self::Materialized(matrix) => Ok(matrix.slice(s![rows, ..]).to_owned()),
1709            Self::Lazy(op) => op.try_row_chunk(rows),
1710        }
1711    }
1712
1713    pub fn row_chunk_into(
1714        &self,
1715        rows: Range<usize>,
1716        out: ArrayViewMut2<'_, f64>,
1717    ) -> Result<(), MatrixMaterializationError> {
1718        match self {
1719            Self::Materialized(matrix) => {
1720                let mut out = out;
1721                out.assign(&matrix.slice(s![rows, ..]));
1722                Ok(())
1723            }
1724            Self::Lazy(op) => op.row_chunk_into(rows, out),
1725        }
1726    }
1727}
1728
1729impl LinearOperator for DenseDesignMatrix {
1730    fn nrows(&self) -> usize {
1731        DenseDesignMatrix::nrows(self)
1732    }
1733
1734    fn ncols(&self) -> usize {
1735        DenseDesignMatrix::ncols(self)
1736    }
1737
1738    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
1739        match self {
1740            Self::Materialized(matrix) => fast_av(matrix, vector),
1741            Self::Lazy(op) => op.apply(vector),
1742        }
1743    }
1744
1745    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
1746        match self {
1747            Self::Materialized(matrix) => fast_atv(matrix, vector),
1748            Self::Lazy(op) => op.apply_transpose(vector),
1749        }
1750    }
1751
1752    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
1753        certify_signed_weights("DenseDesignMatrix::diag_xtw_x", weights, self.nrows())?;
1754        match self {
1755            Self::Materialized(matrix) => {
1756                let mut xtwx = Array2::<f64>::zeros((matrix.ncols(), matrix.ncols()));
1757                stream_weighted_crossprod_into(
1758                    matrix,
1759                    weights,
1760                    &mut xtwx,
1761                    CrossprodStructure::Full,
1762                    CrossprodAccum::Replace,
1763                    effective_global_parallelism(),
1764                );
1765                Ok(xtwx)
1766            }
1767            Self::Lazy(op) => op.diag_xtw_x(weights),
1768        }
1769    }
1770
1771    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
1772        // Exact diagonal of Xᵀdiag(w)X.  It may be signed for observed
1773        // curvature; solve-level stabilization owns positive-definiteness.
1774        certify_signed_weights("DenseDesignMatrix::diag_gram", weights, self.nrows())?;
1775        match self {
1776            Self::Materialized(matrix) => {
1777                let n = matrix.nrows();
1778                let p = matrix.ncols();
1779                if (n as u64) * (p as u64) < DENSE_ROW_PARALLEL_MIN_NP {
1780                    let mut diag = Array1::<f64>::zeros(p);
1781                    for i in 0..n {
1782                        let wi = weights[i];
1783                        if wi == 0.0 {
1784                            continue;
1785                        }
1786                        for j in 0..p {
1787                            let xij = matrix[[i, j]];
1788                            diag[j] += wi * xij * xij;
1789                        }
1790                    }
1791                    return Ok(diag);
1792                }
1793                // Deterministic parallel row reduction (length-only pairwise
1794                // tree; see the standard-layout path above).
1795                let diag = crate::pairwise_reduce::par_deterministic_block_fold(
1796                    n,
1797                    |range: core::ops::Range<usize>| {
1798                        let mut acc = Array1::<f64>::zeros(p);
1799                        for i in range {
1800                            let wi = weights[i];
1801                            if wi != 0.0 {
1802                                for j in 0..p {
1803                                    let xij = matrix[[i, j]];
1804                                    acc[j] += wi * xij * xij;
1805                                }
1806                            }
1807                        }
1808                        acc
1809                    },
1810                    |mut a, b| {
1811                        a += &b;
1812                        a
1813                    },
1814                )
1815                .unwrap_or_else(|| Array1::<f64>::zeros(p));
1816                Ok(diag)
1817            }
1818            Self::Lazy(op) => op.diag_gram(weights),
1819        }
1820    }
1821
1822    fn apply_weighted_normal(
1823        &self,
1824        weights: FiniteSignedWeightsView<'_>,
1825        vector: &Array1<f64>,
1826        penalty: Option<&Array2<f64>>,
1827        ridge: f64,
1828    ) -> Array1<f64> {
1829        assert_eq!(
1830            weights.len(),
1831            self.nrows(),
1832            "DenseDesignMatrix::apply_weighted_normal weight length mismatch"
1833        );
1834        assert_eq!(
1835            vector.len(),
1836            self.ncols(),
1837            "DenseDesignMatrix::apply_weighted_normal vector length mismatch"
1838        );
1839        // Exact signed normal product.  PCG callers ordinarily provide Fisher
1840        // weights, while observed-Hessian callers may provide negative rows;
1841        // definiteness is a property of the assembled/stabilized global matrix,
1842        // not something this row-linear kernel manufactures by projection.
1843        let weights_view = weights.view();
1844        match self {
1845            Self::Materialized(matrix) => {
1846                let n = matrix.nrows();
1847                let p = matrix.ncols();
1848                let mut out = if (n as u64) * (p as u64) < DENSE_ROW_PARALLEL_MIN_NP {
1849                    let mut out = Array1::<f64>::zeros(p);
1850                    for i in 0..n {
1851                        let wi = weights_view[i];
1852                        if wi == 0.0 {
1853                            continue;
1854                        }
1855                        let mut row_dot = 0.0_f64;
1856                        for j in 0..p {
1857                            row_dot += matrix[[i, j]] * vector[j];
1858                        }
1859                        if row_dot == 0.0 {
1860                            continue;
1861                        }
1862                        let scaled = wi * row_dot;
1863                        for j in 0..p {
1864                            out[j] += scaled * matrix[[i, j]];
1865                        }
1866                    }
1867                    out
1868                } else {
1869                    // Deterministic parallel row reduction (length-only
1870                    // pairwise tree; see diag_gram above).
1871                    crate::pairwise_reduce::par_deterministic_block_fold(
1872                        n,
1873                        |range: core::ops::Range<usize>| {
1874                            let mut acc = Array1::<f64>::zeros(p);
1875                            for i in range {
1876                                let wi = weights_view[i];
1877                                if wi != 0.0 {
1878                                    let mut row_dot = 0.0_f64;
1879                                    for j in 0..p {
1880                                        row_dot += matrix[[i, j]] * vector[j];
1881                                    }
1882                                    if row_dot != 0.0 {
1883                                        let scaled = wi * row_dot;
1884                                        for j in 0..p {
1885                                            acc[j] += scaled * matrix[[i, j]];
1886                                        }
1887                                    }
1888                                }
1889                            }
1890                            acc
1891                        },
1892                        |mut a, b| {
1893                            a += &b;
1894                            a
1895                        },
1896                    )
1897                    .unwrap_or_else(|| Array1::<f64>::zeros(p))
1898                };
1899                if let Some(pen) = penalty {
1900                    out += &fast_av(pen, vector);
1901                }
1902                if ridge > 0.0 {
1903                    for j in 0..p {
1904                        out[j] += ridge * vector[j];
1905                    }
1906                }
1907                out
1908            }
1909            Self::Lazy(op) => op.apply_weighted_normal(weights, vector, penalty, ridge),
1910        }
1911    }
1912
1913    fn uses_matrix_free_pcg(&self) -> bool {
1914        match self {
1915            Self::Materialized(_) => true,
1916            Self::Lazy(op) => op.uses_matrix_free_pcg(),
1917        }
1918    }
1919}
1920
1921impl DenseDesignOperator for DenseDesignMatrix {
1922    fn compute_xtwy(&self, weights: &Array1<f64>, y: &Array1<f64>) -> Result<Array1<f64>, String> {
1923        if y.len() != self.nrows() {
1924            return Err(format!(
1925                "DenseDesignMatrix::compute_xtwy response length mismatch: y={}, nrows={}",
1926                y.len(),
1927                self.nrows()
1928            ));
1929        }
1930        certify_signed_weights("DenseDesignMatrix::compute_xtwy", weights, self.nrows())?;
1931        match self {
1932            Self::Materialized(matrix) => {
1933                Ok(dense_transpose_weighted_response(matrix, weights, y, None))
1934            }
1935            Self::Lazy(op) => op.compute_xtwy(weights, y),
1936        }
1937    }
1938
1939    fn quadratic_form_diag(&self, middle: &Array2<f64>) -> Result<Array1<f64>, String> {
1940        match self {
1941            Self::Materialized(matrix) => {
1942                if middle.nrows() != matrix.ncols() || middle.ncols() != matrix.ncols() {
1943                    return Err(format!(
1944                        "quadratic_form_diag dimension mismatch: matrix is {}x{}, expected {}x{}",
1945                        middle.nrows(),
1946                        middle.ncols(),
1947                        matrix.ncols(),
1948                        matrix.ncols()
1949                    ));
1950                }
1951                let xc = fast_ab(matrix, middle);
1952                let n = matrix.nrows();
1953                let p = matrix.ncols();
1954                let mut out = Array1::<f64>::zeros(n);
1955                if matrix.is_standard_layout()
1956                    && xc.is_standard_layout()
1957                    && let (Some(m_all), Some(xc_all), Some(out_slice)) =
1958                        (matrix.as_slice(), xc.as_slice(), out.as_slice_mut())
1959                {
1960                    // Parallel per-row clamped quadratic-form diagonal with
1961                    // stride-1 reads from both row-major operands. Avoids the
1962                    // per-row `Array1::dot` call's overhead at large-scale shapes
1963                    // (n ≈ 2e5, p ≈ 33).
1964                    use rayon::iter::{IndexedParallelIterator, ParallelIterator};
1965                    use rayon::slice::ParallelSliceMut;
1966                    out_slice
1967                        .par_chunks_mut(1)
1968                        .enumerate()
1969                        .for_each(|(i, slot)| {
1970                            let off = i * p;
1971                            let m_row = &m_all[off..off + p];
1972                            let xc_row = &xc_all[off..off + p];
1973                            let mut acc = 0.0_f64;
1974                            for j in 0..p {
1975                                acc += m_row[j] * xc_row[j];
1976                            }
1977                            // clamp tiny-negative fp drift on diag(X M Xᵀ)
1978                            // when M is a PSD covariance/precision matrix.
1979                            slot[0] = acc.max(0.0);
1980                        });
1981                } else {
1982                    for i in 0..n {
1983                        // clamp tiny-negative fp drift on diag(X M Xᵀ)
1984                        // when M is a PSD covariance/precision matrix.
1985                        out[i] = matrix.row(i).dot(&xc.row(i)).max(0.0);
1986                    }
1987                }
1988                Ok(out)
1989            }
1990            Self::Lazy(op) => op.quadratic_form_diag(middle),
1991        }
1992    }
1993
1994    fn as_dense_ref(&self) -> Option<&Array2<f64>> {
1995        DenseDesignMatrix::as_dense_ref(self)
1996    }
1997
1998    fn materialization_policy(&self) -> Option<MaterializationPolicy> {
1999        match self {
2000            Self::Materialized(_) => None,
2001            Self::Lazy(lazy) => lazy.op.materialization_policy(),
2002        }
2003    }
2004
2005    fn row_chunk_into(
2006        &self,
2007        rows: Range<usize>,
2008        mut out: ArrayViewMut2<'_, f64>,
2009    ) -> Result<(), MatrixMaterializationError> {
2010        if out.nrows() != rows.end - rows.start || out.ncols() != self.ncols() {
2011            return Err(MatrixMaterializationError::MissingRowChunk {
2012                context: "DenseDesignMatrix::row_chunk_into shape mismatch",
2013            });
2014        }
2015        match self {
2016            Self::Materialized(matrix) => {
2017                out.assign(&matrix.slice(s![rows, ..]));
2018                Ok(())
2019            }
2020            Self::Lazy(op) => op.row_chunk_into(rows, out),
2021        }
2022    }
2023
2024    fn to_dense(&self) -> Array2<f64> {
2025        DenseDesignMatrix::to_dense(self)
2026    }
2027
2028    fn to_dense_arc(&self) -> Arc<Array2<f64>> {
2029        DenseDesignMatrix::to_dense_arc(self)
2030    }
2031}
2032
2033// ---------------------------------------------------------------------------
2034// ReparamOperator — lazy X·Qs composition without materialization
2035// ---------------------------------------------------------------------------
2036
2037/// Lazy composed operator for reparameterized design: X_transformed = X_original · Qs.
2038///
2039/// Instead of materializing the dense n×p product X·Qs, this operator applies
2040/// the p×p orthogonal transform Qs on the coefficient side:
2041///
2042///   apply(v)           → X · (Qs · v)
2043///   apply_transpose(v) → Qs^T · (X^T · v)
2044///   diag_xtw_x(w)      → Qs^T · (X^T W X) · Qs
2045///
2046/// This preserves the sparsity of X and avoids an O(n·p) dense allocation.
2047pub struct ReparamOperator {
2048    x_original: DesignMatrix,
2049    qs: Arc<Array2<f64>>,
2050    n: usize,
2051    p: usize,
2052}
2053
2054impl ReparamOperator {
2055    pub fn new(x_original: DesignMatrix, qs: Arc<Array2<f64>>) -> Self {
2056        let n = x_original.nrows();
2057        let p = qs.ncols();
2058        assert_eq!(
2059            x_original.ncols(),
2060            qs.nrows(),
2061            "ReparamOperator: X cols ({}) must match Qs rows ({})",
2062            x_original.ncols(),
2063            qs.nrows()
2064        );
2065        Self {
2066            x_original,
2067            qs,
2068            n,
2069            p,
2070        }
2071    }
2072
2073    /// Access the Qs orthogonal transform.
2074    pub fn qs(&self) -> &Array2<f64> {
2075        &self.qs
2076    }
2077}
2078
2079impl LinearOperator for ReparamOperator {
2080    fn nrows(&self) -> usize {
2081        self.n
2082    }
2083
2084    fn ncols(&self) -> usize {
2085        self.p
2086    }
2087
2088    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
2089        // X · (Qs · v): apply Qs on the p-dimensional side first, then sparse/dense X.
2090        let qv = self.qs.dot(vector);
2091        self.x_original.apply(&qv)
2092    }
2093
2094    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
2095        // Qs^T · (X^T · v): apply X^T first (sparse matvec), then small dense Qs^T.
2096        let xtv = self.x_original.apply_transpose(vector);
2097        fast_atv(&self.qs, &xtv)
2098    }
2099
2100    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
2101        // Qs^T · (X^T W X) · Qs: compute X^TWX in original basis (sparse-friendly),
2102        // then two small p×p multiplications.
2103        let xtwx = self.x_original.diag_xtw_x(weights)?;
2104        let tmp = fast_atb(&self.qs, &xtwx);
2105        Ok(fast_ab(&tmp, &self.qs))
2106    }
2107
2108    fn apply_weighted_normal(
2109        &self,
2110        weights: FiniteSignedWeightsView<'_>,
2111        vector: &Array1<f64>,
2112        penalty: Option<&Array2<f64>>,
2113        ridge: f64,
2114    ) -> Array1<f64> {
2115        assert_eq!(
2116            weights.len(),
2117            self.x_original.nrows(),
2118            "ReparamOperator::apply_weighted_normal weight length mismatch"
2119        );
2120        assert_eq!(
2121            vector.len(),
2122            self.qs.ncols(),
2123            "ReparamOperator::apply_weighted_normal vector length mismatch"
2124        );
2125        // Qs^T X^T W X Qs v + S v + ridge v, with signed W preserved.  Any
2126        // ridge needed for a global solve is applied after the exact product.
2127        let weights = weights.view();
2128        let qv = self.qs.dot(vector);
2129        let xqv = self.x_original.apply(&qv);
2130        let mut wxqv = xqv;
2131        for i in 0..wxqv.len() {
2132            wxqv[i] *= weights[i];
2133        }
2134        let xtw = self.x_original.apply_transpose(&wxqv);
2135        let mut out = fast_atv(&self.qs, &xtw);
2136        if let Some(pen) = penalty {
2137            out += &fast_av(pen, vector);
2138        }
2139        if ridge > 0.0 {
2140            // BLAS axpy: out += ridge * vector, no temporary allocation.
2141            out.scaled_add(ridge, vector);
2142        }
2143        out
2144    }
2145}
2146
2147impl DenseDesignOperator for ReparamOperator {
2148    fn compute_xtwy(&self, weights: &Array1<f64>, y: &Array1<f64>) -> Result<Array1<f64>, String> {
2149        // Qs^T · X^T(w ⊙ y)
2150        let xtwy = self.x_original.compute_xtwy(weights, y)?;
2151        Ok(fast_atv(&self.qs, &xtwy))
2152    }
2153
2154    fn quadratic_form_diag(&self, middle: &Array2<f64>) -> Result<Array1<f64>, String> {
2155        // diag(X Qs M Qs^T X^T) = diag(X · (Qs M Qs^T) · X^T)
2156        // Compute M_orig = Qs · M · Qs^T (p×p), then delegate to x_original.
2157        let qm = fast_ab(&self.qs, middle);
2158        let m_orig = fast_ab(&qm, &self.qs.t().to_owned());
2159        self.x_original.quadratic_form_diag(&m_orig)
2160    }
2161
2162    fn to_dense(&self) -> Array2<f64> {
2163        match &self.x_original {
2164            DesignMatrix::Dense(x) => fast_ab(x.to_dense_arc().as_ref(), &self.qs),
2165            _ => {
2166                let x_dense = self.x_original.to_dense();
2167                fast_ab(&x_dense, &self.qs)
2168            }
2169        }
2170    }
2171
2172    fn to_dense_arc(&self) -> Arc<Array2<f64>> {
2173        Arc::new(self.to_dense())
2174    }
2175
2176    fn as_dense_ref(&self) -> Option<&Array2<f64>> {
2177        None
2178    }
2179
2180    fn materialization_policy(&self) -> Option<MaterializationPolicy> {
2181        self.x_original.materialization_policy()
2182    }
2183
2184    fn apply_columns(&self, cols: &[usize]) -> Array2<f64> {
2185        // (X · Qs)[:, cols] = X · Qs[:, cols] — one batched matvec over the inner
2186        // design instead of one-per-column dispatch on a unit vector.
2187        let qs_cols = self.qs.select(Axis(1), cols);
2188        match &self.x_original {
2189            DesignMatrix::Dense(x) => match x.as_dense_ref() {
2190                Some(x_dense) => fast_ab(x_dense, &qs_cols),
2191                None => {
2192                    let n = self.n;
2193                    let mut out = Array2::<f64>::zeros((n, cols.len()));
2194                    for k in 0..cols.len() {
2195                        let col = qs_cols.column(k).to_owned();
2196                        let xc = self.x_original.apply(&col);
2197                        out.column_mut(k).assign(&xc);
2198                    }
2199                    out
2200                }
2201            },
2202            DesignMatrix::Sparse(_) => {
2203                // Sparse X: apply column-by-column over the small qs_cols block.
2204                let n = self.n;
2205                let mut out = Array2::<f64>::zeros((n, cols.len()));
2206                for k in 0..cols.len() {
2207                    let col = qs_cols.column(k).to_owned();
2208                    let xc = self.x_original.apply(&col);
2209                    out.column_mut(k).assign(&xc);
2210                }
2211                out
2212            }
2213        }
2214    }
2215
2216    fn row_chunk_into(
2217        &self,
2218        rows: Range<usize>,
2219        mut out: ArrayViewMut2<'_, f64>,
2220    ) -> Result<(), MatrixMaterializationError> {
2221        if out.nrows() != rows.end - rows.start || out.ncols() != self.p {
2222            return Err(MatrixMaterializationError::MissingRowChunk {
2223                context: "ReparamOperator::row_chunk_into shape mismatch",
2224            });
2225        }
2226        match &self.x_original {
2227            DesignMatrix::Dense(x) => {
2228                let chunk = x.try_row_chunk(rows)?;
2229                out.assign(&fast_ab(&chunk, &self.qs));
2230            }
2231            DesignMatrix::Sparse(sdm) => {
2232                // Extract rows directly from CSR without densifying the full matrix.
2233                let csr = sdm
2234                    .to_csr_arc()
2235                    .ok_or(MatrixMaterializationError::MissingRowChunk {
2236                        context: "ReparamOperator::row_chunk_into: failed to obtain CSR view",
2237                    })?;
2238                let sym = csr.symbolic();
2239                let row_ptr = sym.row_ptr();
2240                let col_idx = sym.col_idx();
2241                let vals = csr.val();
2242                let chunk_rows = rows.end - rows.start;
2243                let p_inner = sdm.ncols();
2244                let mut chunk = Array2::<f64>::zeros((chunk_rows, p_inner));
2245                for (local, global) in (rows.start..rows.end).enumerate() {
2246                    for ptr in row_ptr[global]..row_ptr[global + 1] {
2247                        chunk[[local, col_idx[ptr]]] = vals[ptr];
2248                    }
2249                }
2250                out.assign(&fast_ab(&chunk, &self.qs));
2251            }
2252        }
2253        Ok(())
2254    }
2255}
2256
2257// ---------------------------------------------------------------------------
2258// RandomEffectOperator — O(n) implicit design for random intercepts
2259// ---------------------------------------------------------------------------
2260
2261/// Implicit design operator for random-intercept effects.
2262///
2263/// Instead of materializing an n × q one-hot matrix, stores only the O(n)
2264/// integer group-label vector.  All matvecs, Gram assembly, and
2265/// weighted-normal products operate in O(n) time and O(n + q) memory.
2266#[derive(Clone)]
2267pub struct RandomEffectOperator {
2268    /// For each observation, the column index of its group (0..num_groups),
2269    /// or `None` if the observation's level was not in the kept set (prediction
2270    /// with unseen levels).
2271    pub group_ids: Vec<Option<usize>>,
2272    /// Number of observations.
2273    pub n: usize,
2274    /// Number of groups (columns).
2275    pub num_groups: usize,
2276}
2277
2278impl RandomEffectOperator {
2279    pub fn new(group_ids: Vec<Option<usize>>, num_groups: usize) -> Self {
2280        let n = group_ids.len();
2281        Self {
2282            group_ids,
2283            n,
2284            num_groups,
2285        }
2286    }
2287
2288    /// For a dense block X_dense (n × p_dense) and weights w, compute
2289    /// X_dense' diag(w) X_re  →  (p_dense × num_groups) matrix.
2290    ///
2291    /// Column g of the result = Σ_{i: group\[i\]=g} w\[i\] * X_dense.row(i).
2292    /// Total cost: O(n × p_dense).
2293    pub fn weighted_cross_with_dense(
2294        &self,
2295        dense: &Array2<f64>,
2296        weights: &Array1<f64>,
2297    ) -> Result<Array2<f64>, String> {
2298        if dense.nrows() != self.n {
2299            return Err(format!(
2300                "RandomEffectOperator::weighted_cross_with_dense row mismatch: dense={}, nrows={}",
2301                dense.nrows(),
2302                self.n
2303            ));
2304        }
2305        certify_signed_weights(
2306            "RandomEffectOperator::weighted_cross_with_dense",
2307            weights,
2308            self.n,
2309        )?;
2310        let p_dense = dense.ncols();
2311        let mut cross = Array2::<f64>::zeros((p_dense, self.num_groups));
2312        for i in 0..self.n {
2313            if let Some(g) = self.group_ids[i] {
2314                let wi = weights[i];
2315                if wi == 0.0 {
2316                    continue;
2317                }
2318                for j in 0..p_dense {
2319                    cross[[j, g]] += wi * dense[[i, j]];
2320                }
2321            }
2322        }
2323        Ok(cross)
2324    }
2325
2326    /// For two RE operators, compute X_re_a' diag(w) X_re_b → (qa × qb).
2327    /// Entry (a, b) = Σ_{i: group_a\[i\]=a AND group_b\[i\]=b} w\[i\].
2328    /// Cost: O(n).
2329    pub fn weighted_cross_with_re(
2330        &self,
2331        other: &RandomEffectOperator,
2332        weights: &Array1<f64>,
2333    ) -> Result<Array2<f64>, String> {
2334        if other.n != self.n {
2335            return Err(format!(
2336                "RandomEffectOperator::weighted_cross_with_re row mismatch: other={}, nrows={}",
2337                other.n, self.n
2338            ));
2339        }
2340        certify_signed_weights(
2341            "RandomEffectOperator::weighted_cross_with_re",
2342            weights,
2343            self.n,
2344        )?;
2345        let mut cross = Array2::<f64>::zeros((self.num_groups, other.num_groups));
2346        for i in 0..self.n {
2347            if let (Some(a), Some(b)) = (self.group_ids[i], other.group_ids[i]) {
2348                let wi = weights[i];
2349                if wi != 0.0 {
2350                    cross[[a, b]] += wi;
2351                }
2352            }
2353        }
2354        Ok(cross)
2355    }
2356}
2357
2358impl LinearOperator for RandomEffectOperator {
2359    fn nrows(&self) -> usize {
2360        self.n
2361    }
2362
2363    fn ncols(&self) -> usize {
2364        self.num_groups
2365    }
2366
2367    /// Forward: out\[i\] = β[group\[i\]], or 0 if unmatched.
2368    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
2369        use rayon::prelude::*;
2370        let out: Vec<f64> = self
2371            .group_ids
2372            .par_iter()
2373            .map(|g| g.map(|g| vector[g]).unwrap_or(0.0))
2374            .collect();
2375        Array1::from(out)
2376    }
2377
2378    /// Transpose: out\[g\] = Σ_{i: group\[i\]=g} v\[i\].
2379    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
2380        let mut out = Array1::<f64>::zeros(self.num_groups);
2381        for i in 0..self.n {
2382            if let Some(g) = self.group_ids[i] {
2383                out[g] += vector[i];
2384            }
2385        }
2386        out
2387    }
2388
2389    /// X'WX for a one-hot design is diagonal: D\[g,g\] = Σ_{i: group\[i\]=g} w\[i\].
2390    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
2391        certify_signed_weights("RandomEffectOperator::diag_xtw_x", weights, self.n)?;
2392        let q = self.num_groups;
2393        let mut xtwx = Array2::<f64>::zeros((q, q));
2394        for i in 0..self.n {
2395            if let Some(g) = self.group_ids[i] {
2396                xtwx[[g, g]] += weights[i];
2397            }
2398        }
2399        Ok(xtwx)
2400    }
2401
2402    /// Diagonal of X'WX: per-group weight sums.
2403    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
2404        certify_signed_weights("RandomEffectOperator::diag_gram", weights, self.n)?;
2405        let mut diag = Array1::<f64>::zeros(self.num_groups);
2406        for i in 0..self.n {
2407            if let Some(g) = self.group_ids[i] {
2408                diag[g] += weights[i];
2409            }
2410        }
2411        Ok(diag)
2412    }
2413
2414
2415    // Restored: the dead-code sweep (d484a091a) deleted this override; it is
2416    // reached only through the trait, so the sweep fell back to the default.
2417    /// Fused X'WXβ + Sβ + ridge·β.  O(n + q).
2418    fn apply_weighted_normal(
2419        &self,
2420        weights: FiniteSignedWeightsView<'_>,
2421        vector: &Array1<f64>,
2422        penalty: Option<&Array2<f64>>,
2423        ridge: f64,
2424    ) -> Array1<f64> {
2425        assert_eq!(
2426            weights.len(),
2427            self.n,
2428            "RandomEffectOperator::apply_weighted_normal weight length mismatch"
2429        );
2430        assert_eq!(
2431            vector.len(),
2432            self.num_groups,
2433            "RandomEffectOperator::apply_weighted_normal vector length mismatch"
2434        );
2435        // Step 1: accumulate per-group weighted β[g] contributions.
2436        //   group_acc[g] = Σ_{i in group g} w[i]
2437        //   result[g] = group_acc[g] * vector[g]
2438        let weights = weights.view();
2439        let mut group_wacc = Array1::<f64>::zeros(self.num_groups);
2440        for i in 0..self.n {
2441            if let Some(g) = self.group_ids[i] {
2442                group_wacc[g] += weights[i];
2443            }
2444        }
2445        let mut out = Array1::<f64>::zeros(self.num_groups);
2446        for g in 0..self.num_groups {
2447            out[g] = group_wacc[g] * vector[g];
2448        }
2449        if let Some(pen) = penalty {
2450            out += &pen.dot(vector);
2451        }
2452        if ridge > 0.0 {
2453            for g in 0..self.num_groups {
2454                out[g] += ridge * vector[g];
2455            }
2456        }
2457        out
2458    }
2459
2460    // Restored: the dead-code sweep (d484a091a) deleted this override; it is
2461    // reached only through the trait, so the sweep fell back to the default.
2462    fn uses_matrix_free_pcg(&self) -> bool {
2463        true
2464    }
2465}
2466
2467impl DenseDesignOperator for RandomEffectOperator {
2468
2469    fn row_chunk_into(
2470        &self,
2471        rows: Range<usize>,
2472        mut out: ArrayViewMut2<'_, f64>,
2473    ) -> Result<(), MatrixMaterializationError> {
2474        if out.nrows() != rows.end - rows.start || out.ncols() != self.num_groups {
2475            return Err(MatrixMaterializationError::MissingRowChunk {
2476                context: "RandomEffectOperator::row_chunk_into shape mismatch",
2477            });
2478        }
2479        out.fill(0.0);
2480        for (local, global) in rows.enumerate() {
2481            if let Some(g) = self.group_ids[global] {
2482                out[[local, g]] = 1.0;
2483            }
2484        }
2485        Ok(())
2486    }
2487
2488    /// Materialize the full n × q one-hot matrix (fallback for diagnostics).
2489    fn to_dense(&self) -> Array2<f64> {
2490        let mut out = Array2::<f64>::zeros((self.n, self.num_groups));
2491        ndarray::Zip::indexed(out.rows_mut()).par_for_each(|i, mut row| {
2492            if let Some(g) = self.group_ids[i] {
2493                row[g] = 1.0;
2494            }
2495        });
2496        out
2497    }
2498
2499    // Restored: the dead-code sweep (d484a091a) deleted this override; it is
2500    // reached only through the trait, so the sweep fell back to the default.
2501    fn compute_xtwy(&self, weights: &Array1<f64>, y: &Array1<f64>) -> Result<Array1<f64>, String> {
2502        if weights.len() != self.n || y.len() != self.n {
2503            return Err(format!(
2504                "RandomEffectOperator::compute_xtwy dimension mismatch: weights={}, y={}, nrows={}",
2505                weights.len(),
2506                y.len(),
2507                self.n
2508            ));
2509        }
2510        certify_signed_weights("RandomEffectOperator::compute_xtwy", weights, self.n)?;
2511        let mut out = Array1::<f64>::zeros(self.num_groups);
2512        for i in 0..self.n {
2513            if let Some(g) = self.group_ids[i] {
2514                let wi = weights[i];
2515                out[g] += wi * y[i];
2516            }
2517        }
2518        Ok(out)
2519    }
2520
2521    // Restored: the dead-code sweep (d484a091a) deleted this override; it is
2522    // reached only through the trait, so the sweep fell back to the default.
2523    /// diag(X M X') for one-hot X: out\[i\] = M[group\[i\], group\[i\]].
2524    fn quadratic_form_diag(&self, middle: &Array2<f64>) -> Result<Array1<f64>, String> {
2525        use rayon::prelude::*;
2526        let out: Vec<f64> = self
2527            .group_ids
2528            .par_iter()
2529            .map(|g| g.map(|g| middle[[g, g]].max(0.0)).unwrap_or(0.0))
2530            .collect();
2531        Ok(Array1::from(out))
2532    }
2533}
2534
2535// ---------------------------------------------------------------------------
2536// BlockDesignOperator — horizontal block composition [B₀ | B₁ | … | Bₖ]
2537// ---------------------------------------------------------------------------
2538
2539/// A single block in a horizontally-composed design operator.
2540#[derive(Clone)]
2541pub enum DesignBlock {
2542    Dense(DenseDesignMatrix),
2543    Sparse(SparseDesignMatrix),
2544    RandomEffect(Arc<RandomEffectOperator>),
2545    /// Implicit all-ones intercept column: n rows, 1 column, zero storage.
2546    Intercept(usize),
2547}
2548
2549impl DesignBlock {
2550    pub fn nrows(&self) -> usize {
2551        match self {
2552            Self::Dense(d) => d.nrows(),
2553            Self::Sparse(s) => s.nrows(),
2554            Self::RandomEffect(op) => op.nrows(),
2555            Self::Intercept(n) => *n,
2556        }
2557    }
2558
2559    pub fn ncols(&self) -> usize {
2560        match self {
2561            Self::Dense(d) => d.ncols(),
2562            Self::Sparse(s) => s.ncols(),
2563            Self::RandomEffect(op) => op.ncols(),
2564            Self::Intercept(_) => 1,
2565        }
2566    }
2567
2568    fn materialization_policy(&self) -> Option<MaterializationPolicy> {
2569        match self {
2570            Self::Dense(design) => design.materialization_policy(),
2571            Self::Sparse(_) | Self::RandomEffect(_) | Self::Intercept(_) => None,
2572        }
2573    }
2574
2575    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
2576        match self {
2577            Self::Dense(d) => d.apply(vector),
2578            Self::Sparse(s) => DesignMatrix::Sparse(s.clone()).apply(vector),
2579            Self::RandomEffect(op) => op.apply(vector),
2580            Self::Intercept(n) => Array1::from_elem(*n, vector[0]),
2581        }
2582    }
2583
2584    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
2585        match self {
2586            Self::Dense(d) => d.apply_transpose(vector),
2587            Self::Sparse(s) => DesignMatrix::Sparse(s.clone()).apply_transpose(vector),
2588            Self::RandomEffect(op) => op.apply_transpose(vector),
2589            Self::Intercept(_) => {
2590                let sum: f64 = vector.iter().sum();
2591                Array1::from_vec(vec![sum])
2592            }
2593        }
2594    }
2595
2596    fn try_row_chunk(&self, rows: Range<usize>) -> Result<Array2<f64>, MatrixMaterializationError> {
2597        match self {
2598            Self::Dense(d) => d.try_row_chunk(rows),
2599            Self::Sparse(s) => DesignMatrix::Sparse(s.clone()).try_row_chunk(rows),
2600            Self::RandomEffect(op) => op.try_row_chunk(rows),
2601            Self::Intercept(_) => Ok(Array2::ones((rows.end - rows.start, 1))),
2602        }
2603    }
2604
2605    fn row_chunk_into(
2606        &self,
2607        rows: Range<usize>,
2608        mut out: ArrayViewMut2<'_, f64>,
2609    ) -> Result<(), MatrixMaterializationError> {
2610        if out.nrows() != rows.end - rows.start || out.ncols() != self.ncols() {
2611            return Err(MatrixMaterializationError::MissingRowChunk {
2612                context: "DesignBlock::row_chunk_into shape mismatch",
2613            });
2614        }
2615        match self {
2616            Self::Dense(d) => d.row_chunk_into(rows, out),
2617            Self::Sparse(s) => s.row_chunk_into(rows, out),
2618            Self::RandomEffect(op) => op.row_chunk_into(rows, out),
2619            Self::Intercept(_) => {
2620                out.fill(1.0);
2621                Ok(())
2622            }
2623        }
2624    }
2625
2626    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
2627        certify_signed_weights("DesignBlock::diag_xtw_x", weights, self.nrows())?;
2628        match self {
2629            Self::Dense(d) => d.diag_xtw_x(weights),
2630            Self::Sparse(s) => DesignMatrix::Sparse(s.clone()).diag_xtw_x(weights),
2631            Self::RandomEffect(op) => op.diag_xtw_x(weights),
2632            Self::Intercept(_) => {
2633                // Signed w: diag_xtw_x is the sign-honest XᵀWX assembler (see
2634                // the `LinearOperator::diag_xtw_x` contract), used for
2635                // observed-Hessian curvature on non-canonical links where
2636                // negative working weights are the normal case. Clamping here
2637                // would silently corrupt the intercept row/column whenever any
2638                // weight is negative.
2639                let sum: f64 = weights.iter().sum();
2640                Ok(Array2::from_elem((1, 1), sum))
2641            }
2642        }
2643    }
2644
2645    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
2646        certify_signed_weights("DesignBlock::diag_gram", weights, self.nrows())?;
2647        match self {
2648            Self::Dense(d) => d.diag_gram(weights),
2649            Self::Sparse(s) => DesignMatrix::Sparse(s.clone()).diag_gram(weights),
2650            Self::RandomEffect(op) => op.diag_gram(weights),
2651            Self::Intercept(_) => {
2652                // Signed w, matching diag_xtw_x above (the default
2653                // `LinearOperator::diag_gram` is literally `diag_xtw_x`'s
2654                // diagonal).
2655                let sum: f64 = weights.iter().sum();
2656                Ok(Array1::from_vec(vec![sum]))
2657            }
2658        }
2659    }
2660
2661    /// Materialize this block as a dense (n, p_k) matrix.
2662    fn to_dense(&self) -> Array2<f64> {
2663        match self {
2664            Self::Dense(d) => d.to_dense(),
2665            Self::Sparse(s) => s.to_dense_arc().as_ref().clone(),
2666            Self::RandomEffect(op) => op.to_dense(),
2667            Self::Intercept(n) => Array2::ones((*n, 1)),
2668        }
2669    }
2670}
2671
2672/// Horizontally-composed design operator: X = [B₀ | B₁ | … | Bₖ].
2673///
2674/// Each block can be dense or operator-based.  The coefficient vector β is
2675/// partitioned by block, and the forward product is the sum of per-block
2676/// contributions.  Cross-block terms in X'WX are computed via specialized
2677/// methods on `RandomEffectOperator` for efficiency.
2678#[derive(Clone)]
2679pub struct BlockDesignOperator {
2680    pub blocks: Vec<DesignBlock>,
2681    /// Cumulative column offsets: block i owns columns col_offsets\[i\]..col_offsets[i+1].
2682    pub col_offsets: Vec<usize>,
2683    pub total_cols: usize,
2684    pub n: usize,
2685}
2686
2687impl BlockDesignOperator {
2688    pub fn new(blocks: Vec<DesignBlock>) -> Result<Self, String> {
2689        if blocks.is_empty() {
2690            return Err("BlockDesignOperator: need at least one block".to_string());
2691        }
2692        let n = blocks[0].nrows();
2693        for (i, b) in blocks.iter().enumerate() {
2694            if b.nrows() != n {
2695                return Err(format!(
2696                    "BlockDesignOperator: block {i} has {} rows, expected {n}",
2697                    b.nrows()
2698                ));
2699            }
2700        }
2701        let mut col_offsets = Vec::with_capacity(blocks.len() + 1);
2702        col_offsets.push(0);
2703        // Carry the running width instead of re-reading the last offset, so the
2704        // prefix-sum has no partial state that could be empty.
2705        let mut total_cols = 0usize;
2706        for b in &blocks {
2707            total_cols += b.ncols();
2708            col_offsets.push(total_cols);
2709        }
2710        Ok(Self {
2711            blocks,
2712            col_offsets,
2713            total_cols,
2714            n,
2715        })
2716    }
2717
2718    fn weighted_cross_chunked(
2719        &self,
2720        left: &DesignBlock,
2721        right: &DesignBlock,
2722        weights: &Array1<f64>,
2723    ) -> Result<Array2<f64>, String> {
2724        let pi = left.ncols();
2725        let pj = right.ncols();
2726        let mut cross = Array2::<f64>::zeros((pi, pj));
2727        for start in (0..self.n).step_by(OPERATOR_ROW_CHUNK_SIZE) {
2728            let end = (start + OPERATOR_ROW_CHUNK_SIZE).min(self.n);
2729            let left_chunk = left.try_row_chunk(start..end).map_err(|e| e.to_string())?;
2730            let right_chunk = right.try_row_chunk(start..end).map_err(|e| e.to_string())?;
2731            for local in 0..(end - start) {
2732                // Cross-block X_iᵀ diag(w) X_j is linear in w and well-defined
2733                // for any sign — observed-Hessian assembly (binomial+cloglog,
2734                // Gamma+identity, etc.) legitimately supplies signed w_hessian
2735                // here. The prior `.max(0.0)` silently zeroed the negative-
2736                // curvature contribution, producing an inconsistent off-
2737                // diagonal block. Mirrors the dense-rows kernel's sign-correct
2738                // accumulation a few hundred lines above.
2739                let wi = weights[start + local];
2740                if wi == 0.0 {
2741                    continue;
2742                }
2743                for a in 0..pi {
2744                    let scaled = wi * left_chunk[[local, a]];
2745                    if scaled == 0.0 {
2746                        continue;
2747                    }
2748                    for b in 0..pj {
2749                        cross[[a, b]] += scaled * right_chunk[[local, b]];
2750                    }
2751                }
2752            }
2753        }
2754        Ok(cross)
2755    }
2756
2757    fn quadratic_form_diag_cross_chunked(
2758        &self,
2759        block_a: &DesignBlock,
2760        block_b: &DesignBlock,
2761        m_ab: &Array2<f64>,
2762    ) -> Result<Array1<f64>, String> {
2763        let mut out = Array1::<f64>::zeros(self.n);
2764        for start in (0..self.n).step_by(OPERATOR_ROW_CHUNK_SIZE) {
2765            let end = (start + OPERATOR_ROW_CHUNK_SIZE).min(self.n);
2766            let a_chunk = block_a
2767                .try_row_chunk(start..end)
2768                .map_err(|e| e.to_string())?;
2769            let b_chunk = block_b
2770                .try_row_chunk(start..end)
2771                .map_err(|e| e.to_string())?;
2772            let a_m = fast_ab(&a_chunk, m_ab);
2773            for local in 0..(end - start) {
2774                out[start + local] = a_m.row(local).dot(&b_chunk.row(local));
2775            }
2776        }
2777        Ok(out)
2778    }
2779
2780    /// Compute the cross-block X_i' diag(w) X_j for blocks i < j.
2781    fn cross_block(
2782        &self,
2783        i: usize,
2784        j: usize,
2785        weights: &Array1<f64>,
2786    ) -> Result<Array2<f64>, String> {
2787        match (&self.blocks[i], &self.blocks[j]) {
2788            // ── Dense × Dense ───────────────────────────────────────────
2789            (DesignBlock::Dense(d_i), DesignBlock::Dense(d_j)) => {
2790                if let (Some(xi), Some(xj)) = (d_i.as_dense_ref(), d_j.as_dense_ref()) {
2791                    weighted_crossprod_dense(xi, weights, xj)
2792                } else {
2793                    self.weighted_cross_chunked(&self.blocks[i], &self.blocks[j], weights)
2794                }
2795            }
2796            (DesignBlock::Dense(_), DesignBlock::Sparse(_))
2797            | (DesignBlock::Sparse(_), DesignBlock::Dense(_))
2798            | (DesignBlock::Sparse(_), DesignBlock::Sparse(_))
2799            | (DesignBlock::Sparse(_), DesignBlock::RandomEffect(_))
2800            | (DesignBlock::RandomEffect(_), DesignBlock::Sparse(_)) => {
2801                self.weighted_cross_chunked(&self.blocks[i], &self.blocks[j], weights)
2802            }
2803
2804            // ── Dense × RandomEffect ────────────────────────────────────
2805            (DesignBlock::Dense(d), DesignBlock::RandomEffect(re)) => {
2806                if let Some(dense) = d.as_dense_ref() {
2807                    re.weighted_cross_with_dense(dense, weights)
2808                } else {
2809                    self.weighted_cross_chunked(&self.blocks[i], &self.blocks[j], weights)
2810                }
2811            }
2812            (DesignBlock::RandomEffect(re), DesignBlock::Dense(d)) => {
2813                if let Some(dense) = d.as_dense_ref() {
2814                    let cross_t = re.weighted_cross_with_dense(dense, weights)?;
2815                    Ok(cross_t.t().to_owned())
2816                } else {
2817                    self.weighted_cross_chunked(&self.blocks[i], &self.blocks[j], weights)
2818                }
2819            }
2820
2821            // ── RandomEffect × RandomEffect ─────────────────────────────
2822            (DesignBlock::RandomEffect(re_a), DesignBlock::RandomEffect(re_b)) => {
2823                re_a.weighted_cross_with_re(re_b, weights)
2824            }
2825
2826            // ── Intercept × anything ────────────────────────────────────
2827            // 1'·diag(w)·B_j  →  (1 × p_j) where entry [0,c] = Σ_i w[i] * B_j[i,c]
2828            (DesignBlock::Intercept(_), other) => {
2829                // Signed w (no `.max(0.0)`) — see the sign-honest `diag_xtw_x`
2830                // contract; this cross term feeds the same XᵀWX assembly.
2831                let pj = other.ncols();
2832                let mut cross = Array2::<f64>::zeros((1, pj));
2833                let row = other.apply_transpose(weights);
2834                cross.row_mut(0).assign(&row);
2835                Ok(cross)
2836            }
2837            (other, DesignBlock::Intercept(_)) => {
2838                let pi = other.ncols();
2839                let mut cross = Array2::<f64>::zeros((pi, 1));
2840                let col = other.apply_transpose(weights);
2841                cross.column_mut(0).assign(&col);
2842                Ok(cross)
2843            }
2844        }
2845    }
2846
2847    /// Diagonal contribution diag(X_k M X_k') for a single block.
2848    fn quadratic_form_diag_block(
2849        &self,
2850        block: &DesignBlock,
2851        m_kk: &Array2<f64>,
2852    ) -> Result<Array1<f64>, String> {
2853        match block {
2854            DesignBlock::Dense(d) => {
2855                if let Some(dense) = d.as_dense_ref() {
2856                    let xm = fast_ab(dense, m_kk);
2857                    let mut out = Array1::<f64>::zeros(self.n);
2858                    ndarray::Zip::from(&mut out)
2859                        .and(dense.rows())
2860                        .and(xm.rows())
2861                        .par_for_each(|o, dr, xmr| *o = dr.dot(&xmr));
2862                    Ok(out)
2863                } else {
2864                    d.quadratic_form_diag(m_kk)
2865                }
2866            }
2867            DesignBlock::Sparse(s) => {
2868                let sparse = DesignMatrix::Sparse(s.clone());
2869                sparse.quadratic_form_diag(m_kk)
2870            }
2871            DesignBlock::RandomEffect(re) => {
2872                use rayon::prelude::*;
2873                let out: Vec<f64> = re
2874                    .group_ids
2875                    .par_iter()
2876                    .map(|g| g.map(|g| m_kk[[g, g]]).unwrap_or(0.0))
2877                    .collect();
2878                Ok(Array1::from(out))
2879            }
2880            DesignBlock::Intercept(_) => {
2881                // Row i of intercept block is [1], so contribution = M[0,0] for all i.
2882                Ok(Array1::from_elem(self.n, m_kk[[0, 0]]))
2883            }
2884        }
2885    }
2886
2887    /// Cross-block contribution diag(X_a M_ab X_b') for two distinct blocks.
2888    fn quadratic_form_diag_cross(
2889        &self,
2890        block_a: &DesignBlock,
2891        block_b: &DesignBlock,
2892        m_ab: &Array2<f64>,
2893    ) -> Result<Array1<f64>, String> {
2894        match (block_a, block_b) {
2895            (DesignBlock::Dense(da), DesignBlock::Dense(db)) => {
2896                if let (Some(da), Some(db)) = (da.as_dense_ref(), db.as_dense_ref()) {
2897                    let da_m = fast_ab(da, m_ab);
2898                    let mut out = Array1::<f64>::zeros(self.n);
2899                    ndarray::Zip::from(&mut out)
2900                        .and(da_m.rows())
2901                        .and(db.rows())
2902                        .par_for_each(|o, ar, br| *o = ar.dot(&br));
2903                    Ok(out)
2904                } else {
2905                    self.quadratic_form_diag_cross_chunked(block_a, block_b, m_ab)
2906                }
2907            }
2908            (DesignBlock::Dense(_), DesignBlock::Sparse(_))
2909            | (DesignBlock::Sparse(_), DesignBlock::Dense(_))
2910            | (DesignBlock::Sparse(_), DesignBlock::Sparse(_))
2911            | (DesignBlock::Sparse(_), DesignBlock::RandomEffect(_))
2912            | (DesignBlock::RandomEffect(_), DesignBlock::Sparse(_)) => {
2913                self.quadratic_form_diag_cross_chunked(block_a, block_b, m_ab)
2914            }
2915            (DesignBlock::Dense(d), DesignBlock::RandomEffect(re)) => {
2916                let mut out = Array1::<f64>::zeros(self.n);
2917                for start in (0..self.n).step_by(OPERATOR_ROW_CHUNK_SIZE) {
2918                    let end = (start + OPERATOR_ROW_CHUNK_SIZE).min(self.n);
2919                    let chunk = d.try_row_chunk(start..end).map_err(|e| e.to_string())?;
2920                    for local in 0..chunk.nrows() {
2921                        let i = start + local;
2922                        if let Some(g) = re.group_ids[i] {
2923                            let mut val = 0.0;
2924                            for j in 0..chunk.ncols() {
2925                                val += chunk[[local, j]] * m_ab[[j, g]];
2926                            }
2927                            out[i] = val;
2928                        }
2929                    }
2930                }
2931                Ok(out)
2932            }
2933            (DesignBlock::RandomEffect(re), DesignBlock::Dense(d)) => {
2934                let mut out = Array1::<f64>::zeros(self.n);
2935                for start in (0..self.n).step_by(OPERATOR_ROW_CHUNK_SIZE) {
2936                    let end = (start + OPERATOR_ROW_CHUNK_SIZE).min(self.n);
2937                    let chunk = d.try_row_chunk(start..end).map_err(|e| e.to_string())?;
2938                    for local in 0..chunk.nrows() {
2939                        let i = start + local;
2940                        if let Some(g) = re.group_ids[i] {
2941                            let mut val = 0.0;
2942                            for j in 0..chunk.ncols() {
2943                                val += m_ab[[g, j]] * chunk[[local, j]];
2944                            }
2945                            out[i] = val;
2946                        }
2947                    }
2948                }
2949                Ok(out)
2950            }
2951            (DesignBlock::RandomEffect(re_a), DesignBlock::RandomEffect(re_b)) => {
2952                use rayon::prelude::*;
2953                let out: Vec<f64> = re_a
2954                    .group_ids
2955                    .par_iter()
2956                    .zip(re_b.group_ids.par_iter())
2957                    .map(|(ga, gb)| match (ga, gb) {
2958                        (Some(ga), Some(gb)) => m_ab[[*ga, *gb]],
2959                        _ => 0.0,
2960                    })
2961                    .collect();
2962                Ok(Array1::from(out))
2963            }
2964
2965            // Intercept × anything: contribution at row i = m_ab[0, :] · row_i(B_b)
2966            (DesignBlock::Intercept(_), other) => {
2967                let m_row = m_ab.row(0);
2968                let mut out = Array1::<f64>::zeros(self.n);
2969                for start in (0..self.n).step_by(OPERATOR_ROW_CHUNK_SIZE) {
2970                    let end = (start + OPERATOR_ROW_CHUNK_SIZE).min(self.n);
2971                    let chunk = other.try_row_chunk(start..end).map_err(|e| e.to_string())?;
2972                    for local in 0..(end - start) {
2973                        out[start + local] = chunk.row(local).dot(&m_row);
2974                    }
2975                }
2976                Ok(out)
2977            }
2978            (other, DesignBlock::Intercept(_)) => {
2979                let m_col = m_ab.column(0);
2980                let mut out = Array1::<f64>::zeros(self.n);
2981                for start in (0..self.n).step_by(OPERATOR_ROW_CHUNK_SIZE) {
2982                    let end = (start + OPERATOR_ROW_CHUNK_SIZE).min(self.n);
2983                    let chunk = other.try_row_chunk(start..end).map_err(|e| e.to_string())?;
2984                    for local in 0..(end - start) {
2985                        out[start + local] = chunk.row(local).dot(&m_col);
2986                    }
2987                }
2988                Ok(out)
2989            }
2990        }
2991    }
2992}
2993
2994impl LinearOperator for BlockDesignOperator {
2995    fn nrows(&self) -> usize {
2996        self.n
2997    }
2998
2999    fn ncols(&self) -> usize {
3000        self.total_cols
3001    }
3002
3003    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
3004        let mut out = Array1::<f64>::zeros(self.n);
3005        for (idx, block) in self.blocks.iter().enumerate() {
3006            let start = self.col_offsets[idx];
3007            let end = self.col_offsets[idx + 1];
3008            let slice = vector.slice(s![start..end]).to_owned();
3009            let contribution = block.apply(&slice);
3010            out += &contribution;
3011        }
3012        out
3013    }
3014
3015    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
3016        let mut out = Array1::<f64>::zeros(self.total_cols);
3017        for (idx, block) in self.blocks.iter().enumerate() {
3018            let start = self.col_offsets[idx];
3019            let end = self.col_offsets[idx + 1];
3020            let transposed = block.apply_transpose(vector);
3021            out.slice_mut(s![start..end]).assign(&transposed);
3022        }
3023        out
3024    }
3025
3026    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
3027        certify_signed_weights("BlockDesignOperator::diag_xtw_x", weights, self.n)?;
3028        let p = self.total_cols;
3029        let mut result = Array2::<f64>::zeros((p, p));
3030
3031        // Diagonal blocks.
3032        for (idx, block) in self.blocks.iter().enumerate() {
3033            let start = self.col_offsets[idx];
3034            let end = self.col_offsets[idx + 1];
3035            let block_xtwx = block.diag_xtw_x(weights)?;
3036            result
3037                .slice_mut(s![start..end, start..end])
3038                .assign(&block_xtwx);
3039        }
3040
3041        // Cross blocks (i, j) for i < j.
3042        //
3043        // Perf (#1017): the shared weight-scaled design `diag(w)·X_i` is
3044        // identical across every pairing `(i, j>i)`. The prior code recomputed
3045        // it inside each `cross_block` call (re-scaling X_i by w once per
3046        // partner j) and folded the product with a naive O(n·p_i·p_j) triple
3047        // loop. We now scale each dense block by `w` exactly ONCE up front and
3048        // route every Dense×Dense pair through a single blocked BLAS GEMM
3049        // (`fast_atb`), collapsing the c² hand-rolled accumulations into c²
3050        // batched matmuls over a design that is weight-scaled c times instead
3051        // of O(c²) times. Non-dense pairs keep their specialized kernels.
3052        //
3053        // Bit-identity: `cross[a,b] = Σ_i w_i · X_i[i,a] · X_j[i,b]` is exactly
3054        // `(diag(w)·X_i)ᵀ · X_j`, so pre-scaling then GEMM is the same sum,
3055        // reassociated only by the matmul's blocking (≤1e-10).
3056        // Only blocks that can appear on the LEFT of an `i < j` pair are ever
3057        // read below, so the last block's weight-scaled copy would be a full
3058        // n×p_last allocate-and-fill that nothing reads — the whole cost of
3059        // this optimization with none of its benefit, once per assembly.
3060        let cross_left_blocks = self.blocks.len().saturating_sub(1);
3061        let weighted_dense: Vec<Option<Array2<f64>>> = self
3062            .blocks
3063            .iter()
3064            .take(cross_left_blocks)
3065            .map(|block| match block {
3066                DesignBlock::Dense(d) => d.as_dense_ref().map(|x| {
3067                    // diag(w)·X computed once; signed w (no .max(0.0)) to match
3068                    // the asymmetric cross-block kernel's sign-correct form.
3069                    x * &weights.view().insert_axis(Axis(1))
3070                }),
3071                _ => None,
3072            })
3073            .collect();
3074
3075        for i in 0..cross_left_blocks {
3076            for j in (i + 1)..self.blocks.len() {
3077                let cross = match (&weighted_dense[i], &self.blocks[j]) {
3078                    // Fused Dense×Dense: single GEMM over the shared,
3079                    // already-once-scaled left design.
3080                    (Some(wx_i), DesignBlock::Dense(d_j)) => match d_j.as_dense_ref() {
3081                        Some(x_j) => fast_atb(wx_i, x_j),
3082                        None => self.cross_block(i, j, weights)?,
3083                    },
3084                    _ => self.cross_block(i, j, weights)?,
3085                };
3086                let si = self.col_offsets[i];
3087                let ei = self.col_offsets[i + 1];
3088                let sj = self.col_offsets[j];
3089                let ej = self.col_offsets[j + 1];
3090                result.slice_mut(s![si..ei, sj..ej]).assign(&cross);
3091                result.slice_mut(s![sj..ej, si..ei]).assign(&cross.t());
3092            }
3093        }
3094
3095        Ok(result)
3096    }
3097
3098    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
3099        certify_signed_weights("BlockDesignOperator::diag_gram", weights, self.n)?;
3100        let mut out = Array1::<f64>::zeros(self.total_cols);
3101        for (idx, block) in self.blocks.iter().enumerate() {
3102            let start = self.col_offsets[idx];
3103            let end = self.col_offsets[idx + 1];
3104            let block_diag = block.diag_gram(weights)?;
3105            out.slice_mut(s![start..end]).assign(&block_diag);
3106        }
3107        Ok(out)
3108    }
3109
3110    fn apply_weighted_normal(
3111        &self,
3112        weights: FiniteSignedWeightsView<'_>,
3113        vector: &Array1<f64>,
3114        penalty: Option<&Array2<f64>>,
3115        ridge: f64,
3116    ) -> Array1<f64> {
3117        assert_eq!(
3118            weights.len(),
3119            self.n,
3120            "BlockDesignOperator::apply_weighted_normal weight length mismatch"
3121        );
3122        assert_eq!(
3123            vector.len(),
3124            self.total_cols,
3125            "BlockDesignOperator::apply_weighted_normal vector length mismatch"
3126        );
3127        // Fused: X'W(Xβ) + Sβ + ridge·β
3128        let weights = weights.view();
3129        let xv = self.apply(vector);
3130        let mut weighted = xv;
3131        for i in 0..weighted.len() {
3132            weighted[i] *= weights[i];
3133        }
3134        let mut out = self.apply_transpose(&weighted);
3135        if let Some(pen) = penalty {
3136            out += &fast_av(pen, vector);
3137        }
3138        if ridge > 0.0 {
3139            // BLAS axpy: out += ridge * vector, no temporary allocation.
3140            out.scaled_add(ridge, vector);
3141        }
3142        out
3143    }
3144
3145    fn uses_matrix_free_pcg(&self) -> bool {
3146        // Enable PCG when any block is non-dense (RE, Operator, or Intercept).
3147        self.blocks
3148            .iter()
3149            .any(|b| matches!(b, DesignBlock::RandomEffect(_) | DesignBlock::Intercept(_)))
3150    }
3151}
3152
3153impl DenseDesignOperator for BlockDesignOperator {
3154    fn materialization_policy(&self) -> Option<MaterializationPolicy> {
3155        self.blocks.iter().fold(None, |policy, block| {
3156            merge_operator_materialization_policies(policy, block.materialization_policy())
3157        })
3158    }
3159
3160    fn compute_xtwy(&self, weights: &Array1<f64>, y: &Array1<f64>) -> Result<Array1<f64>, String> {
3161        if weights.len() != self.n || y.len() != self.n {
3162            return Err(format!(
3163                "BlockDesignOperator::compute_xtwy dimension mismatch: weights={}, y={}, nrows={}",
3164                weights.len(),
3165                y.len(),
3166                self.n
3167            ));
3168        }
3169        certify_signed_weights("BlockDesignOperator::compute_xtwy", weights, self.n)?;
3170        let mut wy = Array1::<f64>::zeros(self.n);
3171        ndarray::Zip::from(&mut wy)
3172            .and(weights)
3173            .and(y)
3174            .par_for_each(|o, &w, &yi| *o = w * yi);
3175        Ok(self.apply_transpose(&wy))
3176    }
3177
3178    fn quadratic_form_diag(&self, middle: &Array2<f64>) -> Result<Array1<f64>, String> {
3179        // diag(X M X'): for each observation i, compute row_i(X) · M · row_i(X)'.
3180        // With block structure, this decomposes into diagonal and cross-block terms.
3181        let mut out = Array1::<f64>::zeros(self.n);
3182        let nb = self.blocks.len();
3183
3184        // Diagonal contributions: diag(X_k M_kk X_k')
3185        for k in 0..nb {
3186            let sk = self.col_offsets[k];
3187            let ek = self.col_offsets[k + 1];
3188            let m_kk = middle.slice(s![sk..ek, sk..ek]).to_owned();
3189            let block_diag = self.quadratic_form_diag_block(&self.blocks[k], &m_kk)?;
3190            out += &block_diag;
3191        }
3192
3193        // Cross-block contributions: 2·diag(X_a M_ab X_b')
3194        for a in 0..nb {
3195            for b in (a + 1)..nb {
3196                let sa = self.col_offsets[a];
3197                let ea = self.col_offsets[a + 1];
3198                let sb = self.col_offsets[b];
3199                let eb = self.col_offsets[b + 1];
3200                let m_ab = middle.slice(s![sa..ea, sb..eb]);
3201
3202                let cross_diag = self.quadratic_form_diag_cross(
3203                    &self.blocks[a],
3204                    &self.blocks[b],
3205                    &m_ab.to_owned(),
3206                )?;
3207                for i in 0..self.n {
3208                    out[i] += 2.0 * cross_diag[i];
3209                }
3210            }
3211        }
3212
3213        // Clamp to non-negative (variance-like quantity).
3214        for v in out.iter_mut() {
3215            *v = v.max(0.0);
3216        }
3217        Ok(out)
3218    }
3219
3220    fn row_chunk_into(
3221        &self,
3222        rows: Range<usize>,
3223        mut out: ArrayViewMut2<'_, f64>,
3224    ) -> Result<(), MatrixMaterializationError> {
3225        if out.nrows() != rows.end - rows.start || out.ncols() != self.total_cols {
3226            return Err(MatrixMaterializationError::MissingRowChunk {
3227                context: "BlockDesignOperator::row_chunk_into shape mismatch",
3228            });
3229        }
3230        for (idx, block) in self.blocks.iter().enumerate() {
3231            let cs = self.col_offsets[idx];
3232            let ce = self.col_offsets[idx + 1];
3233            block.row_chunk_into(rows.clone(), out.slice_mut(s![.., cs..ce]))?;
3234        }
3235        Ok(())
3236    }
3237
3238    fn to_dense(&self) -> Array2<f64> {
3239        let mut out = Array2::<f64>::zeros((self.n, self.total_cols));
3240        for (idx, block) in self.blocks.iter().enumerate() {
3241            let start = self.col_offsets[idx];
3242            let end = self.col_offsets[idx + 1];
3243            let dense_block = block.to_dense();
3244            out.slice_mut(s![.., start..end]).assign(&dense_block);
3245        }
3246        out
3247    }
3248}
3249
3250// ---------------------------------------------------------------------------
3251// MultiChannelOperator
3252// ---------------------------------------------------------------------------
3253
3254/// Multi-channel design operator: presents k views of shape (n, p) as a single
3255/// (k*n, p) operator without materializing the stacked matrix.
3256///
3257/// Primary use: survival time blocks with entry/exit/derivative channels.
3258/// Each channel contributes independently to matvecs and Gram assembly:
3259///
3260///   apply(β) = [X₀ β; X₁ β; …; X_{k-1} β]      (concatenated)
3261///   apply_transpose(v) = Σᵢ Xᵢᵀ vᵢ              (summed over channel slices)
3262///   X'WX = Σᵢ Xᵢᵀ diag(wᵢ) Xᵢ                  (summed over channel slices)
3263#[derive(Clone)]
3264pub struct MultiChannelOperator {
3265    /// Per-channel design matrices, each (n, p).
3266    pub channels: Vec<DesignMatrix>,
3267    /// Number of rows per channel (all channels must share the same n).
3268    pub n_per_channel: usize,
3269    /// Number of columns (shared across all channels).
3270    pub p: usize,
3271}
3272
3273impl MultiChannelOperator {
3274    pub fn new(channels: Vec<DesignMatrix>) -> Result<Self, String> {
3275        if channels.is_empty() {
3276            return Err("MultiChannelOperator: need at least one channel".to_string());
3277        }
3278        let n = channels[0].nrows();
3279        let p = channels[0].ncols();
3280        for (i, ch) in channels.iter().enumerate() {
3281            if ch.nrows() != n {
3282                return Err(format!(
3283                    "MultiChannelOperator: channel {i} has {} rows, expected {n}",
3284                    ch.nrows()
3285                ));
3286            }
3287            if ch.ncols() != p {
3288                return Err(format!(
3289                    "MultiChannelOperator: channel {i} has {} cols, expected {p}",
3290                    ch.ncols()
3291                ));
3292            }
3293        }
3294        Ok(Self {
3295            channels,
3296            n_per_channel: n,
3297            p,
3298        })
3299    }
3300}
3301
3302impl LinearOperator for MultiChannelOperator {
3303    fn nrows(&self) -> usize {
3304        self.n_per_channel * self.channels.len()
3305    }
3306
3307    fn ncols(&self) -> usize {
3308        self.p
3309    }
3310
3311    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
3312        let total = self.nrows();
3313        let mut out = Array1::<f64>::zeros(total);
3314        let n = self.n_per_channel;
3315        for (i, ch) in self.channels.iter().enumerate() {
3316            let ch_result = ch.matrixvectormultiply(vector);
3317            out.slice_mut(s![i * n..(i + 1) * n]).assign(&ch_result);
3318        }
3319        out
3320    }
3321
3322    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
3323        let n = self.n_per_channel;
3324        let mut out = Array1::<f64>::zeros(self.p);
3325        for (i, ch) in self.channels.iter().enumerate() {
3326            out += &ch.apply_transpose_view(vector.slice(s![i * n..(i + 1) * n]));
3327        }
3328        out
3329    }
3330
3331    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
3332        let n = self.n_per_channel;
3333        certify_signed_weights("MultiChannelOperator::diag_xtw_x", weights, self.nrows())?;
3334        let mut xtwx = Array2::<f64>::zeros((self.p, self.p));
3335        for (i, ch) in self.channels.iter().enumerate() {
3336            let channel_weights = weights.slice(s![i * n..(i + 1) * n]).to_owned();
3337            let ch_xtwx = ch.diag_xtw_x(&channel_weights)?;
3338            xtwx += &ch_xtwx;
3339        }
3340        Ok(xtwx)
3341    }
3342
3343    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
3344        let n = self.n_per_channel;
3345        certify_signed_weights("MultiChannelOperator::diag_gram", weights, self.nrows())?;
3346        let mut diag = Array1::<f64>::zeros(self.p);
3347        for (i, ch) in self.channels.iter().enumerate() {
3348            diag += &ch.diag_gram_view(weights.slice(s![i * n..(i + 1) * n]))?;
3349        }
3350        Ok(diag)
3351    }
3352
3353    fn uses_matrix_free_pcg(&self) -> bool {
3354        true
3355    }
3356}
3357
3358impl DenseDesignOperator for MultiChannelOperator {
3359    fn materialization_policy(&self) -> Option<MaterializationPolicy> {
3360        self.channels.iter().fold(None, |policy, channel| {
3361            merge_operator_materialization_policies(policy, channel.materialization_policy())
3362        })
3363    }
3364
3365    fn compute_xtwy(&self, weights: &Array1<f64>, y: &Array1<f64>) -> Result<Array1<f64>, String> {
3366        let n = self.n_per_channel;
3367        let total = self.nrows();
3368        if weights.len() != total || y.len() != total {
3369            return Err(format!(
3370                "MultiChannelOperator::compute_xtwy: weights={}, y={}, nrows={}",
3371                weights.len(),
3372                y.len(),
3373                total
3374            ));
3375        }
3376        certify_signed_weights("MultiChannelOperator::compute_xtwy", weights, total)?;
3377        let mut out = Array1::<f64>::zeros(self.p);
3378        for (i, ch) in self.channels.iter().enumerate() {
3379            out += &ch.compute_xtwy_view(
3380                weights.slice(s![i * n..(i + 1) * n]),
3381                y.slice(s![i * n..(i + 1) * n]),
3382            )?;
3383        }
3384        Ok(out)
3385    }
3386
3387    fn quadratic_form_diag(&self, middle: &Array2<f64>) -> Result<Array1<f64>, String> {
3388        let n = self.n_per_channel;
3389        let mut out = Array1::<f64>::zeros(self.nrows());
3390        for (i, ch) in self.channels.iter().enumerate() {
3391            let ch_diag = ch.quadratic_form_diag(middle)?;
3392            out.slice_mut(s![i * n..(i + 1) * n]).assign(&ch_diag);
3393        }
3394        Ok(out)
3395    }
3396
3397    fn to_dense(&self) -> Array2<f64> {
3398        let total = self.nrows();
3399        let n = self.n_per_channel;
3400        let mut out = Array2::<f64>::zeros((total, self.p));
3401        for (i, ch) in self.channels.iter().enumerate() {
3402            let dense = ch.to_dense();
3403            out.slice_mut(s![i * n..(i + 1) * n, ..]).assign(&dense);
3404        }
3405        out
3406    }
3407
3408    fn row_chunk_into(
3409        &self,
3410        rows: Range<usize>,
3411        mut out: ArrayViewMut2<'_, f64>,
3412    ) -> Result<(), MatrixMaterializationError> {
3413        if out.nrows() != rows.end - rows.start || out.ncols() != self.p {
3414            return Err(MatrixMaterializationError::MissingRowChunk {
3415                context: "MultiChannelOperator::row_chunk_into shape mismatch",
3416            });
3417        }
3418        let n = self.n_per_channel;
3419        let mut local = 0usize;
3420        let mut global = rows.start;
3421        while global < rows.end {
3422            let ch_idx = global / n;
3423            let ch_local_start = global % n;
3424            let ch_local_end = ((ch_idx + 1) * n).min(rows.end) - ch_idx * n;
3425            let segment_len = ch_local_end - ch_local_start;
3426            self.channels[ch_idx].row_chunk_into(
3427                ch_local_start..ch_local_end,
3428                out.slice_mut(s![local..local + segment_len, ..]),
3429            )?;
3430            local += segment_len;
3431            global += segment_len;
3432        }
3433        Ok(())
3434    }
3435}
3436
3437// Rowwise-Kronecker + tensor-product design operators (#1145): see `kronecker.rs`.
3438mod kronecker;
3439pub use kronecker::*;
3440
3441/// Coefficient-side transform operator: represents X_eff = X_inner * T without
3442/// materializing the product. Preserves the sparsity/operator structure of the
3443/// inner design by applying T on the coefficient side:
3444///   apply(v) = X_inner * (T * v)
3445///   apply_transpose(v) = T^T * (X_inner^T * v)
3446///   diag_xtw_x(w) = T^T * (X_inner^T W X_inner) * T
3447pub struct CoefficientTransformOperator {
3448    inner: DenseDesignMatrix,
3449    transform: Arc<Array2<f64>>,
3450    n: usize,
3451    p_out: usize,
3452    /// One-time-materialized X · T dense block for an already-materialized
3453    /// inner design. An operator-backed inner has already been routed to a
3454    /// bounded-memory representation, so it must never populate this cache.
3455    materialized: OnceLock<Option<Arc<Array2<f64>>>>,
3456}
3457
3458impl CoefficientTransformOperator {
3459    /// Maximum bytes for the one-shot X · T materialization of an
3460    /// already-materialized inner design.
3461    const MATERIALIZE_MAX_BYTES: usize = 1024 * 1024 * 1024;
3462
3463    pub fn new(inner: DenseDesignMatrix, transform: Array2<f64>) -> Result<Self, String> {
3464        let p_inner = inner.ncols();
3465        if transform.nrows() != p_inner {
3466            return Err(format!(
3467                "CoefficientTransformOperator: inner has {} cols but transform has {} rows",
3468                p_inner,
3469                transform.nrows(),
3470            ));
3471        }
3472        let n = inner.nrows();
3473        let p_out = transform.ncols();
3474        Ok(Self {
3475            inner,
3476            transform: Arc::new(transform),
3477            n,
3478            p_out,
3479            materialized: OnceLock::new(),
3480        })
3481    }
3482
3483    /// Get-or-build the materialized X · T dense block. Operator-backed
3484    /// inputs return `None` unconditionally: a coefficient transform preserves
3485    /// the inner design's lazy storage decision instead of bypassing it through
3486    /// an unrelated local byte ceiling.
3487    fn materialized_combined(&self) -> Option<&Array2<f64>> {
3488        if let Some(slot) = self.materialized.get() {
3489            return slot.as_ref().map(|a| a.as_ref());
3490        }
3491        if self.inner.is_operator_backed() {
3492            if self.materialized.set(None).is_err() {
3493                return self
3494                    .materialized
3495                    .get()
3496                    .and_then(|opt| opt.as_ref().map(|a| a.as_ref()));
3497            }
3498            return None;
3499        }
3500        let bytes = self
3501            .n
3502            .checked_mul(self.p_out)
3503            .and_then(|cells| cells.checked_mul(std::mem::size_of::<f64>()));
3504        let computed = match bytes {
3505            Some(b) if b <= Self::MATERIALIZE_MAX_BYTES => self
3506                .inner
3507                .as_dense_ref()
3508                .map(|x| Arc::new(fast_ab(x, &self.transform))),
3509            _ => None,
3510        };
3511        if self.materialized.set(computed).is_err() {
3512            return self
3513                .materialized
3514                .get()
3515                .and_then(|opt| opt.as_ref().map(|a| a.as_ref()));
3516        }
3517        self.materialized
3518            .get()
3519            .and_then(|opt| opt.as_ref().map(|a| a.as_ref()))
3520    }
3521}
3522
3523impl LinearOperator for CoefficientTransformOperator {
3524    fn nrows(&self) -> usize {
3525        self.n
3526    }
3527    fn ncols(&self) -> usize {
3528        self.p_out
3529    }
3530    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
3531        if let Some(combined) = self.materialized_combined() {
3532            return fast_av(combined, vector);
3533        }
3534        let tv = fast_av(&self.transform, vector);
3535        self.inner.apply(&tv)
3536    }
3537    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
3538        if let Some(combined) = self.materialized_combined() {
3539            return fast_atv(combined, vector);
3540        }
3541        let xtv = self.inner.apply_transpose(vector);
3542        fast_atv(&self.transform, &xtv)
3543    }
3544    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
3545        certify_signed_weights("CoefficientTransformOperator::diag_xtw_x", weights, self.n)?;
3546        if let Some(combined) = self.materialized_combined() {
3547            let mut xtwx = Array2::<f64>::zeros((self.p_out, self.p_out));
3548            stream_weighted_crossprod_into(
3549                combined,
3550                weights,
3551                &mut xtwx,
3552                CrossprodStructure::Full,
3553                CrossprodAccum::Replace,
3554                effective_global_parallelism(),
3555            );
3556            return Ok(xtwx);
3557        }
3558        let inner_xtwx = self.inner.diag_xtw_x(weights)?;
3559        // T^T * (X^T W X) * T
3560        let tmp = fast_ab(&self.transform.t().to_owned(), &inner_xtwx);
3561        Ok(fast_ab(&tmp, &self.transform))
3562    }
3563}
3564
3565impl DenseDesignOperator for CoefficientTransformOperator {
3566    /// Expose the cached X·T materialization when populated. This is what lets
3567    /// `BlockDesignOperator::cross_block` recognize a Dense × Dense pair and
3568    /// route to `weighted_crossprod_dense` (BLAS-3 GEMM) instead of the
3569    /// scalar `weighted_cross_chunked` triple loop. Without this override the
3570    /// default trait impl returns `None`, the fast path is skipped, and a
3571    /// 4-block large-scale fit (pgs + sex + smooth_age + duchon) pays a 24 s
3572    /// cross-block cost per PIRLS curvature build.
3573    fn as_dense_ref(&self) -> Option<&Array2<f64>> {
3574        self.materialized_combined()
3575    }
3576
3577    fn materialization_policy(&self) -> Option<MaterializationPolicy> {
3578        self.inner.materialization_policy()
3579    }
3580
3581    fn to_dense(&self) -> Array2<f64> {
3582        if let Some(combined) = self.materialized_combined() {
3583            return combined.clone();
3584        }
3585        let x = self.inner.to_dense();
3586        fast_ab(&x, &self.transform)
3587    }
3588    fn row_chunk_into(
3589        &self,
3590        rows: Range<usize>,
3591        mut out: ArrayViewMut2<'_, f64>,
3592    ) -> Result<(), MatrixMaterializationError> {
3593        if out.nrows() != rows.end - rows.start || out.ncols() != self.p_out {
3594            return Err(MatrixMaterializationError::MissingRowChunk {
3595                context: "CoefficientTransformOperator::row_chunk_into shape mismatch",
3596            });
3597        }
3598        if let Some(combined) = self.materialized_combined() {
3599            out.assign(&combined.slice(s![rows, ..]));
3600            return Ok(());
3601        }
3602        let chunk = self.inner.try_row_chunk(rows)?;
3603        out.assign(&fast_ab(&chunk, &self.transform));
3604        Ok(())
3605    }
3606}
3607
3608// The subtract-form residualised design `C_b · V_b − Σ_{a<b} A_a · R_{a,b}` is
3609// deliberately NOT built at this layer (gam#2467). Two production homes own it:
3610//
3611//  * the block-triangular lift `T` (V_b on the diagonal, −R_{a→b} above) is
3612//    assembled and applied by `gam_problem::gauge` — `assemble_block_triangular_t`,
3613//    `Gauge::from_v_and_r`, `Gauge::restrict_design`. That is where a cross-block
3614//    anchor correction belongs, because block ordering and gauge priority are
3615//    concepts this crate does not have;
3616//  * the one row-evaluator that subtracts an anchor contribution is BMS
3617//    `DeviationRuntime::design_with_anchor_rows` (gam-models), which is eager and
3618//    dense over a single stacked anchor.
3619//
3620// What reaches gam-linalg is drop-based, not subtract-based: cross-block reduction
3621// arrives already folded into a per-block `V_b` and is applied by
3622// `CoefficientTransformOperator` above.
3623
3624// ---------------------------------------------------------------------------
3625// ConditionedDesign — lazy per-column affine transform
3626// ---------------------------------------------------------------------------
3627
3628/// A design matrix wrapper that lazily applies per-column centering and scaling
3629/// without materializing a new dense matrix.
3630///
3631/// For each conditioned column `j`, the effective column is
3632/// `(X[:,j] - mean_j) / scale_j`.  All other columns pass through unchanged.
3633/// Algebraically this is `X·diag(a) - 1·d'` where `a[j] = 1/scale` for
3634/// conditioned columns (1 otherwise) and `d[j] = mean/scale` for conditioned
3635/// columns (0 otherwise).
3636pub struct ConditionedDesign {
3637    inner: DesignMatrix,
3638    /// Per-conditioned-column: (global_col_idx, mean, scale).
3639    columns: Vec<(usize, f64, f64)>,
3640}
3641
3642impl ConditionedDesign {
3643    pub fn new(inner: DesignMatrix, columns: Vec<(usize, f64, f64)>) -> Self {
3644        Self { inner, columns }
3645    }
3646}
3647
3648impl LinearOperator for ConditionedDesign {
3649    fn nrows(&self) -> usize {
3650        self.inner.nrows()
3651    }
3652
3653    fn ncols(&self) -> usize {
3654        self.inner.ncols()
3655    }
3656
3657    /// X_c v = X(a⊙v) - (d·v)·1
3658    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
3659        let mut scaled = vector.clone();
3660        let mut shift = 0.0;
3661        for &(j, mean, scale) in &self.columns {
3662            scaled[j] /= scale;
3663            shift += mean * scaled[j];
3664        }
3665        let mut result = self.inner.apply(&scaled);
3666        if shift != 0.0 {
3667            result.mapv_inplace(|v| v - shift);
3668        }
3669        result
3670    }
3671
3672    /// X_c'u = a⊙(X'u) - d·Σu
3673    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
3674        let mut result = self.inner.apply_transpose(vector);
3675        let sum_u: f64 = vector.iter().sum();
3676        for &(j, mean, scale) in &self.columns {
3677            result[j] = (result[j] - mean * sum_u) / scale;
3678        }
3679        result
3680    }
3681
3682    /// X_c'WX_c = D_a(X'WX)D_a - D_a(X'w)d' - d(X'w)'D_a + Σw·dd'
3683    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
3684        certify_signed_weights("ConditionedDesign::diag_xtw_x", weights, self.nrows())?;
3685        let mut base = self.inner.diag_xtw_x(weights)?;
3686        if self.columns.is_empty() {
3687            return Ok(base);
3688        }
3689        let p = base.ncols();
3690        let sum_w: f64 = weights.sum();
3691        let cw = self.inner.apply_transpose(weights);
3692
3693        // Precompute a[j] and d[j] for all columns.
3694        let mut a = vec![1.0_f64; p];
3695        let mut d = vec![0.0_f64; p];
3696        for &(j, mean, scale) in &self.columns {
3697            a[j] = 1.0 / scale;
3698            d[j] = mean / scale;
3699        }
3700
3701        // Apply the full transformation in one pass (symmetric).
3702        for i in 0..p {
3703            for j in i..p {
3704                let val = a[i] * base[[i, j]] * a[j] - a[i] * cw[i] * d[j] - d[i] * cw[j] * a[j]
3705                    + sum_w * d[i] * d[j];
3706                base[[i, j]] = val;
3707                base[[j, i]] = val;
3708            }
3709        }
3710        Ok(base)
3711    }
3712
3713    /// Diagonal of X_c'WX_c — only conditioned columns change.
3714    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
3715        certify_signed_weights("ConditionedDesign::diag_gram", weights, self.nrows())?;
3716        let mut result = self.inner.diag_gram(weights)?;
3717        if self.columns.is_empty() {
3718            return Ok(result);
3719        }
3720        let sum_w: f64 = weights.sum();
3721        let cw = self.inner.apply_transpose(weights);
3722        for &(j, mean, scale) in &self.columns {
3723            let a_j = 1.0 / scale;
3724            let d_j = mean / scale;
3725            result[j] = a_j * a_j * result[j] - 2.0 * a_j * cw[j] * d_j + sum_w * d_j * d_j;
3726        }
3727        Ok(result)
3728    }
3729
3730    fn uses_matrix_free_pcg(&self) -> bool {
3731        match &self.inner {
3732            DesignMatrix::Dense(_) => true,
3733            DesignMatrix::Sparse(_) => false,
3734        }
3735    }
3736}
3737
3738impl DenseDesignOperator for ConditionedDesign {
3739    fn materialization_policy(&self) -> Option<MaterializationPolicy> {
3740        self.inner.materialization_policy()
3741    }
3742
3743    /// X_c'(w⊙y) = a⊙(X'(w⊙y)) - d·Σ(w⊙y)
3744    fn compute_xtwy(&self, weights: &Array1<f64>, y: &Array1<f64>) -> Result<Array1<f64>, String> {
3745        if y.len() != self.nrows() {
3746            return Err(format!(
3747                "ConditionedDesign::compute_xtwy response length mismatch: y={}, nrows={}",
3748                y.len(),
3749                self.nrows()
3750            ));
3751        }
3752        certify_signed_weights("ConditionedDesign::compute_xtwy", weights, self.nrows())?;
3753        let mut result = self.inner.compute_xtwy(weights, y)?;
3754        if self.columns.is_empty() {
3755            return Ok(result);
3756        }
3757        let sum_wy: f64 = weights.iter().zip(y.iter()).map(|(&w, &yi)| w * yi).sum();
3758        for &(j, mean, scale) in &self.columns {
3759            result[j] = (result[j] - mean * sum_wy) / scale;
3760        }
3761        Ok(result)
3762    }
3763
3764    /// diag(X_c M X_c') = diag(X(D_a M D_a)X') - 2·X(D_a M d) + d'Md
3765    fn quadratic_form_diag(&self, middle: &Array2<f64>) -> Result<Array1<f64>, String> {
3766        if self.columns.is_empty() {
3767            return self.inner.quadratic_form_diag(middle);
3768        }
3769        let p = self.ncols();
3770        let mut d = Array1::zeros(p);
3771        for &(j, mean, scale) in &self.columns {
3772            d[j] = mean / scale;
3773        }
3774
3775        // D_a M D_a: scale rows and columns for conditioned indices.
3776        let mut ama = middle.clone();
3777        for &(j, _, scale) in &self.columns {
3778            for k in 0..p {
3779                ama[[j, k]] /= scale;
3780                ama[[k, j]] /= scale;
3781            }
3782        }
3783
3784        // D_a M d
3785        let md = middle.dot(&d);
3786        let mut amd = md;
3787        for &(j, _, scale) in &self.columns {
3788            amd[j] /= scale;
3789        }
3790
3791        let dtmd: f64 = d.dot(&middle.dot(&d));
3792
3793        let mut result = self.inner.quadratic_form_diag(&ama)?;
3794        let x_amd = self.inner.apply(&amd);
3795        for i in 0..result.len() {
3796            result[i] = (result[i] - 2.0 * x_amd[i] + dtmd).max(0.0);
3797        }
3798        Ok(result)
3799    }
3800
3801    fn row_chunk_into(
3802        &self,
3803        rows: Range<usize>,
3804        mut out: ArrayViewMut2<'_, f64>,
3805    ) -> Result<(), MatrixMaterializationError> {
3806        if out.nrows() != rows.end - rows.start || out.ncols() != self.ncols() {
3807            return Err(MatrixMaterializationError::MissingRowChunk {
3808                context: "ConditionedDesign::row_chunk_into shape mismatch",
3809            });
3810        }
3811        let mut chunk = self.inner.try_row_chunk(rows)?;
3812        for &(j, mean, scale) in &self.columns {
3813            chunk.column_mut(j).mapv_inplace(|v| (v - mean) / scale);
3814        }
3815        out.assign(&chunk);
3816        Ok(())
3817    }
3818
3819    fn to_dense(&self) -> Array2<f64> {
3820        let mut dense = self.inner.to_dense();
3821        for &(j, mean, scale) in &self.columns {
3822            dense.column_mut(j).mapv_inplace(|v| (v - mean) / scale);
3823        }
3824        dense
3825    }
3826}
3827
3828/// Unified design matrix representation for dense and sparse workflows.
3829///
3830/// Dense matrices are wrapped in Arc for O(1) cloning — at large scale
3831/// design matrices are 100-500MB and get cloned repeatedly during GAMLSS
3832/// family construction, warm-start caching, and prediction.
3833///
3834/// The `Dense` variant wraps both materialized dense matrices and lazy
3835/// dense-backed operators (`DenseDesignMatrix::Lazy`) that implement
3836/// `DenseDesignOperator` without reopening a third top-level storage state.
3837#[derive(Clone)]
3838pub enum DesignMatrix {
3839    Dense(DenseDesignMatrix),
3840    Sparse(SparseDesignMatrix),
3841}
3842
3843impl std::fmt::Debug for DesignMatrix {
3844    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3845        match self {
3846            Self::Dense(m) => write!(f, "DesignMatrix::Dense({}x{})", m.nrows(), m.ncols()),
3847            Self::Sparse(s) => write!(f, "DesignMatrix::Sparse({}x{})", s.nrows(), s.ncols()),
3848        }
3849    }
3850}
3851
3852// Symmetric-matrix container + Gram assembly (#1145): see `symmetric.rs`.
3853mod symmetric;
3854pub use symmetric::*;
3855
3856/// A 64-bit FNV-1a fingerprint of a dense matrix's shape and every element's
3857/// bit pattern, in row-major order.
3858///
3859/// This is an identity of the VALUES, which is what a cache keyed on a matrix
3860/// needs. A heap address is not one: an allocation is freed and reused, and a
3861/// same-shaped matrix that lands at the same address is a different matrix
3862/// with the same key (gam#2515). The pass is `O(n·p)`, negligible beside the
3863/// `O(n·p²)` products such caches exist to skip, and bit-exact: `-0.0` and
3864/// `+0.0` differ, every NaN payload differs, exactly as the arithmetic that
3865/// consumes the matrix would see them.
3866pub fn array2_bits_fingerprint(matrix: &ndarray::Array2<f64>) -> u64 {
3867    let mut hash = Fnv1a::new();
3868    hash.absorb(matrix.nrows() as u64);
3869    hash.absorb(matrix.ncols() as u64);
3870    for value in matrix.iter() {
3871        hash.absorb(value.to_bits());
3872    }
3873    hash.finish()
3874}
3875
3876/// FNV-1a over little-endian `u64` words; the one hash every value identity in
3877/// this module is built from, so two identities of the same bytes agree.
3878struct Fnv1a(u64);
3879
3880impl Fnv1a {
3881    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
3882    const PRIME: u64 = 0x0000_0100_0000_01b3;
3883
3884    fn new() -> Self {
3885        Self(Self::OFFSET)
3886    }
3887
3888    fn absorb(&mut self, value: u64) {
3889        for byte in value.to_le_bytes() {
3890            self.0 ^= u64::from(byte);
3891            self.0 = self.0.wrapping_mul(Self::PRIME);
3892        }
3893    }
3894
3895    fn finish(self) -> u64 {
3896        self.0
3897    }
3898}
3899
3900impl SparseDesignMatrix {
3901    /// A 64-bit fingerprint of this sparse design's VALUES: shape, column
3902    /// pointers, row indices and every stored value's bit pattern. The sparse
3903    /// analogue of [`array2_bits_fingerprint`], for the same reason: a cache
3904    /// token that was this struct's address named an allocation, not a matrix
3905    /// (gam#2515).
3906    pub fn value_fingerprint(&self) -> u64 {
3907        let (symbolic, values) = self.matrix.parts();
3908        let mut hash = Fnv1a::new();
3909        hash.absorb(self.matrix.nrows() as u64);
3910        hash.absorb(self.matrix.ncols() as u64);
3911        for &offset in symbolic.col_ptr() {
3912            hash.absorb(offset as u64);
3913        }
3914        for &row in symbolic.row_idx() {
3915            hash.absorb(row as u64);
3916        }
3917        for value in values {
3918            hash.absorb(value.to_bits());
3919        }
3920        hash.finish()
3921    }
3922}
3923/// A generic abstraction over a factorized symmetric positive-definite (or regularized) system.
3924pub trait FactorizedSystem: Send + Sync {
3925    /// Solve $H x = b$ for a single right-hand side.
3926    fn solve(&self, rhs: &Array1<f64>) -> Result<Array1<f64>, String>;
3927
3928    /// Solve $H X = B$ for multiple right-hand sides.
3929    fn solvemulti(&self, rhs: &Array2<f64>) -> Result<Array2<f64>, String>;
3930
3931    /// Return the log-determinant of the factorized matrix.
3932    fn logdet(&self) -> f64;
3933}
3934
3935pub trait LinearOperator {
3936    fn nrows(&self) -> usize;
3937    fn ncols(&self) -> usize;
3938    fn apply(&self, vector: &Array1<f64>) -> Array1<f64>;
3939    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64>;
3940    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String>;
3941
3942    /// Observed-Hessian / non-canonical-link Gram: `XᵀWX` with sign-honest
3943    /// weights. Returns a dense `Array2<f64>` because the result is symmetric
3944    /// but not guaranteed PSD (so consumers cannot assume the `SymmetricMatrix`
3945    /// PSD contract). Default impl delegates to `diag_xtw_x` for legacy
3946    /// operators; overriding impls may take a sign-aware fast path.
3947    fn xt_diag_x_signed_op(
3948        &self,
3949        weights: FiniteSignedWeightsView<'_>,
3950    ) -> Result<Array2<f64>, String> {
3951        self.diag_xtw_x(&weights.view().to_owned())
3952    }
3953
3954    /// PSD-precondition Gram: `XᵀWX` with `w ≥ 0` discharged at the
3955    /// `PsdWeightsView` constructor. Returns a typed `SymmetricMatrix` so
3956    /// downstream consumers can route through PSD-only solvers (Cholesky).
3957    /// Default impl wraps the signed path's `Array2` in `SymmetricMatrix::Dense`.
3958    fn xt_diag_x_psd_op(&self, weights: PsdWeightsView<'_>) -> Result<SymmetricMatrix, String> {
3959        FiniteSignedWeightsView::try_new(weights.view())
3960            .map_err(|reason| format!("LinearOperator::xt_diag_x_psd_op: {reason}"))?;
3961        let xtwx = self.diag_xtw_x(&weights.view().to_owned())?;
3962        Ok(SymmetricMatrix::Dense(xtwx))
3963    }
3964
3965    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
3966        let xtwx = self.diag_xtw_x(weights)?;
3967        Ok(Array1::from_iter((0..self.ncols()).map(|j| xtwx[[j, j]])))
3968    }
3969    fn apply_weighted_normal(
3970        &self,
3971        weights: FiniteSignedWeightsView<'_>,
3972        vector: &Array1<f64>,
3973        penalty: Option<&Array2<f64>>,
3974        ridge: f64,
3975    ) -> Array1<f64> {
3976        assert_eq!(
3977            weights.len(),
3978            self.nrows(),
3979            "apply_weighted_normal weight length mismatch"
3980        );
3981        assert_eq!(
3982            vector.len(),
3983            self.ncols(),
3984            "apply_weighted_normal vector length mismatch"
3985        );
3986        let weights = weights.view();
3987        let xv = self.apply(vector);
3988        let mut weighted_xv = xv;
3989        for i in 0..weighted_xv.len() {
3990            weighted_xv[i] *= weights[i];
3991        }
3992        let mut out = self.apply_transpose(&weighted_xv);
3993        if let Some(pen) = penalty {
3994            out += &fast_av(pen, vector);
3995        }
3996        if ridge > 0.0 {
3997            // BLAS axpy: out += ridge * vector, no temporary allocation.
3998            out.scaled_add(ridge, vector);
3999        }
4000        out
4001    }
4002    fn uses_matrix_free_pcg(&self) -> bool {
4003        false
4004    }
4005    fn solve_system_matrix_free_pcg_try(
4006        &self,
4007        weights: &Array1<f64>,
4008        rhs: &Array1<f64>,
4009        penalty: Option<&Array2<f64>>,
4010        baseridge: f64,
4011    ) -> Result<Array1<f64>, String> {
4012        self.solve_system_matrix_free_pcg_with_info_try(weights, rhs, penalty, baseridge)
4013            .map(|(solution, _)| solution)
4014    }
4015    fn solve_system_matrix_free_pcg_with_info_try(
4016        &self,
4017        weights: &Array1<f64>,
4018        rhs: &Array1<f64>,
4019        penalty: Option<&Array2<f64>>,
4020        baseridge: f64,
4021    ) -> Result<(Array1<f64>, PcgSolveInfo), String> {
4022        if rhs.len() != self.ncols() {
4023            return Err(format!(
4024                "solve_system_matrix_free_pcg rhs dimension mismatch: rhs length {} != ncols {}",
4025                rhs.len(),
4026                self.ncols()
4027            ));
4028        }
4029        if !self.uses_matrix_free_pcg() {
4030            return Err("matrix-free PCG is only enabled for eligible operator types".to_string());
4031        }
4032        if let Some(pen) = penalty
4033            && (pen.nrows() != self.ncols() || pen.ncols() != self.ncols())
4034        {
4035            return Err(format!(
4036                "solve_system_matrix_free_pcg penalty shape mismatch: got {}x{}, expected {}x{}",
4037                pen.nrows(),
4038                pen.ncols(),
4039                self.ncols(),
4040                self.ncols()
4041            ));
4042        }
4043        let p = self.ncols();
4044        let finite_weights = certify_signed_weights(
4045            "solve_system_matrix_free_pcg_with_info_try",
4046            weights,
4047            self.nrows(),
4048        )?;
4049        if !(baseridge.is_finite() && baseridge >= 0.0) {
4050            return Err(format!(
4051                "matrix-free PCG ridge must be finite and non-negative, got {baseridge:?}"
4052            ));
4053        }
4054        let normal_op = PenalizedWeightedNormalOperator {
4055            operator: self,
4056            weights,
4057            finite_weights,
4058            penalty,
4059            ridge: baseridge,
4060        };
4061        let preconditioner = normal_op.jacobi_preconditioner()?;
4062        let attempt_started = std::time::Instant::now();
4063        let (solution, info) = crate::utils::solve_spd_pcg_with_info(
4064            |v| normal_op.apply(v),
4065            rhs,
4066            &preconditioner,
4067            MATRIX_FREE_PCG_REL_TOL,
4068            MATRIX_FREE_PCG_MAX_ITER.max(4 * p),
4069        )
4070        .ok_or_else(|| {
4071            format!("matrix-free PCG broke down for explicitly requested ridge {baseridge:.3e}")
4072        })?;
4073        if !solution.iter().all(|value| value.is_finite()) {
4074            return Err("matrix-free PCG produced a non-finite solution".to_string());
4075        }
4076        log::debug!(
4077            "[matrix-free PCG] solved: p={p} ridge={baseridge:.3e} iters={} converged={} rel_resid={:.3e} elapsed={:.3}s",
4078            info.iterations,
4079            info.converged,
4080            info.relative_residual_norm,
4081            attempt_started.elapsed().as_secs_f64(),
4082        );
4083        Ok((solution, info))
4084    }
4085    fn factorize_system(
4086        &self,
4087        weights: &Array1<f64>,
4088        penalty: Option<&Array2<f64>>,
4089    ) -> Result<Box<dyn FactorizedSystem>, String> {
4090        let mut system = self.diag_xtw_x(weights)?;
4091        if let Some(pen) = penalty {
4092            if pen.nrows() != system.nrows() || pen.ncols() != system.ncols() {
4093                return Err(format!(
4094                    "factorize_system penalty shape mismatch: got {}x{}, expected {}x{}",
4095                    pen.nrows(),
4096                    pen.ncols(),
4097                    system.nrows(),
4098                    system.ncols()
4099                ));
4100            }
4101            system += pen;
4102        }
4103        let factor = crate::utils::StableSolver::new()
4104            .factorize(&system)
4105            .map_err(|e| format!("factorize_system failed: {e:?}"))?;
4106        Ok(Box::new(factor))
4107    }
4108    fn solve_system(
4109        &self,
4110        weights: &Array1<f64>,
4111        rhs: &Array1<f64>,
4112        penalty: Option<&Array2<f64>>,
4113    ) -> Result<Array1<f64>, String> {
4114        self.solve_systemwith_policy(weights, rhs, penalty, 0.0, RidgePolicy::solver_only())
4115    }
4116    fn solve_systemwith_policy(
4117        &self,
4118        weights: &Array1<f64>,
4119        rhs: &Array1<f64>,
4120        penalty: Option<&Array2<f64>>,
4121        ridge_floor: f64,
4122        ridge_policy: RidgePolicy,
4123    ) -> Result<Array1<f64>, String> {
4124        if rhs.len() != self.ncols() {
4125            return Err(format!(
4126                "solve_systemwith_policy rhs dimension mismatch: rhs length {} != ncols {}",
4127                rhs.len(),
4128                self.ncols()
4129            ));
4130        }
4131        if !(ridge_floor.is_finite() && ridge_floor >= 0.0) {
4132            return Err(format!(
4133                "solve_systemwith_policy ridge floor must be finite and non-negative, got {ridge_floor:?}"
4134            ));
4135        }
4136        let ridge = ridge_floor;
4137        // The size policy selects exactly one algorithm. A failed matrix-free
4138        // solve is surfaced; silently switching algorithms or escalating ridge
4139        // would change both performance and the solved system.
4140        if self.uses_matrix_free_pcg() && self.ncols() >= MATRIX_FREE_PCG_MIN_P {
4141            return self.solve_system_matrix_free_pcg_try(weights, rhs, penalty, ridge);
4142        }
4143        let mut system = self.diag_xtw_x(weights)?;
4144        if let Some(pen) = penalty {
4145            if pen.nrows() != system.nrows() || pen.ncols() != system.ncols() {
4146                return Err(format!(
4147                    "solve_systemwith_policy penalty shape mismatch: got {}x{}, expected {}x{}",
4148                    pen.nrows(),
4149                    pen.ncols(),
4150                    system.nrows(),
4151                    system.ncols()
4152                ));
4153            }
4154            system += pen;
4155        }
4156        if ridge > 0.0 {
4157            for diagonal in 0..system.nrows() {
4158                system[[diagonal, diagonal]] += ridge;
4159            }
4160        }
4161        let factor = crate::utils::StableSolver::new()
4162            .factorize(&system)
4163            .map_err(|error| {
4164                format!(
4165                    "solve_systemwith_policy ({ridge_policy:?}) exact factorization failed at ridge {ridge:.3e}: {error:?}"
4166                )
4167            })?;
4168        let mut solution = rhs.clone();
4169        let mut solution_matrix = crate::faer_ndarray::array1_to_col_matmut(&mut solution);
4170        factor.solve_in_place(solution_matrix.as_mut());
4171        if solution.iter().all(|value| value.is_finite()) {
4172            Ok(solution)
4173        } else {
4174            Err("solve_systemwith_policy produced a non-finite solution".to_string())
4175        }
4176    }
4177}
4178
4179impl LinearOperator for DesignMatrix {
4180    fn uses_matrix_free_pcg(&self) -> bool {
4181        match self {
4182            Self::Dense(matrix) => matrix.uses_matrix_free_pcg(),
4183            Self::Sparse(_) => false,
4184        }
4185    }
4186
4187    fn nrows(&self) -> usize {
4188        match self {
4189            Self::Dense(matrix) => matrix.nrows(),
4190            Self::Sparse(matrix) => matrix.nrows(),
4191        }
4192    }
4193
4194    fn ncols(&self) -> usize {
4195        match self {
4196            Self::Dense(matrix) => matrix.ncols(),
4197            Self::Sparse(matrix) => matrix.ncols(),
4198        }
4199    }
4200
4201    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
4202        match self {
4203            Self::Dense(matrix) => matrix.apply(vector),
4204            Self::Sparse(matrix) => {
4205                let mut output = Array1::<f64>::zeros(matrix.nrows());
4206                let (symbolic, values) = matrix.parts();
4207                let col_ptr = symbolic.col_ptr();
4208                let row_idx = symbolic.row_idx();
4209                for col in 0..matrix.ncols() {
4210                    let start = col_ptr[col];
4211                    let end = col_ptr[col + 1];
4212                    let x = vector[col];
4213                    for idx in start..end {
4214                        let row = row_idx[idx];
4215                        output[row] += values[idx] * x;
4216                    }
4217                }
4218                output
4219            }
4220        }
4221    }
4222
4223    fn apply_weighted_normal(
4224        &self,
4225        weights: FiniteSignedWeightsView<'_>,
4226        vector: &Array1<f64>,
4227        penalty: Option<&Array2<f64>>,
4228        ridge: f64,
4229    ) -> Array1<f64> {
4230        assert_eq!(
4231            weights.len(),
4232            self.nrows(),
4233            "DesignMatrix::apply_weighted_normal weight length mismatch"
4234        );
4235        assert_eq!(
4236            vector.len(),
4237            self.ncols(),
4238            "DesignMatrix::apply_weighted_normal vector length mismatch"
4239        );
4240        let weights_view = weights.view();
4241        match self {
4242            Self::Dense(matrix) => matrix.apply_weighted_normal(weights, vector, penalty, ridge),
4243            Self::Sparse(_) => {
4244                let sparse = self
4245                    .as_sparse()
4246                    .expect("DesignMatrix::Sparse must expose sparse view");
4247                let mut out = if let Some(csr) = sparse.to_csr_arc() {
4248                    let sym = csr.symbolic();
4249                    let row_ptr = sym.row_ptr();
4250                    let col_idx = sym.col_idx();
4251                    let vals = csr.val();
4252                    let mut fused = Array1::<f64>::zeros(self.ncols());
4253                    for i in 0..self.nrows() {
4254                        let wi = weights_view[i];
4255                        if wi == 0.0 {
4256                            continue;
4257                        }
4258                        let start = row_ptr[i];
4259                        let end = row_ptr[i + 1];
4260                        let mut row_dot = 0.0_f64;
4261                        for ptr in start..end {
4262                            row_dot += vals[ptr] * vector[col_idx[ptr]];
4263                        }
4264                        if row_dot == 0.0 {
4265                            continue;
4266                        }
4267                        let scaled = wi * row_dot;
4268                        for ptr in start..end {
4269                            fused[col_idx[ptr]] += vals[ptr] * scaled;
4270                        }
4271                    }
4272                    fused
4273                } else {
4274                    let xv = self.apply(vector);
4275                    let mut weighted_xv = xv;
4276                    for i in 0..weighted_xv.len() {
4277                        weighted_xv[i] *= weights_view[i];
4278                    }
4279                    self.apply_transpose(&weighted_xv)
4280                };
4281                if let Some(pen) = penalty {
4282                    out += &fast_av(pen, vector);
4283                }
4284                if ridge > 0.0 {
4285                    for j in 0..out.len() {
4286                        out[j] += ridge * vector[j];
4287                    }
4288                }
4289                out
4290            }
4291        }
4292    }
4293
4294    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
4295        match self {
4296            Self::Dense(matrix) => matrix.apply_transpose(vector),
4297            Self::Sparse(matrix) => {
4298                let mut output = Array1::<f64>::zeros(matrix.ncols());
4299                let (symbolic, values) = matrix.parts();
4300                let col_ptr = symbolic.col_ptr();
4301                let row_idx = symbolic.row_idx();
4302                for col in 0..matrix.ncols() {
4303                    let mut acc = 0.0;
4304                    let start = col_ptr[col];
4305                    let end = col_ptr[col + 1];
4306                    for idx in start..end {
4307                        let row = row_idx[idx];
4308                        acc += values[idx] * vector[row];
4309                    }
4310                    output[col] = acc;
4311                }
4312                output
4313            }
4314        }
4315    }
4316
4317    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
4318        certify_signed_weights("DesignMatrix::diag_xtw_x", weights, self.nrows())?;
4319        let p = self.ncols();
4320        match self {
4321            Self::Dense(x) => x.diag_xtw_x(weights),
4322            Self::Sparse(xs) => {
4323                // Two regimes for sparse-stored designs:
4324                //
4325                //   (A) Numerically dense — Matern / Duchon radial bases place
4326                //       a nonzero in every column for every row, so XᵀWX has
4327                //       O(p²) fills and the scalar row-loop is dominated by
4328                //       memory traffic over O(n·nnz_row²) ≈ O(n·p²) ops.  Faer
4329                //       hand-tuned BLAS3 runs parallel + SIMD over either the
4330                //       cached dense design or bounded CSC-materialized row
4331                //       chunks, depending on the dense materialization policy.
4332                //
4333                //   (B) Genuinely sparse — B-spline / banded bases keep
4334                //       nnz_row at a small constant (4–6), so the per-row
4335                //       O(nnz_row²) work is ~25× fewer FLOPs than the dense
4336                //       matmul and densification is a regression.  Run a
4337                //       row-parallel scalar accumulation in that regime.
4338                //
4339                // Heuristic: average nnz_per_row >= p/4 picks (A).  In practice
4340                // the upstream `should_use_sparse_native_pirls` already routes
4341                // banded sparse XᵀWX designs to a separate sparse-native PIRLS
4342                // path that does NOT call this function, so the (A) branch
4343                // covers every actual call site we have today; the (B) branch
4344                // is a correctness-preserving safety net for future callers.
4345                let n = self.nrows();
4346                let nnz_x = xs.as_ref().val().len();
4347                let avg_nnz_row = if n > 0 { nnz_x / n } else { p };
4348                let dense_regime = 4 * avg_nnz_row >= p;
4349                if dense_regime {
4350                    let mut xtwx = Array2::<f64>::zeros((p, p));
4351                    // Reserve-or-stream: the dense BLAS route runs only while
4352                    // its full dense footprint is admitted by the process-wide
4353                    // governor; a refusal picks the bounded streaming CSC path.
4354                    if let Ok(xd) =
4355                        xs.try_to_dense_governed("DesignMatrix::diag_xtw_x dense sparse route")
4356                    {
4357                        stream_weighted_crossprod_into(
4358                            &**xd,
4359                            weights,
4360                            &mut xtwx,
4361                            CrossprodStructure::Full,
4362                            CrossprodAccum::Replace,
4363                            effective_global_parallelism(),
4364                        );
4365                    } else {
4366                        let (symbolic, values) = xs.parts();
4367                        streaming_sparse_csc_xt_diag_x(
4368                            symbolic.col_ptr(),
4369                            symbolic.row_idx(),
4370                            values,
4371                            n,
4372                            p,
4373                            weights.view(),
4374                            &mut xtwx,
4375                        );
4376                    }
4377                    return Ok(xtwx);
4378                }
4379                let csr = xs
4380                    .to_csr_arc()
4381                    .ok_or_else(|| "failed to obtain CSR view in xt_diag_x".to_string())?;
4382                let sym = csr.symbolic();
4383                Ok(sparse_csr_weighted_xtwx(
4384                    sym.row_ptr(),
4385                    sym.col_idx(),
4386                    csr.val(),
4387                    n,
4388                    p,
4389                    weights.view(),
4390                ))
4391            }
4392        }
4393    }
4394
4395    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
4396        certify_signed_weights("DesignMatrix::diag_gram", weights, self.nrows())?;
4397        let p = self.ncols();
4398        match self {
4399            Self::Dense(x) => x.diag_gram(weights),
4400            Self::Sparse(xs) => {
4401                let csr = xs
4402                    .to_csr_arc()
4403                    .ok_or_else(|| "failed to obtain CSR view in diag_gram".to_string())?;
4404                let sym = csr.symbolic();
4405                Ok(sparse_csr_diag_gram(
4406                    sym.row_ptr(),
4407                    sym.col_idx(),
4408                    csr.val(),
4409                    self.nrows(),
4410                    p,
4411                    weights.view(),
4412                ))
4413            }
4414        }
4415    }
4416
4417    fn factorize_system(
4418        &self,
4419        weights: &Array1<f64>,
4420        penalty: Option<&Array2<f64>>,
4421    ) -> Result<Box<dyn FactorizedSystem>, String> {
4422        if weights.len() != self.nrows() {
4423            return Err(format!(
4424                "factorize_system dimension mismatch: weights length {} != nrows {}",
4425                weights.len(),
4426                self.nrows()
4427            ));
4428        }
4429        match self {
4430            Self::Dense(_) => self.factorize_system_dense(weights, penalty),
4431            Self::Sparse(matrix) => {
4432                let system = assemble_sparseweighted_gram_system(matrix, weights, penalty)?;
4433                let factor = crate::sparse_exact::factorize_sparse_spd(&system)
4434                    .map_err(|e| format!("factorize_system failed: {e:?}"))?;
4435                Ok(Box::new(factor))
4436            }
4437        }
4438    }
4439}
4440
4441impl DenseDesignOperator for DesignMatrix {
4442    fn materialization_policy(&self) -> Option<MaterializationPolicy> {
4443        match self {
4444            Self::Dense(design) => design.materialization_policy(),
4445            Self::Sparse(_) => None,
4446        }
4447    }
4448
4449    fn compute_xtwy(&self, weights: &Array1<f64>, y: &Array1<f64>) -> Result<Array1<f64>, String> {
4450        if weights.len() != self.nrows() || y.len() != self.nrows() {
4451            return Err(format!(
4452                "compute_xtwy dimension mismatch: weights={}, y={}, nrows={}",
4453                weights.len(),
4454                y.len(),
4455                self.nrows()
4456            ));
4457        }
4458        certify_signed_weights("DesignMatrix::compute_xtwy", weights, self.nrows())?;
4459        match self {
4460            Self::Dense(x) => x.compute_xtwy(weights, y),
4461            Self::Sparse(xs) => {
4462                let csr = xs
4463                    .as_ref()
4464                    .to_row_major()
4465                    .map_err(|_| "failed to obtain CSR view in compute_xtwy".to_string())?;
4466                let sym = csr.symbolic();
4467                let row_ptr = sym.row_ptr();
4468                let col_idx = sym.col_idx();
4469                let vals = csr.val();
4470                let mut out = Array1::<f64>::zeros(xs.ncols());
4471                for i in 0..xs.nrows() {
4472                    let scaled = weights[i] * y[i];
4473                    if scaled == 0.0 {
4474                        continue;
4475                    }
4476                    for idx in row_ptr[i]..row_ptr[i + 1] {
4477                        out[col_idx[idx]] += vals[idx] * scaled;
4478                    }
4479                }
4480                Ok(out)
4481            }
4482        }
4483    }
4484
4485    fn quadratic_form_diag(&self, middle: &Array2<f64>) -> Result<Array1<f64>, String> {
4486        if middle.nrows() != self.ncols() || middle.ncols() != self.ncols() {
4487            return Err(format!(
4488                "quadratic_form_diag dimension mismatch: matrix is {}x{}, expected {}x{}",
4489                middle.nrows(),
4490                middle.ncols(),
4491                self.ncols(),
4492                self.ncols()
4493            ));
4494        }
4495
4496        match self {
4497            Self::Dense(xd) => xd.quadratic_form_diag(middle),
4498            Self::Sparse(xs) => {
4499                let csr = xs
4500                    .to_csr_arc()
4501                    .ok_or_else(|| "quadratic_form_diag: failed to obtain CSR view".to_string())?;
4502                let sym = csr.symbolic();
4503                let row_ptr = sym.row_ptr();
4504                let col_idx = sym.col_idx();
4505                let vals = csr.val();
4506                let mut out = Array1::<f64>::zeros(self.nrows());
4507                for i in 0..xs.nrows() {
4508                    let start = row_ptr[i];
4509                    let end = row_ptr[i + 1];
4510                    let mut acc = 0.0_f64;
4511                    for a in start..end {
4512                        let j = col_idx[a];
4513                        let xij = vals[a];
4514                        for b in start..end {
4515                            let k = col_idx[b];
4516                            let xik = vals[b];
4517                            acc += xij * middle[[j, k]] * xik;
4518                        }
4519                    }
4520                    out[i] = acc.max(0.0);
4521                }
4522                Ok(out)
4523            }
4524        }
4525    }
4526
4527    fn row_chunk_into(
4528        &self,
4529        rows: Range<usize>,
4530        out: ArrayViewMut2<'_, f64>,
4531    ) -> Result<(), MatrixMaterializationError> {
4532        if out.nrows() != rows.end - rows.start || out.ncols() != self.ncols() {
4533            return Err(MatrixMaterializationError::MissingRowChunk {
4534                context: "DesignMatrix::row_chunk_into shape mismatch",
4535            });
4536        }
4537        match self {
4538            Self::Dense(matrix) => matrix.row_chunk_into(rows, out),
4539            Self::Sparse(matrix) => matrix.row_chunk_into(rows, out),
4540        }
4541    }
4542
4543    fn to_dense(&self) -> Array2<f64> {
4544        DesignMatrix::to_dense(self)
4545    }
4546}
4547
4548impl LinearOperator for DenseRightProductView<'_> {
4549    fn nrows(&self) -> usize {
4550        self.base.nrows()
4551    }
4552
4553    fn ncols(&self) -> usize {
4554        self.transformed_ncols()
4555    }
4556
4557    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
4558        let rhs;
4559        let v = match (self.second, self.first) {
4560            (None, None) => vector,
4561            (Some(s), None) => {
4562                rhs = fast_av(s, vector);
4563                &rhs
4564            }
4565            (None, Some(f)) => {
4566                rhs = fast_av(f, vector);
4567                &rhs
4568            }
4569            (Some(s), Some(f)) => {
4570                let tmp = fast_av(s, vector);
4571                rhs = fast_av(f, &tmp);
4572                &rhs
4573            }
4574        };
4575        fast_av(self.base, v)
4576    }
4577
4578    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
4579        let mut out = fast_atv(self.base, vector);
4580        if let Some(factor) = self.first {
4581            out = fast_atv(factor, &out);
4582        }
4583        if let Some(factor) = self.second {
4584            out = fast_atv(factor, &out);
4585        }
4586        out
4587    }
4588
4589    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
4590        if weights.len() != self.nrows() {
4591            return Err(format!(
4592                "xt_diag_x dimension mismatch: weights length {} != nrows {}",
4593                weights.len(),
4594                self.nrows()
4595            ));
4596        }
4597        certify_signed_weights("DenseRightProductView::diag_xtw_x", weights, self.nrows())?;
4598        let mut gram = fast_xt_diag_x(self.base, weights);
4599        if let Some(factor) = self.first {
4600            gram = fast_ab(&fast_atb(factor, &gram), factor);
4601        }
4602        if let Some(factor) = self.second {
4603            gram = fast_ab(&fast_atb(factor, &gram), factor);
4604        }
4605        Ok(gram)
4606    }
4607
4608
4609    // Restored: the dead-code sweep (d484a091a) deleted this override; it is
4610    // reached only through the trait, so the sweep fell back to the default.
4611    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
4612        Ok(self.diag_xtw_x(weights)?.diag().to_owned())
4613    }
4614}
4615
4616impl DenseRightProductView<'_> {
4617
4618}
4619
4620impl LinearOperator for EmbeddedColumnBlock<'_> {
4621    fn nrows(&self) -> usize {
4622        self.local.nrows()
4623    }
4624
4625    fn ncols(&self) -> usize {
4626        self.total_cols
4627    }
4628
4629    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
4630        fast_av(
4631            self.local,
4632            &vector.slice(ndarray::s![self.global_range.clone()]),
4633        )
4634    }
4635
4636    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
4637        let mut out = Array1::<f64>::zeros(self.total_cols);
4638        out.slice_mut(ndarray::s![self.global_range.clone()])
4639            .assign(&fast_atv(self.local, vector));
4640        out
4641    }
4642
4643    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
4644        if weights.len() != self.nrows() {
4645            return Err(format!(
4646                "xt_diag_x dimension mismatch: weights length {} != nrows {}",
4647                weights.len(),
4648                self.nrows()
4649            ));
4650        }
4651        certify_signed_weights("EmbeddedColumnBlock::diag_xtw_x", weights, self.nrows())?;
4652        let mut out = Array2::<f64>::zeros((self.total_cols, self.total_cols));
4653        let local = fast_xt_diag_x(self.local, weights);
4654        out.slice_mut(ndarray::s![
4655            self.global_range.clone(),
4656            self.global_range.clone()
4657        ])
4658        .assign(&local);
4659        Ok(out)
4660    }
4661
4662
4663    // Restored: the dead-code sweep (d484a091a) deleted this override; it is
4664    // reached only through the trait, so the sweep fell back to the default.
4665    fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
4666        let mut out = Array1::<f64>::zeros(self.total_cols);
4667        let local =
4668            DesignMatrix::Dense(DenseDesignMatrix::from(self.local.clone())).diag_gram(weights)?;
4669        out.slice_mut(ndarray::s![self.global_range.clone()])
4670            .assign(&local);
4671        Ok(out)
4672    }
4673}
4674
4675impl EmbeddedColumnBlock<'_> {
4676
4677}
4678
4679impl DesignMatrix {
4680
4681    /// Like [`Self::try_to_dense_by_chunks`] but refuses to allocate when the
4682    /// dense footprint would exceed `max_bytes`. Returned `Err` is the same
4683    /// shape as a densification-refused error from the resource policy, so
4684    /// observability-only callers can convert it into a `warn!` and skip
4685    /// without ever touching the allocator at huge `n`.
4686    pub fn try_to_dense_by_chunks_budgeted(
4687        &self,
4688        context: &str,
4689        max_bytes: usize,
4690    ) -> Result<Array2<f64>, String> {
4691        let n = self.nrows();
4692        let p = self.ncols();
4693        let dense_bytes = checked_dense_nbytes(n, p, context)?;
4694        if dense_bytes > max_bytes {
4695            let gib = dense_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
4696            let cap_gib = max_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
4697            return Err(MatrixError::DensificationRefused {
4698                reason: format!(
4699                    "{context}: refusing to densify {n}x{p} (~{gib:.2} GiB, cap ~{cap_gib:.2} GiB)"
4700                ),
4701            }
4702            .into());
4703        }
4704        self.try_to_dense_by_chunks(context)
4705    }
4706    fn factorize_system_dense(
4707        &self,
4708        weights: &Array1<f64>,
4709        penalty: Option<&Array2<f64>>,
4710    ) -> Result<Box<dyn FactorizedSystem>, String> {
4711        let mut system = self.diag_xtw_x(weights)?;
4712        if let Some(pen) = penalty {
4713            if pen.nrows() != system.nrows() || pen.ncols() != system.ncols() {
4714                return Err(format!(
4715                    "factorize_system penalty shape mismatch: got {}x{}, expected {}x{}",
4716                    pen.nrows(),
4717                    pen.ncols(),
4718                    system.nrows(),
4719                    system.ncols()
4720                ));
4721            }
4722            system += pen;
4723        }
4724        let factor = crate::utils::StableSolver::new()
4725            .factorize(&system)
4726            .map_err(|e| format!("factorize_system failed: {e:?}"))?;
4727        Ok(Box::new(factor))
4728    }
4729}
4730
4731fn assemble_sparseweighted_gram_system(
4732    matrix: &SparseDesignMatrix,
4733    weights: &Array1<f64>,
4734    penalty: Option<&Array2<f64>>,
4735) -> Result<SparseColMat<usize, f64>, String> {
4736    certify_signed_weights(
4737        "assemble_sparseweighted_gram_system",
4738        weights,
4739        matrix.nrows(),
4740    )?;
4741    let csr = matrix
4742        .to_csr_arc()
4743        .ok_or_else(|| "failed to obtain CSR view in factorize_system".to_string())?;
4744    let sym = csr.symbolic();
4745    let row_ptr = sym.row_ptr();
4746    let col_idx = sym.col_idx();
4747    let vals = csr.val();
4748    let p = matrix.ncols();
4749    let mut upper = BTreeMap::<(usize, usize), f64>::new();
4750
4751    for i in 0..csr.nrows() {
4752        let wi = weights[i];
4753        if wi == 0.0 {
4754            continue;
4755        }
4756        let start = row_ptr[i];
4757        let end = row_ptr[i + 1];
4758        for a_ptr in start..end {
4759            let a = col_idx[a_ptr];
4760            let xa = vals[a_ptr];
4761            for b_ptr in a_ptr..end {
4762                let b = col_idx[b_ptr];
4763                let xb = vals[b_ptr];
4764                let key = if a <= b { (a, b) } else { (b, a) };
4765                *upper.entry(key).or_insert(0.0) += wi * xa * xb;
4766            }
4767        }
4768    }
4769
4770    if let Some(pen) = penalty {
4771        if pen.nrows() != p || pen.ncols() != p {
4772            return Err(format!(
4773                "factorize_system penalty shape mismatch: got {}x{}, expected {}x{}",
4774                pen.nrows(),
4775                pen.ncols(),
4776                p,
4777                p
4778            ));
4779        }
4780        for i in 0..p {
4781            for j in i..p {
4782                let value = pen[[i, j]];
4783                if value != 0.0 {
4784                    *upper.entry((i, j)).or_insert(0.0) += value;
4785                }
4786            }
4787        }
4788    }
4789
4790    let mut triplets = Vec::with_capacity(upper.len());
4791    for ((row, col), value) in upper {
4792        if value != 0.0 {
4793            triplets.push(Triplet::new(row, col, value));
4794        }
4795    }
4796    SparseColMat::try_new_from_triplets(p, p, &triplets)
4797        .map_err(|_| "failed to build sparse penalized system".to_string())
4798}
4799
4800impl DesignMatrix {
4801    /// Horizontally concatenate design blocks without forcing eager densification.
4802    ///
4803    /// The returned matrix is a lazy `BlockDesignOperator` when more than one
4804    /// block is provided, so operator-backed inputs stay chunkable on the
4805    /// prediction path.
4806    pub fn hstack(blocks: Vec<DesignMatrix>) -> Result<Self, String> {
4807        if blocks.is_empty() {
4808            return Err("DesignMatrix::hstack requires at least one block".to_string());
4809        }
4810        if blocks.len() == 1 {
4811            return Ok(blocks.into_iter().next().expect("non-empty block list"));
4812        }
4813        let operator =
4814            BlockDesignOperator::new(blocks.into_iter().map(DesignBlock::from).collect())?;
4815        Ok(Self::Dense(DenseDesignMatrix::from(Arc::new(operator))))
4816    }
4817
4818    pub fn nrows(&self) -> usize {
4819        <Self as LinearOperator>::nrows(self)
4820    }
4821
4822    pub fn ncols(&self) -> usize {
4823        <Self as LinearOperator>::ncols(self)
4824    }
4825
4826    /// Extract a dense row chunk without materializing the full matrix.
4827    ///
4828    /// Returns a `(rows.len(), ncols())` dense `Array2` for the requested row
4829    /// range. For lazy dense designs this delegates to the operator-backed
4830    /// implementation, which should remain O(chunk).
4831    pub fn try_row_chunk(
4832        &self,
4833        rows: Range<usize>,
4834    ) -> Result<Array2<f64>, MatrixMaterializationError> {
4835        match self {
4836            Self::Dense(matrix) => matrix.try_row_chunk(rows),
4837            Self::Sparse(matrix) => {
4838                let csr =
4839                    matrix
4840                        .to_csr_arc()
4841                        .ok_or(MatrixMaterializationError::MissingRowChunk {
4842                            context: "DesignMatrix::try_row_chunk: failed to obtain CSR view",
4843                        })?;
4844                let sym = csr.symbolic();
4845                let row_ptr = sym.row_ptr();
4846                let col_idx = sym.col_idx();
4847                let vals = csr.val();
4848                let chunk_rows = rows.end - rows.start;
4849                let ncols = self.ncols();
4850                let mut out = Array2::<f64>::zeros((chunk_rows, ncols));
4851                for (local_row, row) in rows.enumerate() {
4852                    for ptr in row_ptr[row]..row_ptr[row + 1] {
4853                        out[[local_row, col_idx[ptr]]] = vals[ptr];
4854                    }
4855                }
4856                Ok(out)
4857            }
4858        }
4859    }
4860
4861    /// Borrow-only row-chunk accessor: writes the requested rows into an
4862    /// existing `(rows.len(), ncols())` buffer instead of allocating a fresh
4863    /// `Array2<f64>` like [`Self::try_row_chunk`]. Used by hot per-row loops
4864    /// (e.g. latent-survival evaluate) that want to reuse a single 1-row
4865    /// scratch buffer across iterations.
4866    pub fn row_chunk_into(
4867        &self,
4868        rows: Range<usize>,
4869        out: ArrayViewMut2<'_, f64>,
4870    ) -> Result<(), MatrixMaterializationError> {
4871        <Self as DenseDesignOperator>::row_chunk_into(self, rows, out)
4872    }
4873
4874    /// Fully materialize this design under the process-wide byte governor.
4875    ///
4876    /// The returned owner retains the RAII reservation for precisely the
4877    /// matrix lifetime. A refusal is typed, happens before allocation, and is
4878    /// the caller's signal to remain row-chunked or matrix-free.
4879    pub fn try_to_dense_governed(
4880        &self,
4881        context: &'static str,
4882    ) -> Result<Governed<Array2<f64>>, MatrixMaterializationError> {
4883        self.try_to_dense_governed_with_policy(
4884            &ResourcePolicy::default_library().material_policy(),
4885            context,
4886        )
4887    }
4888
4889    /// Policy-aware form of [`Self::try_to_dense_governed`]. Structural
4890    /// operator-only policies refuse before consulting or charging the ledger.
4891    pub fn try_to_dense_governed_with_policy(
4892        &self,
4893        policy: &MaterializationPolicy,
4894        context: &'static str,
4895    ) -> Result<Governed<Array2<f64>>, MatrixMaterializationError> {
4896        governed_dense_operator_to_dense_by_chunks(self, policy, context)
4897    }
4898
4899    pub fn try_to_dense_by_chunks(&self, context: &str) -> Result<Array2<f64>, String> {
4900        let n = self.nrows();
4901        let p = self.ncols();
4902        let chunk_rows = dense_materialization_chunk_rows(n, p);
4903        let mut out = Array2::<f64>::zeros((n, p));
4904        for start in (0..n).step_by(chunk_rows) {
4905            let end = (start + chunk_rows).min(n);
4906            let slice = out.slice_mut(s![start..end, ..]);
4907            self.row_chunk_into(start..end, slice)
4908                .map_err(|err| format!("{context}: failed to materialize row chunk: {err}"))?;
4909        }
4910        Ok(out)
4911    }
4912
4913    /// Dot a single design row against a coefficient vector without allocating
4914    /// a standalone row buffer when the underlying storage permits.
4915    pub fn dot_row(&self, row: usize, beta: &Array1<f64>) -> f64 {
4916        self.dot_row_view(row, beta.view())
4917    }
4918
4919    pub fn dot_row_view(&self, row: usize, beta: ArrayView1<'_, f64>) -> f64 {
4920        assert_eq!(
4921            beta.len(),
4922            self.ncols(),
4923            "DesignMatrix::dot_row_view length mismatch: beta={}, ncols={}",
4924            beta.len(),
4925            self.ncols()
4926        );
4927        match self {
4928            Self::Dense(matrix) => {
4929                if let Some(dense) = matrix.as_dense_ref() {
4930                    dense.row(row).dot(&beta)
4931                } else {
4932                    matrix
4933                        .try_row_chunk(row..row + 1)
4934                        .expect("DesignMatrix::dot_row_view: try_row_chunk must succeed")
4935                        .row(0)
4936                        .dot(&beta)
4937                }
4938            }
4939            Self::Sparse(matrix) => {
4940                // SAFETY: `to_csr_arc` only returns `None` if the underlying
4941                // SparseColMat fails `to_row_major`, which is infallible for
4942                // any well-formed sparse matrix the surrounding type system
4943                // permits (csc → csr conversion requires only valid column
4944                // pointers). Reaching `None` would mean the SparseDesignMatrix
4945                // invariant was violated upstream.
4946                // SAFETY: SparseDesignMatrix invariants guarantee csc→csr conversion succeeds.
4947                let csr = matrix
4948                    .to_csr_arc()
4949                    .expect("DesignMatrix::dot_row: failed to obtain CSR view");
4950                let sym = csr.symbolic();
4951                let row_ptr = sym.row_ptr();
4952                let col_idx = sym.col_idx();
4953                let vals = csr.val();
4954                let mut out = 0.0;
4955                for ptr in row_ptr[row]..row_ptr[row + 1] {
4956                    out += vals[ptr] * beta[col_idx[ptr]];
4957                }
4958                out
4959            }
4960        }
4961    }
4962
4963    /// Add `alpha * X[row, :]` into `out` without allocating a row buffer.
4964    pub fn axpy_row_into(
4965        &self,
4966        row: usize,
4967        alpha: f64,
4968        out: &mut ArrayViewMut1<'_, f64>,
4969    ) -> Result<(), String> {
4970        self.axpy_row_into_impl(row, alpha, out, false, "axpy_row_into")
4971    }
4972
4973    /// Add `alpha * X[row, :]^2` elementwise into `out` without allocating a
4974    /// standalone row buffer.
4975    pub fn squared_axpy_row_into(
4976        &self,
4977        row: usize,
4978        alpha: f64,
4979        out: &mut ArrayViewMut1<'_, f64>,
4980    ) -> Result<(), String> {
4981        self.axpy_row_into_impl(row, alpha, out, true, "squared_axpy_row_into")
4982    }
4983
4984    /// Shared kernel for [`axpy_row_into`](Self::axpy_row_into) and
4985    /// [`squared_axpy_row_into`](Self::squared_axpy_row_into): adds
4986    /// `alpha * X[row, :]` (when `square` is `false`) or
4987    /// `alpha * X[row, :]^2` elementwise (when `square` is `true`) into `out`
4988    /// without allocating a row buffer. `method` names the public entry point
4989    /// in error messages.
4990    #[inline]
4991    fn axpy_row_into_impl(
4992        &self,
4993        row: usize,
4994        alpha: f64,
4995        out: &mut ArrayViewMut1<'_, f64>,
4996        square: bool,
4997        method: &str,
4998    ) -> Result<(), String> {
4999        if out.len() != self.ncols() {
5000            return Err(format!(
5001                "DesignMatrix::{method} length mismatch: out={}, ncols={}",
5002                out.len(),
5003                self.ncols()
5004            ));
5005        }
5006        if alpha == 0.0 {
5007            return Ok(());
5008        }
5009        // Per-element scaling: `alpha * v` (axpy) or `alpha * v^2` (squared).
5010        let scale = |value: f64| {
5011            if square {
5012                alpha * value * value
5013            } else {
5014                alpha * value
5015            }
5016        };
5017        match self {
5018            Self::Dense(matrix) => {
5019                if let Some(dense) = matrix.as_dense_ref() {
5020                    for (dst, &value) in out.iter_mut().zip(dense.row(row).iter()) {
5021                        *dst += scale(value);
5022                    }
5023                } else {
5024                    let chunk = matrix
5025                        .try_row_chunk(row..row + 1)
5026                        .map_err(|e| format!("DesignMatrix::{method}: {e}"))?;
5027                    for (dst, &value) in out.iter_mut().zip(chunk.row(0).iter()) {
5028                        *dst += scale(value);
5029                    }
5030                }
5031            }
5032            Self::Sparse(matrix) => {
5033                // SAFETY: `to_csr_arc` returns `None` only if csc→csr conversion
5034                // fails, which is infallible for the well-formed sparse matrices
5035                // that `SparseDesignMatrix` is contractually allowed to hold.
5036                // SAFETY: SparseDesignMatrix invariants guarantee csc→csr conversion succeeds.
5037                let csr = matrix
5038                    .to_csr_arc()
5039                    .ok_or_else(|| format!("DesignMatrix::{method}: failed to obtain CSR view"))?;
5040                let sym = csr.symbolic();
5041                let row_ptr = sym.row_ptr();
5042                let col_idx = sym.col_idx();
5043                let vals = csr.val();
5044                for ptr in row_ptr[row]..row_ptr[row + 1] {
5045                    out[col_idx[ptr]] += scale(vals[ptr]);
5046                }
5047            }
5048        }
5049        Ok(())
5050    }
5051
5052    /// Add `alpha * self[row, :] * other[row, :]` elementwise into `out`.
5053    ///
5054    /// Both matrices must have the same number of columns (== `out.len()`).
5055    /// For Sparse×Sparse this runs in O(nnz_lhs + nnz_rhs) via sorted
5056    /// merge-intersection on the CSR column indices — no dense expansion.
5057    pub fn crossdiag_axpy_row_into(
5058        &self,
5059        row: usize,
5060        other: &DesignMatrix,
5061        alpha: f64,
5062        out: &mut ArrayViewMut1<'_, f64>,
5063    ) -> Result<(), String> {
5064        assert_eq!(self.ncols(), other.ncols());
5065        assert_eq!(out.len(), self.ncols());
5066        if alpha == 0.0 {
5067            return Ok(());
5068        }
5069        match (self, other) {
5070            (Self::Dense(lhs), Self::Dense(rhs)) => {
5071                let lhs_chunk;
5072                let rhs_chunk;
5073                let x = if let Some(lhs_dense) = lhs.as_dense_ref() {
5074                    lhs_dense.row(row)
5075                } else {
5076                    lhs_chunk = lhs
5077                        .try_row_chunk(row..row + 1)
5078                        .map_err(|e| format!("crossdiag_axpy_row_into lhs: {e}"))?;
5079                    lhs_chunk.row(0)
5080                };
5081                let y = if let Some(rhs_dense) = rhs.as_dense_ref() {
5082                    rhs_dense.row(row)
5083                } else {
5084                    rhs_chunk = rhs
5085                        .try_row_chunk(row..row + 1)
5086                        .map_err(|e| format!("crossdiag_axpy_row_into rhs: {e}"))?;
5087                    rhs_chunk.row(0)
5088                };
5089                for (dst, (&xi, &yi)) in out.iter_mut().zip(x.iter().zip(y.iter())) {
5090                    *dst += alpha * xi * yi;
5091                }
5092            }
5093            (Self::Sparse(lhs), Self::Sparse(rhs)) => {
5094                // `to_csr_arc` returns `None` only if csc→csr conversion fails;
5095                // `SparseDesignMatrix`'s validation invariants make that
5096                // structurally impossible, but the function returns `Result`
5097                // so propagate rather than panic if a future invariant break
5098                // surfaces here.
5099                let lhs_csr = lhs.to_csr_arc().ok_or_else(|| {
5100                    "crossdiag_axpy_row_into: failed to obtain lhs CSR view".to_string()
5101                })?;
5102                let rhs_csr = rhs.to_csr_arc().ok_or_else(|| {
5103                    "crossdiag_axpy_row_into: failed to obtain rhs CSR view".to_string()
5104                })?;
5105                let lhs_sym = lhs_csr.symbolic();
5106                let rhs_sym = rhs_csr.symbolic();
5107                let lhs_rp = lhs_sym.row_ptr();
5108                let rhs_rp = rhs_sym.row_ptr();
5109                let lhs_ci = lhs_sym.col_idx();
5110                let rhs_ci = rhs_sym.col_idx();
5111                let lhs_v = lhs_csr.val();
5112                let rhs_v = rhs_csr.val();
5113                // Merge-intersection: both col_idx slices are sorted.
5114                let mut li = lhs_rp[row];
5115                let mut ri = rhs_rp[row];
5116                let l_end = lhs_rp[row + 1];
5117                let r_end = rhs_rp[row + 1];
5118                while li < l_end && ri < r_end {
5119                    let lc = lhs_ci[li];
5120                    let rc = rhs_ci[ri];
5121                    if lc == rc {
5122                        out[lc] += alpha * lhs_v[li] * rhs_v[ri];
5123                        li += 1;
5124                        ri += 1;
5125                    } else if lc < rc {
5126                        li += 1;
5127                    } else {
5128                        ri += 1;
5129                    }
5130                }
5131            }
5132            _ => {
5133                // Mixed dense/sparse: iterate the sparse side, index into dense.
5134                let (sparse_mat, dense_mat) = match (self, other) {
5135                    (Self::Sparse(s), Self::Dense(d)) => (s, d),
5136                    (Self::Dense(d), Self::Sparse(s)) => (s, d),
5137                    // Outer match's first two arms already handled (Dense,Dense)
5138                    // and (Sparse,Sparse); only mixed pairs reach this fallback.
5139                    _ => {
5140                        return Err(
5141                            "crossdiag_axpy_row_into: mixed-arm dispatch reached non-mixed pair"
5142                                .to_string(),
5143                        );
5144                    }
5145                };
5146                // Same CSR conversion contract as the (Sparse, Sparse) arm
5147                // above — propagate the (structurally impossible) failure
5148                // through this fn's `Result` rather than panicking.
5149                let csr = sparse_mat.to_csr_arc().ok_or_else(|| {
5150                    "crossdiag_axpy_row_into: failed to obtain CSR view".to_string()
5151                })?;
5152                let sym = csr.symbolic();
5153                let row_ptr = sym.row_ptr();
5154                let col_idx = sym.col_idx();
5155                let vals = csr.val();
5156                let dense_chunk;
5157                let dense_row = if let Some(dense_ref) = dense_mat.as_dense_ref() {
5158                    dense_ref.row(row)
5159                } else {
5160                    dense_chunk = dense_mat
5161                        .try_row_chunk(row..row + 1)
5162                        .map_err(|e| format!("crossdiag_axpy_row_into dense chunk: {e}"))?;
5163                    dense_chunk.row(0)
5164                };
5165                for ptr in row_ptr[row]..row_ptr[row + 1] {
5166                    let c = col_idx[ptr];
5167                    out[c] += alpha * vals[ptr] * dense_row[c];
5168                }
5169            }
5170        }
5171        Ok(())
5172    }
5173
5174    /// Symmetric rank-1 update `target += alpha * x_row x_row^T` for one row.
5175    pub fn syr_row_into(
5176        &self,
5177        row: usize,
5178        alpha: f64,
5179        target: &mut Array2<f64>,
5180    ) -> Result<(), String> {
5181        self.syr_row_into_view(row, alpha, target.view_mut())
5182    }
5183
5184    /// Like `syr_row_into` but accepts a mutable view, so callers can pass
5185    /// a slice of a larger matrix without allocating a temporary.
5186    pub fn syr_row_into_view(
5187        &self,
5188        row: usize,
5189        alpha: f64,
5190        mut target: ArrayViewMut2<'_, f64>,
5191    ) -> Result<(), String> {
5192        if target.nrows() != self.ncols() || target.ncols() != self.ncols() {
5193            return Err(format!(
5194                "DesignMatrix::syr_row_into shape mismatch: target={}x{}, ncols={}",
5195                target.nrows(),
5196                target.ncols(),
5197                self.ncols()
5198            ));
5199        }
5200        if alpha == 0.0 {
5201            return Ok(());
5202        }
5203        match self {
5204            Self::Dense(matrix) => {
5205                if let Some(dense) = matrix.as_dense_ref() {
5206                    let x = dense.row(row);
5207                    for i in 0..x.len() {
5208                        let xi = x[i];
5209                        if xi == 0.0 {
5210                            continue;
5211                        }
5212                        for j in 0..x.len() {
5213                            target[[i, j]] += alpha * xi * x[j];
5214                        }
5215                    }
5216                } else {
5217                    let chunk = matrix
5218                        .try_row_chunk(row..row + 1)
5219                        .map_err(|e| format!("DesignMatrix::syr_row_into: {e}"))?;
5220                    let x = chunk.row(0);
5221                    for i in 0..x.len() {
5222                        let xi = x[i];
5223                        if xi == 0.0 {
5224                            continue;
5225                        }
5226                        for j in 0..x.len() {
5227                            target[[i, j]] += alpha * xi * x[j];
5228                        }
5229                    }
5230                }
5231            }
5232            Self::Sparse(matrix) => {
5233                // SAFETY: `to_csr_arc` returns `None` only on csc→csr conversion
5234                // failure for a malformed sparse matrix; `SparseDesignMatrix`
5235                // invariants forbid that case.
5236                // SAFETY: SparseDesignMatrix invariants guarantee csc→csr conversion succeeds.
5237                let csr = matrix.to_csr_arc().ok_or_else(|| {
5238                    "DesignMatrix::syr_row_into: failed to obtain CSR view".to_string()
5239                })?;
5240                let sym = csr.symbolic();
5241                let row_ptr = sym.row_ptr();
5242                let col_idx = sym.col_idx();
5243                let vals = csr.val();
5244                for ptr_i in row_ptr[row]..row_ptr[row + 1] {
5245                    let i = col_idx[ptr_i];
5246                    let xi = vals[ptr_i];
5247                    for ptr_j in row_ptr[row]..row_ptr[row + 1] {
5248                        let j = col_idx[ptr_j];
5249                        target[[i, j]] += alpha * xi * vals[ptr_j];
5250                    }
5251                }
5252            }
5253        }
5254        Ok(())
5255    }
5256
5257    /// Asymmetric rank-1 update: `target += alpha * lhs_row * rhs_row^T`.
5258    ///
5259    /// `self` provides `lhs_row`, `other` provides `rhs_row`.
5260    /// `target` must be `self.ncols() x other.ncols()`.
5261    pub fn row_outer_into(
5262        &self,
5263        row: usize,
5264        other: &DesignMatrix,
5265        alpha: f64,
5266        target: &mut Array2<f64>,
5267    ) -> Result<(), String> {
5268        self.row_outer_into_view(row, other, alpha, target.view_mut())
5269    }
5270
5271    /// Like `row_outer_into` but accepts a mutable view, so callers can pass
5272    /// a slice of a larger matrix without allocating a temporary.
5273    pub fn row_outer_into_view(
5274        &self,
5275        row: usize,
5276        other: &DesignMatrix,
5277        alpha: f64,
5278        mut target: ArrayViewMut2<'_, f64>,
5279    ) -> Result<(), String> {
5280        if target.nrows() != self.ncols() || target.ncols() != other.ncols() {
5281            return Err(format!(
5282                "DesignMatrix::row_outer_into shape mismatch: target={}x{}, lhs={}, rhs={}",
5283                target.nrows(),
5284                target.ncols(),
5285                self.ncols(),
5286                other.ncols()
5287            ));
5288        }
5289        if alpha == 0.0 {
5290            return Ok(());
5291        }
5292        match (self, other) {
5293            (Self::Dense(lhs), Self::Dense(rhs)) => {
5294                let lhs_chunk;
5295                let rhs_chunk;
5296                let x = if let Some(lhs_dense) = lhs.as_dense_ref() {
5297                    lhs_dense.row(row)
5298                } else {
5299                    lhs_chunk = lhs
5300                        .try_row_chunk(row..row + 1)
5301                        .map_err(|e| format!("row_outer_into_view lhs: {e}"))?;
5302                    lhs_chunk.row(0)
5303                };
5304                let y = if let Some(rhs_dense) = rhs.as_dense_ref() {
5305                    rhs_dense.row(row)
5306                } else {
5307                    rhs_chunk = rhs
5308                        .try_row_chunk(row..row + 1)
5309                        .map_err(|e| format!("row_outer_into_view rhs: {e}"))?;
5310                    rhs_chunk.row(0)
5311                };
5312                for i in 0..x.len() {
5313                    let xi = x[i];
5314                    if xi == 0.0 {
5315                        continue;
5316                    }
5317                    for j in 0..y.len() {
5318                        target[[i, j]] += alpha * xi * y[j];
5319                    }
5320                }
5321            }
5322            (Self::Sparse(lhs), Self::Sparse(rhs)) => {
5323                // SAFETY: both `to_csr_arc` calls only fail on csc→csr conversion
5324                // of a malformed sparse matrix; `SparseDesignMatrix` invariants
5325                // upstream guarantee both inputs round-trip to CSR.
5326                // SAFETY: SparseDesignMatrix invariants guarantee csc→csr conversion succeeds.
5327                let lhs_csr = lhs
5328                    .to_csr_arc()
5329                    .ok_or_else(|| "row_outer_into: failed to obtain lhs CSR view".to_string())?;
5330                // SAFETY: SparseDesignMatrix invariants guarantee csc→csr conversion succeeds.
5331                let rhs_csr = rhs
5332                    .to_csr_arc()
5333                    .ok_or_else(|| "row_outer_into: failed to obtain rhs CSR view".to_string())?;
5334                let lhs_sym = lhs_csr.symbolic();
5335                let rhs_sym = rhs_csr.symbolic();
5336                let lhs_rp = lhs_sym.row_ptr();
5337                let rhs_rp = rhs_sym.row_ptr();
5338                let lhs_ci = lhs_sym.col_idx();
5339                let rhs_ci = rhs_sym.col_idx();
5340                let lhs_v = lhs_csr.val();
5341                let rhs_v = rhs_csr.val();
5342                for pi in lhs_rp[row]..lhs_rp[row + 1] {
5343                    let i = lhs_ci[pi];
5344                    let xi = lhs_v[pi];
5345                    for pj in rhs_rp[row]..rhs_rp[row + 1] {
5346                        let j = rhs_ci[pj];
5347                        target[[i, j]] += alpha * xi * rhs_v[pj];
5348                    }
5349                }
5350            }
5351            _ => {
5352                // Mixed dense/sparse: materialize both rows.
5353                let x = self
5354                    .try_row_chunk(row..row + 1)
5355                    .map_err(|e| format!("row_outer_into_view lhs: {e}"))?;
5356                let x_row = x.row(0);
5357                let y = other
5358                    .try_row_chunk(row..row + 1)
5359                    .map_err(|e| format!("row_outer_into_view rhs: {e}"))?;
5360                let y_row = y.row(0);
5361                for i in 0..x_row.len() {
5362                    let xi = x_row[i];
5363                    if xi == 0.0 {
5364                        continue;
5365                    }
5366                    for j in 0..y_row.len() {
5367                        target[[i, j]] += alpha * xi * y_row[j];
5368                    }
5369                }
5370            }
5371        }
5372        Ok(())
5373    }
5374
5375    /// Apply the design to a borrowed vector into caller-owned storage.
5376    ///
5377    /// Unlike [`Self::matrixvectormultiply`], this accepts an `ArrayView1` and
5378    /// does not require either operand to be copied for materialized dense or
5379    /// sparse designs.
5380    pub fn apply_view_into(&self, vector: ArrayView1<'_, f64>, mut output: ArrayViewMut1<'_, f64>) {
5381        assert_eq!(self.ncols(), vector.len());
5382        assert_eq!(self.nrows(), output.len());
5383        match self {
5384            Self::Dense(DenseDesignMatrix::Materialized(matrix)) => {
5385                crate::dense::matvec_into(matrix.as_ref(), vector, output);
5386            }
5387            Self::Dense(DenseDesignMatrix::Lazy(operator)) => {
5388                output.assign(&operator.apply(&vector.to_owned()));
5389            }
5390            Self::Sparse(matrix) => {
5391                output.fill(0.0);
5392                let (symbolic, values) = matrix.parts();
5393                let col_ptr = symbolic.col_ptr();
5394                let row_idx = symbolic.row_idx();
5395                for col in 0..matrix.ncols() {
5396                    let x = vector[col];
5397                    if x == 0.0 {
5398                        continue;
5399                    }
5400                    for idx in col_ptr[col]..col_ptr[col + 1] {
5401                        output[row_idx[idx]] += values[idx] * x;
5402                    }
5403                }
5404            }
5405        }
5406    }
5407
5408    /// Apply the design to a borrowed vector and return the owned result.
5409    pub fn apply_view(&self, vector: ArrayView1<'_, f64>) -> Array1<f64> {
5410        let mut output = Array1::<f64>::zeros(self.nrows());
5411        self.apply_view_into(vector, output.view_mut());
5412        output
5413    }
5414
5415    /// Apply the transposed design to a borrowed vector into caller storage.
5416    pub fn transpose_apply_view_into(
5417        &self,
5418        vector: ArrayView1<'_, f64>,
5419        mut output: ArrayViewMut1<'_, f64>,
5420    ) {
5421        assert_eq!(self.nrows(), vector.len());
5422        assert_eq!(self.ncols(), output.len());
5423        match self {
5424            Self::Dense(DenseDesignMatrix::Materialized(matrix)) => {
5425                crate::dense::transpose_matvec_into(matrix.as_ref(), vector, output);
5426            }
5427            Self::Dense(DenseDesignMatrix::Lazy(operator)) => {
5428                output.assign(&operator.apply_transpose(&vector.to_owned()));
5429            }
5430            Self::Sparse(matrix) => {
5431                let (symbolic, values) = matrix.parts();
5432                let col_ptr = symbolic.col_ptr();
5433                let row_idx = symbolic.row_idx();
5434                for col in 0..matrix.ncols() {
5435                    let mut value = 0.0;
5436                    for idx in col_ptr[col]..col_ptr[col + 1] {
5437                        value += values[idx] * vector[row_idx[idx]];
5438                    }
5439                    output[col] = value;
5440                }
5441            }
5442        }
5443    }
5444
5445    /// Extract one column into caller-owned storage without densification.
5446    pub fn column_into(&self, col: usize, mut output: ArrayViewMut1<'_, f64>) {
5447        assert!(col < self.ncols());
5448        assert_eq!(self.nrows(), output.len());
5449        match self {
5450            Self::Dense(DenseDesignMatrix::Materialized(matrix)) => {
5451                output.assign(&matrix.column(col));
5452            }
5453            Self::Dense(DenseDesignMatrix::Lazy(operator)) => {
5454                let mut basis = Array1::<f64>::zeros(operator.ncols());
5455                basis[col] = 1.0;
5456                output.assign(&operator.apply(&basis));
5457            }
5458            Self::Sparse(matrix) => {
5459                output.fill(0.0);
5460                let (symbolic, values) = matrix.parts();
5461                let col_ptr = symbolic.col_ptr();
5462                let row_idx = symbolic.row_idx();
5463                for idx in col_ptr[col]..col_ptr[col + 1] {
5464                    output[row_idx[idx]] += values[idx];
5465                }
5466            }
5467        }
5468    }
5469
5470    /// Element access: returns the value at row `i`, column `j`.
5471    ///
5472    /// For materialized dense matrices this is O(1). For sparse matrices,
5473    /// the dense form is cached on the `SparseDesignMatrix` itself, so
5474    /// repeated calls amortize to O(1) after the first call populates the
5475    /// cache. For operator-backed (Lazy) dense matrices this call performs
5476    /// an O(n) single-column materialization via `extract_column`; callers
5477    /// sweeping many cells should call `as_dense_cow()` or `to_dense()`
5478    /// once and index the returned array directly — calling `get` in a
5479    /// per-cell loop on a Lazy operator is O(nrows · ncols) per call
5480    /// because the operator has no dense cache.
5481    #[inline]
5482    pub fn get(&self, i: usize, j: usize) -> f64 {
5483        match self {
5484            Self::Dense(matrix) => match matrix.as_dense_ref() {
5485                Some(dense) => dense[[i, j]],
5486                // Lazy operator: pull a single column via apply(e_j) so that
5487                // each call is O(n) instead of O(nrows · ncols); the default
5488                // `try_to_dense_arc` would re-materialize the full operator
5489                // on every call because Lazy operators have no dense cache.
5490                None => {
5491                    let mut e_j = Array1::<f64>::zeros(matrix.ncols());
5492                    e_j[j] = 1.0;
5493                    matrix.apply(&e_j)[i]
5494                }
5495            },
5496            Self::Sparse(sp) => {
5497                // SAFETY: `DesignMatrix::get` is documented as an
5498                // infallible scalar accessor; callers that take this path
5499                // have already accepted dense materialization. A
5500                // densification failure here means the sparse matrix exceeds
5501                // the conservative byte budget, which `DesignMatrix::get`
5502                // contractually forbids.
5503                // SAFETY: `get` is an infallible scalar accessor; caller has accepted dense materialization budget.
5504                let dense = sp
5505                    .try_to_dense_arc("DesignMatrix::get")
5506                    .unwrap_or_else(|msg| std::panic::panic_any(msg));
5507                dense[[i, j]]
5508            }
5509        }
5510    }
5511
5512    /// Extract a single column as a dense vector without full densification.
5513    ///
5514    /// - `Dense`: O(n) column copy.
5515    /// - `Sparse` (CSC): O(nnz_j) using the column pointer structure.
5516    /// - lazy `Dense`: O(matvec) via unit-vector application.
5517    pub fn extract_column(&self, j: usize) -> Array1<f64> {
5518        let mut column = Array1::zeros(self.nrows());
5519        self.column_into(j, column.view_mut());
5520        column
5521    }
5522
5523    /// Batched column extraction: returns an `nrows × cols.len()` dense block
5524    /// whose k-th column equals `extract_column(cols[k])`.
5525    ///
5526    /// For lazy operator-backed designs this routes through the operator's
5527    /// `apply_columns`, which `ReparamOperator` implements as a single GEMM
5528    /// (`X · Qs[:, cols]`) instead of one matvec dispatch per column.
5529    pub fn extract_columns(&self, cols: &[usize]) -> Array2<f64> {
5530        match self {
5531            Self::Dense(m) => match m {
5532                DenseDesignMatrix::Materialized(mat) => mat.select(Axis(1), cols),
5533                DenseDesignMatrix::Lazy(op) => op.apply_columns(cols),
5534            },
5535            Self::Sparse(sp) => {
5536                let n = sp.nrows();
5537                let mut out = Array2::<f64>::zeros((n, cols.len()));
5538                let (symbolic, values) = sp.parts();
5539                let col_ptr = symbolic.col_ptr();
5540                let row_idx = symbolic.row_idx();
5541                for (k, &j) in cols.iter().enumerate() {
5542                    let start = col_ptr[j];
5543                    let end = col_ptr[j + 1];
5544                    let mut out_col = out.column_mut(k);
5545                    for idx in start..end {
5546                        out_col[row_idx[idx]] += values[idx];
5547                    }
5548                }
5549                out
5550            }
5551        }
5552    }
5553
5554    /// Returns a reference to the inner dense array if this is a `Dense` variant.
5555    pub fn as_dense_ref(&self) -> Option<&Array2<f64>> {
5556        match self {
5557            Self::Dense(matrix) => matrix.as_dense_ref(),
5558            Self::Sparse(_) => None,
5559        }
5560    }
5561
5562    /// Whether this design is backed by a sparse (CSR/COO) representation
5563    /// rather than a dense or dense-operator backing. Used to gate the
5564    /// row-chunked `Xᵀ diag(w) X` BLAS-3 Gram path, which is structurally
5565    /// applicable only to dense / dense-operator designs (a sparse block must
5566    /// keep the generic sparse-aware per-row pullback).
5567    pub const fn is_sparse(&self) -> bool {
5568        matches!(self, Self::Sparse(_))
5569    }
5570
5571    /// Borrow when already-materialized dense, otherwise materialize via
5572    /// chunks (or via the sparse conversion path) and return an owned `Cow`.
5573    ///
5574    /// Use this when a code path genuinely needs a contiguous `Array2<f64>`
5575    /// view of an operator-backed design (e.g. legacy dense linear-algebra
5576    /// helpers that the operator-aware code paths have not yet replaced).
5577    /// Prefer `try_row_chunk` / `matrixvectormultiply` when chunked or
5578    /// matrix-free access suffices.
5579    pub fn to_dense_cow(&self) -> Cow<'_, Array2<f64>> {
5580        match self {
5581            Self::Dense(DenseDesignMatrix::Materialized(matrix)) => Cow::Borrowed(matrix.as_ref()),
5582            Self::Dense(DenseDesignMatrix::Lazy(lazy)) => {
5583                if let Some(dense) = lazy.as_dense_ref() {
5584                    Cow::Borrowed(dense)
5585                } else {
5586                    let policy = ResourcePolicy::default_library();
5587                    panic_or_error_if_large_scale_mode_and_to_dense_called_with_policy(
5588                        "DesignMatrix::to_dense_cow",
5589                        lazy.nrows(),
5590                        lazy.ncols(),
5591                        &policy,
5592                    )
5593                    .unwrap_or_else(|reason| std::panic::panic_any(reason));
5594                    // Materialize (or reuse) the design's governed dense memo
5595                    // and borrow from it: zero-copy for repeat callers, and
5596                    // the bytes stay charged on the joint ledger for the
5597                    // memo's lifetime instead of escaping as an unaccounted
5598                    // owned buffer per call.
5599                    lazy.try_governed_dense_arc("DesignMatrix::to_dense_cow")
5600                        // SAFETY: dense-by-contract accessor; refusal means the joint ledger cannot fit this design's dense form.
5601                        .unwrap_or_else(|msg| std::panic::panic_any(msg));
5602                    Cow::Borrowed(
5603                        lazy.dense_memo
5604                            .get()
5605                            .expect("memo initialized by try_governed_dense_arc just above")
5606                            .as_ref()
5607                            .as_ref(),
5608                    )
5609                }
5610            }
5611            Self::Sparse(matrix) => Cow::Owned(
5612                matrix
5613                    .try_to_dense_arc("DesignMatrix::to_dense_cow")
5614                    // SAFETY: callers of `to_dense_cow` have committed to a
5615                    // dense `Array2<f64>` consumer; densification failure
5616                    // would mean the sparse matrix exceeds the conservative
5617                    // byte cap which this accessor's contract forbids.
5618                    // SAFETY: caller of to_dense_cow has accepted dense materialization budget.
5619                    .unwrap_or_else(|msg| std::panic::panic_any(msg))
5620                    .as_ref()
5621                    .clone(),
5622            ),
5623        }
5624    }
5625
5626    /// Returns the design as a contiguous `Array2<f64>`.
5627    ///
5628    /// Operator-backed designs consult the available-memory-derived policy
5629    /// before allocating. Production code that can handle refusal must prefer
5630    /// [`Self::try_to_dense_governed`], which additionally holds the
5631    /// process-wide reservation for the returned matrix's whole lifetime.
5632    ///
5633    /// Sparse designs refuse to densify past the process memory budget
5634    /// (an n×p dense materialization that can never fit is a caller bug —
5635    /// the design should have stayed sparse).
5636    pub fn to_dense(&self) -> Array2<f64> {
5637        match self {
5638            Self::Dense(matrix) => matrix.to_dense(),
5639            Self::Sparse(matrix) => matrix
5640                .try_to_dense_arc("DesignMatrix::to_dense")
5641                // SAFETY: dense-by-contract accessor; failure means the dense footprint exceeds the whole process budget.
5642                .unwrap_or_else(|msg| std::panic::panic_any(msg))
5643                .as_ref()
5644                .clone(),
5645        }
5646    }
5647
5648    /// Arc-shared variant of [`Self::to_dense`], with the same policy guard.
5649    pub fn to_dense_arc(&self) -> Arc<Array2<f64>> {
5650        match self {
5651            Self::Dense(matrix) => matrix.to_dense_arc(),
5652            Self::Sparse(matrix) => matrix
5653                .try_to_dense_arc("DesignMatrix::to_dense_arc")
5654                // SAFETY: dense-by-contract accessor; failure means the dense footprint exceeds the whole process budget.
5655                .unwrap_or_else(|msg| std::panic::panic_any(msg)),
5656        }
5657    }
5658
5659    pub fn try_to_dense_arc(&self, context: &str) -> Result<Arc<Array2<f64>>, String> {
5660        match self {
5661            Self::Dense(matrix) => matrix.try_to_dense_arc(context),
5662            Self::Sparse(matrix) => matrix.try_to_dense_arc(context),
5663        }
5664    }
5665
5666    pub fn to_csr_cache(&self) -> Option<SparseRowMat<usize, f64>> {
5667        match self {
5668            Self::Dense(_) => None,
5669            Self::Sparse(matrix) => matrix.to_csr_arc().map(|arc| (*arc).clone()),
5670        }
5671    }
5672
5673    pub fn as_sparse(&self) -> Option<&SparseDesignMatrix> {
5674        match self {
5675            Self::Sparse(matrix) => Some(matrix),
5676            Self::Dense(_) => None,
5677        }
5678    }
5679
5680    pub fn as_dense(&self) -> Option<&Array2<f64>> {
5681        match self {
5682            Self::Dense(matrix) => matrix.as_dense_ref(),
5683            Self::Sparse(_) => None,
5684        }
5685    }
5686
5687    fn apply_transpose_view(&self, vector: ArrayView1<'_, f64>) -> Array1<f64> {
5688        match self {
5689            Self::Dense(DenseDesignMatrix::Materialized(matrix)) => fast_atv(matrix, &vector),
5690            Self::Dense(DenseDesignMatrix::Lazy(op)) => op.apply_transpose(&vector.to_owned()),
5691            Self::Sparse(matrix) => {
5692                let mut output = Array1::<f64>::zeros(matrix.ncols());
5693                let (symbolic, values) = matrix.parts();
5694                let col_ptr = symbolic.col_ptr();
5695                let row_idx = symbolic.row_idx();
5696                for col in 0..matrix.ncols() {
5697                    let mut acc = 0.0;
5698                    let start = col_ptr[col];
5699                    let end = col_ptr[col + 1];
5700                    for idx in start..end {
5701                        acc += values[idx] * vector[row_idx[idx]];
5702                    }
5703                    output[col] = acc;
5704                }
5705                output
5706            }
5707        }
5708    }
5709
5710    fn diag_gram_view(&self, weights: ArrayView1<'_, f64>) -> Result<Array1<f64>, String> {
5711        if weights.len() != self.nrows() {
5712            return Err(format!(
5713                "diag_gram dimension mismatch: weights length {} != nrows {}",
5714                weights.len(),
5715                self.nrows()
5716            ));
5717        }
5718        FiniteSignedWeightsView::try_new(weights)
5719            .map_err(|reason| format!("DesignMatrix::diag_gram_view: {reason}"))?;
5720        match self {
5721            Self::Dense(DenseDesignMatrix::Materialized(matrix)) => {
5722                Ok(dense_diag_gram_view(matrix, weights))
5723            }
5724            Self::Dense(DenseDesignMatrix::Lazy(op)) => op.diag_gram(&weights.to_owned()),
5725            Self::Sparse(xs) => {
5726                let p = xs.ncols();
5727                let csr = xs
5728                    .to_csr_arc()
5729                    .ok_or_else(|| "failed to obtain CSR view in diag_gram".to_string())?;
5730                let sym = csr.symbolic();
5731                Ok(sparse_csr_diag_gram(
5732                    sym.row_ptr(),
5733                    sym.col_idx(),
5734                    csr.val(),
5735                    xs.nrows(),
5736                    p,
5737                    weights,
5738                ))
5739            }
5740        }
5741    }
5742
5743    fn compute_xtwy_view(
5744        &self,
5745        weights: ArrayView1<'_, f64>,
5746        y: ArrayView1<'_, f64>,
5747    ) -> Result<Array1<f64>, String> {
5748        if weights.len() != self.nrows() || y.len() != self.nrows() {
5749            return Err(format!(
5750                "compute_xtwy dimension mismatch: weights={}, y={}, nrows={}",
5751                weights.len(),
5752                y.len(),
5753                self.nrows()
5754            ));
5755        }
5756        FiniteSignedWeightsView::try_new(weights)
5757            .map_err(|reason| format!("DesignMatrix::compute_xtwy_view: {reason}"))?;
5758        match self {
5759            Self::Dense(DenseDesignMatrix::Materialized(matrix)) => {
5760                Ok(dense_transpose_weighted_response_view(matrix, weights, y))
5761            }
5762            Self::Dense(DenseDesignMatrix::Lazy(op)) => {
5763                op.compute_xtwy(&weights.to_owned(), &y.to_owned())
5764            }
5765            Self::Sparse(xs) => {
5766                let csr = xs
5767                    .as_ref()
5768                    .to_row_major()
5769                    .map_err(|_| "failed to obtain CSR view in compute_xtwy".to_string())?;
5770                let sym = csr.symbolic();
5771                let row_ptr = sym.row_ptr();
5772                let col_idx = sym.col_idx();
5773                let vals = csr.val();
5774                let mut out = Array1::<f64>::zeros(xs.ncols());
5775                for i in 0..xs.nrows() {
5776                    let scaled = weights[i] * y[i];
5777                    if scaled == 0.0 {
5778                        continue;
5779                    }
5780                    for idx in row_ptr[i]..row_ptr[i + 1] {
5781                        out[col_idx[idx]] += vals[idx] * scaled;
5782                    }
5783                }
5784                Ok(out)
5785            }
5786        }
5787    }
5788
5789    pub fn dot(&self, vector: &Array1<f64>) -> Array1<f64> {
5790        <Self as LinearOperator>::apply(self, vector)
5791    }
5792
5793    pub fn matrixvectormultiply(&self, vector: &Array1<f64>) -> Array1<f64> {
5794        <Self as LinearOperator>::apply(self, vector)
5795    }
5796
5797    pub fn transpose_vector_multiply(&self, vector: &Array1<f64>) -> Array1<f64> {
5798        <Self as LinearOperator>::apply_transpose(self, vector)
5799    }
5800
5801    pub fn compute_xtwy(
5802        &self,
5803        weights: &Array1<f64>,
5804        y: &Array1<f64>,
5805    ) -> Result<Array1<f64>, String> {
5806        <Self as DenseDesignOperator>::compute_xtwy(self, weights, y)
5807    }
5808
5809    pub fn diag_gram(&self, weights: &Array1<f64>) -> Result<Array1<f64>, String> {
5810        <Self as LinearOperator>::diag_gram(self, weights)
5811    }
5812
5813    pub fn quadratic_form_diag(&self, middle: &Array2<f64>) -> Result<Array1<f64>, String> {
5814        <Self as DenseDesignOperator>::quadratic_form_diag(self, middle)
5815    }
5816
5817    pub fn apply_weighted_normal(
5818        &self,
5819        weights: &Array1<f64>,
5820        vector: &Array1<f64>,
5821        penalty: Option<&Array2<f64>>,
5822        ridge: f64,
5823    ) -> Result<Array1<f64>, String> {
5824        let finite =
5825            certify_signed_weights("DesignMatrix::apply_weighted_normal", weights, self.nrows())?;
5826        if vector.len() != self.ncols() {
5827            return Err(format!(
5828                "DesignMatrix::apply_weighted_normal vector length mismatch: vector={}, ncols={}",
5829                vector.len(),
5830                self.ncols()
5831            ));
5832        }
5833        Ok(<Self as LinearOperator>::apply_weighted_normal(
5834            self, finite, vector, penalty, ridge,
5835        ))
5836    }
5837
5838    pub fn solve_system(
5839        &self,
5840        weights: &Array1<f64>,
5841        rhs: &Array1<f64>,
5842        penalty: Option<&Array2<f64>>,
5843    ) -> Result<Array1<f64>, String> {
5844        <Self as LinearOperator>::solve_system(self, weights, rhs, penalty)
5845    }
5846
5847    pub fn solve_systemwith_policy(
5848        &self,
5849        weights: &Array1<f64>,
5850        rhs: &Array1<f64>,
5851        penalty: Option<&Array2<f64>>,
5852        ridge_floor: f64,
5853        ridge_policy: RidgePolicy,
5854    ) -> Result<Array1<f64>, String> {
5855        <Self as LinearOperator>::solve_systemwith_policy(
5856            self,
5857            weights,
5858            rhs,
5859            penalty,
5860            ridge_floor,
5861            ridge_policy,
5862        )
5863    }
5864
5865    pub fn factorize_system(
5866        &self,
5867        weights: &Array1<f64>,
5868        penalty: Option<&Array2<f64>>,
5869    ) -> Result<Box<dyn FactorizedSystem>, String> {
5870        <Self as LinearOperator>::factorize_system(self, weights, penalty)
5871    }
5872}
5873
5874impl<'a> From<ArrayView2<'a, f64>> for DesignMatrix {
5875    fn from(value: ArrayView2<'a, f64>) -> Self {
5876        Self::Dense(DenseDesignMatrix::from(value.to_owned()))
5877    }
5878}
5879
5880impl From<Array2<f64>> for DesignMatrix {
5881    fn from(value: Array2<f64>) -> Self {
5882        Self::Dense(DenseDesignMatrix::from(value))
5883    }
5884}
5885
5886impl From<Arc<Array2<f64>>> for DesignMatrix {
5887    fn from(value: Arc<Array2<f64>>) -> Self {
5888        Self::Dense(DenseDesignMatrix::from(value))
5889    }
5890}
5891
5892impl From<&Array2<f64>> for DesignMatrix {
5893    fn from(value: &Array2<f64>) -> Self {
5894        Self::Dense(DenseDesignMatrix::from(value.clone()))
5895    }
5896}
5897
5898impl From<DenseDesignMatrix> for DesignMatrix {
5899    fn from(value: DenseDesignMatrix) -> Self {
5900        Self::Dense(value)
5901    }
5902}
5903
5904impl From<SparseColMat<usize, f64>> for DesignMatrix {
5905    fn from(value: SparseColMat<usize, f64>) -> Self {
5906        Self::Sparse(SparseDesignMatrix::new(value))
5907    }
5908}
5909
5910impl From<&SparseColMat<usize, f64>> for DesignMatrix {
5911    fn from(value: &SparseColMat<usize, f64>) -> Self {
5912        Self::Sparse(SparseDesignMatrix::new(value.clone()))
5913    }
5914}
5915
5916impl From<&DesignMatrix> for DesignMatrix {
5917    fn from(value: &DesignMatrix) -> Self {
5918        value.clone()
5919    }
5920}
5921
5922impl From<DesignMatrix> for DesignBlock {
5923    fn from(value: DesignMatrix) -> Self {
5924        match value {
5925            DesignMatrix::Dense(matrix) => Self::Dense(matrix),
5926            DesignMatrix::Sparse(matrix) => Self::Sparse(matrix),
5927        }
5928    }
5929}
5930
5931impl From<&DesignMatrix> for DesignBlock {
5932    fn from(value: &DesignMatrix) -> Self {
5933        match value {
5934            DesignMatrix::Dense(matrix) => Self::Dense(matrix.clone()),
5935            DesignMatrix::Sparse(matrix) => Self::Sparse(matrix.clone()),
5936        }
5937    }
5938}
5939
5940#[cfg(test)]
5941mod tests {
5942    #[test]
5943    fn array2_bits_fingerprint_is_a_value_identity_not_an_address() {
5944        use ndarray::array;
5945        let a = array![[1.0, 2.0], [3.0, 4.0]];
5946        let same_values_elsewhere = a.clone();
5947        assert_eq!(
5948            super::array2_bits_fingerprint(&a),
5949            super::array2_bits_fingerprint(&same_values_elsewhere),
5950            "equal values must fingerprint equal wherever they live"
5951        );
5952        let one_ulp = array![[1.0, 2.0], [3.0, f64::from_bits(4.0_f64.to_bits() + 1)]];
5953        assert_ne!(super::array2_bits_fingerprint(&a), super::array2_bits_fingerprint(&one_ulp));
5954        let signed_zero = array![[0.0, 2.0], [3.0, 4.0]];
5955        let negative_zero = array![[-0.0, 2.0], [3.0, 4.0]];
5956        assert_ne!(
5957            super::array2_bits_fingerprint(&signed_zero),
5958            super::array2_bits_fingerprint(&negative_zero)
5959        );
5960        let transposed_shape = array![[1.0, 2.0, 3.0, 4.0]];
5961        assert_ne!(
5962            super::array2_bits_fingerprint(&a),
5963            super::array2_bits_fingerprint(&transposed_shape),
5964            "shape is part of the identity"
5965        );
5966    }
5967
5968    use super::{BlockDesignOperator, CoefficientTransformOperator, ConditionedDesign, DenseDesignMatrix, DenseDesignOperator, DesignBlock, DesignMatrix, EmbeddedColumnBlock, FiniteSignedWeightsView, MultiChannelOperator, PsdWeightsView, RandomEffectOperator, ReparamOperator, RowwiseKroneckerOperator, SparseDesignMatrix, dense_operator_to_dense_by_chunks, dense_transpose_weighted_response, fast_atv, fast_av, streaming_sparse_csc_xt_diag_x, weighted_crossprod_dense_view};
5969    use crate::matrix::LinearOperator;
5970    use crate::test_support::no_densify_design;
5971    use faer::sparse::{SparseColMat, SymbolicSparseColMat, Triplet};
5972    use gam_runtime::resource::{MaterializationPolicy, MatrixMaterializationError, ResourcePolicy};
5973    use ndarray::{Array1, Array2, ArrayViewMut2, Axis, array, s};
5974    use std::ops::Range;
5975    use std::sync::Arc;
5976    use std::sync::atomic::{AtomicUsize, Ordering};
5977
5978    struct ChunkOnlyOperator {
5979        n: usize,
5980        p: usize,
5981        row_chunk_calls: AtomicUsize,
5982        materialization_policy: Option<MaterializationPolicy>,
5983    }
5984
5985    impl ChunkOnlyOperator {
5986        fn value(&self, i: usize, j: usize) -> f64 {
5987            ((i % 251) as f64) * 0.25 - ((j % 127) as f64) * 0.5 + ((i + j) % 7) as f64
5988        }
5989    }
5990
5991    impl LinearOperator for ChunkOnlyOperator {
5992        fn nrows(&self) -> usize {
5993            self.n
5994        }
5995
5996        fn ncols(&self) -> usize {
5997            self.p
5998        }
5999
6000        fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
6001            let mut out = Array1::<f64>::zeros(self.n);
6002            for i in 0..self.n {
6003                let mut acc = 0.0;
6004                for j in 0..self.p {
6005                    acc += self.value(i, j) * vector[j];
6006                }
6007                out[i] = acc;
6008            }
6009            out
6010        }
6011
6012        fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
6013            let mut out = Array1::<f64>::zeros(self.p);
6014            for i in 0..self.n {
6015                for j in 0..self.p {
6016                    out[j] += self.value(i, j) * vector[i];
6017                }
6018            }
6019            out
6020        }
6021
6022        fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
6023            let dense = dense_operator_to_dense_by_chunks(self).map_err(|err| err.to_string())?;
6024            let psd = PsdWeightsView::try_new(weights.view())?;
6025            Ok(weighted_crossprod_dense_view(&dense, psd.view(), &dense))
6026        }
6027    }
6028
6029    impl DenseDesignOperator for ChunkOnlyOperator {
6030        fn materialization_policy(&self) -> Option<MaterializationPolicy> {
6031            self.materialization_policy.clone()
6032        }
6033
6034        fn row_chunk_into(
6035            &self,
6036            rows: Range<usize>,
6037            mut out: ArrayViewMut2<'_, f64>,
6038        ) -> Result<(), MatrixMaterializationError> {
6039            self.row_chunk_calls.fetch_add(1, Ordering::SeqCst);
6040            if out.nrows() != rows.end - rows.start || out.ncols() != self.p {
6041                return Err(MatrixMaterializationError::MissingRowChunk {
6042                    context: "ChunkOnlyOperator::row_chunk_into shape mismatch",
6043                });
6044            }
6045            for (local, row) in rows.enumerate() {
6046                for col in 0..self.p {
6047                    out[[local, col]] = self.value(row, col);
6048                }
6049            }
6050            Ok(())
6051        }
6052
6053        fn to_dense(&self) -> Array2<f64> {
6054            // SAFETY: test-only mock asserting row_chunk_into is exercised; reaching to_dense indicates a routing regression.
6055            panic!("ChunkOnlyOperator::to_dense fallback must not be used")
6056        }
6057    }
6058
6059    struct DirectFillOnlyOperator {
6060        values: Array2<f64>,
6061        row_chunk_calls: AtomicUsize,
6062    }
6063
6064    impl LinearOperator for DirectFillOnlyOperator {
6065        fn nrows(&self) -> usize {
6066            self.values.nrows()
6067        }
6068
6069        fn ncols(&self) -> usize {
6070            self.values.ncols()
6071        }
6072
6073        fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
6074            self.values.dot(vector)
6075        }
6076
6077        fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
6078            self.values.t().dot(vector)
6079        }
6080
6081        fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
6082            let mut out = Array2::<f64>::zeros((self.ncols(), self.ncols()));
6083            for row in 0..self.nrows() {
6084                for left in 0..self.ncols() {
6085                    for right in 0..self.ncols() {
6086                        out[[left, right]] +=
6087                            weights[row] * self.values[[row, left]] * self.values[[row, right]];
6088                    }
6089                }
6090            }
6091            Ok(out)
6092        }
6093    }
6094
6095    impl DenseDesignOperator for DirectFillOnlyOperator {
6096        fn row_chunk_into(
6097            &self,
6098            rows: Range<usize>,
6099            mut out: ArrayViewMut2<'_, f64>,
6100        ) -> Result<(), MatrixMaterializationError> {
6101            self.row_chunk_calls.fetch_add(1, Ordering::SeqCst);
6102            if rows.end > self.nrows()
6103                || out.nrows() != rows.end - rows.start
6104                || out.ncols() != self.ncols()
6105            {
6106                return Err(MatrixMaterializationError::MissingRowChunk {
6107                    context: "DirectFillOnlyOperator::row_chunk_into shape mismatch",
6108                });
6109            }
6110            out.assign(&self.values.slice(s![rows, ..]));
6111            Ok(())
6112        }
6113
6114        fn try_row_chunk(
6115            &self,
6116            rows: Range<usize>,
6117        ) -> Result<Array2<f64>, MatrixMaterializationError> {
6118            panic!(
6119                "DirectFillOnlyOperator owned row chunk {}..{} is forbidden",
6120                rows.start, rows.end
6121            )
6122        }
6123
6124        fn to_dense(&self) -> Array2<f64> {
6125            panic!("DirectFillOnlyOperator dense materialization is forbidden")
6126        }
6127    }
6128
6129    #[test]
6130    fn fast_av_matches_ndarray_dot() {
6131        let x = array![[1.0, 2.0, -1.0], [0.5, -3.0, 4.0], [2.0, 0.0, 1.5]];
6132        let v = array![0.25, -1.0, 2.0];
6133        let expected = x.dot(&v);
6134        let got = fast_av(&x, &v);
6135        for i in 0..expected.len() {
6136            assert!((expected[i] - got[i]).abs() < 1e-12);
6137        }
6138    }
6139
6140    #[test]
6141    fn fast_atv_matches_ndarray_dot() {
6142        let x = array![[1.0, 2.0, -1.0], [0.5, -3.0, 4.0], [2.0, 0.0, 1.5]];
6143        let v = array![0.25, -1.0, 2.0];
6144        let expected = x.t().dot(&v);
6145        let got = fast_atv(&x, &v);
6146        for i in 0..expected.len() {
6147            assert!((expected[i] - got[i]).abs() < 1e-12);
6148        }
6149    }
6150
6151    #[test]
6152    fn sparse_to_dense_accumulates_duplicate_entries() {
6153        // `to_dense_arc` charges the process-global ledger, so this test must
6154        // not overlap the ledger-pressure test; see `LEDGER_PRESSURE`. Without
6155        // this guard the densification is refused and `panic_any`s, which
6156        // reads as a duplicate-accumulation failure it is not.
6157        let ledger = ledger_read_guard();
6158        assert_eq!(*ledger, (), "ledger read guard is held for this test");
6159        // Build a non-canonical CSC with duplicate row index in the same column.
6160        // This can happen if a caller bypasses canonical constructors.
6161        let symbolic = SymbolicSparseColMat::new_unsorted_checked(
6162            3,
6163            2,
6164            vec![0_usize, 2, 3],
6165            None,
6166            vec![1_usize, 1, 0],
6167        );
6168        let sparse = SparseColMat::new(symbolic, vec![2.0_f64, 3.5, -1.0]);
6169        let design = DesignMatrix::from(sparse);
6170        let dense = design.to_dense_arc();
6171
6172        assert!((dense[[1, 0]] - 5.5).abs() < 1e-12);
6173        assert!((dense[[0, 1]] + 1.0).abs() < 1e-12);
6174
6175        let v = array![4.0, -2.0];
6176        let y_sparse = design.matrixvectormultiply(&v);
6177        let y_dense = dense.dot(&v);
6178        for i in 0..y_sparse.len() {
6179            assert!((y_sparse[i] - y_dense[i]).abs() < 1e-12);
6180        }
6181    }
6182
6183    #[test]
6184    fn sparse_column_extractors_accumulate_duplicate_entries() {
6185        // `to_dense` charges the process-global ledger; see `LEDGER_PRESSURE`.
6186        let ledger = ledger_read_guard();
6187        assert_eq!(*ledger, (), "ledger read guard is held for this test");
6188        // Same non-canonical CSC as the to_dense fixture: column 0 carries two
6189        // entries at row 1 (2.0 + 3.5 = 5.5); column 1 a single -1.0 at row 0.
6190        // column_into and extract_columns must accumulate duplicates exactly
6191        // like to_dense/apply, not last-write-wins.
6192        let symbolic = SymbolicSparseColMat::new_unsorted_checked(
6193            3,
6194            2,
6195            vec![0_usize, 2, 3],
6196            None,
6197            vec![1_usize, 1, 0],
6198        );
6199        let sparse = SparseColMat::new(symbolic, vec![2.0_f64, 3.5, -1.0]);
6200        let design = DesignMatrix::from(sparse);
6201        let dense = design.to_dense();
6202
6203        let mut col0 = Array1::<f64>::zeros(3);
6204        design.column_into(0, col0.view_mut());
6205        assert!((col0[1] - 5.5).abs() < 1e-12);
6206        for i in 0..3 {
6207            assert!((col0[i] - dense[[i, 0]]).abs() < 1e-12);
6208        }
6209
6210        let block = design.extract_columns(&[0, 1]);
6211        assert!((block[[1, 0]] - 5.5).abs() < 1e-12);
6212        assert!((block[[0, 1]] + 1.0).abs() < 1e-12);
6213        for i in 0..3 {
6214            for (k, &j) in [0usize, 1].iter().enumerate() {
6215                assert!((block[[i, k]] - dense[[i, j]]).abs() < 1e-12);
6216            }
6217        }
6218    }
6219
6220    #[test]
6221    fn huge_sparse_densification_is_rejected_before_allocation() {
6222        // 2^44 × 4 cells → 2^49 dense bytes (512 TiB): over any physical
6223        // process budget, so the refusal is machine-independent. The column
6224        // count stays small so the sparse symbolic metadata is tiny.
6225        let sparse = SparseColMat::try_new_from_triplets(1usize << 44, 4, &[])
6226            .expect("empty sparse matrix should build");
6227        let design = SparseDesignMatrix::new(sparse);
6228        let err = design
6229            .try_to_dense_arc("matrix test")
6230            .expect_err("huge sparse densification should be rejected");
6231        assert!(err.contains("refusing to densify sparse design"));
6232    }
6233
6234    /// Serializes the tests that make the process-global memory ledger
6235    /// unavailable against the tests that need it.
6236    ///
6237    /// `MemoryGovernor::global()` is process-wide and `cargo test` runs this
6238    /// module's tests concurrently in one process, so a test that reserves
6239    /// `governor.remaining_bytes()` makes EVERY concurrent densification
6240    /// refuse — `SparseDesignMatrix::try_to_dense_arc` answers a refusal with
6241    /// `panic_any`, which surfaces as an unrelated test failing on an
6242    /// unrelated assertion. Ledger-exhausting tests take the write guard for
6243    /// the whole of their pressure region; every test that densifies takes the
6244    /// read guard, so they still run concurrently with each other.
6245    static LEDGER_PRESSURE: std::sync::RwLock<()> = std::sync::RwLock::new(());
6246
6247    /// Guard for a test that densifies and therefore needs ledger headroom.
6248    fn ledger_read_guard() -> std::sync::RwLockReadGuard<'static, ()> {
6249        LEDGER_PRESSURE
6250            .read()
6251            .unwrap_or_else(|poisoned| poisoned.into_inner())
6252    }
6253
6254    #[test]
6255    fn streaming_sparse_csc_xt_diag_x_matches_dense_signed_weights() {
6256        let sparse = SparseColMat::try_new_from_triplets(
6257            4,
6258            3,
6259            &[
6260                Triplet::new(0, 0, 1.0),
6261                Triplet::new(1, 0, 2.0),
6262                Triplet::new(2, 0, -1.0),
6263                Triplet::new(0, 1, 0.5),
6264                Triplet::new(1, 1, -3.0),
6265                Triplet::new(3, 1, 4.0),
6266                Triplet::new(0, 2, 2.0),
6267                Triplet::new(2, 2, 1.5),
6268                Triplet::new(3, 2, -0.25),
6269            ],
6270        )
6271        .expect("sparse matrix");
6272        let design = SparseDesignMatrix::new(sparse.clone());
6273        // `to_dense_arc` charges the process-global ledger; see `LEDGER_PRESSURE`.
6274        let ledger = ledger_read_guard();
6275        assert_eq!(*ledger, (), "ledger read guard is held for this test");
6276        let dense = design.to_dense_arc();
6277        let weights = array![1.0, -2.0, 0.5, -1.5];
6278        let (symbolic, values) = sparse.parts();
6279        let mut got = Array2::<f64>::zeros((3, 3));
6280        streaming_sparse_csc_xt_diag_x(
6281            symbolic.col_ptr(),
6282            symbolic.row_idx(),
6283            values,
6284            4,
6285            3,
6286            weights.view(),
6287            &mut got,
6288        );
6289
6290        let mut expected = Array2::<f64>::zeros((3, 3));
6291        for row in 0..4 {
6292            for a in 0..3 {
6293                for b in 0..3 {
6294                    expected[[a, b]] += weights[row] * dense[[row, a]] * dense[[row, b]];
6295                }
6296            }
6297        }
6298        let max_diff = (&got - &expected)
6299            .iter()
6300            .map(|v| v.abs())
6301            .fold(0.0_f64, f64::max);
6302        assert!(
6303            max_diff < 1e-12,
6304            "streamed sparse weighted Gram mismatch: max_diff={max_diff}"
6305        );
6306    }
6307
6308    #[test]
6309    fn block_design_row_chunk_into_fills_mixed_blocks_without_owned_child_chunks() {
6310        let eager = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]];
6311        let lazy = Arc::new(DirectFillOnlyOperator {
6312            values: array![[10.0, 11.0], [12.0, 13.0], [14.0, 15.0], [16.0, 17.0]],
6313            row_chunk_calls: AtomicUsize::new(0),
6314        });
6315        let sparse = SparseColMat::try_new_from_triplets(
6316            4,
6317            3,
6318            &[
6319                Triplet::new(0, 0, 20.0),
6320                Triplet::new(1, 1, 21.0),
6321                Triplet::new(2, 2, 22.0),
6322                Triplet::new(3, 0, 23.0),
6323            ],
6324        )
6325        .expect("sparse block");
6326        let random_effect = Arc::new(RandomEffectOperator::new(
6327            vec![Some(0), None, Some(1), Some(0)],
6328            2,
6329        ));
6330        let op = BlockDesignOperator::new(vec![
6331            DesignBlock::Dense(DenseDesignMatrix::from(eager)),
6332            DesignBlock::Dense(DenseDesignMatrix::from(Arc::clone(&lazy))),
6333            DesignBlock::Sparse(SparseDesignMatrix::new(sparse)),
6334            DesignBlock::RandomEffect(random_effect),
6335            DesignBlock::Intercept(4),
6336        ])
6337        .expect("mixed block design");
6338
6339        let mut got = Array2::<f64>::from_elem((3, 10), f64::NAN);
6340        op.row_chunk_into(1..4, got.view_mut())
6341            .expect("mixed block direct row fill");
6342
6343        assert_eq!(
6344            got,
6345            array![
6346                [3.0, 4.0, 12.0, 13.0, 0.0, 21.0, 0.0, 0.0, 0.0, 1.0],
6347                [5.0, 6.0, 14.0, 15.0, 0.0, 0.0, 22.0, 0.0, 1.0, 1.0],
6348                [7.0, 8.0, 16.0, 17.0, 23.0, 0.0, 0.0, 1.0, 0.0, 1.0],
6349            ]
6350        );
6351        assert_eq!(lazy.row_chunk_calls.load(Ordering::SeqCst), 1);
6352    }
6353
6354    #[test]
6355    fn multi_channel_row_chunk_into_crosses_boundary_without_owned_channel_chunks() {
6356        let first = Arc::new(DirectFillOnlyOperator {
6357            values: array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
6358            row_chunk_calls: AtomicUsize::new(0),
6359        });
6360        let second = Arc::new(DirectFillOnlyOperator {
6361            values: array![[10.0, 20.0], [30.0, 40.0], [50.0, 60.0]],
6362            row_chunk_calls: AtomicUsize::new(0),
6363        });
6364        let op = MultiChannelOperator::new(vec![
6365            DesignMatrix::Dense(DenseDesignMatrix::from(Arc::clone(&first))),
6366            DesignMatrix::Dense(DenseDesignMatrix::from(Arc::clone(&second))),
6367        ])
6368        .expect("direct-fill multi-channel operator");
6369
6370        let mut got = Array2::<f64>::from_elem((3, 2), f64::NAN);
6371        op.row_chunk_into(2..5, got.view_mut())
6372            .expect("cross-channel direct row fill");
6373
6374        assert_eq!(got, array![[5.0, 6.0], [10.0, 20.0], [30.0, 40.0]]);
6375        assert_eq!(first.row_chunk_calls.load(Ordering::SeqCst), 1);
6376        assert_eq!(second.row_chunk_calls.load(Ordering::SeqCst), 1);
6377    }
6378
6379    #[test]
6380    fn multi_channel_operator_view_paths_match_stacked_dense_reference() {
6381        let dense_channel = array![[1.0, 2.0], [0.5, -1.0], [3.0, 0.25]];
6382        let sparse_dense = array![[0.0, 1.5], [2.0, 0.0], [-1.0, 0.75]];
6383        let sparse = SparseColMat::try_new_from_triplets(
6384            3,
6385            2,
6386            &[
6387                Triplet::new(1, 0, 2.0),
6388                Triplet::new(2, 0, -1.0),
6389                Triplet::new(0, 1, 1.5),
6390                Triplet::new(2, 1, 0.75),
6391            ],
6392        )
6393        .expect("sparse channel");
6394        let op = MultiChannelOperator::new(vec![
6395            DesignMatrix::Dense(DenseDesignMatrix::from(dense_channel.clone())),
6396            DesignMatrix::from(sparse),
6397        ])
6398        .expect("multi-channel operator");
6399        let mut stacked = Array2::<f64>::zeros((6, 2));
6400        stacked.slice_mut(s![0..3, ..]).assign(&dense_channel);
6401        stacked.slice_mut(s![3..6, ..]).assign(&sparse_dense);
6402
6403        let beta = array![0.25, -0.4];
6404        let expected_apply = stacked.dot(&beta);
6405        let got_apply = op.apply(&beta);
6406        for i in 0..expected_apply.len() {
6407            assert!((expected_apply[i] - got_apply[i]).abs() < 1e-12);
6408        }
6409
6410        let probe = array![0.5, -1.0, 0.25, 1.5, -0.75, 0.2];
6411        let expected_transpose = stacked.t().dot(&probe);
6412        let got_transpose = op.apply_transpose(&probe);
6413        for i in 0..expected_transpose.len() {
6414            assert!((expected_transpose[i] - got_transpose[i]).abs() < 1e-12);
6415        }
6416
6417        let weights = array![1.0, -0.5, 0.75, 2.0, -0.25, 1.5];
6418        let weighted = stacked.clone() * weights.view().insert_axis(Axis(1));
6419        let expected_xtwx = stacked.t().dot(&weighted);
6420        let got_xtwx = op.diag_xtw_x(&weights).expect("multi-channel xtwx");
6421        for i in 0..expected_xtwx.nrows() {
6422            for j in 0..expected_xtwx.ncols() {
6423                assert!((expected_xtwx[[i, j]] - got_xtwx[[i, j]]).abs() < 1e-12);
6424            }
6425        }
6426
6427        let expected_diag = Array1::from_iter((0..2).map(|j| expected_xtwx[[j, j]]));
6428        let got_diag = op.diag_gram(&weights).expect("multi-channel diag gram");
6429        for i in 0..expected_diag.len() {
6430            assert!((expected_diag[i] - got_diag[i]).abs() < 1e-12);
6431        }
6432
6433        let y = array![1.0, 0.5, -0.25, 2.0, -1.0, 0.75];
6434        let expected_xtwy = stacked.t().dot(&(&weights * &y));
6435        let got_xtwy = op.compute_xtwy(&weights, &y).expect("multi-channel xtwy");
6436        for i in 0..expected_xtwy.len() {
6437            assert!((expected_xtwy[i] - got_xtwy[i]).abs() < 1e-12);
6438        }
6439    }
6440
6441    #[test]
6442    fn random_effect_weighted_operators_preserve_signed_curvature() {
6443        let op = RandomEffectOperator::new(vec![Some(0), Some(1), Some(0), None, Some(1)], 2);
6444        let weights = array![2.0, -3.0, 0.5, -7.0, 1.25];
6445        let expected_diag = array![2.5, -1.75];
6446
6447        let gram = op.diag_xtw_x(&weights).expect("signed random-effect Gram");
6448        assert_eq!(gram, Array2::from_diag(&expected_diag));
6449        assert_eq!(
6450            op.diag_gram(&weights)
6451                .expect("fixture design and weight vector share n rows"),
6452            expected_diag
6453        );
6454
6455        let dense = array![[1.0, 2.0], [3.0, -1.0], [4.0, 0.5], [9.0, 9.0], [-2.0, 3.0]];
6456        let cross = op
6457            .weighted_cross_with_dense(&dense, &weights)
6458            .expect("signed dense/random-effect cross product");
6459        let re_dense = op.to_dense();
6460        let expected_cross = dense
6461            .t()
6462            .dot(&(&re_dense * &weights.view().insert_axis(Axis(1))));
6463        assert_eq!(cross, expected_cross);
6464
6465        let beta = array![4.0, -2.0];
6466        let finite = FiniteSignedWeightsView::try_from_array(&weights)
6467            .expect("fixture weights are finite and signed-representable");
6468        let normal = op.apply_weighted_normal(finite, &beta, None, 0.0);
6469        assert_eq!(normal, &expected_diag * &beta);
6470
6471        let y = array![1.0, 2.0, -4.0, 100.0, 0.5];
6472        let got_xtwy = op
6473            .compute_xtwy(&weights, &y)
6474            .expect("fixture design and weight vector share n rows");
6475        let expected_xtwy = re_dense.t().dot(&(&weights * &y));
6476        assert_eq!(got_xtwy, expected_xtwy);
6477    }
6478
6479    #[test]
6480    fn conditioned_design_signed_gram_and_response_match_materialized_reference() {
6481        let raw = array![[1.0, 5.0], [2.0, -1.0], [-3.0, 2.0], [4.0, 7.0]];
6482        let conditioned = ConditionedDesign::new(
6483            DesignMatrix::Dense(DenseDesignMatrix::from(raw)),
6484            vec![(1, 2.0, 3.0)],
6485        );
6486        let dense = conditioned.to_dense();
6487        let weights = array![2.0, -4.0, 0.5, -1.5];
6488        let weighted = &dense * &weights.view().insert_axis(Axis(1));
6489        let expected_gram = dense.t().dot(&weighted);
6490        let got_gram = conditioned
6491            .diag_xtw_x(&weights)
6492            .expect("fixture design and weight vector share n rows");
6493        assert!(
6494            (&got_gram - &expected_gram)
6495                .iter()
6496                .all(|value| value.abs() < 1e-12)
6497        );
6498        let got_diag = conditioned
6499            .diag_gram(&weights)
6500            .expect("fixture design and weight vector share n rows");
6501        assert!(
6502            (&got_diag - &expected_gram.diag())
6503                .iter()
6504                .all(|value| value.abs() < 1e-12)
6505        );
6506
6507        let y = array![0.5, -2.0, 3.0, 1.25];
6508        let expected_xtwy = dense.t().dot(&(&weights * &y));
6509        let got_xtwy = conditioned
6510            .compute_xtwy(&weights, &y)
6511            .expect("fixture design and weight vector share n rows");
6512        assert!(
6513            (&got_xtwy - &expected_xtwy)
6514                .iter()
6515                .all(|value| value.abs() < 1e-12)
6516        );
6517    }
6518
6519    #[test]
6520    fn weighted_operator_certification_reports_smallest_nonfinite_row() {
6521        let channel = DesignMatrix::Dense(DenseDesignMatrix::from(array![[1.0], [2.0], [3.0]]));
6522        let op = MultiChannelOperator::new(vec![channel])
6523            .expect("a single channel is a valid multi-channel operator");
6524        let bad = array![1.0, f64::NAN, f64::INFINITY];
6525
6526        for err in [
6527            op.diag_xtw_x(&bad).unwrap_err(),
6528            op.diag_gram(&bad).unwrap_err(),
6529            op.compute_xtwy(&bad, &array![1.0, 1.0, 1.0]).unwrap_err(),
6530        ] {
6531            assert!(err.contains("row 1"), "unexpected diagnostic: {err}");
6532        }
6533    }
6534
6535    /// Perf (#1017): the fused scale-once + `fast_atb` Dense×Dense cross-block
6536    /// assembly in `BlockDesignOperator::diag_xtw_x` must equal the full stacked
6537    /// reference Gram `Xᵀ diag(w) X` for a multi-block dense layout with SIGNED
6538    /// weights (the observed-Hessian regime that exercises the sign-correct
6539    /// asymmetric cross kernel). Several dense blocks of differing widths so the
6540    /// off-diagonal slicing and the symmetric transpose fill are both covered.
6541    #[test]
6542    fn block_design_fused_dense_cross_matches_stacked_reference_xtwx() {
6543        let b0 = array![
6544            [1.0, 2.0],
6545            [0.5, -1.0],
6546            [3.0, 0.25],
6547            [-2.0, 1.5],
6548            [0.75, -0.5],
6549        ];
6550        let b1 = array![
6551            [-1.0, 0.5, 2.0],
6552            [1.5, -0.25, 0.0],
6553            [0.0, 1.0, -1.5],
6554            [2.0, 0.5, 1.0],
6555            [-0.5, -1.0, 0.25],
6556        ];
6557        let b2 = array![[0.5], [-1.0], [2.0], [0.25], [-0.75]];
6558
6559        let mut stacked = Array2::<f64>::zeros((5, 6));
6560        stacked.slice_mut(s![.., 0..2]).assign(&b0);
6561        stacked.slice_mut(s![.., 2..5]).assign(&b1);
6562        stacked.slice_mut(s![.., 5..6]).assign(&b2);
6563
6564        let blocks = vec![
6565            DesignBlock::Dense(DenseDesignMatrix::from(b0)),
6566            DesignBlock::Dense(DenseDesignMatrix::from(b1)),
6567            DesignBlock::Dense(DenseDesignMatrix::from(b2)),
6568        ];
6569        let op = BlockDesignOperator::new(blocks).expect("block design");
6570
6571        // Signed weights: the cross kernel must NOT clamp to PSD here.
6572        let weights = array![1.5, -0.5, 2.0, -1.0, 0.75];
6573        let weighted = stacked.clone() * weights.view().insert_axis(Axis(1));
6574        let expected = stacked.t().dot(&weighted);
6575
6576        let got = op.diag_xtw_x(&weights).expect("block fused xtwx");
6577        assert_eq!(got.dim(), (6, 6));
6578        let max_diff = (&got - &expected)
6579            .iter()
6580            .map(|v| v.abs())
6581            .fold(0.0_f64, f64::max);
6582        assert!(
6583            max_diff < 1e-10,
6584            "fused block Dense×Dense Gram mismatch: max_diff={max_diff}"
6585        );
6586    }
6587
6588    #[test]
6589    fn block_design_intercept_cross_and_diag_are_sign_honest() {
6590        // Regression for the Intercept arms of `DesignBlock::diag_xtw_x` /
6591        // `diag_gram` and `BlockDesignOperator::cross_block` silently
6592        // clamping signed working weights to `w.max(0.0)`, corrupting the
6593        // intercept row/column of the observed-Hessian Gram whenever any
6594        // weight is negative (the normal case for non-canonical-link
6595        // observed-Hessian IRLS, e.g. binomial+cloglog, Gamma+identity).
6596        let x = array![[2.0], [5.0], [-1.0], [3.0]];
6597        let mut stacked = Array2::<f64>::zeros((4, 2));
6598        stacked.column_mut(0).fill(1.0);
6599        stacked.slice_mut(s![.., 1..2]).assign(&x);
6600
6601        let blocks = vec![
6602            DesignBlock::Intercept(4),
6603            DesignBlock::Dense(DenseDesignMatrix::from(x)),
6604        ];
6605        let op = BlockDesignOperator::new(blocks).expect("block design");
6606
6607        let weights = array![3.0, -1.0, 2.0, -0.5];
6608        let weighted = stacked.clone() * weights.view().insert_axis(Axis(1));
6609        let expected = stacked.t().dot(&weighted);
6610
6611        let got = op.diag_xtw_x(&weights).expect("block fused xtwx");
6612        assert_eq!(got.dim(), (2, 2));
6613        let max_diff = (&got - &expected)
6614            .iter()
6615            .map(|v| v.abs())
6616            .fold(0.0_f64, f64::max);
6617        assert!(
6618            max_diff < 1e-10,
6619            "intercept-block Gram mismatch: got={got:?} expected={expected:?} max_diff={max_diff}"
6620        );
6621
6622        // The intercept's own diagonal entry (Σw, signed) must match too.
6623        let intercept_block = &op.blocks[0];
6624        let diag = intercept_block
6625            .diag_xtw_x(&weights)
6626            .expect("intercept diag_xtw_x");
6627        assert!((diag[[0, 0]] - weights.sum()).abs() < 1e-12);
6628        let gram = intercept_block
6629            .diag_gram(&weights)
6630            .expect("intercept diag_gram");
6631        assert!((gram[0] - weights.sum()).abs() < 1e-12);
6632    }
6633
6634    #[test]
6635    #[should_panic(expected = "ReparamOperator: X cols (2) must match Qs rows (3)")]
6636    fn reparam_operator_rejects_incompatible_transform_shape() {
6637        let x = array![[1.0, 2.0], [0.5, -1.0]];
6638        let qs = Arc::new(Array2::<f64>::zeros((3, 1)));
6639        ReparamOperator::new(DesignMatrix::Dense(DenseDesignMatrix::from(x)), qs);
6640    }
6641
6642    /// Locks in the dispatch path for the BLAS-3 cross-block fast path:
6643    /// when a `CoefficientTransformOperator` is wrapped as
6644    /// `DenseDesignMatrix::Lazy`, `DenseDesignMatrix::as_dense_ref` must reach
6645    /// the operator's cached materialization. The dispatch goes
6646    /// `DenseDesignMatrix::as_dense_ref` → `DenseDesignOperator::as_dense_ref`,
6647    /// so the override has to live on `DenseDesignOperator`, not
6648    /// `LinearOperator`. A misplaced override on `LinearOperator` is a hard
6649    /// build break today (E0407, fixed in b516891), but if `LinearOperator`
6650    /// ever grew an `as_dense_ref` slot the silent failure would be
6651    /// `BlockDesignOperator::cross_block` falling back to the chunked scalar
6652    /// path with no test signal — this assertion is the missing signal.
6653    #[test]
6654    fn coefficient_transform_operator_exposes_cached_dense_to_block_dispatch() {
6655        let inner = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
6656        let transform = array![[0.5, -1.0, 2.0], [1.0, 0.0, -0.5]];
6657        let expected = inner.dot(&transform);
6658
6659        let op =
6660            CoefficientTransformOperator::new(DenseDesignMatrix::from(inner), transform.clone())
6661                .expect("coefficient transform operator");
6662        let dense_design = DenseDesignMatrix::from(Arc::new(op));
6663
6664        // Touch the cache through any LinearOperator path (`apply_transpose`
6665        // short-circuits through `materialized_combined`). The OnceLock is empty
6666        // until something exercises it, so `as_dense_ref` would otherwise
6667        // return None before the first hot call.
6668        let probe = Array1::from_elem(3, 1.0);
6669        let warmed = dense_design.apply_transpose(&probe);
6670        assert_eq!(warmed.len(), expected.ncols());
6671
6672        let dense_ref = dense_design
6673            .as_dense_ref()
6674            .expect("DenseDesignMatrix::as_dense_ref must reach the cached X·T");
6675        assert_eq!(dense_ref.dim(), expected.dim());
6676        for ((r, c), v) in expected.indexed_iter() {
6677            assert!((dense_ref[[r, c]] - v).abs() < 1e-12);
6678        }
6679    }
6680
6681    #[test]
6682    fn coefficient_transform_operator_preserves_lazy_inner_storage() {
6683        let inner_values = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
6684        let transform = array![[0.5, -1.0], [1.0, 0.25]];
6685        let expected = inner_values.dot(&transform);
6686        let DesignMatrix::Dense(inner) = no_densify_design(inner_values) else {
6687            panic!("no-densify fixture must be dense-operator-backed");
6688        };
6689        let op = CoefficientTransformOperator::new(inner, transform)
6690            .expect("coefficient transform operator");
6691        let dense_design = DenseDesignMatrix::from(Arc::new(op));
6692
6693        let probe = Array1::from_elem(2, 1.0);
6694        let got = dense_design.apply(&probe);
6695        let want = expected.dot(&probe);
6696        for (got_i, want_i) in got.iter().zip(want.iter()) {
6697            assert!((got_i - want_i).abs() < 1e-12);
6698        }
6699        assert!(
6700            dense_design.as_dense_ref().is_none(),
6701            "coefficient transform must not materialize an operator-backed inner design"
6702        );
6703    }
6704
6705    #[test]
6706    fn sparse_factorized_solve_matches_dense_operator_solve() {
6707        let triplets = vec![
6708            Triplet::new(0usize, 0usize, 1.0),
6709            Triplet::new(1, 0, 2.0),
6710            Triplet::new(1, 1, -1.0),
6711            Triplet::new(2, 1, 3.0),
6712            Triplet::new(2, 2, 0.5),
6713        ];
6714        let sparse = SparseColMat::try_new_from_triplets(3, 3, &triplets)
6715            .expect("sparse design should build");
6716        let sparse_design = DesignMatrix::from(sparse);
6717        // `to_dense` charges the process-global ledger; see `LEDGER_PRESSURE`.
6718        let ledger = ledger_read_guard();
6719        assert_eq!(*ledger, (), "ledger read guard is held for this test");
6720        let dense_design = DesignMatrix::Dense(crate::matrix::DenseDesignMatrix::from(
6721            sparse_design.to_dense(),
6722        ));
6723        let weights = array![1.5, 0.75, 2.0];
6724        let rhs = array![1.0, -0.5, 2.0];
6725        let penalty = Array2::from_diag(&array![0.25, 0.5, 0.75]);
6726
6727        let sparse_sol = sparse_design
6728            .solve_system(&weights, &rhs, Some(&penalty))
6729            .expect("sparse solve should factorize natively");
6730        let dense_sol = dense_design
6731            .solve_system(&weights, &rhs, Some(&penalty))
6732            .expect("dense solve should factorize");
6733
6734        for i in 0..rhs.len() {
6735            assert!(
6736                (sparse_sol[i] - dense_sol[i]).abs() < 1e-10,
6737                "solution mismatch at {i}: sparse={} dense={}",
6738                sparse_sol[i],
6739                dense_sol[i]
6740            );
6741        }
6742    }
6743
6744    #[test]
6745    fn solve_system_stabilizes_indefinite_penalty_and_returns_finite_solution() {
6746        let design = DesignMatrix::Dense(crate::matrix::DenseDesignMatrix::from(array![
6747            [1.0, 0.0],
6748            [0.0, 0.0]
6749        ]));
6750        let weights = array![1.0, 1.0];
6751        let rhs = array![2.0, 0.0];
6752        let penalty = array![[0.0, 0.0], [0.0, -1e-12]];
6753
6754        let beta = design
6755            .solve_system(&weights, &rhs, Some(&penalty))
6756            .expect("solve_system should stabilize indefinite systems");
6757
6758        assert!(beta.iter().all(|v| v.is_finite()));
6759        assert!((beta[0] - 2.0).abs() < 1e-10);
6760        assert!(beta[1].abs() < 1e-8);
6761    }
6762
6763    #[test]
6764    fn compute_xtwy_dense_allocationfree_matches_matvec() {
6765        let n = 2_000usize;
6766        let p = 64usize;
6767        let mut x = Array2::<f64>::zeros((n, p));
6768        let mut y = Array1::<f64>::zeros(n);
6769        let mut w = Array1::<f64>::zeros(n);
6770        for i in 0..n {
6771            y[i] = ((i % 17) as f64 - 8.0) * 0.1;
6772            w[i] = 0.25 + ((i % 11) as f64) * 0.05;
6773            for j in 0..p {
6774                x[[i, j]] = (((i * 13 + j * 7) % 97) as f64) / 97.0;
6775            }
6776        }
6777
6778        let reference = {
6779            let wy = Array1::from_shape_fn(n, |i| y[i] * w[i]);
6780            fast_atv(&x, &wy)
6781        };
6782        let fused = dense_transpose_weighted_response(&x, &w, &y, None);
6783        for j in 0..p {
6784            assert!(
6785                (reference[j] - fused[j]).abs() < 1e-10,
6786                "mismatch at column {j}: ref={} fused={}",
6787                reference[j],
6788                fused[j]
6789            );
6790        }
6791    }
6792
6793    #[test]
6794    fn large_lazy_dense_materialization_streams_chunks_without_to_dense_fallback() {
6795        let n = 11_000usize;
6796        let p = 128usize;
6797        let op = Arc::new(ChunkOnlyOperator {
6798            n,
6799            p,
6800            row_chunk_calls: AtomicUsize::new(0),
6801            materialization_policy: None,
6802        });
6803        let design = DenseDesignMatrix::from(Arc::clone(&op));
6804
6805        // `to_dense_arc` on a lazy operator routes through
6806        // `LazyDense::try_governed_dense_arc`, which charges the
6807        // process-global ledger and `panic_any`s on refusal; see
6808        // `LEDGER_PRESSURE`.
6809        let ledger = ledger_read_guard();
6810        assert_eq!(*ledger, (), "ledger read guard is held for this test");
6811        let dense = design.to_dense_arc();
6812
6813        assert_eq!(dense.dim(), (n, p));
6814        assert!(
6815            op.row_chunk_calls.load(Ordering::SeqCst) > 1,
6816            "expected dense materialization to stream more than one row chunk"
6817        );
6818        for &(i, j) in &[(0, 0), (8_191, 127), (8_192, 0), (10_999, 64)] {
6819            assert_eq!(dense[[i, j]], op.value(i, j));
6820        }
6821        assert!(
6822            design.as_dense_ref().is_some(),
6823            "as_dense_ref must expose a populated LazyDense memo so storage certificates can observe it"
6824        );
6825    }
6826
6827    #[test]
6828    fn construction_policy_survives_nested_coefficient_and_block_operators() {
6829        let strict = ResourcePolicy::analytic_operator_required().material_policy();
6830        let op = Arc::new(ChunkOnlyOperator {
6831            n: 32,
6832            p: 2,
6833            row_chunk_calls: AtomicUsize::new(0),
6834            materialization_policy: Some(strict),
6835        });
6836        let inner = DenseDesignMatrix::from(Arc::clone(&op));
6837        let transformed = CoefficientTransformOperator::new(inner, Array2::<f64>::eye(2))
6838            .expect("coefficient transform");
6839        let block = BlockDesignOperator::new(vec![DesignBlock::Dense(DenseDesignMatrix::from(
6840            Arc::new(transformed),
6841        ))])
6842        .expect("block design");
6843        let design = DenseDesignMatrix::from(Arc::new(block));
6844
6845        let error = design
6846            .try_to_dense_arc("nested construction-policy regression")
6847            .expect_err("strict construction policy must survive every wrapper");
6848        assert!(error.contains("construction policy requires streamed storage"));
6849        assert_eq!(op.row_chunk_calls.load(Ordering::SeqCst), 0);
6850        assert!(design.as_dense_ref().is_none());
6851    }
6852
6853    #[test]
6854    fn try_to_dense_by_chunks_writes_directly_into_output_slices() {
6855        let n = 11_000usize;
6856        let p = 128usize;
6857        let op = Arc::new(ChunkOnlyOperator {
6858            n,
6859            p,
6860            row_chunk_calls: AtomicUsize::new(0),
6861            materialization_policy: None,
6862        });
6863        let design = DesignMatrix::Dense(DenseDesignMatrix::from(Arc::clone(&op)));
6864
6865        let dense = design
6866            .try_to_dense_by_chunks("large chunked regression")
6867            .expect("chunked materialization");
6868
6869        assert_eq!(dense.dim(), (n, p));
6870        assert!(
6871            op.row_chunk_calls.load(Ordering::SeqCst) > 1,
6872            "expected direct chunked conversion to use bounded row chunks"
6873        );
6874        for &(i, j) in &[(1, 7), (4_096, 12), (8_193, 63), (10_998, 127)] {
6875            assert_eq!(dense[[i, j]], op.value(i, j));
6876        }
6877    }
6878
6879    #[test]
6880    fn tensor_product_design_operator_matches_dense_2d() {
6881        use super::{DenseDesignOperator, TensorProductDesignOperator};
6882
6883        // Two marginal B-spline-like bases: 10 rows, 4 and 3 columns.
6884        let n = 10;
6885        let q1 = 4;
6886        let q2 = 3;
6887        let mut b1 = Array2::<f64>::zeros((n, q1));
6888        let mut b2 = Array2::<f64>::zeros((n, q2));
6889        // Fill with simple hat-function-like patterns (sparse per row).
6890        for i in 0..n {
6891            let t1 = i as f64 / (n - 1) as f64 * (q1 - 1) as f64;
6892            let j1 = (t1.floor() as usize).min(q1 - 2);
6893            let frac1 = t1 - j1 as f64;
6894            b1[[i, j1]] = 1.0 - frac1;
6895            b1[[i, j1 + 1]] = frac1;
6896
6897            let t2 = i as f64 / (n - 1) as f64 * (q2 - 1) as f64;
6898            let j2 = (t2.floor() as usize).min(q2 - 2);
6899            let frac2 = t2 - j2 as f64;
6900            b2[[i, j2]] = 1.0 - frac2;
6901            b2[[i, j2 + 1]] = frac2;
6902        }
6903
6904        let op = TensorProductDesignOperator::new(vec![Arc::new(b1.clone()), Arc::new(b2.clone())])
6905            .expect("fixture marginals share a row count");
6906
6907        // Build dense reference via explicit Kronecker row products.
6908        let p = q1 * q2;
6909        let mut dense = Array2::<f64>::zeros((n, p));
6910        for i in 0..n {
6911            for j1 in 0..q1 {
6912                for j2 in 0..q2 {
6913                    dense[[i, j1 * q2 + j2]] = b1[[i, j1]] * b2[[i, j2]];
6914                }
6915            }
6916        }
6917
6918        // Test to_dense.
6919        let op_dense = op.to_dense();
6920        let max_diff = (&op_dense - &dense)
6921            .iter()
6922            .map(|v: &f64| v.abs())
6923            .fold(0.0f64, f64::max);
6924        assert!(max_diff < 1e-14, "to_dense mismatch: max_diff={max_diff}");
6925
6926        // Test apply.
6927        let beta = Array1::from_vec((0..p).map(|j| (j as f64 + 1.0) * 0.1).collect());
6928        let ref_result = dense.dot(&beta);
6929        let op_result = op.apply(&beta);
6930        let max_diff = (&op_result - &ref_result)
6931            .iter()
6932            .map(|v: &f64| v.abs())
6933            .fold(0.0f64, f64::max);
6934        assert!(max_diff < 1e-12, "apply mismatch: max_diff={max_diff}");
6935
6936        // Test apply_transpose.
6937        let v = Array1::from_vec((0..n).map(|i| (i as f64 + 1.0) * 0.3).collect());
6938        let ref_xt_v = dense.t().dot(&v);
6939        let op_xt_v = op.apply_transpose(&v);
6940        let max_diff = (&op_xt_v - &ref_xt_v)
6941            .iter()
6942            .map(|v: &f64| v.abs())
6943            .fold(0.0f64, f64::max);
6944        assert!(
6945            max_diff < 1e-12,
6946            "apply_transpose mismatch: max_diff={max_diff}"
6947        );
6948
6949        // Test diag_xtw_x.
6950        let w = Array1::from_vec((0..n).map(|i| 1.0 + i as f64 * 0.1).collect());
6951        let ref_xtwx = {
6952            let mut out = Array2::<f64>::zeros((p, p));
6953            for i in 0..n {
6954                for a in 0..p {
6955                    for b in 0..p {
6956                        out[[a, b]] += w[i] * dense[[i, a]] * dense[[i, b]];
6957                    }
6958                }
6959            }
6960            out
6961        };
6962        let op_xtwx = op
6963            .diag_xtw_x(&w)
6964            .expect("fixture design and weight vector share n rows");
6965        let max_diff = (&op_xtwx - &ref_xtwx)
6966            .iter()
6967            .map(|v: &f64| v.abs())
6968            .fold(0.0f64, f64::max);
6969        assert!(max_diff < 1e-10, "diag_xtw_x mismatch: max_diff={max_diff}");
6970    }
6971
6972    #[test]
6973    fn tensor_product_design_operator_3d() {
6974        use super::{DenseDesignOperator, TensorProductDesignOperator};
6975
6976        let n = 8;
6977        let dims = [3, 2, 2];
6978        let mut marginals: Vec<Array2<f64>> = Vec::new();
6979        for &q in &dims {
6980            let mut b = Array2::<f64>::zeros((n, q));
6981            for i in 0..n {
6982                let t = i as f64 / (n - 1) as f64 * (q - 1) as f64;
6983                let j = (t.floor() as usize).min(q - 2);
6984                let frac = t - j as f64;
6985                b[[i, j]] = 1.0 - frac;
6986                b[[i, j + 1]] = frac;
6987            }
6988            marginals.push(b);
6989        }
6990
6991        let op = TensorProductDesignOperator::new(
6992            marginals.iter().map(|m| Arc::new(m.clone())).collect(),
6993        )
6994        .expect("fixture marginals share a row count");
6995
6996        // Dense reference.
6997        let p: usize = dims.iter().copied().product();
6998        let mut dense = Array2::<f64>::zeros((n, p));
6999        for i in 0..n {
7000            for j0 in 0..dims[0] {
7001                for j1 in 0..dims[1] {
7002                    for j2 in 0..dims[2] {
7003                        let col = j0 * dims[1] * dims[2] + j1 * dims[2] + j2;
7004                        dense[[i, col]] =
7005                            marginals[0][[i, j0]] * marginals[1][[i, j1]] * marginals[2][[i, j2]];
7006                    }
7007                }
7008            }
7009        }
7010
7011        let op_dense = op.to_dense();
7012        let max_diff = (&op_dense - &dense)
7013            .iter()
7014            .map(|v: &f64| v.abs())
7015            .fold(0.0f64, f64::max);
7016        assert!(
7017            max_diff < 1e-14,
7018            "3D to_dense mismatch: max_diff={max_diff}"
7019        );
7020
7021        // Test round-trip: apply then apply_transpose.
7022        let beta = Array1::from_vec((0..p).map(|j| (j as f64).sin()).collect());
7023        let xb = op.apply(&beta);
7024        let xtxb = op.apply_transpose(&xb);
7025        let ref_xtxb = dense.t().dot(&dense.dot(&beta));
7026        let max_diff = (&xtxb - &ref_xtxb)
7027            .iter()
7028            .map(|v: &f64| v.abs())
7029            .fold(0.0f64, f64::max);
7030        assert!(max_diff < 1e-10, "3D X'Xβ mismatch: max_diff={max_diff}");
7031    }
7032
7033    #[test]
7034    fn sparse_weighted_crossprod_parallel_path_matches_dense_reference() {
7035        use faer::sparse::Triplet;
7036
7037        let n = 4096;
7038        let p = 192;
7039        let mut triplets = Vec::with_capacity(n * 4);
7040        let mut dense = Array2::<f64>::zeros((n, p));
7041        for i in 0..n {
7042            let base = (i * 37) % p;
7043            for k in 0..4 {
7044                let col = (base + k * 11) % p;
7045                let val = ((i + 3 * k + 1) as f64).sin() * 0.25 + 0.5;
7046                triplets.push(Triplet::new(i, col, val));
7047                dense[[i, col]] = val;
7048            }
7049        }
7050        let sparse = faer::sparse::SparseColMat::try_new_from_triplets(n, p, &triplets)
7051            .expect("fixture triplets lie inside the declared shape");
7052        let design = DesignMatrix::Sparse(SparseDesignMatrix::new(sparse));
7053        let weights = Array1::from_iter((0..n).map(|i| match i % 7 {
7054            0 => 0.0,
7055            r => 0.5 + r as f64 * 0.125,
7056        }));
7057
7058        let got = <DesignMatrix as LinearOperator>::xt_diag_x_signed_op(
7059            &design,
7060            FiniteSignedWeightsView::try_from_array(&weights)
7061                .expect("fixture weights are finite and signed-representable"),
7062        )
7063        .expect("fixture design and weight vector share n rows");
7064        let mut reference = Array2::<f64>::zeros((p, p));
7065        for i in 0..n {
7066            let wi = weights[i];
7067            if wi == 0.0 {
7068                continue;
7069            }
7070            for a in 0..p {
7071                let xa = dense[[i, a]];
7072                if xa == 0.0 {
7073                    continue;
7074                }
7075                for b in 0..p {
7076                    reference[[a, b]] += wi * xa * dense[[i, b]];
7077                }
7078            }
7079        }
7080        let max_diff = (&got - &reference)
7081            .iter()
7082            .map(|v: &f64| v.abs())
7083            .fold(0.0_f64, f64::max);
7084        assert!(
7085            max_diff < 1e-10,
7086            "sparse xtwx mismatch: max_diff={max_diff}"
7087        );
7088
7089        let got_diag = design
7090            .diag_gram(&weights)
7091            .expect("fixture design and weight vector share n rows");
7092        let ref_diag = reference.diag().to_owned();
7093        let max_diag_diff = (&got_diag - &ref_diag)
7094            .iter()
7095            .map(|v: &f64| v.abs())
7096            .fold(0.0_f64, f64::max);
7097        assert!(
7098            max_diag_diff < 1e-10,
7099            "sparse diag gram mismatch: max_diff={max_diag_diff}"
7100        );
7101    }
7102
7103    #[test]
7104    fn rowwise_kronecker_sparse_structured_xtwx_matches_dense_reference() {
7105        use faer::sparse::Triplet;
7106
7107        let n = 2048;
7108        let p_cov = 64;
7109        let p_time = 6;
7110        let mut triplets = Vec::with_capacity(n * 3);
7111        let mut cov_dense = Array2::<f64>::zeros((n, p_cov));
7112        for i in 0..n {
7113            let base = (i * 17) % p_cov;
7114            for k in 0..3 {
7115                let col = (base + k * 7) % p_cov;
7116                let val = 0.2 + (((i + k) % 13) as f64) / 17.0;
7117                triplets.push(Triplet::new(i, col, val));
7118                cov_dense[[i, col]] = val;
7119            }
7120        }
7121        let cov_sparse = faer::sparse::SparseColMat::try_new_from_triplets(n, p_cov, &triplets)
7122            .expect("fixture triplets lie inside the declared shape");
7123        let cov = DesignMatrix::Sparse(SparseDesignMatrix::new(cov_sparse));
7124        let mut time = Array2::<f64>::zeros((n, p_time));
7125        for i in 0..n {
7126            for t in 0..p_time {
7127                time[[i, t]] = (((i + 1) * (t + 3)) as f64).cos() * 0.1 + 0.4;
7128            }
7129        }
7130        let op = RowwiseKroneckerOperator::new(cov, Arc::new(time.clone()))
7131            .expect("covariate and time marginals share n rows");
7132        let weights = Array1::from_iter((0..n).map(|i| 0.25 + ((i % 11) as f64) * 0.05));
7133        let got = op
7134            .diag_xtw_x(&weights)
7135            .expect("fixture design and weight vector share n rows");
7136
7137        let p_total = p_cov * p_time;
7138        let mut reference = Array2::<f64>::zeros((p_total, p_total));
7139        for i in 0..n {
7140            for c1 in 0..p_cov {
7141                let x1 = cov_dense[[i, c1]];
7142                if x1 == 0.0 {
7143                    continue;
7144                }
7145                for t1 in 0..p_time {
7146                    let a = c1 * p_time + t1;
7147                    let xa = x1 * time[[i, t1]];
7148                    for c2 in 0..p_cov {
7149                        let x2 = cov_dense[[i, c2]];
7150                        if x2 == 0.0 {
7151                            continue;
7152                        }
7153                        for t2 in 0..p_time {
7154                            let b = c2 * p_time + t2;
7155                            reference[[a, b]] += weights[i] * xa * x2 * time[[i, t2]];
7156                        }
7157                    }
7158                }
7159            }
7160        }
7161        let max_diff = (&got - &reference)
7162            .iter()
7163            .map(|v: &f64| v.abs())
7164            .fold(0.0_f64, f64::max);
7165        assert!(
7166            max_diff < 1e-9,
7167            "rowwise kronecker sparse xtwx mismatch: max_diff={max_diff}"
7168        );
7169    }
7170
7171    #[test]
7172    fn embedded_column_block_zero_row_local_materializes_empty_global_width() {
7173        let local = Array2::<f64>::zeros((0, 0));
7174        let out = EmbeddedColumnBlock::new(&local, 2..5, 7).materialize();
7175        assert_eq!(out.dim(), (0, 7));
7176    }
7177
7178    /// gam#2684: a densification refusal must name the cap it compared against.
7179    ///
7180    /// The refusal is `dense_bytes > limit`, and the message used to print only
7181    /// `dense_bytes`. On a 300x12 design that reads
7182    /// `300x12 (~0.00 GiB); use matrix-free or chunked code` — a size that is
7183    /// obviously innocent (28,800 bytes) next to a limit that is not shown at
7184    /// all. The reader cannot tell a real ceiling from a ceiling of ~0, and
7185    /// those are opposite problems: the first is a genuine size refusal, the
7186    /// second means the process could not size a budget and the fix lives at
7187    /// the memory probe.
7188    ///
7189    /// This pins the operands rather than the prose, so a future reword is free
7190    /// but dropping either number is not.
7191    #[test]
7192    fn densification_refusal_names_both_operands_not_just_the_size() {
7193        // A deliberately near-zero cap, which is exactly the state a cgroup
7194        // probe failure produces (the governor fails closed to a zero budget by
7195        // design and "never silently inherits host capacity").
7196        let starved = ResourcePolicy {
7197            max_single_materialization_bytes: 0,
7198            ..ResourcePolicy::default_library()
7199        };
7200        let reason = super::panic_or_error_if_large_scale_mode_and_to_dense_called_with_policy(
7201            "unit_test", 300, 12, &starved,
7202        )
7203        .expect_err("a zero cap must refuse a 28,800-byte design");
7204
7205        // The exact byte counts of BOTH operands, so neither can be hidden by
7206        // GiB rounding — 28,800 bytes renders as `0.00 GiB`, which is what made
7207        // the original message unreadable.
7208        assert!(
7209            reason.contains("28800"),
7210            "refusal must state the exact size in bytes: {reason}"
7211        );
7212        assert!(
7213            reason.contains("cap"),
7214            "refusal must name the cap it compared against: {reason}"
7215        );
7216
7217        // Falsification guard for the assertions above: they must be capable of
7218        // failing. A cap comfortably above the request must NOT refuse at all,
7219        // so a message that always contained these substrings — or a predicate
7220        // that always refused — would be caught here.
7221        let roomy = ResourcePolicy {
7222            max_single_materialization_bytes: 1 << 30,
7223            ..ResourcePolicy::default_library()
7224        };
7225        super::panic_or_error_if_large_scale_mode_and_to_dense_called_with_policy(
7226            "unit_test", 300, 12, &roomy,
7227        )
7228        .expect("a 28,800-byte design must be admitted under a 1 GiB cap");
7229
7230        // And the boundary is the comparison itself, not a rounded proxy: a cap
7231        // one byte below the request refuses, exactly at the request admits.
7232        // Without this, a limit compared in GiB would pass every assertion above
7233        // while still refusing 28,800 bytes against a 1 GiB ceiling.
7234        super::panic_or_error_if_large_scale_mode_and_to_dense_called_with_policy(
7235            "unit_test",
7236            300,
7237            12,
7238            &ResourcePolicy {
7239                max_single_materialization_bytes: 28_799,
7240                ..ResourcePolicy::default_library()
7241            },
7242        )
7243        .expect_err("one byte below the request must refuse");
7244        super::panic_or_error_if_large_scale_mode_and_to_dense_called_with_policy(
7245            "unit_test",
7246            300,
7247            12,
7248            &ResourcePolicy {
7249                max_single_materialization_bytes: 28_800,
7250                ..ResourcePolicy::default_library()
7251            },
7252        )
7253        .expect("exactly the request must be admitted");
7254    }
7255}