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