Skip to main content

gam_linalg/matrix/
mod.rs

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