Skip to main content

gam_solve/reml/
assembly.rs

1//! Canonical `InnerSolution` assembler.
2//!
3//! No production code outside this module may construct
4//! `InnerSolutionBuilder::new(...)` or call `reml_laml_evaluate(...)`.
5//! Tests are exempt.
6//!
7//! All families and runtime paths provide ingredients and call
8//! [`InnerAssembly::evaluate`] or [`InnerAssembly::build`].
9
10use super::reml_outer_engine::{
11    BarrierConfig, ContractedPsiSecondOrderFn, DispersionHandling, EvalMode, FixedDriftDerivFn,
12    HessianDerivativeProvider, HessianFactorization, HyperCoord, HyperCoordPairResult,
13    InnerSolution, InnerSolutionBuilder, PenaltyCoordinate, PenaltyLogdetDerivs,
14    PenaltySubspaceTrace, RemlLamlResult, penalty_matrix_root, reml_laml_evaluate,
15};
16use crate::model_types::ProjectedKktResidual;
17use gam_linalg::faer_ndarray::fast_xt_diag_y;
18use ndarray::{Array1, Array2};
19use rayon::iter::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator};
20use rayon::slice::ParallelSliceMut;
21use std::sync::Arc;
22
23// ═══════════════════════════════════════════════════════════════════════════
24//  Streaming weighted dense-design products
25// ═══════════════════════════════════════════════════════════════════════════
26
27/// Dense weighted-product work below this approximate flop count stays on the
28/// caller thread and uses the existing faer GEMM path. Above the threshold we
29/// stream rows through rayon-local accumulation buffers to avoid materializing
30/// weighted n×p design copies at large scale.
31pub(crate) const DENSE_WEIGHTED_PRODUCT_PAR_FLOPS: usize = 8_000_000;
32pub(crate) const DENSE_ROW_SCALE_PAR_CELLS: usize = 64 * 1024;
33
34#[derive(Clone, Copy)]
35pub(crate) enum DenseRowScaleMode {
36    Direct,
37    InversePositiveOrZero,
38}
39
40/// Write `diag(scale) · x` into `out`, preserving `out`'s allocation when its
41/// shape already matches `x`.
42///
43/// This replaces the former clone-and-row-scale pattern used by REML assembly
44/// tests and Firth kernels. It is intentionally simple and deterministic for a
45/// fixed row order.
46pub(crate) fn row_scale_dense_into(x: &Array2<f64>, scale: &Array1<f64>, out: &mut Array2<f64>) {
47    assert_eq!(x.nrows(), scale.len(), "scale length must match row count");
48    if out.raw_dim() != x.raw_dim() {
49        *out = Array2::<f64>::zeros(x.raw_dim());
50    }
51    out.assign(x);
52    row_scale_dense_in_place(out, scale, DenseRowScaleMode::Direct);
53}
54
55/// Scale each row of `out` by `1 / scale[row]`, writing zero rows where
56/// `scale[row] <= 0`.
57pub(crate) fn row_scale_dense_in_place_by_inverse_positive_or_zero(
58    out: &mut Array2<f64>,
59    scale: &Array1<f64>,
60) {
61    row_scale_dense_in_place(out, scale, DenseRowScaleMode::InversePositiveOrZero);
62}
63
64pub(crate) fn row_scale_dense_in_place(
65    out: &mut Array2<f64>,
66    scale: &Array1<f64>,
67    mode: DenseRowScaleMode,
68) {
69    assert_eq!(
70        out.nrows(),
71        scale.len(),
72        "scale length must match row count"
73    );
74    let ncols = out.ncols();
75    if ncols == 0 {
76        return;
77    }
78
79    let cells = out.nrows().saturating_mul(ncols);
80    if cells >= DENSE_ROW_SCALE_PAR_CELLS
81        && rayon::current_num_threads() > 1
82        && out.is_standard_layout()
83        && let Some(slice) = out.as_slice_memory_order_mut()
84    {
85        slice
86            .par_chunks_mut(ncols)
87            .zip(
88                scale
89                    .as_slice()
90                    .expect("Array1 must be contiguous")
91                    .par_iter(),
92            )
93            .for_each(|(row_values, &w)| scale_dense_row_values(row_values, w, mode));
94        return;
95    }
96
97    ndarray::Zip::from(out.rows_mut())
98        .and(scale.view())
99        .for_each(|mut row, &w| {
100            if let Some(row_values) = row.as_slice_mut() {
101                scale_dense_row_values(row_values, w, mode);
102            } else {
103                match mode {
104                    DenseRowScaleMode::Direct => row *= w,
105                    DenseRowScaleMode::InversePositiveOrZero => {
106                        if w > 0.0 {
107                            row *= w.recip();
108                        } else {
109                            row.fill(0.0);
110                        }
111                    }
112                }
113            }
114        });
115}
116
117#[inline]
118pub(crate) fn scale_dense_row_values(row_values: &mut [f64], scale: f64, mode: DenseRowScaleMode) {
119    match mode {
120        DenseRowScaleMode::Direct => {
121            for value in row_values {
122                *value *= scale;
123            }
124        }
125        DenseRowScaleMode::InversePositiveOrZero => {
126            if scale > 0.0 {
127                let inv = scale.recip();
128                for value in row_values {
129                    *value *= inv;
130                }
131            } else {
132                for value in row_values {
133                    *value = 0.0;
134                }
135            }
136        }
137    }
138}
139
140pub(crate) fn accumulate_weighted_cross_rows(
141    out: &mut Array2<f64>,
142    left: &Array2<f64>,
143    right: &Array2<f64>,
144    weights: &Array1<f64>,
145    row_start: usize,
146    row_end: usize,
147) {
148    let p = left.ncols();
149    let q = right.ncols();
150    for i in row_start..row_end {
151        let wi = weights[i];
152        if wi == 0.0 {
153            continue;
154        }
155        for a in 0..p {
156            let scaled = wi * left[[i, a]];
157            if scaled == 0.0 {
158                continue;
159            }
160            for b in 0..q {
161                out[[a, b]] += scaled * right[[i, b]];
162            }
163        }
164    }
165}
166
167pub(crate) fn accumulate_xt_diag_x_upper_rows(
168    out: &mut Array2<f64>,
169    x: &Array2<f64>,
170    diag: &Array1<f64>,
171    row_start: usize,
172    row_end: usize,
173) {
174    let p = x.ncols();
175    for i in row_start..row_end {
176        let wi = diag[i];
177        if wi == 0.0 {
178            continue;
179        }
180        for a in 0..p {
181            let scaled = wi * x[[i, a]];
182            if scaled == 0.0 {
183                continue;
184            }
185            for b in a..p {
186                out[[a, b]] += scaled * x[[i, b]];
187            }
188        }
189    }
190}
191
192/// Compute `leftᵀ diag(weights) right` using streamed row-block
193/// accumulation for large products. The parallel path allocates one dense
194/// p×q accumulator per rayon worker/task instead of allocating an n×q weighted
195/// design matrix.
196pub(crate) fn weighted_cross_dense(
197    left: &Array2<f64>,
198    right: &Array2<f64>,
199    weights: &Array1<f64>,
200) -> Array2<f64> {
201    assert_eq!(left.nrows(), right.nrows());
202    assert_eq!(left.nrows(), weights.len());
203    let n = weights.len();
204    let p = left.ncols();
205    let q = right.ncols();
206    if n == 0 || p == 0 || q == 0 {
207        return Array2::<f64>::zeros((p, q));
208    }
209
210    let work = n.saturating_mul(p).saturating_mul(q);
211    if rayon::current_num_threads() <= 1 || work < DENSE_WEIGHTED_PRODUCT_PAR_FLOPS {
212        return fast_xt_diag_y(left, weights, right);
213    }
214
215    // Deterministic parallel row reduction: the association tree is a pure
216    // function of `n` (length-only pairwise tree over 128-row base blocks),
217    // never of thread count or work stealing — a rayon `fold(..).reduce(..)`
218    // here groups partials by demand-driven splits, which made the accumulated
219    // float result nondeterministic run-to-run (#2228 determinism probe).
220    gam_linalg::pairwise_reduce::par_deterministic_block_fold(
221        n,
222        |range: core::ops::Range<usize>| {
223            let mut local = Array2::<f64>::zeros((p, q));
224            accumulate_weighted_cross_rows(
225                &mut local,
226                left,
227                right,
228                weights,
229                range.start,
230                range.end,
231            );
232            local
233        },
234        |mut a, b| {
235            a += &b;
236            a
237        },
238    )
239    .unwrap_or_else(|| Array2::<f64>::zeros((p, q)))
240}
241
242/// Compute `xᵀ diag(diag) x`. For small products this reuses `weighted` as an
243/// n×p row-scaled scratch and dispatches to faer GEMM. For large products it
244/// streams rows into rayon-local p×p buffers and mirrors the accumulated upper
245/// triangle, avoiding weighted design materialization.
246pub(crate) fn xt_diag_x_dense_into(
247    x: &Array2<f64>,
248    diag: &Array1<f64>,
249    weighted: &mut Array2<f64>,
250) -> Array2<f64> {
251    let (n, p) = x.dim();
252    assert_eq!(diag.len(), n, "diag length must match row count");
253    if n == 0 || p == 0 {
254        return Array2::<f64>::zeros((p, p));
255    }
256
257    let work = n.saturating_mul(p).saturating_mul(p);
258    if rayon::current_num_threads() <= 1 || work < DENSE_WEIGHTED_PRODUCT_PAR_FLOPS {
259        row_scale_dense_into(x, diag, weighted);
260        return gam_linalg::faer_ndarray::fast_atb(x, weighted);
261    }
262
263    // Deterministic parallel row reduction (length-only pairwise tree; see
264    // `weighted_cross_dense` above for why a rayon fold/reduce is not usable
265    // here).
266    let mut out = gam_linalg::pairwise_reduce::par_deterministic_block_fold(
267        n,
268        |range: core::ops::Range<usize>| {
269            let mut local = Array2::<f64>::zeros((p, p));
270            accumulate_xt_diag_x_upper_rows(&mut local, x, diag, range.start, range.end);
271            local
272        },
273        |mut a, b| {
274            a += &b;
275            a
276        },
277    )
278    .unwrap_or_else(|| Array2::<f64>::zeros((p, p)));
279    for a in 0..p {
280        for b in 0..a {
281            out[[a, b]] = out[[b, a]];
282        }
283    }
284    out
285}
286
287// ═══════════════════════════════════════════════════════════════════════════
288//  InnerAssembly — the single entry point for InnerSolution construction
289// ═══════════════════════════════════════════════════════════════════════════
290
291/// All ingredients needed to assemble an `InnerSolution`.
292///
293/// Callers fill in the required fields and override optional ones as needed.
294/// The assembler builds the `InnerSolution` via `InnerSolutionBuilder` and
295/// calls `reml_laml_evaluate` — the only production code path that does so.
296pub struct InnerAssembly<'dp> {
297    // === Required core ===
298    pub log_likelihood: f64,
299    pub penalty_quadratic: f64,
300    pub beta: Array1<f64>,
301    pub n_observations: usize,
302    pub hessian_op: std::sync::Arc<dyn HessianFactorization>,
303    pub penalty_coords: Vec<PenaltyCoordinate>,
304    pub penalty_logdet: PenaltyLogdetDerivs,
305    pub dispersion: DispersionHandling,
306    pub rho_curvature_scale: f64,
307    pub rho_prior: gam_problem::RhoPrior,
308    pub hessian_logdet_correction: f64,
309    pub penalty_subspace_trace: Option<Arc<PenaltySubspaceTrace>>,
310
311    // === Optional decorations (sensible defaults when None/zero) ===
312    pub deriv_provider: Option<Box<dyn HessianDerivativeProvider + 'dp>>,
313    /// Jeffreys/Firth scalar contribution to the LAML cost. Tier-A GLM callers
314    /// construct it from the dense operator (`ExactJeffreysTerm::new`); the
315    /// Tier-B coupled joint path installs the value-only carrier
316    /// (`ExactJeffreysTerm::value_only`) so the cost subtracts the same gated
317    /// `Φ(β̂)` its inner Newton optimized (gam#979).
318    pub firth: Option<crate::estimate::reml::reml_outer_engine::ExactJeffreysTerm>,
319    pub nullspace_dim: Option<f64>,
320    pub barrier_config: Option<BarrierConfig>,
321    pub kkt_residual: Option<ProjectedKktResidual>,
322    /// Active linear-inequality constraint rows at the converged inner
323    /// iterate. When `Some`, the unified evaluator builds the
324    /// constraint-aware kernel `K_T = K_S − K_S Aᵀ (A K_S Aᵀ)⁻¹ A K_S`
325    /// for per-coordinate mode responses `v_k = ∂β/∂ρ_k`.
326    pub active_constraints: Option<Arc<crate::model_types::ActiveLinearConstraintBlock>>,
327
328    // === Extended hyperparameter coordinates ===
329    pub ext_coords: Vec<HyperCoord>,
330    pub ext_coord_pair_fn:
331        Option<Box<dyn Fn(usize, usize) -> HyperCoordPairResult + Send + Sync>>,
332    pub rho_ext_pair_fn:
333        Option<Box<dyn Fn(usize, usize) -> HyperCoordPairResult + Send + Sync>>,
334    pub fixed_drift_deriv: Option<FixedDriftDerivFn>,
335    /// Direction-contracted ψψ second-order hook (#740). When set, the
336    /// outer-Hessian operator builder skips the `K²` per-pair ψψ assembly and
337    /// applies this once per matvec.
338    pub contracted_psi_second_order: Option<ContractedPsiSecondOrderFn>,
339}
340
341impl<'dp> InnerAssembly<'dp> {
342    /// Build the `InnerSolution` from these ingredients.
343    pub fn build(self) -> InnerSolution<'dp> {
344        let mut builder = InnerSolutionBuilder::new(
345            self.log_likelihood,
346            self.penalty_quadratic,
347            self.beta,
348            self.n_observations,
349            self.hessian_op,
350            self.penalty_coords,
351            self.penalty_logdet,
352            self.dispersion,
353        );
354        builder = builder.rho_curvature_scale(self.rho_curvature_scale);
355        builder = builder.rho_prior(self.rho_prior);
356        builder = builder.hessian_logdet_correction(self.hessian_logdet_correction);
357        builder = builder.penalty_subspace_trace(self.penalty_subspace_trace);
358
359        if let Some(dp) = self.deriv_provider {
360            builder = builder.deriv_provider(dp);
361        }
362        builder = builder.firth_term(self.firth);
363        if let Some(nd) = self.nullspace_dim {
364            builder = builder.nullspace_dim_override(nd);
365        }
366        builder = builder.barrier_config(self.barrier_config);
367        builder = builder.kkt_residual(self.kkt_residual);
368        builder = builder.active_constraints(self.active_constraints);
369
370        if !self.ext_coords.is_empty() {
371            builder = builder.ext_coords(self.ext_coords);
372        }
373        if let Some(f) = self.ext_coord_pair_fn {
374            builder = builder.ext_coord_pair_fn(f);
375        }
376        if let Some(f) = self.rho_ext_pair_fn {
377            builder = builder.rho_ext_pair_fn(f);
378        }
379        if let Some(f) = self.fixed_drift_deriv {
380            builder = builder.fixed_drift_deriv(f);
381        }
382        builder = builder.contracted_psi_second_order(self.contracted_psi_second_order);
383
384        builder.build()
385    }
386
387    /// Build and evaluate in one step.
388    pub fn evaluate(
389        self,
390        rho: &[f64],
391        mode: EvalMode,
392        prior: Option<(f64, Array1<f64>, Option<Array2<f64>>)>,
393    ) -> Result<RemlLamlResult, String> {
394        let solution = self.build();
395        reml_laml_evaluate(&solution, rho, mode, prior)
396    }
397}
398
399/// Evaluate a pre-built `InnerSolution` through the unified evaluator.
400///
401/// Use this when the caller needs the `InnerSolution` to outlive the evaluation
402/// (e.g., for EFS step computation after evaluation). Prefer
403/// [`InnerAssembly::evaluate`] when the solution is not needed afterwards.
404pub fn evaluate_solution(
405    solution: &InnerSolution<'_>,
406    rho: &[f64],
407    mode: EvalMode,
408    prior: Option<(f64, Array1<f64>, Option<Array2<f64>>)>,
409) -> Result<RemlLamlResult, String> {
410    reml_laml_evaluate(solution, rho, mode, prior)
411}
412
413// ═══════════════════════════════════════════════════════════════════════════
414//  Penalty coordinate helpers for family modules
415// ═══════════════════════════════════════════════════════════════════════════
416
417/// Descriptor for a single penalty block within the parameter vector.
418pub struct PenaltyBlockDesc<'a> {
419    pub matrix: &'a Array2<f64>,
420    pub range_start: usize,
421    pub range_end: usize,
422}
423
424/// Build `PenaltyCoordinate`s from block descriptors.
425///
426/// Replaces the manual `penalty_matrix_root` + `from_block_root` loops
427/// in `survival.rs` and `custom_family.rs`.
428pub fn penalty_coords_from_blocks(
429    blocks: &[PenaltyBlockDesc],
430    total_dim: usize,
431) -> Result<Vec<PenaltyCoordinate>, String> {
432    blocks
433        .iter()
434        .map(|b| {
435            let root = penalty_matrix_root(b.matrix)?;
436            Ok(PenaltyCoordinate::from_block_root(
437                root,
438                b.range_start,
439                b.range_end,
440                total_dim,
441            ))
442        })
443        .collect()
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use approx::assert_relative_eq;
450    use ndarray::Array2;
451
452    pub(crate) fn assert_matrix_close(
453        got: &Array2<f64>,
454        expected: &Array2<f64>,
455        epsilon: f64,
456        max_relative: f64,
457    ) {
458        assert_eq!(got.dim(), expected.dim());
459        for ((i, j), &value) in got.indexed_iter() {
460            assert_relative_eq!(
461                value,
462                expected[[i, j]],
463                epsilon = epsilon,
464                max_relative = max_relative
465            );
466        }
467    }
468
469    pub(crate) fn deterministic_matrix(n: usize, p: usize, phase: f64) -> Array2<f64> {
470        Array2::from_shape_fn((n, p), |(i, j)| {
471            let a = ((i as f64 + 1.0) * (j as f64 + 3.0) + phase).sin();
472            let b = ((i as f64 + 5.0) / (j as f64 + 2.0) + phase).cos();
473            0.25 * a + 0.75 * b
474        })
475    }
476
477    pub(crate) fn deterministic_weights(n: usize) -> Array1<f64> {
478        Array1::from_shape_fn(n, |i| {
479            if i % 17 == 0 {
480                0.0
481            } else {
482                0.2 + ((i as f64 + 1.0) * 0.013).sin().abs()
483            }
484        })
485    }
486
487    pub(crate) fn weighted_cross_reference(
488        left: &Array2<f64>,
489        right: &Array2<f64>,
490        weights: &Array1<f64>,
491    ) -> Array2<f64> {
492        let mut out = Array2::<f64>::zeros((left.ncols(), right.ncols()));
493        for i in 0..weights.len() {
494            for a in 0..left.ncols() {
495                let scaled = weights[i] * left[[i, a]];
496                for b in 0..right.ncols() {
497                    out[[a, b]] += scaled * right[[i, b]];
498                }
499            }
500        }
501        out
502    }
503
504    #[test]
505    pub(crate) fn row_scale_dense_into_reuses_buffer_and_matches_reference() {
506        let x = deterministic_matrix(37, 11, 0.3);
507        let weights = deterministic_weights(x.nrows());
508        let mut out = Array2::<f64>::zeros(x.raw_dim());
509        let ptr = out.as_ptr();
510        row_scale_dense_into(&x, &weights, &mut out);
511        assert_eq!(out.as_ptr(), ptr);
512        for i in 0..x.nrows() {
513            for j in 0..x.ncols() {
514                assert_relative_eq!(out[[i, j]], x[[i, j]] * weights[i], epsilon = 0.0);
515            }
516        }
517    }
518
519    #[test]
520    pub(crate) fn weighted_cross_dense_matches_rowwise_reference_at_large_scale_block_size() {
521        let left = deterministic_matrix(2048, 96, 0.1);
522        let right = deterministic_matrix(2048, 64, 0.7);
523        let weights = deterministic_weights(left.nrows());
524        let got = weighted_cross_dense(&left, &right, &weights);
525        let expected = weighted_cross_reference(&left, &right, &weights);
526        assert_matrix_close(&got, &expected, 5e-10, 5e-12);
527    }
528
529    #[test]
530    pub(crate) fn xt_diag_x_dense_into_matches_symmetric_reference_at_large_scale_block_size() {
531        let x = deterministic_matrix(1024, 96, 1.1);
532        let weights = deterministic_weights(x.nrows());
533        let mut scratch = Array2::<f64>::zeros((0, 0));
534        let got = xt_diag_x_dense_into(&x, &weights, &mut scratch);
535        let expected = weighted_cross_reference(&x, &x, &weights);
536        assert_matrix_close(&got, &expected, 3e-10, 5e-12);
537        for i in 0..got.nrows() {
538            for j in 0..got.ncols() {
539                assert_relative_eq!(got[[i, j]], got[[j, i]], epsilon = 0.0);
540            }
541        }
542    }
543}