1use self::inner_strategy::GeometryBackendKind;
2use super::*;
3use crate::pirls::PIRLS_CACHE_BYTE_BUDGET;
4use crate::pirls::assemble_and_factor_sparse_penalized_system;
5use gam_linalg::sparse_exact::SparseExactFactor;
6use gam_problem::OuterEval;
7use gam_problem::SasLinkState;
8use gam_terms::basis::LocalDesignJacobianProvider;
9use ndarray::{Array1, Array2, s};
10use std::collections::{HashMap, VecDeque};
11use std::ops::Range;
12use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize};
13use std::sync::{Arc, RwLock};
14
15pub mod assembly;
16pub mod atoms;
17pub mod boundary_laml;
18pub(crate) mod continuation;
19pub(crate) mod eval;
20mod firth;
21pub(super) mod hyper;
22mod inner_strategy;
23pub mod jeffreys_subspace;
26pub mod outer_eval;
27pub mod penalty_logdet;
28pub mod reparameterized_inner;
29pub mod per_atom_efs;
30pub mod reml_outer_engine;
31mod rho_key;
32mod sparse_exact_penalty;
33mod trace;
34
35pub(crate) use sparse_exact_penalty::sparse_penalty_block_count_from_canonical;
36
37pub(crate) const EXACT_TAU_TAU_HESSIAN_DENSE_CACHE_BUDGET_BYTES: usize = 512 * 1024 * 1024;
38pub(crate) const FIRTH_MAX_OBSERVATIONS: usize = 20_000;
39pub(crate) const FIRTH_MAX_COEFFICIENTS: usize = 256;
40pub(crate) const FIRTH_MAX_LINEAR_WORK: usize = 2_000_000;
41pub(crate) const FIRTH_MAX_QUADRATIC_WORK: usize = 100_000_000;
42pub(crate) const PERSISTENT_LATENT_VALUES_CACHE_CAPACITY: usize = 8;
43
44#[derive(Debug)]
45pub(crate) struct PersistentLatentValuesCache {
46 pub(crate) entries: HashMap<String, Array2<f64>>,
47 pub(crate) lru: VecDeque<String>,
48 pub(crate) capacity: usize,
49}
50
51impl Default for PersistentLatentValuesCache {
52 fn default() -> Self {
53 Self {
54 entries: HashMap::new(),
55 lru: VecDeque::new(),
56 capacity: PERSISTENT_LATENT_VALUES_CACHE_CAPACITY,
57 }
58 }
59}
60
61impl PersistentLatentValuesCache {
62 pub(crate) fn lookup(
63 &mut self,
64 key: &str,
65 n_obs: usize,
66 latent_dim: usize,
67 ) -> Option<Array2<f64>> {
68 let values = self.entries.get(key)?;
69 if values.dim() != (n_obs, latent_dim) {
70 return None;
71 }
72 let values = values.clone();
73 self.touch(key.to_string());
74 Some(values)
75 }
76
77 pub(crate) fn insert(&mut self, key: String, values: Array2<f64>) {
78 if values.iter().any(|value| !value.is_finite()) {
79 return;
80 }
81 self.entries.insert(key.clone(), values);
82 self.touch(key);
83 while self.entries.len() > self.capacity {
84 let Some(evicted) = self.lru.pop_front() else {
85 break;
86 };
87 self.entries.remove(&evicted);
88 }
89 }
90
91 pub(crate) fn touch(&mut self, key: String) {
92 if let Some(index) = self.lru.iter().position(|queued| queued == &key) {
93 self.lru.remove(index);
94 }
95 self.lru.push_back(key);
96 }
97}
98
99#[derive(Clone)]
104pub(crate) struct IftWarmStartCache {
105 pub beta_original: ndarray::Array1<f64>,
111 pub rho: ndarray::Array1<f64>,
114 pub penalized_hessian_transformed: gam_linalg::matrix::SymmetricMatrix,
118 pub qs: ndarray::Array2<f64>,
122 pub frame_was_original: bool,
126 pub lambda_s_beta_blocks: Option<Vec<ndarray::Array1<f64>>>,
143}
144
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
146pub(crate) struct TauTauPlanEstimate {
147 pub(crate) dense_x_bytes: usize,
148 pub(crate) first_order_tau_bytes: usize,
149 pub(crate) second_order_tau_bytes: usize,
150 pub(crate) penalty_first_bytes: usize,
151 pub(crate) penalty_pair_bytes: usize,
152 pub(crate) rho_tau_penalty_bytes: usize,
153 pub(crate) vector_cache_bytes: usize,
154 pub(crate) weighted_scratch_bytes: usize,
155}
156
157impl TauTauPlanEstimate {
158 pub(crate) fn total_bytes(self) -> usize {
159 self.dense_x_bytes
160 .saturating_add(self.first_order_tau_bytes)
161 .saturating_add(self.second_order_tau_bytes)
162 .saturating_add(self.penalty_first_bytes)
163 .saturating_add(self.penalty_pair_bytes)
164 .saturating_add(self.rho_tau_penalty_bytes)
165 .saturating_add(self.vector_cache_bytes)
166 .saturating_add(self.weighted_scratch_bytes)
167 }
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
171pub(crate) struct TauTauHessianPolicy {
172 pub(crate) any_has_implicit: bool,
173 pub(crate) implicit_multidim_duchon: bool,
174 pub(crate) estimated_dense_tau_cache_bytes: usize,
175 pub(crate) gradient_plan: TauTauPlanEstimate,
176 pub(crate) hessian_plan: TauTauPlanEstimate,
177 pub(crate) budget_bytes: usize,
178 pub(crate) firth_pair_terms_unavailable: bool,
179}
180
181impl TauTauHessianPolicy {
182 pub(crate) fn prefer_gradient_only(self) -> bool {
210 self.firth_pair_terms_unavailable
211 }
212}
213
214pub(crate) fn exact_tau_tau_hessian_policy_with_firth(
215 n_obs: usize,
216 p_coeff: usize,
217 hyper_dirs: &[DirectionalHyperParam],
218 firth_pair_terms_unavailable: bool,
219) -> TauTauHessianPolicy {
220 let f64_bytes = std::mem::size_of::<f64>();
221 let dense_matrix_bytes =
222 |rows: usize, cols: usize| -> usize { rows.saturating_mul(cols).saturating_mul(f64_bytes) };
223 let dense_design_bytes = dense_matrix_bytes(n_obs, p_coeff);
224 let dense_penalty_bytes = dense_matrix_bytes(p_coeff, p_coeff);
225 let psi_dim = hyper_dirs.len();
226 let implicit_n_axes = hyper_dirs
227 .iter()
228 .find_map(DirectionalHyperParam::implicit_axis_count_hint)
229 .unwrap_or(0);
230 let gradient_uses_implicit_design = hyper_dirs
231 .iter()
232 .any(DirectionalHyperParam::has_implicit_operator)
233 && gam_terms::basis::should_use_implicit_operators_with_policy(
234 n_obs,
235 p_coeff,
236 implicit_n_axes,
237 &gam_runtime::resource::ResourcePolicy::default_library(),
238 );
239 let dense_first_order_count = hyper_dirs
240 .iter()
241 .filter(|dir| !dir.has_implicit_operator())
242 .count();
243 let first_penalty_component_count = hyper_dirs
244 .iter()
245 .map(DirectionalHyperParam::penalty_first_component_count)
246 .sum::<usize>();
247
248 let mut dense_second_order_count = 0usize;
249 let mut penalty_pair_count = 0usize;
250 for i in 0..psi_dim {
251 for j in i..psi_dim {
252 if hyper_dirs[i]
253 .x_tau_tau_entry_at(j)
254 .or_else(|| hyper_dirs[j].x_tau_tau_entry_at(i))
255 .is_some_and(|entry| !entry.uses_implicit_storage())
256 {
257 dense_second_order_count += if i == j { 1 } else { 2 };
258 }
259 if hyper_dirs[i].has_penaltysecond_pair_at(j)
260 || hyper_dirs[j].has_penaltysecond_pair_at(i)
261 {
262 penalty_pair_count += if i == j { 1 } else { 2 };
263 }
264 }
265 }
266
267 let gradient_dense_first_order_count = if gradient_uses_implicit_design {
268 dense_first_order_count
269 } else {
270 psi_dim
271 };
272 let gradient_needs_dense_x =
273 firth_pair_terms_unavailable || gradient_dense_first_order_count > 0;
274 let gradient_plan = TauTauPlanEstimate {
275 dense_x_bytes: if gradient_needs_dense_x {
276 dense_design_bytes
277 } else {
278 0
279 },
280 first_order_tau_bytes: if gradient_dense_first_order_count > 0 {
281 dense_design_bytes
282 } else {
283 0
284 },
285 second_order_tau_bytes: 0,
286 penalty_first_bytes: psi_dim.saturating_mul(dense_penalty_bytes),
287 penalty_pair_bytes: 0,
288 rho_tau_penalty_bytes: 0,
289 vector_cache_bytes: n_obs.saturating_mul(f64_bytes),
290 weighted_scratch_bytes: dense_penalty_bytes,
291 };
292 let hessian_plan = TauTauPlanEstimate {
293 dense_x_bytes: if psi_dim > 0 { dense_design_bytes } else { 0 },
294 first_order_tau_bytes: dense_first_order_count.saturating_mul(dense_design_bytes),
295 second_order_tau_bytes: dense_second_order_count.saturating_mul(dense_design_bytes),
296 penalty_first_bytes: psi_dim.saturating_mul(dense_penalty_bytes),
297 penalty_pair_bytes: penalty_pair_count.saturating_mul(dense_penalty_bytes),
298 rho_tau_penalty_bytes: first_penalty_component_count
299 .saturating_mul(2)
300 .saturating_mul(dense_penalty_bytes),
301 vector_cache_bytes: psi_dim.saturating_mul(n_obs).saturating_mul(f64_bytes),
302 weighted_scratch_bytes: dense_penalty_bytes,
303 };
304 let any_has_implicit = hyper_dirs
305 .iter()
306 .any(DirectionalHyperParam::has_implicit_operator);
307 let implicit_multidim_duchon = hyper_dirs
308 .iter()
309 .any(DirectionalHyperParam::has_implicit_multidim_duchon);
310 let estimated_dense_tau_cache_bytes = hessian_plan
311 .first_order_tau_bytes
312 .saturating_add(hessian_plan.second_order_tau_bytes);
313 TauTauHessianPolicy {
314 any_has_implicit,
315 implicit_multidim_duchon,
316 estimated_dense_tau_cache_bytes,
317 gradient_plan,
318 hessian_plan,
319 budget_bytes: EXACT_TAU_TAU_HESSIAN_DENSE_CACHE_BUDGET_BYTES,
320 firth_pair_terms_unavailable: firth_pair_terms_unavailable && !hyper_dirs.is_empty(),
321 }
322}
323
324pub(crate) fn firth_problem_scale_allows(n_obs: usize, p_coeff: usize) -> bool {
325 let linear_work = n_obs.saturating_mul(p_coeff);
326 let quadratic_work = linear_work.saturating_mul(p_coeff);
327 n_obs <= FIRTH_MAX_OBSERVATIONS
328 && p_coeff <= FIRTH_MAX_COEFFICIENTS
329 && linear_work <= FIRTH_MAX_LINEAR_WORK
330 && quadratic_work <= FIRTH_MAX_QUADRATIC_WORK
331}
332
333#[cfg(test)]
334mod tests {
335 use super::atoms::CriterionAtom;
336 use super::{
337 DirectionalHyperParam, EvalCacheManager, EvalShared, HyperDesignDerivative,
338 HyperPenaltyDerivative, ImplicitDerivLevel, RemlConfig, RemlState,
339 };
340 use crate::estimate::EstimationError;
341 use crate::pirls::PirlsCoordinateFrame;
342 use faer::Side;
343 use gam_linalg::faer_ndarray::FaerCholesky;
344 use gam_linalg::matrix::symmetrize_in_place;
345 use gam_problem::{
346 GlmLikelihoodSpec, InverseLink, LikelihoodSpec, ResponseFamily, StandardLink,
347 };
348 use gam_problem::{HessianValue, OuterEval};
349 use gam_terms::basis::{ImplicitDesignPsiDerivative, RadialScalarKind};
350 use ndarray::{Array1, Array2, array, s};
351 use std::sync::Arc;
352
353 pub(crate) fn binomial_logit_glm_spec() -> GlmLikelihoodSpec {
356 GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
357 ResponseFamily::Binomial,
358 InverseLink::Standard(StandardLink::Logit),
359 ))
360 }
361
362 pub(crate) fn gaussian_identity_glm_spec() -> GlmLikelihoodSpec {
365 GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
366 ResponseFamily::Gaussian,
367 InverseLink::Standard(StandardLink::Identity),
368 ))
369 }
370
371 impl DirectionalHyperParam {
372 pub(super) fn new(
373 x_tau_original: Array2<f64>,
374 penalty_first_components: Vec<(usize, Array2<f64>)>,
375 x_tau_tau_original: Option<Vec<Option<Array2<f64>>>>,
376 penaltysecond_components: Option<Vec<Option<Vec<(usize, Array2<f64>)>>>>,
377 ) -> Result<Self, EstimationError> {
378 let x_tau_tau_original = x_tau_tau_original.map(|rows| {
379 rows.into_iter()
380 .map(|entry| entry.map(HyperDesignDerivative::from))
381 .collect::<Vec<_>>()
382 });
383 let penalty_first_components = penalty_first_components
384 .into_iter()
385 .map(|(idx, matrix)| (idx, HyperPenaltyDerivative::from(matrix)))
386 .collect();
387 let penaltysecond_components = penaltysecond_components.map(|rows| {
388 rows.into_iter()
389 .map(|row| {
390 row.map(|components| {
391 components
392 .into_iter()
393 .map(|(idx, matrix)| (idx, HyperPenaltyDerivative::from(matrix)))
394 .collect::<Vec<_>>()
395 })
396 })
397 .collect::<Vec<_>>()
398 });
399 Self::new_compact(
400 HyperDesignDerivative::from(x_tau_original),
401 penalty_first_components,
402 x_tau_tau_original,
403 penaltysecond_components,
404 )
405 }
406
407 pub(super) fn single_penalty(
408 penalty_index: usize,
409 x_tau_original: Array2<f64>,
410 s_tau_original: Array2<f64>,
411 x_tau_tau_original: Option<Vec<Option<Array2<f64>>>>,
412 s_tau_tau_original: Option<Vec<Option<Array2<f64>>>>,
413 ) -> Result<Self, EstimationError> {
414 let penaltysecond_components = s_tau_tau_original.map(|rows| {
415 rows.into_iter()
416 .map(|mat| mat.map(|mat| vec![(penalty_index, mat)]))
417 .collect::<Vec<_>>()
418 });
419 Self::new(
420 x_tau_original,
421 vec![(penalty_index, s_tau_original)],
422 x_tau_tau_original,
423 penaltysecond_components,
424 )
425 }
426 }
427
428 #[test]
429 pub(crate) fn firth_problem_scale_gate_blocks_large_quadratic_work() {
430 assert!(super::firth_problem_scale_allows(2_000, 200));
431 assert!(!super::firth_problem_scale_allows(4_800, 241));
432 assert!(!super::firth_problem_scale_allows(4_800, 433));
433 }
434
435 #[test]
436 pub(crate) fn tau_tau_hessian_policy_prefers_gradient_only_for_implicit_tau() {
437 let operator = ImplicitDesignPsiDerivative::new(
438 array![1.0, 2.0, 3.0, 4.0],
439 array![0.5, -1.0, 1.5, 2.0],
440 array![0.1, 0.2, 0.3, 0.4],
441 array![[1.0, 0.2], [0.5, 0.1], [1.5, 0.3], [2.0, 0.4]],
442 None,
443 None,
444 2,
445 2,
446 1,
447 2,
448 );
449 let dir = DirectionalHyperParam::new_compact(
450 HyperDesignDerivative::from_implicit(
451 Arc::new(operator),
452 ImplicitDerivLevel::First(0),
453 1..4,
454 5,
455 ),
456 Vec::new(),
457 None,
458 None,
459 )
460 .expect("implicit directional hyperparam");
461 let policy = super::exact_tau_tau_hessian_policy_with_firth(10, 5, &[dir], false);
462 assert!(policy.any_has_implicit);
463 assert_eq!(
464 policy.gradient_plan.dense_x_bytes,
465 10 * 5 * std::mem::size_of::<f64>()
466 );
467 assert!(!policy.prefer_gradient_only());
468 }
469
470 #[test]
471 pub(crate) fn tau_tau_hessian_policy_does_not_force_gradient_only_for_implicit_multidim_duchon()
472 {
473 let operator = ImplicitDesignPsiDerivative::new_streaming(
481 Arc::new(array![[0.0, 0.0], [1.0, 0.2]]),
482 Arc::new(array![[0.0, 0.0], [1.0, 1.0]]),
483 vec![0.0, 0.0],
484 RadialScalarKind::PureDuchon {
485 block_order: 1,
486 p_order: 0,
487 s_order: 0,
488 dim: 2,
489 },
490 None,
491 None,
492 0,
493 );
494 let dir = DirectionalHyperParam::new_compact(
495 HyperDesignDerivative::from_implicit(
496 Arc::new(operator),
497 ImplicitDerivLevel::First(0),
498 0..2,
499 2,
500 ),
501 Vec::new(),
502 None,
503 None,
504 )
505 .expect("implicit duchon directional hyperparam");
506 let policy = super::exact_tau_tau_hessian_policy_with_firth(10, 5, &[dir], false);
507 assert!(policy.any_has_implicit);
508 assert!(policy.implicit_multidim_duchon);
509 assert!(!policy.prefer_gradient_only());
510 }
511
512 #[test]
513 pub(crate) fn tau_tau_hessian_policy_does_not_force_gradient_only_when_cache_budget_is_exceeded()
514 {
515 let dirs = (0..16)
521 .map(|_| {
522 DirectionalHyperParam::new_compact(
523 HyperDesignDerivative::from(Array2::<f64>::zeros((2, 2))),
524 Vec::new(),
525 None,
526 None,
527 )
528 .expect("dense directional hyperparam")
529 })
530 .collect::<Vec<_>>();
531 let policy = super::exact_tau_tau_hessian_policy_with_firth(320_000, 71, &dirs, false);
532 assert!(!policy.any_has_implicit);
533 assert!(policy.hessian_plan.total_bytes() > policy.budget_bytes);
534 assert!(policy.hessian_plan.total_bytes() > policy.gradient_plan.total_bytes());
535 assert!(!policy.prefer_gradient_only());
536 }
537
538 #[test]
539 pub(crate) fn tau_tau_hessian_policy_prefers_gradient_only_for_firth_pair_gap() {
540 let dir = DirectionalHyperParam::new_compact(
541 HyperDesignDerivative::from(Array2::<f64>::zeros((2, 2))),
542 Vec::new(),
543 None,
544 None,
545 )
546 .expect("dense directional hyperparam");
547 let policy = super::exact_tau_tau_hessian_policy_with_firth(10, 5, &[dir], true);
548 assert!(policy.firth_pair_terms_unavailable);
549 assert!(policy.prefer_gradient_only());
550 }
551
552 trait LogitDesignMotionFixture {
560 fn y(&self) -> &Array1<f64>;
561 fn w(&self) -> &Array1<f64>;
562 fn x(&self) -> &Array2<f64>;
563 fn s0(&self) -> &Array2<f64>;
564 fn cfg(&self) -> &RemlConfig;
565 fn rho(&self) -> &Array1<f64>;
566
567 fn state(&self) -> RemlState<'_> {
568 build_logit_state(self.y(), self.w(), self.x(), self.s0(), self.cfg())
569 }
570
571 fn state_perturbed(
572 &self,
573 x_tau: &Array2<f64>,
574 s_tau: &Array2<f64>,
575 eps: f64,
576 ) -> (RemlState<'_>, RemlState<'_>) {
577 let x_plus = self.x() + &x_tau.mapv(|v| eps * v);
578 let x_minus = self.x() - &x_tau.mapv(|v| eps * v);
579 let s_plus = self.s0() + &s_tau.mapv(|v| eps * v);
580 let s_minus = self.s0() - &s_tau.mapv(|v| eps * v);
581 (
582 build_logit_state(self.y(), self.w(), &x_plus, &s_plus, self.cfg()),
583 build_logit_state(self.y(), self.w(), &x_minus, &s_minus, self.cfg()),
584 )
585 }
586
587 fn fd_directional_gradient(&self, x_tau: &Array2<f64>, s_tau: &Array2<f64>) -> f64 {
589 let h = 2e-5;
590 let (state_plus, state_minus) = self.state_perturbed(x_tau, s_tau, h);
591 let v_plus = state_plus.compute_cost(self.rho()).expect("cost+");
592 let v_minus = state_minus.compute_cost(self.rho()).expect("cost-");
593 (v_plus - v_minus) / (2.0 * h)
594 }
595 }
596
597 pub(crate) fn build_logit_state<'a>(
598 y: &'a Array1<f64>,
599 w: &'a Array1<f64>,
600 x: &Array2<f64>,
601 s: &Array2<f64>,
602 cfg: &'a RemlConfig,
603 ) -> RemlState<'a> {
604 use crate::estimate::PenaltySpec;
605 let p = x.ncols();
606 let offset = Array1::<f64>::zeros(y.len());
607 let spec = PenaltySpec::Dense(s.clone());
608 let canonical =
609 gam_terms::construction::canonicalize_penalty_specs(&[spec], &[1], p, "test")
610 .map(|(canonical, _)| canonical)
611 .expect("canonicalize");
612 RemlState::newwith_offset(
613 y.view(),
614 x.clone(),
615 w.view(),
616 offset.view(),
617 canonical,
618 p,
619 cfg,
620 Some(vec![1]),
621 None,
622 None,
623 )
624 .expect("state")
625 }
626
627 fn bundle_with_inner_kkt_residual(bundle: &EvalShared, residual: Array1<f64>) -> EvalShared {
628 let mut pirls_result = bundle.pirls_result.as_ref().clone();
629 pirls_result.lastgradient_norm = residual.dot(&residual).sqrt();
630 pirls_result.penalized_gradient_transformed = residual;
631 let mut cloned = bundle.clone();
632 cloned.pirls_result = Arc::new(pirls_result);
633 cloned
634 }
635
636 fn evaluate_synthetic_psi_value_without_inner_kkt(
637 state: &RemlState<'_>,
638 rho: &Array1<f64>,
639 bundle: &EvalShared,
640 ) -> f64 {
641 let mode = super::reml_outer_engine::EvalMode::ValueOnly;
642 let mut assembly = state
643 .build_auto_assembly(rho, bundle, mode, true, false)
644 .expect("uncorrected synthetic-psi assembly");
645 assert!(
646 matches!(
647 &assembly.dispersion,
648 super::reml_outer_engine::DispersionHandling::Fixed { .. }
649 ),
650 "the #2305 bridge fixture must exercise the fixed-dispersion LAML identity"
651 );
652 let p_dim = assembly.beta.len();
653 assembly.ext_coords = vec![super::reml_outer_engine::HyperCoord {
654 a: 0.0,
655 g: Array1::zeros(p_dim),
656 drift: super::reml_outer_engine::HyperCoordDrift::none(),
657 ld_s: 0.0,
658 b_depends_on_beta: false,
659 is_penalty_like: false,
660 firth_g: None,
661 tk_eta_fixed: None,
662 tk_x_fixed: None,
663 }];
664 state
665 .assemble_and_evaluate(rho, bundle, mode, assembly)
666 .expect("uncorrected synthetic-psi value")
667 .cost
668 }
669
670 #[test]
671 fn psi_value_bridge_corrects_nonstationary_inner_mode_2305() {
672 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
677 let w = Array1::<f64>::ones(y.len());
678 let x = array![
679 [1.0, -1.0, 0.2],
680 [1.0, -0.6, -0.4],
681 [1.0, -0.2, 0.7],
682 [1.0, 0.3, -0.5],
683 [1.0, 0.8, 0.1],
684 [1.0, 1.2, 0.6],
685 ];
686 let s = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.15], [0.0, 0.15, 0.8]];
687 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-12, false);
688 let state = build_logit_state(&y, &w, &x, &s, &cfg);
689 let rho = array![0.2];
690 let base = state.obtain_eval_bundle(&rho).expect("base inner mode");
691 let residual = array![0.18, -0.11, 0.07];
692 let capped = bundle_with_inner_kkt_residual(&base, residual);
693
694 let corrected = state
695 .evaluate_unified_value_only_with_synthetic_ext_count(&rho, &capped, 1, false)
696 .expect("psi value with inner-KKT correction");
697 let exact_kkt_assumption =
698 evaluate_synthetic_psi_value_without_inner_kkt(&state, &rho, &capped);
699 let residual_energy = corrected
700 .ift_residual_energy
701 .expect("design-moving bridge must attach the nonstationary KKT residual");
702
703 assert!(
704 residual_energy.abs() > 1e-10,
705 "the synthetic nonstationary residual must produce a material fixed-dispersion \
706 IFT correction, got residual_energy={residual_energy:.12e}"
707 );
708 assert_eq!(
709 corrected.cost,
710 exact_kkt_assumption - residual_energy,
711 "the psi value bridge must apply exactly the correction reported by the unified \
712 evaluator: corrected={:.12e}, exact-kkt={exact_kkt_assumption:.12e}, \
713 residual-energy={residual_energy:.12e}",
714 corrected.cost,
715 );
716 }
717
718 #[test]
719 fn psi_value_bridge_correction_vanishes_at_stationarity_2305() {
720 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
721 let w = Array1::<f64>::ones(y.len());
722 let x = array![
723 [1.0, -1.0, 0.2],
724 [1.0, -0.6, -0.4],
725 [1.0, -0.2, 0.7],
726 [1.0, 0.3, -0.5],
727 [1.0, 0.8, 0.1],
728 [1.0, 1.2, 0.6],
729 ];
730 let s = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.15], [0.0, 0.15, 0.8]];
731 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-12, false);
732 let state = build_logit_state(&y, &w, &x, &s, &cfg);
733 let rho = array![0.2];
734 let base = state.obtain_eval_bundle(&rho).expect("base inner mode");
735 let stationary = bundle_with_inner_kkt_residual(&base, Array1::zeros(x.ncols()));
736
737 let corrected = state
738 .evaluate_unified_value_only_with_synthetic_ext_count(&rho, &stationary, 1, false)
739 .expect("stationary psi value with correction enabled");
740 let exact_kkt_assumption =
741 evaluate_synthetic_psi_value_without_inner_kkt(&state, &rho, &stationary);
742
743 assert_eq!(
744 corrected.ift_residual_energy,
745 Some(0.0),
746 "the design-moving bridge must attach the residual, whose correction is exactly \
747 zero at stationarity"
748 );
749 assert_eq!(
750 corrected.cost, exact_kkt_assumption,
751 "the generic inner-KKT correction must be exactly zero at a stationary inner mode"
752 );
753 }
754
755 #[test]
756 fn repeated_penalty_ranges_keep_analytic_outer_hessian() {
757 let y = array![0.2, -0.1, 0.3, 0.0];
758 let w = Array1::<f64>::ones(y.len());
759 let x = array![[1.0, -0.7], [1.0, -0.2], [1.0, 0.3], [1.0, 0.9]];
760 let offset = Array1::<f64>::zeros(y.len());
761 let cfg = RemlConfig::external(gaussian_identity_glm_spec(), 1e-10, false);
762 let p = x.ncols();
763 let canonical = vec![
764 gam_terms::construction::CanonicalPenalty::from_dense_root(array![[0.0, 1.0]], p),
765 gam_terms::construction::CanonicalPenalty::from_dense_root(array![[1.0, 0.0]], p),
766 ];
767 let state = RemlState::newwith_offset(
768 y.view(),
769 x,
770 w.view(),
771 offset.view(),
772 canonical,
773 p,
774 &cfg,
775 Some(vec![1, 1]),
776 None,
777 None,
778 )
779 .expect("state");
780
781 assert!(
782 state.analytic_outer_hessian_enabled(),
783 "double-penalty-style repeated coefficient ranges must still route to exact Hessian"
784 );
785 }
786
787 #[test]
803 fn adaptive_ift_controller_does_not_survive_into_the_next_state_in_the_same_slot() {
804 const DEFAULT_CAP: f64 = 7.5;
808
809 fn probe(record: bool) -> (usize, f64, bool) {
810 let y = array![0.2, -0.1, 0.3, 0.0];
811 let w = Array1::<f64>::ones(y.len());
812 let x = array![[1.0, -0.7], [1.0, -0.2], [1.0, 0.3], [1.0, 0.9]];
813 let offset = Array1::<f64>::zeros(y.len());
814 let cfg = RemlConfig::external(gaussian_identity_glm_spec(), 1e-10, false);
815 let p = x.ncols();
816 let canonical = vec![gam_terms::construction::CanonicalPenalty::from_dense_root(
817 array![[0.0, 1.0]],
818 p,
819 )];
820 let state = RemlState::newwith_offset(
821 y.view(),
822 x,
823 w.view(),
824 offset.view(),
825 canonical,
826 p,
827 &cfg,
828 Some(vec![1]),
829 None,
830 None,
831 )
832 .expect("state");
833 let address = &state as *const RemlState<'_> as usize;
834 if record {
835 let shrunk = state
839 .record_ift_prediction_quality(1.0e3, 0.25)
840 .expect("a finite quality and a positive cap must update the controller");
841 assert!(
842 shrunk < 0.25,
843 "a quality above the flat-fallback band must shrink the step cap; got {shrunk}"
844 );
845 return (address, shrunk, true);
846 }
847 let cap = state.ift_quality_step_cap(DEFAULT_CAP);
848 let flat = state.take_ift_quality_flat_override();
849 (address, cap, flat)
850 }
851
852 let (first_address, _, _) = probe(true);
853 let (second_address, cap, flat) = probe(false);
854
855 assert_eq!(
856 first_address, second_address,
857 "the probe only exercises the defect when the second state reuses the first's slot; \
858 both calls are to the same fn at the same depth, so the frame offsets must coincide"
859 );
860 assert_eq!(
861 cap, DEFAULT_CAP,
862 "a freshly built state must return the caller's default step cap, not the cap the \
863 previous state at this address shrank to"
864 );
865 assert!(
866 !flat,
867 "a freshly built state must not inherit the previous state's armed flat fallback"
868 );
869 }
870
871 #[test]
878 fn gaussian_profiled_diagonal_seed_clamps_into_its_validated_box() {
879 let n = 40usize;
882 let y = Array1::from_iter((0..n).map(|i| {
883 let t = (i as f64 + 0.5) / n as f64;
884 (std::f64::consts::TAU * t).sin() + 0.05 * (i as f64 % 3.0 - 1.0)
885 }));
886 let w = Array1::<f64>::ones(n);
887 let mut x = Array2::<f64>::zeros((n, 3));
888 for i in 0..n {
889 let t = (i as f64 + 0.5) / n as f64;
890 x[[i, 0]] = 1.0;
891 x[[i, 1]] = t;
892 x[[i, 2]] = t * t;
893 }
894 let offset = Array1::<f64>::zeros(n);
895 let cfg = RemlConfig::external(gaussian_identity_glm_spec(), 1e-10, false);
896 let p = x.ncols();
897 let canonical = vec![gam_terms::construction::CanonicalPenalty::from_dense_root(
898 array![[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
899 p,
900 )];
901 let state = RemlState::newwith_offset(
902 y.view(),
903 x,
904 w.view(),
905 offset.view(),
906 canonical,
907 p,
908 &cfg,
909 Some(vec![1]),
910 None,
911 None,
912 )
913 .expect("state");
914
915 let wide = gam_problem::OrderedRhoBounds::new(-12.0, 12.0).unwrap();
918 let seed_wide = state
919 .analytic_gaussian_profiled_diagonal_rho(wide)
920 .expect("no error")
921 .expect("gaussian-identity profiled diagonal returns a seed");
922 let natural = seed_wide[0];
923 for &r in seed_wide.iter() {
924 assert!(r.is_finite(), "seed coordinate is finite");
925 assert!(
926 (-12.0..=12.0).contains(&r),
927 "seed {r} stays inside the wide box"
928 );
929 }
930
931 let cap_hi = natural - 2.0;
937 let cap_lo = natural - 10.0;
938 let capped = gam_problem::OrderedRhoBounds::new(cap_lo, cap_hi).unwrap();
939 let seed_capped = state
940 .analytic_gaussian_profiled_diagonal_rho(capped)
941 .expect("no error")
942 .expect("seed present");
943 assert!(
944 seed_capped.iter().all(|&r| (r - cap_hi).abs() < 1e-9),
945 "capped seed {seed_capped:?} clamps to the binding upper bound {cap_hi}"
946 );
947 }
948
949 #[test]
950 fn canonical_logit_firth_declines_exact_tk_hessian_when_row_pair_work_is_large() {
951 let n = 2_000usize;
952 let p = 28usize;
953 let y = Array1::from_iter((0..n).map(|i| if i % 3 == 0 { 1.0 } else { 0.0 }));
954 let w = Array1::<f64>::ones(n);
955 let mut x = Array2::<f64>::zeros((n, p));
956 for i in 0..n {
957 let t = (i as f64 + 0.5) / n as f64;
958 x[[i, 0]] = 1.0;
959 for j in 1..p {
960 x[[i, j]] = ((j as f64) * std::f64::consts::TAU * t).sin()
961 + 0.25 * (((j + 1) as f64) * std::f64::consts::TAU * t).cos();
962 }
963 }
964 let mut s = Array2::<f64>::zeros((p, p));
965 for j in 1..p {
966 s[[j, j]] = 1.0;
967 }
968 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, true);
969 let state = build_logit_state(&y, &w, &x, &s, &cfg);
970
971 assert!(
972 !RemlState::firth_tk_exact_hessian_scale_allows(n, p),
973 "fixture must sit beyond the O(n²·p) exact-Hessian budget"
974 );
975 assert!(
976 !state.analytic_outer_hessian_enabled(),
977 "large canonical-logit Firth fits should keep exact value/gradient but route outer curvature to BFGS"
978 );
979 }
980
981 #[test]
982 fn canonical_logit_firth_keeps_exact_tk_hessian_for_small_separation_guards() {
983 let n = 40usize;
984 let p = 6usize;
985 let y = Array1::from_iter((0..n).map(|i| if i >= n / 2 { 1.0 } else { 0.0 }));
986 let w = Array1::<f64>::ones(n);
987 let mut x = Array2::<f64>::zeros((n, p));
988 for i in 0..n {
989 let t = (i as f64) / (n - 1) as f64;
990 x[[i, 0]] = 1.0;
991 for j in 1..p {
992 x[[i, j]] = t.powi(j as i32);
993 }
994 }
995 let mut s = Array2::<f64>::zeros((p, p));
996 for j in 1..p {
997 s[[j, j]] = 1.0;
998 }
999 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, true);
1000 let state = build_logit_state(&y, &w, &x, &s, &cfg);
1001
1002 assert!(RemlState::firth_tk_exact_hessian_scale_allows(n, p));
1003 assert!(
1004 state.analytic_outer_hessian_enabled(),
1005 "small Firth rescue fits should keep exact TK Hessian curvature"
1006 );
1007 }
1008
1009 #[test]
1010 fn nonlogit_firth_keeps_tk_value_and_gradient() {
1011 let y = array![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
1012 let w = Array1::<f64>::ones(y.len());
1013 let x = array![
1014 [1.0, -1.0, 0.3],
1015 [1.0, -0.7, -0.2],
1016 [1.0, -0.3, 0.4],
1017 [1.0, 0.0, -0.5],
1018 [1.0, 0.2, 0.6],
1019 [1.0, 0.6, -0.4],
1020 [1.0, 0.9, 0.2],
1021 [1.0, 1.3, -0.1],
1022 ];
1023 let s = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.1], [0.0, 0.1, 0.7]];
1024 let rho = array![0.15];
1025
1026 for link in [StandardLink::Probit, StandardLink::CLogLog] {
1027 let likelihood = GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1028 ResponseFamily::Binomial,
1029 InverseLink::Standard(link),
1030 ));
1031 let cfg = RemlConfig::external(likelihood, 1e-9, true).with_max_iterations(500);
1032 let state = build_logit_state(&y, &w, &x, &s, &cfg);
1033 assert!(
1034 !state.analytic_outer_hessian_enabled(),
1035 "{link:?} should use BFGS curvature until exact f_obs is available"
1036 );
1037
1038 let bundle = state
1039 .obtain_eval_bundle(&rho)
1040 .expect("non-logit Firth bundle");
1041 let atom = state
1042 .tierney_kadane_terms(
1043 &rho,
1044 &bundle,
1045 super::reml_outer_engine::EvalMode::ValueAndGradient,
1046 &[],
1047 )
1048 .expect("non-logit TK correction");
1049 let value = CriterionAtom::value(&atom);
1050 let gradient = atom.gradient().expect("TK gradient");
1051 assert!(
1052 value.is_finite() && value.abs() > 1e-12,
1053 "{link:?} must receive a material finite TK correction, got {value}"
1054 );
1055 assert_eq!(gradient.len(), rho.len());
1056 assert!(
1057 gradient.iter().all(|entry| entry.is_finite()),
1058 "{link:?} TK gradient must be finite: {gradient:?}"
1059 );
1060 }
1061 }
1062
1063 pub(crate) fn poisson_log_glm_spec() -> GlmLikelihoodSpec {
1064 GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
1065 ResponseFamily::Poisson,
1066 InverseLink::Standard(StandardLink::Log),
1067 ))
1068 }
1069
1070 #[test]
1084 pub(crate) fn fixed_dispersion_laml_surface_is_replication_invariant() {
1085 let n = 200usize;
1086 let p = 8usize;
1087 let c = 3usize;
1088 let mut x = Array2::<f64>::zeros((n, p));
1089 let mut y = Array1::<f64>::zeros(n);
1090 for i in 0..n {
1091 let t = (i as f64) / ((n - 1) as f64);
1092 let tau = std::f64::consts::TAU;
1093 x[[i, 0]] = 1.0;
1094 x[[i, 1]] = t;
1095 x[[i, 2]] = (tau * t).sin();
1096 x[[i, 3]] = (tau * t).cos();
1097 x[[i, 4]] = (2.0 * tau * t).sin();
1098 x[[i, 5]] = (2.0 * tau * t).cos();
1099 x[[i, 6]] = (3.0 * tau * t).sin();
1100 x[[i, 7]] = (3.0 * tau * t).cos();
1101 let eta = 0.3 + 0.9 * (1.4 * (t - 0.5)).sin();
1102 y[i] = (eta.exp() + 0.5 * ((i as f64) * 2.399_963).sin())
1104 .round()
1105 .max(0.0);
1106 }
1107 let mut s = Array2::<f64>::zeros((p, p));
1108 for j in 1..p {
1109 s[[j, j]] = 1.0;
1110 }
1111
1112 let mut x_rep = Array2::<f64>::zeros((n * c, p));
1114 let mut y_rep = Array1::<f64>::zeros(n * c);
1115 for r in 0..c {
1116 for i in 0..n {
1117 let row = r * n + i;
1118 for j in 0..p {
1119 x_rep[[row, j]] = x[[i, j]];
1120 }
1121 y_rep[row] = y[i];
1122 }
1123 }
1124
1125 let w_weighted = Array1::<f64>::from_elem(n, c as f64);
1126 let w_rep = Array1::<f64>::ones(n * c);
1127
1128 let cfg = RemlConfig::external(poisson_log_glm_spec(), 1e-10, false);
1129 let st_w = build_logit_state(&y, &w_weighted, &x, &s, &cfg);
1130 let st_r = build_logit_state(&y_rep, &w_rep, &x_rep, &s, &cfg);
1131
1132 for &rho in &[-2.0_f64, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0] {
1133 let r = Array1::from_elem(1, rho);
1134 let cw = st_w.compute_cost(&r).expect("weighted cost");
1135 let cr = st_r.compute_cost(&r).expect("replicated cost");
1136 let gw = st_w.compute_gradient(&r).expect("weighted grad");
1137 let gr = st_r.compute_gradient(&r).expect("replicated grad");
1138 assert!(
1141 (cw - cr).abs() <= 1e-9 * (1.0 + cw.abs()),
1142 "LAML cost differs between w=c and c× replication at rho={rho}: \
1143 cost_w={cw:.12e} cost_r={cr:.12e} diff={:.3e}",
1144 cw - cr
1145 );
1146 assert!(
1147 (gw[0] - gr[0]).abs() <= 1e-9 * (1.0 + gw[0].abs()),
1148 "LAML gradient differs between w=c and c× replication at rho={rho}: \
1149 g_w={:.12e} g_r={:.12e} diff={:.3e}",
1150 gw[0],
1151 gr[0],
1152 gw[0] - gr[0]
1153 );
1154 }
1155 }
1156
1157 #[test]
1166 pub(crate) fn rho_weight_anchor_is_zero_for_fixed_dispersion() {
1167 let n = 50usize;
1168 let p = 3usize;
1169 let mut x = Array2::<f64>::zeros((n, p));
1170 let mut y = Array1::<f64>::zeros(n);
1171 for i in 0..n {
1172 let t = (i as f64) / ((n - 1) as f64);
1173 x[[i, 0]] = 1.0;
1174 x[[i, 1]] = t;
1175 x[[i, 2]] = t * t;
1176 y[i] = (1.0 + (3.0 * t).sin()).round().max(0.0);
1177 }
1178 let mut s = Array2::<f64>::zeros((p, p));
1179 s[[2, 2]] = 1.0;
1180 let c = 4.0_f64;
1182 let w = Array1::<f64>::from_elem(n, c);
1183
1184 let cfg_pois = RemlConfig::external(poisson_log_glm_spec(), 1e-10, false);
1185 let st_pois = build_logit_state(&y, &w, &x, &s, &cfg_pois);
1186 assert_eq!(
1187 st_pois.rho_weight_anchor(),
1188 0.0,
1189 "fixed-dispersion (Poisson) anchor must be 0, not the geometric-mean log-weight"
1190 );
1191
1192 let cfg_gauss = RemlConfig::external(gaussian_identity_glm_spec(), 1e-10, false);
1193 let st_gauss = build_logit_state(&y, &w, &x, &s, &cfg_gauss);
1194 assert!(
1195 (st_gauss.rho_weight_anchor() - c.ln()).abs() <= 1e-12,
1196 "Gaussian-identity (profiled) anchor must be the geometric-mean log-weight ln(c)={:.6}, got {:.6}",
1197 c.ln(),
1198 st_gauss.rho_weight_anchor()
1199 );
1200 }
1201
1202 pub(crate) fn beta_original_from_bundle(bundle: &EvalShared) -> Array1<f64> {
1203 let pr = bundle.pirls_result.as_ref();
1204 match pr.coordinate_frame {
1205 PirlsCoordinateFrame::OriginalSparseNative => pr.beta_transformed.as_ref().clone(),
1206 PirlsCoordinateFrame::TransformedQs => {
1207 pr.reparam_result.qs.dot(pr.beta_transformed.as_ref())
1208 }
1209 }
1210 }
1211
1212 pub(crate) fn compute_joint_hypercostgradienthessian(
1213 state: &RemlState<'_>,
1214 theta: &Array1<f64>,
1215 rho_dim: usize,
1216 hyper_dirs: &[DirectionalHyperParam],
1217 ) -> Result<(f64, Array1<f64>, Array2<f64>), EstimationError> {
1218 let (cost, gradient, hessian) = state.compute_joint_hyper_eval_with_order(
1219 theta,
1220 rho_dim,
1221 hyper_dirs,
1222 crate::rho_optimizer::OuterEvalOrder::ValueGradientHessian,
1223 )?;
1224 Ok((
1225 cost,
1226 gradient,
1227 hessian
1228 .materialize_dense()
1229 .map_err(|error| EstimationError::RemlOptimizationFailed(error.to_string()))?
1230 .ok_or_else(|| {
1231 EstimationError::RemlOptimizationFailed(
1232 "joint hyper Hessian requested but unavailable".to_string(),
1233 )
1234 })?,
1235 ))
1236 }
1237
1238 pub(crate) fn h_original_from_bundle(bundle: &EvalShared) -> Array2<f64> {
1239 let pr = bundle.pirls_result.as_ref();
1240 match pr.coordinate_frame {
1241 PirlsCoordinateFrame::OriginalSparseNative => bundle.h_total.as_ref().clone(),
1242 PirlsCoordinateFrame::TransformedQs => {
1243 let qs = &pr.reparam_result.qs;
1244 let tmp = gam_linalg::faer_ndarray::fast_ab(qs, bundle.h_total.as_ref());
1245 gam_linalg::faer_ndarray::fast_abt(&tmp, qs)
1246 }
1247 }
1248 }
1249
1250 pub(crate) fn single_directional_tau_gradient(
1251 state: &RemlState<'_>,
1252 rho: &Array1<f64>,
1253 hyper: DirectionalHyperParam,
1254 ) -> Result<f64, EstimationError> {
1255 let mut theta = Array1::<f64>::zeros(rho.len() + 1);
1256 theta.slice_mut(s![..rho.len()]).assign(rho);
1257 let (_, gradient, _) = state.compute_joint_hyper_eval_with_order(
1258 &theta,
1259 rho.len(),
1260 &[hyper],
1261 crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1262 )?;
1263 Ok(gradient[rho.len()])
1264 }
1265
1266 pub(crate) fn fd_directional_tau_cost_gradient(
1267 y: &Array1<f64>,
1268 w: &Array1<f64>,
1269 x: &Array2<f64>,
1270 s0: &Array2<f64>,
1271 cfg: &RemlConfig,
1272 rho: &Array1<f64>,
1273 x_tau: &Array2<f64>,
1274 s_tau: &Array2<f64>,
1275 ) -> f64 {
1276 let h = 2e-5;
1277 let x_plus = x + &x_tau.mapv(|v| h * v);
1278 let x_minus = x - &x_tau.mapv(|v| h * v);
1279 let s_plus = s0 + &s_tau.mapv(|v| h * v);
1280 let s_minus = s0 - &s_tau.mapv(|v| h * v);
1281 let state_plus = build_logit_state(y, w, &x_plus, &s_plus, cfg);
1282 let state_minus = build_logit_state(y, w, &x_minus, &s_minus, cfg);
1283 let v_plus = state_plus.compute_cost(rho).expect("cost+");
1284 let v_minus = state_minus.compute_cost(rho).expect("cost-");
1285 (v_plus - v_minus) / (2.0 * h)
1286 }
1287
1288 pub(crate) fn directional_tau_hessian_fd_reference(
1289 y: &Array1<f64>,
1290 w: &Array1<f64>,
1291 x: &Array2<f64>,
1292 s0: &Array2<f64>,
1293 cfg: &RemlConfig,
1294 rho: &Array1<f64>,
1295 hyper_dirs: &[DirectionalHyperParam],
1296 x_tau_mats: &[Array2<f64>],
1297 s_tau_mats: &[Array2<f64>],
1298 ) -> Array2<f64> {
1299 assert_eq!(hyper_dirs.len(), x_tau_mats.len());
1300 assert_eq!(hyper_dirs.len(), s_tau_mats.len());
1301
1302 const TARGET_PHYSICAL_STEP: f64 = 1e-5;
1303
1304 let n_dirs = hyper_dirs.len();
1305 let mut h_ttfd = Array2::<f64>::zeros((n_dirs, n_dirs));
1306 for j in 0..n_dirs {
1307 let direction_scale = x_tau_mats[j]
1308 .iter()
1309 .chain(s_tau_mats[j].iter())
1310 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1311 let h = if direction_scale > 0.0 {
1312 TARGET_PHYSICAL_STEP / direction_scale
1313 } else {
1314 TARGET_PHYSICAL_STEP
1315 };
1316
1317 let x_plus = x + &x_tau_mats[j].mapv(|v| h * v);
1318 let x_minus = x - &x_tau_mats[j].mapv(|v| h * v);
1319 let s_plus = s0 + &s_tau_mats[j].mapv(|v| h * v);
1320 let s_minus = s0 - &s_tau_mats[j].mapv(|v| h * v);
1321
1322 let state_plus = build_logit_state(y, w, &x_plus, &s_plus, cfg);
1323 let state_minus = build_logit_state(y, w, &x_minus, &s_minus, cfg);
1324 for i in 0..n_dirs {
1325 let g_plus =
1326 single_directional_tau_gradient(&state_plus, rho, hyper_dirs[i].clone())
1327 .expect("g+ for FD");
1328 let g_minus =
1329 single_directional_tau_gradient(&state_minus, rho, hyper_dirs[i].clone())
1330 .expect("g- for FD");
1331 h_ttfd[[i, j]] = (g_plus - g_minus) / (2.0 * h);
1332 }
1333 }
1334 symmetrize_in_place(&mut h_ttfd);
1335 h_ttfd
1336 }
1337
1338 #[test]
1339 pub(crate) fn eval_cache_manager_stores_first_order_outer_eval() {
1340 let cache = EvalCacheManager::new();
1341 let rho = array![0.25, -0.0];
1342 let rho_key = super::rho_key::sanitized_rhokey(&rho);
1343 let eval = OuterEval {
1344 cost: 3.5,
1345 gradient: array![1.0, -2.0],
1346 hessian: HessianValue::Unavailable,
1347 inner_beta_hint: None,
1348 };
1349
1350 cache.store_outer_eval(&rho_key, &eval);
1351
1352 let cached = cache
1353 .cached_outer_eval(&rho_key)
1354 .expect("first-order outer eval should be cached");
1355 assert_eq!(cached.cost, eval.cost);
1356 assert_eq!(cached.gradient, eval.gradient);
1357 assert!(matches!(cached.hessian, HessianValue::Unavailable));
1358
1359 cache.invalidate_eval_bundle();
1360 assert!(
1361 cache.cached_outer_eval(&rho_key).is_none(),
1362 "invalidating the bundle should clear the outer-eval cache too"
1363 );
1364 }
1365
1366 #[test]
1376 pub(crate) fn outer_eval_lru_hit_is_bit_identical_and_evicts_honestly_1575() {
1377 use super::OUTER_EVAL_LRU_CAPACITY;
1378
1379 let make_eval = |seed: f64| OuterEval {
1382 cost: (seed * std::f64::consts::PI).sin() / 3.0 - seed,
1383 gradient: array![seed, -seed * 2.0, seed.recip()],
1384 hessian: HessianValue::Unavailable,
1385 inner_beta_hint: Some(array![seed + 0.5, seed - 0.5]),
1386 };
1387 let bits_eq = |a: &OuterEval, b: &OuterEval| -> bool {
1388 a.cost.to_bits() == b.cost.to_bits()
1389 && a.gradient.len() == b.gradient.len()
1390 && a.gradient
1391 .iter()
1392 .zip(b.gradient.iter())
1393 .all(|(x, y)| x.to_bits() == y.to_bits())
1394 };
1395
1396 let cache = EvalCacheManager::new();
1397
1398 let rho_a = array![0.25, -1.5];
1401 let key_a = super::rho_key::sanitized_rhokey(&rho_a);
1402 let eval_a = make_eval(0.25);
1403 cache.store_outer_eval(&key_a, &eval_a);
1404 let hit_a = cache
1405 .cached_outer_eval(&key_a)
1406 .expect("stored rho_a must hit");
1407 assert!(
1408 bits_eq(&hit_a, &eval_a),
1409 "cache hit must be bit-identical (cost+gradient) to the stored miss-path eval"
1410 );
1411 assert_eq!(
1412 hit_a.inner_beta_hint.as_ref().map(|b| b.to_vec()),
1413 eval_a.inner_beta_hint.as_ref().map(|b| b.to_vec()),
1414 "inner_beta_hint must round-trip unchanged"
1415 );
1416
1417 let rho_b = array![0.25, -1.4999999999999998];
1420 let key_b = super::rho_key::sanitized_rhokey(&rho_b);
1421 assert_ne!(key_a, key_b, "the two rho-keys must differ");
1422 let eval_b = make_eval(7.0);
1423 cache.store_outer_eval(&key_b, &eval_b);
1424 assert!(
1425 bits_eq(
1426 &cache.cached_outer_eval(&key_b).expect("rho_b must hit"),
1427 &eval_b
1428 ),
1429 "rho_b must return its own eval, not rho_a's"
1430 );
1431 assert!(
1432 bits_eq(
1433 &cache
1434 .cached_outer_eval(&key_a)
1435 .expect("rho_a must still hit"),
1436 &eval_a
1437 ),
1438 "rho_a must be unaffected by the rho_b insert"
1439 );
1440
1441 let cache = EvalCacheManager::new();
1445 let mut keys = Vec::new();
1446 let mut evals = Vec::new();
1447 for i in 0..OUTER_EVAL_LRU_CAPACITY {
1448 let rho = array![i as f64, -(i as f64)];
1449 let key = super::rho_key::sanitized_rhokey(&rho);
1450 let eval = make_eval(i as f64 + 0.123);
1451 cache.store_outer_eval(&key, &eval);
1452 keys.push(key);
1453 evals.push(eval);
1454 }
1455 assert_eq!(
1457 cache.outer_eval_lru.read().unwrap().entries.len(),
1458 OUTER_EVAL_LRU_CAPACITY
1459 );
1460 let rho_overflow = array![999.0, -999.0];
1462 let key_overflow = super::rho_key::sanitized_rhokey(&rho_overflow);
1463 let eval_overflow = make_eval(42.0);
1464 cache.store_outer_eval(&key_overflow, &eval_overflow);
1465 assert_eq!(
1466 cache.outer_eval_lru.read().unwrap().entries.len(),
1467 OUTER_EVAL_LRU_CAPACITY,
1468 "capacity must stay bounded"
1469 );
1470 assert!(
1471 cache.cached_outer_eval(&keys[0]).is_none(),
1472 "the least-recently-used key must be evicted and now MISS (recompute), not return stale"
1473 );
1474 assert!(
1475 bits_eq(
1476 &cache
1477 .cached_outer_eval(&keys[1])
1478 .expect("a still-resident key must hit"),
1479 &evals[1]
1480 ),
1481 "a still-resident key must return its exact stored bits"
1482 );
1483 assert!(
1484 bits_eq(
1485 &cache
1486 .cached_outer_eval(&key_overflow)
1487 .expect("the freshest key must hit"),
1488 &eval_overflow
1489 ),
1490 "the freshest key must hit with its own eval"
1491 );
1492 }
1493
1494 #[test]
1495 pub(crate) fn reset_outer_seed_state_clears_pirls_cache() {
1496 let y = array![0.0, 1.0, 1.0, 0.0, 0.0, 1.0];
1502 let w = Array1::<f64>::ones(y.len());
1503 let x = array![
1504 [1.0, -1.0, 0.2],
1505 [1.0, -0.5, -0.4],
1506 [1.0, 0.0, 0.7],
1507 [1.0, 0.4, -0.3],
1508 [1.0, 0.9, 0.1],
1509 [1.0, 1.3, -0.6],
1510 ];
1511 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.1, 0.15], [0.0, 0.15, 0.8],];
1512 let rho = array![0.0];
1513 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false);
1514 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1515
1516 state
1519 .compute_outer_eval_with_order(
1520 &rho,
1521 crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1522 )
1523 .expect("outer eval should succeed");
1524
1525 let populated_len = state.cache_manager.pirls_cache.read().unwrap().map.len();
1526 assert!(
1527 populated_len > 0,
1528 "evaluating the outer objective should populate the PIRLS LRU, got {populated_len}"
1529 );
1530
1531 state.reset_outer_seed_state();
1532
1533 let cleared_len = state.cache_manager.pirls_cache.read().unwrap().map.len();
1534 assert_eq!(
1535 cleared_len, 0,
1536 "reset_outer_seed_state must clear the cross-call PIRLS LRU; got {cleared_len} entries"
1537 );
1538 }
1539
1540 #[test]
1541 pub(crate) fn reset_outer_seed_state_preserves_frozen_negbin_theta_1448() {
1542 use std::sync::atomic::Ordering;
1559
1560 let y = array![0.0, 1.0, 1.0, 0.0, 0.0, 1.0];
1561 let w = Array1::<f64>::ones(y.len());
1562 let x = array![
1563 [1.0, -1.0, 0.2],
1564 [1.0, -0.5, -0.4],
1565 [1.0, 0.0, 0.7],
1566 [1.0, 0.4, -0.3],
1567 [1.0, 0.9, 0.1],
1568 [1.0, 1.3, -0.6],
1569 ];
1570 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.1, 0.15], [0.0, 0.15, 0.8],];
1571 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false);
1572 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1573
1574 let theta_final_bits = 2.5_f64.to_bits();
1576 state
1577 .frozen_negbin_theta
1578 .store(theta_final_bits, Ordering::Relaxed);
1579 assert_eq!(
1580 state.frozen_negbin_theta.load(Ordering::Relaxed),
1581 theta_final_bits,
1582 "precondition: the re-freeze stores θ_final into the frozen slot"
1583 );
1584
1585 state.reset_outer_seed_state();
1587
1588 assert_eq!(
1589 state.frozen_negbin_theta.load(Ordering::Relaxed),
1590 theta_final_bits,
1591 "reset_outer_seed_state (alternation-round reset) must PRESERVE the \
1592 re-frozen NB θ; clearing it would defeat the #1448 θ↔λ alternation \
1593 (the next ρ search would re-derive θ from the seed and never reach \
1594 the joint fixed point)"
1595 );
1596 }
1597
1598 #[test]
1599 pub(crate) fn implicit_hyper_design_derivative_respects_full_model_embedding() {
1600 let operator = ImplicitDesignPsiDerivative::new(
1601 array![1.0, 2.0, 3.0, 4.0],
1602 array![0.5, -1.0, 1.5, 2.0],
1603 array![0.1, 0.2, 0.3, 0.4],
1604 array![[1.0, 0.2], [0.5, 0.1], [1.5, 0.3], [2.0, 0.4]],
1605 None,
1606 None,
1607 2,
1608 2,
1609 1,
1610 2,
1611 );
1612 let local = operator
1613 .materialize_first(0)
1614 .expect("materialized first derivative");
1615 assert_eq!(
1616 local.ncols(),
1617 3,
1618 "operator-local derivative should stay smooth-local"
1619 );
1620
1621 let implicit = HyperDesignDerivative::from_implicit(
1622 Arc::new(operator),
1623 ImplicitDerivLevel::First(0),
1624 1..4,
1625 5,
1626 );
1627 let embedded = HyperDesignDerivative::from_embedded(local.clone(), 1..4, 5);
1628
1629 assert_eq!(implicit.nrows(), embedded.nrows());
1630 assert_eq!(implicit.ncols(), 5);
1631 assert_eq!(implicit.materialize(), embedded.materialize());
1632
1633 let u = array![7.0, 1.5, -2.0, 0.25, -3.0];
1634 let v = array![0.75, -1.25];
1635 assert_eq!(
1636 implicit.forward_mul_original(&u).expect("implicit forward"),
1637 embedded.forward_mul_original(&u).expect("embedded forward")
1638 );
1639 assert_eq!(
1640 implicit
1641 .transpose_mul_original(&v)
1642 .expect("implicit transpose"),
1643 embedded
1644 .transpose_mul_original(&v)
1645 .expect("embedded transpose")
1646 );
1647
1648 let qs = array![
1649 [1.0, 0.0, 0.0],
1650 [0.0, 1.0, 0.0],
1651 [0.0, 0.5, 0.5],
1652 [0.0, 0.0, 1.0],
1653 [0.0, 0.0, 0.0],
1654 ];
1655 assert_eq!(
1656 implicit
1657 .transformed(&qs, None)
1658 .expect("implicit transformed"),
1659 embedded
1660 .transformed(&qs, None)
1661 .expect("embedded transformed")
1662 );
1663 let u_transformed = array![1.0, -0.5, 2.0];
1664 assert_eq!(
1665 implicit
1666 .transformed_forward_mul(&qs, None, &u_transformed)
1667 .expect("implicit transformed forward"),
1668 embedded
1669 .transformed_forward_mul(&qs, None, &u_transformed)
1670 .expect("embedded transformed forward")
1671 );
1672 assert_eq!(
1673 implicit
1674 .transformed_transpose_mul(&qs, None, &v)
1675 .expect("implicit transformed transpose"),
1676 embedded
1677 .transformed_transpose_mul(&qs, None, &v)
1678 .expect("embedded transformed transpose")
1679 );
1680 }
1681
1682 #[test]
1683 pub(crate) fn directional_hyper_identities_match_finite_differences_logit() {
1684 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
1685 let w = Array1::<f64>::ones(y.len());
1686 let x = array![
1687 [1.0, -1.2, 0.3],
1688 [1.0, -0.8, -0.4],
1689 [1.0, -0.3, 0.7],
1690 [1.0, 0.1, -0.9],
1691 [1.0, 0.5, 0.2],
1692 [1.0, 0.9, -0.1],
1693 [1.0, 1.3, 0.8],
1694 [1.0, 1.7, -0.6],
1695 ];
1696 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
1697
1698 let x_tau = Array2::<f64>::zeros(x.raw_dim());
1703 let s_tau = array![[0.0, 0.0, 0.0], [0.0, 0.25, 0.04], [0.0, 0.04, 0.15],];
1704 let hyper =
1705 DirectionalHyperParam::single_penalty(0, x_tau.clone(), s_tau.clone(), None, None)
1706 .expect("single-penalty hyper direction");
1707 let rho = array![0.0];
1708
1709 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
1713 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1714 let bundle = state.obtain_eval_bundle(&rho).expect("bundle");
1715 let pr = bundle.pirls_result.as_ref();
1716
1717 let beta = beta_original_from_bundle(&bundle);
1718 let h_orig = h_original_from_bundle(&bundle);
1719 let u = &pr.solveweights * &(&pr.solveworking_response - &pr.final_eta);
1720
1721 let x_tau_beta = gam_linalg::faer_ndarray::fast_av(&x_tau, &beta);
1724 let weighted_x_tau_beta = &pr.finalweights * &x_tau_beta;
1725 let rhs = gam_linalg::faer_ndarray::fast_atv(&x_tau, &u)
1726 - gam_linalg::faer_ndarray::fast_atv(&x, &weighted_x_tau_beta)
1727 - s_tau.dot(&beta);
1728 let chol = h_orig.cholesky(Side::Lower).expect("chol(H)");
1729 let b_analytic = chol.solvevec(&rhs);
1730
1731 let eta_dot = &x_tau_beta + &gam_linalg::faer_ndarray::fast_av(&x, &b_analytic);
1735 let w_direction = crate::pirls::directionalworking_curvature_from_c_array(
1736 &pr.solve_c_array.to_owned(),
1737 &eta_dot,
1738 );
1739 let wx = RemlState::row_scale(&x, &pr.finalweights.to_owned());
1740 let wx_tau = RemlState::row_scale(&x_tau, &pr.finalweights.to_owned());
1741 let mut xwtau_x = x.clone();
1742 match w_direction {
1743 crate::pirls::DirectionalWorkingCurvature::Diagonal(diag) => {
1744 xwtau_x = RemlState::row_scale(&xwtau_x, &diag);
1745 }
1746 }
1747 let mut h_tau_analytic = gam_linalg::faer_ndarray::fast_atb(&x_tau, &wx);
1748 h_tau_analytic += &gam_linalg::faer_ndarray::fast_atb(&x, &wx_tau);
1749 h_tau_analytic += &gam_linalg::faer_ndarray::fast_atb(&x, &xwtau_x);
1750 h_tau_analytic += &s_tau;
1751
1752 let ell_beta = gam_linalg::faer_ndarray::fast_atv(&x, &u);
1757 let s_eff = &h_orig - &gam_linalg::faer_ndarray::fast_atb(&x, &wx);
1758 let cancellation = -ell_beta.dot(&b_analytic) + beta.dot(&s_eff.dot(&b_analytic));
1759
1760 let h = 2e-5;
1762 let x_plus = &x + &(x_tau.mapv(|v| h * v));
1763 let x_minus = &x - &(x_tau.mapv(|v| h * v));
1764 let s_plus = &s0 + &(s_tau.mapv(|v| h * v));
1765 let s_minus = &s0 - &(s_tau.mapv(|v| h * v));
1766
1767 let state_plus = build_logit_state(&y, &w, &x_plus, &s_plus, &cfg);
1768 let state_minus = build_logit_state(&y, &w, &x_minus, &s_minus, &cfg);
1769 let bundle_plus = state_plus.obtain_eval_bundle(&rho).expect("bundle+");
1770 let bundle_minus = state_minus.obtain_eval_bundle(&rho).expect("bundle-");
1771 let beta_plus = beta_original_from_bundle(&bundle_plus);
1772 let beta_minus = beta_original_from_bundle(&bundle_minus);
1773 let bfd = (&beta_plus - &beta_minus).mapv(|v| v / (2.0 * h));
1774
1775 let h_plus = h_original_from_bundle(&bundle_plus);
1776 let h_minus = h_original_from_bundle(&bundle_minus);
1777 let h_taufd = (&h_plus - &h_minus).mapv(|v| v / (2.0 * h));
1778
1779 let v_plus = state_plus.compute_cost(&rho).expect("cost+");
1780 let v_minus = state_minus.compute_cost(&rho).expect("cost-");
1781 let v_taufd = (v_plus - v_minus) / (2.0 * h);
1782
1783 let v_tau_analytic = single_directional_tau_gradient(&state, &rho, hyper.clone())
1784 .expect("analytic directional gradient");
1785
1786 let b_num = (&b_analytic - &bfd).mapv(|v| v * v).sum().sqrt();
1787 let b_den = bfd.mapv(|v| v * v).sum().sqrt().max(1e-12);
1788 let b_rel = b_num / b_den;
1789 for i in 0..b_analytic.len() {
1790 assert_eq!(
1791 b_analytic[i].signum(),
1792 bfd[i].signum(),
1793 "B sign mismatch at i={i}: analytic={} fd={}",
1794 b_analytic[i],
1795 bfd[i]
1796 );
1797 }
1798 assert!(
1799 b_rel < 2e-2,
1800 "B implicit solve mismatch vs FD: rel={b_rel:.3e}, num={b_num:.3e}, den={b_den:.3e}"
1801 );
1802
1803 let dh_num = (&h_tau_analytic - &h_taufd).mapv(|v| v * v).sum().sqrt();
1804 let dh_den = h_taufd.mapv(|v| v * v).sum().sqrt().max(1e-12);
1805 let dh_rel = dh_num / dh_den;
1806 for i in 0..h_tau_analytic.nrows() {
1807 for j in 0..h_tau_analytic.ncols() {
1808 assert_eq!(
1809 h_tau_analytic[[i, j]].signum(),
1810 h_taufd[[i, j]].signum(),
1811 "H_tau sign mismatch at ({i},{j}): analytic={} fd={}",
1812 h_tau_analytic[[i, j]],
1813 h_taufd[[i, j]]
1814 );
1815 }
1816 }
1817 assert!(
1818 dh_rel < 3e-2,
1819 "H_tau mismatch vs FD: rel={dh_rel:.3e}, num={dh_num:.3e}, den={dh_den:.3e}"
1820 );
1821
1822 let v_abs = (v_tau_analytic - v_taufd).abs();
1823 let v_rel = v_abs / v_taufd.abs().max(1e-10);
1824 assert_eq!(
1825 v_tau_analytic.signum(),
1826 v_taufd.signum(),
1827 "V_tau sign mismatch: analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
1828 );
1829 assert!(
1830 v_rel < 2e-2,
1831 "V_tau mismatch vs FD: rel={v_rel:.3e}, abs={v_abs:.3e}, analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
1832 );
1833
1834 assert!(
1835 cancellation.abs() < 1e-10,
1836 "stationarity cancellation failed: | -ell_beta^T B + beta^T S B | = {:.3e}",
1837 cancellation.abs()
1838 );
1839 }
1840
1841 #[test]
1842 pub(crate) fn firth_exacthessian_includes_analytic_tk_second_derivatives() {
1843 let y = array![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
1845 let w = Array1::<f64>::ones(y.len());
1846 let x = array![
1847 [1.0, -1.2, 0.4, -2.4],
1848 [1.0, -0.9, -0.1, -1.8],
1849 [1.0, -0.6, 0.3, -1.2],
1850 [1.0, -0.2, -0.4, -0.4],
1851 [1.0, 0.1, 0.5, 0.2],
1852 [1.0, 0.4, -0.6, 0.8],
1853 [1.0, 0.8, 0.2, 1.6],
1854 [1.0, 1.1, -0.3, 2.2],
1855 [1.0, 1.4, 0.7, 2.8],
1856 [1.0, 1.7, -0.2, 3.4],
1857 ];
1858 let s0 = array![
1859 [0.0, 0.0, 0.0, 0.0],
1860 [0.0, 1.5, 0.2, 0.0],
1861 [0.0, 0.2, 1.0, 0.0],
1862 [0.0, 0.0, 0.0, 0.5],
1863 ];
1864 let s1 = array![
1865 [0.0, 0.0, 0.0, 0.0],
1866 [0.0, 0.8, -0.1, 0.0],
1867 [0.0, -0.1, 0.6, 0.0],
1868 [0.0, 0.0, 0.0, 0.3],
1869 ];
1870 let offset = Array1::<f64>::zeros(y.len());
1871 let cfg =
1874 RemlConfig::external(binomial_logit_glm_spec(), 1e-9, true).with_max_iterations(500);
1875 let p = x.ncols();
1876 use crate::estimate::PenaltySpec;
1877 let specs = vec![PenaltySpec::Dense(s0), PenaltySpec::Dense(s1)];
1878 let canonical =
1879 gam_terms::construction::canonicalize_penalty_specs(&specs, &[1, 1], p, "test")
1880 .map(|(canonical, _)| canonical)
1881 .expect("canonicalize");
1882 let state = RemlState::newwith_offset(
1883 y.view(),
1884 x.clone(),
1885 w.view(),
1886 offset.view(),
1887 canonical,
1888 p,
1889 &cfg,
1890 Some(vec![1, 1]),
1891 None,
1892 None,
1893 )
1894 .expect("state");
1895 let rho = array![0.1, -0.2];
1896 assert!(
1897 state.analytic_outer_hessian_enabled(),
1898 "Firth logit should no longer disable analytic outer Hessian planning"
1899 );
1900 let outer = state
1901 .compute_outer_eval_with_order(
1902 &rho,
1903 crate::rho_optimizer::OuterEvalOrder::ValueGradientHessian,
1904 )
1905 .expect("outer Hessian eval should succeed");
1906 assert!(
1907 outer.hessian.is_analytic(),
1908 "outer planner should request and return an analytic Hessian"
1909 );
1910 let bundle = state.obtain_eval_bundle(&rho).expect("exact firth bundle");
1911 let h_dense = state
1912 .compute_lamlhessian_exact_from_bundle(&rho, &bundle)
1913 .expect("Firth exact Hessian should include analytic TK second derivatives");
1914 assert_eq!(h_dense.raw_dim(), ndarray::Ix2(2, 2));
1915 assert!(
1916 h_dense.iter().all(|value| value.is_finite()),
1917 "Hessian should be finite: {h_dense:?}"
1918 );
1919 }
1920
1921 #[test]
1922 pub(crate) fn firth_outer_hessian_matches_gradient_finite_difference_with_tk_terms() {
1923 let y = array![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
1924 let w = Array1::<f64>::ones(y.len());
1925 let x = array![
1926 [1.0, -1.0, 0.3],
1927 [1.0, -0.7, -0.2],
1928 [1.0, -0.3, 0.4],
1929 [1.0, 0.0, -0.5],
1930 [1.0, 0.2, 0.6],
1931 [1.0, 0.6, -0.4],
1932 [1.0, 0.9, 0.2],
1933 [1.0, 1.3, -0.1],
1934 ];
1935 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.1], [0.0, 0.1, 0.7],];
1936 let s1 = array![[0.0, 0.0, 0.0], [0.0, 0.4, -0.05], [0.0, -0.05, 0.9],];
1937 let cfg =
1938 RemlConfig::external(binomial_logit_glm_spec(), 1e-9, true).with_max_iterations(500);
1939 let p_dim = x.ncols();
1940 use crate::estimate::PenaltySpec;
1941 let specs = vec![PenaltySpec::Dense(s0), PenaltySpec::Dense(s1)];
1942 let canonical =
1943 gam_terms::construction::canonicalize_penalty_specs(&specs, &[1, 1], p_dim, "test")
1944 .map(|(canonical, _)| canonical)
1945 .expect("canonicalize");
1946 let offset = Array1::<f64>::zeros(y.len());
1947 let state = RemlState::newwith_offset(
1948 y.view(),
1949 x.clone(),
1950 w.view(),
1951 offset.view(),
1952 canonical,
1953 p_dim,
1954 &cfg,
1955 Some(vec![1, 1]),
1956 None,
1957 None,
1958 )
1959 .expect("state");
1960 let rho = array![0.15, -0.25];
1961 let eval = state
1962 .compute_outer_eval_with_order(
1963 &rho,
1964 crate::rho_optimizer::OuterEvalOrder::ValueGradientHessian,
1965 )
1966 .expect("analytic Hessian eval");
1967 let h = match eval.hessian {
1968 HessianValue::Dense(hessian) => hessian,
1969 HessianValue::Operator(_) | HessianValue::Unavailable => {
1970 panic!("expected dense analytic Hessian")
1971 }
1972 };
1973 let delta = 2.0e-5;
1974 for col in 0..rho.len() {
1975 let mut rp = rho.clone();
1976 let mut rm = rho.clone();
1977 rp[col] += delta;
1978 rm[col] -= delta;
1979 let gp = state
1980 .compute_outer_eval_with_order(
1981 &rp,
1982 crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1983 )
1984 .expect("plus grad")
1985 .gradient;
1986 let gm = state
1987 .compute_outer_eval_with_order(
1988 &rm,
1989 crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1990 )
1991 .expect("minus grad")
1992 .gradient;
1993 for row in 0..rho.len() {
1994 let fd = (gp[row] - gm[row]) / (2.0 * delta);
1995 let an = h[[row, col]];
1996 let rel = (fd - an).abs() / fd.abs().max(an.abs()).max(1e-6);
1997 assert!(
1998 rel < 2.0e-3,
1999 "Hessian mismatch ({row},{col}): analytic={an:.9e}, fd={fd:.9e}, rel={rel:.3e}"
2000 );
2001 }
2002 }
2003 }
2004
2005 #[test]
2006 pub(crate) fn firthgradient_lives_in_design_column_space_under_rank_deficiency() {
2007 let x = array![
2009 [1.0, -1.2, 0.4, -2.4],
2010 [1.0, -0.9, -0.1, -1.8],
2011 [1.0, -0.6, 0.3, -1.2],
2012 [1.0, -0.2, -0.4, -0.4],
2013 [1.0, 0.1, 0.5, 0.2],
2014 [1.0, 0.4, -0.6, 0.8],
2015 [1.0, 0.8, 0.2, 1.6],
2016 [1.0, 1.1, -0.3, 2.2],
2017 ];
2018 let beta = array![0.1, -0.2, 0.3, 0.05];
2019 let eta = x.dot(&beta);
2020 let op = super::RemlState::build_firth_dense_operator_for_link(
2021 &gam_problem::InverseLink::Standard(gam_problem::StandardLink::Logit),
2022 &x,
2023 &eta,
2024 ndarray::Array1::ones(x.nrows()).view(),
2025 )
2026 .expect("firth operator");
2027
2028 let gradphi = 0.5 * x.t().dot(&(&op.w1 * &op.h_diag));
2031
2032 let q = &op.q_basis;
2034 let proj = q.dot(&q.t().dot(&gradphi));
2035 let resid = &gradphi - &proj;
2036 let rel =
2037 resid.mapv(|v| v * v).sum().sqrt() / gradphi.mapv(|v| v * v).sum().sqrt().max(1e-12);
2038 assert!(
2039 rel < 1e-10,
2040 "Firth gradient should lie in Col(Xᵀ): rel residual={rel:.3e}"
2041 );
2042 }
2043
2044 #[test]
2045 pub(crate) fn firth_logit_directional_hypergradient_accepts_penalty_only_with_full_tk_gradient()
2046 {
2047 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
2048 let w = Array1::<f64>::ones(y.len());
2049 let x = array![
2050 [1.0, -1.1, 0.2],
2051 [1.0, -0.6, -0.3],
2052 [1.0, -0.1, 0.5],
2053 [1.0, 0.3, -0.7],
2054 [1.0, 0.8, 0.1],
2055 [1.0, 1.2, -0.4],
2056 ];
2057 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2058 let hyper = DirectionalHyperParam::single_penalty(
2059 0,
2060 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2061 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.03], [0.0, 0.03, 0.12],],
2062 None,
2063 None,
2064 )
2065 .expect("single-penalty hyper direction");
2066 let rho = array![0.0];
2067 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-8, true);
2068 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2069 let gradient = single_directional_tau_gradient(&state, &rho, hyper)
2070 .expect("Firth penalty-only directional gradient should use analytic TK propagation");
2071 assert!(gradient.is_finite(), "gradient={gradient}");
2072 let fd = fd_directional_tau_cost_gradient(
2073 &y,
2074 &w,
2075 &x,
2076 &s0,
2077 &cfg,
2078 &rho,
2079 &Array2::<f64>::zeros((x.nrows(), x.ncols())),
2080 &array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.03], [0.0, 0.03, 0.12],],
2081 );
2082 let rel = (gradient - fd).abs() / gradient.abs().max(fd.abs()).max(1.0e-10);
2083 assert!(
2084 rel < 1.0e-3,
2085 "Firth penalty-only directional gradient mismatch: analytic={gradient:.12e}, fd={fd:.12e}, rel={rel:.3e}"
2086 );
2087
2088 let efs_hyper = DirectionalHyperParam::single_penalty(
2089 0,
2090 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2091 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.03], [0.0, 0.03, 0.12],],
2092 None,
2093 None,
2094 )
2095 .expect("single-penalty EFS hyper direction");
2096 let efs = state
2097 .compute_efs_steps_with_psi_ext(&rho, &[efs_hyper])
2098 .expect("Firth penalty-only EFS should use analytic TK propagation");
2099 assert!(efs.cost.is_finite(), "efs cost={}", efs.cost);
2100 }
2101
2102 #[test]
2122 pub(crate) fn firth_logit_rho_gradient_matches_finite_difference_through_inner_solve() {
2123 let x = array![[1.0, -6.0], [1.0, 0.2], [1.0, 5.8]];
2127 let y = array![0.0, 0.0, 1.0];
2128 let w = Array1::<f64>::ones(y.len());
2129 let s0 = array![[1.0, 0.0], [0.0, 1.0]];
2131 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-12, true);
2134 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2135 let delta = 1e-4_f64;
2136 for &rho in &[-0.6_f64, -0.3, 0.0, 0.3, 0.6] {
2137 let r = array![rho];
2138 let analytic = state
2139 .compute_gradient(&r)
2140 .expect("Firth LAML ρ-gradient should evaluate")[0];
2141 let cost_plus = state
2142 .compute_cost(&array![rho + delta])
2143 .expect("Firth LAML cost(ρ+δ) should evaluate");
2144 let cost_minus = state
2145 .compute_cost(&array![rho - delta])
2146 .expect("Firth LAML cost(ρ−δ) should evaluate");
2147 let fd = (cost_plus - cost_minus) / (2.0 * delta);
2148 let rel = (fd - analytic).abs() / fd.abs().max(1e-3);
2149 assert!(
2150 analytic.is_finite() && fd.is_finite(),
2151 "non-finite Firth ρ-gradient at rho={rho:+.3}: fd={fd:+.6e}, analytic={analytic:+.6e}"
2152 );
2153 assert!(
2154 rel < 1e-4,
2155 "Firth ρ-gradient FD desync at rho={rho:+.3}: fd={fd:+.6e}, analytic={analytic:+.6e}, rel={rel:.3e} (>= 1e-4). \
2156 The inner P-IRLS likely converged off the Firth-KKT mode (gam#1821)."
2157 );
2158 }
2159 }
2160
2161 #[test]
2162 pub(crate) fn firth_logit_directional_hypergradient_accepts_design_moving_with_full_tk_gradient()
2163 {
2164 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
2165 let w = Array1::<f64>::ones(y.len());
2166 let x = array![
2167 [1.0, -1.1, 0.2],
2168 [1.0, -0.6, -0.3],
2169 [1.0, -0.1, 0.5],
2170 [1.0, 0.3, -0.7],
2171 [1.0, 0.8, 0.1],
2172 [1.0, 1.2, -0.4],
2173 ];
2174 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2175 let hyper = DirectionalHyperParam::single_penalty(
2176 0,
2177 Array2::from_elem((x.nrows(), x.ncols()), 1e-3),
2178 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2179 None,
2180 None,
2181 )
2182 .expect("single-penalty hyper direction");
2183 let rho = array![0.0];
2184 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-8, true);
2185 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2186 let gradient = single_directional_tau_gradient(&state, &rho, hyper)
2187 .expect("Firth design-moving directional gradient should use analytic TK propagation");
2188 assert!(gradient.is_finite(), "gradient={gradient}");
2189 let x_tau = Array2::from_elem((x.nrows(), x.ncols()), 1e-3);
2190 let s_tau = Array2::<f64>::zeros((x.ncols(), x.ncols()));
2191 let fd = fd_directional_tau_cost_gradient(&y, &w, &x, &s0, &cfg, &rho, &x_tau, &s_tau);
2192 let rel = (gradient - fd).abs() / gradient.abs().max(fd.abs()).max(1.0e-10);
2193 assert!(
2194 rel < 2.0e-2,
2195 "Firth design-moving directional gradient mismatch: analytic={gradient:.12e}, fd={fd:.12e}, rel={rel:.3e}"
2196 );
2197 }
2198
2199 #[test]
2200 pub(crate) fn firth_logit_hybrid_efs_accepts_full_tk_psi_gradient() {
2201 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
2202 let w = Array1::<f64>::ones(y.len());
2203 let x = array![
2204 [1.0, -1.1, 0.2],
2205 [1.0, -0.6, -0.3],
2206 [1.0, -0.1, 0.5],
2207 [1.0, 0.3, -0.7],
2208 [1.0, 0.8, 0.1],
2209 [1.0, 1.2, -0.4],
2210 ];
2211 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2212 let hyper_dirs = vec![
2213 DirectionalHyperParam::single_penalty(
2214 0,
2215 Array2::from_shape_fn((x.nrows(), x.ncols()), |(i, j)| {
2216 1e-3 * ((i + 1) as f64) * ((j + 2) as f64)
2217 }),
2218 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2219 None,
2220 None,
2221 )
2222 .expect("design-moving hyper direction"),
2223 ];
2224 let rho = array![0.0];
2225 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-8, true);
2226 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2227
2228 let full = state
2229 .evaluate_unified_with_psi_ext(
2230 &rho,
2231 None,
2232 crate::estimate::reml::reml_outer_engine::EvalMode::ValueAndGradient,
2233 &hyper_dirs,
2234 )
2235 .expect("full Firth psi gradient should use analytic TK propagation");
2236 assert!(full.cost.is_finite(), "full cost={}", full.cost);
2237 let full_grad = full.gradient.expect("gradient should be present");
2238 assert!(
2239 full_grad.iter().all(|value| value.is_finite()),
2240 "full gradient={full_grad:?}"
2241 );
2242
2243 let efs = state
2244 .compute_efs_steps_with_psi_ext(&rho, &hyper_dirs)
2245 .expect("hybrid EFS should use analytic TK propagation");
2246 assert!(efs.cost.is_finite(), "efs cost={}", efs.cost);
2247 }
2248
2249 #[test]
2250 pub(crate) fn joint_hyperhessianwires_mixed_blocks() {
2251 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
2252 let w = Array1::<f64>::ones(y.len());
2253 let x = array![
2254 [1.0, -1.2, 0.3],
2255 [1.0, -0.8, -0.4],
2256 [1.0, -0.3, 0.7],
2257 [1.0, 0.1, -0.9],
2258 [1.0, 0.5, 0.2],
2259 [1.0, 0.9, -0.1],
2260 [1.0, 1.3, 0.8],
2261 [1.0, 1.7, -0.6],
2262 ];
2263 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
2264 let cfg =
2265 RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false).with_max_iterations(500);
2266 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2267 let rho = array![0.0];
2268 let theta = array![0.0, 0.0, 0.0];
2269 let hyper_dirs = vec![
2270 DirectionalHyperParam::single_penalty(
2271 0,
2272 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2273 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15],],
2274 None,
2275 None,
2276 )
2277 .expect("single-penalty hyper direction"),
2278 DirectionalHyperParam::single_penalty(
2279 0,
2280 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2281 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2282 None,
2283 None,
2284 )
2285 .expect("single-penalty hyper direction"),
2286 ];
2287
2288 let (_, _, h) =
2289 compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2290 .expect("joint hyper cost+gradient+hessian");
2291 assert_eq!(h.nrows(), theta.len());
2292 assert_eq!(h.ncols(), theta.len());
2293 assert!(h.iter().all(|v| v.is_finite()));
2294 for i in 0..h.nrows() {
2295 for j in 0..i {
2296 let diff = (h[[i, j]] - h[[j, i]]).abs();
2297 assert!(
2298 diff < 1e-6,
2299 "joint hessian asymmetry at ({i},{j}): {diff:.3e}"
2300 );
2301 }
2302 }
2303 let mixed_0 = h[[0, 1]];
2305 let mixed_1 = h[[0, 2]];
2306 assert!(
2307 mixed_0.is_finite() && mixed_1.is_finite(),
2308 "mixed blocks must be finite"
2309 );
2310 }
2311
2312 #[test]
2313 pub(crate) fn joint_tau_tau_linear_dirs_matchfd_reference_away_fromzero_psi() {
2314 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
2315 let w = Array1::<f64>::ones(y.len());
2316 let x = array![
2317 [1.0, -1.2, 0.3],
2318 [1.0, -0.8, -0.4],
2319 [1.0, -0.3, 0.7],
2320 [1.0, 0.1, -0.9],
2321 [1.0, 0.5, 0.2],
2322 [1.0, 0.9, -0.1],
2323 [1.0, 1.3, 0.8],
2324 [1.0, 1.7, -0.6],
2325 ];
2326 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
2327 let cfg =
2328 RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false).with_max_iterations(500);
2329 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2330 let rho = array![0.0];
2331 let psi = array![0.7, -0.4];
2332 let theta = array![rho[0], psi[0], psi[1]];
2333 let hyper_dirs = vec![
2334 DirectionalHyperParam::single_penalty(
2335 0,
2336 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2337 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15],],
2338 None,
2339 None,
2340 )
2341 .expect("linear tau direction"),
2342 DirectionalHyperParam::single_penalty(
2343 0,
2344 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2345 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2346 None,
2347 None,
2348 )
2349 .expect("linear tau direction"),
2350 ];
2351
2352 let (_, _, h_full) =
2353 compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2354 .expect("joint hyper cost+gradient+hessian");
2355 let h_tt_analytic = h_full.slice(s![rho.len().., rho.len()..]).to_owned();
2356
2357 let x_tau_mats: Vec<Array2<f64>> = vec![
2362 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2363 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2364 ];
2365 let s_tau_mats: Vec<Array2<f64>> = vec![
2366 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15]],
2367 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2368 ];
2369
2370 let h_ttfd = directional_tau_hessian_fd_reference(
2371 &y,
2372 &w,
2373 &x,
2374 &s0,
2375 &cfg,
2376 &rho,
2377 &hyper_dirs,
2378 &x_tau_mats,
2379 &s_tau_mats,
2380 );
2381
2382 let num = (&h_tt_analytic - &h_ttfd)
2383 .iter()
2384 .map(|v| v * v)
2385 .sum::<f64>()
2386 .sqrt();
2387 let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2388 let rel = num / den;
2389 assert!(
2390 rel < 1e-4,
2391 "linear-dir joint tau-tau block deviates from FD reference away from zero psi: rel={rel:.3e}, analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2392 );
2393 }
2394
2395 #[test]
2396 pub(crate) fn joint_hypervalidation_rejects_out_of_boundssecond_order_penalty_index() {
2397 let y = array![0.0, 1.0, 0.0, 1.0];
2414 let w = Array1::<f64>::ones(y.len());
2415 let x = array![
2416 [1.0, -0.5, 0.2],
2417 [1.0, -0.1, -0.3],
2418 [1.0, 0.4, 0.6],
2419 [1.0, 0.9, -0.2],
2420 ];
2421 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2422 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, true);
2423 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2424 let theta = array![0.0, 0.0];
2425 let hyper_dirs = vec![
2426 DirectionalHyperParam::new(
2427 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2428 vec![(0, Array2::<f64>::zeros((x.ncols(), x.ncols())))],
2429 None,
2430 Some(vec![Some(vec![(1, Array2::<f64>::eye(x.ncols()))])]),
2431 )
2432 .expect("hyper direction with invalid second-order penalty index"),
2433 ];
2434
2435 let msg = match compute_joint_hypercostgradienthessian(&state, &theta, 1, &hyper_dirs) {
2436 Ok(_) => panic!("invalid second-order penalty index should be rejected"),
2437 Err(err) => err.to_string(),
2438 };
2439 assert!(
2440 msg.contains("out of bounds") || msg.contains("penalty_index"),
2441 "unexpected validation error: {msg}"
2442 );
2443 }
2444
2445 #[test]
2446 pub(crate) fn joint_tau_tau_analytic_matchesfd_reference() {
2447 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
2448 let w = Array1::<f64>::ones(y.len());
2449 let x = array![
2450 [1.0, -1.2, 0.3],
2451 [1.0, -0.8, -0.4],
2452 [1.0, -0.3, 0.7],
2453 [1.0, 0.1, -0.9],
2454 [1.0, 0.5, 0.2],
2455 [1.0, 0.9, -0.1],
2456 [1.0, 1.3, 0.8],
2457 [1.0, 1.7, -0.6],
2458 ];
2459 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
2460 let cfg =
2461 RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false).with_max_iterations(500);
2462 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2463 let rho = array![0.0];
2464 let psi = array![0.0, 0.0];
2465 let hyper_dirs = vec![
2466 DirectionalHyperParam::single_penalty(
2467 0,
2468 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2469 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15],],
2470 None,
2471 None,
2472 )
2473 .expect("single-penalty hyper direction"),
2474 DirectionalHyperParam::single_penalty(
2475 0,
2476 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2477 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2478 None,
2479 None,
2480 )
2481 .expect("single-penalty hyper direction"),
2482 ];
2483
2484 let theta = {
2485 let mut t = Array1::<f64>::zeros(rho.len() + psi.len());
2486 t.slice_mut(s![..rho.len()]).assign(&rho);
2487 t.slice_mut(s![rho.len()..]).assign(&psi);
2488 t
2489 };
2490 let (_, _, h_full) =
2491 compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2492 .expect("joint hyper cost+gradient+hessian");
2493 let h_tt_analytic = h_full.slice(s![rho.len().., rho.len()..]).to_owned();
2494 assert_eq!(h_tt_analytic.nrows(), hyper_dirs.len());
2495 assert_eq!(h_tt_analytic.ncols(), hyper_dirs.len());
2496
2497 let x_tau_mats: Vec<Array2<f64>> = vec![
2502 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2503 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2504 ];
2505 let s_tau_mats: Vec<Array2<f64>> = vec![
2506 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15]],
2507 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2508 ];
2509
2510 let h_ttfd = directional_tau_hessian_fd_reference(
2511 &y,
2512 &w,
2513 &x,
2514 &s0,
2515 &cfg,
2516 &rho,
2517 &hyper_dirs,
2518 &x_tau_mats,
2519 &s_tau_mats,
2520 );
2521
2522 let num = (&h_tt_analytic - &h_ttfd)
2523 .iter()
2524 .map(|v| v * v)
2525 .sum::<f64>()
2526 .sqrt();
2527 let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2528 let rel = num / den;
2529 assert!(
2530 rel < 1e-4,
2531 "analytic tau-tau block deviates from FD reference: rel={rel:.3e}, analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2532 );
2533 }
2534
2535 pub(crate) struct GaussianRemlFixture {
2545 pub(crate) y: Array1<f64>,
2546 pub(crate) w: Array1<f64>,
2547 pub(crate) x: Array2<f64>,
2548 pub(crate) s0: Array2<f64>,
2549 pub(crate) cfg: RemlConfig,
2550 pub(crate) rho: Array1<f64>,
2551 pub(crate) x_tau_design: Array2<f64>,
2553 pub(crate) s_tau_penalty: Array2<f64>,
2555 }
2556
2557 impl GaussianRemlFixture {
2558 pub(crate) fn new() -> Self {
2559 let y = array![0.5, 1.2, -0.3, 0.8, 1.1, -0.6, 0.9, 0.1, -0.2, 0.7];
2560 let x = array![
2561 [1.0, -1.2, 0.3],
2562 [1.0, -0.8, -0.4],
2563 [1.0, -0.3, 0.7],
2564 [1.0, 0.1, -0.9],
2565 [1.0, 0.5, 0.2],
2566 [1.0, 0.9, -0.1],
2567 [1.0, 1.3, 0.8],
2568 [1.0, 1.7, -0.6],
2569 [1.0, -0.5, 0.5],
2570 [1.0, 0.3, -0.3],
2571 ];
2572 Self {
2573 w: Array1::<f64>::ones(y.len()),
2574 y,
2575 x: x.clone(),
2576 s0: array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9]],
2577 cfg: RemlConfig::external(gaussian_identity_glm_spec(), 1e-14, false),
2578 rho: array![0.0],
2579 x_tau_design: array![
2580 [0.0, 1e-3, -2e-3],
2581 [0.0, -3e-3, 1e-3],
2582 [0.0, 2e-3, 0.5e-3],
2583 [0.0, -1e-3, 3e-3],
2584 [0.0, 0.5e-3, -1e-3],
2585 [0.0, 1.5e-3, 2e-3],
2586 [0.0, -2e-3, -0.5e-3],
2587 [0.0, 3e-3, 1e-3],
2588 [0.0, -0.5e-3, 2e-3],
2589 [0.0, 1e-3, -1.5e-3],
2590 ],
2591 s_tau_penalty: array![[0.0, 0.0, 0.0], [0.0, 0.25, 0.04], [0.0, 0.04, 0.15]],
2592 }
2593 }
2594 }
2595
2596 impl LogitDesignMotionFixture for GaussianRemlFixture {
2597 fn y(&self) -> &Array1<f64> {
2598 &self.y
2599 }
2600 fn w(&self) -> &Array1<f64> {
2601 &self.w
2602 }
2603 fn x(&self) -> &Array2<f64> {
2604 &self.x
2605 }
2606 fn s0(&self) -> &Array2<f64> {
2607 &self.s0
2608 }
2609 fn cfg(&self) -> &RemlConfig {
2610 &self.cfg
2611 }
2612 fn rho(&self) -> &Array1<f64> {
2613 &self.rho
2614 }
2615 }
2616
2617 #[test]
2618 pub(crate) fn profiled_gaussian_design_moving_gradient_matches_fd() {
2619 let f = GaussianRemlFixture::new();
2620 let state = f.state();
2621 let s_tau = Array2::<f64>::zeros((3, 3));
2622 let hyper = DirectionalHyperParam::single_penalty(
2623 0,
2624 f.x_tau_design.clone(),
2625 s_tau.clone(),
2626 None,
2627 None,
2628 )
2629 .expect("design-moving hyper direction");
2630
2631 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2632 .expect("analytic directional gradient");
2633 let v_taufd = f.fd_directional_gradient(&f.x_tau_design, &s_tau);
2634
2635 let v_rel = (v_tau_analytic - v_taufd).abs() / v_taufd.abs().max(1e-10);
2636 assert!(
2637 v_rel < 1e-3,
2638 "Gaussian REML design-moving V_tau mismatch: rel={v_rel:.3e}, \
2639 analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
2640 );
2641 }
2642
2643 #[test]
2644 pub(crate) fn profiled_gaussian_penalty_only_gradient_matches_fd() {
2645 let f = GaussianRemlFixture::new();
2646 let state = f.state();
2647 let x_tau = Array2::<f64>::zeros(f.x.raw_dim());
2648 let hyper = DirectionalHyperParam::single_penalty(
2649 0,
2650 x_tau.clone(),
2651 f.s_tau_penalty.clone(),
2652 None,
2653 None,
2654 )
2655 .expect("penalty-only hyper direction");
2656
2657 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2658 .expect("analytic directional gradient");
2659 let v_taufd = f.fd_directional_gradient(&x_tau, &f.s_tau_penalty);
2660
2661 let v_rel = (v_tau_analytic - v_taufd).abs() / v_taufd.abs().max(1e-10);
2662 assert!(
2663 v_rel < 1e-3,
2664 "Gaussian REML penalty-only V_tau mismatch: rel={v_rel:.3e}, \
2665 analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
2666 );
2667 }
2668
2669 #[test]
2670 pub(crate) fn profiled_gaussian_joint_hessian_matches_fd() {
2671 let f = GaussianRemlFixture::new();
2674 let x_tau_0 = Array2::<f64>::zeros(f.x.raw_dim());
2675 let s_tau_0 = f.s_tau_penalty.clone();
2676 let x_tau_1 = f.x_tau_design.clone();
2677 let s_tau_1 = Array2::<f64>::zeros((3, 3));
2678
2679 let hyper_dirs = vec![
2680 DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
2681 .expect("penalty-only direction"),
2682 DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
2683 .expect("design-moving direction"),
2684 ];
2685
2686 let state = f.state();
2687 let mut theta = Array1::<f64>::zeros(f.rho.len() + hyper_dirs.len());
2688 theta.slice_mut(s![..f.rho.len()]).assign(&f.rho);
2689 let (_, _, h_full) =
2690 compute_joint_hypercostgradienthessian(&state, &theta, f.rho.len(), &hyper_dirs)
2691 .expect("joint cost+gradient+hessian");
2692 let h_tt_analytic = h_full.slice(s![f.rho.len().., f.rho.len()..]).to_owned();
2693
2694 let x_tau_mats = vec![x_tau_0.clone(), x_tau_1.clone()];
2697 let s_tau_mats = vec![s_tau_0.clone(), s_tau_1.clone()];
2698 let h_ttfd = directional_tau_hessian_fd_reference(
2699 &f.y,
2700 &f.w,
2701 &f.x,
2702 &f.s0,
2703 &f.cfg,
2704 &f.rho,
2705 &hyper_dirs,
2706 &x_tau_mats,
2707 &s_tau_mats,
2708 );
2709
2710 let num = (&h_tt_analytic - &h_ttfd)
2711 .iter()
2712 .map(|v| v * v)
2713 .sum::<f64>()
2714 .sqrt();
2715 let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2716 let rel = num / den;
2717 assert!(
2718 rel < 1e-4,
2719 "Gaussian REML tau-tau Hessian mismatch: rel={rel:.3e}, \
2720 analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2721 );
2722 }
2723
2724 #[test]
2738 pub(crate) fn logit_design_moving_gradient_matches_fd() {
2739 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
2740 let w = Array1::<f64>::ones(y.len());
2741 let x = array![
2742 [1.0, -1.2, 0.3],
2743 [1.0, -0.8, -0.4],
2744 [1.0, -0.3, 0.7],
2745 [1.0, 0.1, -0.9],
2746 [1.0, 0.5, 0.2],
2747 [1.0, 0.9, -0.1],
2748 [1.0, 1.3, 0.8],
2749 [1.0, 1.7, -0.6],
2750 [1.0, -0.5, 0.5],
2751 [1.0, 0.3, -0.3],
2752 ];
2753 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9]];
2754 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
2755 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2756 let rho = array![0.0];
2757
2758 let x_tau = array![
2760 [0.0, 1e-3, -2e-3],
2761 [0.0, -3e-3, 1e-3],
2762 [0.0, 2e-3, 0.5e-3],
2763 [0.0, -1e-3, 3e-3],
2764 [0.0, 0.5e-3, -1e-3],
2765 [0.0, 1.5e-3, 2e-3],
2766 [0.0, -2e-3, -0.5e-3],
2767 [0.0, 3e-3, 1e-3],
2768 [0.0, -0.5e-3, 2e-3],
2769 [0.0, 1e-3, -1.5e-3],
2770 ];
2771 let s_tau = Array2::<f64>::zeros((3, 3));
2772 let hyper =
2773 DirectionalHyperParam::single_penalty(0, x_tau.clone(), s_tau.clone(), None, None)
2774 .expect("design-moving hyper direction");
2775
2776 let v_tau_analytic = single_directional_tau_gradient(&state, &rho, hyper)
2777 .expect("analytic directional gradient");
2778
2779 let h = 2e-5;
2780 let x_plus = &x + &x_tau.mapv(|v| h * v);
2781 let x_minus = &x - &x_tau.mapv(|v| h * v);
2782 let state_plus = build_logit_state(&y, &w, &x_plus, &s0, &cfg);
2783 let state_minus = build_logit_state(&y, &w, &x_minus, &s0, &cfg);
2784 let v_plus = state_plus.compute_cost(&rho).expect("cost+");
2785 let v_minus = state_minus.compute_cost(&rho).expect("cost-");
2786 let v_taufd = (v_plus - v_minus) / (2.0 * h);
2787
2788 let v_rel = (v_tau_analytic - v_taufd).abs() / v_taufd.abs().max(1e-10);
2789 assert!(
2790 v_rel < 1e-3,
2791 "Logit REML design-moving V_tau mismatch: rel={v_rel:.3e}, \
2792 analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
2793 );
2794 }
2795
2796 #[test]
2797 pub(crate) fn logit_design_moving_hessian_matches_fd() {
2798 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
2803 let w = Array1::<f64>::ones(y.len());
2804 let x = array![
2805 [1.0, -1.2, 0.3],
2806 [1.0, -0.8, -0.4],
2807 [1.0, -0.3, 0.7],
2808 [1.0, 0.1, -0.9],
2809 [1.0, 0.5, 0.2],
2810 [1.0, 0.9, -0.1],
2811 [1.0, 1.3, 0.8],
2812 [1.0, 1.7, -0.6],
2813 [1.0, -0.5, 0.5],
2814 [1.0, 0.3, -0.3],
2815 ];
2816 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9]];
2817 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
2818 let rho = array![0.0];
2819
2820 let x_tau_0 = Array2::<f64>::zeros(x.raw_dim());
2822 let s_tau_0 = array![[0.0, 0.0, 0.0], [0.0, 0.25, 0.04], [0.0, 0.04, 0.15]];
2823 let x_tau_1 = array![
2824 [0.0, 1e-3, -2e-3],
2825 [0.0, -3e-3, 1e-3],
2826 [0.0, 2e-3, 0.5e-3],
2827 [0.0, -1e-3, 3e-3],
2828 [0.0, 0.5e-3, -1e-3],
2829 [0.0, 1.5e-3, 2e-3],
2830 [0.0, -2e-3, -0.5e-3],
2831 [0.0, 3e-3, 1e-3],
2832 [0.0, -0.5e-3, 2e-3],
2833 [0.0, 1e-3, -1.5e-3],
2834 ];
2835 let s_tau_1 = Array2::<f64>::zeros((3, 3));
2836
2837 let hyper_dirs = vec![
2838 DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
2839 .expect("penalty-only direction"),
2840 DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
2841 .expect("design-moving direction"),
2842 ];
2843
2844 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2845 let mut theta = Array1::<f64>::zeros(rho.len() + hyper_dirs.len());
2846 theta.slice_mut(s![..rho.len()]).assign(&rho);
2847 let (_, _, h_full) =
2848 compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2849 .expect("joint cost+gradient+hessian");
2850 let h_tt_analytic = h_full.slice(s![rho.len().., rho.len()..]).to_owned();
2851
2852 let x_tau_mats = vec![x_tau_0.clone(), x_tau_1.clone()];
2853 let s_tau_mats = vec![s_tau_0.clone(), s_tau_1.clone()];
2854 let h_ttfd = directional_tau_hessian_fd_reference(
2855 &y,
2856 &w,
2857 &x,
2858 &s0,
2859 &cfg,
2860 &rho,
2861 &hyper_dirs,
2862 &x_tau_mats,
2863 &s_tau_mats,
2864 );
2865
2866 let num = (&h_tt_analytic - &h_ttfd)
2867 .iter()
2868 .map(|v| v * v)
2869 .sum::<f64>()
2870 .sqrt();
2871 let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2872 let rel = num / den;
2873 assert!(
2874 rel < 1e-4,
2875 "Logit REML design-moving tau-tau Hessian mismatch: rel={rel:.3e}, \
2876 analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2877 );
2878 }
2879
2880 pub(crate) struct BinomialLogitDesignMotionFixture {
2890 pub(crate) y: Array1<f64>,
2891 pub(crate) w: Array1<f64>,
2892 pub(crate) x: Array2<f64>,
2893 pub(crate) s0: Array2<f64>,
2894 pub(crate) cfg: RemlConfig,
2895 pub(crate) rho: Array1<f64>,
2896 pub(crate) x_tau_design: Array2<f64>,
2898 pub(crate) s_tau_penalty: Array2<f64>,
2900 }
2901
2902 impl BinomialLogitDesignMotionFixture {
2903 pub(crate) fn new() -> Self {
2904 let y = array![
2906 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0,
2907 1.0, 1.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0
2908 ];
2909 let x = array![
2911 [1.0, -1.50, 0.42, 0.88, -0.31],
2912 [1.0, -1.12, -0.65, 0.14, 1.23],
2913 [1.0, -0.80, 1.10, -0.53, 0.07],
2914 [1.0, -0.55, -0.22, 1.40, -0.90],
2915 [1.0, -0.30, 0.73, -1.05, 0.44],
2916 [1.0, -0.05, -1.33, 0.60, 0.81],
2917 [1.0, 0.18, 0.55, -0.27, -1.15],
2918 [1.0, 0.42, -0.90, 1.12, 0.33],
2919 [1.0, 0.70, 1.28, -0.78, -0.56],
2920 [1.0, 0.95, -0.18, 0.45, 1.40],
2921 [1.0, 1.20, 0.66, -1.30, -0.02],
2922 [1.0, 1.45, -1.05, 0.22, 0.68],
2923 [1.0, -1.35, 0.90, 0.55, -0.43],
2924 [1.0, -0.98, -0.40, -0.88, 1.05],
2925 [1.0, -0.62, 1.42, 0.30, -0.70],
2926 [1.0, -0.28, -0.77, -1.18, 0.52],
2927 [1.0, 0.05, 0.15, 0.95, -1.35],
2928 [1.0, 0.33, -1.20, -0.40, 0.18],
2929 [1.0, 0.60, 0.82, 1.25, -0.85],
2930 [1.0, 0.88, -0.50, -0.65, 1.10],
2931 [1.0, 1.15, 1.05, 0.10, -0.22],
2932 [1.0, -1.22, -0.95, 0.72, 0.90],
2933 [1.0, -0.75, 0.38, -1.42, 0.15],
2934 [1.0, -0.42, -1.15, 0.50, -1.08],
2935 [1.0, -0.10, 0.60, -0.15, 0.75],
2936 [1.0, 0.25, -0.28, 1.05, -0.48],
2937 [1.0, 0.52, 1.35, -0.92, 0.30],
2938 [1.0, 0.80, -0.70, 0.38, 1.20],
2939 [1.0, 1.08, 0.48, -0.60, -0.95],
2940 [1.0, 1.35, -0.55, 0.85, 0.42]
2941 ];
2942 let s0 = array![
2944 [0.0, 0.0, 0.0, 0.0, 0.0],
2945 [0.0, 1.40, 0.15, 0.05, -0.10],
2946 [0.0, 0.15, 1.10, -0.20, 0.08],
2947 [0.0, 0.05, -0.20, 0.95, 0.12],
2948 [0.0, -0.10, 0.08, 0.12, 1.25]
2949 ];
2950 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
2951 let x_tau_design = array![
2954 [0.0, 1.2e-3, -0.8e-3, 0.5e-3, -1.5e-3],
2955 [0.0, -2.0e-3, 1.4e-3, -0.3e-3, 0.9e-3],
2956 [0.0, 0.6e-3, -1.1e-3, 1.8e-3, -0.4e-3],
2957 [0.0, -1.3e-3, 0.7e-3, -1.0e-3, 2.1e-3],
2958 [0.0, 0.9e-3, -0.5e-3, 0.2e-3, -0.8e-3],
2959 [0.0, -0.4e-3, 1.8e-3, -1.5e-3, 0.3e-3],
2960 [0.0, 1.5e-3, -1.3e-3, 0.8e-3, -1.1e-3],
2961 [0.0, -0.7e-3, 0.4e-3, -2.0e-3, 1.6e-3],
2962 [0.0, 2.2e-3, -0.9e-3, 1.3e-3, -0.6e-3],
2963 [0.0, -1.0e-3, 1.6e-3, -0.7e-3, 0.5e-3],
2964 [0.0, 0.3e-3, -2.1e-3, 1.1e-3, -1.8e-3],
2965 [0.0, -1.8e-3, 0.2e-3, -0.4e-3, 1.3e-3],
2966 [0.0, 1.1e-3, -1.5e-3, 2.0e-3, -0.2e-3],
2967 [0.0, -0.5e-3, 0.9e-3, -1.2e-3, 0.7e-3],
2968 [0.0, 1.7e-3, -0.3e-3, 0.6e-3, -2.0e-3],
2969 [0.0, -1.4e-3, 1.1e-3, -0.9e-3, 0.4e-3],
2970 [0.0, 0.8e-3, -1.7e-3, 1.5e-3, -0.1e-3],
2971 [0.0, -0.2e-3, 0.6e-3, -1.8e-3, 1.0e-3],
2972 [0.0, 1.4e-3, -0.4e-3, 0.3e-3, -1.3e-3],
2973 [0.0, -0.9e-3, 2.0e-3, -0.5e-3, 0.8e-3],
2974 [0.0, 0.5e-3, -1.0e-3, 1.6e-3, -0.7e-3],
2975 [0.0, -2.1e-3, 0.3e-3, -0.8e-3, 1.5e-3],
2976 [0.0, 0.7e-3, -1.8e-3, 0.9e-3, -0.3e-3],
2977 [0.0, -0.6e-3, 1.3e-3, -2.2e-3, 1.1e-3],
2978 [0.0, 1.9e-3, -0.7e-3, 0.4e-3, -0.9e-3],
2979 [0.0, -1.1e-3, 0.5e-3, -1.4e-3, 2.2e-3],
2980 [0.0, 0.4e-3, -1.6e-3, 1.2e-3, -0.5e-3],
2981 [0.0, -1.6e-3, 0.8e-3, -0.1e-3, 0.6e-3],
2982 [0.0, 1.3e-3, -2.2e-3, 0.7e-3, -1.4e-3],
2983 [0.0, -0.3e-3, 1.0e-3, -1.6e-3, 1.8e-3]
2984 ];
2985 let s_tau_penalty = array![
2987 [0.0, 0.0, 0.0, 0.0, 0.0],
2988 [0.0, 0.30, 0.05, -0.02, 0.04],
2989 [0.0, 0.05, 0.22, 0.03, -0.01],
2990 [0.0, -0.02, 0.03, 0.18, 0.06],
2991 [0.0, 0.04, -0.01, 0.06, 0.26]
2992 ];
2993 Self {
2994 w: Array1::<f64>::ones(y.len()),
2995 y,
2996 x,
2997 s0,
2998 cfg,
2999 rho: array![0.0],
3000 x_tau_design,
3001 s_tau_penalty,
3002 }
3003 }
3004 }
3005
3006 impl LogitDesignMotionFixture for BinomialLogitDesignMotionFixture {
3007 fn y(&self) -> &Array1<f64> {
3008 &self.y
3009 }
3010 fn w(&self) -> &Array1<f64> {
3011 &self.w
3012 }
3013 fn x(&self) -> &Array2<f64> {
3014 &self.x
3015 }
3016 fn s0(&self) -> &Array2<f64> {
3017 &self.s0
3018 }
3019 fn cfg(&self) -> &RemlConfig {
3020 &self.cfg
3021 }
3022 fn rho(&self) -> &Array1<f64> {
3023 &self.rho
3024 }
3025 }
3026
3027 #[test]
3030 pub(crate) fn binomial_logit_n30_design_moving_gradient_matches_fd() {
3031 let f = BinomialLogitDesignMotionFixture::new();
3038 let state = f.state();
3039 let s_tau = Array2::<f64>::zeros((5, 5));
3040 let hyper = DirectionalHyperParam::single_penalty(
3041 0,
3042 f.x_tau_design.clone(),
3043 s_tau.clone(),
3044 None,
3045 None,
3046 )
3047 .expect("design-moving hyper direction");
3048
3049 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
3050 .expect("analytic directional gradient");
3051 let v_tau_fd = f.fd_directional_gradient(&f.x_tau_design, &s_tau);
3052
3053 let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3054 assert!(
3055 v_rel < 1e-3,
3056 "Binomial-logit n=30 design-moving gradient mismatch: rel={v_rel:.3e}, \
3057 analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3058 );
3059 }
3060
3061 #[test]
3062 pub(crate) fn binomial_logit_n30_penalty_only_gradient_matches_fd() {
3063 let f = BinomialLogitDesignMotionFixture::new();
3068 let state = f.state();
3069 let x_tau = Array2::<f64>::zeros(f.x.raw_dim());
3070 let hyper = DirectionalHyperParam::single_penalty(
3071 0,
3072 x_tau.clone(),
3073 f.s_tau_penalty.clone(),
3074 None,
3075 None,
3076 )
3077 .expect("penalty-only hyper direction");
3078
3079 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
3080 .expect("analytic directional gradient");
3081 let v_tau_fd = f.fd_directional_gradient(&x_tau, &f.s_tau_penalty);
3082
3083 let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3084 assert!(
3085 v_rel < 1e-3,
3086 "Binomial-logit n=30 penalty-only gradient mismatch: rel={v_rel:.3e}, \
3087 analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3088 );
3089 }
3090
3091 #[test]
3092 pub(crate) fn binomial_logit_n30_joint_design_penalty_gradient_matches_fd() {
3093 let f = BinomialLogitDesignMotionFixture::new();
3098 let state = f.state();
3099 let hyper = DirectionalHyperParam::single_penalty(
3100 0,
3101 f.x_tau_design.clone(),
3102 f.s_tau_penalty.clone(),
3103 None,
3104 None,
3105 )
3106 .expect("joint design+penalty hyper direction");
3107
3108 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
3109 .expect("analytic directional gradient");
3110 let v_tau_fd = f.fd_directional_gradient(&f.x_tau_design, &f.s_tau_penalty);
3111
3112 let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3113 assert!(
3114 v_rel < 1e-3,
3115 "Binomial-logit n=30 joint design+penalty gradient mismatch: rel={v_rel:.3e}, \
3116 analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3117 );
3118 }
3119
3120 #[test]
3121 pub(crate) fn binomial_logit_n30_design_moving_hessian_matches_fd() {
3122 let f = BinomialLogitDesignMotionFixture::new();
3127 let x_tau_0 = Array2::<f64>::zeros(f.x.raw_dim());
3128 let s_tau_0 = f.s_tau_penalty.clone();
3129 let x_tau_1 = f.x_tau_design.clone();
3130 let s_tau_1 = Array2::<f64>::zeros((5, 5));
3131
3132 let hyper_dirs = vec![
3133 DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
3134 .expect("penalty-only direction"),
3135 DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
3136 .expect("design-moving direction"),
3137 ];
3138
3139 let state = f.state();
3140 let mut theta = Array1::<f64>::zeros(f.rho.len() + hyper_dirs.len());
3141 theta.slice_mut(s![..f.rho.len()]).assign(&f.rho);
3142 let (_, _, h_full) =
3143 compute_joint_hypercostgradienthessian(&state, &theta, f.rho.len(), &hyper_dirs)
3144 .expect("joint cost+gradient+hessian");
3145 let h_tt_analytic = h_full.slice(s![f.rho.len().., f.rho.len()..]).to_owned();
3146
3147 let x_tau_mats = vec![x_tau_0.clone(), x_tau_1.clone()];
3148 let s_tau_mats = vec![s_tau_0.clone(), s_tau_1.clone()];
3149 let h_tt_fd = directional_tau_hessian_fd_reference(
3150 &f.y,
3151 &f.w,
3152 &f.x,
3153 &f.s0,
3154 &f.cfg,
3155 &f.rho,
3156 &hyper_dirs,
3157 &x_tau_mats,
3158 &s_tau_mats,
3159 );
3160
3161 let num = (&h_tt_analytic - &h_tt_fd)
3162 .iter()
3163 .map(|v| v * v)
3164 .sum::<f64>()
3165 .sqrt();
3166 let den = h_tt_fd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
3167 let rel = num / den;
3168 assert!(
3169 rel < 1e-4,
3170 "Binomial-logit n=30 tau-tau Hessian mismatch: rel={rel:.3e}, \
3171 analytic={h_tt_analytic:?}, fd={h_tt_fd:?}"
3172 );
3173 }
3174
3175 #[test]
3176 pub(crate) fn binomial_logit_n30_nonzero_rho_design_moving_gradient_matches_fd() {
3177 let f = BinomialLogitDesignMotionFixture::new();
3181 let rho = array![1.5];
3182 let s_tau = Array2::<f64>::zeros((5, 5));
3183
3184 let state = f.state();
3185 let hyper = DirectionalHyperParam::single_penalty(
3186 0,
3187 f.x_tau_design.clone(),
3188 s_tau.clone(),
3189 None,
3190 None,
3191 )
3192 .expect("design-moving hyper direction");
3193
3194 let v_tau_analytic = single_directional_tau_gradient(&state, &rho, hyper)
3195 .expect("analytic directional gradient");
3196
3197 let h = 2e-5;
3199 let (state_plus, state_minus) = f.state_perturbed(&f.x_tau_design, &s_tau, h);
3200 let v_plus = state_plus.compute_cost(&rho).expect("cost+");
3201 let v_minus = state_minus.compute_cost(&rho).expect("cost-");
3202 let v_tau_fd = (v_plus - v_minus) / (2.0 * h);
3203
3204 let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3205 assert!(
3206 v_rel < 1e-3,
3207 "Binomial-logit n=30 rho=1.5 design-moving gradient mismatch: rel={v_rel:.3e}, \
3208 analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3209 );
3210 }
3211
3212 #[test]
3213 pub(crate) fn binomial_logit_n30_rank_deficient_hessian_matches_cost_fd() {
3214 let f = BinomialLogitDesignMotionFixture::new();
3249 let x_tau_0 = Array2::<f64>::zeros(f.x.raw_dim());
3250 let s_tau_0 = f.s_tau_penalty.clone();
3251 let x_tau_1 = f.x_tau_design.clone();
3252 let s_tau_1 = Array2::<f64>::zeros((5, 5));
3253
3254 let hyper_dirs = vec![
3255 DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
3256 .expect("penalty-only direction"),
3257 DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
3258 .expect("design-moving direction"),
3259 ];
3260
3261 let state = f.state();
3263 let mut theta = Array1::<f64>::zeros(f.rho.len() + hyper_dirs.len());
3264 theta.slice_mut(s![..f.rho.len()]).assign(&f.rho);
3265 let (_, _, h_full) =
3266 compute_joint_hypercostgradienthessian(&state, &theta, f.rho.len(), &hyper_dirs)
3267 .expect("joint cost+gradient+hessian");
3268 let h_tt_analytic = h_full.slice(s![f.rho.len().., f.rho.len()..]).to_owned();
3269
3270 const TARGET_PHYSICAL_STEP: f64 = 1e-5;
3274 let x_tau_mats = [&x_tau_0, &x_tau_1];
3275 let s_tau_mats = [&s_tau_0, &s_tau_1];
3276 let steps: [f64; 2] = {
3277 let mut steps = [0.0; 2];
3278 for (j, step) in steps.iter_mut().enumerate() {
3279 let scale = x_tau_mats[j]
3280 .iter()
3281 .chain(s_tau_mats[j].iter())
3282 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
3283 *step = if scale > 0.0 {
3284 TARGET_PHYSICAL_STEP / scale
3285 } else {
3286 TARGET_PHYSICAL_STEP
3287 };
3288 }
3289 steps
3290 };
3291
3292 let eval_cost = |a: f64, b: f64| -> f64 {
3294 let x_eval = &f.x
3295 + &x_tau_mats[0].mapv(|v| a * steps[0] * v)
3296 + &x_tau_mats[1].mapv(|v| b * steps[1] * v);
3297 let s_eval = &f.s0
3298 + &s_tau_mats[0].mapv(|v| a * steps[0] * v)
3299 + &s_tau_mats[1].mapv(|v| b * steps[1] * v);
3300 let st = build_logit_state(&f.y, &f.w, &x_eval, &s_eval, &f.cfg);
3301 st.compute_cost(&f.rho).expect("cost eval")
3302 };
3303
3304 let v_00 = eval_cost(0.0, 0.0);
3305 let v_p0 = eval_cost(1.0, 0.0);
3306 let v_m0 = eval_cost(-1.0, 0.0);
3307 let v_0p = eval_cost(0.0, 1.0);
3308 let v_0m = eval_cost(0.0, -1.0);
3309 let v_pp = eval_cost(1.0, 1.0);
3310 let v_pm = eval_cost(1.0, -1.0);
3311 let v_mp = eval_cost(-1.0, 1.0);
3312 let v_mm = eval_cost(-1.0, -1.0);
3313
3314 let h00_fd = (v_p0 - 2.0 * v_00 + v_m0) / (steps[0] * steps[0]);
3315 let h11_fd = (v_0p - 2.0 * v_00 + v_0m) / (steps[1] * steps[1]);
3316 let h01_fd = (v_pp - v_pm - v_mp + v_mm) / (4.0 * steps[0] * steps[1]);
3317
3318 let h_tt_fd = array![[h00_fd, h01_fd], [h01_fd, h11_fd]];
3319
3320 let num = (&h_tt_analytic - &h_tt_fd)
3321 .iter()
3322 .map(|v| v * v)
3323 .sum::<f64>()
3324 .sqrt();
3325 let den = h_tt_fd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
3326 let rel = num / den;
3327
3328 assert!(
3329 rel < 3e-3,
3330 "Binomial-logit n=30 rank-deficient Hessian vs cost-FD mismatch: rel={rel:.3e}, \
3331 analytic={h_tt_analytic:?}, fd={h_tt_fd:?}"
3332 );
3333 }
3334}
3335
3336#[derive(Clone, Copy, Debug)]
3337pub(crate) enum RemlGeometry {
3338 DenseSpectral,
3339 SparseExactSpd,
3340}
3341
3342trait PenalizedGeometry {
3343 fn backend_kind(&self) -> GeometryBackendKind;
3344}
3345
3346#[derive(Clone)]
3347pub(crate) enum DerivativeMatrixStorage {
3348 Dense(Array2<f64>),
3349 Zero(ZeroDerivativeMatrix),
3350 Embedded(EmbeddedDerivativeMatrix),
3351 Implicit(ImplicitDerivativeOp),
3352 LatentCoord(LatentCoordDerivativeOp),
3353}
3354
3355trait DerivativeStorageBackend {
3367 fn resident_byte_count(&self) -> usize;
3368 fn design_nrows(&self) -> usize;
3369 fn design_ncols(&self) -> usize;
3370 fn penalty_dim(&self) -> usize;
3371 fn uses_implicit_storage(&self) -> bool;
3372 fn any_nonzero(&self) -> bool;
3373 fn materialize(&self) -> Array2<f64>;
3374 fn implicit_first_axis_info(
3375 &self,
3376 ) -> Option<(
3377 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3378 usize,
3379 )>;
3380 fn implicit_axis_count_hint(&self) -> Option<usize>;
3381 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError>;
3382 fn design_transpose_mul_original(
3383 &self,
3384 v: &Array1<f64>,
3385 ) -> Result<Array1<f64>, EstimationError>;
3386 fn design_transformed(
3387 &self,
3388 qs: &Array2<f64>,
3389 free_basis_opt: Option<&Array2<f64>>,
3390 ) -> Result<Array2<f64>, EstimationError>;
3391 fn design_transformed_forward_mul(
3395 &self,
3396 qs: &Array2<f64>,
3397 free_basis_opt: Option<&Array2<f64>>,
3398 u: &Array1<f64>,
3399 ) -> Result<Array1<f64>, EstimationError> {
3400 Ok(self.design_transformed(qs, free_basis_opt)?.dot(u))
3401 }
3402 fn design_transformed_transpose_mul(
3405 &self,
3406 qs: &Array2<f64>,
3407 free_basis_opt: Option<&Array2<f64>>,
3408 v: &Array1<f64>,
3409 ) -> Result<Array1<f64>, EstimationError> {
3410 Ok(self.design_transformed(qs, free_basis_opt)?.t().dot(v))
3411 }
3412 fn penalty_transformed(
3413 &self,
3414 qs: &Array2<f64>,
3415 free_basis_opt: Option<&Array2<f64>>,
3416 ) -> Result<Array2<f64>, EstimationError>;
3417 fn penalty_scaled_add_to(
3418 &self,
3419 target: &mut Array2<f64>,
3420 amp: f64,
3421 ) -> Result<(), EstimationError>;
3422}
3423
3424macro_rules! storage_dispatch {
3429 ($scrutinee:expr, $backend:ident => $body:expr) => {
3430 match $scrutinee {
3431 DerivativeMatrixStorage::Dense($backend) => $body,
3432 DerivativeMatrixStorage::Zero($backend) => $body,
3433 DerivativeMatrixStorage::Embedded($backend) => $body,
3434 DerivativeMatrixStorage::Implicit($backend) => $body,
3435 DerivativeMatrixStorage::LatentCoord($backend) => $body,
3436 }
3437 };
3438}
3439
3440#[derive(Clone)]
3441pub(crate) struct ZeroDerivativeMatrix {
3442 rows: usize,
3443 cols: usize,
3444}
3445
3446impl ZeroDerivativeMatrix {
3447 pub(crate) fn new(rows: usize, cols: usize) -> Self {
3448 Self { rows, cols }
3449 }
3450}
3451
3452#[derive(Clone, Copy, Debug)]
3454pub enum ImplicitDerivLevel {
3455 First(usize),
3457 SecondDiag(usize),
3459 SecondCross(usize, usize),
3461}
3462
3463#[derive(Clone)]
3466pub(crate) struct ImplicitDerivativeOp {
3467 pub(crate) operator: std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3468 pub(crate) level: ImplicitDerivLevel,
3469 pub(crate) global_range: Range<usize>,
3470 pub(crate) total_dim: usize,
3471 pub(crate) cached_dense: std::sync::Arc<gam_runtime::resource::RayonSafeOnce<Array2<f64>>>,
3481}
3482
3483#[derive(Clone)]
3484pub(crate) struct LatentCoordDerivativeOp {
3485 pub(crate) operator: std::sync::Arc<gam_terms::basis::LatentCoordDesignDerivative>,
3486 pub(crate) flat_axis: usize,
3487 pub(crate) global_range: Range<usize>,
3488 pub(crate) total_dim: usize,
3489 pub(crate) cached_dense: std::sync::Arc<gam_runtime::resource::RayonSafeOnce<Array2<f64>>>,
3490}
3491
3492impl LatentCoordDerivativeOp {
3493 pub(crate) fn materialize_local(&self) -> Array2<f64> {
3494 self.operator.materialize_axis(self.flat_axis).expect(
3495 "radial scalar evaluation failed during latent-coordinate derivative materialization",
3496 )
3497 }
3498
3499 pub(crate) fn materialize_dense(&self) -> &Array2<f64> {
3500 self.cached_dense.get_or_compute(|| {
3501 let local = self.materialize_local();
3502 let mut out = Array2::<f64>::zeros((local.nrows(), self.total_dim));
3503 out.slice_mut(s![.., self.global_range.clone()])
3504 .assign(&local);
3505 out
3506 })
3507 }
3508
3509 pub(crate) fn nrows(&self) -> usize {
3510 self.operator.n_data()
3511 }
3512
3513 pub(crate) fn ncols(&self) -> usize {
3514 self.total_dim
3515 }
3516
3517 pub(crate) fn transpose_mul(&self, v: &Array1<f64>) -> Array1<f64> {
3518 let local = self
3519 .operator
3520 .transpose_mul_axis(self.flat_axis, &v.view())
3521 .expect(
3522 "radial scalar evaluation failed during latent-coordinate derivative transpose_mul",
3523 );
3524 let mut out = Array1::<f64>::zeros(self.total_dim);
3525 out.slice_mut(s![self.global_range.clone()]).assign(&local);
3526 out
3527 }
3528
3529 pub(crate) fn forward_mul(&self, u: &Array1<f64>) -> Array1<f64> {
3530 let u_local = u.slice(s![self.global_range.clone()]).to_owned();
3531 self.operator
3532 .forward_mul_axis(self.flat_axis, &u_local.view())
3533 .expect(
3534 "radial scalar evaluation failed during latent-coordinate derivative forward_mul",
3535 )
3536 }
3537}
3538
3539impl ImplicitDerivativeOp {
3540 pub(crate) fn materialize_local(&self) -> Array2<f64> {
3541 match self.level {
3542 ImplicitDerivLevel::First(axis) => self.operator.materialize_first(axis).expect(
3543 "radial scalar evaluation failed during implicit derivative materialization",
3544 ),
3545 ImplicitDerivLevel::SecondDiag(axis) => {
3546 self.operator.materialize_second_diag(axis).expect(
3547 "radial scalar evaluation failed during implicit derivative materialization",
3548 )
3549 }
3550 ImplicitDerivLevel::SecondCross(d, e) => {
3551 self.operator.materialize_second_cross(d, e).expect(
3552 "radial scalar evaluation failed during implicit derivative materialization",
3553 )
3554 }
3555 }
3556 }
3557
3558 pub(crate) fn materialize_dense(&self) -> &Array2<f64> {
3559 self.cached_dense.get_or_compute(|| {
3560 let local = self.materialize_local();
3561 let mut out = Array2::<f64>::zeros((local.nrows(), self.total_dim));
3562 out.slice_mut(s![.., self.global_range.clone()])
3563 .assign(&local);
3564 out
3565 })
3566 }
3567
3568 pub(crate) fn nrows(&self) -> usize {
3569 self.operator.n_data()
3570 }
3571
3572 pub(crate) fn ncols(&self) -> usize {
3573 self.total_dim
3574 }
3575
3576 pub(crate) fn transpose_mul(&self, v: &Array1<f64>) -> Array1<f64> {
3577 let local = match self.level {
3578 ImplicitDerivLevel::First(axis) => self
3579 .operator
3580 .transpose_mul(axis, &v.view())
3581 .expect("radial scalar evaluation failed during implicit derivative transpose_mul"),
3582 ImplicitDerivLevel::SecondDiag(axis) => self
3583 .operator
3584 .transpose_mul_second_diag(axis, &v.view())
3585 .expect("radial scalar evaluation failed during implicit derivative transpose_mul"),
3586 ImplicitDerivLevel::SecondCross(d, e) => self
3587 .operator
3588 .transpose_mul_second_cross(d, e, &v.view())
3589 .expect("radial scalar evaluation failed during implicit derivative transpose_mul"),
3590 };
3591 let mut out = Array1::<f64>::zeros(self.total_dim);
3592 out.slice_mut(s![self.global_range.clone()]).assign(&local);
3593 out
3594 }
3595
3596 pub(crate) fn forward_mul(&self, u: &Array1<f64>) -> Array1<f64> {
3597 let u_local = u.slice(s![self.global_range.clone()]).to_owned();
3598 match self.level {
3599 ImplicitDerivLevel::First(axis) => self
3600 .operator
3601 .forward_mul(axis, &u_local.view())
3602 .expect("radial scalar evaluation failed during implicit derivative forward_mul"),
3603 ImplicitDerivLevel::SecondDiag(axis) => self
3604 .operator
3605 .forward_mul_second_diag(axis, &u_local.view())
3606 .expect("radial scalar evaluation failed during implicit derivative forward_mul"),
3607 ImplicitDerivLevel::SecondCross(d, e) => self
3608 .operator
3609 .forward_mul_second_cross(d, e, &u_local.view())
3610 .expect("radial scalar evaluation failed during implicit derivative forward_mul"),
3611 }
3612 }
3613}
3614
3615#[derive(Clone)]
3616pub(crate) struct EmbeddedDerivativeMatrix {
3617 pub(crate) local: Array2<f64>,
3618 pub(crate) global_range: Range<usize>,
3619 pub(crate) total_dim: usize,
3620}
3621
3622impl EmbeddedDerivativeMatrix {
3623 pub(crate) fn new(local: Array2<f64>, global_range: Range<usize>, total_dim: usize) -> Self {
3624 Self {
3625 local,
3626 global_range,
3627 total_dim,
3628 }
3629 }
3630}
3631
3632impl DerivativeStorageBackend for Array2<f64> {
3633 fn resident_byte_count(&self) -> usize {
3634 self.len().saturating_mul(std::mem::size_of::<f64>())
3635 }
3636 fn design_nrows(&self) -> usize {
3637 Array2::nrows(self)
3638 }
3639 fn design_ncols(&self) -> usize {
3640 Array2::ncols(self)
3641 }
3642 fn penalty_dim(&self) -> usize {
3643 Array2::nrows(self)
3644 }
3645 fn uses_implicit_storage(&self) -> bool {
3646 false
3647 }
3648 fn any_nonzero(&self) -> bool {
3649 self.iter().any(|v| *v != 0.0)
3650 }
3651 fn materialize(&self) -> Array2<f64> {
3652 self.clone()
3653 }
3654 fn implicit_first_axis_info(
3655 &self,
3656 ) -> Option<(
3657 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3658 usize,
3659 )> {
3660 None
3661 }
3662 fn implicit_axis_count_hint(&self) -> Option<usize> {
3663 None
3664 }
3665
3666 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3667 if Array2::ncols(self) != u.len() {
3668 crate::bail_invalid_estim!(
3669 "dense hyper design derivative forward_mul_original width mismatch: matrix={}x{}, vector={}",
3670 Array2::nrows(self),
3671 Array2::ncols(self),
3672 u.len()
3673 );
3674 }
3675 Ok(self.dot(u))
3676 }
3677
3678 fn design_transpose_mul_original(
3679 &self,
3680 v: &Array1<f64>,
3681 ) -> Result<Array1<f64>, EstimationError> {
3682 if Array2::nrows(self) != v.len() {
3683 crate::bail_invalid_estim!(
3684 "dense hyper design derivative transpose_mul_original height mismatch: matrix={}x{}, vector={}",
3685 Array2::nrows(self),
3686 Array2::ncols(self),
3687 v.len()
3688 );
3689 }
3690 Ok(self.t().dot(v))
3691 }
3692
3693 fn design_transformed(
3694 &self,
3695 qs: &Array2<f64>,
3696 free_basis_opt: Option<&Array2<f64>>,
3697 ) -> Result<Array2<f64>, EstimationError> {
3698 Ok(gam_linalg::matrix::DenseRightProductView::new(self)
3699 .with_factor(qs)
3700 .with_optional_factor(free_basis_opt)
3701 .materialize())
3702 }
3703
3704 fn penalty_transformed(
3705 &self,
3706 qs: &Array2<f64>,
3707 free_basis_opt: Option<&Array2<f64>>,
3708 ) -> Result<Array2<f64>, EstimationError> {
3709 let mut transformed = qs.t().dot(self).dot(qs);
3710 if let Some(z) = free_basis_opt {
3711 transformed = z.t().dot(&transformed).dot(z);
3712 }
3713 Ok(transformed)
3714 }
3715
3716 fn penalty_scaled_add_to(
3717 &self,
3718 target: &mut Array2<f64>,
3719 amp: f64,
3720 ) -> Result<(), EstimationError> {
3721 if target.raw_dim() != self.raw_dim() {
3722 crate::bail_invalid_estim!(
3723 "dense hyper penalty derivative shape mismatch: target={}x{}, matrix={}x{}",
3724 target.nrows(),
3725 target.ncols(),
3726 Array2::nrows(self),
3727 Array2::ncols(self)
3728 );
3729 }
3730 target.scaled_add(amp, self);
3731 Ok(())
3732 }
3733}
3734
3735impl DerivativeStorageBackend for ZeroDerivativeMatrix {
3736 fn resident_byte_count(&self) -> usize {
3737 0
3738 }
3739 fn design_nrows(&self) -> usize {
3740 self.rows
3741 }
3742 fn design_ncols(&self) -> usize {
3743 self.cols
3744 }
3745 fn penalty_dim(&self) -> usize {
3746 self.cols
3747 }
3748 fn uses_implicit_storage(&self) -> bool {
3749 false
3750 }
3751 fn any_nonzero(&self) -> bool {
3752 false
3753 }
3754 fn materialize(&self) -> Array2<f64> {
3755 Array2::<f64>::zeros((self.rows, self.cols))
3756 }
3757 fn implicit_first_axis_info(
3758 &self,
3759 ) -> Option<(
3760 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3761 usize,
3762 )> {
3763 None
3764 }
3765 fn implicit_axis_count_hint(&self) -> Option<usize> {
3766 None
3767 }
3768
3769 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3770 if self.cols != u.len() {
3771 crate::bail_invalid_estim!(
3772 "zero hyper design derivative forward_mul_original width mismatch: matrix={}x{}, vector={}",
3773 self.rows,
3774 self.cols,
3775 u.len()
3776 );
3777 }
3778 Ok(Array1::<f64>::zeros(self.rows))
3779 }
3780
3781 fn design_transpose_mul_original(
3782 &self,
3783 v: &Array1<f64>,
3784 ) -> Result<Array1<f64>, EstimationError> {
3785 if self.rows != v.len() {
3786 crate::bail_invalid_estim!(
3787 "zero hyper design derivative transpose_mul_original height mismatch: matrix={}x{}, vector={}",
3788 self.rows,
3789 self.cols,
3790 v.len()
3791 );
3792 }
3793 Ok(Array1::<f64>::zeros(self.cols))
3794 }
3795
3796 fn design_transformed(
3797 &self,
3798 qs: &Array2<f64>,
3799 free_basis_opt: Option<&Array2<f64>>,
3800 ) -> Result<Array2<f64>, EstimationError> {
3801 if self.cols != qs.nrows() {
3802 crate::bail_invalid_estim!(
3803 "zero design derivative width mismatch: total_cols={}, qs rows={}",
3804 self.cols,
3805 qs.nrows()
3806 );
3807 }
3808 let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3809 Ok(Array2::<f64>::zeros((self.rows, cols)))
3810 }
3811
3812 fn design_transformed_forward_mul(
3813 &self,
3814 qs: &Array2<f64>,
3815 free_basis_opt: Option<&Array2<f64>>,
3816 u: &Array1<f64>,
3817 ) -> Result<Array1<f64>, EstimationError> {
3818 if self.cols != qs.nrows() {
3819 crate::bail_invalid_estim!(
3820 "zero design derivative width mismatch: total_cols={}, qs rows={}",
3821 self.cols,
3822 qs.nrows()
3823 );
3824 }
3825 let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3826 if u.len() != cols {
3827 crate::bail_invalid_estim!(
3828 "zero design derivative transformed forward width mismatch: expected {}, vector={}",
3829 cols,
3830 u.len()
3831 );
3832 }
3833 Ok(Array1::<f64>::zeros(self.rows))
3834 }
3835
3836 fn design_transformed_transpose_mul(
3837 &self,
3838 qs: &Array2<f64>,
3839 free_basis_opt: Option<&Array2<f64>>,
3840 v: &Array1<f64>,
3841 ) -> Result<Array1<f64>, EstimationError> {
3842 if self.rows != v.len() {
3843 crate::bail_invalid_estim!(
3844 "zero design derivative transpose height mismatch: matrix rows={}, vector={}",
3845 self.rows,
3846 v.len()
3847 );
3848 }
3849 if self.cols != qs.nrows() {
3850 crate::bail_invalid_estim!(
3851 "zero design derivative width mismatch: total_cols={}, qs rows={}",
3852 self.cols,
3853 qs.nrows()
3854 );
3855 }
3856 let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3857 Ok(Array1::<f64>::zeros(cols))
3858 }
3859
3860 fn penalty_transformed(
3861 &self,
3862 qs: &Array2<f64>,
3863 free_basis_opt: Option<&Array2<f64>>,
3864 ) -> Result<Array2<f64>, EstimationError> {
3865 if self.cols != qs.nrows() {
3866 crate::bail_invalid_estim!(
3867 "zero penalty derivative width mismatch: total_dim={}, qs rows={}",
3868 self.cols,
3869 qs.nrows()
3870 );
3871 }
3872 let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3873 Ok(Array2::<f64>::zeros((cols, cols)))
3874 }
3875
3876 fn penalty_scaled_add_to(
3877 &self,
3878 target: &mut Array2<f64>,
3879 amp: f64,
3880 ) -> Result<(), EstimationError> {
3881 if !amp.is_finite() {
3885 crate::bail_invalid_estim!(
3886 "zero hyper penalty derivative received non-finite amp={amp}"
3887 );
3888 }
3889 if target.nrows() != self.cols || target.ncols() != self.cols {
3890 crate::bail_invalid_estim!(
3891 "zero hyper penalty derivative shape mismatch: target={}x{}, expected {}x{}",
3892 target.nrows(),
3893 target.ncols(),
3894 self.cols,
3895 self.cols
3896 );
3897 }
3898 Ok(())
3899 }
3900}
3901
3902impl DerivativeStorageBackend for EmbeddedDerivativeMatrix {
3903 fn resident_byte_count(&self) -> usize {
3904 self.local.len().saturating_mul(std::mem::size_of::<f64>())
3905 }
3906 fn design_nrows(&self) -> usize {
3907 self.local.nrows()
3908 }
3909 fn design_ncols(&self) -> usize {
3910 self.total_dim
3911 }
3912 fn penalty_dim(&self) -> usize {
3913 self.total_dim
3914 }
3915 fn uses_implicit_storage(&self) -> bool {
3916 false
3917 }
3918 fn any_nonzero(&self) -> bool {
3919 self.local.iter().any(|v| *v != 0.0)
3920 }
3921 fn materialize(&self) -> Array2<f64> {
3922 let mut dense = Array2::<f64>::zeros((self.local.nrows(), self.total_dim));
3923 dense
3924 .slice_mut(s![.., self.global_range.clone()])
3925 .assign(&self.local);
3926 dense
3927 }
3928 fn implicit_first_axis_info(
3929 &self,
3930 ) -> Option<(
3931 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3932 usize,
3933 )> {
3934 None
3935 }
3936 fn implicit_axis_count_hint(&self) -> Option<usize> {
3937 None
3938 }
3939
3940 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3941 if self.total_dim != u.len() {
3942 crate::bail_invalid_estim!(
3943 "embedded hyper design derivative forward_mul_original width mismatch: total_dim={}, vector={}",
3944 self.total_dim,
3945 u.len()
3946 );
3947 }
3948 let u_local = u.slice(s![self.global_range.clone()]).to_owned();
3949 Ok(self.local.dot(&u_local))
3950 }
3951
3952 fn design_transpose_mul_original(
3953 &self,
3954 v: &Array1<f64>,
3955 ) -> Result<Array1<f64>, EstimationError> {
3956 if self.local.nrows() != v.len() {
3957 crate::bail_invalid_estim!(
3958 "embedded hyper design derivative transpose_mul_original height mismatch: local_rows={}, vector={}",
3959 self.local.nrows(),
3960 v.len()
3961 );
3962 }
3963 let mut out = Array1::<f64>::zeros(self.total_dim);
3964 let pulled = self.local.t().dot(v);
3965 out.slice_mut(s![self.global_range.clone()]).assign(&pulled);
3966 Ok(out)
3967 }
3968
3969 fn design_transformed(
3970 &self,
3971 qs: &Array2<f64>,
3972 free_basis_opt: Option<&Array2<f64>>,
3973 ) -> Result<Array2<f64>, EstimationError> {
3974 if self.total_dim != qs.nrows() {
3975 crate::bail_invalid_estim!(
3976 "embedded design derivative width mismatch: total_cols={}, qs rows={}",
3977 self.total_dim,
3978 qs.nrows()
3979 );
3980 }
3981 let qs_local = qs.slice(s![self.global_range.clone(), ..]);
3982 let mut transformed = self.local.dot(&qs_local);
3983 if let Some(z) = free_basis_opt {
3984 transformed = transformed.dot(z);
3985 }
3986 Ok(transformed)
3987 }
3988
3989 fn penalty_transformed(
3990 &self,
3991 qs: &Array2<f64>,
3992 free_basis_opt: Option<&Array2<f64>>,
3993 ) -> Result<Array2<f64>, EstimationError> {
3994 if self.total_dim != qs.nrows() {
3995 crate::bail_invalid_estim!(
3996 "embedded penalty derivative width mismatch: total_dim={}, qs rows={}",
3997 self.total_dim,
3998 qs.nrows()
3999 );
4000 }
4001 let qs_local = qs.slice(s![self.global_range.clone(), ..]);
4002 let mut transformed = qs_local.t().dot(&self.local).dot(&qs_local);
4003 if let Some(z) = free_basis_opt {
4004 transformed = z.t().dot(&transformed).dot(z);
4005 }
4006 Ok(transformed)
4007 }
4008
4009 fn penalty_scaled_add_to(
4010 &self,
4011 target: &mut Array2<f64>,
4012 amp: f64,
4013 ) -> Result<(), EstimationError> {
4014 if target.nrows() != self.total_dim || target.ncols() != self.total_dim {
4015 crate::bail_invalid_estim!(
4016 "embedded hyper penalty derivative shape mismatch: target={}x{}, expected {}x{}",
4017 target.nrows(),
4018 target.ncols(),
4019 self.total_dim,
4020 self.total_dim
4021 );
4022 }
4023 target
4024 .slice_mut(s![self.global_range.clone(), self.global_range.clone()])
4025 .scaled_add(amp, &self.local);
4026 Ok(())
4027 }
4028}
4029
4030impl DerivativeStorageBackend for ImplicitDerivativeOp {
4031 fn resident_byte_count(&self) -> usize {
4032 0
4033 }
4034 fn design_nrows(&self) -> usize {
4035 self.nrows()
4036 }
4037 fn design_ncols(&self) -> usize {
4038 self.ncols()
4039 }
4040 fn penalty_dim(&self) -> usize {
4041 self.nrows()
4042 }
4043 fn uses_implicit_storage(&self) -> bool {
4044 true
4045 }
4046 fn any_nonzero(&self) -> bool {
4047 true
4048 }
4049 fn materialize(&self) -> Array2<f64> {
4050 self.materialize_dense().clone()
4051 }
4052 fn implicit_first_axis_info(
4053 &self,
4054 ) -> Option<(
4055 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4056 usize,
4057 )> {
4058 match self.level {
4059 ImplicitDerivLevel::First(axis) => Some((self.operator.clone(), axis)),
4060 _ => None,
4061 }
4062 }
4063 fn implicit_axis_count_hint(&self) -> Option<usize> {
4064 Some(self.operator.n_axes())
4065 }
4066
4067 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
4068 if self.ncols() != u.len() {
4069 crate::bail_invalid_estim!(
4070 "implicit hyper design derivative forward_mul_original width mismatch: operator_cols={}, vector={}",
4071 self.ncols(),
4072 u.len()
4073 );
4074 }
4075 Ok(self.forward_mul(u))
4076 }
4077
4078 fn design_transpose_mul_original(
4079 &self,
4080 v: &Array1<f64>,
4081 ) -> Result<Array1<f64>, EstimationError> {
4082 if self.nrows() != v.len() {
4083 crate::bail_invalid_estim!(
4084 "implicit hyper design derivative transpose_mul_original height mismatch: operator_rows={}, vector={}",
4085 self.nrows(),
4086 v.len()
4087 );
4088 }
4089 Ok(self.transpose_mul(v))
4090 }
4091
4092 fn design_transformed(
4093 &self,
4094 qs: &Array2<f64>,
4095 free_basis_opt: Option<&Array2<f64>>,
4096 ) -> Result<Array2<f64>, EstimationError> {
4097 let dense = self.materialize_dense();
4098 Ok(gam_linalg::matrix::DenseRightProductView::new(dense)
4099 .with_factor(qs)
4100 .with_optional_factor(free_basis_opt)
4101 .materialize())
4102 }
4103
4104 fn design_transformed_forward_mul(
4105 &self,
4106 qs: &Array2<f64>,
4107 free_basis_opt: Option<&Array2<f64>>,
4108 u: &Array1<f64>,
4109 ) -> Result<Array1<f64>, EstimationError> {
4110 let mut right = if let Some(z) = free_basis_opt {
4111 z.dot(u)
4112 } else {
4113 u.clone()
4114 };
4115 right = qs.dot(&right);
4116 Ok(self.forward_mul(&right))
4117 }
4118
4119 fn design_transformed_transpose_mul(
4120 &self,
4121 qs: &Array2<f64>,
4122 free_basis_opt: Option<&Array2<f64>>,
4123 v: &Array1<f64>,
4124 ) -> Result<Array1<f64>, EstimationError> {
4125 let mut pulled = qs.t().dot(&self.transpose_mul(v));
4126 if let Some(z) = free_basis_opt {
4127 pulled = z.t().dot(&pulled);
4128 }
4129 Ok(pulled)
4130 }
4131
4132 fn penalty_transformed(
4133 &self,
4134 qs: &Array2<f64>,
4135 free_basis_opt: Option<&Array2<f64>>,
4136 ) -> Result<Array2<f64>, EstimationError> {
4137 let dense = self.materialize_dense();
4138 let mut transformed = qs.t().dot(dense).dot(qs);
4139 if let Some(z) = free_basis_opt {
4140 transformed = z.t().dot(&transformed).dot(z);
4141 }
4142 Ok(transformed)
4143 }
4144
4145 fn penalty_scaled_add_to(
4146 &self,
4147 target: &mut Array2<f64>,
4148 amp: f64,
4149 ) -> Result<(), EstimationError> {
4150 let dense = self.materialize_dense();
4151 if target.raw_dim() != dense.raw_dim() {
4152 crate::bail_invalid_estim!(
4153 "implicit hyper penalty derivative shape mismatch: target={}x{}, matrix={}x{}",
4154 target.nrows(),
4155 target.ncols(),
4156 dense.nrows(),
4157 dense.ncols()
4158 );
4159 }
4160 target.scaled_add(amp, dense);
4161 Ok(())
4162 }
4163}
4164
4165impl DerivativeStorageBackend for LatentCoordDerivativeOp {
4166 fn resident_byte_count(&self) -> usize {
4167 0
4168 }
4169 fn design_nrows(&self) -> usize {
4170 self.nrows()
4171 }
4172 fn design_ncols(&self) -> usize {
4173 self.ncols()
4174 }
4175 fn penalty_dim(&self) -> usize {
4176 self.nrows()
4177 }
4178 fn uses_implicit_storage(&self) -> bool {
4179 true
4180 }
4181 fn any_nonzero(&self) -> bool {
4182 true
4183 }
4184 fn materialize(&self) -> Array2<f64> {
4185 self.materialize_dense().clone()
4186 }
4187 fn implicit_first_axis_info(
4188 &self,
4189 ) -> Option<(
4190 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4191 usize,
4192 )> {
4193 None
4194 }
4195 fn implicit_axis_count_hint(&self) -> Option<usize> {
4196 Some(self.operator.n_axes())
4197 }
4198
4199 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
4200 if self.ncols() != u.len() {
4201 crate::bail_invalid_estim!(
4202 "latent-coordinate hyper design derivative forward_mul_original width mismatch: operator_cols={}, vector={}",
4203 self.ncols(),
4204 u.len()
4205 );
4206 }
4207 Ok(self.forward_mul(u))
4208 }
4209
4210 fn design_transpose_mul_original(
4211 &self,
4212 v: &Array1<f64>,
4213 ) -> Result<Array1<f64>, EstimationError> {
4214 if self.nrows() != v.len() {
4215 crate::bail_invalid_estim!(
4216 "latent-coordinate hyper design derivative transpose_mul_original height mismatch: operator_rows={}, vector={}",
4217 self.nrows(),
4218 v.len()
4219 );
4220 }
4221 Ok(self.transpose_mul(v))
4222 }
4223
4224 fn design_transformed(
4225 &self,
4226 qs: &Array2<f64>,
4227 free_basis_opt: Option<&Array2<f64>>,
4228 ) -> Result<Array2<f64>, EstimationError> {
4229 let dense = self.materialize_dense();
4230 Ok(gam_linalg::matrix::DenseRightProductView::new(dense)
4231 .with_factor(qs)
4232 .with_optional_factor(free_basis_opt)
4233 .materialize())
4234 }
4235
4236 fn design_transformed_forward_mul(
4237 &self,
4238 qs: &Array2<f64>,
4239 free_basis_opt: Option<&Array2<f64>>,
4240 u: &Array1<f64>,
4241 ) -> Result<Array1<f64>, EstimationError> {
4242 let mut right = if let Some(z) = free_basis_opt {
4243 z.dot(u)
4244 } else {
4245 u.clone()
4246 };
4247 right = qs.dot(&right);
4248 Ok(self.forward_mul(&right))
4249 }
4250
4251 fn design_transformed_transpose_mul(
4252 &self,
4253 qs: &Array2<f64>,
4254 free_basis_opt: Option<&Array2<f64>>,
4255 v: &Array1<f64>,
4256 ) -> Result<Array1<f64>, EstimationError> {
4257 let mut pulled = qs.t().dot(&self.transpose_mul(v));
4258 if let Some(z) = free_basis_opt {
4259 pulled = z.t().dot(&pulled);
4260 }
4261 Ok(pulled)
4262 }
4263
4264 fn penalty_transformed(
4265 &self,
4266 qs: &Array2<f64>,
4267 free_basis_opt: Option<&Array2<f64>>,
4268 ) -> Result<Array2<f64>, EstimationError> {
4269 let dense = self.materialize_dense();
4270 let mut transformed = qs.t().dot(dense).dot(qs);
4271 if let Some(z) = free_basis_opt {
4272 transformed = z.t().dot(&transformed).dot(z);
4273 }
4274 Ok(transformed)
4275 }
4276
4277 fn penalty_scaled_add_to(
4278 &self,
4279 target: &mut Array2<f64>,
4280 amp: f64,
4281 ) -> Result<(), EstimationError> {
4282 let dense = self.materialize_dense();
4283 if target.raw_dim() != dense.raw_dim() {
4284 crate::bail_invalid_estim!(
4285 "latent-coordinate hyper penalty derivative shape mismatch: target={}x{}, matrix={}x{}",
4286 target.nrows(),
4287 target.ncols(),
4288 dense.nrows(),
4289 dense.ncols()
4290 );
4291 }
4292 target.scaled_add(amp, dense);
4293 Ok(())
4294 }
4295}
4296
4297#[derive(Clone)]
4298pub struct HyperDesignDerivative {
4299 pub(crate) storage: DerivativeMatrixStorage,
4300}
4301
4302impl HyperDesignDerivative {
4303 pub fn zero(nrows: usize, ncols: usize) -> Self {
4304 Self {
4305 storage: DerivativeMatrixStorage::Zero(ZeroDerivativeMatrix::new(nrows, ncols)),
4306 }
4307 }
4308
4309 pub fn from_embedded(
4310 local: Array2<f64>,
4311 global_range: Range<usize>,
4312 total_cols: usize,
4313 ) -> Self {
4314 Self {
4315 storage: DerivativeMatrixStorage::Embedded(EmbeddedDerivativeMatrix::new(
4316 local,
4317 global_range,
4318 total_cols,
4319 )),
4320 }
4321 }
4322
4323 pub fn from_implicit(
4324 operator: std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4325 level: ImplicitDerivLevel,
4326 global_range: Range<usize>,
4327 total_cols: usize,
4328 ) -> Self {
4329 Self {
4330 storage: DerivativeMatrixStorage::Implicit(ImplicitDerivativeOp {
4331 operator,
4332 level,
4333 global_range,
4334 total_dim: total_cols,
4335 cached_dense: std::sync::Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
4336 }),
4337 }
4338 }
4339
4340 pub fn from_latent_coord(
4341 operator: std::sync::Arc<gam_terms::basis::LatentCoordDesignDerivative>,
4342 flat_axis: usize,
4343 global_range: Range<usize>,
4344 total_cols: usize,
4345 ) -> Self {
4346 Self {
4347 storage: DerivativeMatrixStorage::LatentCoord(LatentCoordDerivativeOp {
4348 operator,
4349 flat_axis,
4350 global_range,
4351 total_dim: total_cols,
4352 cached_dense: std::sync::Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
4353 }),
4354 }
4355 }
4356
4357 pub(crate) fn resident_byte_count(&self) -> usize {
4358 storage_dispatch!(&self.storage, b => b.resident_byte_count())
4359 }
4360
4361 pub(crate) fn nrows(&self) -> usize {
4362 storage_dispatch!(&self.storage, b => b.design_nrows())
4363 }
4364
4365 pub(crate) fn ncols(&self) -> usize {
4366 storage_dispatch!(&self.storage, b => b.design_ncols())
4367 }
4368
4369 pub(crate) fn uses_implicit_storage(&self) -> bool {
4370 storage_dispatch!(&self.storage, b => b.uses_implicit_storage())
4371 }
4372
4373 pub(crate) fn materialize(&self) -> Array2<f64> {
4374 storage_dispatch!(&self.storage, b => b.materialize())
4375 }
4376
4377 pub(crate) fn any_nonzero(&self) -> bool {
4378 storage_dispatch!(&self.storage, b => b.any_nonzero())
4379 }
4380
4381 pub(crate) fn forward_mul_original(
4382 &self,
4383 u: &Array1<f64>,
4384 ) -> Result<Array1<f64>, EstimationError> {
4385 storage_dispatch!(&self.storage, b => b.design_forward_mul_original(u))
4386 }
4387
4388 pub(crate) fn transpose_mul_original(
4389 &self,
4390 v: &Array1<f64>,
4391 ) -> Result<Array1<f64>, EstimationError> {
4392 storage_dispatch!(&self.storage, b => b.design_transpose_mul_original(v))
4393 }
4394
4395 pub(crate) fn transformed(
4396 &self,
4397 qs: &Array2<f64>,
4398 free_basis_opt: Option<&Array2<f64>>,
4399 ) -> Result<Array2<f64>, EstimationError> {
4400 storage_dispatch!(&self.storage, b => b.design_transformed(qs, free_basis_opt))
4401 }
4402
4403 pub(crate) fn transformed_forward_mul(
4404 &self,
4405 qs: &Array2<f64>,
4406 free_basis_opt: Option<&Array2<f64>>,
4407 u: &Array1<f64>,
4408 ) -> Result<Array1<f64>, EstimationError> {
4409 storage_dispatch!(&self.storage, b => b.design_transformed_forward_mul(qs, free_basis_opt, u))
4410 }
4411
4412 pub(crate) fn transformed_transpose_mul(
4413 &self,
4414 qs: &Array2<f64>,
4415 free_basis_opt: Option<&Array2<f64>>,
4416 v: &Array1<f64>,
4417 ) -> Result<Array1<f64>, EstimationError> {
4418 storage_dispatch!(&self.storage, b => b.design_transformed_transpose_mul(qs, free_basis_opt, v))
4419 }
4420
4421 pub(crate) fn implicit_first_axis_info(
4426 &self,
4427 ) -> Option<(
4428 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4429 usize,
4430 )> {
4431 storage_dispatch!(&self.storage, b => b.implicit_first_axis_info())
4432 }
4433
4434 pub(crate) fn implicit_axis_count_hint(&self) -> Option<usize> {
4435 storage_dispatch!(&self.storage, b => b.implicit_axis_count_hint())
4436 }
4437}
4438
4439impl From<Array2<f64>> for HyperDesignDerivative {
4440 fn from(value: Array2<f64>) -> Self {
4441 Self {
4442 storage: DerivativeMatrixStorage::Dense(value),
4443 }
4444 }
4445}
4446
4447#[derive(Clone)]
4448pub struct HyperPenaltyDerivative {
4449 pub(crate) storage: DerivativeMatrixStorage,
4450}
4451
4452impl HyperPenaltyDerivative {
4453 pub fn from_embedded(local: Array2<f64>, global_range: Range<usize>, total_dim: usize) -> Self {
4454 Self {
4455 storage: DerivativeMatrixStorage::Embedded(EmbeddedDerivativeMatrix::new(
4456 local,
4457 global_range,
4458 total_dim,
4459 )),
4460 }
4461 }
4462
4463 pub(crate) fn resident_byte_count(&self) -> usize {
4464 storage_dispatch!(&self.storage, b => b.resident_byte_count())
4465 }
4466
4467 pub(crate) fn nrows(&self) -> usize {
4468 storage_dispatch!(&self.storage, b => b.penalty_dim())
4469 }
4470
4471 pub(crate) fn ncols(&self) -> usize {
4472 self.nrows()
4473 }
4474
4475 pub(crate) fn scaled_materialize(&self, amp: f64) -> Array2<f64> {
4476 let mut out = Array2::<f64>::zeros((self.nrows(), self.ncols()));
4477 self.scaled_add_to(&mut out, amp)
4478 .expect("scaled materialize uses matching target shape");
4479 out
4480 }
4481
4482 pub(crate) fn transformed(
4483 &self,
4484 qs: &Array2<f64>,
4485 free_basis_opt: Option<&Array2<f64>>,
4486 ) -> Result<Array2<f64>, EstimationError> {
4487 storage_dispatch!(&self.storage, b => b.penalty_transformed(qs, free_basis_opt))
4488 }
4489
4490 pub(crate) fn scaled_add_to(
4491 &self,
4492 target: &mut Array2<f64>,
4493 amp: f64,
4494 ) -> Result<(), EstimationError> {
4495 storage_dispatch!(&self.storage, b => b.penalty_scaled_add_to(target, amp))
4496 }
4497}
4498
4499impl From<Array2<f64>> for HyperPenaltyDerivative {
4500 fn from(value: Array2<f64>) -> Self {
4501 Self {
4502 storage: DerivativeMatrixStorage::Dense(value),
4503 }
4504 }
4505}
4506
4507#[derive(Clone)]
4508pub struct PenaltyDerivativeComponent {
4509 pub penalty_index: usize,
4510 pub matrix: HyperPenaltyDerivative,
4511}
4512
4513#[derive(Clone)]
4514pub struct DirectionalHyperParam {
4515 pub(crate) x_tau_original: HyperDesignDerivative,
4516 pub(crate) penalty_first_components: Vec<PenaltyDerivativeComponent>,
4519 pub(crate) x_tau_tau_original: Option<Vec<Option<HyperDesignDerivative>>>,
4523 pub(crate) penaltysecond_components: Option<Vec<Option<Vec<PenaltyDerivativeComponent>>>>,
4526 pub(crate) penaltysecond_component_provider: Option<
4527 std::sync::Arc<
4528 dyn Fn(usize) -> Result<Option<Vec<PenaltyDerivativeComponent>>, EstimationError>
4529 + Send
4530 + Sync
4531 + 'static,
4532 >,
4533 >,
4534 pub(crate) penaltysecond_partner_indices: Option<std::sync::Arc<[usize]>>,
4535 pub(crate) is_penalty_like: bool,
4539}
4540
4541impl DirectionalHyperParam {
4542 pub(crate) fn resident_byte_count(&self) -> usize {
4543 let mut bytes = self.x_tau_original.resident_byte_count();
4544 for component in &self.penalty_first_components {
4545 bytes = bytes.saturating_add(component.matrix.resident_byte_count());
4546 }
4547 if let Some(entries) = self.x_tau_tau_original.as_ref() {
4548 for entry in entries.iter().flatten() {
4549 bytes = bytes.saturating_add(entry.resident_byte_count());
4550 }
4551 }
4552 if let Some(rows) = self.penaltysecond_components.as_ref() {
4553 for components in rows.iter().flatten() {
4554 for component in components {
4555 bytes = bytes.saturating_add(component.matrix.resident_byte_count());
4556 }
4557 }
4558 }
4559 bytes
4560 }
4561
4562 pub(crate) fn canonicalize_penalty_components(
4563 components: Vec<(usize, HyperPenaltyDerivative)>,
4564 ) -> Result<Vec<PenaltyDerivativeComponent>, EstimationError> {
4565 let mut out: Vec<PenaltyDerivativeComponent> = Vec::with_capacity(components.len());
4566 for (penalty_index, matrix) in components {
4567 if out.iter().any(|c| c.penalty_index == penalty_index) {
4568 crate::bail_invalid_estim!(
4569 "duplicate penalty derivative component for penalty {}",
4570 penalty_index
4571 );
4572 }
4573 out.push(PenaltyDerivativeComponent {
4574 penalty_index,
4575 matrix,
4576 });
4577 }
4578 Ok(out)
4579 }
4580
4581 pub fn new_compact(
4582 x_tau_original: HyperDesignDerivative,
4583 penalty_first_components: Vec<(usize, HyperPenaltyDerivative)>,
4584 x_tau_tau_original: Option<Vec<Option<HyperDesignDerivative>>>,
4585 penaltysecond_components: Option<Vec<Option<Vec<(usize, HyperPenaltyDerivative)>>>>,
4586 ) -> Result<Self, EstimationError> {
4587 let is_penalty_like = !x_tau_original.any_nonzero();
4588 let penalty_first_components =
4589 Self::canonicalize_penalty_components(penalty_first_components)?;
4590 let penaltysecond_components = match penaltysecond_components {
4591 Some(rows) => {
4592 let mut out = Vec::with_capacity(rows.len());
4593 for row in rows {
4594 out.push(match row {
4595 Some(components) => {
4596 Some(Self::canonicalize_penalty_components(components)?)
4597 }
4598 None => None,
4599 });
4600 }
4601 Some(out)
4602 }
4603 None => None,
4604 };
4605 Ok(Self {
4606 x_tau_original,
4607 penalty_first_components,
4608 x_tau_tau_original,
4609 penaltysecond_components,
4610 penaltysecond_component_provider: None,
4611 penaltysecond_partner_indices: None,
4612 is_penalty_like,
4613 })
4614 }
4615
4616 pub fn not_penalty_like(mut self) -> Self {
4619 self.is_penalty_like = false;
4620 self
4621 }
4622
4623 pub fn with_penaltysecond_component_provider(
4624 mut self,
4625 provider: std::sync::Arc<
4626 dyn Fn(usize) -> Result<Option<Vec<PenaltyDerivativeComponent>>, EstimationError>
4627 + Send
4628 + Sync
4629 + 'static,
4630 >,
4631 ) -> Self {
4632 self.penaltysecond_component_provider = Some(provider);
4633 self
4634 }
4635
4636 pub fn with_penaltysecond_partner_indices(mut self, partners: Vec<usize>) -> Self {
4637 self.penaltysecond_partner_indices = Some(std::sync::Arc::from(partners));
4638 self
4639 }
4640
4641 pub(crate) fn x_tau_dense(&self) -> Array2<f64> {
4642 self.x_tau_original.materialize()
4643 }
4644
4645 pub(crate) fn transformed_x_tau(
4646 &self,
4647 qs: &Array2<f64>,
4648 free_basis_opt: Option<&Array2<f64>>,
4649 ) -> Result<Array2<f64>, EstimationError> {
4650 self.x_tau_original.transformed(qs, free_basis_opt)
4651 }
4652
4653 pub(crate) fn x_tau_tau_entry_at(&self, j: usize) -> Option<HyperDesignDerivative> {
4654 self.x_tau_tau_original
4655 .as_ref()
4656 .and_then(|rows| rows.get(j))
4657 .and_then(|entry| entry.clone())
4658 }
4659
4660 pub(crate) fn has_implicit_operator(&self) -> bool {
4663 self.x_tau_original.uses_implicit_storage()
4664 }
4665
4666 pub(crate) fn has_implicit_multidim_duchon(&self) -> bool {
4667 self.implicit_first_axis_info()
4668 .is_some_and(|(op, _)| op.n_axes() > 1 && op.is_duchon_family())
4669 }
4670
4671 pub(crate) fn implicit_first_axis_info(
4673 &self,
4674 ) -> Option<(
4675 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4676 usize,
4677 )> {
4678 self.x_tau_original.implicit_first_axis_info()
4679 }
4680
4681 pub(crate) fn implicit_axis_count_hint(&self) -> Option<usize> {
4682 self.x_tau_original.implicit_axis_count_hint()
4683 }
4684
4685 pub(crate) fn penalty_first_components(&self) -> &[PenaltyDerivativeComponent] {
4686 &self.penalty_first_components
4687 }
4688
4689 pub(crate) fn penalty_total_at(
4690 &self,
4691 rho: &Array1<f64>,
4692 p: usize,
4693 ) -> Result<Array2<f64>, EstimationError> {
4694 let mut out = Array2::<f64>::zeros((p, p));
4695 for component in &self.penalty_first_components {
4696 if component.matrix.nrows() != p || component.matrix.ncols() != p {
4697 crate::bail_invalid_estim!(
4698 "S_tau shape mismatch for penalty {}: expected {}x{}, got {}x{}",
4699 component.penalty_index,
4700 p,
4701 p,
4702 component.matrix.nrows(),
4703 component.matrix.ncols()
4704 );
4705 }
4706 if component.penalty_index >= rho.len() {
4707 crate::bail_invalid_estim!(
4708 "penalty_index {} out of bounds for rho dimension {}",
4709 component.penalty_index,
4710 rho.len()
4711 );
4712 }
4713 let lambda = gam_problem::checked_exp_log_strength(rho[component.penalty_index])
4714 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
4715 component.matrix.scaled_add_to(&mut out, lambda)?;
4716 }
4717 Ok(out)
4718 }
4719
4720 pub(crate) fn penaltysecond_components_for(
4721 &self,
4722 j: usize,
4723 ) -> Result<Option<Vec<PenaltyDerivativeComponent>>, EstimationError> {
4724 if let Some(components) = self
4725 .penaltysecond_components
4726 .as_ref()
4727 .and_then(|rows| rows.get(j))
4728 .and_then(|row| row.clone())
4729 {
4730 return Ok(Some(components));
4731 }
4732 if let Some(provider) = self.penaltysecond_component_provider.as_ref() {
4733 return provider(j);
4734 }
4735 Ok(None)
4736 }
4737
4738 pub(crate) fn penaltysecond_componentrows(
4739 &self,
4740 ) -> Option<&[Option<Vec<PenaltyDerivativeComponent>>]> {
4741 self.penaltysecond_components.as_deref()
4742 }
4743
4744 pub(crate) fn penalty_first_component_count(&self) -> usize {
4745 self.penalty_first_components.len()
4746 }
4747
4748 pub(crate) fn has_penaltysecond_pair_at(&self, j: usize) -> bool {
4749 self.penaltysecond_components
4750 .as_ref()
4751 .and_then(|rows| rows.get(j))
4752 .is_some_and(Option::is_some)
4753 || self
4754 .penaltysecond_partner_indices
4755 .as_ref()
4756 .is_some_and(|partners| partners.contains(&j))
4757 }
4758}
4759
4760#[derive(Clone, Debug)]
4761pub(crate) struct SparseRemlDecision {
4762 pub(crate) geometry: RemlGeometry,
4763 pub(crate) reason: &'static str,
4764 pub(crate) p: usize,
4765 pub(crate) nnz_x: usize,
4766 pub(crate) nnz_h_upper_est: Option<usize>,
4767 pub(crate) density_h_upper_est: Option<f64>,
4768}
4769
4770#[derive(Clone)]
4771pub(crate) struct SparseExactEvalData {
4772 pub(crate) factor: Arc<SparseExactFactor>,
4773 pub(crate) takahashi: Option<Arc<gam_linalg::sparse_exact::TakahashiInverse>>,
4774 pub(crate) logdet_h: f64,
4775 pub(crate) logdet_s_pos: f64,
4776 pub(crate) penalty_rank: usize,
4777 pub(crate) det1_values: Arc<Array1<f64>>,
4778}
4779
4780#[derive(Clone)]
4781pub struct FirthDenseOperator {
4782 pub(crate) x_dense: Array2<f64>,
4809 pub(crate) x_dense_t: Array2<f64>,
4810 pub(crate) q_basis: Array2<f64>,
4813 pub(crate) x_reduced: Array2<f64>,
4816 pub(crate) observation_weight_sqrt: Option<Array1<f64>>,
4822 pub(crate) k_reduced: Array2<f64>,
4824 pub(crate) x_metric_reduced_inv_diag: Array1<f64>,
4829 pub(crate) half_log_det: f64,
4831 pub(crate) h_diag: Array1<f64>,
4833 pub(crate) w: Array1<f64>,
4835 pub(crate) w1: Array1<f64>,
4836 pub(crate) w2: Array1<f64>,
4837 pub(crate) w3: Array1<f64>,
4838 pub(crate) w4: Array1<f64>,
4839 pub(crate) b_base: Array2<f64>,
4841 pub(crate) p_b_base: Array2<f64>,
4844}
4845
4846#[derive(Clone)]
4863pub(crate) struct FirthDesignFactor {
4864 pub(crate) x_dense: Array2<f64>,
4866 pub(crate) x_dense_t: Array2<f64>,
4867 pub(crate) q_basis: Array2<f64>,
4869 pub(crate) x_reduced: Array2<f64>,
4871 pub(crate) observation_weight_sqrt: Option<Array1<f64>>,
4873 pub(crate) metric_spectrum: Array1<f64>,
4875 pub(crate) x_metric_reduced_inv_diag: Array1<f64>,
4877 pub(crate) r: usize,
4879 pub(crate) n: usize,
4880}
4881
4882#[derive(Clone)]
4883pub(crate) struct FirthDirection {
4884 pub(crate) deta: Array1<f64>,
4885 pub(crate) g_u_reduced: Array2<f64>,
4886 pub(crate) a_u_reduced: Array2<f64>,
4887 pub(crate) dh: Array1<f64>,
4888 pub(crate) b_uvec: Array1<f64>,
4890}
4891
4892#[derive(Clone)]
4893pub(crate) struct FirthTauPartialKernel {
4894 pub(super) deta_partial: Array1<f64>,
4895 pub(crate) dotw1: Array1<f64>,
4896 pub(crate) dotw2: Array1<f64>,
4897 pub(crate) dot_h_partial: Array1<f64>,
4898 pub(crate) x_tau_reduced: Array2<f64>,
4901 pub(super) dot_i_partial: Array2<f64>,
4902 pub(crate) dot_k_reduced: Array2<f64>,
4906}
4907
4908#[derive(Clone)]
4909pub(crate) struct FirthTauExactKernel {
4910 pub(crate) gphi_tau: Array1<f64>,
4911 pub(crate) phi_tau_partial: f64,
4912 pub(crate) tau_kernel: Option<FirthTauPartialKernel>,
4913}
4914
4915#[derive(Clone)]
4927pub(crate) struct FirthTauTauExactKernel {
4928 pub(super) phi_tau_tau_partial: f64,
4929 pub(super) gphi_tau_tau: Array1<f64>,
4930 pub(super) tau_tau_kernel: Option<FirthTauTauPartialKernel>,
4931}
4932
4933#[derive(Clone, Default)]
4946pub(crate) struct FirthTauTauPartialKernel {
4947 pub(super) x_tau_i_reduced: Array2<f64>,
4948 pub(super) x_tau_j_reduced: Array2<f64>,
4949 pub(super) deta_i_partial: Array1<f64>,
4950 pub(super) deta_j_partial: Array1<f64>,
4951 pub(super) dot_h_i_partial: Array1<f64>,
4952 pub(super) dot_h_j_partial: Array1<f64>,
4953 pub(super) dot_k_i_reduced: Array2<f64>,
4954 pub(super) dot_k_j_reduced: Array2<f64>,
4955 pub(super) dot_i_i_partial: Array2<f64>,
4956 pub(super) dot_i_j_partial: Array2<f64>,
4957 pub(super) x_tau_tau_reduced: Option<Array2<f64>>,
4958 pub(super) deta_ij_partial: Option<Array1<f64>>,
4959}
4960
4961#[derive(Clone, Default)]
4969pub(crate) struct FirthTauBetaPartialKernel {
4970 pub(super) x_tau_reduced: Array2<f64>,
4971 pub(super) deta_partial: Array1<f64>,
4972 pub(super) dot_h_partial: Array1<f64>,
4973 pub(super) dot_i_partial: Array2<f64>,
4974 pub(super) dot_k_reduced: Array2<f64>,
4975 pub(super) deta_v: Array1<f64>,
4976 pub(super) deta_tau_v: Array1<f64>,
4977 pub(super) a_v_reduced: Array2<f64>,
4978 pub(super) dh_v: Array1<f64>,
4979 pub(super) b_vvec: Array1<f64>,
4980 pub(super) d_beta_dot_k: Array2<f64>,
4981 pub(super) d_beta_dot_h: Array1<f64>,
4982}
4983
4984#[derive(Clone)]
4995pub(crate) struct EvalShared {
4996 pub(crate) key: Option<Vec<u64>>,
4997 pub(crate) pirls_result: Arc<PirlsResult>,
4998 pub(crate) ridge_passport: RidgePassport,
4999 pub(crate) geometry: RemlGeometry,
5000 pub(crate) h_total: Arc<Array2<f64>>,
5004 pub(crate) sparse_exact: Option<Arc<SparseExactEvalData>>,
5005 pub(crate) firth_dense_operator: Option<Arc<FirthDenseOperator>>,
5006 pub(crate) firth_dense_operator_original: Option<Arc<FirthDenseOperator>>,
5009 pub(crate) penalty_pseudologdet: std::sync::OnceLock<Arc<penalty_logdet::PenaltyPseudologdet>>,
5023 pub(crate) penalty_scores_at_mode: std::sync::OnceLock<Arc<Vec<Array1<f64>>>>,
5036 pub(crate) block_local_correction:
5054 std::sync::OnceLock<(usize, Arc<outer_eval::TkCorrectionTerms>)>,
5055}
5056
5057impl EvalShared {
5058 pub(crate) fn matches(&self, key: &Option<Vec<u64>>) -> bool {
5059 match (&self.key, key) {
5060 (None, None) => true,
5061 (Some(a), Some(b)) => a == b,
5062 _ => false,
5063 }
5064 }
5065
5066 pub(crate) fn penalty_pseudologdet_original(
5081 &self,
5082 canonical_penalties: &[gam_terms::construction::CanonicalPenalty],
5083 lambdas: &[f64],
5084 p: usize,
5085 ) -> Result<Arc<penalty_logdet::PenaltyPseudologdet>, EstimationError> {
5086 if let Some(pld) = self.penalty_pseudologdet.get() {
5087 if pld.dim() != p {
5088 return Err(EstimationError::LayoutError(format!(
5089 "shared penalty pseudo-logdet frame mismatch: cached p={}, requested p={}",
5090 pld.dim(),
5091 p
5092 )));
5093 }
5094 return Ok(Arc::clone(pld));
5095 }
5096 let pld = Arc::new(
5097 penalty_logdet::PenaltyPseudologdet::from_penalties(
5098 canonical_penalties,
5099 lambdas,
5100 self.ridge_passport.penalty_logdet_ridge(),
5101 p,
5102 )
5103 .map_err(EstimationError::InvalidInput)?,
5104 );
5105 match self.penalty_pseudologdet.set(Arc::clone(&pld)) {
5106 Ok(()) => Ok(pld),
5107 Err(_) => Ok(Arc::clone(
5111 self.penalty_pseudologdet
5112 .get()
5113 .expect("OnceLock set raced, so it is initialized"),
5114 )),
5115 }
5116 }
5117}
5118
5119impl PenalizedGeometry for EvalShared {
5120 fn backend_kind(&self) -> GeometryBackendKind {
5121 match self.geometry {
5122 RemlGeometry::DenseSpectral => GeometryBackendKind::DenseSpectral,
5123 RemlGeometry::SparseExactSpd => GeometryBackendKind::SparseExactSpd,
5124 }
5125 }
5126}
5127
5128pub(crate) struct PirlsLruCache {
5138 pub(crate) map: HashMap<Vec<u64>, (Arc<PirlsResult>, u64, usize)>,
5140 pub(crate) byte_budget: usize,
5141 pub(crate) current_bytes: usize,
5142 pub(crate) clock: u64,
5143}
5144
5145impl PirlsLruCache {
5146 pub(crate) fn new(byte_budget: usize) -> Self {
5147 Self {
5148 map: HashMap::new(),
5149 byte_budget: byte_budget.max(1),
5150 current_bytes: 0,
5151 clock: 0,
5152 }
5153 }
5154
5155 pub(crate) fn get(&mut self, key: &Vec<u64>) -> Option<Arc<PirlsResult>> {
5156 if let Some(entry) = self.map.get_mut(key) {
5157 self.clock += 1;
5158 entry.1 = self.clock;
5159 Some(entry.0.clone())
5160 } else {
5161 None
5162 }
5163 }
5164
5165 pub(crate) fn insert(&mut self, key: Vec<u64>, value: Arc<PirlsResult>) {
5166 self.clock += 1;
5167 let bytes = pirls_result_cache_bytes(&value);
5168 if bytes > self.byte_budget {
5172 if let Some((_, _, prev_bytes)) = self.map.remove(&key) {
5173 self.current_bytes = self.current_bytes.saturating_sub(prev_bytes);
5174 }
5175 return;
5176 }
5177 if let Some((_, _, prev_bytes)) = self.map.remove(&key) {
5178 self.current_bytes = self.current_bytes.saturating_sub(prev_bytes);
5179 }
5180 while self.current_bytes + bytes > self.byte_budget {
5181 let evict_key = self
5182 .map
5183 .iter()
5184 .min_by_key(|(_, (_, ts, _))| *ts)
5185 .map(|(k, _)| k.clone());
5186 match evict_key {
5187 Some(k) => {
5188 if let Some((_, _, evict_bytes)) = self.map.remove(&k) {
5189 self.current_bytes = self.current_bytes.saturating_sub(evict_bytes);
5190 }
5191 }
5192 None => break,
5193 }
5194 }
5195 self.current_bytes += bytes;
5196 self.map.insert(key, (value, self.clock, bytes));
5197 }
5198
5199 pub(crate) fn clear(&mut self) {
5200 self.map.clear();
5201 self.current_bytes = 0;
5202 }
5203}
5204
5205#[derive(Clone, Copy, PartialEq, Eq)]
5206pub(crate) struct PenaltySubspaceCacheKey {
5207 pub(crate) penalty_matrix_fingerprint: u64,
5208 pub(crate) ridge_passport_signature: u64,
5209}
5210
5211pub(crate) struct PenaltySubspaceCache {
5212 pub(crate) entry: Option<(PenaltySubspaceCacheKey, Arc<outer_eval::PenaltySubspace>)>,
5213}
5214
5215impl PenaltySubspaceCache {
5216 pub(crate) fn new() -> Self {
5217 Self { entry: None }
5218 }
5219
5220 pub(crate) fn get(
5221 &self,
5222 key: &PenaltySubspaceCacheKey,
5223 ) -> Option<Arc<outer_eval::PenaltySubspace>> {
5224 self.entry
5225 .as_ref()
5226 .filter(|(cached_key, _)| cached_key == key)
5227 .map(|(_, value)| value.clone())
5228 }
5229
5230 pub(crate) fn insert(
5231 &mut self,
5232 key: PenaltySubspaceCacheKey,
5233 value: Arc<outer_eval::PenaltySubspace>,
5234 ) {
5235 self.entry = Some((key, value));
5236 }
5237
5238 pub(crate) fn clear(&mut self) {
5239 self.entry = None;
5240 }
5241}
5242
5243impl PenaltySubspaceCacheKey {
5244 pub(crate) fn from_inputs(
5249 e_transformed: &ndarray::Array2<f64>,
5250 ridge_passport: &gam_problem::RidgePassport,
5251 ) -> Self {
5252 use std::collections::hash_map::DefaultHasher;
5253 use std::hash::{Hash, Hasher};
5254 let mut hasher = DefaultHasher::new();
5255 e_transformed.nrows().hash(&mut hasher);
5256 e_transformed.ncols().hash(&mut hasher);
5257 for value in e_transformed.iter() {
5258 value.to_bits().hash(&mut hasher);
5259 }
5260 let penalty_matrix_fingerprint = hasher.finish();
5261 let mut ridge_hasher = DefaultHasher::new();
5262 ridge_passport.delta().to_bits().hash(&mut ridge_hasher);
5263 ridge_passport.matrix_form().hash(&mut ridge_hasher);
5264 ridge_passport.policy().hash(&mut ridge_hasher);
5265 let ridge_passport_signature = ridge_hasher.finish();
5266 Self {
5267 penalty_matrix_fingerprint,
5268 ridge_passport_signature,
5269 }
5270 }
5271}
5272
5273pub(crate) fn pirls_result_cache_bytes(result: &PirlsResult) -> usize {
5288 use std::mem::size_of;
5289 let n_array_elems = result.final_eta.len()
5290 + result.solveweights.len()
5291 + result.solveworking_response.len()
5292 + result.solvemu.len()
5293 + result.solve_c_array.len()
5294 + result.solve_d_array.len();
5295 let p = result.beta_transformed.0.len();
5296 let pen_h = symmetric_matrix_cache_bytes(&result.penalized_hessian_transformed);
5297 let stab_h = symmetric_matrix_cache_bytes(&result.stabilizedhessian_transformed);
5298 let reparam = (result.reparam_result.s_transformed.len()
5299 + result.reparam_result.qs.len()
5300 + result.reparam_result.e_transformed.len()
5301 + result.reparam_result.det1.len())
5302 * size_of::<f64>();
5303 n_array_elems * size_of::<f64>() + p * size_of::<f64>() + pen_h + stab_h + reparam + 1024
5304}
5305
5306pub(crate) fn symmetric_matrix_cache_bytes(m: &gam_linalg::matrix::SymmetricMatrix) -> usize {
5307 use gam_linalg::matrix::SymmetricMatrix;
5308 use std::mem::size_of;
5309 match m {
5310 SymmetricMatrix::Dense(a) => a.len() * size_of::<f64>(),
5311 SymmetricMatrix::Sparse(s) => {
5312 let (symbolic, values) = s.parts();
5314 values.len() * (size_of::<f64>() + size_of::<usize>())
5315 + std::mem::size_of_val(symbolic.col_ptr())
5316 }
5317 }
5318}
5319
5320pub(crate) const OUTER_EVAL_LRU_CAPACITY: usize = 8;
5328
5329pub(crate) struct OuterEvalLru {
5342 capacity: usize,
5343 entries: std::collections::VecDeque<(Vec<u64>, OuterEval)>,
5345}
5346
5347impl OuterEvalLru {
5348 pub(crate) fn new(capacity: usize) -> Self {
5349 Self {
5350 capacity: capacity.max(1),
5351 entries: std::collections::VecDeque::new(),
5352 }
5353 }
5354
5355 pub(crate) fn get(&mut self, key: &[u64]) -> Option<OuterEval> {
5359 let pos = self.entries.iter().position(|(k, _)| k.as_slice() == key)?;
5360 let entry = self.entries.remove(pos)?;
5361 let eval = entry.1.clone();
5362 self.entries.push_back(entry);
5363 Some(eval)
5364 }
5365
5366 pub(crate) fn insert(&mut self, key: Vec<u64>, eval: OuterEval) {
5369 if let Some(pos) = self
5370 .entries
5371 .iter()
5372 .position(|(k, _)| k.as_slice() == key.as_slice())
5373 {
5374 self.entries.remove(pos);
5375 }
5376 self.entries.push_back((key, eval));
5377 while self.entries.len() > self.capacity {
5378 self.entries.pop_front();
5379 }
5380 }
5381
5382 pub(crate) fn clear(&mut self) {
5383 self.entries.clear();
5384 }
5385}
5386
5387pub(crate) struct EvalCacheManager {
5392 pub(crate) pirls_cache: RwLock<PirlsLruCache>,
5393 pub(crate) penalty_subspace_cache: RwLock<PenaltySubspaceCache>,
5394 pub(crate) current_eval_bundle: RwLock<Option<EvalShared>>,
5395 pub(crate) current_outer_eval: RwLock<Option<(Vec<u64>, OuterEval)>>,
5399 pub(crate) outer_eval_lru: RwLock<OuterEvalLru>,
5414 pub(crate) pirls_cache_enabled: AtomicBool,
5415}
5416
5417impl EvalCacheManager {
5418 pub(crate) fn new() -> Self {
5419 Self {
5420 pirls_cache: RwLock::new(PirlsLruCache::new(PIRLS_CACHE_BYTE_BUDGET)),
5421 penalty_subspace_cache: RwLock::new(PenaltySubspaceCache::new()),
5422 current_eval_bundle: RwLock::new(None),
5423 current_outer_eval: RwLock::new(None),
5424 outer_eval_lru: RwLock::new(OuterEvalLru::new(OUTER_EVAL_LRU_CAPACITY)),
5425 pirls_cache_enabled: AtomicBool::new(true),
5426 }
5427 }
5428
5429 pub(super) fn cached_penalty_subspace<F>(
5436 &self,
5437 e_transformed: &ndarray::Array2<f64>,
5438 ridge_passport: &gam_problem::RidgePassport,
5439 build: F,
5440 ) -> Result<Arc<outer_eval::PenaltySubspace>, EstimationError>
5441 where
5442 F: FnOnce() -> Result<outer_eval::PenaltySubspace, EstimationError>,
5443 {
5444 let key = PenaltySubspaceCacheKey::from_inputs(e_transformed, ridge_passport);
5445 if let Some(hit) = self.penalty_subspace_cache.read().unwrap().get(&key) {
5446 return Ok(hit);
5447 }
5448 let value = Arc::new(build()?);
5449 self.penalty_subspace_cache
5450 .write()
5451 .unwrap()
5452 .insert(key, value.clone());
5453 Ok(value)
5454 }
5455
5456 pub(crate) fn cached_eval_bundle(&self, key: &Option<Vec<u64>>) -> Option<EvalShared> {
5457 let guard = self.current_eval_bundle.read().unwrap();
5458 let bundle: &EvalShared = guard.as_ref()?;
5459 bundle.matches(key).then(|| bundle.clone())
5460 }
5461
5462 pub(crate) fn store_eval_bundle(&self, bundle: EvalShared) {
5463 *self.current_eval_bundle.write().unwrap() = Some(bundle);
5464 }
5465
5466 pub(crate) fn cached_outer_eval(&self, key: &Option<Vec<u64>>) -> Option<OuterEval> {
5467 let key = key.as_ref()?;
5468 self.outer_eval_lru.write().unwrap().get(key)
5475 }
5476
5477 pub(crate) fn store_outer_eval(&self, key: &Option<Vec<u64>>, eval: &OuterEval) {
5478 if let Some(key) = key.clone() {
5479 *self.current_outer_eval.write().unwrap() = Some((key.clone(), eval.clone()));
5483 self.outer_eval_lru
5484 .write()
5485 .unwrap()
5486 .insert(key, eval.clone());
5487 }
5488 }
5489
5490 pub(crate) fn invalidate_eval_bundle(&self) {
5491 self.current_eval_bundle.write().unwrap().take();
5492 self.current_outer_eval.write().unwrap().take();
5493 self.outer_eval_lru.write().unwrap().clear();
5494 }
5495
5496 pub(crate) fn clear_eval_and_factor_caches(&self) {
5497 self.invalidate_eval_bundle();
5498 self.penalty_subspace_cache.write().unwrap().clear();
5499 }
5500}
5501
5502pub(crate) struct RemlArena {
5505 pub(crate) cost_eval_count: RwLock<u64>,
5506 pub(crate) inner_pirls_solve_count: AtomicU64,
5519 pub(crate) lastgradient_used_stochastic_fallback: AtomicBool,
5520}
5521
5522impl RemlArena {
5523 pub(crate) fn new() -> Self {
5524 Self {
5525 cost_eval_count: RwLock::new(0),
5526 inner_pirls_solve_count: AtomicU64::new(0),
5527 lastgradient_used_stochastic_fallback: AtomicBool::new(false),
5528 }
5529 }
5530}
5531
5532pub(crate) struct RemlState<'a> {
5533 pub(crate) y: ArrayView1<'a, f64>,
5534 pub(crate) x: DesignMatrix,
5535 pub(crate) weights: ArrayView1<'a, f64>,
5536 pub(crate) offset: Array1<f64>,
5537 pub(crate) canonical_penalties: Arc<Vec<gam_terms::construction::CanonicalPenalty>>,
5541 pub(crate) balanced_penalty_root: Array2<f64>,
5542 pub(crate) reparam_invariant: ReparamInvariant,
5543 pub(crate) sparse_penalty_block_count: Option<usize>,
5544 pub(crate) p: usize,
5545 pub(crate) config: Arc<RemlConfig>,
5546 pub(crate) runtime_mixture_link_state: Option<gam_problem::MixtureLinkState>,
5547 pub(crate) runtime_sas_link_state: Option<SasLinkState>,
5548 pub(crate) nullspace_dims: Vec<usize>,
5549 pub(crate) coefficient_lower_bounds: Option<Array1<f64>>,
5550 pub(crate) linear_constraints: Option<crate::pirls::LinearInequalityConstraints>,
5551 pub(crate) penalty_shrinkage_floor: Option<f64>,
5553 pub(crate) rho_prior: gam_problem::RhoPrior,
5555
5556 pub(crate) cache_manager: EvalCacheManager,
5557 pub(crate) arena: RemlArena,
5558 pub(crate) warm_start_beta: RwLock<Option<Coefficients>>,
5559 pub(crate) warm_start_rho: RwLock<Option<Array1<f64>>>,
5569 pub(crate) prev_warm_start_beta: RwLock<Option<Coefficients>>,
5570 pub(crate) prev_warm_start_rho: RwLock<Option<Array1<f64>>>,
5571 pub(crate) ift_quality_runtime: std::sync::Mutex<outer_eval::IftQualityRuntimeState>,
5591 pub(crate) hypergradient_runtime:
5592 std::sync::Mutex<Option<outer_eval::HyperGradientRuntimeState>>,
5593 pub(crate) ift_mode_response_slot:
5594 std::sync::Mutex<Option<outer_eval::IftModeResponseRuntimeCache>>,
5595 pub(crate) ift_joint_mode_response_slot:
5596 std::sync::Mutex<Option<outer_eval::IftJointModeResponseRuntimeCache>>,
5597 pub(crate) warm_start_enabled: AtomicBool,
5598 pub(crate) screening_max_inner_iterations: Arc<AtomicUsize>,
5599 pub(crate) outer_inner_cap: Arc<AtomicUsize>,
5614
5615 pub(crate) last_inner_iters: Arc<AtomicUsize>,
5628 pub(crate) last_inner_converged: Arc<AtomicBool>,
5629
5630 pub(crate) ift_warm_start_cache: RwLock<Option<IftWarmStartCache>>,
5646
5647 pub(crate) last_pirls_lm_lambda: Arc<AtomicU64>,
5659
5660 pub(crate) frozen_negbin_theta: Arc<AtomicU64>,
5672
5673 pub(crate) frozen_tweedie_phi: Arc<AtomicU64>,
5687
5688 pub(crate) frozen_gamma_shape: Arc<AtomicU64>,
5705
5706 pub(crate) frozen_beta_phi: Arc<AtomicU64>,
5724
5725 pub(crate) last_ift_prediction_residual: Arc<AtomicU64>,
5747
5748 pub(crate) last_pirls_accept_rho: Arc<AtomicU64>,
5763
5764 pub(crate) ift_cached_factor: RwLock<Option<Arc<dyn gam_linalg::matrix::FactorizedSystem>>>,
5775
5776 pub(crate) kronecker_penalty_system: Option<gam_terms::smooth::KroneckerPenaltySystem>,
5780 pub(crate) kronecker_factored: Option<gam_terms::basis::KroneckerFactoredBasis>,
5783
5784 pub(crate) gaussian_fixed_cache: RwLock<Option<Arc<crate::pirls::GaussianFixedCache>>>,
5794 pub(crate) gaussian_cost_only_frozen_rows:
5802 RwLock<Option<Arc<crate::pirls::GaussianFrozenRows>>>,
5803 pub(crate) gaussian_psi_gram_deriv:
5814 RwLock<Option<Arc<(ndarray::Array2<f64>, ndarray::Array1<f64>)>>>,
5815 pub(crate) glm_psi_gram_deriv:
5833 RwLock<Option<Arc<(ndarray::Array2<f64>, ndarray::Array1<f64>)>>>,
5834 pub(crate) glm_first_step_gram: RwLock<Option<Arc<ndarray::Array2<f64>>>>,
5853 pub(crate) flat_glm_first_step_gram:
5868 RwLock<Option<gam_runtime::resource::Governed<Arc<ndarray::Array2<f64>>>>>,
5869 pub(crate) persistent_warm_start_key: RwLock<Option<String>>,
5872 pub(crate) persistent_latent_values_fingerprint: Option<u64>,
5873 pub(crate) persistent_latent_values_cache: RwLock<PersistentLatentValuesCache>,
5874 pub(crate) analytic_penalty_registry_fingerprint: u64,
5875 pub(crate) persistent_warm_start_loaded: AtomicBool,
5877 pub(crate) persistent_warm_start_store_suppression: AtomicUsize,
5881 pub(crate) persistent_warm_start_disk_enabled: AtomicBool,
5895 pub(crate) gaussian_weight_log_sum_half_cache: std::sync::OnceLock<f64>,
5906 pub(crate) gaussian_dp_floor_scale_cache: std::sync::OnceLock<f64>,
5907 pub(crate) positive_weight_observation_count_cache: std::sync::OnceLock<usize>,
5908 pub(crate) rho_weight_anchor_cache: std::sync::OnceLock<f64>,
5909}