1use ndarray::{Array1, ArrayView1};
2use serde::{Deserialize, Serialize};
3use std::ops::{Deref, DerefMut};
4
5pub use gam_linalg::RidgePolicy;
6
7pub use gam_spec::*;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub enum RidgeMatrixForm {
12 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#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
247pub enum StabilizationRule {
248 FixedConstant,
250 InertiaTarget { spd_floor: f64 },
252 Heuristic,
254 UserSpecified,
256 BackoffEscalation { attempts: usize },
258}
259
260#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
262pub enum StabilizationKind {
263 None,
264 SolverDampingOnly,
267 NumericalPerturbation,
272 ApproximationOnly,
276 ObjectiveStabilization,
279 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#[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 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 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 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 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 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 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 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 #[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 #[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 #[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}
666macro_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#[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 #[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#[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#[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#[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#[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 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 pub fn exact_exp(&self) -> Array1<f64> {
913 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::exact_full_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::exact_full_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::exact_full_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}