Skip to main content

gam_model_kernels/
scale_design.rs

1use gam_linalg::faer_ndarray::{FaerSvd, fast_ab};
2use gam_linalg::matrix::{
3    DenseDesignMatrix, DenseDesignOperator, DesignMatrix, FiniteSignedWeightsView, LinearOperator,
4};
5use ndarray::{Array1, Array2, ArrayViewMut2, s};
6use std::ops::Range;
7use std::sync::Arc;
8
9/// Typed error variants for the scale-deviation design module.
10///
11/// External-facing helpers continue to return `Result<_, String>`; this enum
12/// is materialized internally and converted at the boundary so that error
13/// text remains byte-identical to the previous `format!` output.
14#[derive(Debug, Clone)]
15pub enum ScaleDesignError {
16    /// Weight vector contains an invalid entry (NaN/inf, negative, or sums
17    /// to a non-positive / non-finite total).
18    InvalidWeights { reason: String },
19    /// Dimensions of the supplied matrices/vectors are inconsistent.
20    IncompatibleDimensions { reason: String },
21    /// Input value is not finite where finiteness is required (e.g. saved
22    /// projection cutoff alpha).
23    NonFiniteInput { reason: String },
24    /// Saved payload is partially populated or the projection is degenerate
25    /// (e.g. zero rows with non-empty columns).
26    DegenerateDesign { reason: String },
27    /// Row materialization from an underlying `DesignMatrix` failed.
28    RowMaterializationFailed { reason: String },
29    /// Thin SVD of the weighted primary design failed or produced no
30    /// singular vectors.
31    SvdFailed { reason: String },
32}
33
34impl_reason_error_boilerplate! {
35    ScaleDesignError {
36        InvalidWeights,
37        IncompatibleDimensions,
38        NonFiniteInput,
39        DegenerateDesign,
40        RowMaterializationFailed,
41        SvdFailed,
42    }
43}
44
45const COLUMN_TOL: f64 = 1e-12;
46const SCALE_DESIGN_TARGET_CHUNK_BYTES: usize = 8 * 1024 * 1024;
47// Numerical conditioning floor for the SVD truncation tolerance: we drop any
48// singular direction below `RCOND_FLOOR * sigma_max`, which is the standard
49// machine-precision boundary for considering a direction resolvable. Above
50// this floor, the replay solve is unbiased least squares (no Tikhonov
51// damping), so noise in the primary span is recovered exactly. This is the
52// primary safety net.
53const SCALE_PROJECTION_REPLAY_RCOND_FLOOR: f64 = 1e-8;
54// Optional tighter cap on coefficient amplification, used only when the
55// design is so well-conditioned that even the worst retained direction would
56// not amplify a unit prediction row beyond this multiple. For natural smooth
57// bases (cond ≈ 100–1000) this cap is dominated by the rcond floor and has no
58// effect; it kicks in only for nearly-orthogonal designs where one could
59// otherwise tighten the cutoff without losing real signal. Setting this much
60// smaller than `1 / RCOND_FLOOR` would discard real signal from moderately
61// conditioned bases and is intentionally avoided.
62const SCALE_PROJECTION_LEVERAGE_AMPLIFICATION: f64 = 1.0e8;
63// Above this many materialized entries (rows × noise columns) the scale-deviation
64// operator routes its normal-equation solve through matrix-free PCG instead of
65// forming a dense `XᵀWX`. The dense path costs `O(n · p²)` time and `O(p²)`
66// memory; once the explicit operator footprint reaches ~10⁶ doubles (~8 MiB,
67// matching `SCALE_DESIGN_TARGET_CHUNK_BYTES`) the chunked matrix-free path is
68// the cheaper, more cache-friendly route.
69const SCALE_OPERATOR_MATRIX_FREE_PCG_THRESHOLD: usize = 1_000_000;
70
71#[derive(Clone, Debug)]
72pub struct ScaleDeviationTransform {
73    pub projection_coef: Array2<f64>,
74    pub weighted_column_mean: Array1<f64>,
75    pub rescale: Array1<f64>,
76    pub non_intercept_start: usize,
77    /// Squared SVD truncation cutoff used when fitting `projection_coef`.
78    /// Stored so prediction-time replay is reproducible without re-deriving
79    /// the cutoff from heuristics.
80    pub projection_ridge_alpha: f64,
81}
82
83impl ScaleDeviationTransform {
84    /// Identity (no-op) reparameterization: zero projection, zero centering,
85    /// unit rescale. [`build_scale_deviation_operator`] with this transform
86    /// returns the raw scale design verbatim, and the saved-payload round-trip
87    /// replays the same identity at prediction time.
88    ///
89    /// A location and a scale predictor remain SEPARATELY identifiable even when
90    /// they share a covariate basis: they enter the likelihood through different
91    /// sufficient statistics (the standardized residual versus its square / the
92    /// log-scale), so residualizing the scale design against the location design
93    /// — replacing `X_σ` with `(I − P_{X_μ}) X_σ` — imposes a spurious
94    /// constraint and erases real heteroscedastic signal whenever the two blocks
95    /// overlap. The Gaussian location-scale path already keeps its log-σ design
96    /// un-residualized (`identified_gaussian_log_sigma_design`); this constructor
97    /// lets the survival location-scale path do the same while preserving the
98    /// transform plumbing (payload serialization, prediction-time replay).
99    pub fn identity(p_primary: usize, p_noise: usize, non_intercept_start: usize) -> Self {
100        ScaleDeviationTransform {
101            projection_coef: Array2::<f64>::zeros((p_primary, p_noise)),
102            weighted_column_mean: Array1::<f64>::zeros(p_noise),
103            rescale: Array1::<f64>::ones(p_noise),
104            non_intercept_start,
105            projection_ridge_alpha: 0.0,
106        }
107    }
108}
109
110/// Build a [`ScaleDeviationTransform`] from saved projection metadata.
111///
112/// Returns `Ok(None)` only when the payload is completely absent; partial
113/// payloads are invalid because prediction cannot replay the fitted scale
114/// reparameterization unambiguously.
115pub fn scale_transform_from_payload(
116    projection: &Option<Vec<Vec<f64>>>,
117    center: &Option<Vec<f64>>,
118    scale: &Option<Vec<f64>>,
119    non_intercept_start: Option<usize>,
120    projection_ridge_alpha: Option<f64>,
121) -> Result<Option<ScaleDeviationTransform>, String> {
122    scale_transform_from_payload_typed(
123        projection,
124        center,
125        scale,
126        non_intercept_start,
127        projection_ridge_alpha,
128    )
129    .map_err(|e| e.to_string())
130}
131
132fn scale_transform_from_payload_typed(
133    projection: &Option<Vec<Vec<f64>>>,
134    center: &Option<Vec<f64>>,
135    scale: &Option<Vec<f64>>,
136    non_intercept_start: Option<usize>,
137    projection_ridge_alpha: Option<f64>,
138) -> Result<Option<ScaleDeviationTransform>, ScaleDesignError> {
139    match (projection, center, scale, non_intercept_start) {
140        (None, None, None, None) => Ok(None),
141        (Some(projection), Some(center), Some(scale), Some(non_intercept_start)) => {
142            let rows = projection.len();
143            let cols = center.len();
144            if cols != scale.len() {
145                return Err(ScaleDesignError::IncompatibleDimensions {
146                    reason: "saved scale transform center/scale length mismatch".to_string(),
147                });
148            }
149            if rows == 0 && cols > 0 {
150                return Err(ScaleDesignError::DegenerateDesign {
151                    reason: "saved scale transform projection has zero rows".to_string(),
152                });
153            }
154            let mut projection_coef = Array2::<f64>::zeros((rows, cols));
155            for (i, row) in projection.iter().enumerate() {
156                if row.len() != cols {
157                    return Err(ScaleDesignError::IncompatibleDimensions {
158                        reason: "saved scale transform projection width mismatch".to_string(),
159                    });
160                }
161                for (j, &value) in row.iter().enumerate() {
162                    projection_coef[[i, j]] = value;
163                }
164            }
165            let Some(projection_ridge_alpha) = projection_ridge_alpha else {
166                return Err(ScaleDesignError::DegenerateDesign {
167                    reason:
168                        "saved scale transform payload is missing projection_ridge_alpha; refit"
169                            .to_string(),
170                });
171            };
172            if !projection_ridge_alpha.is_finite() || projection_ridge_alpha < 0.0 {
173                return Err(ScaleDesignError::NonFiniteInput {
174                    reason: format!(
175                        "saved scale transform projection_ridge_alpha must be finite and non-negative, got {projection_ridge_alpha}"
176                    ),
177                });
178            }
179            Ok(Some(ScaleDeviationTransform {
180                projection_coef,
181                weighted_column_mean: Array1::from_vec(center.clone()),
182                rescale: Array1::from_vec(scale.clone()),
183                non_intercept_start,
184                projection_ridge_alpha,
185            }))
186        }
187        _ => Err(ScaleDesignError::DegenerateDesign {
188            reason: "saved scale transform payload is only partially populated; refit".to_string(),
189        }),
190    }
191}
192
193#[derive(Clone, Copy)]
194enum ScaleDesignMatrixRef<'a> {
195    Dense(&'a Array2<f64>),
196    Design(&'a DesignMatrix),
197}
198
199impl ScaleDesignMatrixRef<'_> {
200    #[inline]
201    fn nrows(self) -> usize {
202        match self {
203            Self::Dense(matrix) => matrix.nrows(),
204            Self::Design(matrix) => matrix.nrows(),
205        }
206    }
207
208    #[inline]
209    fn ncols(self) -> usize {
210        match self {
211            Self::Dense(matrix) => matrix.ncols(),
212            Self::Design(matrix) => matrix.ncols(),
213        }
214    }
215
216    fn row_chunk(self, rows: Range<usize>) -> Result<Array2<f64>, ScaleDesignError> {
217        match self {
218            Self::Dense(matrix) => Ok(matrix.slice(s![rows, ..]).to_owned()),
219            Self::Design(matrix) => {
220                matrix
221                    .try_row_chunk(rows)
222                    .map_err(|e| ScaleDesignError::RowMaterializationFailed {
223                        reason: format!("scale deviation row materialization failed: {e}"),
224                    })
225            }
226        }
227    }
228}
229
230pub fn infer_non_intercept_start(design: &Array2<f64>, weights: &Array1<f64>) -> usize {
231    infer_non_intercept_start_impl(
232        ScaleDesignMatrixRef::Dense(design),
233        weights,
234        "weighted column stats row mismatch".to_string(),
235    )
236    .unwrap_or(0)
237}
238
239fn dim_err(reason: impl Into<String>) -> ScaleDesignError {
240    ScaleDesignError::IncompatibleDimensions {
241        reason: reason.into(),
242    }
243}
244
245pub fn build_scale_deviation_transform(
246    primary_design: &Array2<f64>,
247    noise_design: &Array2<f64>,
248    weights: &Array1<f64>,
249    non_intercept_start: usize,
250) -> Result<ScaleDeviationTransform, String> {
251    build_scale_deviation_transform_impl(
252        ScaleDesignMatrixRef::Dense(primary_design),
253        ScaleDesignMatrixRef::Dense(noise_design),
254        weights,
255        non_intercept_start,
256        "scale deviation transform row mismatch",
257    )
258    .map_err(|e| e.to_string())
259}
260
261pub fn apply_scale_deviation_transform(
262    primary_design: &Array2<f64>,
263    rawnoise_design: &Array2<f64>,
264    transform: &ScaleDeviationTransform,
265) -> Result<Array2<f64>, String> {
266    apply_scale_deviation_transform_typed(primary_design, rawnoise_design, transform)
267        .map_err(|e| e.to_string())
268}
269
270fn apply_scale_deviation_transform_typed(
271    primary_design: &Array2<f64>,
272    rawnoise_design: &Array2<f64>,
273    transform: &ScaleDeviationTransform,
274) -> Result<Array2<f64>, ScaleDesignError> {
275    if primary_design.nrows() != rawnoise_design.nrows() {
276        return Err(dim_err("scale deviation apply row mismatch"));
277    }
278    if primary_design.ncols() != transform.projection_coef.nrows()
279        || rawnoise_design.ncols() != transform.projection_coef.ncols()
280    {
281        return Err(dim_err("scale deviation apply column mismatch"));
282    }
283    let n = rawnoise_design.nrows();
284    let p_primary = primary_design.ncols();
285    let p_noise = rawnoise_design.ncols();
286    let chunk_rows = scale_design_row_chunk_size(n, p_primary.max(p_noise));
287    let mut out = Array2::<f64>::zeros((n, p_noise));
288    for start in (0..n).step_by(chunk_rows) {
289        let end = (start + chunk_rows).min(n);
290        let primary_chunk = primary_design.slice(s![start..end, ..]).to_owned();
291        let noise_chunk = rawnoise_design.slice(s![start..end, ..]).to_owned();
292        let chunk = apply_scale_deviation_reparam_chunk(&primary_chunk, &noise_chunk, transform);
293        out.slice_mut(s![start..end, ..]).assign(&chunk);
294    }
295    Ok(out)
296}
297
298#[derive(Clone)]
299struct ScaleDeviationOperator {
300    primary_design: DesignMatrix,
301    rawnoise_design: DesignMatrix,
302    transform: ScaleDeviationTransform,
303    chunk_rows: usize,
304}
305
306impl ScaleDeviationOperator {
307    fn row_chunk(&self, rows: Range<usize>) -> Result<Array2<f64>, ScaleDesignError> {
308        let primary_chunk = self
309            .primary_design
310            .try_row_chunk(rows.clone())
311            .map_err(|e| ScaleDesignError::RowMaterializationFailed {
312                reason: format!("scale deviation operator primary chunk: {e}"),
313            })?;
314        let noise_chunk = self.rawnoise_design.try_row_chunk(rows).map_err(|e| {
315            ScaleDesignError::RowMaterializationFailed {
316                reason: format!("scale deviation operator noise chunk: {e}"),
317            }
318        })?;
319        Ok(apply_scale_deviation_reparam_chunk(
320            &primary_chunk,
321            &noise_chunk,
322            &self.transform,
323        ))
324    }
325}
326
327impl LinearOperator for ScaleDeviationOperator {
328    fn nrows(&self) -> usize {
329        self.rawnoise_design.nrows()
330    }
331
332    fn ncols(&self) -> usize {
333        self.rawnoise_design.ncols()
334    }
335
336    fn apply(&self, vector: &Array1<f64>) -> Array1<f64> {
337        assert_eq!(vector.len(), self.ncols());
338        let n = self.nrows();
339        let mut out = Array1::<f64>::zeros(n);
340        for start in (0..n).step_by(self.chunk_rows) {
341            let end = (start + self.chunk_rows).min(n);
342            let chunk = self
343                .row_chunk(start..end)
344                .expect("scale deviation operator row chunk failed");
345            out.slice_mut(s![start..end]).assign(&chunk.dot(vector));
346        }
347        out
348    }
349
350    fn apply_transpose(&self, vector: &Array1<f64>) -> Array1<f64> {
351        assert_eq!(vector.len(), self.nrows());
352        let n = self.nrows();
353        let p = self.ncols();
354        let mut out = Array1::<f64>::zeros(p);
355        for start in (0..n).step_by(self.chunk_rows) {
356            let end = (start + self.chunk_rows).min(n);
357            let chunk = self
358                .row_chunk(start..end)
359                .expect("scale deviation operator row chunk failed");
360            out += &chunk.t().dot(&vector.slice(s![start..end]).to_owned());
361        }
362        out
363    }
364
365    fn diag_xtw_x(&self, weights: &Array1<f64>) -> Result<Array2<f64>, String> {
366        if weights.len() != self.nrows() {
367            return Err(dim_err(format!(
368                "scale deviation operator XtWX weight mismatch: weights={}, rows={}",
369                weights.len(),
370                self.nrows()
371            ))
372            .to_string());
373        }
374        FiniteSignedWeightsView::try_from_array(weights)
375            .map_err(|reason| format!("scale deviation operator XtWX: {reason}"))?;
376        let n = self.nrows();
377        let p = self.ncols();
378        let mut out = Array2::<f64>::zeros((p, p));
379        for start in (0..n).step_by(self.chunk_rows) {
380            let end = (start + self.chunk_rows).min(n);
381            let chunk = self.row_chunk(start..end).map_err(|e| e.to_string())?;
382            for local in 0..chunk.nrows() {
383                let w = weights[start + local];
384                if w == 0.0 {
385                    continue;
386                }
387                for a in 0..p {
388                    let xa = chunk[[local, a]];
389                    for b in a..p {
390                        let value = w * xa * chunk[[local, b]];
391                        out[[a, b]] += value;
392                        if a != b {
393                            out[[b, a]] += value;
394                        }
395                    }
396                }
397            }
398        }
399        Ok(out)
400    }
401
402    fn uses_matrix_free_pcg(&self) -> bool {
403        self.primary_design
404            .nrows()
405            .saturating_mul(self.rawnoise_design.ncols())
406            > SCALE_OPERATOR_MATRIX_FREE_PCG_THRESHOLD
407    }
408}
409
410impl DenseDesignOperator for ScaleDeviationOperator {
411    fn row_chunk_into(
412        &self,
413        rows: Range<usize>,
414        mut out: ArrayViewMut2<'_, f64>,
415    ) -> Result<(), gam_runtime::resource::MatrixMaterializationError> {
416        let chunk = self.row_chunk(rows).map_err(|err| {
417            gam_runtime::resource::MatrixMaterializationError::RowMaterializationFailed {
418                context: "ScaleDeviationOperator::row_chunk_into",
419                reason: err.to_string(),
420            }
421        })?;
422        out.assign(&chunk);
423        Ok(())
424    }
425
426    fn to_dense(&self) -> Array2<f64> {
427        let n = self.nrows();
428        let p = self.ncols();
429        let mut out = Array2::<f64>::zeros((n, p));
430        for start in (0..n).step_by(self.chunk_rows) {
431            let end = (start + self.chunk_rows).min(n);
432            let chunk = self
433                .row_chunk(start..end)
434                .expect("scale deviation operator row chunk failed");
435            out.slice_mut(s![start..end, ..]).assign(&chunk);
436        }
437        out
438    }
439}
440
441#[derive(Debug)]
442struct WeightedColumnStats {
443    weighted_sum: Array1<f64>,
444    weighted_sum_sq: Array1<f64>,
445    total_weight: f64,
446}
447
448fn validate_scale_weights(weights: &Array1<f64>) -> Result<f64, ScaleDesignError> {
449    let mut total_weight = 0.0;
450    for (idx, &w) in weights.iter().enumerate() {
451        if !w.is_finite() {
452            return Err(ScaleDesignError::NonFiniteInput {
453                reason: format!("scale deviation weight {idx} is not finite"),
454            });
455        }
456        if w < 0.0 {
457            return Err(ScaleDesignError::InvalidWeights {
458                reason: format!(
459                    "scale deviation requires non-negative weights, got {w} at index {idx}"
460                ),
461            });
462        }
463        total_weight += w;
464    }
465    if !total_weight.is_finite() || total_weight <= 0.0 {
466        return Err(ScaleDesignError::InvalidWeights {
467            reason: "scale deviation requires positive finite total weight".to_string(),
468        });
469    }
470    Ok(total_weight)
471}
472
473fn scale_design_row_chunk_size(nrows: usize, max_cols: usize) -> usize {
474    (SCALE_DESIGN_TARGET_CHUNK_BYTES / (max_cols.max(1) * std::mem::size_of::<f64>()))
475        .max(1)
476        .min(nrows.max(1))
477}
478
479fn weighted_column_stats(
480    design: ScaleDesignMatrixRef<'_>,
481    weights: &Array1<f64>,
482    row_mismatch_error: String,
483) -> Result<WeightedColumnStats, ScaleDesignError> {
484    if design.nrows() != weights.len() {
485        return Err(dim_err(row_mismatch_error));
486    }
487    let total_weight = validate_scale_weights(weights)?;
488    let p = design.ncols();
489    let mut weighted_sum = Array1::<f64>::zeros(p);
490    let mut weighted_sum_sq = Array1::<f64>::zeros(p);
491    let chunk_rows = scale_design_row_chunk_size(design.nrows(), p);
492    for start in (0..design.nrows()).step_by(chunk_rows) {
493        let end = (start + chunk_rows).min(design.nrows());
494        let chunk = design.row_chunk(start..end)?;
495        for local in 0..(end - start) {
496            let w = weights[start + local];
497            if w == 0.0 {
498                continue;
499            }
500            for j in 0..p {
501                let x = chunk[[local, j]];
502                weighted_sum[j] += w * x;
503                weighted_sum_sq[j] += w * x * x;
504            }
505        }
506    }
507    Ok(WeightedColumnStats {
508        weighted_sum,
509        weighted_sum_sq,
510        total_weight,
511    })
512}
513
514fn infer_non_intercept_start_impl(
515    design: ScaleDesignMatrixRef<'_>,
516    weights: &Array1<f64>,
517    row_mismatch_error: String,
518) -> Result<usize, ScaleDesignError> {
519    let stats = weighted_column_stats(design, weights, row_mismatch_error)?;
520    let mut end = 0;
521    for j in 0..stats.weighted_sum.len() {
522        let centered_ss = stats.weighted_sum_sq[j]
523            - stats.weighted_sum[j] * stats.weighted_sum[j] / stats.total_weight;
524        if centered_ss <= COLUMN_TOL {
525            end = j + 1;
526        } else {
527            break;
528        }
529    }
530    Ok(end)
531}
532
533fn build_weighted_primary_design(
534    primary_design: ScaleDesignMatrixRef<'_>,
535    sqrtw: &Array1<f64>,
536    chunk_rows: usize,
537) -> Result<Array2<f64>, ScaleDesignError> {
538    let n = primary_design.nrows();
539    let p_primary = primary_design.ncols();
540    let mut wx = Array2::<f64>::zeros((n, p_primary));
541    for start in (0..n).step_by(chunk_rows) {
542        let end = (start + chunk_rows).min(n);
543        let x_chunk = primary_design.row_chunk(start..end)?;
544        for local in 0..(end - start) {
545            let sw = sqrtw[start + local];
546            for col in 0..p_primary {
547                wx[[start + local, col]] = sw * x_chunk[[local, col]];
548            }
549        }
550    }
551    Ok(wx)
552}
553
554/// Pick the squared singular-value cutoff for the replay solve.
555///
556/// Retained directions use the exact inverse `1 / sigma_k`; directions at or
557/// below `sqrt(alpha)` are dropped. We want the worst-case prediction-row
558/// leverage amplification — a unit-norm new row transformed by the saved
559/// coefficients — to be at most `SCALE_PROJECTION_LEVERAGE_AMPLIFICATION`
560/// times what a sigma_max-scale direction sees in the un-regularized solve.
561/// The rcond floor supplies the minimum cutoff for numerical conditioning.
562fn choose_scale_projection_ridge_alpha(singular: &[f64]) -> f64 {
563    if singular.is_empty() {
564        return 0.0;
565    }
566    let sigma_max = singular.iter().copied().fold(0.0_f64, f64::max);
567    if !sigma_max.is_finite() || sigma_max <= 0.0 {
568        return 0.0;
569    }
570    let derived_tol = sigma_max / SCALE_PROJECTION_LEVERAGE_AMPLIFICATION;
571    let truncation_tol = derived_tol.max(SCALE_PROJECTION_REPLAY_RCOND_FLOOR * sigma_max);
572    truncation_tol * truncation_tol
573}
574
575fn solve_scale_projection(
576    primary_design: ScaleDesignMatrixRef<'_>,
577    noise_design: ScaleDesignMatrixRef<'_>,
578    weights: &Array1<f64>,
579    first_active: usize,
580    chunk_rows: usize,
581) -> Result<(Array2<f64>, f64), ScaleDesignError> {
582    let n = primary_design.nrows();
583    let p_primary = primary_design.ncols();
584    let p_noise = noise_design.ncols();
585    let mut projection_coef = Array2::<f64>::zeros((p_primary, p_noise));
586    let active_cols = p_noise.saturating_sub(first_active);
587
588    if active_cols == 0 || p_primary == 0 {
589        return Ok((projection_coef, 0.0));
590    }
591
592    let sqrtw = weights.mapv(f64::sqrt);
593    let wx = build_weighted_primary_design(primary_design, &sqrtw, chunk_rows)?;
594    // Thin SVD of W^{1/2} X_primary: replay reduces to V * diag(filter) * U^T
595    // applied to the weighted noise RHS. Retained singular directions use the
596    // exact inverse; unresolved directions are dropped by the cutoff below.
597    let (u_opt, singular, vt_opt) =
598        wx.svd(true, true)
599            .map_err(|e| ScaleDesignError::SvdFailed {
600                reason: format!("scale projection SVD failed: {e:?}"),
601            })?;
602    let (Some(u), Some(vt)) = (u_opt, vt_opt) else {
603        return Err(ScaleDesignError::SvdFailed {
604            reason: "scale projection SVD did not return singular vectors".to_string(),
605        });
606    };
607    let alpha = choose_scale_projection_ridge_alpha(singular.as_slice().unwrap_or(&[]));
608    let rank = singular.len();
609    if rank == 0 {
610        return Ok((projection_coef, alpha));
611    }
612    // Truncated SVD with leverage-bound cutoff: directions resolved well
613    // enough to keep coefficient amplification under
614    // SCALE_PROJECTION_LEVERAGE_AMPLIFICATION are inverted exactly (no
615    // damping on the dominant components), and weaker directions are
616    // dropped. The primary design is fixed across any single replay, so no
617    // threshold-crossings occur within a call: the projection is a linear
618    // function of the noise RHS, which is the continuity property the audit
619    // asked for. The discarded singular value floor sqrt(alpha) doubles as
620    // the recovered-coefficient leverage cap.
621    let cutoff = alpha.sqrt();
622    let mut filter = Array1::<f64>::zeros(rank);
623    for k in 0..rank {
624        let s = singular[k];
625        filter[k] = if s > cutoff && s > 0.0 { 1.0 / s } else { 0.0 };
626    }
627
628    let chunk_cols = (SCALE_DESIGN_TARGET_CHUNK_BYTES / (n.max(1) * std::mem::size_of::<f64>()))
629        .max(1)
630        .min(active_cols);
631
632    for chunk_start in (0..active_cols).step_by(chunk_cols) {
633        let width = (active_cols - chunk_start).min(chunk_cols);
634        let mut rhs = Array2::<f64>::zeros((n, width));
635        for start in (0..n).step_by(chunk_rows) {
636            let end = (start + chunk_rows).min(n);
637            let noise_chunk = noise_design.row_chunk(start..end)?;
638            for local in 0..(end - start) {
639                let sw = sqrtw[start + local];
640                for col in 0..width {
641                    rhs[[start + local, col]] =
642                        sw * noise_chunk[[local, first_active + chunk_start + col]];
643                }
644            }
645        }
646
647        // U^T (rank x n) * rhs (n x width) -> (rank x width)
648        let mut t = u.t().dot(&rhs);
649        // Apply filter rowwise: t_k *= 1 / sigma_k for retained directions.
650        for k in 0..rank {
651            let f = filter[k];
652            for col in 0..width {
653                t[[k, col]] *= f;
654            }
655        }
656        // V (p_primary x rank) * t (rank x width) -> (p_primary x width).
657        // vt has shape (rank, p_primary), so V = vt^T.
658        let block = vt.t().dot(&t);
659        for col in 0..width {
660            for row in 0..p_primary {
661                projection_coef[[row, first_active + chunk_start + col]] = block[[row, col]];
662            }
663        }
664    }
665
666    Ok((projection_coef, alpha))
667}
668
669fn apply_projection_chunk(
670    primary_chunk: &Array2<f64>,
671    projection_coef: &Array2<f64>,
672    first_active: usize,
673) -> Array2<f64> {
674    if first_active >= projection_coef.ncols() {
675        Array2::<f64>::zeros((primary_chunk.nrows(), 0))
676    } else {
677        fast_ab(
678            primary_chunk,
679            &projection_coef.slice(s![.., first_active..]).to_owned(),
680        )
681    }
682}
683
684fn build_scale_deviation_transform_impl(
685    primary_design: ScaleDesignMatrixRef<'_>,
686    noise_design: ScaleDesignMatrixRef<'_>,
687    weights: &Array1<f64>,
688    non_intercept_start: usize,
689    row_mismatch_error: &str,
690) -> Result<ScaleDeviationTransform, ScaleDesignError> {
691    if primary_design.nrows() != noise_design.nrows() || weights.len() != noise_design.nrows() {
692        return Err(dim_err(row_mismatch_error.to_string()));
693    }
694    validate_scale_weights(weights)?;
695
696    let n = primary_design.nrows();
697    let p_primary = primary_design.ncols();
698    let p_noise = noise_design.ncols();
699    let first_active = non_intercept_start.min(p_noise);
700    let chunk_rows = scale_design_row_chunk_size(n, p_primary.max(p_noise));
701    let (projection_coef, projection_ridge_alpha) = solve_scale_projection(
702        primary_design,
703        noise_design,
704        weights,
705        first_active,
706        chunk_rows,
707    )?;
708    let mut weighted_column_mean = Array1::<f64>::zeros(p_noise);
709    let mut rescale = Array1::<f64>::ones(p_noise);
710    let active_cols = p_noise - first_active;
711
712    if active_cols > 0 {
713        let projection_only_transform = ScaleDeviationTransform {
714            projection_coef: projection_coef.clone(),
715            weighted_column_mean: Array1::<f64>::zeros(p_noise),
716            rescale: Array1::<f64>::ones(p_noise),
717            non_intercept_start,
718            projection_ridge_alpha,
719        };
720        let mut w_sum = 0.0;
721        let mut w_resid_sum = Array1::<f64>::zeros(active_cols);
722        let mut w_noise_sum = Array1::<f64>::zeros(active_cols);
723
724        for start in (0..n).step_by(chunk_rows) {
725            let end = (start + chunk_rows).min(n);
726            let x_chunk = primary_design.row_chunk(start..end)?;
727            let noise_chunk = noise_design.row_chunk(start..end)?;
728            let resid_chunk = apply_scale_deviation_reparam_chunk(
729                &x_chunk,
730                &noise_chunk,
731                &projection_only_transform,
732            );
733            for local in 0..(end - start) {
734                let w = weights[start + local];
735                if w == 0.0 {
736                    continue;
737                }
738                w_sum += w;
739                for jj in 0..active_cols {
740                    let nij = noise_chunk[[local, first_active + jj]];
741                    w_noise_sum[jj] += w * nij;
742                    w_resid_sum[jj] += w * resid_chunk[[local, first_active + jj]];
743                }
744            }
745        }
746
747        if !w_sum.is_finite() || w_sum <= 0.0 {
748            return Err(ScaleDesignError::InvalidWeights {
749                reason: "scale deviation requires positive finite total weight".to_string(),
750            });
751        }
752
753        let resid_center = w_resid_sum.mapv(|sum| sum / w_sum);
754        let noise_mean = w_noise_sum.mapv(|sum| sum / w_sum);
755        let mut orig_css = Array1::<f64>::zeros(active_cols);
756        let mut resid_css = Array1::<f64>::zeros(active_cols);
757
758        for start in (0..n).step_by(chunk_rows) {
759            let end = (start + chunk_rows).min(n);
760            let x_chunk = primary_design.row_chunk(start..end)?;
761            let noise_chunk = noise_design.row_chunk(start..end)?;
762            let resid_chunk = apply_scale_deviation_reparam_chunk(
763                &x_chunk,
764                &noise_chunk,
765                &projection_only_transform,
766            );
767            for local in 0..(end - start) {
768                let w = weights[start + local];
769                if w == 0.0 {
770                    continue;
771                }
772                for jj in 0..active_cols {
773                    let nij = noise_chunk[[local, first_active + jj]];
774                    let d_orig = nij - noise_mean[jj];
775                    orig_css[jj] += w * d_orig * d_orig;
776                    let d_resid = resid_chunk[[local, first_active + jj]] - resid_center[jj];
777                    resid_css[jj] += w * d_resid * d_resid;
778                }
779            }
780        }
781
782        for jj in 0..active_cols {
783            let j = first_active + jj;
784            let scale = if resid_css[jj].is_finite()
785                && resid_css[jj] > COLUMN_TOL
786                && orig_css[jj].is_finite()
787                && orig_css[jj] > COLUMN_TOL
788            {
789                (orig_css[jj] / resid_css[jj]).sqrt()
790            } else {
791                1.0
792            };
793            weighted_column_mean[j] = resid_center[jj];
794            rescale[j] = scale;
795        }
796    }
797
798    Ok(ScaleDeviationTransform {
799        projection_coef,
800        weighted_column_mean,
801        rescale,
802        non_intercept_start,
803        projection_ridge_alpha,
804    })
805}
806
807pub fn infer_non_intercept_start_design(
808    design: &DesignMatrix,
809    weights: &Array1<f64>,
810) -> Result<usize, String> {
811    infer_non_intercept_start_impl(
812        ScaleDesignMatrixRef::Design(design),
813        weights,
814        format!(
815            "weighted column stats row mismatch: design has {} rows, weights have {} entries",
816            design.nrows(),
817            weights.len()
818        ),
819    )
820    .map_err(|e| e.to_string())
821}
822
823pub fn build_scale_deviation_transform_design(
824    primary_design: &DesignMatrix,
825    noise_design: &DesignMatrix,
826    weights: &Array1<f64>,
827    non_intercept_start: usize,
828) -> Result<ScaleDeviationTransform, String> {
829    build_scale_deviation_transform_impl(
830        ScaleDesignMatrixRef::Design(primary_design),
831        ScaleDesignMatrixRef::Design(noise_design),
832        weights,
833        non_intercept_start,
834        "scale deviation transform design row mismatch",
835    )
836    .map_err(|e| e.to_string())
837}
838
839/// Apply the scale-deviation reparameterisation to a chunk of rows.
840///
841/// Instead of embedding the projection coefficients into a large augmented
842/// matrix (which changes FP operation order relative to the canonical
843/// `apply_projection_chunk`), we compute the projection via the shared
844/// helper and then fold in rescaling and centering explicitly.  This
845/// guarantees bit-identical projection arithmetic on both paths.
846fn apply_scale_deviation_reparam_chunk(
847    primary_chunk: &Array2<f64>,
848    noise_chunk: &Array2<f64>,
849    transform: &ScaleDeviationTransform,
850) -> Array2<f64> {
851    let rows = noise_chunk.nrows();
852    let p_noise = noise_chunk.ncols();
853    let first_active = transform.non_intercept_start.min(p_noise);
854    let mut out = Array2::<f64>::zeros((rows, p_noise));
855
856    // Pass-through columns (intercept-like) are copied verbatim.
857    for j in 0..first_active {
858        for i in 0..rows {
859            out[[i, j]] = noise_chunk[[i, j]];
860        }
861    }
862
863    // Active columns: residual = noise - projection, then center & rescale.
864    if first_active < p_noise {
865        let fitted =
866            apply_projection_chunk(primary_chunk, &transform.projection_coef, first_active);
867        for j in first_active..p_noise {
868            let jj = j - first_active;
869            let scale = transform.rescale[j];
870            let center = transform.weighted_column_mean[j];
871            for i in 0..rows {
872                out[[i, j]] = (noise_chunk[[i, j]] - fitted[[i, jj]] - center) * scale;
873            }
874        }
875    }
876
877    out
878}
879
880pub fn build_scale_deviation_operator(
881    primary_design: DesignMatrix,
882    rawnoise_design: DesignMatrix,
883    transform: &ScaleDeviationTransform,
884) -> Result<DesignMatrix, String> {
885    build_scale_deviation_operator_typed(primary_design, rawnoise_design, transform)
886        .map_err(|e| e.to_string())
887}
888
889fn build_scale_deviation_operator_typed(
890    primary_design: DesignMatrix,
891    rawnoise_design: DesignMatrix,
892    transform: &ScaleDeviationTransform,
893) -> Result<DesignMatrix, ScaleDesignError> {
894    if primary_design.nrows() != rawnoise_design.nrows() {
895        return Err(dim_err(format!(
896            "scale deviation operator row mismatch: primary rows={}, noise rows={}",
897            primary_design.nrows(),
898            rawnoise_design.nrows()
899        )));
900    }
901    if primary_design.ncols() != transform.projection_coef.nrows()
902        || rawnoise_design.ncols() != transform.projection_coef.ncols()
903    {
904        return Err(dim_err(format!(
905            "scale deviation operator column mismatch: primary cols={}, noise cols={}, transform is {}x{}",
906            primary_design.ncols(),
907            rawnoise_design.ncols(),
908            transform.projection_coef.nrows(),
909            transform.projection_coef.ncols()
910        )));
911    }
912    let n = rawnoise_design.nrows();
913    let p_primary = primary_design.ncols();
914    let p_noise = rawnoise_design.ncols();
915    let chunk_rows = scale_design_row_chunk_size(n, p_primary.max(p_noise));
916    Ok(DesignMatrix::Dense(DenseDesignMatrix::from(Arc::new(
917        ScaleDeviationOperator {
918            primary_design,
919            rawnoise_design,
920            transform: transform.clone(),
921            chunk_rows,
922        },
923    ))))
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929    use gam_linalg::matrix::DesignMatrix;
930    use ndarray::array;
931
932    fn assert_matrix_close(lhs: &Array2<f64>, rhs: &Array2<f64>, tol: f64, label: &str) {
933        assert_eq!(
934            lhs.dim(),
935            rhs.dim(),
936            "{label} shape mismatch: left {:?}, right {:?}",
937            lhs.dim(),
938            rhs.dim()
939        );
940        for i in 0..lhs.nrows() {
941            for j in 0..lhs.ncols() {
942                assert!(
943                    (lhs[[i, j]] - rhs[[i, j]]).abs() <= tol,
944                    "{label} mismatch at ({i}, {j}): {} vs {}",
945                    lhs[[i, j]],
946                    rhs[[i, j]]
947                );
948            }
949        }
950    }
951
952    fn assert_transform_close(
953        lhs: &ScaleDeviationTransform,
954        rhs: &ScaleDeviationTransform,
955        tol: f64,
956    ) {
957        assert_eq!(lhs.non_intercept_start, rhs.non_intercept_start);
958        assert_matrix_close(
959            &lhs.projection_coef,
960            &rhs.projection_coef,
961            tol,
962            "projection coefficients",
963        );
964        assert_eq!(
965            lhs.weighted_column_mean.len(),
966            rhs.weighted_column_mean.len()
967        );
968        assert_eq!(lhs.rescale.len(), rhs.rescale.len());
969        for j in 0..lhs.weighted_column_mean.len() {
970            assert!(
971                (lhs.weighted_column_mean[j] - rhs.weighted_column_mean[j]).abs() <= tol,
972                "weighted column mean mismatch at {j}: {} vs {}",
973                lhs.weighted_column_mean[j],
974                rhs.weighted_column_mean[j]
975            );
976            assert!(
977                (lhs.rescale[j] - rhs.rescale[j]).abs() <= tol,
978                "rescale mismatch at {j}: {} vs {}",
979                lhs.rescale[j],
980                rhs.rescale[j]
981            );
982        }
983    }
984
985    #[test]
986    fn scale_deviation_transform_overdetermined() {
987        let n = 1000;
988        let p_primary = 10;
989        let p_noise = 5;
990
991        let mut primary = Array2::<f64>::zeros((n, p_primary));
992        let mut noise = Array2::<f64>::zeros((n, p_noise));
993        for i in 0..n {
994            for j in 0..p_primary {
995                primary[[i, j]] = ((i * 3 + j * 11) as f64 * 0.1).sin();
996            }
997            for j in 0..p_noise {
998                noise[[i, j]] = ((i * 5 + j * 13) as f64 * 0.1).cos();
999            }
1000        }
1001        noise.column_mut(0).fill(1.0);
1002        let weights = Array1::<f64>::ones(n);
1003
1004        let transform = build_scale_deviation_transform(&primary, &noise, &weights, 1)
1005            .expect("transform should succeed for overdetermined inputs");
1006        let transformed = apply_scale_deviation_transform(&primary, &noise, &transform)
1007            .expect("apply should succeed for overdetermined inputs");
1008
1009        assert_eq!(transform.projection_coef.dim(), (p_primary, p_noise));
1010        assert_eq!(transformed.dim(), (n, p_noise));
1011        assert!(transformed.iter().all(|v| v.is_finite()));
1012        assert!(transformed.column(0).iter().all(|&v| v == 1.0));
1013
1014        let primary_design =
1015            DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(primary.clone()));
1016        let noise_design =
1017            DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(noise.clone()));
1018        let non_intercept_start = infer_non_intercept_start_design(&noise_design, &weights)
1019            .expect("design-native non-intercept detection should succeed");
1020        assert_eq!(non_intercept_start, 1);
1021        let design_transform = build_scale_deviation_transform_design(
1022            &primary_design,
1023            &noise_design,
1024            &weights,
1025            non_intercept_start,
1026        )
1027        .expect("design-native transform should succeed");
1028        let transformed_design =
1029            build_scale_deviation_operator(primary_design, noise_design, &design_transform)
1030                .expect("design-native operator should build")
1031                .to_dense();
1032
1033        assert_eq!(design_transform.projection_coef.dim(), (p_primary, p_noise));
1034        assert_eq!(transformed_design.dim(), transformed.dim());
1035        assert_transform_close(&transform, &design_transform, 1e-10);
1036        assert_matrix_close(
1037            &transformed_design,
1038            &transformed,
1039            1e-8,
1040            "transformed design",
1041        );
1042    }
1043
1044    #[test]
1045    fn scale_deviation_operator_gram_preserves_signed_weights() {
1046        let primary = array![[1.0], [2.0], [-1.0], [0.5]];
1047        let noise = array![[1.0, 2.0], [3.0, -1.0], [0.5, 4.0], [-2.0, 1.5]];
1048        let transform = ScaleDeviationTransform::identity(1, 2, 0);
1049        let design = build_scale_deviation_operator(
1050            DesignMatrix::Dense(DenseDesignMatrix::from(primary)),
1051            DesignMatrix::Dense(DenseDesignMatrix::from(noise.clone())),
1052            &transform,
1053        )
1054        .unwrap();
1055        let weights = array![2.0, -3.0, 0.25, -1.5];
1056        let weighted_noise = noise.clone() * weights.view().insert_axis(ndarray::Axis(1));
1057        let expected = noise.t().dot(&weighted_noise);
1058        let got = design.diag_xtw_x(&weights).unwrap();
1059        assert_matrix_close(&got, &expected, 1e-12, "signed scale-deviation Gram");
1060
1061        let bad = array![1.0, f64::NAN, f64::INFINITY, 1.0];
1062        let err = design.diag_xtw_x(&bad).unwrap_err();
1063        assert!(err.contains("row 1"), "unexpected diagnostic: {err}");
1064    }
1065
1066    #[test]
1067    fn scale_deviation_transform_rank_deficient_primary_matches_design_path() {
1068        let n = 384;
1069        let p_primary = 4;
1070        let p_noise = 4;
1071        let mut primary = Array2::<f64>::zeros((n, p_primary));
1072        let mut noise = Array2::<f64>::zeros((n, p_noise));
1073        let mut weights = Array1::<f64>::zeros(n);
1074
1075        for i in 0..n {
1076            let t = i as f64 / n as f64;
1077            let wobble = (17.0 * t).sin();
1078            primary[[i, 0]] = 1.0;
1079            primary[[i, 1]] = t;
1080            primary[[i, 2]] = t + 1e-12 * wobble;
1081            primary[[i, 3]] = 2.0 * t - 1e-12 * wobble;
1082
1083            noise[[i, 0]] = 1.0;
1084            noise[[i, 1]] = 0.7 * t + 0.2 * (9.0 * t).cos();
1085            noise[[i, 2]] = primary[[i, 1]] - primary[[i, 2]] + 0.1 * (13.0 * t).sin();
1086            noise[[i, 3]] = 0.5 * primary[[i, 3]] + 0.3 * (5.0 * t).cos();
1087
1088            weights[i] = if i % 17 == 0 {
1089                0.0
1090            } else {
1091                0.5 + (11.0 * t).sin().abs()
1092            };
1093        }
1094
1095        let transform = build_scale_deviation_transform(&primary, &noise, &weights, 1)
1096            .expect("dense transform should succeed for ill-conditioned primary");
1097        let transformed = apply_scale_deviation_transform(&primary, &noise, &transform)
1098            .expect("dense apply should succeed for ill-conditioned primary");
1099
1100        let primary_design =
1101            DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(primary.clone()));
1102        let noise_design =
1103            DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(noise.clone()));
1104        let non_intercept_start = infer_non_intercept_start_design(&noise_design, &weights)
1105            .expect("design-native non-intercept detection should succeed");
1106        assert_eq!(non_intercept_start, 1);
1107
1108        let design_transform = build_scale_deviation_transform_design(
1109            &primary_design,
1110            &noise_design,
1111            &weights,
1112            non_intercept_start,
1113        )
1114        .expect("design-native transform should succeed for ill-conditioned primary");
1115        let transformed_design =
1116            build_scale_deviation_operator(primary_design, noise_design, &design_transform)
1117                .expect("design-native operator should build for ill-conditioned primary")
1118                .to_dense();
1119
1120        assert_transform_close(&transform, &design_transform, 1e-10);
1121        assert_matrix_close(
1122            &transformed_design,
1123            &transformed,
1124            1e-8,
1125            "ill-conditioned transformed design",
1126        );
1127    }
1128
1129    #[test]
1130    fn choose_scale_projection_ridge_alpha_scales_with_sigma_max() {
1131        // Truncation tolerance is `RCOND_FLOOR * sigma_max` whenever the
1132        // leverage cap is looser (which it always is for the default 1e8
1133        // value), so alpha = (RCOND_FLOOR * sigma_max)^2.
1134        let alpha_unit = choose_scale_projection_ridge_alpha(&[1.0, 0.5, 1e-6]);
1135        let expected_unit = SCALE_PROJECTION_REPLAY_RCOND_FLOOR.powi(2);
1136        assert!(alpha_unit > 0.0);
1137        assert!(
1138            (alpha_unit - expected_unit).abs() < 1e-24,
1139            "alpha should be {expected_unit:e} for sigma_max=1, got {alpha_unit}"
1140        );
1141
1142        let alpha_scaled = choose_scale_projection_ridge_alpha(&[100.0, 1.0]);
1143        let expected_scaled = (SCALE_PROJECTION_REPLAY_RCOND_FLOOR * 100.0).powi(2);
1144        assert!(
1145            (alpha_scaled - expected_scaled).abs() < 1e-18,
1146            "alpha should be {expected_scaled:e} for sigma_max=100, got {alpha_scaled}"
1147        );
1148        // Scales as sigma_max^2.
1149        assert!(
1150            (alpha_scaled / alpha_unit - 1.0e4).abs() < 1e-6,
1151            "alpha should scale as sigma_max^2; got ratio {}",
1152            alpha_scaled / alpha_unit
1153        );
1154
1155        let alpha_floor = choose_scale_projection_ridge_alpha(&[]);
1156        assert_eq!(alpha_floor, 0.0);
1157    }
1158
1159    #[test]
1160    fn ridge_replay_continuous_under_input_sweep() {
1161        // A near-collinear primary design plus a sweepable perturbation column
1162        // would, under the old hard coefficient cap, jump discontinuously when
1163        // the cap kicks in. With a fixed SVD cutoff, the replayed coefficient
1164        // is a linear function of the input perturbation.
1165        let n = 64;
1166        let mut primary = Array2::<f64>::zeros((n, 3));
1167        let mut noise = Array2::<f64>::zeros((n, 2));
1168        let weights = Array1::<f64>::ones(n);
1169        for i in 0..n {
1170            let t = i as f64 / n as f64;
1171            primary[[i, 0]] = 1.0;
1172            primary[[i, 1]] = t;
1173            // Near-collinear with col 1 — this is the high-gain direction.
1174            primary[[i, 2]] = t + 1e-9 * (5.0 * t).sin();
1175            noise[[i, 0]] = 1.0;
1176            noise[[i, 1]] = (0.4 * t).cos();
1177        }
1178
1179        // Sweep: gradually scale one noise entry; record the corresponding
1180        // projected coefficient cell. Numerical first differences should be
1181        // bounded because the fixed projection operator is linear in the input.
1182        let mut last: Option<f64> = None;
1183        let mut max_step: f64 = 0.0;
1184        for k in 0..50 {
1185            let s = k as f64 / 49.0;
1186            let mut perturbed = noise.clone();
1187            for i in 0..n {
1188                perturbed[[i, 1]] += s;
1189            }
1190            let transform = build_scale_deviation_transform(&primary, &perturbed, &weights, 1)
1191                .expect("ridge transform should succeed under input sweep");
1192            let val = transform.projection_coef[[2, 1]];
1193            if let Some(prev) = last {
1194                let step = (val - prev).abs();
1195                max_step = max_step.max(step);
1196            }
1197            last = Some(val);
1198        }
1199        // Step bound: with 50 samples over a unit sweep, a smooth dependence
1200        // produces uniform tiny jumps.  The old coefficient cap would emit a
1201        // single huge step at the cap boundary, easily blowing 1.0 here.
1202        assert!(
1203            max_step < 0.5,
1204            "replay coefficient sweep should be continuous, got max step {max_step}"
1205        );
1206    }
1207
1208    #[test]
1209    fn ridge_replay_noise_free_is_near_identity() {
1210        // When the noise design lives in the column span of the primary
1211        // design and W^{1/2} X is well-conditioned, the retained singular
1212        // directions use the exact inverse and the residual after subtracting
1213        // the projected fit is at numerical zero.
1214        let n = 128;
1215        let p_primary = 4;
1216        let p_noise = 3;
1217        let mut primary = Array2::<f64>::zeros((n, p_primary));
1218        let mut noise = Array2::<f64>::zeros((n, p_noise));
1219        let weights = Array1::<f64>::ones(n);
1220        for i in 0..n {
1221            let t = i as f64 / n as f64;
1222            primary[[i, 0]] = 1.0;
1223            primary[[i, 1]] = t;
1224            primary[[i, 2]] = (3.0 * t).sin();
1225            primary[[i, 3]] = (2.0 * t - 0.4).powi(2);
1226            noise[[i, 0]] = 1.0;
1227            // Linear combinations of primary cols so the projection should
1228            // recover them through the retained exact-inverse directions.
1229            noise[[i, 1]] = 0.7 * primary[[i, 1]] - 0.3 * primary[[i, 2]];
1230            noise[[i, 2]] = 0.2 * primary[[i, 3]] + 0.1 * primary[[i, 1]];
1231        }
1232
1233        let transform = build_scale_deviation_transform(&primary, &noise, &weights, 1)
1234            .expect("transform should succeed");
1235        let transformed = apply_scale_deviation_transform(&primary, &noise, &transform)
1236            .expect("apply should succeed");
1237
1238        // Pass-through column unaffected.
1239        for i in 0..n {
1240            assert_eq!(transformed[[i, 0]], 1.0);
1241        }
1242        // Active columns: residuals should be near zero because the relevant
1243        // singular directions are retained and inverted exactly. The design is
1244        // well-conditioned, so 1e-6 is a safe envelope for roundoff.
1245        for j in 1..p_noise {
1246            for i in 0..n {
1247                assert!(
1248                    transformed[[i, j]].abs() < 1e-6,
1249                    "noise-free residual should be near zero at ({i},{j}), got {}",
1250                    transformed[[i, j]]
1251                );
1252            }
1253        }
1254        assert!(transform.projection_ridge_alpha > 0.0);
1255    }
1256
1257    #[test]
1258    fn scale_transform_payload_round_trips_alpha() {
1259        let n = 64;
1260        let mut primary = Array2::<f64>::zeros((n, 3));
1261        let mut noise = Array2::<f64>::zeros((n, 2));
1262        let weights = Array1::<f64>::ones(n);
1263        for i in 0..n {
1264            let t = i as f64 / n as f64;
1265            primary[[i, 0]] = 1.0;
1266            primary[[i, 1]] = t;
1267            primary[[i, 2]] = (4.0 * t).cos();
1268            noise[[i, 0]] = 1.0;
1269            noise[[i, 1]] = (2.0 * t).sin();
1270        }
1271        let transform = build_scale_deviation_transform(&primary, &noise, &weights, 1)
1272            .expect("transform should succeed");
1273
1274        let projection: Vec<Vec<f64>> = transform
1275            .projection_coef
1276            .rows()
1277            .into_iter()
1278            .map(|row| row.to_vec())
1279            .collect();
1280        let center = transform.weighted_column_mean.to_vec();
1281        let scale = transform.rescale.to_vec();
1282        let restored = scale_transform_from_payload(
1283            &Some(projection),
1284            &Some(center),
1285            &Some(scale),
1286            Some(transform.non_intercept_start),
1287            Some(transform.projection_ridge_alpha),
1288        )
1289        .expect("payload round-trip should succeed")
1290        .expect("payload should produce a transform");
1291        assert_eq!(
1292            restored.projection_ridge_alpha, transform.projection_ridge_alpha,
1293            "alpha must round-trip exactly through payload serialization"
1294        );
1295    }
1296}