#[cfg(test)]
mod adaptive_bounded_duchon_tests {
use super::*;
use gam_terms::basis::{
BSplineBasisSpec, BSplineIdentifiability, BSplineKnotSpec, DuchonBasisSpec,
DuchonNullspaceOrder, DuchonOperatorPenaltySpec, MaternBasisSpec, MaternNu,
OneDimensionalBoundary, SpatialIdentifiability,
};
use gam_solve::estimate::AdaptiveRegularizationOptions;
use super::design_assembly_constraint_tests::{
assert_term_collection_designs_match, run_two_block_exact_joint_optimize,
two_block_exact_joint_hyper_setup,
};
use ndarray::array;
#[test]
fn pure_duchon_aniso_penalties_stay_symmetric_through_freeze_and_cache() {
fn max_asymmetry(matrix: &Array2<f64>) -> f64 {
let n = matrix.nrows().min(matrix.ncols());
let mut max_asym = 0.0_f64;
for i in 0..n {
for j in 0..i {
max_asym = max_asym.max((matrix[[i, j]] - matrix[[j, i]]).abs());
}
}
max_asym
}
fn assert_design_penalties_symmetric(label: &str, design: &TermCollectionDesign) {
for (penalty_idx, penalty) in design.penalties.iter().enumerate() {
let max_asym = max_asymmetry(&penalty.local);
assert!(
max_asym <= 1e-10,
"{label} penalty {penalty_idx} asymmetry too large: {max_asym:.3e}"
);
}
}
fn assert_reparam_penalty_symmetric(label: &str, design: &TermCollectionDesign) {
let p_total = design.design.ncols();
let penalty_specs = design
.penalties
.iter()
.map(|penalty| gam_solve::estimate::PenaltySpec::Dense(penalty.to_global(p_total)))
.collect::<Vec<_>>();
let (canonical_penalties, _) = gam_terms::construction::canonicalize_penalty_specs(
&penalty_specs,
&design.nullspace_dims,
p_total,
label,
)
.expect("canonicalize penalties");
let invariant = gam_terms::construction::precompute_reparam_invariant_from_canonical(
&canonical_penalties,
p_total,
)
.expect("reparam invariant");
let lambdas = vec![1.0; canonical_penalties.len()];
let reparam = gam_terms::construction::stable_reparameterizationwith_invariant(
&canonical_penalties,
&lambdas,
p_total,
&invariant,
None,
)
.expect("stable reparameterization");
let max_asym = max_asymmetry(&reparam.s_transformed);
assert!(
max_asym <= 1e-10,
"{label} transformed penalty asymmetry too large: {max_asym:.3e}"
);
}
let data = array![
[0.0, 0.1, 0.2],
[0.2, 0.0, 0.4],
[0.4, 0.3, 0.1],
[0.6, 0.5, 0.7],
[0.8, 0.7, 0.3],
[1.0, 0.9, 0.8],
];
let spec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![SmoothTermSpec {
name: "pure_duchon".to_string(),
basis: SmoothBasisSpec::Duchon {
feature_cols: vec![0, 1, 2],
spec: DuchonBasisSpec {
radial_reparam: None,
periodic: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 5 },
length_scale: None,
power: 1.0,
nullspace_order: DuchonNullspaceOrder::Linear,
identifiability: SpatialIdentifiability::default(),
aniso_log_scales: Some(vec![0.0, 0.0, 0.0]),
operator_penalties: DuchonOperatorPenaltySpec::default(),
boundary: OneDimensionalBoundary::Open,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
}],
};
let base_design = build_term_collection_design(data.view(), &spec).expect("base design");
assert_design_penalties_symmetric("base", &base_design);
assert_reparam_penalty_symmetric("base", &base_design);
let frozen = freeze_term_collection_from_design(&spec, &base_design).expect("freeze spec");
let frozen_design = build_term_collection_design(data.view(), &frozen).expect("frozen rebuild");
assert_design_penalties_symmetric("frozen", &frozen_design);
assert_reparam_penalty_symmetric("frozen", &frozen_design);
let spatial_terms = spatial_length_scale_term_indices(&frozen);
assert!(
spatial_terms.is_empty(),
"pure Duchon enrolls no outer κ/ψ axis"
);
let rho_dim = frozen_design.penalties.len();
let dims_per_term = spatial_dims_per_term(&frozen, &spatial_terms);
let theta = Array1::<f64>::zeros(rho_dim);
let mut cache = SingleBlockExactJointDesignCache::new(
data.view(),
frozen.clone(),
frozen_design.clone(),
spatial_terms,
rho_dim,
dims_per_term,
)
.expect("single-block cache");
cache.ensure_theta(&theta).expect("updated theta");
assert_design_penalties_symmetric("cache", cache.design());
assert_reparam_penalty_symmetric("cache", cache.design());
}
#[test]
fn single_block_no_spatial_fast_path_returns_fully_frozen_spec() {
let n = 48usize;
let mut data = Array2::<f64>::zeros((n, 2));
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
let t = i as f64 / (n as f64 - 1.0);
data[[i, 0]] = t;
data[[i, 1]] = (i % 4) as f64;
y[i] = 0.5 + 1.5 * t;
}
let spec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![RandomEffectTermSpec {
name: "grp".to_string(),
feature_col: 1,
drop_first_level: false,
penalized: true,
frozen_levels: None,
}],
smooth_terms: vec![SmoothTermSpec {
name: "ps".to_string(),
basis: SmoothBasisSpec::BSpline1D {
feature_col: 0,
spec: BSplineBasisSpec {
degree: 3,
penalty_order: 2,
knotspec: BSplineKnotSpec::Generate {
data_range: (0.0, 1.0),
num_internal_knots: 4,
},
double_penalty: true,
identifiability: BSplineIdentifiability::None,
boundary: OneDimensionalBoundary::Open,
boundary_conditions: gam_terms::basis::BSplineBoundaryConditions::default(),
},
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
}],
};
let fit_opts = FitOptions {
max_iter: 40,
penalty_shrinkage_floor: None,
..FitOptions::default()
};
let fitted = fit_term_collectionwith_spatial_length_scale_optimization(
data.view(),
y,
Array1::ones(n),
Array1::zeros(n),
&spec,
LikelihoodSpec::gaussian_identity(),
&fit_opts,
&SpatialLengthScaleOptimizationOptions::default(),
)
.expect("single-block no-spatial fit should succeed");
fitted
.resolvedspec
.validate_frozen("resolvedspec")
.expect("single-block no-spatial fast path should fully freeze specs");
match &fitted.resolvedspec.smooth_terms[0].basis {
SmoothBasisSpec::BSpline1D { spec, .. } => {
assert!(matches!(spec.knotspec, BSplineKnotSpec::Provided(_)));
}
_ => panic!("expected P-spline term"),
}
assert!(
fitted.resolvedspec.random_effect_terms[0]
.frozen_levels
.is_some(),
"random-effect levels should be frozen in single-block no-spatial fast path"
);
}
#[test]
fn exact_joint_two_block_spatial_length_scale_freezes_duchon_centers() {
let n = 40usize;
let mut data = Array2::<f64>::zeros((n, 2));
for i in 0..n {
let x0 = i as f64 / (n as f64 - 1.0);
let x1 = (i as f64 * 0.19).cos();
data[[i, 0]] = x0;
data[[i, 1]] = x1;
}
let duchon_term = |name: &str, length_scale: f64| SmoothTermSpec {
name: name.to_string(),
basis: SmoothBasisSpec::Duchon {
feature_cols: vec![0, 1],
spec: DuchonBasisSpec {
radial_reparam: None,
periodic: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 8 },
length_scale: Some(length_scale),
power: 3.0,
nullspace_order: DuchonNullspaceOrder::Linear,
identifiability: SpatialIdentifiability::default(),
aniso_log_scales: None,
operator_penalties: DuchonOperatorPenaltySpec::default(),
boundary: OneDimensionalBoundary::Open,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
};
let meanspec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![duchon_term("mean_duchon", 0.8)],
};
let noisespec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![duchon_term("noise_duchon", 1.1)],
};
let solved = run_two_block_exact_joint_optimize(
data.view(),
&meanspec,
&noisespec,
"exact joint two-block spatial length-scale optimization should succeed",
);
for resolved in [&solved.resolved_specs[0], &solved.resolved_specs[1]] {
match &resolved.smooth_terms[0].basis {
SmoothBasisSpec::Duchon { spec, .. } => {
assert!(matches!(
spec.center_strategy,
CenterStrategy::UserProvided(_)
));
assert!(matches!(
spec.identifiability,
SpatialIdentifiability::FrozenTransform { .. }
));
}
_ => panic!("expected Duchon term"),
}
}
}
#[test]
fn joint_build_and_cache_rebuild_frozen_pure_duchon_blocks() {
let n = 72usize;
let d = 5usize;
let mut data = Array2::<f64>::zeros((n, d));
for i in 0..n {
let t = i as f64 / (n as f64 - 1.0);
data[[i, 0]] = t;
data[[i, 1]] = (0.17 * i as f64).sin();
data[[i, 2]] = (0.11 * i as f64).cos();
data[[i, 3]] = ((i % 7) as f64) / 6.0;
data[[i, 4]] = t * (1.0 - t);
}
let pure_duchon_term = |name: &str| SmoothTermSpec {
name: name.to_string(),
basis: SmoothBasisSpec::Duchon {
feature_cols: (0..d).collect(),
spec: DuchonBasisSpec {
radial_reparam: None,
periodic: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 24 },
length_scale: None,
power: 2.0,
nullspace_order: DuchonNullspaceOrder::Linear,
identifiability: SpatialIdentifiability::default(),
aniso_log_scales: Some(vec![0.0; d]),
operator_penalties: DuchonOperatorPenaltySpec::default(),
boundary: OneDimensionalBoundary::Open,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
};
let meanspec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![pure_duchon_term("mean_pure_duchon")],
};
let noisespec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![pure_duchon_term("noise_pure_duchon")],
};
let (boot_designs, frozen_specs) = build_term_collection_designs_and_freeze_joint(
data.view(),
&[meanspec.clone(), noisespec.clone()],
)
.expect("initial joint pure Duchon build");
assert_eq!(boot_designs.len(), 2);
assert_eq!(frozen_specs.len(), 2);
assert_eq!(boot_designs[0].smooth.terms[0].coeff_range.len(), 23);
assert_eq!(boot_designs[1].smooth.terms[0].coeff_range.len(), 23);
let (rebuilt_designs, refrozen_specs) =
build_term_collection_designs_and_freeze_joint(data.view(), &frozen_specs)
.expect("rebuilding frozen joint pure Duchon specs should succeed");
assert_eq!(rebuilt_designs.len(), 2);
assert_eq!(refrozen_specs.len(), 2);
for idx in 0..2 {
let direct = build_term_collection_design(data.view(), &frozen_specs[idx])
.expect("direct frozen pure Duchon rebuild");
assert_term_collection_designs_match(
&rebuilt_designs[idx],
&direct,
if idx == 0 {
"mean pure Duchon frozen rebuild"
} else {
"noise pure Duchon frozen rebuild"
},
);
assert_eq!(rebuilt_designs[idx].smooth.terms[0].coeff_range.len(), 23);
match &refrozen_specs[idx].smooth_terms[0].basis {
SmoothBasisSpec::Duchon { spec, .. } => {
assert!(matches!(
spec.identifiability,
SpatialIdentifiability::FrozenTransform { .. }
));
}
_ => panic!("expected Duchon term"),
}
}
let kappa_options = SpatialLengthScaleOptimizationOptions {
max_outer_iter: 1,
rel_tol: 1e-6,
pilot_subsample_threshold: 0,
outer_wall_clock_budget_secs: None,
..SpatialLengthScaleOptimizationOptions::default()
};
let joint_setup =
two_block_exact_joint_hyper_setup(&frozen_specs[0], &frozen_specs[1], &kappa_options);
assert_eq!(joint_setup.log_kappa_dim(), 0);
let mean_term_indices = spatial_length_scale_term_indices(&frozen_specs[0]);
let noise_term_indices = spatial_length_scale_term_indices(&frozen_specs[1]);
assert!(
mean_term_indices.is_empty() && noise_term_indices.is_empty(),
"pure Duchon blocks enroll no outer κ/ψ axis"
);
let mut cache = ExactJointDesignCache::new(
data.view(),
vec![
(
frozen_specs[0].clone(),
rebuilt_designs[0].clone(),
mean_term_indices.clone(),
),
(
frozen_specs[1].clone(),
rebuilt_designs[1].clone(),
noise_term_indices.clone(),
),
],
joint_setup.rho_dim(),
joint_setup.log_kappa_dims_per_term(),
)
.expect("pure Duchon exact-joint cache");
let theta0 = joint_setup.theta0();
assert_eq!(theta0.len(), joint_setup.rho_dim());
cache
.ensure_theta(&theta0)
.expect("pure Duchon cache theta update");
let cache_designs = cache.designs();
assert_term_collection_designs_match(
cache_designs[0],
&rebuilt_designs[0],
"mean pure Duchon cache",
);
assert_term_collection_designs_match(
cache_designs[1],
&rebuilt_designs[1],
"noise pure Duchon cache",
);
}
#[test]
fn bounded_linear_gaussian_fit_respects_interval() {
let n = 64usize;
let mut data = Array2::<f64>::zeros((n, 2));
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
let t = (i as f64) / ((n - 1) as f64);
let x = -1.0 + 2.0 * t;
let z = (2.0 * std::f64::consts::PI * 2.0 * t).sin();
data[[i, 0]] = x;
data[[i, 1]] = z;
y[i] = 0.25 + 0.8 * x + 0.05 * z;
}
let spec = TermCollectionSpec {
linear_terms: vec![
LinearTermSpec {
name: "x".to_string(),
feature_col: 0,
feature_cols: vec![0],
categorical_levels: vec![],
double_penalty: false,
coefficient_geometry: LinearCoefficientGeometry::Bounded {
min: 0.0,
max: 0.5,
prior: BoundedCoefficientPriorSpec::Beta { a: 2.0, b: 2.0 },
},
coefficient_min: None,
coefficient_max: None,
},
LinearTermSpec {
name: "z".to_string(),
feature_col: 1,
feature_cols: vec![1],
categorical_levels: vec![],
double_penalty: false,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: None,
coefficient_max: None,
},
],
random_effect_terms: vec![],
smooth_terms: vec![],
};
let fitted = fit_term_collectionwith_spatial_length_scale_optimization(
data.view(),
y,
Array1::ones(n),
Array1::zeros(n),
&spec,
LikelihoodSpec::gaussian_identity(),
&FitOptions {
max_iter: 40,
penalty_shrinkage_floor: None,
..FitOptions::default()
},
&SpatialLengthScaleOptimizationOptions {
enabled: false,
..SpatialLengthScaleOptimizationOptions::default()
},
)
.expect("bounded gaussian fit");
let bounded_idx = fitted.design.linear_ranges[0].1.start;
let estimate = fitted.fit.beta[bounded_idx];
assert!(
(0.0..=0.5).contains(&estimate),
"bounded coefficient escaped interval: {estimate}"
);
assert!(
estimate > 0.1,
"bounded coefficient should move into the positive interior, got {estimate}"
);
}
#[test]
fn bounded_fit_geometry_precision_is_on_user_scale() {
use gam_linalg::faer_ndarray::FaerCholesky;
let n = 72usize;
let mut data = Array2::<f64>::zeros((n, 2));
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
let t = (i as f64) / ((n - 1) as f64);
let x = -1.0 + 2.0 * t;
let z = (4.0 * std::f64::consts::PI * t).sin();
data[[i, 0]] = x;
data[[i, 1]] = z;
y[i] = 0.2 + 0.35 * x - 0.15 * z;
}
let spec = TermCollectionSpec {
linear_terms: vec![
LinearTermSpec {
name: "x".to_string(),
feature_col: 0,
feature_cols: vec![0],
categorical_levels: vec![],
double_penalty: false,
coefficient_geometry: LinearCoefficientGeometry::Bounded {
min: -0.5,
max: 0.5,
prior: BoundedCoefficientPriorSpec::Beta { a: 2.0, b: 2.0 },
},
coefficient_min: None,
coefficient_max: None,
},
LinearTermSpec {
name: "z".to_string(),
feature_col: 1,
feature_cols: vec![1],
categorical_levels: vec![],
double_penalty: false,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: None,
coefficient_max: None,
},
],
random_effect_terms: vec![],
smooth_terms: vec![],
};
let fitted = fit_term_collection_forspec(
data.view(),
y.view(),
Array1::ones(n).view(),
Array1::zeros(n).view(),
&spec,
LikelihoodSpec::gaussian_identity(),
&FitOptions {
max_iter: 40,
penalty_shrinkage_floor: None,
..FitOptions::default()
},
)
.expect("bounded gaussian fit");
let precision = &fitted
.fit
.geometry
.as_ref()
.expect("bounded fit geometry")
.penalized_hessian;
let covariance = fitted
.fit
.beta_covariance()
.expect("bounded user covariance");
assert_eq!(
covariance.nrows(),
precision.nrows(),
"bounded user covariance must be square and match the precision dimension"
);
assert_eq!(
covariance.ncols(),
precision.ncols(),
"bounded user covariance must be square and match the precision dimension"
);
assert!(
covariance.iter().all(|v| v.is_finite()),
"bounded user covariance must be finite on every entry"
);
assert!(
(0..covariance.nrows()).all(|i| covariance[[i, i]] > 0.0),
"bounded user covariance must have a strictly positive variance on every coefficient"
);
let cov_scale = fitted.fit.coefficient_covariance_scale();
assert!(
cov_scale.is_finite() && cov_scale > 0.0,
"profiled-Gaussian bounded fit must report a finite positive σ̂² scale, got {cov_scale}"
);
assert!(
cov_scale < 1.0,
"near-noiseless fit should have a small residual variance, got σ̂²={cov_scale}"
);
let chol = precision
.cholesky(faer::Side::Lower)
.expect("bounded user precision cholesky");
let solved = chol.solve_mat(&Array2::eye(covariance.nrows()));
for i in 0..solved.nrows() {
for j in 0..solved.ncols() {
let unscaled_cov = covariance[[i, j]] / cov_scale;
assert!(
(solved[[i, j]] - unscaled_cov).abs() < 1e-5,
"user-scale precision/covariance mismatch at ({i},{j}): inverse {}, \
covariance/σ̂² {unscaled_cov} (σ̂²={cov_scale})",
solved[[i, j]]
);
}
}
}
#[test]
fn term_collection_design_emits_linear_coefficient_constraints() {
let data = array![[0.0], [1.0], [2.0], [3.0]];
let spec = TermCollectionSpec {
linear_terms: vec![LinearTermSpec {
name: "x".to_string(),
feature_col: 0,
feature_cols: vec![0],
categorical_levels: vec![],
double_penalty: false,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: Some(0.0),
coefficient_max: Some(1.0),
}],
random_effect_terms: vec![],
smooth_terms: vec![],
};
let design = build_term_collection_design(data.view(), &spec).expect("design");
let constraints = design.linear_constraints.expect("constraints");
assert_eq!(constraints.a.ncols(), design.design.ncols());
assert_eq!(constraints.a.nrows(), 2);
let linear_idx = design.linear_ranges[0].1.start;
assert_eq!(constraints.a[[0, linear_idx]], 1.0);
assert_eq!(constraints.b[0], 0.0);
assert_eq!(constraints.a[[1, linear_idx]], -1.0);
assert_eq!(constraints.b[1], -1.0);
}
#[test]
fn linear_termspec_defaults_to_unpenalizedwhen_field_is_omitted() {
let json = r#"{"name":"x","feature_col":0}"#;
let term: LinearTermSpec = serde_json::from_str(json).expect("deserialize linear term");
assert!(!term.double_penalty);
assert!(matches!(
term.coefficient_geometry,
LinearCoefficientGeometry::Unconstrained
));
}
#[test]
fn linear_double_penalties_share_one_globalridge_block() {
let data = array![[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
let spec = TermCollectionSpec {
linear_terms: vec![
LinearTermSpec {
name: "x1".to_string(),
feature_col: 0,
feature_cols: vec![0],
categorical_levels: vec![],
double_penalty: true,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: None,
coefficient_max: None,
},
LinearTermSpec {
name: "x2".to_string(),
feature_col: 1,
feature_cols: vec![1],
categorical_levels: vec![],
double_penalty: true,
coefficient_geometry: LinearCoefficientGeometry::Unconstrained,
coefficient_min: None,
coefficient_max: None,
},
],
random_effect_terms: vec![],
smooth_terms: vec![],
};
let design = build_term_collection_design(data.view(), &spec).expect("design");
assert_eq!(design.penalties.len(), 1);
assert_eq!(design.penaltyinfo.len(), 1);
assert_eq!(design.penaltyinfo[0].termname.as_deref(), Some("linear"));
assert_eq!(design.penaltyinfo[0].penalty.effective_rank, 2);
let x1 = design.linear_ranges[0].1.start;
let x2 = design.linear_ranges[1].1.start;
let bp = &design.penalties[0];
let x1_local = x1 - bp.col_range.start;
let x2_local = x2 - bp.col_range.start;
assert_eq!(bp.local[[x1_local, x1_local]], 1.0);
assert_eq!(bp.local[[x2_local, x2_local]], 1.0);
}
#[test]
fn bounded_uniform_prior_matches_beta_one_one_terms() {
let theta = 0.7;
let uniform = bounded_prior_terms(theta, &BoundedCoefficientPriorSpec::Uniform);
let beta11 = bounded_prior_terms(theta, &BoundedCoefficientPriorSpec::Beta { a: 1.0, b: 1.0 });
assert!((uniform.0 - beta11.0).abs() < 1e-12);
assert!((uniform.1 - beta11.1).abs() < 1e-12);
assert!((uniform.2 - beta11.2).abs() < 1e-12);
assert!((uniform.3 - beta11.3).abs() < 1e-12);
}
#[test]
fn boundednone_prior_has_no_extra_latentobjective_terms() {
let theta = 0.7;
let none = bounded_prior_terms(theta, &BoundedCoefficientPriorSpec::None);
assert_eq!(none, (0.0, 0.0, 0.0, 0.0));
let uniform = bounded_prior_terms(theta, &BoundedCoefficientPriorSpec::Uniform);
assert!(uniform.0.is_finite());
assert!(uniform.0 < 0.0);
assert!(uniform.1.abs() > 1e-6);
assert!(uniform.2 > 0.0);
assert!(uniform.3.is_finite());
}
#[test]
fn exact_bounded_edf_matches_trace_formula_for_simple_penalty() {
let penalties = vec![PenaltySpec::Dense(Array2::eye(1))];
let lambdas = array![0.25];
let cov = array![[2.0]];
let (edf_by_block, _penalty_block_trace, edf_total) =
exact_bounded_edf(&penalties, &lambdas, &cov).expect("exact bounded edf");
assert_eq!(edf_by_block.len(), 1);
assert!((edf_by_block[0] - 0.5).abs() < 1e-12);
assert!((edf_total - 0.5).abs() < 1e-12);
}
#[test]
fn bounded_joint_hessian_directional_derivative_matches_finite_difference() {
let x = array![[0.2, -1.0], [0.8, 0.5], [1.1, 1.2], [1.7, -0.3]];
let y = array![0.4, 1.0, 1.7, 2.2];
let weights = Array1::ones(y.len());
let family = BoundedLinearFamily {
family: LikelihoodSpec::gaussian_identity(),
latent_cloglog_state: None,
mixture_link_state: None,
sas_link_state: None,
y: y.clone(),
weights: weights.clone(),
design: x.clone(),
designzeroed: {
let mut dz = x.clone();
dz.column_mut(0).fill(0.0);
dz
},
offset: Array1::zeros(y.len()),
bounded_terms: vec![BoundedLinearTermMeta {
col_idx: 0,
min: 0.0,
max: 1.0,
prior: BoundedCoefficientPriorSpec::Uniform,
}],
};
let state = vec![ParameterBlockState {
beta: array![0.4, -0.2],
eta: Array1::zeros(y.len()),
}];
let direction = array![0.3, -0.4];
let analytic = family
.exact_newton_joint_hessian_directional_derivative(&state, &direction)
.expect("analytic derivative")
.expect("joint derivative");
let h = 1e-6;
let plus_state = vec![ParameterBlockState {
beta: &state[0].beta + &(direction.clone() * h),
eta: Array1::zeros(y.len()),
}];
let minus_state = vec![ParameterBlockState {
beta: &state[0].beta - &(direction.clone() * h),
eta: Array1::zeros(y.len()),
}];
let plus = family
.exact_newton_joint_hessian(&plus_state)
.expect("plus hessian")
.expect("plus exact hessian");
let minus = family
.exact_newton_joint_hessian(&minus_state)
.expect("minus hessian")
.expect("minus exact hessian");
let fd = (plus - minus) / (2.0 * h);
for i in 0..analytic.nrows() {
for j in 0..analytic.ncols() {
assert_eq!(
analytic[[i, j]].signum(),
fd[[i, j]].signum(),
"directional derivative sign mismatch at ({i},{j}): analytic={}, fd={}",
analytic[[i, j]],
fd[[i, j]]
);
assert!(
(analytic[[i, j]] - fd[[i, j]]).abs() < 1e-5,
"directional derivative mismatch at ({i},{j}): analytic={}, fd={}",
analytic[[i, j]],
fd[[i, j]]
);
}
}
}
#[test]
fn adaptive_initial_epsilons_use_mean_fallbackwhen_median_is_tiny() {
let cache = SpatialOperatorRuntimeCache {
termname: "matern".to_string(),
feature_cols: vec![0, 1],
coeff_global_range: 0..2,
mass_penalty_global_idx: 0,
tension_penalty_global_idx: 1,
stiffness_penalty_global_idx: 2,
d0: array![[5e-10, 0.0], [6e-10, 0.0]],
d1: array![[1e-10, 0.0], [0.0, 1e-10], [2e-10, 0.0], [0.0, 2e-10]],
d2: array![
[3e-10, 0.0],
[0.0, 0.0],
[0.0, 0.0],
[3e-10, 0.0],
[4e-10, 0.0],
[0.0, 0.0],
[0.0, 0.0],
[4e-10, 0.0],
],
collocation_points: array![[0.0, 0.0], [1.0, 1.0]],
dimension: 2,
};
let beta = array![1.0, 1.0];
let (eps_0, eps_g, eps_c) =
compute_initial_epsilons(&beta, &[cache], 1e-8).expect("initial epsilons");
assert!(eps_0 >= 1e-8);
assert!(eps_g >= 1e-8);
assert!(eps_c >= 1e-8);
}
#[test]
fn adaptive_exact_psigradient_symmetrizes_nearly_symmetrichessian() {
let family = SpatialAdaptiveExactFamily {
family: LikelihoodSpec::gaussian_identity(),
latent_cloglog_state: None,
mixture_link_state: None,
sas_link_state: None,
y: Arc::new(array![0.0, 0.0]),
weights: Arc::new(array![1.0, 1.0]),
design: Arc::new(array![[1.0, 0.0], [0.0, 1.0]]),
offset: Arc::new(array![0.0, 0.0]),
linear_constraints: None,
runtime_caches: Arc::new(vec![SpatialOperatorRuntimeCache {
termname: "toy".to_string(),
feature_cols: vec![0],
coeff_global_range: 0..2,
mass_penalty_global_idx: 0,
tension_penalty_global_idx: 1,
stiffness_penalty_global_idx: 2,
d0: array![[1.0, 0.0], [0.0, 1.0]],
d1: array![[1.0, 0.0], [0.0, 1.0]],
d2: array![[1.0, 0.0], [0.0, 1.0]],
collocation_points: array![[0.0], [1.0]],
dimension: 1,
}]),
adaptive_params: vec![SpatialAdaptiveTermHyperParams {
lambda: [1.0, 1.0, 1.0],
epsilon: [1.0, 1.0, 1.0],
}],
fixed_quadratichessian: Arc::new(array![[0.0, 0.1], [3.0, 0.0]]),
hyperspecs: Arc::new(build_spatial_adaptive_hyperspecs(1)),
exact_eval_cache: Arc::new(Mutex::new(None)),
};
let spec = ParameterBlockSpec {
name: "toy".to_string(),
design: DesignMatrix::Dense(gam_linalg::matrix::DenseDesignMatrix::from(array![
[1.0, 0.0],
[0.0, 1.0]
])),
offset: array![0.0, 0.0],
penalties: vec![],
nullspace_dims: vec![],
initial_log_lambdas: Array1::zeros(0),
initial_beta: Some(array![0.0, 0.0]),
gauge_priority: 100,
jacobian_callback: None,
stacked_design: None,
stacked_offset: None,
};
let deriv = CustomFamilyBlockPsiDerivative {
penalty_index: None,
x_psi: Array2::zeros((2, 2)),
s_psi: Array2::zeros((2, 2)),
s_psi_components: None,
s_psi_penalty_components: None,
x_psi_psi: None,
s_psi_psi: None,
s_psi_psi_components: None,
s_psi_psi_penalty_components: None,
implicit_operator: None,
implicit_axis: 0,
implicit_group_id: None,
};
let state = vec![ParameterBlockState {
beta: array![0.0, 0.0],
eta: array![0.0, 0.0],
}];
let gradient = family
.exact_newton_joint_psi_terms(&state, std::slice::from_ref(&spec), &[vec![deriv]], 0)
.expect("adaptive joint psi terms should tolerate nearly symmetric Hessian")
.expect("adaptive joint psi terms should be present")
.objective_psi;
assert!(
gradient.is_finite(),
"expected finite adaptive joint psi objective term after symmetrization, got {gradient}"
);
}
#[test]
fn adaptiveweight_clamp_is_applied_in_u_space() {
let cache = SpatialOperatorRuntimeCache {
termname: "matern".to_string(),
feature_cols: vec![0, 1],
coeff_global_range: 0..2,
mass_penalty_global_idx: 0,
tension_penalty_global_idx: 1,
stiffness_penalty_global_idx: 2,
d0: array![[0.0, 0.0]],
d1: array![[0.0, 0.0], [0.0, 0.0]],
d2: array![[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]],
collocation_points: array![[0.0, 0.0]],
dimension: 2,
};
let beta = array![0.0, 0.0];
let out = compute_spatial_adaptiveweights_for_beta(
&beta,
&[cache],
1e-8,
1e-8,
1e-8,
1e-8,
1e2,
None,
)
.expect("adaptive weights");
assert_eq!(out.len(), 1);
assert!((out[0].inv_magweight[0] - 1e-2).abs() < 1e-12);
assert!((out[0].invgradweight[0] - 1e-2).abs() < 1e-12);
assert!((out[0].inv_lapweight[0] - 1e-2).abs() < 1e-12);
}
#[test]
fn adaptiveweight_inverse_consistencywithout_clamp() {
let cache = SpatialOperatorRuntimeCache {
termname: "matern".to_string(),
feature_cols: vec![0, 1],
coeff_global_range: 0..2,
mass_penalty_global_idx: 0,
tension_penalty_global_idx: 1,
stiffness_penalty_global_idx: 2,
d0: array![[1.0, 0.0], [2.0, 0.0]],
d1: array![[1.0, 0.0], [0.0, 1.0], [2.0, 0.0], [0.0, 2.0]],
d2: array![
[1.0, 0.0],
[0.0, 0.0],
[0.0, 0.0],
[0.0, 0.0],
[2.0, 0.0],
[0.0, 0.0],
[0.0, 0.0],
[0.0, 0.0],
],
collocation_points: array![[0.0, 0.0], [1.0, 1.0]],
dimension: 2,
};
let beta = array![1.0, 1.0];
let out = compute_spatial_adaptiveweights_for_beta(
&beta,
&[cache],
1e-6,
1e-6,
1e-6,
1e-12,
1e12,
None,
)
.expect("adaptive weights");
assert_eq!(out.len(), 1);
assert!((out[0].inv_magweight[0] - 1.0).abs() < 1e-10);
assert!((out[0].inv_magweight[1] - 2.0).abs() < 1e-10);
assert!((out[0].invgradweight[0] - 2.0_f64.sqrt()).abs() < 1e-10);
assert!((out[0].invgradweight[1] - 8.0_f64.sqrt()).abs() < 1e-10);
assert!((out[0].inv_lapweight[0] - 1.0).abs() < 1e-10);
assert!((out[0].inv_lapweight[1] - 2.0).abs() < 1e-10);
}
#[test]
fn adaptiveweight_is_monotone_in_signal_magnitude() {
let cache = SpatialOperatorRuntimeCache {
termname: "matern".to_string(),
feature_cols: vec![0, 1],
coeff_global_range: 0..2,
mass_penalty_global_idx: 0,
tension_penalty_global_idx: 1,
stiffness_penalty_global_idx: 2,
d0: array![[1.0, 0.0]],
d1: array![[1.0, 0.0], [0.0, 1.0]],
d2: array![[1.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]],
collocation_points: array![[0.0, 0.0]],
dimension: 2,
};
let beta_small = array![0.25, 0.25];
let beta_large = array![2.0, 2.0];
let small = compute_spatial_adaptiveweights_for_beta(
&beta_small,
std::slice::from_ref(&cache),
1e-8,
1e-8,
1e-8,
1e-12,
1e12,
None,
)
.expect("small adaptive weights");
let large = compute_spatial_adaptiveweights_for_beta(
&beta_large,
&[cache],
1e-8,
1e-8,
1e-8,
1e-12,
1e12,
None,
)
.expect("large adaptive weights");
assert!(small[0].inv_magweight[0] < large[0].inv_magweight[0]);
assert!(small[0].invgradweight[0] < large[0].invgradweight[0]);
assert!(small[0].inv_lapweight[0] < large[0].inv_lapweight[0]);
}
fn posterior_snr_finite_difference_d2(m: usize, h: f64) -> Array2<f64> {
let mut d2 = Array2::<f64>::zeros((m, m));
for k in 1..m - 1 {
d2[[k, k - 1]] = 1.0 / (h * h);
d2[[k, k]] = -2.0 / (h * h);
d2[[k, k + 1]] = 1.0 / (h * h);
}
d2
}
#[test]
fn posterior_snr_weighting_suppresses_noise_and_preserves_edge() {
use gam_linalg::faer_ndarray::FaerCholesky;
use faer::Side;
let m = 41usize;
let h = 1.0 / (m as f64 - 1.0);
let xs: Vec<f64> = (0..m).map(|j| j as f64 * h).collect();
let edge_center = 0.5;
let bump_width = 0.06;
let amplitude = 2.0;
let truth = Array1::from_iter(xs.iter().map(|&x| {
let z = (x - edge_center) / bump_width;
amplitude * (-0.5 * z * z).exp()
}));
let left_flat: Vec<usize> = (0..m).filter(|&j| xs[j] <= 0.25).collect();
let edge_band: Vec<usize> = (0..m)
.filter(|&j| (xs[j] - edge_center).abs() <= 0.10)
.collect();
assert!(!left_flat.is_empty() && !edge_band.is_empty());
let noise = |j: usize| -> f64 {
let s = ((j as f64) * 12.9898).sin() * 43758.5453;
let frac = s - s.floor(); 2.0 * frac - 1.0 };
let mut y = truth.clone();
let mut beta_hat = truth.clone();
for &j in &left_flat {
let nz = 0.85 * noise(j);
y[j] += nz;
beta_hat[j] += nz; }
for j in 0..m {
if !left_flat.contains(&j) {
let nz = 0.02 * noise(j + 7);
y[j] += nz;
beta_hat[j] += nz;
}
}
let mut sigma = Array2::<f64>::zeros((m, m));
for j in 0..m {
let var = if left_flat.contains(&j) { 0.55 } else { 1e-4 };
sigma[[j, j]] = var;
}
let d2 = posterior_snr_finite_difference_d2(m, h);
let cache = SpatialOperatorRuntimeCache {
termname: "snr_1d".to_string(),
feature_cols: vec![0],
coeff_global_range: 0..m,
mass_penalty_global_idx: 0,
tension_penalty_global_idx: 1,
stiffness_penalty_global_idx: 2,
d0: Array2::<f64>::zeros((m, m)),
d1: Array2::<f64>::zeros((m, m)),
d2: d2.clone(),
collocation_points: {
let mut cp = Array2::<f64>::zeros((m, 1));
for j in 0..m {
cp[[j, 0]] = xs[j];
}
cp
},
dimension: 1,
};
let eps = 0.05; let weight_floor = 1e-12;
let weight_ceiling = 1e12;
let mag = compute_spatial_adaptiveweights_for_beta(
&beta_hat,
std::slice::from_ref(&cache),
eps,
eps,
eps,
weight_floor,
weight_ceiling,
None,
)
.expect("magnitude-only weights");
let snr = compute_spatial_adaptiveweights_for_beta(
&beta_hat,
std::slice::from_ref(&cache),
eps,
eps,
eps,
weight_floor,
weight_ceiling,
Some(&sigma),
)
.expect("posterior-SNR weights");
let w_mag = mag[0].inv_lapweight.mapv(|iv| 1.0 / iv);
let w_snr = snr[0].inv_lapweight.mapv(|iv| 1.0 / iv);
let mean = |idx: &[usize], w: &Array1<f64>| -> f64 {
idx.iter().map(|&j| w[j]).sum::<f64>() / idx.len() as f64
};
assert!(
mean(&left_flat, &w_snr) > mean(&left_flat, &w_mag) * 1.5,
"SNR must penalize the noisy flat region more: w_snr={:.4e} vs w_mag={:.4e}",
mean(&left_flat, &w_snr),
mean(&left_flat, &w_mag),
);
let lambda = 0.5;
let fit = |w: &Array1<f64>| -> Array1<f64> {
let k = scalar_operatorhessian(&d2, w); let mut a = Array2::<f64>::eye(m);
a.scaled_add(lambda, &k);
let factor = a
.cholesky(Side::Lower)
.expect("penalized normal matrix is SPD");
factor.solvevec(&y)
};
let fit_mag = fit(&w_mag);
let fit_snr = fit(&w_snr);
let region_mse = |idx: &[usize], f: &Array1<f64>| -> f64 {
idx.iter().map(|&j| (f[j] - truth[j]).powi(2)).sum::<f64>() / idx.len() as f64
};
let mse_flat_mag = region_mse(&left_flat, &fit_mag);
let mse_flat_snr = region_mse(&left_flat, &fit_snr);
let mse_edge_mag = region_mse(&edge_band, &fit_mag);
let mse_edge_snr = region_mse(&edge_band, &fit_snr);
assert!(
mse_flat_snr < mse_flat_mag * 0.9,
"posterior-SNR should be strictly smoother in the noisy flat region: \
mse_flat_snr={mse_flat_snr:.6e} vs mse_flat_mag={mse_flat_mag:.6e}"
);
assert!(
mse_edge_snr <= mse_edge_mag * 1.05,
"posterior-SNR must recover the edge at least as sharply: \
mse_edge_snr={mse_edge_snr:.6e} vs mse_edge_mag={mse_edge_mag:.6e}"
);
assert!(
(mse_flat_mag - mse_flat_snr).abs() > 1e-9,
"the two weightings must produce materially different flat-region fits"
);
}
#[test]
fn exact_spatial_adaptive_regularization_fit_runswithout_mm() {
let n = 48usize;
let mut data = Array2::<f64>::zeros((n, 2));
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
let x0 = i as f64 / (n as f64 - 1.0);
let x1 = (0.19 * i as f64).sin();
data[[i, 0]] = x0;
data[[i, 1]] = x1;
y[i] = (3.0 * x0).sin() + 0.25 * x1;
}
let spec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![SmoothTermSpec {
name: "matern".to_string(),
basis: SmoothBasisSpec::Matern {
feature_cols: vec![0, 1],
spec: MaternBasisSpec {
periodic: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 10 },
length_scale: 0.7,
nu: MaternNu::FiveHalves,
include_intercept: false,
double_penalty: true,
identifiability: MaternIdentifiability::CenterSumToZero,
aniso_log_scales: None,
nullspace_shrinkage_survived: None,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
}],
};
let fit = fit_term_collection_forspec(
data.view(),
y.view(),
Array1::ones(n).view(),
Array1::zeros(n).view(),
&spec,
LikelihoodSpec::gaussian_identity(),
&FitOptions {
max_iter: 25,
tol: 1e-5,
adaptive_regularization: Some(AdaptiveRegularizationOptions {
enabled: true,
max_mm_iter: 4,
beta_rel_tol: 1e-4,
max_epsilon_outer_iter: 2,
min_epsilon: 1e-6,
..AdaptiveRegularizationOptions::default()
}),
penalty_shrinkage_floor: None,
..FitOptions::default()
},
)
.expect("exact adaptive spatial fit should succeed");
let diag = fit
.adaptive_diagnostics
.as_ref()
.expect("adaptive diagnostics should be present");
assert_eq!(diag.mm_iterations, 0);
assert!(diag.epsilon_0.is_finite() && diag.epsilon_0 > 0.0);
assert!(diag.epsilon_g.is_finite() && diag.epsilon_g > 0.0);
assert!(diag.epsilon_c.is_finite() && diag.epsilon_c > 0.0);
assert_eq!(diag.maps.len(), 1);
assert!(fit.fit.beta.iter().all(|v| v.is_finite()));
assert!(fit.fit.reml_score.is_finite());
}
#[test]
fn pure_duchon_skips_operator_triplet_adaptive_overlay() {
let n = 56usize;
let mut data = Array2::<f64>::zeros((n, 2));
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
let t = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
data[[i, 0]] = t.cos();
data[[i, 1]] = t.sin();
y[i] = 0.55 * t.sin() + 0.18 * (3.0 * t).cos();
}
let spec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![SmoothTermSpec {
name: "pure_duchon_circle".to_string(),
basis: SmoothBasisSpec::Duchon {
feature_cols: vec![0, 1],
spec: DuchonBasisSpec {
radial_reparam: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 16 },
length_scale: None,
power: 0.0,
nullspace_order: DuchonNullspaceOrder::Degree(2),
identifiability: SpatialIdentifiability::default(),
aniso_log_scales: None,
operator_penalties: DuchonOperatorPenaltySpec::all_disabled(),
periodic: None,
boundary: OneDimensionalBoundary::Open,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
}],
};
let fit = fit_term_collection_forspec(
data.view(),
y.view(),
Array1::ones(n).view(),
Array1::zeros(n).view(),
&spec,
LikelihoodSpec::gaussian_identity(),
&FitOptions {
compute_inference: false,
max_iter: 28,
tol: 1e-5,
adaptive_regularization: Some(AdaptiveRegularizationOptions {
enabled: true,
max_mm_iter: 4,
beta_rel_tol: 1e-4,
max_epsilon_outer_iter: 2,
min_epsilon: 1e-6,
..AdaptiveRegularizationOptions::default()
}),
penalty_shrinkage_floor: None,
..FitOptions::default()
},
)
.expect("pure Duchon exact adaptive fit should succeed");
assert!(
fit.adaptive_diagnostics.is_none(),
"pure Duchon carries no operator triplet, so the Charbonnier overlay must not run"
);
assert!(fit.fit.beta.iter().all(|v| v.is_finite()));
assert!(fit.fit.reml_score.is_finite());
}
#[test]
fn exact_spatial_adaptive_binomial_sas_fit_preserves_link_state() {
let n = 36usize;
let mut data = Array2::<f64>::zeros((n, 2));
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
let x0 = -1.0 + 2.0 * (i as f64 / (n as f64 - 1.0));
let x1 = (0.23 * i as f64).sin();
data[[i, 0]] = x0;
data[[i, 1]] = x1;
let eta = 0.55 * x0 - 0.2 * x1 + 0.1 * x0 * x1;
let p = 1.0 / (1.0 + (-eta).exp());
let u = ((i * 37 + 13) % 100) as f64 / 100.0;
y[i] = if u < p { 1.0 } else { 0.0 };
}
let spec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![SmoothTermSpec {
name: "matern".to_string(),
basis: SmoothBasisSpec::Matern {
feature_cols: vec![0, 1],
spec: MaternBasisSpec {
periodic: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 10 },
length_scale: 0.7,
nu: MaternNu::FiveHalves,
include_intercept: false,
double_penalty: true,
identifiability: MaternIdentifiability::CenterSumToZero,
aniso_log_scales: None,
nullspace_shrinkage_survived: None,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
}],
};
let fit = fit_term_collection_forspec(
data.view(),
y.view(),
Array1::ones(n).view(),
Array1::zeros(n).view(),
&spec,
LikelihoodSpec::binomial_sas(
gam_solve::mixture_link::sas_link_state_from_raw(0.1, -0.2)
.expect("valid SAS link state"),
),
&FitOptions {
sas_link: Some(gam_problem::SasLinkSpec {
initial_epsilon: 0.1,
initial_log_delta: -0.2,
}),
max_iter: 40,
tol: 1e-5,
adaptive_regularization: Some(AdaptiveRegularizationOptions {
enabled: true,
max_mm_iter: 4,
beta_rel_tol: 1e-4,
max_epsilon_outer_iter: 2,
min_epsilon: 1e-6,
..AdaptiveRegularizationOptions::default()
}),
penalty_shrinkage_floor: None,
..FitOptions::default()
},
)
.expect("exact adaptive SAS fit should succeed");
match fit.fit.fitted_link {
FittedLinkState::Sas { state, covariance } => {
assert!(state.epsilon.is_finite());
assert!(state.log_delta.is_finite());
assert!(state.delta.is_finite() && state.delta > 0.0);
assert!(covariance.is_none());
}
other => panic!("expected SAS link parameters, got {other:?}"),
}
}
fn build_spatial_adaptive_joint_hyper_scaffold(
baseline: &FittedTermCollection,
runtime_caches: &[SpatialOperatorRuntimeCache],
y: &Array1<f64>,
n: usize,
) -> (
SpatialAdaptiveExactFamily,
ParameterBlockSpec,
Vec<Vec<CustomFamilyBlockPsiDerivative>>,
) {
let hyperspecs = build_spatial_adaptive_hyperspecs(runtime_caches.len());
let zero_psi_op: std::sync::Arc<dyn gam_custom_family::CustomFamilyPsiDerivativeOperator> =
std::sync::Arc::new(gam_custom_family::ZeroPsiDerivativeOperator::new(
baseline.design.design.nrows(),
baseline.design.design.ncols(),
));
let derivative_blocks = vec![
hyperspecs
.iter()
.map(|_| CustomFamilyBlockPsiDerivative {
penalty_index: None,
x_psi: Array2::<f64>::zeros((0, 0)),
s_psi: Array2::<f64>::zeros((0, 0)),
s_psi_components: None,
s_psi_penalty_components: None,
x_psi_psi: None,
s_psi_psi: None,
s_psi_psi_components: None,
s_psi_psi_penalty_components: None,
implicit_operator: Some(std::sync::Arc::clone(&zero_psi_op)),
implicit_axis: 0,
implicit_group_id: None,
})
.collect::<Vec<_>>(),
];
let base_family = SpatialAdaptiveExactFamily {
family: LikelihoodSpec::gaussian_identity(),
latent_cloglog_state: None,
mixture_link_state: None,
sas_link_state: None,
y: Arc::new(y.clone()),
weights: Arc::new(Array1::ones(n)),
design: baseline.design.design.to_dense_arc(),
offset: Arc::new(Array1::zeros(n)),
linear_constraints: baseline.design.linear_constraints.clone(),
runtime_caches: Arc::new(runtime_caches.to_vec()),
adaptive_params: Vec::new(),
fixed_quadratichessian: Arc::new(Array2::<f64>::zeros((
baseline.design.design.ncols(),
baseline.design.design.ncols(),
))),
hyperspecs: Arc::new(hyperspecs),
exact_eval_cache: Arc::new(Mutex::new(None)),
};
let blockspec = ParameterBlockSpec {
name: "eta".to_string(),
design: baseline.design.design.clone(),
offset: Array1::zeros(n),
penalties: vec![],
nullspace_dims: vec![],
initial_log_lambdas: Array1::zeros(0),
initial_beta: Some(baseline.fit.beta.clone()),
gauge_priority: 100,
jacobian_callback: None,
stacked_design: None,
stacked_offset: None,
};
(base_family, blockspec, derivative_blocks)
}
#[test]
fn exact_spatial_adaptive_1dobjective_profile_has_finite_gradient_lambda_surface() {
let n = 96usize;
let mut data = Array2::<f64>::zeros((n, 1));
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
let x = i as f64 / (n as f64 - 1.0);
data[[i, 0]] = x;
y[i] = 0.12 * (2.0 * std::f64::consts::PI * x).sin()
+ 0.05 * (5.0 * std::f64::consts::PI * x).cos()
+ 1.4 / (1.0 + (-(x - 0.5) / 0.012).exp());
}
let spec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![SmoothTermSpec {
name: "duchon".to_string(),
basis: SmoothBasisSpec::Duchon {
feature_cols: vec![0],
spec: DuchonBasisSpec {
radial_reparam: None,
periodic: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 31 },
length_scale: Some(1.0),
power: 2.0,
nullspace_order: DuchonNullspaceOrder::Linear,
identifiability: SpatialIdentifiability::default(),
aniso_log_scales: None,
operator_penalties: DuchonOperatorPenaltySpec::all_active(),
boundary: OneDimensionalBoundary::Open,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
}],
};
let baseline = fit_term_collection_forspec(
data.view(),
y.view(),
Array1::ones(n).view(),
Array1::zeros(n).view(),
&spec,
LikelihoodSpec::gaussian_identity(),
&FitOptions {
max_iter: 20,
penalty_shrinkage_floor: None,
..FitOptions::default()
},
)
.expect("baseline fit");
let runtime_caches =
extract_spatial_operator_runtime_caches(&spec, &baseline.design).expect("runtime caches");
assert_eq!(runtime_caches.len(), 1);
let (eps_0, eps_g, eps_c) = compute_initial_epsilons(&baseline.fit.beta, &runtime_caches, 1e-8)
.expect("initial epsilons");
let (base_family, blockspec, derivative_blocks) =
build_spatial_adaptive_joint_hyper_scaffold(&baseline, &runtime_caches, &y, n);
let outer_opts = BlockwiseFitOptions {
inner_max_cycles: 20,
inner_tol: 1e-6,
outer_max_iter: 20,
outer_tol: 1e-6,
compute_covariance: false,
..BlockwiseFitOptions::default()
};
let evaluate_theta = |log_lambda_g: f64| {
let family = base_family.with_adaptive_params(
vec![SpatialAdaptiveTermHyperParams {
lambda: [1e-12, log_lambda_g.exp(), 1e-12],
epsilon: [eps_0, eps_g, eps_c],
}],
Arc::new(Array2::<f64>::zeros((
baseline.design.design.ncols(),
baseline.design.design.ncols(),
))),
);
evaluate_custom_family_joint_hyper(
&family,
std::slice::from_ref(&blockspec),
&outer_opts,
&Array1::zeros(0),
&derivative_blocks,
None,
gam_solve::estimate::reml::reml_outer_engine::EvalMode::ValueAndGradient,
)
.expect("joint hyper eval")
};
let low = evaluate_theta((1e-8_f64).ln());
let mid = evaluate_theta((1e-4_f64).ln());
let high = evaluate_theta((1e-2_f64).ln());
for (label, eval) in [("low", &low), ("mid", &mid), ("high", &high)] {
assert!(
eval.objective.is_finite(),
"{label} gradient-lambda profile objective is not finite: {}",
eval.objective
);
assert!(
eval.gradient.iter().all(|v| v.is_finite()),
"{label} gradient-lambda profile gradient contains non-finite entries: {:?}",
eval.gradient
);
}
assert!(
(low.objective - high.objective).abs() > 1e-8,
"gradient-lambda profile should remain identifiable: low={}, high={}",
low.objective,
high.objective
);
}
#[test]
fn high_center_duchon_fit_ignores_unavailable_spatial_adaptive_overlay() {
let n = 320usize;
let mut data = Array2::<f64>::zeros((n, 1));
let mut y = Array1::<f64>::zeros(n);
for i in 0..n {
let x = i as f64 / (n as f64 - 1.0);
data[[i, 0]] = x;
y[i] = 0.12 * (2.0 * std::f64::consts::PI * x).sin()
+ 0.05 * (5.0 * std::f64::consts::PI * x).cos()
+ 1.4 / (1.0 + (-(x - 0.5) / 0.012).exp());
}
let spec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![SmoothTermSpec {
name: "duchon".to_string(),
basis: SmoothBasisSpec::Duchon {
feature_cols: vec![0],
spec: DuchonBasisSpec {
radial_reparam: None,
periodic: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 120 },
length_scale: Some(1.0),
power: 2.0,
nullspace_order: DuchonNullspaceOrder::Linear,
identifiability: SpatialIdentifiability::default(),
aniso_log_scales: None,
operator_penalties: DuchonOperatorPenaltySpec::default(),
boundary: OneDimensionalBoundary::Open,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
}],
};
let fit = fit_term_collection_forspec(
data.view(),
y.view(),
Array1::ones(n).view(),
Array1::zeros(n).view(),
&spec,
LikelihoodSpec::gaussian_identity(),
&FitOptions {
max_iter: 40,
adaptive_regularization: Some(AdaptiveRegularizationOptions {
enabled: true,
..AdaptiveRegularizationOptions::default()
}),
penalty_shrinkage_floor: None,
..FitOptions::default()
},
)
.expect("high-center adaptive Duchon fit should not fail");
assert!(fit.fit.beta.iter().all(|v| v.is_finite()));
assert!(fit.fit.deviance.is_finite());
assert!(fit.fit.edf_total().is_some_and(f64::is_finite));
assert!(
fit.adaptive_diagnostics.is_none(),
"Duchon does not expose the complete operator triplet required by the runtime adaptive overlay"
);
}
#[test]
fn binomial_logit_tail_curvature_uses_stable_exact_formula() {
let eta = array![30.0, 30.0, -30.0, -30.0, 40.0, -40.0];
let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
let weights = Array1::ones(eta.len());
let obs = evaluate_standard_familyobservations(
LikelihoodSpec::binomial_logit(),
None,
None,
None,
&y,
&weights,
&eta,
)
.expect("stable logit observations");
for i in 0..eta.len() {
let jet = logit_inverse_link_jet5(eta[i]);
assert!(
(obs.neghessian_eta[i] - jet.d1).abs() <= 1e-12 * (1.0 + jet.d1.abs()),
"eta={} y={} curvature={} target={}",
eta[i],
y[i],
obs.neghessian_eta[i],
jet.d1
);
assert!(
(obs.neghessian_eta_derivative[i] - jet.d2).abs() <= 1e-12 * (1.0 + jet.d2.abs()),
"eta={} y={} dcurvature={} target={}",
eta[i],
y[i],
obs.neghessian_eta_derivative[i],
jet.d2
);
assert!(
obs.neghessian_eta[i].is_finite()
&& obs.neghessian_eta_derivative[i].is_finite()
&& obs.log_likelihood.is_finite(),
"expected finite logit tail observation state at eta={} y={}",
eta[i],
y[i]
);
}
}
#[test]
fn non_logit_binomial_tailobservations_stay_finite() {
let eta = array![12.0, -12.0, 18.0, -18.0];
let y = array![0.0, 1.0, 1.0, 0.0];
let weights = Array1::ones(eta.len());
for family in [
LikelihoodSpec::binomial_probit(),
LikelihoodSpec::binomial_cloglog(),
] {
let obs = evaluate_standard_familyobservations(
family.clone(),
None,
None,
None,
&y,
&weights,
&eta,
)
.expect("tail observations");
assert!(obs.log_likelihood.is_finite(), "family={family:?}");
assert!(
obs.score.iter().all(|v| v.is_finite())
&& obs.neghessian_eta.iter().all(|v| v.is_finite())
&& obs.neghessian_eta_derivative.iter().all(|v| v.is_finite()),
"family={family:?}"
);
}
}
#[test]
fn two_block_exact_joint_setup_sanitizes_non_finite_rho_seed() {
let setup = ExactJointHyperSetup::new(
array![f64::NEG_INFINITY, 0.25, f64::INFINITY],
array![-12.0, -12.0, -12.0],
array![12.0, 12.0, 12.0],
SpatialLogKappaCoords::new_with_dims(array![0.5], vec![1]),
SpatialLogKappaCoords::new_with_dims(array![-2.0], vec![1]),
SpatialLogKappaCoords::new_with_dims(array![2.0], vec![1]),
);
let theta0 = setup.theta0();
assert!(theta0.iter().all(|v| v.is_finite()));
assert_eq!(theta0[0], 0.0);
assert_eq!(theta0[1], 0.25);
assert_eq!(theta0[2], 0.0);
assert_eq!(theta0[3], 0.5);
}
#[test]
fn extracted_spatial_runtime_cache_matches_normalized_design_penalties() {
let n = 24usize;
let mut data = Array2::<f64>::zeros((n, 2));
for i in 0..n {
data[[i, 0]] = i as f64 / (n as f64 - 1.0);
data[[i, 1]] = (0.23 * i as f64).cos();
}
let spec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![SmoothTermSpec {
name: "matern".to_string(),
basis: SmoothBasisSpec::Matern {
feature_cols: vec![0, 1],
spec: MaternBasisSpec {
periodic: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 7 },
length_scale: 0.8,
nu: MaternNu::FiveHalves,
include_intercept: false,
double_penalty: true,
identifiability: MaternIdentifiability::CenterSumToZero,
aniso_log_scales: None,
nullspace_shrinkage_survived: None,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
}],
};
let design = build_term_collection_design(data.view(), &spec).expect("design");
let caches = extract_spatial_operator_runtime_caches(&spec, &design).expect("runtime caches");
assert_eq!(caches.len(), 1);
let cache = &caches[0];
let s0 = {
let raw = cache.d0.t().dot(&cache.d0);
(&raw + &raw.t()) * 0.5
};
let s1 = {
let raw = cache.d1.t().dot(&cache.d1);
(&raw + &raw.t()) * 0.5
};
let s2 = {
let raw = cache.d2.t().dot(&cache.d2);
(&raw + &raw.t()) * 0.5
};
let s0_global = penalty_matrixwith_local_block(
design.design.ncols(),
cache.coeff_global_range.clone(),
&s0,
);
let s1_global = penalty_matrixwith_local_block(
design.design.ncols(),
cache.coeff_global_range.clone(),
&s1,
);
let s2_global = penalty_matrixwith_local_block(
design.design.ncols(),
cache.coeff_global_range.clone(),
&s2,
);
let p_total = design.design.ncols();
let err0 = (&s0_global - &design.penalties[cache.mass_penalty_global_idx].to_global(p_total))
.iter()
.map(|v| v.abs())
.fold(0.0_f64, f64::max);
let err1 = (&s1_global
- &design.penalties[cache.tension_penalty_global_idx].to_global(p_total))
.iter()
.map(|v| v.abs())
.fold(0.0_f64, f64::max);
let err2 = (&s2_global
- &design.penalties[cache.stiffness_penalty_global_idx].to_global(p_total))
.iter()
.map(|v| v.abs())
.fold(0.0_f64, f64::max);
assert!(err0 < 1e-8, "mass penalty mismatch too large: {err0}");
assert!(err1 < 1e-8, "tension penalty mismatch too large: {err1}");
assert!(err2 < 1e-8, "stiffness penalty mismatch too large: {err2}");
}
#[test]
fn extracted_duchon_spatial_runtime_cache_matches_normalized_design_penalties() {
let n = 32usize;
let mut data = Array2::<f64>::zeros((n, 1));
for i in 0..n {
data[[i, 0]] = i as f64 / (n as f64 - 1.0);
}
let spec = TermCollectionSpec {
linear_terms: vec![],
random_effect_terms: vec![],
smooth_terms: vec![SmoothTermSpec {
name: "duchon".to_string(),
basis: SmoothBasisSpec::Duchon {
feature_cols: vec![0],
spec: DuchonBasisSpec {
radial_reparam: None,
periodic: None,
center_strategy: CenterStrategy::FarthestPoint { num_centers: 11 },
length_scale: Some(0.8),
power: 2.0,
nullspace_order: DuchonNullspaceOrder::Zero,
identifiability: SpatialIdentifiability::default(),
aniso_log_scales: None,
operator_penalties: DuchonOperatorPenaltySpec::all_active(),
boundary: OneDimensionalBoundary::Open,
},
input_scales: None,
},
shape: ShapeConstraint::None,
joint_null_rotation: None,
}],
};
let design = build_term_collection_design(data.view(), &spec).expect("design");
assert_eq!(design.penalties.len(), 4);
let caches = extract_spatial_operator_runtime_caches(&spec, &design).expect("runtime caches");
assert_eq!(caches.len(), 1);
let cache = &caches[0];
let s0 = {
let raw = cache.d0.t().dot(&cache.d0);
(&raw + &raw.t()) * 0.5
};
let s1 = {
let raw = cache.d1.t().dot(&cache.d1);
(&raw + &raw.t()) * 0.5
};
let s2 = {
let raw = cache.d2.t().dot(&cache.d2);
(&raw + &raw.t()) * 0.5
};
let s0_global = penalty_matrixwith_local_block(
design.design.ncols(),
cache.coeff_global_range.clone(),
&s0,
);
let s1_global = penalty_matrixwith_local_block(
design.design.ncols(),
cache.coeff_global_range.clone(),
&s1,
);
let s2_global = penalty_matrixwith_local_block(
design.design.ncols(),
cache.coeff_global_range.clone(),
&s2,
);
let p_total = design.design.ncols();
let err0 = (&s0_global - &design.penalties[cache.mass_penalty_global_idx].to_global(p_total))
.iter()
.map(|v| v.abs())
.fold(0.0_f64, f64::max);
let err1 = (&s1_global
- &design.penalties[cache.tension_penalty_global_idx].to_global(p_total))
.iter()
.map(|v| v.abs())
.fold(0.0_f64, f64::max);
let err2 = (&s2_global
- &design.penalties[cache.stiffness_penalty_global_idx].to_global(p_total))
.iter()
.map(|v| v.abs())
.fold(0.0_f64, f64::max);
assert!(
err0 < 1e-4,
"Duchon mass penalty mismatch too large: {err0}"
);
assert!(
err1 < 1e-4,
"Duchon tension penalty mismatch too large: {err1}"
);
assert!(
err2 < 1e-4,
"Duchon stiffness penalty mismatch too large: {err2}"
);
}
#[test]
fn spatial_adaptive_explicit_second_order_kind_matches_block_sparsity() {
let alpha_mass_0 = SpatialAdaptiveHyperSpec {
cache_index: 0,
kind: SpatialAdaptiveHyperKind::LogLambdaMagnitude,
};
let alpha_mass_1 = SpatialAdaptiveHyperSpec {
cache_index: 1,
kind: SpatialAdaptiveHyperKind::LogLambdaMagnitude,
};
let alpha_grad_0 = SpatialAdaptiveHyperSpec {
cache_index: 0,
kind: SpatialAdaptiveHyperKind::LogLambdaGradient,
};
let eta_mass = SpatialAdaptiveHyperSpec {
cache_index: 0,
kind: SpatialAdaptiveHyperKind::LogEpsilonMagnitude,
};
let eta_grad = SpatialAdaptiveHyperSpec {
cache_index: 0,
kind: SpatialAdaptiveHyperKind::LogEpsilonGradient,
};
assert_eq!(
alpha_mass_0.explicit_second_order_kind(alpha_mass_0),
SpatialAdaptiveExplicitSecondOrderKind::LocalAlphaAlpha
);
assert_eq!(
alpha_mass_0.explicit_second_order_kind(alpha_mass_1),
SpatialAdaptiveExplicitSecondOrderKind::StructuralZero
);
assert_eq!(
alpha_mass_0.explicit_second_order_kind(alpha_grad_0),
SpatialAdaptiveExplicitSecondOrderKind::StructuralZero
);
assert_eq!(
alpha_mass_1.explicit_second_order_kind(eta_mass),
SpatialAdaptiveExplicitSecondOrderKind::LocalAlphaEta
);
assert_eq!(
eta_mass.explicit_second_order_kind(alpha_mass_1),
SpatialAdaptiveExplicitSecondOrderKind::LocalAlphaEta
);
assert_eq!(
eta_mass.explicit_second_order_kind(eta_mass),
SpatialAdaptiveExplicitSecondOrderKind::SharedEtaEta
);
assert_eq!(
eta_mass.explicit_second_order_kind(eta_grad),
SpatialAdaptiveExplicitSecondOrderKind::StructuralZero
);
}
#[test]
fn scalar_charbonnier_exact_derivatives_match_finite_difference() {
let signal = array![0.7, -1.1];
let epsilon = 0.3;
let state = CharbonnierScalarBlockState::from_signal(signal.clone(), epsilon);
let h = 1e-5;
let value = |x: &Array1<f64>| {
CharbonnierScalarBlockState::from_signal(x.clone(), epsilon).penalty_value()
};
for i in 0..signal.len() {
let mut plus = signal.clone();
plus[i] += h;
let mut minus = signal.clone();
minus[i] -= h;
let gradfd = (value(&plus) - value(&minus)) / (2.0 * h);
let hessfd = (value(&plus) - 2.0 * value(&signal) + value(&minus)) / (h * h);
assert!((state.betagradient_coeff()[i] - gradfd).abs() < 1e-6);
assert!((state.betahessian_diag()[i] - hessfd).abs() < 1e-4);
}
}
#[test]
fn grouped_charbonnier_exactgradient_matches_finite_difference() {
let blocks = array![[0.8, -0.4], [0.3, 0.9]];
let epsilon = 0.25;
let state = CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), epsilon);
let h = 1e-6;
let value = |x: &Array2<f64>| {
CharbonnierGroupedBlockState::from_signal_blocks(x.clone(), epsilon).penalty_value()
};
let analytic = state.betagradient_blocks();
for k in 0..blocks.nrows() {
for axis in 0..blocks.ncols() {
let mut plus = blocks.clone();
plus[[k, axis]] += h;
let mut minus = blocks.clone();
minus[[k, axis]] -= h;
let gradfd = (value(&plus) - value(&minus)) / (2.0 * h);
assert!((analytic[[k, axis]] - gradfd).abs() < 1e-6);
}
}
}
#[test]
fn scalar_charbonnier_log_epsilon_derivatives_match_finite_difference() {
let signal = array![0.4, -0.9];
let epsilon = 0.35_f64;
let eta = epsilon.ln();
let state = CharbonnierScalarBlockState::from_signal(signal.clone(), epsilon);
let h = 1e-5;
let value = |eta_value: f64| {
CharbonnierScalarBlockState::from_signal(signal.clone(), eta_value.exp()).penalty_value()
};
let gradfd = (value(eta + h) - value(eta - h)) / (2.0 * h);
let hessfd = (value(eta + h) - 2.0 * value(eta) + value(eta - h)) / (h * h);
assert!((state.log_epsilon_gradient_terms().sum() - gradfd).abs() < 1e-6);
assert!((state.log_epsilon_hessian_terms().sum() - hessfd).abs() < 1e-4);
let eval_grad = |eta_value: f64| {
CharbonnierScalarBlockState::from_signal(signal.clone(), eta_value.exp())
.betagradient_coeff()
};
let mixedfd = (&eval_grad(eta + h) - &eval_grad(eta - h)) / (2.0 * h);
for i in 0..signal.len() {
assert!((state.log_epsilon_betagradient_coeff()[i] - mixedfd[i]).abs() < 1e-6);
}
let eval_hess = |eta_value: f64| {
CharbonnierScalarBlockState::from_signal(signal.clone(), eta_value.exp()).betahessian_diag()
};
let betahessfd = (&eval_hess(eta + h) - &eval_hess(eta - h)) / (2.0 * h);
for i in 0..signal.len() {
assert!((state.log_epsilon_betahessian_diag()[i] - betahessfd[i]).abs() < 1e-5);
}
let eval_log_grad = |eta_value: f64| {
CharbonnierScalarBlockState::from_signal(signal.clone(), eta_value.exp())
.log_epsilon_betagradient_coeff()
};
let second_mixedfd = (&eval_log_grad(eta + h) - &eval_log_grad(eta - h)) / (2.0 * h);
for i in 0..signal.len() {
assert!((state.log_epsilon_beta_mixed_second_coeff()[i] - second_mixedfd[i]).abs() < 1e-5);
}
let eval_log_hess = |eta_value: f64| {
CharbonnierScalarBlockState::from_signal(signal.clone(), eta_value.exp())
.log_epsilon_betahessian_diag()
};
let second_hessfd = (&eval_log_hess(eta + h) - &eval_log_hess(eta - h)) / (2.0 * h);
for i in 0..signal.len() {
assert!((state.log_epsilon_betahessian_second_diag()[i] - second_hessfd[i]).abs() < 1e-4);
}
}
#[test]
fn scalar_charbonnier_directionalhessian_matches_finite_difference() {
let signal = array![0.5, -0.6];
let epsilon = 0.2;
let direction = array![0.3, -0.1];
let state = CharbonnierScalarBlockState::from_signal(signal.clone(), epsilon);
let h = 1e-6;
let analytic = state.directionalhessian_diag(&direction);
let evalhess = |step: f64| {
let shifted = &signal + &(direction.mapv(|v| step * v));
CharbonnierScalarBlockState::from_signal(shifted, epsilon).betahessian_diag()
};
let fd = (&evalhess(h) - &evalhess(-h)) / (2.0 * h);
for i in 0..signal.len() {
assert!((analytic[i] - fd[i]).abs() < 1e-5);
}
}
#[test]
fn scalar_charbonnier_log_epsilon_directionalhessian_matches_finite_difference() {
let signal = array![0.5, -0.6];
let epsilon = 0.2;
let direction = array![0.3, -0.1];
let state = CharbonnierScalarBlockState::from_signal(signal.clone(), epsilon);
let h = 1e-6;
let analytic = state.log_epsilon_betahessian_directional_diag(&direction);
let evalhess = |step: f64| {
let shifted = &signal + &(direction.mapv(|v| step * v));
CharbonnierScalarBlockState::from_signal(shifted, epsilon).log_epsilon_betahessian_diag()
};
let fd = (&evalhess(h) - &evalhess(-h)) / (2.0 * h);
for i in 0..signal.len() {
assert!((analytic[i] - fd[i]).abs() < 1e-4);
}
}
#[test]
fn grouped_charbonnier_log_epsilon_derivatives_match_finite_difference() {
let blocks = array![[0.7, -0.2], [0.1, 0.8]];
let epsilon = 0.3_f64;
let eta = epsilon.ln();
let state = CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), epsilon);
let h = 1e-5;
let value = |eta_value: f64| {
CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), eta_value.exp())
.penalty_value()
};
let gradfd = (value(eta + h) - value(eta - h)) / (2.0 * h);
let hessfd = (value(eta + h) - 2.0 * value(eta) + value(eta - h)) / (h * h);
assert!((state.log_epsilon_gradient_terms().sum() - gradfd).abs() < 1e-6);
assert!((state.log_epsilon_hessian_terms().sum() - hessfd).abs() < 1e-4);
let eval_grad = |eta_value: f64| {
CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), eta_value.exp())
.betagradient_blocks()
};
let mixedfd = (&eval_grad(eta + h) - &eval_grad(eta - h)) / (2.0 * h);
let analytic_mixed = state.log_epsilon_betagradient_blocks();
for k in 0..blocks.nrows() {
for axis in 0..blocks.ncols() {
assert!((analytic_mixed[[k, axis]] - mixedfd[[k, axis]]).abs() < 1e-6);
}
}
let eval_hess = |eta_value: f64| {
CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), eta_value.exp())
.betahessian_blocks()
};
let plus_hess = eval_hess(eta + h);
let minus_hess = eval_hess(eta - h);
let analytic_hess = state.log_epsilon_betahessian_blocks();
for k in 0..analytic_hess.len() {
let fd = (&plus_hess[k] - &minus_hess[k]) / (2.0 * h);
for i in 0..fd.nrows() {
for j in 0..fd.ncols() {
assert!((analytic_hess[k][[i, j]] - fd[[i, j]]).abs() < 1e-5);
}
}
}
let eval_log_grad = |eta_value: f64| {
CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), eta_value.exp())
.log_epsilon_betagradient_blocks()
};
let second_mixedfd = (&eval_log_grad(eta + h) - &eval_log_grad(eta - h)) / (2.0 * h);
let analytic_second_mixed = state.log_epsilon_beta_mixed_second_blocks();
for k in 0..blocks.nrows() {
for axis in 0..blocks.ncols() {
assert!((analytic_second_mixed[[k, axis]] - second_mixedfd[[k, axis]]).abs() < 1e-5);
}
}
let eval_log_hess = |eta_value: f64| {
CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), eta_value.exp())
.log_epsilon_betahessian_blocks()
};
let plus_log_hess = eval_log_hess(eta + h);
let minus_log_hess = eval_log_hess(eta - h);
let analytic_second_hess = state.log_epsilon_betahessian_second_blocks();
for k in 0..analytic_second_hess.len() {
let fd = (&plus_log_hess[k] - &minus_log_hess[k]) / (2.0 * h);
for i in 0..fd.nrows() {
for j in 0..fd.ncols() {
assert!((analytic_second_hess[k][[i, j]] - fd[[i, j]]).abs() < 1e-4);
}
}
}
}
#[test]
fn grouped_charbonnier_directionalhessian_matches_finite_difference() {
let blocks = array![[0.6, -0.2], [0.4, 0.5]];
let direction = array![[0.1, -0.3], [0.2, 0.15]];
let epsilon = 0.4;
let state = CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), epsilon);
let analytic = state.directionalhessian_blocks(&direction);
let h = 1e-6;
let evalhess = |step: f64| {
let shifted = &blocks + &(direction.mapv(|v| step * v));
CharbonnierGroupedBlockState::from_signal_blocks(shifted, epsilon).betahessian_blocks()
};
let plus = evalhess(h);
let minus = evalhess(-h);
for k in 0..analytic.len() {
let fd = (&plus[k] - &minus[k]) / (2.0 * h);
for i in 0..fd.nrows() {
for j in 0..fd.ncols() {
assert!((analytic[k][[i, j]] - fd[[i, j]]).abs() < 1e-5);
}
}
}
}
#[test]
fn grouped_charbonnier_log_epsilon_directionalhessian_matches_finite_difference() {
let blocks = array![[0.6, -0.2], [0.4, 0.5]];
let direction = array![[0.1, -0.3], [0.2, 0.15]];
let epsilon = 0.4;
let state = CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), epsilon);
let analytic = state.log_epsilon_betahessian_directional_blocks(&direction);
let h = 1e-6;
let evalhess = |step: f64| {
let shifted = &blocks + &(direction.mapv(|v| step * v));
CharbonnierGroupedBlockState::from_signal_blocks(shifted, epsilon)
.log_epsilon_betahessian_blocks()
};
let plus = evalhess(h);
let minus = evalhess(-h);
for k in 0..analytic.len() {
let fd = (&plus[k] - &minus[k]) / (2.0 * h);
for i in 0..fd.nrows() {
for j in 0..fd.ncols() {
assert!((analytic[k][[i, j]] - fd[[i, j]]).abs() < 1e-4);
}
}
}
}
#[test]
fn grouped_charbonnier_directionalhessian_blocks_are_symmetric() {
let blocks = array![[0.6, -0.2], [0.4, 0.5]];
let direction = array![[0.1, -0.3], [0.2, 0.15]];
let epsilon = 0.4;
let analytic = CharbonnierGroupedBlockState::from_signal_blocks(blocks, epsilon)
.directionalhessian_blocks(&direction);
for (k, block) in analytic.iter().enumerate() {
for i in 0..block.nrows() {
for j in 0..block.ncols() {
assert!(
(block[[i, j]] - block[[j, i]]).abs() < 1e-12,
"directional Hessian block {k} is not symmetric at ({i},{j})"
);
}
}
}
}
#[test]
fn scalar_charbonnier_local_quadratic_curvature_matches_transition_scale() {
let signal = array![0.0, 0.0, 0.0];
let small = CharbonnierScalarBlockState::from_signal(signal.clone(), 1e-3);
let large = CharbonnierScalarBlockState::from_signal(signal, 1e3);
for (&a, &b) in small
.betahessian_diag()
.iter()
.zip(large.betahessian_diag().iter())
{
assert!(
(a - 1e3).abs() < 1e-7,
"small-epsilon curvature should be 1/eps, got {a}"
);
assert!(
(b - 1e-3).abs() < 1e-13,
"large-epsilon curvature should be 1/eps, got {b}"
);
}
}
#[test]
fn grouped_charbonnier_local_quadratic_curvature_matches_transition_scale() {
let blocks = array![[0.0, 0.0], [0.0, 0.0]];
let small = CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), 1e-3);
let large = CharbonnierGroupedBlockState::from_signal_blocks(blocks, 1e3);
for (small_block, large_block) in small
.betahessian_blocks()
.into_iter()
.zip(large.betahessian_blocks().into_iter())
{
let eye = Array2::<f64>::eye(small_block.nrows());
assert!(
(&small_block - &eye.mapv(|v| 1e3 * v)).mapv(f64::abs).sum() < 1e-7,
"small-epsilon grouped curvature should equal I/eps"
);
assert!(
(&large_block - &eye.mapv(|v| 1e-3 * v))
.mapv(f64::abs)
.sum()
< 1e-13,
"large-epsilon grouped curvature should equal I/eps"
);
}
}
#[test]
fn scalar_charbonnier_small_signal_matches_local_half_quadratic() {
let signal = array![1e-5, -2e-5, 3e-5];
for &epsilon in &[1e-3, 1e-1, 1.0, 1e2] {
let state = CharbonnierScalarBlockState::from_signal(signal.clone(), epsilon);
let value = state.penalty_value();
let target = 0.5 * signal.iter().map(|v| v * v).sum::<f64>() / epsilon;
let rel = (value - target).abs() / target.max(1e-20);
assert!(
rel < 5e-3,
"scalar Charbonnier should match 0.5*t^2/eps locally: eps={epsilon}, value={value}, target={target}, rel={rel}"
);
}
}
#[test]
fn grouped_charbonnier_small_signal_matches_local_half_quadratic() {
let blocks = array![[1e-5, -2e-5], [3e-5, 4e-5]];
for &epsilon in &[1e-3, 1e-1, 1.0, 1e2] {
let state = CharbonnierGroupedBlockState::from_signal_blocks(blocks.clone(), epsilon);
let value = state.penalty_value();
let target = 0.5 * blocks.iter().map(|v| v * v).sum::<f64>() / epsilon;
let rel = (value - target).abs() / target.max(1e-20);
assert!(
rel < 5e-3,
"grouped Charbonnier should match 0.5*||v||^2/eps locally: eps={epsilon}, value={value}, target={target}, rel={rel}"
);
}
}
#[test]
fn adaptive_diagnostics_json_roundtrip_preserves_shapes() {
let diag = AdaptiveRegularizationDiagnostics {
epsilon_0: 0.01,
epsilon_g: 0.02,
epsilon_c: 0.03,
epsilon_outer_iterations: 2,
mm_iterations: 3,
converged: true,
maps: vec![AdaptiveSpatialMap {
termname: "matern".to_string(),
feature_cols: vec![0, 1],
collocation_points: array![[1.0, 2.0], [3.0, 4.0]],
inv_magweight: array![0.05, 0.15],
invgradweight: array![0.1, 0.2],
inv_lapweight: array![0.3, 0.4],
}],
};
let payload = serde_json::to_value(&diag).expect("serialize diagnostics");
assert_eq!(payload["mm_iterations"].as_u64(), Some(3));
assert_eq!(
payload["maps"][0]["collocation_points"]["dim"]
.as_array()
.map(|v| v.len()),
Some(2)
);
let decoded: AdaptiveRegularizationDiagnostics =
serde_json::from_value(payload).expect("deserialize diagnostics");
assert_eq!(decoded.epsilon_outer_iterations, 2);
assert_eq!(decoded.mm_iterations, 3);
assert!(decoded.converged);
assert_eq!(decoded.maps.len(), 1);
assert_eq!(decoded.maps[0].collocation_points.nrows(), 2);
assert_eq!(decoded.maps[0].collocation_points.ncols(), 2);
assert_eq!(decoded.maps[0].invgradweight.len(), 2);
assert_eq!(decoded.maps[0].inv_lapweight.len(), 2);
}
#[test]
fn bounded_latent_sampler_draws_in_bounds_and_preserves_joint() {
let (min, max) = (-0.5_f64, 0.5_f64);
let beta_user = array![0.2, 1.3];
let user_hessian = array![[4.0, 1.2], [1.2, 3.0]];
let bounded_columns = vec![BoundedSampleColumn {
col_idx: 0,
min,
max,
}];
let n_draws = 40_000usize;
let draws = sample_bounded_latent_posterior_internal(
&beta_user,
&user_hessian,
&bounded_columns,
n_draws,
1.0,
7607760,
)
.expect("bounded latent sampler");
assert_eq!(draws.dim(), (n_draws, 2));
for k in 0..n_draws {
let b = draws[(k, 0)];
assert!(
b > min && b < max,
"bounded draw {b} escaped interval ({min}, {max})"
);
}
let theta_mode0 = bounded_user_to_latent(beta_user[0], min, max);
let (_, _, j0) = bounded_latent_to_user(theta_mode0, min, max);
let h_latent = array![
[user_hessian[[0, 0]] * j0 * j0, user_hessian[[0, 1]] * j0],
[user_hessian[[1, 0]] * j0, user_hessian[[1, 1]]]
];
let det = h_latent[[0, 0]] * h_latent[[1, 1]] - h_latent[[0, 1]] * h_latent[[1, 0]];
let cov_latent = array![
[h_latent[[1, 1]] / det, -h_latent[[0, 1]] / det],
[-h_latent[[1, 0]] / det, h_latent[[0, 0]] / det]
];
let mut theta0 = Array1::<f64>::zeros(n_draws);
let mut theta1 = Array1::<f64>::zeros(n_draws);
for k in 0..n_draws {
theta0[k] = bounded_user_to_latent(draws[(k, 0)], min, max);
theta1[k] = draws[(k, 1)];
}
let mean0 = theta0.sum() / n_draws as f64;
let mean1 = theta1.sum() / n_draws as f64;
let var0 = theta0.iter().map(|&t| (t - mean0).powi(2)).sum::<f64>() / n_draws as f64;
let var1 = theta1.iter().map(|&t| (t - mean1).powi(2)).sum::<f64>() / n_draws as f64;
let cov01 = theta0
.iter()
.zip(theta1.iter())
.map(|(&a, &b)| (a - mean0) * (b - mean1))
.sum::<f64>()
/ n_draws as f64;
let rel = |emp: f64, truth: f64| (emp - truth).abs() / truth.abs().max(1e-12);
assert!(
rel(var0, cov_latent[[0, 0]]) < 0.05,
"latent var0 {var0} vs {} ",
cov_latent[[0, 0]]
);
assert!(
rel(var1, cov_latent[[1, 1]]) < 0.05,
"latent var1 {var1} vs {}",
cov_latent[[1, 1]]
);
let corr_emp = cov01 / (var0.sqrt() * var1.sqrt());
let corr_truth = cov_latent[[0, 1]] / (cov_latent[[0, 0]].sqrt() * cov_latent[[1, 1]].sqrt());
assert!(
corr_truth.abs() > 0.2,
"fixture must carry real correlation, got {corr_truth}"
);
assert!(
(corr_emp - corr_truth).abs() < 0.03,
"joint correlation not preserved: empirical {corr_emp} vs truth {corr_truth}"
);
}
#[test]
fn bounded_latent_sampler_applies_dispersion_scale() {
let (min, max) = (-0.5_f64, 0.5_f64);
let beta_user = array![0.2, 1.3];
let user_hessian = array![[4.0, 1.2], [1.2, 3.0]];
let bounded_columns = vec![BoundedSampleColumn {
col_idx: 0,
min,
max,
}];
let n_draws = 60_000usize;
let theta_mode0 = bounded_user_to_latent(beta_user[0], min, max);
let (_, _, j0) = bounded_latent_to_user(theta_mode0, min, max);
let h_latent = array![
[user_hessian[[0, 0]] * j0 * j0, user_hessian[[0, 1]] * j0],
[user_hessian[[1, 0]] * j0, user_hessian[[1, 1]]]
];
let det = h_latent[[0, 0]] * h_latent[[1, 1]] - h_latent[[0, 1]] * h_latent[[1, 0]];
let cov_latent_unit = array![
[h_latent[[1, 1]] / det, -h_latent[[0, 1]] / det],
[-h_latent[[1, 0]] / det, h_latent[[0, 0]] / det]
];
let cov_scale = 2.25_f64;
let sqrt_cov_scale = cov_scale.sqrt();
let draws = sample_bounded_latent_posterior_internal(
&beta_user,
&user_hessian,
&bounded_columns,
n_draws,
sqrt_cov_scale,
424242,
)
.expect("scaled bounded latent sampler");
let mut theta0 = Array1::<f64>::zeros(n_draws);
let mut theta1 = Array1::<f64>::zeros(n_draws);
for k in 0..n_draws {
theta0[k] = bounded_user_to_latent(draws[(k, 0)], min, max);
theta1[k] = draws[(k, 1)];
}
let mean0 = theta0.sum() / n_draws as f64;
let mean1 = theta1.sum() / n_draws as f64;
let var0 = theta0.iter().map(|&t| (t - mean0).powi(2)).sum::<f64>() / n_draws as f64;
let var1 = theta1.iter().map(|&t| (t - mean1).powi(2)).sum::<f64>() / n_draws as f64;
let rel = |emp: f64, truth: f64| (emp - truth).abs() / truth.abs().max(1e-12);
let truth0 = cov_scale * cov_latent_unit[[0, 0]];
let truth1 = cov_scale * cov_latent_unit[[1, 1]];
assert!(
rel(var0, truth0) < 0.05,
"scaled latent var0 {var0} vs {truth0} (cov_scale={cov_scale})"
);
assert!(
rel(var1, truth1) < 0.05,
"scaled latent var1 {var1} vs {truth1} (cov_scale={cov_scale})"
);
assert!(
var0 > 1.5 * cov_latent_unit[[0, 0]],
"dispersion scale was not applied: var0 {var0} ~ unit cov {}",
cov_latent_unit[[0, 0]]
);
}
}