Skip to main content

gam_problem/
types.rs

1use ndarray::{Array1, ArrayView1};
2use serde::{Deserialize, Serialize};
3use std::ops::{Deref, DerefMut};
4
5pub use gam_linalg::{RidgeDeterminantMode, RidgePolicy};
6
7pub use gam_spec::*;
8
9/// Storage form of the ridge penalty matrix.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub enum RidgeMatrixForm {
12    /// Ridge matrix is `delta * I`.
13    ScaledIdentity,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct InvalidStabilization {
18    reason: String,
19}
20
21impl InvalidStabilization {
22    fn new(reason: impl Into<String>) -> Self {
23        Self {
24            reason: reason.into(),
25        }
26    }
27
28    pub fn reason(&self) -> &str {
29        &self.reason
30    }
31}
32
33impl std::fmt::Display for InvalidStabilization {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        write!(f, "invalid stabilization metadata: {}", self.reason)
36    }
37}
38
39impl std::error::Error for InvalidStabilization {}
40
41#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
42struct RidgePassportWire {
43    delta: f64,
44    matrix_form: RidgeMatrixForm,
45    policy: RidgePolicy,
46}
47
48/// Validated ridge metadata stamped into a fitted PIRLS result.
49///
50/// Construction and deserialization both reject non-finite or negative
51/// magnitudes; fields are private so invalid state cannot be assembled with a
52/// literal.
53#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
54#[serde(try_from = "RidgePassportWire", into = "RidgePassportWire")]
55pub struct RidgePassport {
56    delta: f64,
57    matrix_form: RidgeMatrixForm,
58    policy: RidgePolicy,
59}
60
61impl RidgePassport {
62    pub fn scaled_identity(delta: f64, policy: RidgePolicy) -> Result<Self, InvalidStabilization> {
63        if !(delta.is_finite() && delta >= 0.0) {
64            return Err(InvalidStabilization::new(format!(
65                "ridge delta must be finite and non-negative, got {delta:?}"
66            )));
67        }
68        Ok(Self {
69            delta: if delta == 0.0 { 0.0 } else { delta },
70            matrix_form: RidgeMatrixForm::ScaledIdentity,
71            policy,
72        })
73    }
74
75    /// Exact zero-ridge passport; this fixed sentinel has no unchecked input.
76    pub const fn zero(policy: RidgePolicy) -> Self {
77        Self {
78            delta: 0.0,
79            matrix_form: RidgeMatrixForm::ScaledIdentity,
80            policy,
81        }
82    }
83
84    #[inline]
85    pub const fn delta(self) -> f64 {
86        self.delta
87    }
88
89    #[inline]
90    pub const fn matrix_form(self) -> RidgeMatrixForm {
91        self.matrix_form
92    }
93
94    #[inline]
95    pub const fn policy(self) -> RidgePolicy {
96        self.policy
97    }
98
99    #[inline]
100    pub const fn penalty_logdet_ridge(self) -> f64 {
101        if self.policy.accounts_for_objective() {
102            self.delta
103        } else {
104            0.0
105        }
106    }
107
108    #[inline]
109    pub const fn laplace_hessian_ridge(self) -> f64 {
110        if self.policy.accounts_for_objective() {
111            self.delta
112        } else {
113            0.0
114        }
115    }
116}
117
118impl TryFrom<RidgePassportWire> for RidgePassport {
119    type Error = InvalidStabilization;
120
121    fn try_from(wire: RidgePassportWire) -> Result<Self, Self::Error> {
122        let mut passport = Self::scaled_identity(wire.delta, wire.policy)?;
123        passport.matrix_form = wire.matrix_form;
124        Ok(passport)
125    }
126}
127
128impl From<RidgePassport> for RidgePassportWire {
129    fn from(passport: RidgePassport) -> Self {
130        Self {
131            delta: passport.delta,
132            matrix_form: passport.matrix_form,
133            policy: passport.policy,
134        }
135    }
136}
137
138// ============================================================================
139// StabilizationLedger: canonical accounting for every fixed/heuristic ridge
140// added anywhere in the solver, linear-algebra, or family code paths.
141//
142// Five semantically distinct ridge uses must NEVER be conflated:
143//   1. SolverDampingOnly      — Levenberg/trust-region damping; never enters
144//                               objective, gradient, logdet, Hessian, or any
145//                               saved/serialized model artifact.
146//   2. NumericalPerturbation  — added strictly so a linear solve is well-
147//                               posed (e.g. Cholesky of a near-singular
148//                               matrix). Carries an optional backward-error
149//                               bound. Does NOT change the objective.
150//   3. ExplicitPrior          — model-level `delta * I` (or block-diagonal)
151//                               prior. Appears in quadratic, log normalizer,
152//                               Laplace Hessian, serialization, diagnostics.
153//   4. ApproximationOnly      — changes a named downstream approximation
154//                               (for example sigma-point cubature covariance)
155//                               but not the fitted model or its objective.
156//   5. ObjectiveStabilization — algorithm-selected ridge consistently included
157//                               in the fitted objective, preserving exact versus
158//                               approximate determinant provenance.
159//
160// `RidgePassport` above already encodes the inclusion-flag matrix for the
161// PIRLS Laplace ridge specifically; this ledger is the broader sibling for
162// every declared solver, approximation, and model ridge, so a downstream consumer can ask
163// `ledger.quadratic_delta()` rather than rediscovering the policy. The three
164// inclusion bits were lifted into the `StabilizationKind` discriminant so the
165// (kind, inclusion-flags) invariant is enforced statically — heterogeneous
166// combinations like "ExplicitPrior with quadratic excluded" no longer typecheck.
167// ============================================================================
168
169/// Inertia of a symmetric matrix (count of positive / zero / negative
170/// eigenvalues). Used by `bump_with_matrix` and other indefinite-aware
171/// stabilization rules to drive δ from spectral evidence rather than a
172/// condition-number heuristic.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(try_from = "InertiaWire", into = "InertiaWire")]
175pub struct Inertia {
176    positive: usize,
177    zero: usize,
178    negative: usize,
179}
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
182struct InertiaWire {
183    positive: usize,
184    zero: usize,
185    negative: usize,
186}
187
188impl Inertia {
189    pub fn new(
190        positive: usize,
191        zero: usize,
192        negative: usize,
193    ) -> Result<Self, InvalidStabilization> {
194        let total = positive
195            .checked_add(zero)
196            .and_then(|value| value.checked_add(negative))
197            .ok_or_else(|| InvalidStabilization::new("inertia count sum overflows usize"))?;
198        if total == 0 {
199            return Err(InvalidStabilization::new(
200                "inertia must describe a non-empty matrix",
201            ));
202        }
203        Ok(Self {
204            positive,
205            zero,
206            negative,
207        })
208    }
209
210    pub const fn positive(self) -> usize {
211        self.positive
212    }
213
214    pub const fn zero(self) -> usize {
215        self.zero
216    }
217
218    pub const fn negative(self) -> usize {
219        self.negative
220    }
221
222    pub fn total(self) -> usize {
223        self.positive + self.zero + self.negative
224    }
225}
226
227impl TryFrom<InertiaWire> for Inertia {
228    type Error = InvalidStabilization;
229
230    fn try_from(wire: InertiaWire) -> Result<Self, Self::Error> {
231        Self::new(wire.positive, wire.zero, wire.negative)
232    }
233}
234
235impl From<Inertia> for InertiaWire {
236    fn from(inertia: Inertia) -> Self {
237        Self {
238            positive: inertia.positive,
239            zero: inertia.zero,
240            negative: inertia.negative,
241        }
242    }
243}
244
245/// Why a stabilization δ was chosen at this site.
246#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
247pub enum StabilizationRule {
248    /// δ is a hard-coded constant in the source.
249    FixedConstant,
250    /// δ chosen so the SPD floor τ is met: δ = max(0, τ - λ_min(H)).
251    InertiaTarget { spd_floor: f64 },
252    /// δ chosen via a condition-number / sqrt-ratio heuristic.
253    Heuristic,
254    /// User- or family-specified prior precision.
255    UserSpecified,
256    /// δ derived from a back-off escalation after a factorization failure.
257    BackoffEscalation { attempts: usize },
258}
259
260/// Semantically distinct flavours a ridge δ can have.
261#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
262pub enum StabilizationKind {
263    None,
264    /// LM/TR damping. NEVER enters the objective, gradient, logdet, Hessian,
265    /// or any saved model artifact. Lives only inside the trust-region step.
266    SolverDampingOnly,
267    /// Added strictly so a linear solve succeeds. The objective/Hessian the
268    /// caller sees is unchanged; the perturbation is a property of the
269    /// solver, not the model. Its optional backward-error bound lives on the
270    /// enclosing ledger.
271    NumericalPerturbation,
272    /// An explicit part of a downstream approximation, not of the fitted
273    /// model. Unlike `NumericalPerturbation`, consumers must not report the
274    /// result as if the unperturbed estimand had been evaluated.
275    ApproximationOnly,
276    /// Algorithm-selected ridge consistently included in the fitted objective.
277    /// The ledger's objective policy preserves determinant provenance.
278    ObjectiveStabilization,
279    /// Part of the model. Enters quadratic, log normalizer, Hessian,
280    /// serialization, and user-visible summaries.
281    ExplicitPrior,
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
285struct StabilizationLedgerWire {
286    kind: StabilizationKind,
287    delta: f64,
288    matrix_form: RidgeMatrixForm,
289    chosen_by: StabilizationRule,
290    objective_policy: Option<RidgePolicy>,
291    backward_error_bound: Option<f64>,
292    inertia_before: Option<Inertia>,
293    inertia_after: Option<Inertia>,
294}
295
296/// Canonical validated record of one stabilization applied at one site.
297#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
298#[serde(try_from = "StabilizationLedgerWire", into = "StabilizationLedgerWire")]
299pub struct StabilizationLedger {
300    kind: StabilizationKind,
301    delta: f64,
302    matrix_form: RidgeMatrixForm,
303    chosen_by: StabilizationRule,
304    objective_policy: Option<RidgePolicy>,
305    backward_error_bound: Option<f64>,
306    inertia_before: Option<Inertia>,
307    inertia_after: Option<Inertia>,
308}
309
310impl StabilizationLedger {
311    /// "No stabilization applied at this site" sentinel.
312    pub const fn none() -> Self {
313        Self {
314            kind: StabilizationKind::None,
315            delta: 0.0,
316            matrix_form: RidgeMatrixForm::ScaledIdentity,
317            chosen_by: StabilizationRule::FixedConstant,
318            objective_policy: None,
319            backward_error_bound: None,
320            inertia_before: None,
321            inertia_after: None,
322        }
323    }
324
325    /// LM/TR damping. δ is invisible to the objective, gradient, and any
326    /// saved artifact. Asserting this invariant at every read site is the
327    /// whole reason the ledger exists.
328    pub fn solver_damping(
329        delta: f64,
330        chosen_by: StabilizationRule,
331    ) -> Result<Self, InvalidStabilization> {
332        Self::try_new(StabilizationKind::SolverDampingOnly, delta, chosen_by, None)
333    }
334
335    /// Solver-only perturbation that leaves the objective unchanged. The
336    /// caller may attach a backward-error bound when one is available
337    /// (e.g. from iterative refinement / Wilkinson-style analysis).
338    pub fn numerical_perturbation(
339        delta: f64,
340        chosen_by: StabilizationRule,
341        backward_error_bound: Option<f64>,
342    ) -> Result<Self, InvalidStabilization> {
343        Self::try_new(
344            StabilizationKind::NumericalPerturbation,
345            delta,
346            chosen_by,
347            backward_error_bound,
348        )
349    }
350
351    /// Ridge declared as part of a downstream approximation (for example the
352    /// regularized rho covariance used to place sigma points).
353    pub fn approximation_only(
354        delta: f64,
355        chosen_by: StabilizationRule,
356    ) -> Result<Self, InvalidStabilization> {
357        Self::try_new(StabilizationKind::ApproximationOnly, delta, chosen_by, None)
358    }
359
360    /// Model-level explicit prior. δ enters every accounting pass: the
361    /// quadratic penalty, the Laplace Hessian, the penalty log-determinant,
362    /// and serialization.
363    pub fn explicit_prior(
364        delta: f64,
365        matrix_form: RidgeMatrixForm,
366        policy: RidgePolicy,
367    ) -> Result<Self, InvalidStabilization> {
368        if !policy.accounts_for_objective() {
369            return Err(InvalidStabilization::new(
370                "an explicit prior requires an objective-accounted ridge policy",
371            ));
372        }
373        let mut ledger = Self::try_new(
374            StabilizationKind::ExplicitPrior,
375            delta,
376            StabilizationRule::UserSpecified,
377            None,
378        )?;
379        ledger.matrix_form = matrix_form;
380        ledger.objective_policy = Some(policy);
381        Ok(ledger)
382    }
383
384    /// Bridge from the existing `RidgePassport` so PIRLS-side code (which
385    /// already passes a `RidgePassport` through every call) can hand a
386    /// ledger to anything that wants the new uniform view.
387    ///
388    /// `RidgePolicy` is homogeneous-by-construction: every constructor sets
389    /// the three inclusion flags identically. A passport whose policy
390    /// excludes every accounting term is morally a numerical perturbation
391    /// (the ridge is there to make the solve work but the objective ignores
392    /// it); a passport whose policy includes every accounting term is an
393    /// objective-accounted stabilization. Heterogeneous flag combinations cannot be produced
394    /// by the public `RidgePolicy` API and have no inhabitants downstream.
395    pub const fn from_passport(passport: RidgePassport) -> Self {
396        let objective_policy = if passport.policy.accounts_for_objective() {
397            Some(passport.policy)
398        } else {
399            None
400        };
401        let kind = if objective_policy.is_some() {
402            StabilizationKind::ObjectiveStabilization
403        } else {
404            StabilizationKind::NumericalPerturbation
405        };
406        Self {
407            kind,
408            delta: passport.delta,
409            matrix_form: passport.matrix_form,
410            chosen_by: StabilizationRule::FixedConstant,
411            objective_policy,
412            backward_error_bound: None,
413            inertia_before: None,
414            inertia_after: None,
415        }
416    }
417
418    fn try_new(
419        kind: StabilizationKind,
420        delta: f64,
421        chosen_by: StabilizationRule,
422        backward_error_bound: Option<f64>,
423    ) -> Result<Self, InvalidStabilization> {
424        if matches!(kind, StabilizationKind::None) {
425            return Err(InvalidStabilization::new(
426                "None stabilization must be constructed with StabilizationLedger::none",
427            ));
428        }
429        if !(delta.is_finite() && delta >= 0.0) {
430            return Err(InvalidStabilization::new(format!(
431                "stabilization delta must be finite and non-negative, got {delta:?}"
432            )));
433        }
434        Self::validate_rule(chosen_by)?;
435        if let Some(bound) = backward_error_bound
436            && !(bound.is_finite() && bound >= 0.0)
437        {
438            return Err(InvalidStabilization::new(format!(
439                "backward-error bound must be finite and non-negative, got {bound:?}"
440            )));
441        }
442        if !matches!(kind, StabilizationKind::NumericalPerturbation)
443            && backward_error_bound.is_some()
444        {
445            return Err(InvalidStabilization::new(
446                "only a numerical perturbation may carry a backward-error bound",
447            ));
448        }
449        Ok(Self {
450            kind,
451            delta: if delta == 0.0 { 0.0 } else { delta },
452            matrix_form: RidgeMatrixForm::ScaledIdentity,
453            chosen_by,
454            objective_policy: None,
455            backward_error_bound,
456            inertia_before: None,
457            inertia_after: None,
458        })
459    }
460
461    fn validate_rule(rule: StabilizationRule) -> Result<(), InvalidStabilization> {
462        match rule {
463            StabilizationRule::InertiaTarget { spd_floor }
464                if !(spd_floor.is_finite() && spd_floor > 0.0) =>
465            {
466                Err(InvalidStabilization::new(format!(
467                    "inertia-target SPD floor must be finite and strictly positive, got {spd_floor:?}"
468                )))
469            }
470            StabilizationRule::BackoffEscalation { attempts } if attempts == 0 => Err(
471                InvalidStabilization::new("backoff escalation must record at least one attempt"),
472            ),
473            _ => Ok(()),
474        }
475    }
476
477    pub fn with_inertia(
478        mut self,
479        before: Option<Inertia>,
480        after: Option<Inertia>,
481    ) -> Result<Self, InvalidStabilization> {
482        if before.is_some() != after.is_some() {
483            return Err(InvalidStabilization::new(
484                "inertia diagnostics must record both the pre- and post-stabilization matrix",
485            ));
486        }
487        if let (Some(before), Some(after)) = (before, after)
488            && before.total() != after.total()
489        {
490            return Err(InvalidStabilization::new(format!(
491                "inertia dimensions disagree: before={}, after={}",
492                before.total(),
493                after.total()
494            )));
495        }
496        if matches!(self.chosen_by, StabilizationRule::InertiaTarget { .. }) {
497            let Some(after) = after else {
498                return Err(InvalidStabilization::new(
499                    "inertia-target stabilization must record post-stabilization inertia",
500                ));
501            };
502            if after.zero() != 0 || after.negative() != 0 {
503                return Err(InvalidStabilization::new(format!(
504                    "inertia-target stabilization did not certify SPD curvature: zero={}, negative={}",
505                    after.zero(),
506                    after.negative()
507                )));
508            }
509        }
510        self.inertia_before = before;
511        self.inertia_after = after;
512        Ok(self)
513    }
514
515    pub const fn kind(self) -> StabilizationKind {
516        self.kind
517    }
518
519    pub const fn delta(self) -> f64 {
520        self.delta
521    }
522
523    pub const fn matrix_form(self) -> RidgeMatrixForm {
524        self.matrix_form
525    }
526
527    pub const fn chosen_by(self) -> StabilizationRule {
528        self.chosen_by
529    }
530
531    /// Exact determinant/objective provenance for an explicit prior or
532    /// objective-accounted algorithmic stabilization. `None` for every
533    /// solver-only, numerical, and approximation-only perturbation.
534    pub const fn objective_policy(self) -> Option<RidgePolicy> {
535        self.objective_policy
536    }
537
538    pub const fn backward_error_bound(self) -> Option<f64> {
539        self.backward_error_bound
540    }
541
542    pub const fn inertia_before(self) -> Option<Inertia> {
543        self.inertia_before
544    }
545
546    pub const fn inertia_after(self) -> Option<Inertia> {
547        self.inertia_after
548    }
549
550    /// δ value to fold into the quadratic penalty term, or 0.0 if this
551    /// ledger entry is not part of the model. Derived from `kind`: only
552    /// objective-accounted stabilization contributes.
553    #[inline]
554    pub const fn quadratic_delta(&self) -> f64 {
555        match self.kind {
556            StabilizationKind::ExplicitPrior | StabilizationKind::ObjectiveStabilization => {
557                self.delta
558            }
559            StabilizationKind::None
560            | StabilizationKind::SolverDampingOnly
561            | StabilizationKind::NumericalPerturbation
562            | StabilizationKind::ApproximationOnly => 0.0,
563        }
564    }
565
566    /// δ value to add to the Laplace Hessian, or 0.0 if not included.
567    /// Derived from `kind`: only objective-accounted stabilization contributes.
568    #[inline]
569    pub const fn laplace_hessian_delta(&self) -> f64 {
570        match self.kind {
571            StabilizationKind::ExplicitPrior | StabilizationKind::ObjectiveStabilization => {
572                self.delta
573            }
574            StabilizationKind::None
575            | StabilizationKind::SolverDampingOnly
576            | StabilizationKind::NumericalPerturbation
577            | StabilizationKind::ApproximationOnly => 0.0,
578        }
579    }
580
581    /// δ value to add inside log|S + δ I|, or 0.0 if not included.
582    /// Derived from `kind`: only objective-accounted stabilization contributes.
583    #[inline]
584    pub const fn penalty_logdet_delta(&self) -> f64 {
585        match self.kind {
586            StabilizationKind::ExplicitPrior | StabilizationKind::ObjectiveStabilization => {
587                self.delta
588            }
589            StabilizationKind::None
590            | StabilizationKind::SolverDampingOnly
591            | StabilizationKind::NumericalPerturbation
592            | StabilizationKind::ApproximationOnly => 0.0,
593        }
594    }
595}
596
597impl TryFrom<StabilizationLedgerWire> for StabilizationLedger {
598    type Error = InvalidStabilization;
599
600    fn try_from(wire: StabilizationLedgerWire) -> Result<Self, Self::Error> {
601        if matches!(wire.kind, StabilizationKind::None) {
602            if wire.delta != 0.0
603                || wire.chosen_by != StabilizationRule::FixedConstant
604                || wire.objective_policy.is_some()
605                || wire.backward_error_bound.is_some()
606                || wire.inertia_before.is_some()
607                || wire.inertia_after.is_some()
608            {
609                return Err(InvalidStabilization::new(
610                    "None stabilization must have zero delta and no diagnostic payload",
611                ));
612            }
613            return Ok(Self::none());
614        }
615        let mut ledger = Self::try_new(
616            wire.kind,
617            wire.delta,
618            wire.chosen_by,
619            wire.backward_error_bound,
620        )?;
621        if matches!(wire.kind, StabilizationKind::ExplicitPrior)
622            && wire.chosen_by != StabilizationRule::UserSpecified
623        {
624            return Err(InvalidStabilization::new(
625                "an explicit prior must be recorded as user specified",
626            ));
627        }
628        match (wire.kind, wire.objective_policy) {
629            (
630                StabilizationKind::ExplicitPrior | StabilizationKind::ObjectiveStabilization,
631                Some(policy),
632            ) if policy.accounts_for_objective() => {
633                ledger.objective_policy = Some(policy);
634            }
635            (StabilizationKind::ExplicitPrior | StabilizationKind::ObjectiveStabilization, _) => {
636                return Err(InvalidStabilization::new(
637                    "objective-accounted stabilization must preserve its ridge policy",
638                ));
639            }
640            (_, Some(_)) => {
641                return Err(InvalidStabilization::new(
642                    "only objective-accounted stabilization may carry objective ridge provenance",
643                ));
644            }
645            (_, None) => {}
646        }
647        ledger.matrix_form = wire.matrix_form;
648        ledger.with_inertia(wire.inertia_before, wire.inertia_after)
649    }
650}
651
652impl From<StabilizationLedger> for StabilizationLedgerWire {
653    fn from(ledger: StabilizationLedger) -> Self {
654        Self {
655            kind: ledger.kind,
656            delta: ledger.delta,
657            matrix_form: ledger.matrix_form,
658            chosen_by: ledger.chosen_by,
659            objective_policy: ledger.objective_policy,
660            backward_error_bound: ledger.backward_error_bound,
661            inertia_before: ledger.inertia_before,
662            inertia_after: ledger.inertia_after,
663        }
664    }
665}
666/// Generate a `#[repr(transparent)]` `Array1<f64>` newtype with the
667/// `new`/`Deref`/`DerefMut`/`AsRef`/`From` boilerplate used by unconstrained
668/// numeric vectors in this module.
669macro_rules! array1_f64_newtype {
670    ($name:ident) => {
671        #[repr(transparent)]
672        #[derive(Clone, Debug, PartialEq)]
673        pub struct $name(pub Array1<f64>);
674
675        impl $name {
676            #[inline]
677            pub fn new(values: Array1<f64>) -> Self {
678                Self(values)
679            }
680
681            #[inline]
682            pub fn zeros(len: usize) -> Self {
683                Self(Array1::zeros(len))
684            }
685        }
686
687        impl Deref for $name {
688            type Target = Array1<f64>;
689            #[inline]
690            fn deref(&self) -> &Self::Target {
691                &self.0
692            }
693        }
694
695        impl DerefMut for $name {
696            #[inline]
697            fn deref_mut(&mut self) -> &mut Self::Target {
698                &mut self.0
699            }
700        }
701
702        impl AsRef<Array1<f64>> for $name {
703            #[inline]
704            fn as_ref(&self) -> &Array1<f64> {
705                &self.0
706            }
707        }
708
709        impl From<Array1<f64>> for $name {
710            #[inline]
711            fn from(values: Array1<f64>) -> Self {
712                Self(values)
713            }
714        }
715
716        impl From<$name> for Array1<f64> {
717            #[inline]
718            fn from(values: $name) -> Self {
719                values.0
720            }
721        }
722    };
723}
724
725array1_f64_newtype!(Coefficients);
726array1_f64_newtype!(LinearPredictor);
727
728/// Index into `TermCollectionSpec::smooth_terms` (and the parallel
729/// `TermCollectionDesign::smooth.terms` slice produced from it).
730///
731/// This is **not** a penalty/ρ index, **not** a column index, and **not** a
732/// coefficient-offset index. Keeping it behind a `#[repr(transparent)]`
733/// newtype makes those confusables a compile error: a `SmoothTermIdx` cannot
734/// be silently used to index `rho`, `beta`, or a design column.
735#[repr(transparent)]
736#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
737pub struct SmoothTermIdx(usize);
738
739impl SmoothTermIdx {
740    #[inline]
741    pub const fn new(idx: usize) -> Self {
742        Self(idx)
743    }
744
745    /// Sentinel used by transient builders that must allocate a coord config
746    /// before the smooth term it references has been positioned in the spec.
747    /// Every code path that constructs a sentinel must overwrite it before
748    /// the value escapes the builder.
749    #[inline]
750    pub const fn placeholder() -> Self {
751        Self(usize::MAX)
752    }
753
754    #[inline]
755    pub const fn get(self) -> usize {
756        self.0
757    }
758
759    #[inline]
760    pub const fn is_placeholder(self) -> bool {
761        self.0 == usize::MAX
762    }
763}
764
765impl std::fmt::Display for SmoothTermIdx {
766    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
767        write!(f, "{}", self.0)
768    }
769}
770
771/// Index into the canonical penalty list `&[CanonicalPenalty]` — equivalently,
772/// the position of a smoothing parameter in the ρ / λ vector.
773///
774/// Penalty/ρ indices are not interchangeable with `SmoothTermIdx` (a smooth
775/// term can carry multiple canonical penalties — e.g. tensor-product double
776/// penalties — and structural penalties don't correspond to any smooth term).
777/// Keeping them as separate newtypes makes the historical bug pattern
778/// "indexed `rho` with a smooth-term ordinal" impossible to express.
779#[repr(transparent)]
780#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
781pub struct PenaltyIdx(usize);
782
783impl PenaltyIdx {
784    #[inline]
785    pub const fn new(idx: usize) -> Self {
786        Self(idx)
787    }
788
789    #[inline]
790    pub const fn get(self) -> usize {
791        self.0
792    }
793}
794
795impl std::fmt::Display for PenaltyIdx {
796    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
797        write!(f, "{}", self.0)
798    }
799}
800
801/// Index into a single smooth term's set of basis functions — i.e. the `k`
802/// in "the k-th basis function `B_k(x)` of this term".
803///
804/// Distinct from:
805///   * [`SmoothTermIdx`] — selects *which* smooth term in the spec.
806///   * [`PenaltyIdx`]    — selects *which* ρ/λ entry / canonical penalty.
807///   * A design-matrix column index — which lives in the *combined* layout
808///     after intercept/parametric blocks and per-term offsets are applied;
809///     a `BasisIdx` is term-local, a column index is model-global.
810///
811/// Keeping this as its own `#[repr(transparent)]` newtype makes the
812/// historically-easy confusion "indexed a global column slice with a
813/// term-local basis ordinal" (or vice versa) a compile error.
814#[repr(transparent)]
815#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
816pub struct BasisIdx(usize);
817
818impl BasisIdx {
819    #[inline]
820    pub const fn new(idx: usize) -> Self {
821        Self(idx)
822    }
823
824    #[inline]
825    pub const fn get(self) -> usize {
826        self.0
827    }
828}
829
830impl std::fmt::Display for BasisIdx {
831    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
832        write!(f, "{}", self.0)
833    }
834}
835
836/// Index into the user-facing design matrix `data: Array2<f64>` — i.e. the
837/// position of a covariate column in the raw input frame, *before* any
838/// per-family basis expansion or intercept/parametric layout is applied.
839///
840/// Distinct from:
841///   * [`BasisIdx`] — term-local basis-function ordinal `k` of `B_k(x)`.
842///   * [`SmoothTermIdx`] — position in `TermCollectionSpec::smooth_terms`.
843///   * A coefficient-vector offset `β[i]` — spans the combined design after
844///     expansion, which is much wider than the user-facing data matrix.
845///
846/// Keeping this as its own `#[repr(transparent)]` newtype rules out the easy
847/// confusion of indexing the raw data frame with an expanded-column offset.
848#[repr(transparent)]
849#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
850pub struct ColIdx(usize);
851
852impl ColIdx {
853    #[inline]
854    pub const fn new(idx: usize) -> Self {
855        Self(idx)
856    }
857
858    #[inline]
859    pub const fn get(self) -> usize {
860        self.0
861    }
862}
863
864impl std::fmt::Display for ColIdx {
865    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
866        write!(f, "{}", self.0)
867    }
868}
869
870/// Index of an observation (row) in the user-facing data frame / design
871/// matrix — i.e. the `i` in "the i-th observation".
872///
873/// Distinct from every column-type index in this module ([`ColIdx`],
874/// [`BasisIdx`], [`SmoothTermIdx`], [`PenaltyIdx`]) and from coefficient
875/// offsets. Keeping rows behind their own `#[repr(transparent)]` newtype
876/// makes the classic `data[[col, row]]` transposition a compile error.
877#[repr(transparent)]
878#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
879pub struct RowIdx(usize);
880
881impl RowIdx {
882    #[inline]
883    pub const fn new(idx: usize) -> Self {
884        Self(idx)
885    }
886
887    #[inline]
888    pub const fn get(self) -> usize {
889        self.0
890    }
891}
892
893impl std::fmt::Display for RowIdx {
894    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
895        write!(f, "{}", self.0)
896    }
897}
898
899#[repr(transparent)]
900#[derive(Clone, Copy, Debug)]
901pub struct LogSmoothingParamsView<'a>(ArrayView1<'a, f64>);
902
903impl<'a> LogSmoothingParamsView<'a> {
904    /// Borrow a smoothing vector only after every coordinate satisfies the
905    /// exact shared logarithmic-strength contract.
906    pub fn new(values: ArrayView1<'a, f64>) -> Result<Self, crate::IndexedLogStrengthDomainError> {
907        crate::validate_log_strengths(values.iter().copied())?;
908        Ok(Self(values))
909    }
910
911    /// Exact physical strengths for this already-validated vector.
912    pub fn exact_exp(&self) -> Array1<f64> {
913        // `new` established the private invariant; the borrow prevents the
914        // source array from being mutated for this view's lifetime.
915        self.0.mapv(f64::exp)
916    }
917}
918
919impl<'a> Deref for LogSmoothingParamsView<'a> {
920    type Target = ArrayView1<'a, f64>;
921
922    fn deref(&self) -> &Self::Target {
923        &self.0
924    }
925}
926
927#[cfg(test)]
928mod newtype_tests {
929    use super::*;
930    use ndarray::array;
931
932    #[test]
933    fn smooth_term_idx_new_get_roundtrip() {
934        let idx = SmoothTermIdx::new(7);
935        assert_eq!(idx.get(), 7);
936        assert!(!idx.is_placeholder());
937        assert_eq!(format!("{idx}"), "7");
938    }
939
940    #[test]
941    fn smooth_term_idx_placeholder_is_detected() {
942        let p = SmoothTermIdx::placeholder();
943        assert!(p.is_placeholder());
944        assert_eq!(p.get(), usize::MAX);
945    }
946
947    #[test]
948    fn smooth_term_idx_ordering() {
949        let a = SmoothTermIdx::new(1);
950        let b = SmoothTermIdx::new(2);
951        assert!(a < b);
952        assert_eq!(a, SmoothTermIdx::new(1));
953    }
954
955    #[test]
956    fn coefficients_zeros_and_deref() {
957        let c = Coefficients::zeros(3);
958        assert_eq!(c.len(), 3);
959        assert!(c.iter().all(|&v| v == 0.0));
960    }
961
962    #[test]
963    fn coefficients_from_array1() {
964        let arr = array![1.0, 2.0, 3.0];
965        let c = Coefficients::from(arr.clone());
966        assert_eq!(*c, arr);
967    }
968
969    #[test]
970    fn log_smoothing_params_view_is_validated_and_exponentiates_exactly() {
971        let arr = array![crate::LOG_STRENGTH_MIN, 0.0, crate::LOG_STRENGTH_MAX];
972        let rho = LogSmoothingParamsView::new(arr.view()).expect("closed domain");
973        for (actual, expected) in rho.exact_exp().iter().zip(arr.iter()) {
974            assert_eq!(actual.to_bits(), expected.exp().to_bits());
975        }
976
977        let invalid = array![0.0, crate::LOG_STRENGTH_MAX + 1.0];
978        let error = LogSmoothingParamsView::new(invalid.view()).unwrap_err();
979        assert_eq!(error.coordinate, 1);
980        assert_eq!(error.value, crate::LOG_STRENGTH_MAX + 1.0);
981    }
982
983    #[test]
984    fn linear_predictor_zeros_and_deref() {
985        let lp = LinearPredictor::zeros(4);
986        assert_eq!(lp.len(), 4);
987        assert!(lp.iter().all(|&v| v == 0.0));
988    }
989}
990
991#[cfg(test)]
992mod ridge_policy_tests {
993    use super::{
994        Inertia, RidgeMatrixForm, RidgePassport, RidgePolicy, StabilizationKind,
995        StabilizationLedger, StabilizationRule,
996    };
997    use serde_json::json;
998
999    #[test]
1000    fn solver_only_ridge_policy_stays_off_objective_accounting() {
1001        let passport = RidgePassport::scaled_identity(1.0e-4, RidgePolicy::solver_only())
1002            .expect("finite non-negative ridge");
1003
1004        assert!(
1005            !passport.policy().accounts_for_objective(),
1006            "solver-only ridge must not add a quadratic prior"
1007        );
1008        assert_eq!(
1009            passport.penalty_logdet_ridge(),
1010            0.0,
1011            "solver-only ridge must not shift the penalty logdet"
1012        );
1013        assert_eq!(
1014            passport.laplace_hessian_ridge(),
1015            0.0,
1016            "solver-only ridge must not shift the Laplace Hessian"
1017        );
1018
1019        let ledger = StabilizationLedger::from_passport(passport);
1020        assert!(
1021            matches!(ledger.kind(), StabilizationKind::NumericalPerturbation),
1022            "solver-only ridge is a numerical perturbation, not an explicit prior"
1023        );
1024        assert_eq!(ledger.backward_error_bound(), None);
1025        assert_eq!(
1026            ledger.quadratic_delta(),
1027            0.0,
1028            "solver-only ridge must not contribute to the optimized objective"
1029        );
1030        assert_eq!(
1031            ledger.laplace_hessian_delta(),
1032            0.0,
1033            "solver-only ridge must not contribute to REML curvature accounting"
1034        );
1035        assert_eq!(
1036            ledger.penalty_logdet_delta(),
1037            0.0,
1038            "solver-only ridge must not contribute to determinant accounting"
1039        );
1040    }
1041
1042    #[test]
1043    fn approximation_ridge_is_passported_without_becoming_a_model_prior() {
1044        let ledger =
1045            StabilizationLedger::approximation_only(1.0e-8, StabilizationRule::FixedConstant)
1046                .expect("valid approximation ridge");
1047        assert!(matches!(
1048            ledger.kind(),
1049            StabilizationKind::ApproximationOnly
1050        ));
1051        assert_eq!(ledger.delta(), 1.0e-8);
1052        assert_eq!(ledger.quadratic_delta(), 0.0);
1053        assert_eq!(ledger.laplace_hessian_delta(), 0.0);
1054        assert_eq!(ledger.penalty_logdet_delta(), 0.0);
1055    }
1056
1057    #[test]
1058    fn stabilization_magnitudes_reject_negative_and_non_finite_values() {
1059        for invalid in [-1.0, f64::NEG_INFINITY, f64::INFINITY, f64::NAN] {
1060            assert!(RidgePassport::scaled_identity(invalid, RidgePolicy::solver_only()).is_err());
1061            assert!(
1062                StabilizationLedger::solver_damping(invalid, StabilizationRule::FixedConstant)
1063                    .is_err()
1064            );
1065        }
1066    }
1067
1068    #[test]
1069    fn serde_cannot_bypass_passport_validation() {
1070        let negative = json!({
1071            "delta": -1.0,
1072            "matrix_form": "ScaledIdentity",
1073            "policy": "SolverOnly"
1074        });
1075        assert!(serde_json::from_value::<RidgePassport>(negative).is_err());
1076
1077        let passport = RidgePassport::scaled_identity(
1078            2.5e-7,
1079            RidgePolicy::positive_part_approximate_objective(),
1080        )
1081        .expect("valid ridge");
1082        let roundtrip: RidgePassport =
1083            serde_json::from_value(serde_json::to_value(passport).expect("serialize passport"))
1084                .expect("deserialize validated passport");
1085        assert_eq!(roundtrip, passport);
1086    }
1087
1088    #[test]
1089    fn serde_cannot_bypass_ledger_semantics() {
1090        let invalid_none = json!({
1091            "kind": "None",
1092            "delta": 1.0,
1093            "matrix_form": "ScaledIdentity",
1094            "chosen_by": "FixedConstant",
1095            "objective_policy": null,
1096            "backward_error_bound": null,
1097            "inertia_before": null,
1098            "inertia_after": null
1099        });
1100        assert!(serde_json::from_value::<StabilizationLedger>(invalid_none).is_err());
1101
1102        let invalid_prior_rule = json!({
1103            "kind": "ExplicitPrior",
1104            "delta": 1.0,
1105            "matrix_form": "ScaledIdentity",
1106            "chosen_by": "Heuristic",
1107            "objective_policy": "ExactFullObjective",
1108            "backward_error_bound": null,
1109            "inertia_before": null,
1110            "inertia_after": null
1111        });
1112        assert!(serde_json::from_value::<StabilizationLedger>(invalid_prior_rule).is_err());
1113
1114        let bound_on_wrong_kind = json!({
1115            "kind": "ApproximationOnly",
1116            "delta": 1.0,
1117            "matrix_form": "ScaledIdentity",
1118            "chosen_by": "FixedConstant",
1119            "objective_policy": null,
1120            "backward_error_bound": 1.0e-10,
1121            "inertia_before": null,
1122            "inertia_after": null
1123        });
1124        assert!(serde_json::from_value::<StabilizationLedger>(bound_on_wrong_kind).is_err());
1125    }
1126
1127    #[test]
1128    fn rule_and_inertia_metadata_are_validated() {
1129        assert!(
1130            StabilizationLedger::solver_damping(
1131                1.0,
1132                StabilizationRule::InertiaTarget { spd_floor: 0.0 }
1133            )
1134            .is_err()
1135        );
1136        assert!(
1137            StabilizationLedger::solver_damping(
1138                1.0,
1139                StabilizationRule::BackoffEscalation { attempts: 0 }
1140            )
1141            .is_err()
1142        );
1143        assert!(Inertia::new(0, 0, 0).is_err());
1144        assert!(Inertia::new(usize::MAX, 1, 0).is_err());
1145
1146        let before = Inertia::new(2, 1, 0).expect("valid inertia");
1147        let wrong_dimension = Inertia::new(3, 1, 0).expect("valid inertia");
1148        let still_indefinite = Inertia::new(2, 0, 1).expect("valid inertia");
1149        let ledger = StabilizationLedger::numerical_perturbation(
1150            1.0e-8,
1151            StabilizationRule::BackoffEscalation { attempts: 2 },
1152            Some(1.0e-12),
1153        )
1154        .expect("valid perturbation");
1155        assert!(ledger.with_inertia(Some(before), None).is_err());
1156        assert!(
1157            ledger
1158                .with_inertia(Some(before), Some(wrong_dimension))
1159                .is_err()
1160        );
1161        let inertia_target = StabilizationLedger::numerical_perturbation(
1162            1.0e-8,
1163            StabilizationRule::InertiaTarget { spd_floor: 1.0e-12 },
1164            Some(1.0e-12),
1165        )
1166        .expect("valid inertia target parameters");
1167        assert!(
1168            inertia_target
1169                .with_inertia(Some(before), Some(still_indefinite))
1170                .is_err()
1171        );
1172    }
1173
1174    #[test]
1175    fn objective_accounting_is_structural() {
1176        let explicit = StabilizationLedger::explicit_prior(
1177            0.25,
1178            RidgeMatrixForm::ScaledIdentity,
1179            RidgePolicy::exact_full_objective(),
1180        )
1181        .expect("valid explicit prior");
1182        assert_eq!(explicit.quadratic_delta(), 0.25);
1183        assert_eq!(explicit.laplace_hessian_delta(), 0.25);
1184        assert_eq!(explicit.penalty_logdet_delta(), 0.25);
1185
1186        for policy in [
1187            RidgePolicy::exact_full_objective(),
1188            RidgePolicy::positive_part_approximate_objective(),
1189        ] {
1190            let passport = RidgePassport::scaled_identity(0.25, policy).expect("valid ridge");
1191            assert_eq!(passport.penalty_logdet_ridge(), 0.25);
1192            assert_eq!(passport.laplace_hessian_ridge(), 0.25);
1193        }
1194    }
1195
1196    #[test]
1197    fn passport_bridge_preserves_approximate_determinant_provenance() {
1198        let policy = RidgePolicy::positive_part_approximate_objective();
1199        let passport = RidgePassport::scaled_identity(0.5, policy).expect("valid ridge");
1200        let ledger = StabilizationLedger::from_passport(passport);
1201        assert_eq!(ledger.kind(), StabilizationKind::ObjectiveStabilization);
1202        assert_eq!(ledger.objective_policy(), Some(policy));
1203
1204        let roundtrip: StabilizationLedger =
1205            serde_json::from_value(serde_json::to_value(ledger).expect("serialize ledger"))
1206                .expect("deserialize ledger");
1207        assert_eq!(roundtrip.objective_policy(), Some(policy));
1208
1209        let collapsed = json!({
1210            "kind": "ObjectiveStabilization",
1211            "delta": 0.5,
1212            "matrix_form": "ScaledIdentity",
1213            "chosen_by": "FixedConstant",
1214            "objective_policy": null,
1215            "backward_error_bound": null,
1216            "inertia_before": null,
1217            "inertia_after": null
1218        });
1219        assert!(serde_json::from_value::<StabilizationLedger>(collapsed).is_err());
1220    }
1221}