Skip to main content

gam_test_support/
lib.rs

1//! Generic testing utilities.
2
3pub mod cli_harness;
4pub mod fd_checker;
5pub mod reference;
6
7use gam_linalg::matrix::{DenseDesignMatrix, DesignMatrix};
8use gam_problem::PenaltyMatrix;
9use gam_problem::block_spec::ParameterBlockSpec;
10use ndarray::{Array1, Array2, array};
11
12// `no_densify_design` (and the operator-backed fixture behind it) is a
13// linear-algebra fixture; it lives in `gam-linalg` alongside the operator traits
14// it exercises and is re-exported here so this crate's tests keep their familiar
15// `crate::test_support::no_densify_design` path. Single source of truth — the
16// previous duplicate copy drifted out of the crate that owns the types.
17pub use gam_linalg::test_support::no_densify_design;
18
19pub struct BinomialLocationScaleBaseFixture {
20    pub n: usize,
21    pub y: Array1<f64>,
22    pub weights: Array1<f64>,
23    pub threshold_design: DesignMatrix,
24    pub log_sigma_design: DesignMatrix,
25    pub threshold_spec: ParameterBlockSpec,
26    pub log_sigma_spec: ParameterBlockSpec,
27}
28
29pub fn binomial_location_scale_base_fixture() -> BinomialLocationScaleBaseFixture {
30    let n = 7usize;
31    let y = Array1::from_vec(vec![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0]);
32    let weights = Array1::from_vec(vec![1.0; n]);
33    let threshold_design =
34        DesignMatrix::Dense(DenseDesignMatrix::from(Array2::from_elem((n, 1), 1.0)));
35    let log_sigma_design =
36        DesignMatrix::Dense(DenseDesignMatrix::from(Array2::from_elem((n, 1), 1.0)));
37    let threshold_spec = ParameterBlockSpec {
38        name: "threshold".to_string(),
39        design: threshold_design.clone(),
40        offset: Array1::zeros(n),
41        penalties: vec![PenaltyMatrix::Dense(Array2::eye(1))],
42        nullspace_dims: vec![],
43        initial_log_lambdas: array![0.0],
44        initial_beta: Some(array![0.2]),
45        gauge_priority: 100,
46        jacobian_callback: None,
47        stacked_design: None,
48        stacked_offset: None,
49    };
50    let log_sigma_spec = ParameterBlockSpec {
51        name: "log_sigma".to_string(),
52        design: log_sigma_design.clone(),
53        offset: Array1::zeros(n),
54        penalties: vec![PenaltyMatrix::Dense(Array2::eye(1))],
55        nullspace_dims: vec![],
56        initial_log_lambdas: array![-0.2],
57        initial_beta: Some(array![-0.1]),
58        gauge_priority: 100,
59        jacobian_callback: None,
60        stacked_design: None,
61        stacked_offset: None,
62    };
63    BinomialLocationScaleBaseFixture {
64        n,
65        y,
66        weights,
67        threshold_design,
68        log_sigma_design,
69        threshold_spec,
70        log_sigma_spec,
71    }
72}
73
74/// Assert that a central difference of an array-producing function matches the analytical derivative.
75#[macro_export]
76macro_rules! assert_central_difference_array {
77    ($x:expr, $h:expr, |$var:ident| $eval:expr, $analytical:expr, $tol:expr) => {
78        let f_plus = {
79            let $var = $x + $h;
80            $eval
81        };
82        let f_minus = {
83            let $var = $x - $h;
84            $eval
85        };
86        assert_eq!(f_plus.len(), $analytical.len());
87        for j in 0..$analytical.len() {
88            let fd = (f_plus[j] - f_minus[j]) / (2.0 * $h);
89            approx::assert_abs_diff_eq!(fd, $analytical[j], epsilon = $tol);
90        }
91    };
92}
93
94/// Asserts that a finite difference dense matrix matches an analytically
95/// computed directional derivative matrix to a *relative* tolerance
96/// `rel_tol·(1 + |analytic|)`, plus component-wise sign agreement.
97///
98/// Use this (rather than the absolute-tolerance [`assert_matrix_derivativefd`])
99/// when the comparison's dominant components are O(0.1–1) and the finite
100/// difference is contaminated by a small, non-smooth solver channel — e.g. an
101/// adaptive PIRLS stabilization ridge whose magnitude shifts discontinuously
102/// across the ± FD re-solves. There the exact analytic IFT derivative (which
103/// correctly excludes that solver-only ridge) and the FD disagree by a fixed
104/// *fraction* of the component magnitude, not a fixed absolute amount, so an
105/// absolute bound tuned for the small components is spuriously tight on the
106/// large ones. The two underlying derivative channels are validated separately
107/// against their own FDs, so this asserts the composite to the achievable
108/// relative precision rather than weakening the per-channel checks (gam#855).
109pub fn assert_matrix_derivativefd_rel(
110    fd: &Array2<f64>,
111    analytic: &Array2<f64>,
112    rel_tol: f64,
113    label: &str,
114) {
115    assert_eq!(analytic.dim(), fd.dim(), "{} dimensions must match", label);
116    for i in 0..analytic.nrows() {
117        for j in 0..analytic.ncols() {
118            let analytic_ij = analytic[[i, j]];
119            let fd_ij = fd[[i, j]];
120            let tol = rel_tol * (1.0 + analytic_ij.abs());
121            if analytic_ij.abs() > tol && fd_ij.abs() > tol {
122                assert_eq!(
123                    analytic_ij.signum(),
124                    fd_ij.signum(),
125                    "{} sign mismatch at ({}, {}): analytic={}, fd={}",
126                    label,
127                    i,
128                    j,
129                    analytic_ij,
130                    fd_ij
131                );
132            }
133            let diff = (analytic_ij - fd_ij).abs();
134            assert!(
135                diff <= tol,
136                "{} value mismatch at ({}, {}): analytic={}, fd={}, abs_diff={}, rel_tol={}, tol={}",
137                label,
138                i,
139                j,
140                analytic_ij,
141                fd_ij,
142                diff,
143                rel_tol,
144                tol
145            );
146        }
147    }
148}
149
150/// Asserts that a finite difference dense matrix closely matches an analytically computed
151/// directional derivative matrix, both in tolerance and in component-wise sign.
152pub fn assert_matrix_derivativefd(fd: &Array2<f64>, analytic: &Array2<f64>, tol: f64, label: &str) {
153    assert_eq!(analytic.dim(), fd.dim(), "{} dimensions must match", label);
154    for i in 0..analytic.nrows() {
155        for j in 0..analytic.ncols() {
156            let analytic_ij = analytic[[i, j]];
157            let fd_ij = fd[[i, j]];
158            let diff = (analytic_ij - fd_ij).abs();
159
160            if analytic_ij.abs() > tol && fd_ij.abs() > tol {
161                assert_eq!(
162                    analytic_ij.signum(),
163                    fd_ij.signum(),
164                    "{} sign mismatch at ({}, {}): analytic={}, fd={}",
165                    label,
166                    i,
167                    j,
168                    analytic_ij,
169                    fd_ij
170                );
171            }
172            assert!(
173                diff <= tol,
174                "{} value mismatch at ({}, {}): analytic={}, fd={}, abs_diff={}, tol={}",
175                label,
176                i,
177                j,
178                analytic_ij,
179                fd_ij,
180                diff,
181                tol
182            );
183        }
184    }
185}
186
187pub fn spec_from_dense(
188    name: &str,
189    design: ndarray::Array2<f64>,
190) -> gam_problem::block_spec::ParameterBlockSpec {
191    let n = design.nrows();
192    gam_problem::block_spec::ParameterBlockSpec {
193        name: name.to_string(),
194        design: gam_linalg::matrix::DesignMatrix::Dense(
195            gam_linalg::matrix::DenseDesignMatrix::from(design),
196        ),
197        offset: ndarray::Array1::<f64>::zeros(n),
198        penalties: Vec::new(),
199        nullspace_dims: Vec::new(),
200        initial_log_lambdas: ndarray::Array1::<f64>::zeros(0),
201        initial_beta: None,
202        gauge_priority: 100,
203        jacobian_callback: None,
204        stacked_design: None,
205        stacked_offset: None,
206    }
207}
208
209pub fn spec_from_dense_with_priority(
210    name: &str,
211    design: ndarray::Array2<f64>,
212    priority: u8,
213) -> gam_problem::block_spec::ParameterBlockSpec {
214    let mut s = spec_from_dense(name, design);
215    s.gauge_priority = priority;
216    s
217}