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