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]
794 fn gaussian_profiled_diagonal_seed_clamps_into_its_validated_box() {
795 let n = 40usize;
798 let y = Array1::from_iter((0..n).map(|i| {
799 let t = (i as f64 + 0.5) / n as f64;
800 (std::f64::consts::TAU * t).sin() + 0.05 * (i as f64 % 3.0 - 1.0)
801 }));
802 let w = Array1::<f64>::ones(n);
803 let mut x = Array2::<f64>::zeros((n, 3));
804 for i in 0..n {
805 let t = (i as f64 + 0.5) / n as f64;
806 x[[i, 0]] = 1.0;
807 x[[i, 1]] = t;
808 x[[i, 2]] = t * t;
809 }
810 let offset = Array1::<f64>::zeros(n);
811 let cfg = RemlConfig::external(gaussian_identity_glm_spec(), 1e-10, false);
812 let p = x.ncols();
813 let canonical = vec![gam_terms::construction::CanonicalPenalty::from_dense_root(
814 array![[0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
815 p,
816 )];
817 let state = RemlState::newwith_offset(
818 y.view(),
819 x,
820 w.view(),
821 offset.view(),
822 canonical,
823 p,
824 &cfg,
825 Some(vec![1]),
826 None,
827 None,
828 )
829 .expect("state");
830
831 let wide = gam_problem::OrderedRhoBounds::new(-12.0, 12.0).unwrap();
834 let seed_wide = state
835 .analytic_gaussian_profiled_diagonal_rho(wide)
836 .expect("no error")
837 .expect("gaussian-identity profiled diagonal returns a seed");
838 let natural = seed_wide[0];
839 for &r in seed_wide.iter() {
840 assert!(r.is_finite(), "seed coordinate is finite");
841 assert!(
842 (-12.0..=12.0).contains(&r),
843 "seed {r} stays inside the wide box"
844 );
845 }
846
847 let cap_hi = natural - 2.0;
853 let cap_lo = natural - 10.0;
854 let capped = gam_problem::OrderedRhoBounds::new(cap_lo, cap_hi).unwrap();
855 let seed_capped = state
856 .analytic_gaussian_profiled_diagonal_rho(capped)
857 .expect("no error")
858 .expect("seed present");
859 assert!(
860 seed_capped.iter().all(|&r| (r - cap_hi).abs() < 1e-9),
861 "capped seed {seed_capped:?} clamps to the binding upper bound {cap_hi}"
862 );
863 }
864
865 #[test]
866 fn canonical_logit_firth_declines_exact_tk_hessian_when_row_pair_work_is_large() {
867 let n = 2_000usize;
868 let p = 28usize;
869 let y = Array1::from_iter((0..n).map(|i| if i % 3 == 0 { 1.0 } else { 0.0 }));
870 let w = Array1::<f64>::ones(n);
871 let mut x = Array2::<f64>::zeros((n, p));
872 for i in 0..n {
873 let t = (i as f64 + 0.5) / n as f64;
874 x[[i, 0]] = 1.0;
875 for j in 1..p {
876 x[[i, j]] = ((j as f64) * std::f64::consts::TAU * t).sin()
877 + 0.25 * (((j + 1) as f64) * std::f64::consts::TAU * t).cos();
878 }
879 }
880 let mut s = Array2::<f64>::zeros((p, p));
881 for j in 1..p {
882 s[[j, j]] = 1.0;
883 }
884 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, true);
885 let state = build_logit_state(&y, &w, &x, &s, &cfg);
886
887 assert!(
888 !RemlState::firth_tk_exact_hessian_scale_allows(n, p),
889 "fixture must sit beyond the O(n²·p) exact-Hessian budget"
890 );
891 assert!(
892 !state.analytic_outer_hessian_enabled(),
893 "large canonical-logit Firth fits should keep exact value/gradient but route outer curvature to BFGS"
894 );
895 }
896
897 #[test]
898 fn canonical_logit_firth_keeps_exact_tk_hessian_for_small_separation_guards() {
899 let n = 40usize;
900 let p = 6usize;
901 let y = Array1::from_iter((0..n).map(|i| if i >= n / 2 { 1.0 } else { 0.0 }));
902 let w = Array1::<f64>::ones(n);
903 let mut x = Array2::<f64>::zeros((n, p));
904 for i in 0..n {
905 let t = (i as f64) / (n - 1) as f64;
906 x[[i, 0]] = 1.0;
907 for j in 1..p {
908 x[[i, j]] = t.powi(j as i32);
909 }
910 }
911 let mut s = Array2::<f64>::zeros((p, p));
912 for j in 1..p {
913 s[[j, j]] = 1.0;
914 }
915 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, true);
916 let state = build_logit_state(&y, &w, &x, &s, &cfg);
917
918 assert!(RemlState::firth_tk_exact_hessian_scale_allows(n, p));
919 assert!(
920 state.analytic_outer_hessian_enabled(),
921 "small Firth rescue fits should keep exact TK Hessian curvature"
922 );
923 }
924
925 #[test]
926 fn nonlogit_firth_keeps_tk_value_and_gradient() {
927 let y = array![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
928 let w = Array1::<f64>::ones(y.len());
929 let x = array![
930 [1.0, -1.0, 0.3],
931 [1.0, -0.7, -0.2],
932 [1.0, -0.3, 0.4],
933 [1.0, 0.0, -0.5],
934 [1.0, 0.2, 0.6],
935 [1.0, 0.6, -0.4],
936 [1.0, 0.9, 0.2],
937 [1.0, 1.3, -0.1],
938 ];
939 let s = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.1], [0.0, 0.1, 0.7]];
940 let rho = array![0.15];
941
942 for link in [StandardLink::Probit, StandardLink::CLogLog] {
943 let likelihood = GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
944 ResponseFamily::Binomial,
945 InverseLink::Standard(link),
946 ));
947 let cfg = RemlConfig::external(likelihood, 1e-9, true).with_max_iterations(500);
948 let state = build_logit_state(&y, &w, &x, &s, &cfg);
949 assert!(
950 !state.analytic_outer_hessian_enabled(),
951 "{link:?} should use BFGS curvature until exact f_obs is available"
952 );
953
954 let bundle = state
955 .obtain_eval_bundle(&rho)
956 .expect("non-logit Firth bundle");
957 let atom = state
958 .tierney_kadane_terms(
959 &rho,
960 &bundle,
961 super::reml_outer_engine::EvalMode::ValueAndGradient,
962 &[],
963 )
964 .expect("non-logit TK correction");
965 let value = CriterionAtom::value(&atom);
966 let gradient = atom.gradient().expect("TK gradient");
967 assert!(
968 value.is_finite() && value.abs() > 1e-12,
969 "{link:?} must receive a material finite TK correction, got {value}"
970 );
971 assert_eq!(gradient.len(), rho.len());
972 assert!(
973 gradient.iter().all(|entry| entry.is_finite()),
974 "{link:?} TK gradient must be finite: {gradient:?}"
975 );
976 }
977 }
978
979 pub(crate) fn poisson_log_glm_spec() -> GlmLikelihoodSpec {
980 GlmLikelihoodSpec::canonical(LikelihoodSpec::new(
981 ResponseFamily::Poisson,
982 InverseLink::Standard(StandardLink::Log),
983 ))
984 }
985
986 #[test]
1000 pub(crate) fn fixed_dispersion_laml_surface_is_replication_invariant() {
1001 let n = 200usize;
1002 let p = 8usize;
1003 let c = 3usize;
1004 let mut x = Array2::<f64>::zeros((n, p));
1005 let mut y = Array1::<f64>::zeros(n);
1006 for i in 0..n {
1007 let t = (i as f64) / ((n - 1) as f64);
1008 let tau = std::f64::consts::TAU;
1009 x[[i, 0]] = 1.0;
1010 x[[i, 1]] = t;
1011 x[[i, 2]] = (tau * t).sin();
1012 x[[i, 3]] = (tau * t).cos();
1013 x[[i, 4]] = (2.0 * tau * t).sin();
1014 x[[i, 5]] = (2.0 * tau * t).cos();
1015 x[[i, 6]] = (3.0 * tau * t).sin();
1016 x[[i, 7]] = (3.0 * tau * t).cos();
1017 let eta = 0.3 + 0.9 * (1.4 * (t - 0.5)).sin();
1018 y[i] = (eta.exp() + 0.5 * ((i as f64) * 2.399_963).sin())
1020 .round()
1021 .max(0.0);
1022 }
1023 let mut s = Array2::<f64>::zeros((p, p));
1024 for j in 1..p {
1025 s[[j, j]] = 1.0;
1026 }
1027
1028 let mut x_rep = Array2::<f64>::zeros((n * c, p));
1030 let mut y_rep = Array1::<f64>::zeros(n * c);
1031 for r in 0..c {
1032 for i in 0..n {
1033 let row = r * n + i;
1034 for j in 0..p {
1035 x_rep[[row, j]] = x[[i, j]];
1036 }
1037 y_rep[row] = y[i];
1038 }
1039 }
1040
1041 let w_weighted = Array1::<f64>::from_elem(n, c as f64);
1042 let w_rep = Array1::<f64>::ones(n * c);
1043
1044 let cfg = RemlConfig::external(poisson_log_glm_spec(), 1e-10, false);
1045 let st_w = build_logit_state(&y, &w_weighted, &x, &s, &cfg);
1046 let st_r = build_logit_state(&y_rep, &w_rep, &x_rep, &s, &cfg);
1047
1048 for &rho in &[-2.0_f64, -1.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0] {
1049 let r = Array1::from_elem(1, rho);
1050 let cw = st_w.compute_cost(&r).expect("weighted cost");
1051 let cr = st_r.compute_cost(&r).expect("replicated cost");
1052 let gw = st_w.compute_gradient(&r).expect("weighted grad");
1053 let gr = st_r.compute_gradient(&r).expect("replicated grad");
1054 assert!(
1057 (cw - cr).abs() <= 1e-9 * (1.0 + cw.abs()),
1058 "LAML cost differs between w=c and c× replication at rho={rho}: \
1059 cost_w={cw:.12e} cost_r={cr:.12e} diff={:.3e}",
1060 cw - cr
1061 );
1062 assert!(
1063 (gw[0] - gr[0]).abs() <= 1e-9 * (1.0 + gw[0].abs()),
1064 "LAML gradient differs between w=c and c× replication at rho={rho}: \
1065 g_w={:.12e} g_r={:.12e} diff={:.3e}",
1066 gw[0],
1067 gr[0],
1068 gw[0] - gr[0]
1069 );
1070 }
1071 }
1072
1073 #[test]
1082 pub(crate) fn rho_weight_anchor_is_zero_for_fixed_dispersion() {
1083 let n = 50usize;
1084 let p = 3usize;
1085 let mut x = Array2::<f64>::zeros((n, p));
1086 let mut y = Array1::<f64>::zeros(n);
1087 for i in 0..n {
1088 let t = (i as f64) / ((n - 1) as f64);
1089 x[[i, 0]] = 1.0;
1090 x[[i, 1]] = t;
1091 x[[i, 2]] = t * t;
1092 y[i] = (1.0 + (3.0 * t).sin()).round().max(0.0);
1093 }
1094 let mut s = Array2::<f64>::zeros((p, p));
1095 s[[2, 2]] = 1.0;
1096 let c = 4.0_f64;
1098 let w = Array1::<f64>::from_elem(n, c);
1099
1100 let cfg_pois = RemlConfig::external(poisson_log_glm_spec(), 1e-10, false);
1101 let st_pois = build_logit_state(&y, &w, &x, &s, &cfg_pois);
1102 assert_eq!(
1103 st_pois.rho_weight_anchor(),
1104 0.0,
1105 "fixed-dispersion (Poisson) anchor must be 0, not the geometric-mean log-weight"
1106 );
1107
1108 let cfg_gauss = RemlConfig::external(gaussian_identity_glm_spec(), 1e-10, false);
1109 let st_gauss = build_logit_state(&y, &w, &x, &s, &cfg_gauss);
1110 assert!(
1111 (st_gauss.rho_weight_anchor() - c.ln()).abs() <= 1e-12,
1112 "Gaussian-identity (profiled) anchor must be the geometric-mean log-weight ln(c)={:.6}, got {:.6}",
1113 c.ln(),
1114 st_gauss.rho_weight_anchor()
1115 );
1116 }
1117
1118 pub(crate) fn beta_original_from_bundle(bundle: &EvalShared) -> Array1<f64> {
1119 let pr = bundle.pirls_result.as_ref();
1120 match pr.coordinate_frame {
1121 PirlsCoordinateFrame::OriginalSparseNative => pr.beta_transformed.as_ref().clone(),
1122 PirlsCoordinateFrame::TransformedQs => {
1123 pr.reparam_result.qs.dot(pr.beta_transformed.as_ref())
1124 }
1125 }
1126 }
1127
1128 pub(crate) fn compute_joint_hypercostgradienthessian(
1129 state: &RemlState<'_>,
1130 theta: &Array1<f64>,
1131 rho_dim: usize,
1132 hyper_dirs: &[DirectionalHyperParam],
1133 ) -> Result<(f64, Array1<f64>, Array2<f64>), EstimationError> {
1134 let (cost, gradient, hessian) = state.compute_joint_hyper_eval_with_order(
1135 theta,
1136 rho_dim,
1137 hyper_dirs,
1138 crate::rho_optimizer::OuterEvalOrder::ValueGradientHessian,
1139 )?;
1140 Ok((
1141 cost,
1142 gradient,
1143 hessian
1144 .materialize_dense()
1145 .map_err(|error| EstimationError::RemlOptimizationFailed(error.to_string()))?
1146 .ok_or_else(|| {
1147 EstimationError::RemlOptimizationFailed(
1148 "joint hyper Hessian requested but unavailable".to_string(),
1149 )
1150 })?,
1151 ))
1152 }
1153
1154 pub(crate) fn h_original_from_bundle(bundle: &EvalShared) -> Array2<f64> {
1155 let pr = bundle.pirls_result.as_ref();
1156 match pr.coordinate_frame {
1157 PirlsCoordinateFrame::OriginalSparseNative => bundle.h_total.as_ref().clone(),
1158 PirlsCoordinateFrame::TransformedQs => {
1159 let qs = &pr.reparam_result.qs;
1160 let tmp = gam_linalg::faer_ndarray::fast_ab(qs, bundle.h_total.as_ref());
1161 gam_linalg::faer_ndarray::fast_abt(&tmp, qs)
1162 }
1163 }
1164 }
1165
1166 pub(crate) fn single_directional_tau_gradient(
1167 state: &RemlState<'_>,
1168 rho: &Array1<f64>,
1169 hyper: DirectionalHyperParam,
1170 ) -> Result<f64, EstimationError> {
1171 let mut theta = Array1::<f64>::zeros(rho.len() + 1);
1172 theta.slice_mut(s![..rho.len()]).assign(rho);
1173 let (_, gradient, _) = state.compute_joint_hyper_eval_with_order(
1174 &theta,
1175 rho.len(),
1176 &[hyper],
1177 crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1178 )?;
1179 Ok(gradient[rho.len()])
1180 }
1181
1182 pub(crate) fn fd_directional_tau_cost_gradient(
1183 y: &Array1<f64>,
1184 w: &Array1<f64>,
1185 x: &Array2<f64>,
1186 s0: &Array2<f64>,
1187 cfg: &RemlConfig,
1188 rho: &Array1<f64>,
1189 x_tau: &Array2<f64>,
1190 s_tau: &Array2<f64>,
1191 ) -> f64 {
1192 let h = 2e-5;
1193 let x_plus = x + &x_tau.mapv(|v| h * v);
1194 let x_minus = x - &x_tau.mapv(|v| h * v);
1195 let s_plus = s0 + &s_tau.mapv(|v| h * v);
1196 let s_minus = s0 - &s_tau.mapv(|v| h * v);
1197 let state_plus = build_logit_state(y, w, &x_plus, &s_plus, cfg);
1198 let state_minus = build_logit_state(y, w, &x_minus, &s_minus, cfg);
1199 let v_plus = state_plus.compute_cost(rho).expect("cost+");
1200 let v_minus = state_minus.compute_cost(rho).expect("cost-");
1201 (v_plus - v_minus) / (2.0 * h)
1202 }
1203
1204 pub(crate) fn directional_tau_hessian_fd_reference(
1205 y: &Array1<f64>,
1206 w: &Array1<f64>,
1207 x: &Array2<f64>,
1208 s0: &Array2<f64>,
1209 cfg: &RemlConfig,
1210 rho: &Array1<f64>,
1211 hyper_dirs: &[DirectionalHyperParam],
1212 x_tau_mats: &[Array2<f64>],
1213 s_tau_mats: &[Array2<f64>],
1214 ) -> Array2<f64> {
1215 assert_eq!(hyper_dirs.len(), x_tau_mats.len());
1216 assert_eq!(hyper_dirs.len(), s_tau_mats.len());
1217
1218 const TARGET_PHYSICAL_STEP: f64 = 1e-5;
1219
1220 let n_dirs = hyper_dirs.len();
1221 let mut h_ttfd = Array2::<f64>::zeros((n_dirs, n_dirs));
1222 for j in 0..n_dirs {
1223 let direction_scale = x_tau_mats[j]
1224 .iter()
1225 .chain(s_tau_mats[j].iter())
1226 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
1227 let h = if direction_scale > 0.0 {
1228 TARGET_PHYSICAL_STEP / direction_scale
1229 } else {
1230 TARGET_PHYSICAL_STEP
1231 };
1232
1233 let x_plus = x + &x_tau_mats[j].mapv(|v| h * v);
1234 let x_minus = x - &x_tau_mats[j].mapv(|v| h * v);
1235 let s_plus = s0 + &s_tau_mats[j].mapv(|v| h * v);
1236 let s_minus = s0 - &s_tau_mats[j].mapv(|v| h * v);
1237
1238 let state_plus = build_logit_state(y, w, &x_plus, &s_plus, cfg);
1239 let state_minus = build_logit_state(y, w, &x_minus, &s_minus, cfg);
1240 for i in 0..n_dirs {
1241 let g_plus =
1242 single_directional_tau_gradient(&state_plus, rho, hyper_dirs[i].clone())
1243 .expect("g+ for FD");
1244 let g_minus =
1245 single_directional_tau_gradient(&state_minus, rho, hyper_dirs[i].clone())
1246 .expect("g- for FD");
1247 h_ttfd[[i, j]] = (g_plus - g_minus) / (2.0 * h);
1248 }
1249 }
1250 symmetrize_in_place(&mut h_ttfd);
1251 h_ttfd
1252 }
1253
1254 #[test]
1255 pub(crate) fn eval_cache_manager_stores_first_order_outer_eval() {
1256 let cache = EvalCacheManager::new();
1257 let rho = array![0.25, -0.0];
1258 let rho_key = super::rho_key::sanitized_rhokey(&rho);
1259 let eval = OuterEval {
1260 cost: 3.5,
1261 gradient: array![1.0, -2.0],
1262 hessian: HessianValue::Unavailable,
1263 inner_beta_hint: None,
1264 };
1265
1266 cache.store_outer_eval(&rho_key, &eval);
1267
1268 let cached = cache
1269 .cached_outer_eval(&rho_key)
1270 .expect("first-order outer eval should be cached");
1271 assert_eq!(cached.cost, eval.cost);
1272 assert_eq!(cached.gradient, eval.gradient);
1273 assert!(matches!(cached.hessian, HessianValue::Unavailable));
1274
1275 cache.invalidate_eval_bundle();
1276 assert!(
1277 cache.cached_outer_eval(&rho_key).is_none(),
1278 "invalidating the bundle should clear the outer-eval cache too"
1279 );
1280 }
1281
1282 #[test]
1292 pub(crate) fn outer_eval_lru_hit_is_bit_identical_and_evicts_honestly_1575() {
1293 use super::OUTER_EVAL_LRU_CAPACITY;
1294
1295 let make_eval = |seed: f64| OuterEval {
1298 cost: (seed * std::f64::consts::PI).sin() / 3.0 - seed,
1299 gradient: array![seed, -seed * 2.0, seed.recip()],
1300 hessian: HessianValue::Unavailable,
1301 inner_beta_hint: Some(array![seed + 0.5, seed - 0.5]),
1302 };
1303 let bits_eq = |a: &OuterEval, b: &OuterEval| -> bool {
1304 a.cost.to_bits() == b.cost.to_bits()
1305 && a.gradient.len() == b.gradient.len()
1306 && a.gradient
1307 .iter()
1308 .zip(b.gradient.iter())
1309 .all(|(x, y)| x.to_bits() == y.to_bits())
1310 };
1311
1312 let cache = EvalCacheManager::new();
1313
1314 let rho_a = array![0.25, -1.5];
1317 let key_a = super::rho_key::sanitized_rhokey(&rho_a);
1318 let eval_a = make_eval(0.25);
1319 cache.store_outer_eval(&key_a, &eval_a);
1320 let hit_a = cache
1321 .cached_outer_eval(&key_a)
1322 .expect("stored rho_a must hit");
1323 assert!(
1324 bits_eq(&hit_a, &eval_a),
1325 "cache hit must be bit-identical (cost+gradient) to the stored miss-path eval"
1326 );
1327 assert_eq!(
1328 hit_a.inner_beta_hint.as_ref().map(|b| b.to_vec()),
1329 eval_a.inner_beta_hint.as_ref().map(|b| b.to_vec()),
1330 "inner_beta_hint must round-trip unchanged"
1331 );
1332
1333 let rho_b = array![0.25, -1.4999999999999998];
1336 let key_b = super::rho_key::sanitized_rhokey(&rho_b);
1337 assert_ne!(key_a, key_b, "the two rho-keys must differ");
1338 let eval_b = make_eval(7.0);
1339 cache.store_outer_eval(&key_b, &eval_b);
1340 assert!(
1341 bits_eq(
1342 &cache.cached_outer_eval(&key_b).expect("rho_b must hit"),
1343 &eval_b
1344 ),
1345 "rho_b must return its own eval, not rho_a's"
1346 );
1347 assert!(
1348 bits_eq(
1349 &cache
1350 .cached_outer_eval(&key_a)
1351 .expect("rho_a must still hit"),
1352 &eval_a
1353 ),
1354 "rho_a must be unaffected by the rho_b insert"
1355 );
1356
1357 let cache = EvalCacheManager::new();
1361 let mut keys = Vec::new();
1362 let mut evals = Vec::new();
1363 for i in 0..OUTER_EVAL_LRU_CAPACITY {
1364 let rho = array![i as f64, -(i as f64)];
1365 let key = super::rho_key::sanitized_rhokey(&rho);
1366 let eval = make_eval(i as f64 + 0.123);
1367 cache.store_outer_eval(&key, &eval);
1368 keys.push(key);
1369 evals.push(eval);
1370 }
1371 assert_eq!(
1373 cache.outer_eval_lru.read().unwrap().entries.len(),
1374 OUTER_EVAL_LRU_CAPACITY
1375 );
1376 let rho_overflow = array![999.0, -999.0];
1378 let key_overflow = super::rho_key::sanitized_rhokey(&rho_overflow);
1379 let eval_overflow = make_eval(42.0);
1380 cache.store_outer_eval(&key_overflow, &eval_overflow);
1381 assert_eq!(
1382 cache.outer_eval_lru.read().unwrap().entries.len(),
1383 OUTER_EVAL_LRU_CAPACITY,
1384 "capacity must stay bounded"
1385 );
1386 assert!(
1387 cache.cached_outer_eval(&keys[0]).is_none(),
1388 "the least-recently-used key must be evicted and now MISS (recompute), not return stale"
1389 );
1390 assert!(
1391 bits_eq(
1392 &cache
1393 .cached_outer_eval(&keys[1])
1394 .expect("a still-resident key must hit"),
1395 &evals[1]
1396 ),
1397 "a still-resident key must return its exact stored bits"
1398 );
1399 assert!(
1400 bits_eq(
1401 &cache
1402 .cached_outer_eval(&key_overflow)
1403 .expect("the freshest key must hit"),
1404 &eval_overflow
1405 ),
1406 "the freshest key must hit with its own eval"
1407 );
1408 }
1409
1410 #[test]
1411 pub(crate) fn reset_outer_seed_state_clears_pirls_cache() {
1412 let y = array![0.0, 1.0, 1.0, 0.0, 0.0, 1.0];
1418 let w = Array1::<f64>::ones(y.len());
1419 let x = array![
1420 [1.0, -1.0, 0.2],
1421 [1.0, -0.5, -0.4],
1422 [1.0, 0.0, 0.7],
1423 [1.0, 0.4, -0.3],
1424 [1.0, 0.9, 0.1],
1425 [1.0, 1.3, -0.6],
1426 ];
1427 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.1, 0.15], [0.0, 0.15, 0.8],];
1428 let rho = array![0.0];
1429 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false);
1430 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1431
1432 state
1435 .compute_outer_eval_with_order(
1436 &rho,
1437 crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1438 )
1439 .expect("outer eval should succeed");
1440
1441 let populated_len = state.cache_manager.pirls_cache.read().unwrap().map.len();
1442 assert!(
1443 populated_len > 0,
1444 "evaluating the outer objective should populate the PIRLS LRU, got {populated_len}"
1445 );
1446
1447 state.reset_outer_seed_state();
1448
1449 let cleared_len = state.cache_manager.pirls_cache.read().unwrap().map.len();
1450 assert_eq!(
1451 cleared_len, 0,
1452 "reset_outer_seed_state must clear the cross-call PIRLS LRU; got {cleared_len} entries"
1453 );
1454 }
1455
1456 #[test]
1457 pub(crate) fn reset_outer_seed_state_preserves_frozen_negbin_theta_1448() {
1458 use std::sync::atomic::Ordering;
1475
1476 let y = array![0.0, 1.0, 1.0, 0.0, 0.0, 1.0];
1477 let w = Array1::<f64>::ones(y.len());
1478 let x = array![
1479 [1.0, -1.0, 0.2],
1480 [1.0, -0.5, -0.4],
1481 [1.0, 0.0, 0.7],
1482 [1.0, 0.4, -0.3],
1483 [1.0, 0.9, 0.1],
1484 [1.0, 1.3, -0.6],
1485 ];
1486 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.1, 0.15], [0.0, 0.15, 0.8],];
1487 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false);
1488 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1489
1490 let theta_final_bits = 2.5_f64.to_bits();
1492 state
1493 .frozen_negbin_theta
1494 .store(theta_final_bits, Ordering::Relaxed);
1495 assert_eq!(
1496 state.frozen_negbin_theta.load(Ordering::Relaxed),
1497 theta_final_bits,
1498 "precondition: the re-freeze stores θ_final into the frozen slot"
1499 );
1500
1501 state.reset_outer_seed_state();
1503
1504 assert_eq!(
1505 state.frozen_negbin_theta.load(Ordering::Relaxed),
1506 theta_final_bits,
1507 "reset_outer_seed_state (alternation-round reset) must PRESERVE the \
1508 re-frozen NB θ; clearing it would defeat the #1448 θ↔λ alternation \
1509 (the next ρ search would re-derive θ from the seed and never reach \
1510 the joint fixed point)"
1511 );
1512 }
1513
1514 #[test]
1515 pub(crate) fn implicit_hyper_design_derivative_respects_full_model_embedding() {
1516 let operator = ImplicitDesignPsiDerivative::new(
1517 array![1.0, 2.0, 3.0, 4.0],
1518 array![0.5, -1.0, 1.5, 2.0],
1519 array![0.1, 0.2, 0.3, 0.4],
1520 array![[1.0, 0.2], [0.5, 0.1], [1.5, 0.3], [2.0, 0.4]],
1521 None,
1522 None,
1523 2,
1524 2,
1525 1,
1526 2,
1527 );
1528 let local = operator
1529 .materialize_first(0)
1530 .expect("materialized first derivative");
1531 assert_eq!(
1532 local.ncols(),
1533 3,
1534 "operator-local derivative should stay smooth-local"
1535 );
1536
1537 let implicit = HyperDesignDerivative::from_implicit(
1538 Arc::new(operator),
1539 ImplicitDerivLevel::First(0),
1540 1..4,
1541 5,
1542 );
1543 let embedded = HyperDesignDerivative::from_embedded(local.clone(), 1..4, 5);
1544
1545 assert_eq!(implicit.nrows(), embedded.nrows());
1546 assert_eq!(implicit.ncols(), 5);
1547 assert_eq!(implicit.materialize(), embedded.materialize());
1548
1549 let u = array![7.0, 1.5, -2.0, 0.25, -3.0];
1550 let v = array![0.75, -1.25];
1551 assert_eq!(
1552 implicit.forward_mul_original(&u).expect("implicit forward"),
1553 embedded.forward_mul_original(&u).expect("embedded forward")
1554 );
1555 assert_eq!(
1556 implicit
1557 .transpose_mul_original(&v)
1558 .expect("implicit transpose"),
1559 embedded
1560 .transpose_mul_original(&v)
1561 .expect("embedded transpose")
1562 );
1563
1564 let qs = array![
1565 [1.0, 0.0, 0.0],
1566 [0.0, 1.0, 0.0],
1567 [0.0, 0.5, 0.5],
1568 [0.0, 0.0, 1.0],
1569 [0.0, 0.0, 0.0],
1570 ];
1571 assert_eq!(
1572 implicit
1573 .transformed(&qs, None)
1574 .expect("implicit transformed"),
1575 embedded
1576 .transformed(&qs, None)
1577 .expect("embedded transformed")
1578 );
1579 let u_transformed = array![1.0, -0.5, 2.0];
1580 assert_eq!(
1581 implicit
1582 .transformed_forward_mul(&qs, None, &u_transformed)
1583 .expect("implicit transformed forward"),
1584 embedded
1585 .transformed_forward_mul(&qs, None, &u_transformed)
1586 .expect("embedded transformed forward")
1587 );
1588 assert_eq!(
1589 implicit
1590 .transformed_transpose_mul(&qs, None, &v)
1591 .expect("implicit transformed transpose"),
1592 embedded
1593 .transformed_transpose_mul(&qs, None, &v)
1594 .expect("embedded transformed transpose")
1595 );
1596 }
1597
1598 #[test]
1599 pub(crate) fn directional_hyper_identities_match_finite_differences_logit() {
1600 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
1601 let w = Array1::<f64>::ones(y.len());
1602 let x = array![
1603 [1.0, -1.2, 0.3],
1604 [1.0, -0.8, -0.4],
1605 [1.0, -0.3, 0.7],
1606 [1.0, 0.1, -0.9],
1607 [1.0, 0.5, 0.2],
1608 [1.0, 0.9, -0.1],
1609 [1.0, 1.3, 0.8],
1610 [1.0, 1.7, -0.6],
1611 ];
1612 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
1613
1614 let x_tau = Array2::<f64>::zeros(x.raw_dim());
1619 let s_tau = array![[0.0, 0.0, 0.0], [0.0, 0.25, 0.04], [0.0, 0.04, 0.15],];
1620 let hyper =
1621 DirectionalHyperParam::single_penalty(0, x_tau.clone(), s_tau.clone(), None, None)
1622 .expect("single-penalty hyper direction");
1623 let rho = array![0.0];
1624
1625 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
1629 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1630 let bundle = state.obtain_eval_bundle(&rho).expect("bundle");
1631 let pr = bundle.pirls_result.as_ref();
1632
1633 let beta = beta_original_from_bundle(&bundle);
1634 let h_orig = h_original_from_bundle(&bundle);
1635 let u = &pr.solveweights * &(&pr.solveworking_response - &pr.final_eta);
1636
1637 let x_tau_beta = gam_linalg::faer_ndarray::fast_av(&x_tau, &beta);
1640 let weighted_x_tau_beta = &pr.finalweights * &x_tau_beta;
1641 let rhs = gam_linalg::faer_ndarray::fast_atv(&x_tau, &u)
1642 - gam_linalg::faer_ndarray::fast_atv(&x, &weighted_x_tau_beta)
1643 - s_tau.dot(&beta);
1644 let chol = h_orig.cholesky(Side::Lower).expect("chol(H)");
1645 let b_analytic = chol.solvevec(&rhs);
1646
1647 let eta_dot = &x_tau_beta + &gam_linalg::faer_ndarray::fast_av(&x, &b_analytic);
1651 let w_direction = crate::pirls::directionalworking_curvature_from_c_array(
1652 &pr.solve_c_array.to_owned(),
1653 &eta_dot,
1654 );
1655 let wx = RemlState::row_scale(&x, &pr.finalweights.to_owned());
1656 let wx_tau = RemlState::row_scale(&x_tau, &pr.finalweights.to_owned());
1657 let mut xwtau_x = x.clone();
1658 match w_direction {
1659 crate::pirls::DirectionalWorkingCurvature::Diagonal(diag) => {
1660 xwtau_x = RemlState::row_scale(&xwtau_x, &diag);
1661 }
1662 }
1663 let mut h_tau_analytic = gam_linalg::faer_ndarray::fast_atb(&x_tau, &wx);
1664 h_tau_analytic += &gam_linalg::faer_ndarray::fast_atb(&x, &wx_tau);
1665 h_tau_analytic += &gam_linalg::faer_ndarray::fast_atb(&x, &xwtau_x);
1666 h_tau_analytic += &s_tau;
1667
1668 let ell_beta = gam_linalg::faer_ndarray::fast_atv(&x, &u);
1673 let s_eff = &h_orig - &gam_linalg::faer_ndarray::fast_atb(&x, &wx);
1674 let cancellation = -ell_beta.dot(&b_analytic) + beta.dot(&s_eff.dot(&b_analytic));
1675
1676 let h = 2e-5;
1678 let x_plus = &x + &(x_tau.mapv(|v| h * v));
1679 let x_minus = &x - &(x_tau.mapv(|v| h * v));
1680 let s_plus = &s0 + &(s_tau.mapv(|v| h * v));
1681 let s_minus = &s0 - &(s_tau.mapv(|v| h * v));
1682
1683 let state_plus = build_logit_state(&y, &w, &x_plus, &s_plus, &cfg);
1684 let state_minus = build_logit_state(&y, &w, &x_minus, &s_minus, &cfg);
1685 let bundle_plus = state_plus.obtain_eval_bundle(&rho).expect("bundle+");
1686 let bundle_minus = state_minus.obtain_eval_bundle(&rho).expect("bundle-");
1687 let beta_plus = beta_original_from_bundle(&bundle_plus);
1688 let beta_minus = beta_original_from_bundle(&bundle_minus);
1689 let bfd = (&beta_plus - &beta_minus).mapv(|v| v / (2.0 * h));
1690
1691 let h_plus = h_original_from_bundle(&bundle_plus);
1692 let h_minus = h_original_from_bundle(&bundle_minus);
1693 let h_taufd = (&h_plus - &h_minus).mapv(|v| v / (2.0 * h));
1694
1695 let v_plus = state_plus.compute_cost(&rho).expect("cost+");
1696 let v_minus = state_minus.compute_cost(&rho).expect("cost-");
1697 let v_taufd = (v_plus - v_minus) / (2.0 * h);
1698
1699 let v_tau_analytic = single_directional_tau_gradient(&state, &rho, hyper.clone())
1700 .expect("analytic directional gradient");
1701
1702 let b_num = (&b_analytic - &bfd).mapv(|v| v * v).sum().sqrt();
1703 let b_den = bfd.mapv(|v| v * v).sum().sqrt().max(1e-12);
1704 let b_rel = b_num / b_den;
1705 for i in 0..b_analytic.len() {
1706 assert_eq!(
1707 b_analytic[i].signum(),
1708 bfd[i].signum(),
1709 "B sign mismatch at i={i}: analytic={} fd={}",
1710 b_analytic[i],
1711 bfd[i]
1712 );
1713 }
1714 assert!(
1715 b_rel < 2e-2,
1716 "B implicit solve mismatch vs FD: rel={b_rel:.3e}, num={b_num:.3e}, den={b_den:.3e}"
1717 );
1718
1719 let dh_num = (&h_tau_analytic - &h_taufd).mapv(|v| v * v).sum().sqrt();
1720 let dh_den = h_taufd.mapv(|v| v * v).sum().sqrt().max(1e-12);
1721 let dh_rel = dh_num / dh_den;
1722 for i in 0..h_tau_analytic.nrows() {
1723 for j in 0..h_tau_analytic.ncols() {
1724 assert_eq!(
1725 h_tau_analytic[[i, j]].signum(),
1726 h_taufd[[i, j]].signum(),
1727 "H_tau sign mismatch at ({i},{j}): analytic={} fd={}",
1728 h_tau_analytic[[i, j]],
1729 h_taufd[[i, j]]
1730 );
1731 }
1732 }
1733 assert!(
1734 dh_rel < 3e-2,
1735 "H_tau mismatch vs FD: rel={dh_rel:.3e}, num={dh_num:.3e}, den={dh_den:.3e}"
1736 );
1737
1738 let v_abs = (v_tau_analytic - v_taufd).abs();
1739 let v_rel = v_abs / v_taufd.abs().max(1e-10);
1740 assert_eq!(
1741 v_tau_analytic.signum(),
1742 v_taufd.signum(),
1743 "V_tau sign mismatch: analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
1744 );
1745 assert!(
1746 v_rel < 2e-2,
1747 "V_tau mismatch vs FD: rel={v_rel:.3e}, abs={v_abs:.3e}, analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
1748 );
1749
1750 assert!(
1751 cancellation.abs() < 1e-10,
1752 "stationarity cancellation failed: | -ell_beta^T B + beta^T S B | = {:.3e}",
1753 cancellation.abs()
1754 );
1755 }
1756
1757 #[test]
1758 pub(crate) fn firth_exacthessian_includes_analytic_tk_second_derivatives() {
1759 let y = array![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
1761 let w = Array1::<f64>::ones(y.len());
1762 let x = array![
1763 [1.0, -1.2, 0.4, -2.4],
1764 [1.0, -0.9, -0.1, -1.8],
1765 [1.0, -0.6, 0.3, -1.2],
1766 [1.0, -0.2, -0.4, -0.4],
1767 [1.0, 0.1, 0.5, 0.2],
1768 [1.0, 0.4, -0.6, 0.8],
1769 [1.0, 0.8, 0.2, 1.6],
1770 [1.0, 1.1, -0.3, 2.2],
1771 [1.0, 1.4, 0.7, 2.8],
1772 [1.0, 1.7, -0.2, 3.4],
1773 ];
1774 let s0 = array![
1775 [0.0, 0.0, 0.0, 0.0],
1776 [0.0, 1.5, 0.2, 0.0],
1777 [0.0, 0.2, 1.0, 0.0],
1778 [0.0, 0.0, 0.0, 0.5],
1779 ];
1780 let s1 = array![
1781 [0.0, 0.0, 0.0, 0.0],
1782 [0.0, 0.8, -0.1, 0.0],
1783 [0.0, -0.1, 0.6, 0.0],
1784 [0.0, 0.0, 0.0, 0.3],
1785 ];
1786 let offset = Array1::<f64>::zeros(y.len());
1787 let cfg =
1790 RemlConfig::external(binomial_logit_glm_spec(), 1e-9, true).with_max_iterations(500);
1791 let p = x.ncols();
1792 use crate::estimate::PenaltySpec;
1793 let specs = vec![PenaltySpec::Dense(s0), PenaltySpec::Dense(s1)];
1794 let canonical =
1795 gam_terms::construction::canonicalize_penalty_specs(&specs, &[1, 1], p, "test")
1796 .map(|(canonical, _)| canonical)
1797 .expect("canonicalize");
1798 let state = RemlState::newwith_offset(
1799 y.view(),
1800 x.clone(),
1801 w.view(),
1802 offset.view(),
1803 canonical,
1804 p,
1805 &cfg,
1806 Some(vec![1, 1]),
1807 None,
1808 None,
1809 )
1810 .expect("state");
1811 let rho = array![0.1, -0.2];
1812 assert!(
1813 state.analytic_outer_hessian_enabled(),
1814 "Firth logit should no longer disable analytic outer Hessian planning"
1815 );
1816 let outer = state
1817 .compute_outer_eval_with_order(
1818 &rho,
1819 crate::rho_optimizer::OuterEvalOrder::ValueGradientHessian,
1820 )
1821 .expect("outer Hessian eval should succeed");
1822 assert!(
1823 outer.hessian.is_analytic(),
1824 "outer planner should request and return an analytic Hessian"
1825 );
1826 let bundle = state.obtain_eval_bundle(&rho).expect("exact firth bundle");
1827 let h_dense = state
1828 .compute_lamlhessian_exact_from_bundle(&rho, &bundle)
1829 .expect("Firth exact Hessian should include analytic TK second derivatives");
1830 assert_eq!(h_dense.raw_dim(), ndarray::Ix2(2, 2));
1831 assert!(
1832 h_dense.iter().all(|value| value.is_finite()),
1833 "Hessian should be finite: {h_dense:?}"
1834 );
1835 }
1836
1837 #[test]
1838 pub(crate) fn firth_outer_hessian_matches_gradient_finite_difference_with_tk_terms() {
1839 let y = array![0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
1840 let w = Array1::<f64>::ones(y.len());
1841 let x = array![
1842 [1.0, -1.0, 0.3],
1843 [1.0, -0.7, -0.2],
1844 [1.0, -0.3, 0.4],
1845 [1.0, 0.0, -0.5],
1846 [1.0, 0.2, 0.6],
1847 [1.0, 0.6, -0.4],
1848 [1.0, 0.9, 0.2],
1849 [1.0, 1.3, -0.1],
1850 ];
1851 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.1], [0.0, 0.1, 0.7],];
1852 let s1 = array![[0.0, 0.0, 0.0], [0.0, 0.4, -0.05], [0.0, -0.05, 0.9],];
1853 let cfg =
1854 RemlConfig::external(binomial_logit_glm_spec(), 1e-9, true).with_max_iterations(500);
1855 let p_dim = x.ncols();
1856 use crate::estimate::PenaltySpec;
1857 let specs = vec![PenaltySpec::Dense(s0), PenaltySpec::Dense(s1)];
1858 let canonical =
1859 gam_terms::construction::canonicalize_penalty_specs(&specs, &[1, 1], p_dim, "test")
1860 .map(|(canonical, _)| canonical)
1861 .expect("canonicalize");
1862 let offset = Array1::<f64>::zeros(y.len());
1863 let state = RemlState::newwith_offset(
1864 y.view(),
1865 x.clone(),
1866 w.view(),
1867 offset.view(),
1868 canonical,
1869 p_dim,
1870 &cfg,
1871 Some(vec![1, 1]),
1872 None,
1873 None,
1874 )
1875 .expect("state");
1876 let rho = array![0.15, -0.25];
1877 let eval = state
1878 .compute_outer_eval_with_order(
1879 &rho,
1880 crate::rho_optimizer::OuterEvalOrder::ValueGradientHessian,
1881 )
1882 .expect("analytic Hessian eval");
1883 let h = match eval.hessian {
1884 HessianValue::Dense(hessian) => hessian,
1885 HessianValue::Operator(_) | HessianValue::Unavailable => {
1886 panic!("expected dense analytic Hessian")
1887 }
1888 };
1889 let delta = 2.0e-5;
1890 for col in 0..rho.len() {
1891 let mut rp = rho.clone();
1892 let mut rm = rho.clone();
1893 rp[col] += delta;
1894 rm[col] -= delta;
1895 let gp = state
1896 .compute_outer_eval_with_order(
1897 &rp,
1898 crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1899 )
1900 .expect("plus grad")
1901 .gradient;
1902 let gm = state
1903 .compute_outer_eval_with_order(
1904 &rm,
1905 crate::rho_optimizer::OuterEvalOrder::ValueAndGradient,
1906 )
1907 .expect("minus grad")
1908 .gradient;
1909 for row in 0..rho.len() {
1910 let fd = (gp[row] - gm[row]) / (2.0 * delta);
1911 let an = h[[row, col]];
1912 let rel = (fd - an).abs() / fd.abs().max(an.abs()).max(1e-6);
1913 assert!(
1914 rel < 2.0e-3,
1915 "Hessian mismatch ({row},{col}): analytic={an:.9e}, fd={fd:.9e}, rel={rel:.3e}"
1916 );
1917 }
1918 }
1919 }
1920
1921 #[test]
1922 pub(crate) fn firthgradient_lives_in_design_column_space_under_rank_deficiency() {
1923 let x = array![
1925 [1.0, -1.2, 0.4, -2.4],
1926 [1.0, -0.9, -0.1, -1.8],
1927 [1.0, -0.6, 0.3, -1.2],
1928 [1.0, -0.2, -0.4, -0.4],
1929 [1.0, 0.1, 0.5, 0.2],
1930 [1.0, 0.4, -0.6, 0.8],
1931 [1.0, 0.8, 0.2, 1.6],
1932 [1.0, 1.1, -0.3, 2.2],
1933 ];
1934 let beta = array![0.1, -0.2, 0.3, 0.05];
1935 let eta = x.dot(&beta);
1936 let op = super::RemlState::build_firth_dense_operator_for_link(
1937 &gam_problem::InverseLink::Standard(gam_problem::StandardLink::Logit),
1938 &x,
1939 &eta,
1940 ndarray::Array1::ones(x.nrows()).view(),
1941 )
1942 .expect("firth operator");
1943
1944 let gradphi = 0.5 * x.t().dot(&(&op.w1 * &op.h_diag));
1947
1948 let q = &op.q_basis;
1950 let proj = q.dot(&q.t().dot(&gradphi));
1951 let resid = &gradphi - &proj;
1952 let rel =
1953 resid.mapv(|v| v * v).sum().sqrt() / gradphi.mapv(|v| v * v).sum().sqrt().max(1e-12);
1954 assert!(
1955 rel < 1e-10,
1956 "Firth gradient should lie in Col(Xᵀ): rel residual={rel:.3e}"
1957 );
1958 }
1959
1960 #[test]
1961 pub(crate) fn firth_logit_directional_hypergradient_accepts_penalty_only_with_full_tk_gradient()
1962 {
1963 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
1964 let w = Array1::<f64>::ones(y.len());
1965 let x = array![
1966 [1.0, -1.1, 0.2],
1967 [1.0, -0.6, -0.3],
1968 [1.0, -0.1, 0.5],
1969 [1.0, 0.3, -0.7],
1970 [1.0, 0.8, 0.1],
1971 [1.0, 1.2, -0.4],
1972 ];
1973 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
1974 let hyper = DirectionalHyperParam::single_penalty(
1975 0,
1976 Array2::<f64>::zeros((x.nrows(), x.ncols())),
1977 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.03], [0.0, 0.03, 0.12],],
1978 None,
1979 None,
1980 )
1981 .expect("single-penalty hyper direction");
1982 let rho = array![0.0];
1983 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-8, true);
1984 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
1985 let gradient = single_directional_tau_gradient(&state, &rho, hyper)
1986 .expect("Firth penalty-only directional gradient should use analytic TK propagation");
1987 assert!(gradient.is_finite(), "gradient={gradient}");
1988 let fd = fd_directional_tau_cost_gradient(
1989 &y,
1990 &w,
1991 &x,
1992 &s0,
1993 &cfg,
1994 &rho,
1995 &Array2::<f64>::zeros((x.nrows(), x.ncols())),
1996 &array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.03], [0.0, 0.03, 0.12],],
1997 );
1998 let rel = (gradient - fd).abs() / gradient.abs().max(fd.abs()).max(1.0e-10);
1999 assert!(
2000 rel < 1.0e-3,
2001 "Firth penalty-only directional gradient mismatch: analytic={gradient:.12e}, fd={fd:.12e}, rel={rel:.3e}"
2002 );
2003
2004 let efs_hyper = DirectionalHyperParam::single_penalty(
2005 0,
2006 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2007 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.03], [0.0, 0.03, 0.12],],
2008 None,
2009 None,
2010 )
2011 .expect("single-penalty EFS hyper direction");
2012 let efs = state
2013 .compute_efs_steps_with_psi_ext(&rho, &[efs_hyper])
2014 .expect("Firth penalty-only EFS should use analytic TK propagation");
2015 assert!(efs.cost.is_finite(), "efs cost={}", efs.cost);
2016 }
2017
2018 #[test]
2038 pub(crate) fn firth_logit_rho_gradient_matches_finite_difference_through_inner_solve() {
2039 let x = array![[1.0, -6.0], [1.0, 0.2], [1.0, 5.8]];
2043 let y = array![0.0, 0.0, 1.0];
2044 let w = Array1::<f64>::ones(y.len());
2045 let s0 = array![[1.0, 0.0], [0.0, 1.0]];
2047 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-12, true);
2050 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2051 let delta = 1e-4_f64;
2052 for &rho in &[-0.6_f64, -0.3, 0.0, 0.3, 0.6] {
2053 let r = array![rho];
2054 let analytic = state
2055 .compute_gradient(&r)
2056 .expect("Firth LAML ρ-gradient should evaluate")[0];
2057 let cost_plus = state
2058 .compute_cost(&array![rho + delta])
2059 .expect("Firth LAML cost(ρ+δ) should evaluate");
2060 let cost_minus = state
2061 .compute_cost(&array![rho - delta])
2062 .expect("Firth LAML cost(ρ−δ) should evaluate");
2063 let fd = (cost_plus - cost_minus) / (2.0 * delta);
2064 let rel = (fd - analytic).abs() / fd.abs().max(1e-3);
2065 assert!(
2066 analytic.is_finite() && fd.is_finite(),
2067 "non-finite Firth ρ-gradient at rho={rho:+.3}: fd={fd:+.6e}, analytic={analytic:+.6e}"
2068 );
2069 assert!(
2070 rel < 1e-4,
2071 "Firth ρ-gradient FD desync at rho={rho:+.3}: fd={fd:+.6e}, analytic={analytic:+.6e}, rel={rel:.3e} (>= 1e-4). \
2072 The inner P-IRLS likely converged off the Firth-KKT mode (gam#1821)."
2073 );
2074 }
2075 }
2076
2077 #[test]
2078 pub(crate) fn firth_logit_directional_hypergradient_accepts_design_moving_with_full_tk_gradient()
2079 {
2080 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
2081 let w = Array1::<f64>::ones(y.len());
2082 let x = array![
2083 [1.0, -1.1, 0.2],
2084 [1.0, -0.6, -0.3],
2085 [1.0, -0.1, 0.5],
2086 [1.0, 0.3, -0.7],
2087 [1.0, 0.8, 0.1],
2088 [1.0, 1.2, -0.4],
2089 ];
2090 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2091 let hyper = DirectionalHyperParam::single_penalty(
2092 0,
2093 Array2::from_elem((x.nrows(), x.ncols()), 1e-3),
2094 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2095 None,
2096 None,
2097 )
2098 .expect("single-penalty hyper direction");
2099 let rho = array![0.0];
2100 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-8, true);
2101 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2102 let gradient = single_directional_tau_gradient(&state, &rho, hyper)
2103 .expect("Firth design-moving directional gradient should use analytic TK propagation");
2104 assert!(gradient.is_finite(), "gradient={gradient}");
2105 let x_tau = Array2::from_elem((x.nrows(), x.ncols()), 1e-3);
2106 let s_tau = Array2::<f64>::zeros((x.ncols(), x.ncols()));
2107 let fd = fd_directional_tau_cost_gradient(&y, &w, &x, &s0, &cfg, &rho, &x_tau, &s_tau);
2108 let rel = (gradient - fd).abs() / gradient.abs().max(fd.abs()).max(1.0e-10);
2109 assert!(
2110 rel < 2.0e-2,
2111 "Firth design-moving directional gradient mismatch: analytic={gradient:.12e}, fd={fd:.12e}, rel={rel:.3e}"
2112 );
2113 }
2114
2115 #[test]
2116 pub(crate) fn firth_logit_hybrid_efs_accepts_full_tk_psi_gradient() {
2117 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
2118 let w = Array1::<f64>::ones(y.len());
2119 let x = array![
2120 [1.0, -1.1, 0.2],
2121 [1.0, -0.6, -0.3],
2122 [1.0, -0.1, 0.5],
2123 [1.0, 0.3, -0.7],
2124 [1.0, 0.8, 0.1],
2125 [1.0, 1.2, -0.4],
2126 ];
2127 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2128 let hyper_dirs = vec![
2129 DirectionalHyperParam::single_penalty(
2130 0,
2131 Array2::from_shape_fn((x.nrows(), x.ncols()), |(i, j)| {
2132 1e-3 * ((i + 1) as f64) * ((j + 2) as f64)
2133 }),
2134 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2135 None,
2136 None,
2137 )
2138 .expect("design-moving hyper direction"),
2139 ];
2140 let rho = array![0.0];
2141 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-8, true);
2142 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2143
2144 let full = state
2145 .evaluate_unified_with_psi_ext(
2146 &rho,
2147 None,
2148 crate::estimate::reml::reml_outer_engine::EvalMode::ValueAndGradient,
2149 &hyper_dirs,
2150 )
2151 .expect("full Firth psi gradient should use analytic TK propagation");
2152 assert!(full.cost.is_finite(), "full cost={}", full.cost);
2153 let full_grad = full.gradient.expect("gradient should be present");
2154 assert!(
2155 full_grad.iter().all(|value| value.is_finite()),
2156 "full gradient={full_grad:?}"
2157 );
2158
2159 let efs = state
2160 .compute_efs_steps_with_psi_ext(&rho, &hyper_dirs)
2161 .expect("hybrid EFS should use analytic TK propagation");
2162 assert!(efs.cost.is_finite(), "efs cost={}", efs.cost);
2163 }
2164
2165 #[test]
2166 pub(crate) fn joint_hyperhessianwires_mixed_blocks() {
2167 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
2168 let w = Array1::<f64>::ones(y.len());
2169 let x = array![
2170 [1.0, -1.2, 0.3],
2171 [1.0, -0.8, -0.4],
2172 [1.0, -0.3, 0.7],
2173 [1.0, 0.1, -0.9],
2174 [1.0, 0.5, 0.2],
2175 [1.0, 0.9, -0.1],
2176 [1.0, 1.3, 0.8],
2177 [1.0, 1.7, -0.6],
2178 ];
2179 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
2180 let cfg =
2181 RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false).with_max_iterations(500);
2182 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2183 let rho = array![0.0];
2184 let theta = array![0.0, 0.0, 0.0];
2185 let hyper_dirs = vec![
2186 DirectionalHyperParam::single_penalty(
2187 0,
2188 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2189 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15],],
2190 None,
2191 None,
2192 )
2193 .expect("single-penalty hyper direction"),
2194 DirectionalHyperParam::single_penalty(
2195 0,
2196 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2197 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2198 None,
2199 None,
2200 )
2201 .expect("single-penalty hyper direction"),
2202 ];
2203
2204 let (_, _, h) =
2205 compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2206 .expect("joint hyper cost+gradient+hessian");
2207 assert_eq!(h.nrows(), theta.len());
2208 assert_eq!(h.ncols(), theta.len());
2209 assert!(h.iter().all(|v| v.is_finite()));
2210 for i in 0..h.nrows() {
2211 for j in 0..i {
2212 let diff = (h[[i, j]] - h[[j, i]]).abs();
2213 assert!(
2214 diff < 1e-6,
2215 "joint hessian asymmetry at ({i},{j}): {diff:.3e}"
2216 );
2217 }
2218 }
2219 let mixed_0 = h[[0, 1]];
2221 let mixed_1 = h[[0, 2]];
2222 assert!(
2223 mixed_0.is_finite() && mixed_1.is_finite(),
2224 "mixed blocks must be finite"
2225 );
2226 }
2227
2228 #[test]
2229 pub(crate) fn joint_tau_tau_linear_dirs_matchfd_reference_away_fromzero_psi() {
2230 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
2231 let w = Array1::<f64>::ones(y.len());
2232 let x = array![
2233 [1.0, -1.2, 0.3],
2234 [1.0, -0.8, -0.4],
2235 [1.0, -0.3, 0.7],
2236 [1.0, 0.1, -0.9],
2237 [1.0, 0.5, 0.2],
2238 [1.0, 0.9, -0.1],
2239 [1.0, 1.3, 0.8],
2240 [1.0, 1.7, -0.6],
2241 ];
2242 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
2243 let cfg =
2244 RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false).with_max_iterations(500);
2245 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2246 let rho = array![0.0];
2247 let psi = array![0.7, -0.4];
2248 let theta = array![rho[0], psi[0], psi[1]];
2249 let hyper_dirs = vec![
2250 DirectionalHyperParam::single_penalty(
2251 0,
2252 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2253 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15],],
2254 None,
2255 None,
2256 )
2257 .expect("linear tau direction"),
2258 DirectionalHyperParam::single_penalty(
2259 0,
2260 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2261 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2262 None,
2263 None,
2264 )
2265 .expect("linear tau direction"),
2266 ];
2267
2268 let (_, _, h_full) =
2269 compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2270 .expect("joint hyper cost+gradient+hessian");
2271 let h_tt_analytic = h_full.slice(s![rho.len().., rho.len()..]).to_owned();
2272
2273 let x_tau_mats: Vec<Array2<f64>> = vec![
2278 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2279 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2280 ];
2281 let s_tau_mats: Vec<Array2<f64>> = vec![
2282 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15]],
2283 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2284 ];
2285
2286 let h_ttfd = directional_tau_hessian_fd_reference(
2287 &y,
2288 &w,
2289 &x,
2290 &s0,
2291 &cfg,
2292 &rho,
2293 &hyper_dirs,
2294 &x_tau_mats,
2295 &s_tau_mats,
2296 );
2297
2298 let num = (&h_tt_analytic - &h_ttfd)
2299 .iter()
2300 .map(|v| v * v)
2301 .sum::<f64>()
2302 .sqrt();
2303 let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2304 let rel = num / den;
2305 assert!(
2306 rel < 1e-4,
2307 "linear-dir joint tau-tau block deviates from FD reference away from zero psi: rel={rel:.3e}, analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2308 );
2309 }
2310
2311 #[test]
2312 pub(crate) fn joint_hypervalidation_rejects_out_of_boundssecond_order_penalty_index() {
2313 let y = array![0.0, 1.0, 0.0, 1.0];
2330 let w = Array1::<f64>::ones(y.len());
2331 let x = array![
2332 [1.0, -0.5, 0.2],
2333 [1.0, -0.1, -0.3],
2334 [1.0, 0.4, 0.6],
2335 [1.0, 0.9, -0.2],
2336 ];
2337 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.0, 0.1], [0.0, 0.1, 0.8],];
2338 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-10, true);
2339 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2340 let theta = array![0.0, 0.0];
2341 let hyper_dirs = vec![
2342 DirectionalHyperParam::new(
2343 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2344 vec![(0, Array2::<f64>::zeros((x.ncols(), x.ncols())))],
2345 None,
2346 Some(vec![Some(vec![(1, Array2::<f64>::eye(x.ncols()))])]),
2347 )
2348 .expect("hyper direction with invalid second-order penalty index"),
2349 ];
2350
2351 let msg = match compute_joint_hypercostgradienthessian(&state, &theta, 1, &hyper_dirs) {
2352 Ok(_) => panic!("invalid second-order penalty index should be rejected"),
2353 Err(err) => err.to_string(),
2354 };
2355 assert!(
2356 msg.contains("out of bounds") || msg.contains("penalty_index"),
2357 "unexpected validation error: {msg}"
2358 );
2359 }
2360
2361 #[test]
2362 pub(crate) fn joint_tau_tau_analytic_matchesfd_reference() {
2363 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0];
2364 let w = Array1::<f64>::ones(y.len());
2365 let x = array![
2366 [1.0, -1.2, 0.3],
2367 [1.0, -0.8, -0.4],
2368 [1.0, -0.3, 0.7],
2369 [1.0, 0.1, -0.9],
2370 [1.0, 0.5, 0.2],
2371 [1.0, 0.9, -0.1],
2372 [1.0, 1.3, 0.8],
2373 [1.0, 1.7, -0.6],
2374 ];
2375 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9],];
2376 let cfg =
2377 RemlConfig::external(binomial_logit_glm_spec(), 1e-10, false).with_max_iterations(500);
2378 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2379 let rho = array![0.0];
2380 let psi = array![0.0, 0.0];
2381 let hyper_dirs = vec![
2382 DirectionalHyperParam::single_penalty(
2383 0,
2384 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2385 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15],],
2386 None,
2387 None,
2388 )
2389 .expect("single-penalty hyper direction"),
2390 DirectionalHyperParam::single_penalty(
2391 0,
2392 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2393 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2394 None,
2395 None,
2396 )
2397 .expect("single-penalty hyper direction"),
2398 ];
2399
2400 let theta = {
2401 let mut t = Array1::<f64>::zeros(rho.len() + psi.len());
2402 t.slice_mut(s![..rho.len()]).assign(&rho);
2403 t.slice_mut(s![rho.len()..]).assign(&psi);
2404 t
2405 };
2406 let (_, _, h_full) =
2407 compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2408 .expect("joint hyper cost+gradient+hessian");
2409 let h_tt_analytic = h_full.slice(s![rho.len().., rho.len()..]).to_owned();
2410 assert_eq!(h_tt_analytic.nrows(), hyper_dirs.len());
2411 assert_eq!(h_tt_analytic.ncols(), hyper_dirs.len());
2412
2413 let x_tau_mats: Vec<Array2<f64>> = vec![
2418 Array2::<f64>::zeros((x.nrows(), x.ncols())),
2419 Array2::from_elem((x.nrows(), x.ncols()), 2e-4),
2420 ];
2421 let s_tau_mats: Vec<Array2<f64>> = vec![
2422 array![[0.0, 0.0, 0.0], [0.0, 0.2, 0.01], [0.0, 0.01, 0.15]],
2423 Array2::<f64>::zeros((x.ncols(), x.ncols())),
2424 ];
2425
2426 let h_ttfd = directional_tau_hessian_fd_reference(
2427 &y,
2428 &w,
2429 &x,
2430 &s0,
2431 &cfg,
2432 &rho,
2433 &hyper_dirs,
2434 &x_tau_mats,
2435 &s_tau_mats,
2436 );
2437
2438 let num = (&h_tt_analytic - &h_ttfd)
2439 .iter()
2440 .map(|v| v * v)
2441 .sum::<f64>()
2442 .sqrt();
2443 let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2444 let rel = num / den;
2445 assert!(
2446 rel < 1e-4,
2447 "analytic tau-tau block deviates from FD reference: rel={rel:.3e}, analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2448 );
2449 }
2450
2451 pub(crate) struct GaussianRemlFixture {
2461 pub(crate) y: Array1<f64>,
2462 pub(crate) w: Array1<f64>,
2463 pub(crate) x: Array2<f64>,
2464 pub(crate) s0: Array2<f64>,
2465 pub(crate) cfg: RemlConfig,
2466 pub(crate) rho: Array1<f64>,
2467 pub(crate) x_tau_design: Array2<f64>,
2469 pub(crate) s_tau_penalty: Array2<f64>,
2471 }
2472
2473 impl GaussianRemlFixture {
2474 pub(crate) fn new() -> Self {
2475 let y = array![0.5, 1.2, -0.3, 0.8, 1.1, -0.6, 0.9, 0.1, -0.2, 0.7];
2476 let x = array![
2477 [1.0, -1.2, 0.3],
2478 [1.0, -0.8, -0.4],
2479 [1.0, -0.3, 0.7],
2480 [1.0, 0.1, -0.9],
2481 [1.0, 0.5, 0.2],
2482 [1.0, 0.9, -0.1],
2483 [1.0, 1.3, 0.8],
2484 [1.0, 1.7, -0.6],
2485 [1.0, -0.5, 0.5],
2486 [1.0, 0.3, -0.3],
2487 ];
2488 Self {
2489 w: Array1::<f64>::ones(y.len()),
2490 y,
2491 x: x.clone(),
2492 s0: array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9]],
2493 cfg: RemlConfig::external(gaussian_identity_glm_spec(), 1e-14, false),
2494 rho: array![0.0],
2495 x_tau_design: array![
2496 [0.0, 1e-3, -2e-3],
2497 [0.0, -3e-3, 1e-3],
2498 [0.0, 2e-3, 0.5e-3],
2499 [0.0, -1e-3, 3e-3],
2500 [0.0, 0.5e-3, -1e-3],
2501 [0.0, 1.5e-3, 2e-3],
2502 [0.0, -2e-3, -0.5e-3],
2503 [0.0, 3e-3, 1e-3],
2504 [0.0, -0.5e-3, 2e-3],
2505 [0.0, 1e-3, -1.5e-3],
2506 ],
2507 s_tau_penalty: array![[0.0, 0.0, 0.0], [0.0, 0.25, 0.04], [0.0, 0.04, 0.15]],
2508 }
2509 }
2510 }
2511
2512 impl LogitDesignMotionFixture for GaussianRemlFixture {
2513 fn y(&self) -> &Array1<f64> {
2514 &self.y
2515 }
2516 fn w(&self) -> &Array1<f64> {
2517 &self.w
2518 }
2519 fn x(&self) -> &Array2<f64> {
2520 &self.x
2521 }
2522 fn s0(&self) -> &Array2<f64> {
2523 &self.s0
2524 }
2525 fn cfg(&self) -> &RemlConfig {
2526 &self.cfg
2527 }
2528 fn rho(&self) -> &Array1<f64> {
2529 &self.rho
2530 }
2531 }
2532
2533 #[test]
2534 pub(crate) fn profiled_gaussian_design_moving_gradient_matches_fd() {
2535 let f = GaussianRemlFixture::new();
2536 let state = f.state();
2537 let s_tau = Array2::<f64>::zeros((3, 3));
2538 let hyper = DirectionalHyperParam::single_penalty(
2539 0,
2540 f.x_tau_design.clone(),
2541 s_tau.clone(),
2542 None,
2543 None,
2544 )
2545 .expect("design-moving hyper direction");
2546
2547 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2548 .expect("analytic directional gradient");
2549 let v_taufd = f.fd_directional_gradient(&f.x_tau_design, &s_tau);
2550
2551 let v_rel = (v_tau_analytic - v_taufd).abs() / v_taufd.abs().max(1e-10);
2552 assert!(
2553 v_rel < 1e-3,
2554 "Gaussian REML design-moving V_tau mismatch: rel={v_rel:.3e}, \
2555 analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
2556 );
2557 }
2558
2559 #[test]
2560 pub(crate) fn profiled_gaussian_penalty_only_gradient_matches_fd() {
2561 let f = GaussianRemlFixture::new();
2562 let state = f.state();
2563 let x_tau = Array2::<f64>::zeros(f.x.raw_dim());
2564 let hyper = DirectionalHyperParam::single_penalty(
2565 0,
2566 x_tau.clone(),
2567 f.s_tau_penalty.clone(),
2568 None,
2569 None,
2570 )
2571 .expect("penalty-only hyper direction");
2572
2573 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2574 .expect("analytic directional gradient");
2575 let v_taufd = f.fd_directional_gradient(&x_tau, &f.s_tau_penalty);
2576
2577 let v_rel = (v_tau_analytic - v_taufd).abs() / v_taufd.abs().max(1e-10);
2578 assert!(
2579 v_rel < 1e-3,
2580 "Gaussian REML penalty-only V_tau mismatch: rel={v_rel:.3e}, \
2581 analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
2582 );
2583 }
2584
2585 #[test]
2586 pub(crate) fn profiled_gaussian_joint_hessian_matches_fd() {
2587 let f = GaussianRemlFixture::new();
2590 let x_tau_0 = Array2::<f64>::zeros(f.x.raw_dim());
2591 let s_tau_0 = f.s_tau_penalty.clone();
2592 let x_tau_1 = f.x_tau_design.clone();
2593 let s_tau_1 = Array2::<f64>::zeros((3, 3));
2594
2595 let hyper_dirs = vec![
2596 DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
2597 .expect("penalty-only direction"),
2598 DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
2599 .expect("design-moving direction"),
2600 ];
2601
2602 let state = f.state();
2603 let mut theta = Array1::<f64>::zeros(f.rho.len() + hyper_dirs.len());
2604 theta.slice_mut(s![..f.rho.len()]).assign(&f.rho);
2605 let (_, _, h_full) =
2606 compute_joint_hypercostgradienthessian(&state, &theta, f.rho.len(), &hyper_dirs)
2607 .expect("joint cost+gradient+hessian");
2608 let h_tt_analytic = h_full.slice(s![f.rho.len().., f.rho.len()..]).to_owned();
2609
2610 let x_tau_mats = vec![x_tau_0.clone(), x_tau_1.clone()];
2613 let s_tau_mats = vec![s_tau_0.clone(), s_tau_1.clone()];
2614 let h_ttfd = directional_tau_hessian_fd_reference(
2615 &f.y,
2616 &f.w,
2617 &f.x,
2618 &f.s0,
2619 &f.cfg,
2620 &f.rho,
2621 &hyper_dirs,
2622 &x_tau_mats,
2623 &s_tau_mats,
2624 );
2625
2626 let num = (&h_tt_analytic - &h_ttfd)
2627 .iter()
2628 .map(|v| v * v)
2629 .sum::<f64>()
2630 .sqrt();
2631 let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2632 let rel = num / den;
2633 assert!(
2634 rel < 1e-4,
2635 "Gaussian REML tau-tau Hessian mismatch: rel={rel:.3e}, \
2636 analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2637 );
2638 }
2639
2640 #[test]
2654 pub(crate) fn logit_design_moving_gradient_matches_fd() {
2655 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
2656 let w = Array1::<f64>::ones(y.len());
2657 let x = array![
2658 [1.0, -1.2, 0.3],
2659 [1.0, -0.8, -0.4],
2660 [1.0, -0.3, 0.7],
2661 [1.0, 0.1, -0.9],
2662 [1.0, 0.5, 0.2],
2663 [1.0, 0.9, -0.1],
2664 [1.0, 1.3, 0.8],
2665 [1.0, 1.7, -0.6],
2666 [1.0, -0.5, 0.5],
2667 [1.0, 0.3, -0.3],
2668 ];
2669 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9]];
2670 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
2671 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2672 let rho = array![0.0];
2673
2674 let x_tau = array![
2676 [0.0, 1e-3, -2e-3],
2677 [0.0, -3e-3, 1e-3],
2678 [0.0, 2e-3, 0.5e-3],
2679 [0.0, -1e-3, 3e-3],
2680 [0.0, 0.5e-3, -1e-3],
2681 [0.0, 1.5e-3, 2e-3],
2682 [0.0, -2e-3, -0.5e-3],
2683 [0.0, 3e-3, 1e-3],
2684 [0.0, -0.5e-3, 2e-3],
2685 [0.0, 1e-3, -1.5e-3],
2686 ];
2687 let s_tau = Array2::<f64>::zeros((3, 3));
2688 let hyper =
2689 DirectionalHyperParam::single_penalty(0, x_tau.clone(), s_tau.clone(), None, None)
2690 .expect("design-moving hyper direction");
2691
2692 let v_tau_analytic = single_directional_tau_gradient(&state, &rho, hyper)
2693 .expect("analytic directional gradient");
2694
2695 let h = 2e-5;
2696 let x_plus = &x + &x_tau.mapv(|v| h * v);
2697 let x_minus = &x - &x_tau.mapv(|v| h * v);
2698 let state_plus = build_logit_state(&y, &w, &x_plus, &s0, &cfg);
2699 let state_minus = build_logit_state(&y, &w, &x_minus, &s0, &cfg);
2700 let v_plus = state_plus.compute_cost(&rho).expect("cost+");
2701 let v_minus = state_minus.compute_cost(&rho).expect("cost-");
2702 let v_taufd = (v_plus - v_minus) / (2.0 * h);
2703
2704 let v_rel = (v_tau_analytic - v_taufd).abs() / v_taufd.abs().max(1e-10);
2705 assert!(
2706 v_rel < 1e-3,
2707 "Logit REML design-moving V_tau mismatch: rel={v_rel:.3e}, \
2708 analytic={v_tau_analytic:.6e}, fd={v_taufd:.6e}"
2709 );
2710 }
2711
2712 #[test]
2713 pub(crate) fn logit_design_moving_hessian_matches_fd() {
2714 let y = array![0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 0.0];
2719 let w = Array1::<f64>::ones(y.len());
2720 let x = array![
2721 [1.0, -1.2, 0.3],
2722 [1.0, -0.8, -0.4],
2723 [1.0, -0.3, 0.7],
2724 [1.0, 0.1, -0.9],
2725 [1.0, 0.5, 0.2],
2726 [1.0, 0.9, -0.1],
2727 [1.0, 1.3, 0.8],
2728 [1.0, 1.7, -0.6],
2729 [1.0, -0.5, 0.5],
2730 [1.0, 0.3, -0.3],
2731 ];
2732 let s0 = array![[0.0, 0.0, 0.0], [0.0, 1.2, 0.2], [0.0, 0.2, 0.9]];
2733 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
2734 let rho = array![0.0];
2735
2736 let x_tau_0 = Array2::<f64>::zeros(x.raw_dim());
2738 let s_tau_0 = array![[0.0, 0.0, 0.0], [0.0, 0.25, 0.04], [0.0, 0.04, 0.15]];
2739 let x_tau_1 = array![
2740 [0.0, 1e-3, -2e-3],
2741 [0.0, -3e-3, 1e-3],
2742 [0.0, 2e-3, 0.5e-3],
2743 [0.0, -1e-3, 3e-3],
2744 [0.0, 0.5e-3, -1e-3],
2745 [0.0, 1.5e-3, 2e-3],
2746 [0.0, -2e-3, -0.5e-3],
2747 [0.0, 3e-3, 1e-3],
2748 [0.0, -0.5e-3, 2e-3],
2749 [0.0, 1e-3, -1.5e-3],
2750 ];
2751 let s_tau_1 = Array2::<f64>::zeros((3, 3));
2752
2753 let hyper_dirs = vec![
2754 DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
2755 .expect("penalty-only direction"),
2756 DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
2757 .expect("design-moving direction"),
2758 ];
2759
2760 let state = build_logit_state(&y, &w, &x, &s0, &cfg);
2761 let mut theta = Array1::<f64>::zeros(rho.len() + hyper_dirs.len());
2762 theta.slice_mut(s![..rho.len()]).assign(&rho);
2763 let (_, _, h_full) =
2764 compute_joint_hypercostgradienthessian(&state, &theta, rho.len(), &hyper_dirs)
2765 .expect("joint cost+gradient+hessian");
2766 let h_tt_analytic = h_full.slice(s![rho.len().., rho.len()..]).to_owned();
2767
2768 let x_tau_mats = vec![x_tau_0.clone(), x_tau_1.clone()];
2769 let s_tau_mats = vec![s_tau_0.clone(), s_tau_1.clone()];
2770 let h_ttfd = directional_tau_hessian_fd_reference(
2771 &y,
2772 &w,
2773 &x,
2774 &s0,
2775 &cfg,
2776 &rho,
2777 &hyper_dirs,
2778 &x_tau_mats,
2779 &s_tau_mats,
2780 );
2781
2782 let num = (&h_tt_analytic - &h_ttfd)
2783 .iter()
2784 .map(|v| v * v)
2785 .sum::<f64>()
2786 .sqrt();
2787 let den = h_ttfd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
2788 let rel = num / den;
2789 assert!(
2790 rel < 1e-4,
2791 "Logit REML design-moving tau-tau Hessian mismatch: rel={rel:.3e}, \
2792 analytic={h_tt_analytic:?}, fd={h_ttfd:?}"
2793 );
2794 }
2795
2796 pub(crate) struct BinomialLogitDesignMotionFixture {
2806 pub(crate) y: Array1<f64>,
2807 pub(crate) w: Array1<f64>,
2808 pub(crate) x: Array2<f64>,
2809 pub(crate) s0: Array2<f64>,
2810 pub(crate) cfg: RemlConfig,
2811 pub(crate) rho: Array1<f64>,
2812 pub(crate) x_tau_design: Array2<f64>,
2814 pub(crate) s_tau_penalty: Array2<f64>,
2816 }
2817
2818 impl BinomialLogitDesignMotionFixture {
2819 pub(crate) fn new() -> Self {
2820 let y = array![
2822 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,
2823 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
2824 ];
2825 let x = array![
2827 [1.0, -1.50, 0.42, 0.88, -0.31],
2828 [1.0, -1.12, -0.65, 0.14, 1.23],
2829 [1.0, -0.80, 1.10, -0.53, 0.07],
2830 [1.0, -0.55, -0.22, 1.40, -0.90],
2831 [1.0, -0.30, 0.73, -1.05, 0.44],
2832 [1.0, -0.05, -1.33, 0.60, 0.81],
2833 [1.0, 0.18, 0.55, -0.27, -1.15],
2834 [1.0, 0.42, -0.90, 1.12, 0.33],
2835 [1.0, 0.70, 1.28, -0.78, -0.56],
2836 [1.0, 0.95, -0.18, 0.45, 1.40],
2837 [1.0, 1.20, 0.66, -1.30, -0.02],
2838 [1.0, 1.45, -1.05, 0.22, 0.68],
2839 [1.0, -1.35, 0.90, 0.55, -0.43],
2840 [1.0, -0.98, -0.40, -0.88, 1.05],
2841 [1.0, -0.62, 1.42, 0.30, -0.70],
2842 [1.0, -0.28, -0.77, -1.18, 0.52],
2843 [1.0, 0.05, 0.15, 0.95, -1.35],
2844 [1.0, 0.33, -1.20, -0.40, 0.18],
2845 [1.0, 0.60, 0.82, 1.25, -0.85],
2846 [1.0, 0.88, -0.50, -0.65, 1.10],
2847 [1.0, 1.15, 1.05, 0.10, -0.22],
2848 [1.0, -1.22, -0.95, 0.72, 0.90],
2849 [1.0, -0.75, 0.38, -1.42, 0.15],
2850 [1.0, -0.42, -1.15, 0.50, -1.08],
2851 [1.0, -0.10, 0.60, -0.15, 0.75],
2852 [1.0, 0.25, -0.28, 1.05, -0.48],
2853 [1.0, 0.52, 1.35, -0.92, 0.30],
2854 [1.0, 0.80, -0.70, 0.38, 1.20],
2855 [1.0, 1.08, 0.48, -0.60, -0.95],
2856 [1.0, 1.35, -0.55, 0.85, 0.42]
2857 ];
2858 let s0 = array![
2860 [0.0, 0.0, 0.0, 0.0, 0.0],
2861 [0.0, 1.40, 0.15, 0.05, -0.10],
2862 [0.0, 0.15, 1.10, -0.20, 0.08],
2863 [0.0, 0.05, -0.20, 0.95, 0.12],
2864 [0.0, -0.10, 0.08, 0.12, 1.25]
2865 ];
2866 let cfg = RemlConfig::external(binomial_logit_glm_spec(), 1e-14, false);
2867 let x_tau_design = array![
2870 [0.0, 1.2e-3, -0.8e-3, 0.5e-3, -1.5e-3],
2871 [0.0, -2.0e-3, 1.4e-3, -0.3e-3, 0.9e-3],
2872 [0.0, 0.6e-3, -1.1e-3, 1.8e-3, -0.4e-3],
2873 [0.0, -1.3e-3, 0.7e-3, -1.0e-3, 2.1e-3],
2874 [0.0, 0.9e-3, -0.5e-3, 0.2e-3, -0.8e-3],
2875 [0.0, -0.4e-3, 1.8e-3, -1.5e-3, 0.3e-3],
2876 [0.0, 1.5e-3, -1.3e-3, 0.8e-3, -1.1e-3],
2877 [0.0, -0.7e-3, 0.4e-3, -2.0e-3, 1.6e-3],
2878 [0.0, 2.2e-3, -0.9e-3, 1.3e-3, -0.6e-3],
2879 [0.0, -1.0e-3, 1.6e-3, -0.7e-3, 0.5e-3],
2880 [0.0, 0.3e-3, -2.1e-3, 1.1e-3, -1.8e-3],
2881 [0.0, -1.8e-3, 0.2e-3, -0.4e-3, 1.3e-3],
2882 [0.0, 1.1e-3, -1.5e-3, 2.0e-3, -0.2e-3],
2883 [0.0, -0.5e-3, 0.9e-3, -1.2e-3, 0.7e-3],
2884 [0.0, 1.7e-3, -0.3e-3, 0.6e-3, -2.0e-3],
2885 [0.0, -1.4e-3, 1.1e-3, -0.9e-3, 0.4e-3],
2886 [0.0, 0.8e-3, -1.7e-3, 1.5e-3, -0.1e-3],
2887 [0.0, -0.2e-3, 0.6e-3, -1.8e-3, 1.0e-3],
2888 [0.0, 1.4e-3, -0.4e-3, 0.3e-3, -1.3e-3],
2889 [0.0, -0.9e-3, 2.0e-3, -0.5e-3, 0.8e-3],
2890 [0.0, 0.5e-3, -1.0e-3, 1.6e-3, -0.7e-3],
2891 [0.0, -2.1e-3, 0.3e-3, -0.8e-3, 1.5e-3],
2892 [0.0, 0.7e-3, -1.8e-3, 0.9e-3, -0.3e-3],
2893 [0.0, -0.6e-3, 1.3e-3, -2.2e-3, 1.1e-3],
2894 [0.0, 1.9e-3, -0.7e-3, 0.4e-3, -0.9e-3],
2895 [0.0, -1.1e-3, 0.5e-3, -1.4e-3, 2.2e-3],
2896 [0.0, 0.4e-3, -1.6e-3, 1.2e-3, -0.5e-3],
2897 [0.0, -1.6e-3, 0.8e-3, -0.1e-3, 0.6e-3],
2898 [0.0, 1.3e-3, -2.2e-3, 0.7e-3, -1.4e-3],
2899 [0.0, -0.3e-3, 1.0e-3, -1.6e-3, 1.8e-3]
2900 ];
2901 let s_tau_penalty = array![
2903 [0.0, 0.0, 0.0, 0.0, 0.0],
2904 [0.0, 0.30, 0.05, -0.02, 0.04],
2905 [0.0, 0.05, 0.22, 0.03, -0.01],
2906 [0.0, -0.02, 0.03, 0.18, 0.06],
2907 [0.0, 0.04, -0.01, 0.06, 0.26]
2908 ];
2909 Self {
2910 w: Array1::<f64>::ones(y.len()),
2911 y,
2912 x,
2913 s0,
2914 cfg,
2915 rho: array![0.0],
2916 x_tau_design,
2917 s_tau_penalty,
2918 }
2919 }
2920 }
2921
2922 impl LogitDesignMotionFixture for BinomialLogitDesignMotionFixture {
2923 fn y(&self) -> &Array1<f64> {
2924 &self.y
2925 }
2926 fn w(&self) -> &Array1<f64> {
2927 &self.w
2928 }
2929 fn x(&self) -> &Array2<f64> {
2930 &self.x
2931 }
2932 fn s0(&self) -> &Array2<f64> {
2933 &self.s0
2934 }
2935 fn cfg(&self) -> &RemlConfig {
2936 &self.cfg
2937 }
2938 fn rho(&self) -> &Array1<f64> {
2939 &self.rho
2940 }
2941 }
2942
2943 #[test]
2946 pub(crate) fn binomial_logit_n30_design_moving_gradient_matches_fd() {
2947 let f = BinomialLogitDesignMotionFixture::new();
2954 let state = f.state();
2955 let s_tau = Array2::<f64>::zeros((5, 5));
2956 let hyper = DirectionalHyperParam::single_penalty(
2957 0,
2958 f.x_tau_design.clone(),
2959 s_tau.clone(),
2960 None,
2961 None,
2962 )
2963 .expect("design-moving hyper direction");
2964
2965 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2966 .expect("analytic directional gradient");
2967 let v_tau_fd = f.fd_directional_gradient(&f.x_tau_design, &s_tau);
2968
2969 let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
2970 assert!(
2971 v_rel < 1e-3,
2972 "Binomial-logit n=30 design-moving gradient mismatch: rel={v_rel:.3e}, \
2973 analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
2974 );
2975 }
2976
2977 #[test]
2978 pub(crate) fn binomial_logit_n30_penalty_only_gradient_matches_fd() {
2979 let f = BinomialLogitDesignMotionFixture::new();
2984 let state = f.state();
2985 let x_tau = Array2::<f64>::zeros(f.x.raw_dim());
2986 let hyper = DirectionalHyperParam::single_penalty(
2987 0,
2988 x_tau.clone(),
2989 f.s_tau_penalty.clone(),
2990 None,
2991 None,
2992 )
2993 .expect("penalty-only hyper direction");
2994
2995 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
2996 .expect("analytic directional gradient");
2997 let v_tau_fd = f.fd_directional_gradient(&x_tau, &f.s_tau_penalty);
2998
2999 let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3000 assert!(
3001 v_rel < 1e-3,
3002 "Binomial-logit n=30 penalty-only gradient mismatch: rel={v_rel:.3e}, \
3003 analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3004 );
3005 }
3006
3007 #[test]
3008 pub(crate) fn binomial_logit_n30_joint_design_penalty_gradient_matches_fd() {
3009 let f = BinomialLogitDesignMotionFixture::new();
3014 let state = f.state();
3015 let hyper = DirectionalHyperParam::single_penalty(
3016 0,
3017 f.x_tau_design.clone(),
3018 f.s_tau_penalty.clone(),
3019 None,
3020 None,
3021 )
3022 .expect("joint design+penalty hyper direction");
3023
3024 let v_tau_analytic = single_directional_tau_gradient(&state, &f.rho, hyper)
3025 .expect("analytic directional gradient");
3026 let v_tau_fd = f.fd_directional_gradient(&f.x_tau_design, &f.s_tau_penalty);
3027
3028 let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3029 assert!(
3030 v_rel < 1e-3,
3031 "Binomial-logit n=30 joint design+penalty gradient mismatch: rel={v_rel:.3e}, \
3032 analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3033 );
3034 }
3035
3036 #[test]
3037 pub(crate) fn binomial_logit_n30_design_moving_hessian_matches_fd() {
3038 let f = BinomialLogitDesignMotionFixture::new();
3043 let x_tau_0 = Array2::<f64>::zeros(f.x.raw_dim());
3044 let s_tau_0 = f.s_tau_penalty.clone();
3045 let x_tau_1 = f.x_tau_design.clone();
3046 let s_tau_1 = Array2::<f64>::zeros((5, 5));
3047
3048 let hyper_dirs = vec![
3049 DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
3050 .expect("penalty-only direction"),
3051 DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
3052 .expect("design-moving direction"),
3053 ];
3054
3055 let state = f.state();
3056 let mut theta = Array1::<f64>::zeros(f.rho.len() + hyper_dirs.len());
3057 theta.slice_mut(s![..f.rho.len()]).assign(&f.rho);
3058 let (_, _, h_full) =
3059 compute_joint_hypercostgradienthessian(&state, &theta, f.rho.len(), &hyper_dirs)
3060 .expect("joint cost+gradient+hessian");
3061 let h_tt_analytic = h_full.slice(s![f.rho.len().., f.rho.len()..]).to_owned();
3062
3063 let x_tau_mats = vec![x_tau_0.clone(), x_tau_1.clone()];
3064 let s_tau_mats = vec![s_tau_0.clone(), s_tau_1.clone()];
3065 let h_tt_fd = directional_tau_hessian_fd_reference(
3066 &f.y,
3067 &f.w,
3068 &f.x,
3069 &f.s0,
3070 &f.cfg,
3071 &f.rho,
3072 &hyper_dirs,
3073 &x_tau_mats,
3074 &s_tau_mats,
3075 );
3076
3077 let num = (&h_tt_analytic - &h_tt_fd)
3078 .iter()
3079 .map(|v| v * v)
3080 .sum::<f64>()
3081 .sqrt();
3082 let den = h_tt_fd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
3083 let rel = num / den;
3084 assert!(
3085 rel < 1e-4,
3086 "Binomial-logit n=30 tau-tau Hessian mismatch: rel={rel:.3e}, \
3087 analytic={h_tt_analytic:?}, fd={h_tt_fd:?}"
3088 );
3089 }
3090
3091 #[test]
3092 pub(crate) fn binomial_logit_n30_nonzero_rho_design_moving_gradient_matches_fd() {
3093 let f = BinomialLogitDesignMotionFixture::new();
3097 let rho = array![1.5];
3098 let s_tau = Array2::<f64>::zeros((5, 5));
3099
3100 let state = f.state();
3101 let hyper = DirectionalHyperParam::single_penalty(
3102 0,
3103 f.x_tau_design.clone(),
3104 s_tau.clone(),
3105 None,
3106 None,
3107 )
3108 .expect("design-moving hyper direction");
3109
3110 let v_tau_analytic = single_directional_tau_gradient(&state, &rho, hyper)
3111 .expect("analytic directional gradient");
3112
3113 let h = 2e-5;
3115 let (state_plus, state_minus) = f.state_perturbed(&f.x_tau_design, &s_tau, h);
3116 let v_plus = state_plus.compute_cost(&rho).expect("cost+");
3117 let v_minus = state_minus.compute_cost(&rho).expect("cost-");
3118 let v_tau_fd = (v_plus - v_minus) / (2.0 * h);
3119
3120 let v_rel = (v_tau_analytic - v_tau_fd).abs() / v_tau_fd.abs().max(1e-10);
3121 assert!(
3122 v_rel < 1e-3,
3123 "Binomial-logit n=30 rho=1.5 design-moving gradient mismatch: rel={v_rel:.3e}, \
3124 analytic={v_tau_analytic:.6e}, fd={v_tau_fd:.6e}"
3125 );
3126 }
3127
3128 #[test]
3129 pub(crate) fn binomial_logit_n30_rank_deficient_hessian_matches_cost_fd() {
3130 let f = BinomialLogitDesignMotionFixture::new();
3165 let x_tau_0 = Array2::<f64>::zeros(f.x.raw_dim());
3166 let s_tau_0 = f.s_tau_penalty.clone();
3167 let x_tau_1 = f.x_tau_design.clone();
3168 let s_tau_1 = Array2::<f64>::zeros((5, 5));
3169
3170 let hyper_dirs = vec![
3171 DirectionalHyperParam::single_penalty(0, x_tau_0.clone(), s_tau_0.clone(), None, None)
3172 .expect("penalty-only direction"),
3173 DirectionalHyperParam::single_penalty(0, x_tau_1.clone(), s_tau_1.clone(), None, None)
3174 .expect("design-moving direction"),
3175 ];
3176
3177 let state = f.state();
3179 let mut theta = Array1::<f64>::zeros(f.rho.len() + hyper_dirs.len());
3180 theta.slice_mut(s![..f.rho.len()]).assign(&f.rho);
3181 let (_, _, h_full) =
3182 compute_joint_hypercostgradienthessian(&state, &theta, f.rho.len(), &hyper_dirs)
3183 .expect("joint cost+gradient+hessian");
3184 let h_tt_analytic = h_full.slice(s![f.rho.len().., f.rho.len()..]).to_owned();
3185
3186 const TARGET_PHYSICAL_STEP: f64 = 1e-5;
3190 let x_tau_mats = [&x_tau_0, &x_tau_1];
3191 let s_tau_mats = [&s_tau_0, &s_tau_1];
3192 let steps: [f64; 2] = {
3193 let mut steps = [0.0; 2];
3194 for (j, step) in steps.iter_mut().enumerate() {
3195 let scale = x_tau_mats[j]
3196 .iter()
3197 .chain(s_tau_mats[j].iter())
3198 .fold(0.0_f64, |acc, value| acc.max(value.abs()));
3199 *step = if scale > 0.0 {
3200 TARGET_PHYSICAL_STEP / scale
3201 } else {
3202 TARGET_PHYSICAL_STEP
3203 };
3204 }
3205 steps
3206 };
3207
3208 let eval_cost = |a: f64, b: f64| -> f64 {
3210 let x_eval = &f.x
3211 + &x_tau_mats[0].mapv(|v| a * steps[0] * v)
3212 + &x_tau_mats[1].mapv(|v| b * steps[1] * v);
3213 let s_eval = &f.s0
3214 + &s_tau_mats[0].mapv(|v| a * steps[0] * v)
3215 + &s_tau_mats[1].mapv(|v| b * steps[1] * v);
3216 let st = build_logit_state(&f.y, &f.w, &x_eval, &s_eval, &f.cfg);
3217 st.compute_cost(&f.rho).expect("cost eval")
3218 };
3219
3220 let v_00 = eval_cost(0.0, 0.0);
3221 let v_p0 = eval_cost(1.0, 0.0);
3222 let v_m0 = eval_cost(-1.0, 0.0);
3223 let v_0p = eval_cost(0.0, 1.0);
3224 let v_0m = eval_cost(0.0, -1.0);
3225 let v_pp = eval_cost(1.0, 1.0);
3226 let v_pm = eval_cost(1.0, -1.0);
3227 let v_mp = eval_cost(-1.0, 1.0);
3228 let v_mm = eval_cost(-1.0, -1.0);
3229
3230 let h00_fd = (v_p0 - 2.0 * v_00 + v_m0) / (steps[0] * steps[0]);
3231 let h11_fd = (v_0p - 2.0 * v_00 + v_0m) / (steps[1] * steps[1]);
3232 let h01_fd = (v_pp - v_pm - v_mp + v_mm) / (4.0 * steps[0] * steps[1]);
3233
3234 let h_tt_fd = array![[h00_fd, h01_fd], [h01_fd, h11_fd]];
3235
3236 let num = (&h_tt_analytic - &h_tt_fd)
3237 .iter()
3238 .map(|v| v * v)
3239 .sum::<f64>()
3240 .sqrt();
3241 let den = h_tt_fd.iter().map(|v| v * v).sum::<f64>().sqrt().max(1e-10);
3242 let rel = num / den;
3243
3244 assert!(
3245 rel < 3e-3,
3246 "Binomial-logit n=30 rank-deficient Hessian vs cost-FD mismatch: rel={rel:.3e}, \
3247 analytic={h_tt_analytic:?}, fd={h_tt_fd:?}"
3248 );
3249 }
3250}
3251
3252#[derive(Clone, Copy, Debug)]
3253pub(crate) enum RemlGeometry {
3254 DenseSpectral,
3255 SparseExactSpd,
3256}
3257
3258trait PenalizedGeometry {
3259 fn backend_kind(&self) -> GeometryBackendKind;
3260}
3261
3262#[derive(Clone)]
3263pub(crate) enum DerivativeMatrixStorage {
3264 Dense(Array2<f64>),
3265 Zero(ZeroDerivativeMatrix),
3266 Embedded(EmbeddedDerivativeMatrix),
3267 Implicit(ImplicitDerivativeOp),
3268 LatentCoord(LatentCoordDerivativeOp),
3269}
3270
3271trait DerivativeStorageBackend {
3283 fn resident_byte_count(&self) -> usize;
3284 fn design_nrows(&self) -> usize;
3285 fn design_ncols(&self) -> usize;
3286 fn penalty_dim(&self) -> usize;
3287 fn uses_implicit_storage(&self) -> bool;
3288 fn any_nonzero(&self) -> bool;
3289 fn materialize(&self) -> Array2<f64>;
3290 fn implicit_first_axis_info(
3291 &self,
3292 ) -> Option<(
3293 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3294 usize,
3295 )>;
3296 fn implicit_axis_count_hint(&self) -> Option<usize>;
3297 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError>;
3298 fn design_transpose_mul_original(
3299 &self,
3300 v: &Array1<f64>,
3301 ) -> Result<Array1<f64>, EstimationError>;
3302 fn design_transformed(
3303 &self,
3304 qs: &Array2<f64>,
3305 free_basis_opt: Option<&Array2<f64>>,
3306 ) -> Result<Array2<f64>, EstimationError>;
3307 fn design_transformed_forward_mul(
3311 &self,
3312 qs: &Array2<f64>,
3313 free_basis_opt: Option<&Array2<f64>>,
3314 u: &Array1<f64>,
3315 ) -> Result<Array1<f64>, EstimationError> {
3316 Ok(self.design_transformed(qs, free_basis_opt)?.dot(u))
3317 }
3318 fn design_transformed_transpose_mul(
3321 &self,
3322 qs: &Array2<f64>,
3323 free_basis_opt: Option<&Array2<f64>>,
3324 v: &Array1<f64>,
3325 ) -> Result<Array1<f64>, EstimationError> {
3326 Ok(self.design_transformed(qs, free_basis_opt)?.t().dot(v))
3327 }
3328 fn penalty_transformed(
3329 &self,
3330 qs: &Array2<f64>,
3331 free_basis_opt: Option<&Array2<f64>>,
3332 ) -> Result<Array2<f64>, EstimationError>;
3333 fn penalty_scaled_add_to(
3334 &self,
3335 target: &mut Array2<f64>,
3336 amp: f64,
3337 ) -> Result<(), EstimationError>;
3338}
3339
3340macro_rules! storage_dispatch {
3345 ($scrutinee:expr, $backend:ident => $body:expr) => {
3346 match $scrutinee {
3347 DerivativeMatrixStorage::Dense($backend) => $body,
3348 DerivativeMatrixStorage::Zero($backend) => $body,
3349 DerivativeMatrixStorage::Embedded($backend) => $body,
3350 DerivativeMatrixStorage::Implicit($backend) => $body,
3351 DerivativeMatrixStorage::LatentCoord($backend) => $body,
3352 }
3353 };
3354}
3355
3356#[derive(Clone)]
3357pub(crate) struct ZeroDerivativeMatrix {
3358 rows: usize,
3359 cols: usize,
3360}
3361
3362impl ZeroDerivativeMatrix {
3363 pub(crate) fn new(rows: usize, cols: usize) -> Self {
3364 Self { rows, cols }
3365 }
3366}
3367
3368#[derive(Clone, Copy, Debug)]
3370pub enum ImplicitDerivLevel {
3371 First(usize),
3373 SecondDiag(usize),
3375 SecondCross(usize, usize),
3377}
3378
3379#[derive(Clone)]
3382pub(crate) struct ImplicitDerivativeOp {
3383 pub(crate) operator: std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3384 pub(crate) level: ImplicitDerivLevel,
3385 pub(crate) global_range: Range<usize>,
3386 pub(crate) total_dim: usize,
3387 pub(crate) cached_dense: std::sync::Arc<gam_runtime::resource::RayonSafeOnce<Array2<f64>>>,
3397}
3398
3399#[derive(Clone)]
3400pub(crate) struct LatentCoordDerivativeOp {
3401 pub(crate) operator: std::sync::Arc<gam_terms::basis::LatentCoordDesignDerivative>,
3402 pub(crate) flat_axis: usize,
3403 pub(crate) global_range: Range<usize>,
3404 pub(crate) total_dim: usize,
3405 pub(crate) cached_dense: std::sync::Arc<gam_runtime::resource::RayonSafeOnce<Array2<f64>>>,
3406}
3407
3408impl LatentCoordDerivativeOp {
3409 pub(crate) fn materialize_local(&self) -> Array2<f64> {
3410 self.operator.materialize_axis(self.flat_axis).expect(
3411 "radial scalar evaluation failed during latent-coordinate derivative materialization",
3412 )
3413 }
3414
3415 pub(crate) fn materialize_dense(&self) -> &Array2<f64> {
3416 self.cached_dense.get_or_compute(|| {
3417 let local = self.materialize_local();
3418 let mut out = Array2::<f64>::zeros((local.nrows(), self.total_dim));
3419 out.slice_mut(s![.., self.global_range.clone()])
3420 .assign(&local);
3421 out
3422 })
3423 }
3424
3425 pub(crate) fn nrows(&self) -> usize {
3426 self.operator.n_data()
3427 }
3428
3429 pub(crate) fn ncols(&self) -> usize {
3430 self.total_dim
3431 }
3432
3433 pub(crate) fn transpose_mul(&self, v: &Array1<f64>) -> Array1<f64> {
3434 let local = self
3435 .operator
3436 .transpose_mul_axis(self.flat_axis, &v.view())
3437 .expect(
3438 "radial scalar evaluation failed during latent-coordinate derivative transpose_mul",
3439 );
3440 let mut out = Array1::<f64>::zeros(self.total_dim);
3441 out.slice_mut(s![self.global_range.clone()]).assign(&local);
3442 out
3443 }
3444
3445 pub(crate) fn forward_mul(&self, u: &Array1<f64>) -> Array1<f64> {
3446 let u_local = u.slice(s![self.global_range.clone()]).to_owned();
3447 self.operator
3448 .forward_mul_axis(self.flat_axis, &u_local.view())
3449 .expect(
3450 "radial scalar evaluation failed during latent-coordinate derivative forward_mul",
3451 )
3452 }
3453}
3454
3455impl ImplicitDerivativeOp {
3456 pub(crate) fn materialize_local(&self) -> Array2<f64> {
3457 match self.level {
3458 ImplicitDerivLevel::First(axis) => self.operator.materialize_first(axis).expect(
3459 "radial scalar evaluation failed during implicit derivative materialization",
3460 ),
3461 ImplicitDerivLevel::SecondDiag(axis) => {
3462 self.operator.materialize_second_diag(axis).expect(
3463 "radial scalar evaluation failed during implicit derivative materialization",
3464 )
3465 }
3466 ImplicitDerivLevel::SecondCross(d, e) => {
3467 self.operator.materialize_second_cross(d, e).expect(
3468 "radial scalar evaluation failed during implicit derivative materialization",
3469 )
3470 }
3471 }
3472 }
3473
3474 pub(crate) fn materialize_dense(&self) -> &Array2<f64> {
3475 self.cached_dense.get_or_compute(|| {
3476 let local = self.materialize_local();
3477 let mut out = Array2::<f64>::zeros((local.nrows(), self.total_dim));
3478 out.slice_mut(s![.., self.global_range.clone()])
3479 .assign(&local);
3480 out
3481 })
3482 }
3483
3484 pub(crate) fn nrows(&self) -> usize {
3485 self.operator.n_data()
3486 }
3487
3488 pub(crate) fn ncols(&self) -> usize {
3489 self.total_dim
3490 }
3491
3492 pub(crate) fn transpose_mul(&self, v: &Array1<f64>) -> Array1<f64> {
3493 let local = match self.level {
3494 ImplicitDerivLevel::First(axis) => self
3495 .operator
3496 .transpose_mul(axis, &v.view())
3497 .expect("radial scalar evaluation failed during implicit derivative transpose_mul"),
3498 ImplicitDerivLevel::SecondDiag(axis) => self
3499 .operator
3500 .transpose_mul_second_diag(axis, &v.view())
3501 .expect("radial scalar evaluation failed during implicit derivative transpose_mul"),
3502 ImplicitDerivLevel::SecondCross(d, e) => self
3503 .operator
3504 .transpose_mul_second_cross(d, e, &v.view())
3505 .expect("radial scalar evaluation failed during implicit derivative transpose_mul"),
3506 };
3507 let mut out = Array1::<f64>::zeros(self.total_dim);
3508 out.slice_mut(s![self.global_range.clone()]).assign(&local);
3509 out
3510 }
3511
3512 pub(crate) fn forward_mul(&self, u: &Array1<f64>) -> Array1<f64> {
3513 let u_local = u.slice(s![self.global_range.clone()]).to_owned();
3514 match self.level {
3515 ImplicitDerivLevel::First(axis) => self
3516 .operator
3517 .forward_mul(axis, &u_local.view())
3518 .expect("radial scalar evaluation failed during implicit derivative forward_mul"),
3519 ImplicitDerivLevel::SecondDiag(axis) => self
3520 .operator
3521 .forward_mul_second_diag(axis, &u_local.view())
3522 .expect("radial scalar evaluation failed during implicit derivative forward_mul"),
3523 ImplicitDerivLevel::SecondCross(d, e) => self
3524 .operator
3525 .forward_mul_second_cross(d, e, &u_local.view())
3526 .expect("radial scalar evaluation failed during implicit derivative forward_mul"),
3527 }
3528 }
3529}
3530
3531#[derive(Clone)]
3532pub(crate) struct EmbeddedDerivativeMatrix {
3533 pub(crate) local: Array2<f64>,
3534 pub(crate) global_range: Range<usize>,
3535 pub(crate) total_dim: usize,
3536}
3537
3538impl EmbeddedDerivativeMatrix {
3539 pub(crate) fn new(local: Array2<f64>, global_range: Range<usize>, total_dim: usize) -> Self {
3540 Self {
3541 local,
3542 global_range,
3543 total_dim,
3544 }
3545 }
3546}
3547
3548impl DerivativeStorageBackend for Array2<f64> {
3549 fn resident_byte_count(&self) -> usize {
3550 self.len().saturating_mul(std::mem::size_of::<f64>())
3551 }
3552 fn design_nrows(&self) -> usize {
3553 Array2::nrows(self)
3554 }
3555 fn design_ncols(&self) -> usize {
3556 Array2::ncols(self)
3557 }
3558 fn penalty_dim(&self) -> usize {
3559 Array2::nrows(self)
3560 }
3561 fn uses_implicit_storage(&self) -> bool {
3562 false
3563 }
3564 fn any_nonzero(&self) -> bool {
3565 self.iter().any(|v| *v != 0.0)
3566 }
3567 fn materialize(&self) -> Array2<f64> {
3568 self.clone()
3569 }
3570 fn implicit_first_axis_info(
3571 &self,
3572 ) -> Option<(
3573 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3574 usize,
3575 )> {
3576 None
3577 }
3578 fn implicit_axis_count_hint(&self) -> Option<usize> {
3579 None
3580 }
3581
3582 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3583 if Array2::ncols(self) != u.len() {
3584 crate::bail_invalid_estim!(
3585 "dense hyper design derivative forward_mul_original width mismatch: matrix={}x{}, vector={}",
3586 Array2::nrows(self),
3587 Array2::ncols(self),
3588 u.len()
3589 );
3590 }
3591 Ok(self.dot(u))
3592 }
3593
3594 fn design_transpose_mul_original(
3595 &self,
3596 v: &Array1<f64>,
3597 ) -> Result<Array1<f64>, EstimationError> {
3598 if Array2::nrows(self) != v.len() {
3599 crate::bail_invalid_estim!(
3600 "dense hyper design derivative transpose_mul_original height mismatch: matrix={}x{}, vector={}",
3601 Array2::nrows(self),
3602 Array2::ncols(self),
3603 v.len()
3604 );
3605 }
3606 Ok(self.t().dot(v))
3607 }
3608
3609 fn design_transformed(
3610 &self,
3611 qs: &Array2<f64>,
3612 free_basis_opt: Option<&Array2<f64>>,
3613 ) -> Result<Array2<f64>, EstimationError> {
3614 Ok(gam_linalg::matrix::DenseRightProductView::new(self)
3615 .with_factor(qs)
3616 .with_optional_factor(free_basis_opt)
3617 .materialize())
3618 }
3619
3620 fn penalty_transformed(
3621 &self,
3622 qs: &Array2<f64>,
3623 free_basis_opt: Option<&Array2<f64>>,
3624 ) -> Result<Array2<f64>, EstimationError> {
3625 let mut transformed = qs.t().dot(self).dot(qs);
3626 if let Some(z) = free_basis_opt {
3627 transformed = z.t().dot(&transformed).dot(z);
3628 }
3629 Ok(transformed)
3630 }
3631
3632 fn penalty_scaled_add_to(
3633 &self,
3634 target: &mut Array2<f64>,
3635 amp: f64,
3636 ) -> Result<(), EstimationError> {
3637 if target.raw_dim() != self.raw_dim() {
3638 crate::bail_invalid_estim!(
3639 "dense hyper penalty derivative shape mismatch: target={}x{}, matrix={}x{}",
3640 target.nrows(),
3641 target.ncols(),
3642 Array2::nrows(self),
3643 Array2::ncols(self)
3644 );
3645 }
3646 target.scaled_add(amp, self);
3647 Ok(())
3648 }
3649}
3650
3651impl DerivativeStorageBackend for ZeroDerivativeMatrix {
3652 fn resident_byte_count(&self) -> usize {
3653 0
3654 }
3655 fn design_nrows(&self) -> usize {
3656 self.rows
3657 }
3658 fn design_ncols(&self) -> usize {
3659 self.cols
3660 }
3661 fn penalty_dim(&self) -> usize {
3662 self.cols
3663 }
3664 fn uses_implicit_storage(&self) -> bool {
3665 false
3666 }
3667 fn any_nonzero(&self) -> bool {
3668 false
3669 }
3670 fn materialize(&self) -> Array2<f64> {
3671 Array2::<f64>::zeros((self.rows, self.cols))
3672 }
3673 fn implicit_first_axis_info(
3674 &self,
3675 ) -> Option<(
3676 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3677 usize,
3678 )> {
3679 None
3680 }
3681 fn implicit_axis_count_hint(&self) -> Option<usize> {
3682 None
3683 }
3684
3685 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3686 if self.cols != u.len() {
3687 crate::bail_invalid_estim!(
3688 "zero hyper design derivative forward_mul_original width mismatch: matrix={}x{}, vector={}",
3689 self.rows,
3690 self.cols,
3691 u.len()
3692 );
3693 }
3694 Ok(Array1::<f64>::zeros(self.rows))
3695 }
3696
3697 fn design_transpose_mul_original(
3698 &self,
3699 v: &Array1<f64>,
3700 ) -> Result<Array1<f64>, EstimationError> {
3701 if self.rows != v.len() {
3702 crate::bail_invalid_estim!(
3703 "zero hyper design derivative transpose_mul_original height mismatch: matrix={}x{}, vector={}",
3704 self.rows,
3705 self.cols,
3706 v.len()
3707 );
3708 }
3709 Ok(Array1::<f64>::zeros(self.cols))
3710 }
3711
3712 fn design_transformed(
3713 &self,
3714 qs: &Array2<f64>,
3715 free_basis_opt: Option<&Array2<f64>>,
3716 ) -> Result<Array2<f64>, EstimationError> {
3717 if self.cols != qs.nrows() {
3718 crate::bail_invalid_estim!(
3719 "zero design derivative width mismatch: total_cols={}, qs rows={}",
3720 self.cols,
3721 qs.nrows()
3722 );
3723 }
3724 let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3725 Ok(Array2::<f64>::zeros((self.rows, cols)))
3726 }
3727
3728 fn design_transformed_forward_mul(
3729 &self,
3730 qs: &Array2<f64>,
3731 free_basis_opt: Option<&Array2<f64>>,
3732 u: &Array1<f64>,
3733 ) -> Result<Array1<f64>, EstimationError> {
3734 if self.cols != qs.nrows() {
3735 crate::bail_invalid_estim!(
3736 "zero design derivative width mismatch: total_cols={}, qs rows={}",
3737 self.cols,
3738 qs.nrows()
3739 );
3740 }
3741 let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3742 if u.len() != cols {
3743 crate::bail_invalid_estim!(
3744 "zero design derivative transformed forward width mismatch: expected {}, vector={}",
3745 cols,
3746 u.len()
3747 );
3748 }
3749 Ok(Array1::<f64>::zeros(self.rows))
3750 }
3751
3752 fn design_transformed_transpose_mul(
3753 &self,
3754 qs: &Array2<f64>,
3755 free_basis_opt: Option<&Array2<f64>>,
3756 v: &Array1<f64>,
3757 ) -> Result<Array1<f64>, EstimationError> {
3758 if self.rows != v.len() {
3759 crate::bail_invalid_estim!(
3760 "zero design derivative transpose height mismatch: matrix rows={}, vector={}",
3761 self.rows,
3762 v.len()
3763 );
3764 }
3765 if self.cols != qs.nrows() {
3766 crate::bail_invalid_estim!(
3767 "zero design derivative width mismatch: total_cols={}, qs rows={}",
3768 self.cols,
3769 qs.nrows()
3770 );
3771 }
3772 let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3773 Ok(Array1::<f64>::zeros(cols))
3774 }
3775
3776 fn penalty_transformed(
3777 &self,
3778 qs: &Array2<f64>,
3779 free_basis_opt: Option<&Array2<f64>>,
3780 ) -> Result<Array2<f64>, EstimationError> {
3781 if self.cols != qs.nrows() {
3782 crate::bail_invalid_estim!(
3783 "zero penalty derivative width mismatch: total_dim={}, qs rows={}",
3784 self.cols,
3785 qs.nrows()
3786 );
3787 }
3788 let cols = free_basis_opt.map_or(qs.ncols(), |z| z.ncols());
3789 Ok(Array2::<f64>::zeros((cols, cols)))
3790 }
3791
3792 fn penalty_scaled_add_to(
3793 &self,
3794 target: &mut Array2<f64>,
3795 amp: f64,
3796 ) -> Result<(), EstimationError> {
3797 if !amp.is_finite() {
3801 crate::bail_invalid_estim!(
3802 "zero hyper penalty derivative received non-finite amp={amp}"
3803 );
3804 }
3805 if target.nrows() != self.cols || target.ncols() != self.cols {
3806 crate::bail_invalid_estim!(
3807 "zero hyper penalty derivative shape mismatch: target={}x{}, expected {}x{}",
3808 target.nrows(),
3809 target.ncols(),
3810 self.cols,
3811 self.cols
3812 );
3813 }
3814 Ok(())
3815 }
3816}
3817
3818impl DerivativeStorageBackend for EmbeddedDerivativeMatrix {
3819 fn resident_byte_count(&self) -> usize {
3820 self.local.len().saturating_mul(std::mem::size_of::<f64>())
3821 }
3822 fn design_nrows(&self) -> usize {
3823 self.local.nrows()
3824 }
3825 fn design_ncols(&self) -> usize {
3826 self.total_dim
3827 }
3828 fn penalty_dim(&self) -> usize {
3829 self.total_dim
3830 }
3831 fn uses_implicit_storage(&self) -> bool {
3832 false
3833 }
3834 fn any_nonzero(&self) -> bool {
3835 self.local.iter().any(|v| *v != 0.0)
3836 }
3837 fn materialize(&self) -> Array2<f64> {
3838 let mut dense = Array2::<f64>::zeros((self.local.nrows(), self.total_dim));
3839 dense
3840 .slice_mut(s![.., self.global_range.clone()])
3841 .assign(&self.local);
3842 dense
3843 }
3844 fn implicit_first_axis_info(
3845 &self,
3846 ) -> Option<(
3847 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3848 usize,
3849 )> {
3850 None
3851 }
3852 fn implicit_axis_count_hint(&self) -> Option<usize> {
3853 None
3854 }
3855
3856 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3857 if self.total_dim != u.len() {
3858 crate::bail_invalid_estim!(
3859 "embedded hyper design derivative forward_mul_original width mismatch: total_dim={}, vector={}",
3860 self.total_dim,
3861 u.len()
3862 );
3863 }
3864 let u_local = u.slice(s![self.global_range.clone()]).to_owned();
3865 Ok(self.local.dot(&u_local))
3866 }
3867
3868 fn design_transpose_mul_original(
3869 &self,
3870 v: &Array1<f64>,
3871 ) -> Result<Array1<f64>, EstimationError> {
3872 if self.local.nrows() != v.len() {
3873 crate::bail_invalid_estim!(
3874 "embedded hyper design derivative transpose_mul_original height mismatch: local_rows={}, vector={}",
3875 self.local.nrows(),
3876 v.len()
3877 );
3878 }
3879 let mut out = Array1::<f64>::zeros(self.total_dim);
3880 let pulled = self.local.t().dot(v);
3881 out.slice_mut(s![self.global_range.clone()]).assign(&pulled);
3882 Ok(out)
3883 }
3884
3885 fn design_transformed(
3886 &self,
3887 qs: &Array2<f64>,
3888 free_basis_opt: Option<&Array2<f64>>,
3889 ) -> Result<Array2<f64>, EstimationError> {
3890 if self.total_dim != qs.nrows() {
3891 crate::bail_invalid_estim!(
3892 "embedded design derivative width mismatch: total_cols={}, qs rows={}",
3893 self.total_dim,
3894 qs.nrows()
3895 );
3896 }
3897 let qs_local = qs.slice(s![self.global_range.clone(), ..]);
3898 let mut transformed = self.local.dot(&qs_local);
3899 if let Some(z) = free_basis_opt {
3900 transformed = transformed.dot(z);
3901 }
3902 Ok(transformed)
3903 }
3904
3905 fn penalty_transformed(
3906 &self,
3907 qs: &Array2<f64>,
3908 free_basis_opt: Option<&Array2<f64>>,
3909 ) -> Result<Array2<f64>, EstimationError> {
3910 if self.total_dim != qs.nrows() {
3911 crate::bail_invalid_estim!(
3912 "embedded penalty derivative width mismatch: total_dim={}, qs rows={}",
3913 self.total_dim,
3914 qs.nrows()
3915 );
3916 }
3917 let qs_local = qs.slice(s![self.global_range.clone(), ..]);
3918 let mut transformed = qs_local.t().dot(&self.local).dot(&qs_local);
3919 if let Some(z) = free_basis_opt {
3920 transformed = z.t().dot(&transformed).dot(z);
3921 }
3922 Ok(transformed)
3923 }
3924
3925 fn penalty_scaled_add_to(
3926 &self,
3927 target: &mut Array2<f64>,
3928 amp: f64,
3929 ) -> Result<(), EstimationError> {
3930 if target.nrows() != self.total_dim || target.ncols() != self.total_dim {
3931 crate::bail_invalid_estim!(
3932 "embedded hyper penalty derivative shape mismatch: target={}x{}, expected {}x{}",
3933 target.nrows(),
3934 target.ncols(),
3935 self.total_dim,
3936 self.total_dim
3937 );
3938 }
3939 target
3940 .slice_mut(s![self.global_range.clone(), self.global_range.clone()])
3941 .scaled_add(amp, &self.local);
3942 Ok(())
3943 }
3944}
3945
3946impl DerivativeStorageBackend for ImplicitDerivativeOp {
3947 fn resident_byte_count(&self) -> usize {
3948 0
3949 }
3950 fn design_nrows(&self) -> usize {
3951 self.nrows()
3952 }
3953 fn design_ncols(&self) -> usize {
3954 self.ncols()
3955 }
3956 fn penalty_dim(&self) -> usize {
3957 self.nrows()
3958 }
3959 fn uses_implicit_storage(&self) -> bool {
3960 true
3961 }
3962 fn any_nonzero(&self) -> bool {
3963 true
3964 }
3965 fn materialize(&self) -> Array2<f64> {
3966 self.materialize_dense().clone()
3967 }
3968 fn implicit_first_axis_info(
3969 &self,
3970 ) -> Option<(
3971 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
3972 usize,
3973 )> {
3974 match self.level {
3975 ImplicitDerivLevel::First(axis) => Some((self.operator.clone(), axis)),
3976 _ => None,
3977 }
3978 }
3979 fn implicit_axis_count_hint(&self) -> Option<usize> {
3980 Some(self.operator.n_axes())
3981 }
3982
3983 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
3984 if self.ncols() != u.len() {
3985 crate::bail_invalid_estim!(
3986 "implicit hyper design derivative forward_mul_original width mismatch: operator_cols={}, vector={}",
3987 self.ncols(),
3988 u.len()
3989 );
3990 }
3991 Ok(self.forward_mul(u))
3992 }
3993
3994 fn design_transpose_mul_original(
3995 &self,
3996 v: &Array1<f64>,
3997 ) -> Result<Array1<f64>, EstimationError> {
3998 if self.nrows() != v.len() {
3999 crate::bail_invalid_estim!(
4000 "implicit hyper design derivative transpose_mul_original height mismatch: operator_rows={}, vector={}",
4001 self.nrows(),
4002 v.len()
4003 );
4004 }
4005 Ok(self.transpose_mul(v))
4006 }
4007
4008 fn design_transformed(
4009 &self,
4010 qs: &Array2<f64>,
4011 free_basis_opt: Option<&Array2<f64>>,
4012 ) -> Result<Array2<f64>, EstimationError> {
4013 let dense = self.materialize_dense();
4014 Ok(gam_linalg::matrix::DenseRightProductView::new(dense)
4015 .with_factor(qs)
4016 .with_optional_factor(free_basis_opt)
4017 .materialize())
4018 }
4019
4020 fn design_transformed_forward_mul(
4021 &self,
4022 qs: &Array2<f64>,
4023 free_basis_opt: Option<&Array2<f64>>,
4024 u: &Array1<f64>,
4025 ) -> Result<Array1<f64>, EstimationError> {
4026 let mut right = if let Some(z) = free_basis_opt {
4027 z.dot(u)
4028 } else {
4029 u.clone()
4030 };
4031 right = qs.dot(&right);
4032 Ok(self.forward_mul(&right))
4033 }
4034
4035 fn design_transformed_transpose_mul(
4036 &self,
4037 qs: &Array2<f64>,
4038 free_basis_opt: Option<&Array2<f64>>,
4039 v: &Array1<f64>,
4040 ) -> Result<Array1<f64>, EstimationError> {
4041 let mut pulled = qs.t().dot(&self.transpose_mul(v));
4042 if let Some(z) = free_basis_opt {
4043 pulled = z.t().dot(&pulled);
4044 }
4045 Ok(pulled)
4046 }
4047
4048 fn penalty_transformed(
4049 &self,
4050 qs: &Array2<f64>,
4051 free_basis_opt: Option<&Array2<f64>>,
4052 ) -> Result<Array2<f64>, EstimationError> {
4053 let dense = self.materialize_dense();
4054 let mut transformed = qs.t().dot(dense).dot(qs);
4055 if let Some(z) = free_basis_opt {
4056 transformed = z.t().dot(&transformed).dot(z);
4057 }
4058 Ok(transformed)
4059 }
4060
4061 fn penalty_scaled_add_to(
4062 &self,
4063 target: &mut Array2<f64>,
4064 amp: f64,
4065 ) -> Result<(), EstimationError> {
4066 let dense = self.materialize_dense();
4067 if target.raw_dim() != dense.raw_dim() {
4068 crate::bail_invalid_estim!(
4069 "implicit hyper penalty derivative shape mismatch: target={}x{}, matrix={}x{}",
4070 target.nrows(),
4071 target.ncols(),
4072 dense.nrows(),
4073 dense.ncols()
4074 );
4075 }
4076 target.scaled_add(amp, dense);
4077 Ok(())
4078 }
4079}
4080
4081impl DerivativeStorageBackend for LatentCoordDerivativeOp {
4082 fn resident_byte_count(&self) -> usize {
4083 0
4084 }
4085 fn design_nrows(&self) -> usize {
4086 self.nrows()
4087 }
4088 fn design_ncols(&self) -> usize {
4089 self.ncols()
4090 }
4091 fn penalty_dim(&self) -> usize {
4092 self.nrows()
4093 }
4094 fn uses_implicit_storage(&self) -> bool {
4095 true
4096 }
4097 fn any_nonzero(&self) -> bool {
4098 true
4099 }
4100 fn materialize(&self) -> Array2<f64> {
4101 self.materialize_dense().clone()
4102 }
4103 fn implicit_first_axis_info(
4104 &self,
4105 ) -> Option<(
4106 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4107 usize,
4108 )> {
4109 None
4110 }
4111 fn implicit_axis_count_hint(&self) -> Option<usize> {
4112 Some(self.operator.n_axes())
4113 }
4114
4115 fn design_forward_mul_original(&self, u: &Array1<f64>) -> Result<Array1<f64>, EstimationError> {
4116 if self.ncols() != u.len() {
4117 crate::bail_invalid_estim!(
4118 "latent-coordinate hyper design derivative forward_mul_original width mismatch: operator_cols={}, vector={}",
4119 self.ncols(),
4120 u.len()
4121 );
4122 }
4123 Ok(self.forward_mul(u))
4124 }
4125
4126 fn design_transpose_mul_original(
4127 &self,
4128 v: &Array1<f64>,
4129 ) -> Result<Array1<f64>, EstimationError> {
4130 if self.nrows() != v.len() {
4131 crate::bail_invalid_estim!(
4132 "latent-coordinate hyper design derivative transpose_mul_original height mismatch: operator_rows={}, vector={}",
4133 self.nrows(),
4134 v.len()
4135 );
4136 }
4137 Ok(self.transpose_mul(v))
4138 }
4139
4140 fn design_transformed(
4141 &self,
4142 qs: &Array2<f64>,
4143 free_basis_opt: Option<&Array2<f64>>,
4144 ) -> Result<Array2<f64>, EstimationError> {
4145 let dense = self.materialize_dense();
4146 Ok(gam_linalg::matrix::DenseRightProductView::new(dense)
4147 .with_factor(qs)
4148 .with_optional_factor(free_basis_opt)
4149 .materialize())
4150 }
4151
4152 fn design_transformed_forward_mul(
4153 &self,
4154 qs: &Array2<f64>,
4155 free_basis_opt: Option<&Array2<f64>>,
4156 u: &Array1<f64>,
4157 ) -> Result<Array1<f64>, EstimationError> {
4158 let mut right = if let Some(z) = free_basis_opt {
4159 z.dot(u)
4160 } else {
4161 u.clone()
4162 };
4163 right = qs.dot(&right);
4164 Ok(self.forward_mul(&right))
4165 }
4166
4167 fn design_transformed_transpose_mul(
4168 &self,
4169 qs: &Array2<f64>,
4170 free_basis_opt: Option<&Array2<f64>>,
4171 v: &Array1<f64>,
4172 ) -> Result<Array1<f64>, EstimationError> {
4173 let mut pulled = qs.t().dot(&self.transpose_mul(v));
4174 if let Some(z) = free_basis_opt {
4175 pulled = z.t().dot(&pulled);
4176 }
4177 Ok(pulled)
4178 }
4179
4180 fn penalty_transformed(
4181 &self,
4182 qs: &Array2<f64>,
4183 free_basis_opt: Option<&Array2<f64>>,
4184 ) -> Result<Array2<f64>, EstimationError> {
4185 let dense = self.materialize_dense();
4186 let mut transformed = qs.t().dot(dense).dot(qs);
4187 if let Some(z) = free_basis_opt {
4188 transformed = z.t().dot(&transformed).dot(z);
4189 }
4190 Ok(transformed)
4191 }
4192
4193 fn penalty_scaled_add_to(
4194 &self,
4195 target: &mut Array2<f64>,
4196 amp: f64,
4197 ) -> Result<(), EstimationError> {
4198 let dense = self.materialize_dense();
4199 if target.raw_dim() != dense.raw_dim() {
4200 crate::bail_invalid_estim!(
4201 "latent-coordinate hyper penalty derivative shape mismatch: target={}x{}, matrix={}x{}",
4202 target.nrows(),
4203 target.ncols(),
4204 dense.nrows(),
4205 dense.ncols()
4206 );
4207 }
4208 target.scaled_add(amp, dense);
4209 Ok(())
4210 }
4211}
4212
4213#[derive(Clone)]
4214pub struct HyperDesignDerivative {
4215 pub(crate) storage: DerivativeMatrixStorage,
4216}
4217
4218impl HyperDesignDerivative {
4219 pub fn zero(nrows: usize, ncols: usize) -> Self {
4220 Self {
4221 storage: DerivativeMatrixStorage::Zero(ZeroDerivativeMatrix::new(nrows, ncols)),
4222 }
4223 }
4224
4225 pub fn from_embedded(
4226 local: Array2<f64>,
4227 global_range: Range<usize>,
4228 total_cols: usize,
4229 ) -> Self {
4230 Self {
4231 storage: DerivativeMatrixStorage::Embedded(EmbeddedDerivativeMatrix::new(
4232 local,
4233 global_range,
4234 total_cols,
4235 )),
4236 }
4237 }
4238
4239 pub fn from_implicit(
4240 operator: std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4241 level: ImplicitDerivLevel,
4242 global_range: Range<usize>,
4243 total_cols: usize,
4244 ) -> Self {
4245 Self {
4246 storage: DerivativeMatrixStorage::Implicit(ImplicitDerivativeOp {
4247 operator,
4248 level,
4249 global_range,
4250 total_dim: total_cols,
4251 cached_dense: std::sync::Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
4252 }),
4253 }
4254 }
4255
4256 pub fn from_latent_coord(
4257 operator: std::sync::Arc<gam_terms::basis::LatentCoordDesignDerivative>,
4258 flat_axis: usize,
4259 global_range: Range<usize>,
4260 total_cols: usize,
4261 ) -> Self {
4262 Self {
4263 storage: DerivativeMatrixStorage::LatentCoord(LatentCoordDerivativeOp {
4264 operator,
4265 flat_axis,
4266 global_range,
4267 total_dim: total_cols,
4268 cached_dense: std::sync::Arc::new(gam_runtime::resource::RayonSafeOnce::new()),
4269 }),
4270 }
4271 }
4272
4273 pub(crate) fn resident_byte_count(&self) -> usize {
4274 storage_dispatch!(&self.storage, b => b.resident_byte_count())
4275 }
4276
4277 pub(crate) fn nrows(&self) -> usize {
4278 storage_dispatch!(&self.storage, b => b.design_nrows())
4279 }
4280
4281 pub(crate) fn ncols(&self) -> usize {
4282 storage_dispatch!(&self.storage, b => b.design_ncols())
4283 }
4284
4285 pub(crate) fn uses_implicit_storage(&self) -> bool {
4286 storage_dispatch!(&self.storage, b => b.uses_implicit_storage())
4287 }
4288
4289 pub(crate) fn materialize(&self) -> Array2<f64> {
4290 storage_dispatch!(&self.storage, b => b.materialize())
4291 }
4292
4293 pub(crate) fn any_nonzero(&self) -> bool {
4294 storage_dispatch!(&self.storage, b => b.any_nonzero())
4295 }
4296
4297 pub(crate) fn forward_mul_original(
4298 &self,
4299 u: &Array1<f64>,
4300 ) -> Result<Array1<f64>, EstimationError> {
4301 storage_dispatch!(&self.storage, b => b.design_forward_mul_original(u))
4302 }
4303
4304 pub(crate) fn transpose_mul_original(
4305 &self,
4306 v: &Array1<f64>,
4307 ) -> Result<Array1<f64>, EstimationError> {
4308 storage_dispatch!(&self.storage, b => b.design_transpose_mul_original(v))
4309 }
4310
4311 pub(crate) fn transformed(
4312 &self,
4313 qs: &Array2<f64>,
4314 free_basis_opt: Option<&Array2<f64>>,
4315 ) -> Result<Array2<f64>, EstimationError> {
4316 storage_dispatch!(&self.storage, b => b.design_transformed(qs, free_basis_opt))
4317 }
4318
4319 pub(crate) fn transformed_forward_mul(
4320 &self,
4321 qs: &Array2<f64>,
4322 free_basis_opt: Option<&Array2<f64>>,
4323 u: &Array1<f64>,
4324 ) -> Result<Array1<f64>, EstimationError> {
4325 storage_dispatch!(&self.storage, b => b.design_transformed_forward_mul(qs, free_basis_opt, u))
4326 }
4327
4328 pub(crate) fn transformed_transpose_mul(
4329 &self,
4330 qs: &Array2<f64>,
4331 free_basis_opt: Option<&Array2<f64>>,
4332 v: &Array1<f64>,
4333 ) -> Result<Array1<f64>, EstimationError> {
4334 storage_dispatch!(&self.storage, b => b.design_transformed_transpose_mul(qs, free_basis_opt, v))
4335 }
4336
4337 pub(crate) fn implicit_first_axis_info(
4342 &self,
4343 ) -> Option<(
4344 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4345 usize,
4346 )> {
4347 storage_dispatch!(&self.storage, b => b.implicit_first_axis_info())
4348 }
4349
4350 pub(crate) fn implicit_axis_count_hint(&self) -> Option<usize> {
4351 storage_dispatch!(&self.storage, b => b.implicit_axis_count_hint())
4352 }
4353}
4354
4355impl From<Array2<f64>> for HyperDesignDerivative {
4356 fn from(value: Array2<f64>) -> Self {
4357 Self {
4358 storage: DerivativeMatrixStorage::Dense(value),
4359 }
4360 }
4361}
4362
4363#[derive(Clone)]
4364pub struct HyperPenaltyDerivative {
4365 pub(crate) storage: DerivativeMatrixStorage,
4366}
4367
4368impl HyperPenaltyDerivative {
4369 pub fn from_embedded(local: Array2<f64>, global_range: Range<usize>, total_dim: usize) -> Self {
4370 Self {
4371 storage: DerivativeMatrixStorage::Embedded(EmbeddedDerivativeMatrix::new(
4372 local,
4373 global_range,
4374 total_dim,
4375 )),
4376 }
4377 }
4378
4379 pub(crate) fn resident_byte_count(&self) -> usize {
4380 storage_dispatch!(&self.storage, b => b.resident_byte_count())
4381 }
4382
4383 pub(crate) fn nrows(&self) -> usize {
4384 storage_dispatch!(&self.storage, b => b.penalty_dim())
4385 }
4386
4387 pub(crate) fn ncols(&self) -> usize {
4388 self.nrows()
4389 }
4390
4391 pub(crate) fn scaled_materialize(&self, amp: f64) -> Array2<f64> {
4392 let mut out = Array2::<f64>::zeros((self.nrows(), self.ncols()));
4393 self.scaled_add_to(&mut out, amp)
4394 .expect("scaled materialize uses matching target shape");
4395 out
4396 }
4397
4398 pub(crate) fn transformed(
4399 &self,
4400 qs: &Array2<f64>,
4401 free_basis_opt: Option<&Array2<f64>>,
4402 ) -> Result<Array2<f64>, EstimationError> {
4403 storage_dispatch!(&self.storage, b => b.penalty_transformed(qs, free_basis_opt))
4404 }
4405
4406 pub(crate) fn scaled_add_to(
4407 &self,
4408 target: &mut Array2<f64>,
4409 amp: f64,
4410 ) -> Result<(), EstimationError> {
4411 storage_dispatch!(&self.storage, b => b.penalty_scaled_add_to(target, amp))
4412 }
4413}
4414
4415impl From<Array2<f64>> for HyperPenaltyDerivative {
4416 fn from(value: Array2<f64>) -> Self {
4417 Self {
4418 storage: DerivativeMatrixStorage::Dense(value),
4419 }
4420 }
4421}
4422
4423#[derive(Clone)]
4424pub struct PenaltyDerivativeComponent {
4425 pub penalty_index: usize,
4426 pub matrix: HyperPenaltyDerivative,
4427}
4428
4429#[derive(Clone)]
4430pub struct DirectionalHyperParam {
4431 pub(crate) x_tau_original: HyperDesignDerivative,
4432 pub(crate) penalty_first_components: Vec<PenaltyDerivativeComponent>,
4435 pub(crate) x_tau_tau_original: Option<Vec<Option<HyperDesignDerivative>>>,
4439 pub(crate) penaltysecond_components: Option<Vec<Option<Vec<PenaltyDerivativeComponent>>>>,
4442 pub(crate) penaltysecond_component_provider: Option<
4443 std::sync::Arc<
4444 dyn Fn(usize) -> Result<Option<Vec<PenaltyDerivativeComponent>>, EstimationError>
4445 + Send
4446 + Sync
4447 + 'static,
4448 >,
4449 >,
4450 pub(crate) penaltysecond_partner_indices: Option<std::sync::Arc<[usize]>>,
4451 pub(crate) is_penalty_like: bool,
4455}
4456
4457impl DirectionalHyperParam {
4458 pub(crate) fn resident_byte_count(&self) -> usize {
4459 let mut bytes = self.x_tau_original.resident_byte_count();
4460 for component in &self.penalty_first_components {
4461 bytes = bytes.saturating_add(component.matrix.resident_byte_count());
4462 }
4463 if let Some(entries) = self.x_tau_tau_original.as_ref() {
4464 for entry in entries.iter().flatten() {
4465 bytes = bytes.saturating_add(entry.resident_byte_count());
4466 }
4467 }
4468 if let Some(rows) = self.penaltysecond_components.as_ref() {
4469 for components in rows.iter().flatten() {
4470 for component in components {
4471 bytes = bytes.saturating_add(component.matrix.resident_byte_count());
4472 }
4473 }
4474 }
4475 bytes
4476 }
4477
4478 pub(crate) fn canonicalize_penalty_components(
4479 components: Vec<(usize, HyperPenaltyDerivative)>,
4480 ) -> Result<Vec<PenaltyDerivativeComponent>, EstimationError> {
4481 let mut out: Vec<PenaltyDerivativeComponent> = Vec::with_capacity(components.len());
4482 for (penalty_index, matrix) in components {
4483 if out.iter().any(|c| c.penalty_index == penalty_index) {
4484 crate::bail_invalid_estim!(
4485 "duplicate penalty derivative component for penalty {}",
4486 penalty_index
4487 );
4488 }
4489 out.push(PenaltyDerivativeComponent {
4490 penalty_index,
4491 matrix,
4492 });
4493 }
4494 Ok(out)
4495 }
4496
4497 pub fn new_compact(
4498 x_tau_original: HyperDesignDerivative,
4499 penalty_first_components: Vec<(usize, HyperPenaltyDerivative)>,
4500 x_tau_tau_original: Option<Vec<Option<HyperDesignDerivative>>>,
4501 penaltysecond_components: Option<Vec<Option<Vec<(usize, HyperPenaltyDerivative)>>>>,
4502 ) -> Result<Self, EstimationError> {
4503 let is_penalty_like = !x_tau_original.any_nonzero();
4504 let penalty_first_components =
4505 Self::canonicalize_penalty_components(penalty_first_components)?;
4506 let penaltysecond_components = match penaltysecond_components {
4507 Some(rows) => {
4508 let mut out = Vec::with_capacity(rows.len());
4509 for row in rows {
4510 out.push(match row {
4511 Some(components) => {
4512 Some(Self::canonicalize_penalty_components(components)?)
4513 }
4514 None => None,
4515 });
4516 }
4517 Some(out)
4518 }
4519 None => None,
4520 };
4521 Ok(Self {
4522 x_tau_original,
4523 penalty_first_components,
4524 x_tau_tau_original,
4525 penaltysecond_components,
4526 penaltysecond_component_provider: None,
4527 penaltysecond_partner_indices: None,
4528 is_penalty_like,
4529 })
4530 }
4531
4532 pub fn not_penalty_like(mut self) -> Self {
4535 self.is_penalty_like = false;
4536 self
4537 }
4538
4539 pub fn with_penaltysecond_component_provider(
4540 mut self,
4541 provider: std::sync::Arc<
4542 dyn Fn(usize) -> Result<Option<Vec<PenaltyDerivativeComponent>>, EstimationError>
4543 + Send
4544 + Sync
4545 + 'static,
4546 >,
4547 ) -> Self {
4548 self.penaltysecond_component_provider = Some(provider);
4549 self
4550 }
4551
4552 pub fn with_penaltysecond_partner_indices(mut self, partners: Vec<usize>) -> Self {
4553 self.penaltysecond_partner_indices = Some(std::sync::Arc::from(partners));
4554 self
4555 }
4556
4557 pub(crate) fn x_tau_dense(&self) -> Array2<f64> {
4558 self.x_tau_original.materialize()
4559 }
4560
4561 pub(crate) fn transformed_x_tau(
4562 &self,
4563 qs: &Array2<f64>,
4564 free_basis_opt: Option<&Array2<f64>>,
4565 ) -> Result<Array2<f64>, EstimationError> {
4566 self.x_tau_original.transformed(qs, free_basis_opt)
4567 }
4568
4569 pub(crate) fn x_tau_tau_entry_at(&self, j: usize) -> Option<HyperDesignDerivative> {
4570 self.x_tau_tau_original
4571 .as_ref()
4572 .and_then(|rows| rows.get(j))
4573 .and_then(|entry| entry.clone())
4574 }
4575
4576 pub(crate) fn has_implicit_operator(&self) -> bool {
4579 self.x_tau_original.uses_implicit_storage()
4580 }
4581
4582 pub(crate) fn has_implicit_multidim_duchon(&self) -> bool {
4583 self.implicit_first_axis_info()
4584 .is_some_and(|(op, _)| op.n_axes() > 1 && op.is_duchon_family())
4585 }
4586
4587 pub(crate) fn implicit_first_axis_info(
4589 &self,
4590 ) -> Option<(
4591 std::sync::Arc<gam_terms::basis::ImplicitDesignPsiDerivative>,
4592 usize,
4593 )> {
4594 self.x_tau_original.implicit_first_axis_info()
4595 }
4596
4597 pub(crate) fn implicit_axis_count_hint(&self) -> Option<usize> {
4598 self.x_tau_original.implicit_axis_count_hint()
4599 }
4600
4601 pub(crate) fn penalty_first_components(&self) -> &[PenaltyDerivativeComponent] {
4602 &self.penalty_first_components
4603 }
4604
4605 pub(crate) fn penalty_total_at(
4606 &self,
4607 rho: &Array1<f64>,
4608 p: usize,
4609 ) -> Result<Array2<f64>, EstimationError> {
4610 let mut out = Array2::<f64>::zeros((p, p));
4611 for component in &self.penalty_first_components {
4612 if component.matrix.nrows() != p || component.matrix.ncols() != p {
4613 crate::bail_invalid_estim!(
4614 "S_tau shape mismatch for penalty {}: expected {}x{}, got {}x{}",
4615 component.penalty_index,
4616 p,
4617 p,
4618 component.matrix.nrows(),
4619 component.matrix.ncols()
4620 );
4621 }
4622 if component.penalty_index >= rho.len() {
4623 crate::bail_invalid_estim!(
4624 "penalty_index {} out of bounds for rho dimension {}",
4625 component.penalty_index,
4626 rho.len()
4627 );
4628 }
4629 let lambda = gam_problem::checked_exp_log_strength(rho[component.penalty_index])
4630 .map_err(|error| EstimationError::InvalidInput(error.to_string()))?;
4631 component.matrix.scaled_add_to(&mut out, lambda)?;
4632 }
4633 Ok(out)
4634 }
4635
4636 pub(crate) fn penaltysecond_components_for(
4637 &self,
4638 j: usize,
4639 ) -> Result<Option<Vec<PenaltyDerivativeComponent>>, EstimationError> {
4640 if let Some(components) = self
4641 .penaltysecond_components
4642 .as_ref()
4643 .and_then(|rows| rows.get(j))
4644 .and_then(|row| row.clone())
4645 {
4646 return Ok(Some(components));
4647 }
4648 if let Some(provider) = self.penaltysecond_component_provider.as_ref() {
4649 return provider(j);
4650 }
4651 Ok(None)
4652 }
4653
4654 pub(crate) fn penaltysecond_componentrows(
4655 &self,
4656 ) -> Option<&[Option<Vec<PenaltyDerivativeComponent>>]> {
4657 self.penaltysecond_components.as_deref()
4658 }
4659
4660 pub(crate) fn penalty_first_component_count(&self) -> usize {
4661 self.penalty_first_components.len()
4662 }
4663
4664 pub(crate) fn has_penaltysecond_pair_at(&self, j: usize) -> bool {
4665 self.penaltysecond_components
4666 .as_ref()
4667 .and_then(|rows| rows.get(j))
4668 .is_some_and(Option::is_some)
4669 || self
4670 .penaltysecond_partner_indices
4671 .as_ref()
4672 .is_some_and(|partners| partners.contains(&j))
4673 }
4674}
4675
4676#[derive(Clone, Debug)]
4677pub(crate) struct SparseRemlDecision {
4678 pub(crate) geometry: RemlGeometry,
4679 pub(crate) reason: &'static str,
4680 pub(crate) p: usize,
4681 pub(crate) nnz_x: usize,
4682 pub(crate) nnz_h_upper_est: Option<usize>,
4683 pub(crate) density_h_upper_est: Option<f64>,
4684}
4685
4686#[derive(Clone)]
4687pub(crate) struct SparseExactEvalData {
4688 pub(crate) factor: Arc<SparseExactFactor>,
4689 pub(crate) takahashi: Option<Arc<gam_linalg::sparse_exact::TakahashiInverse>>,
4690 pub(crate) logdet_h: f64,
4691 pub(crate) logdet_s_pos: f64,
4692 pub(crate) penalty_rank: usize,
4693 pub(crate) det1_values: Arc<Array1<f64>>,
4694}
4695
4696#[derive(Clone)]
4697pub struct FirthDenseOperator {
4698 pub(crate) x_dense: Array2<f64>,
4725 pub(crate) x_dense_t: Array2<f64>,
4726 pub(crate) q_basis: Array2<f64>,
4729 pub(crate) x_reduced: Array2<f64>,
4732 pub(crate) observation_weight_sqrt: Option<Array1<f64>>,
4738 pub(crate) k_reduced: Array2<f64>,
4740 pub(crate) x_metric_reduced_inv_diag: Array1<f64>,
4745 pub(crate) half_log_det: f64,
4747 pub(crate) h_diag: Array1<f64>,
4749 pub(crate) w: Array1<f64>,
4751 pub(crate) w1: Array1<f64>,
4752 pub(crate) w2: Array1<f64>,
4753 pub(crate) w3: Array1<f64>,
4754 pub(crate) w4: Array1<f64>,
4755 pub(crate) b_base: Array2<f64>,
4757 pub(crate) p_b_base: Array2<f64>,
4760}
4761
4762#[derive(Clone)]
4779pub(crate) struct FirthDesignFactor {
4780 pub(crate) x_dense: Array2<f64>,
4782 pub(crate) x_dense_t: Array2<f64>,
4783 pub(crate) q_basis: Array2<f64>,
4785 pub(crate) x_reduced: Array2<f64>,
4787 pub(crate) observation_weight_sqrt: Option<Array1<f64>>,
4789 pub(crate) metric_spectrum: Array1<f64>,
4791 pub(crate) x_metric_reduced_inv_diag: Array1<f64>,
4793 pub(crate) r: usize,
4795 pub(crate) n: usize,
4796}
4797
4798#[derive(Clone)]
4799pub(crate) struct FirthDirection {
4800 pub(crate) deta: Array1<f64>,
4801 pub(crate) g_u_reduced: Array2<f64>,
4802 pub(crate) a_u_reduced: Array2<f64>,
4803 pub(crate) dh: Array1<f64>,
4804 pub(crate) b_uvec: Array1<f64>,
4806}
4807
4808#[derive(Clone)]
4809pub(crate) struct FirthTauPartialKernel {
4810 pub(super) deta_partial: Array1<f64>,
4811 pub(crate) dotw1: Array1<f64>,
4812 pub(crate) dotw2: Array1<f64>,
4813 pub(crate) dot_h_partial: Array1<f64>,
4814 pub(crate) x_tau_reduced: Array2<f64>,
4817 pub(super) dot_i_partial: Array2<f64>,
4818 pub(crate) dot_k_reduced: Array2<f64>,
4822}
4823
4824#[derive(Clone)]
4825pub(crate) struct FirthTauExactKernel {
4826 pub(crate) gphi_tau: Array1<f64>,
4827 pub(crate) phi_tau_partial: f64,
4828 pub(crate) tau_kernel: Option<FirthTauPartialKernel>,
4829}
4830
4831#[derive(Clone)]
4843pub(crate) struct FirthTauTauExactKernel {
4844 pub(super) phi_tau_tau_partial: f64,
4845 pub(super) gphi_tau_tau: Array1<f64>,
4846 pub(super) tau_tau_kernel: Option<FirthTauTauPartialKernel>,
4847}
4848
4849#[derive(Clone, Default)]
4862pub(crate) struct FirthTauTauPartialKernel {
4863 pub(super) x_tau_i_reduced: Array2<f64>,
4864 pub(super) x_tau_j_reduced: Array2<f64>,
4865 pub(super) deta_i_partial: Array1<f64>,
4866 pub(super) deta_j_partial: Array1<f64>,
4867 pub(super) dot_h_i_partial: Array1<f64>,
4868 pub(super) dot_h_j_partial: Array1<f64>,
4869 pub(super) dot_k_i_reduced: Array2<f64>,
4870 pub(super) dot_k_j_reduced: Array2<f64>,
4871 pub(super) dot_i_i_partial: Array2<f64>,
4872 pub(super) dot_i_j_partial: Array2<f64>,
4873 pub(super) x_tau_tau_reduced: Option<Array2<f64>>,
4874 pub(super) deta_ij_partial: Option<Array1<f64>>,
4875}
4876
4877#[derive(Clone, Default)]
4885pub(crate) struct FirthTauBetaPartialKernel {
4886 pub(super) x_tau_reduced: Array2<f64>,
4887 pub(super) deta_partial: Array1<f64>,
4888 pub(super) dot_h_partial: Array1<f64>,
4889 pub(super) dot_i_partial: Array2<f64>,
4890 pub(super) dot_k_reduced: Array2<f64>,
4891 pub(super) deta_v: Array1<f64>,
4892 pub(super) deta_tau_v: Array1<f64>,
4893 pub(super) a_v_reduced: Array2<f64>,
4894 pub(super) dh_v: Array1<f64>,
4895 pub(super) b_vvec: Array1<f64>,
4896 pub(super) d_beta_dot_k: Array2<f64>,
4897 pub(super) d_beta_dot_h: Array1<f64>,
4898}
4899
4900#[derive(Clone)]
4911pub(crate) struct EvalShared {
4912 pub(crate) key: Option<Vec<u64>>,
4913 pub(crate) pirls_result: Arc<PirlsResult>,
4914 pub(crate) ridge_passport: RidgePassport,
4915 pub(crate) geometry: RemlGeometry,
4916 pub(crate) h_total: Arc<Array2<f64>>,
4920 pub(crate) sparse_exact: Option<Arc<SparseExactEvalData>>,
4921 pub(crate) firth_dense_operator: Option<Arc<FirthDenseOperator>>,
4922 pub(crate) firth_dense_operator_original: Option<Arc<FirthDenseOperator>>,
4925 pub(crate) penalty_pseudologdet: std::sync::OnceLock<Arc<penalty_logdet::PenaltyPseudologdet>>,
4939 pub(crate) penalty_scores_at_mode: std::sync::OnceLock<Arc<Vec<Array1<f64>>>>,
4952 pub(crate) block_local_correction:
4970 std::sync::OnceLock<(usize, Arc<outer_eval::TkCorrectionTerms>)>,
4971}
4972
4973impl EvalShared {
4974 pub(crate) fn matches(&self, key: &Option<Vec<u64>>) -> bool {
4975 match (&self.key, key) {
4976 (None, None) => true,
4977 (Some(a), Some(b)) => a == b,
4978 _ => false,
4979 }
4980 }
4981
4982 pub(crate) fn penalty_pseudologdet_original(
4997 &self,
4998 canonical_penalties: &[gam_terms::construction::CanonicalPenalty],
4999 lambdas: &[f64],
5000 p: usize,
5001 ) -> Result<Arc<penalty_logdet::PenaltyPseudologdet>, EstimationError> {
5002 if let Some(pld) = self.penalty_pseudologdet.get() {
5003 if pld.dim() != p {
5004 return Err(EstimationError::LayoutError(format!(
5005 "shared penalty pseudo-logdet frame mismatch: cached p={}, requested p={}",
5006 pld.dim(),
5007 p
5008 )));
5009 }
5010 return Ok(Arc::clone(pld));
5011 }
5012 let pld = Arc::new(
5013 penalty_logdet::PenaltyPseudologdet::from_penalties(
5014 canonical_penalties,
5015 lambdas,
5016 self.ridge_passport.penalty_logdet_ridge(),
5017 p,
5018 )
5019 .map_err(EstimationError::InvalidInput)?,
5020 );
5021 match self.penalty_pseudologdet.set(Arc::clone(&pld)) {
5022 Ok(()) => Ok(pld),
5023 Err(_) => Ok(Arc::clone(
5027 self.penalty_pseudologdet
5028 .get()
5029 .expect("OnceLock set raced, so it is initialized"),
5030 )),
5031 }
5032 }
5033}
5034
5035impl PenalizedGeometry for EvalShared {
5036 fn backend_kind(&self) -> GeometryBackendKind {
5037 match self.geometry {
5038 RemlGeometry::DenseSpectral => GeometryBackendKind::DenseSpectral,
5039 RemlGeometry::SparseExactSpd => GeometryBackendKind::SparseExactSpd,
5040 }
5041 }
5042}
5043
5044pub(crate) struct PirlsLruCache {
5054 pub(crate) map: HashMap<Vec<u64>, (Arc<PirlsResult>, u64, usize)>,
5056 pub(crate) byte_budget: usize,
5057 pub(crate) current_bytes: usize,
5058 pub(crate) clock: u64,
5059}
5060
5061impl PirlsLruCache {
5062 pub(crate) fn new(byte_budget: usize) -> Self {
5063 Self {
5064 map: HashMap::new(),
5065 byte_budget: byte_budget.max(1),
5066 current_bytes: 0,
5067 clock: 0,
5068 }
5069 }
5070
5071 pub(crate) fn get(&mut self, key: &Vec<u64>) -> Option<Arc<PirlsResult>> {
5072 if let Some(entry) = self.map.get_mut(key) {
5073 self.clock += 1;
5074 entry.1 = self.clock;
5075 Some(entry.0.clone())
5076 } else {
5077 None
5078 }
5079 }
5080
5081 pub(crate) fn insert(&mut self, key: Vec<u64>, value: Arc<PirlsResult>) {
5082 self.clock += 1;
5083 let bytes = pirls_result_cache_bytes(&value);
5084 if bytes > self.byte_budget {
5088 if let Some((_, _, prev_bytes)) = self.map.remove(&key) {
5089 self.current_bytes = self.current_bytes.saturating_sub(prev_bytes);
5090 }
5091 return;
5092 }
5093 if let Some((_, _, prev_bytes)) = self.map.remove(&key) {
5094 self.current_bytes = self.current_bytes.saturating_sub(prev_bytes);
5095 }
5096 while self.current_bytes + bytes > self.byte_budget {
5097 let evict_key = self
5098 .map
5099 .iter()
5100 .min_by_key(|(_, (_, ts, _))| *ts)
5101 .map(|(k, _)| k.clone());
5102 match evict_key {
5103 Some(k) => {
5104 if let Some((_, _, evict_bytes)) = self.map.remove(&k) {
5105 self.current_bytes = self.current_bytes.saturating_sub(evict_bytes);
5106 }
5107 }
5108 None => break,
5109 }
5110 }
5111 self.current_bytes += bytes;
5112 self.map.insert(key, (value, self.clock, bytes));
5113 }
5114
5115 pub(crate) fn clear(&mut self) {
5116 self.map.clear();
5117 self.current_bytes = 0;
5118 }
5119}
5120
5121#[derive(Clone, Copy, PartialEq, Eq)]
5122pub(crate) struct PenaltySubspaceCacheKey {
5123 pub(crate) penalty_matrix_fingerprint: u64,
5124 pub(crate) ridge_passport_signature: u64,
5125}
5126
5127pub(crate) struct PenaltySubspaceCache {
5128 pub(crate) entry: Option<(PenaltySubspaceCacheKey, Arc<outer_eval::PenaltySubspace>)>,
5129}
5130
5131impl PenaltySubspaceCache {
5132 pub(crate) fn new() -> Self {
5133 Self { entry: None }
5134 }
5135
5136 pub(crate) fn get(
5137 &self,
5138 key: &PenaltySubspaceCacheKey,
5139 ) -> Option<Arc<outer_eval::PenaltySubspace>> {
5140 self.entry
5141 .as_ref()
5142 .filter(|(cached_key, _)| cached_key == key)
5143 .map(|(_, value)| value.clone())
5144 }
5145
5146 pub(crate) fn insert(
5147 &mut self,
5148 key: PenaltySubspaceCacheKey,
5149 value: Arc<outer_eval::PenaltySubspace>,
5150 ) {
5151 self.entry = Some((key, value));
5152 }
5153
5154 pub(crate) fn clear(&mut self) {
5155 self.entry = None;
5156 }
5157}
5158
5159impl PenaltySubspaceCacheKey {
5160 pub(crate) fn from_inputs(
5165 e_transformed: &ndarray::Array2<f64>,
5166 ridge_passport: &gam_problem::RidgePassport,
5167 ) -> Self {
5168 use std::collections::hash_map::DefaultHasher;
5169 use std::hash::{Hash, Hasher};
5170 let mut hasher = DefaultHasher::new();
5171 e_transformed.nrows().hash(&mut hasher);
5172 e_transformed.ncols().hash(&mut hasher);
5173 for value in e_transformed.iter() {
5174 value.to_bits().hash(&mut hasher);
5175 }
5176 let penalty_matrix_fingerprint = hasher.finish();
5177 let mut ridge_hasher = DefaultHasher::new();
5178 ridge_passport.delta().to_bits().hash(&mut ridge_hasher);
5179 ridge_passport.matrix_form().hash(&mut ridge_hasher);
5180 ridge_passport.policy().hash(&mut ridge_hasher);
5181 let ridge_passport_signature = ridge_hasher.finish();
5182 Self {
5183 penalty_matrix_fingerprint,
5184 ridge_passport_signature,
5185 }
5186 }
5187}
5188
5189pub(crate) fn pirls_result_cache_bytes(result: &PirlsResult) -> usize {
5204 use std::mem::size_of;
5205 let n_array_elems = result.final_eta.len()
5206 + result.solveweights.len()
5207 + result.solveworking_response.len()
5208 + result.solvemu.len()
5209 + result.solve_c_array.len()
5210 + result.solve_d_array.len();
5211 let p = result.beta_transformed.0.len();
5212 let pen_h = symmetric_matrix_cache_bytes(&result.penalized_hessian_transformed);
5213 let stab_h = symmetric_matrix_cache_bytes(&result.stabilizedhessian_transformed);
5214 let reparam = (result.reparam_result.s_transformed.len()
5215 + result.reparam_result.qs.len()
5216 + result.reparam_result.e_transformed.len()
5217 + result.reparam_result.det1.len())
5218 * size_of::<f64>();
5219 n_array_elems * size_of::<f64>() + p * size_of::<f64>() + pen_h + stab_h + reparam + 1024
5220}
5221
5222pub(crate) fn symmetric_matrix_cache_bytes(m: &gam_linalg::matrix::SymmetricMatrix) -> usize {
5223 use gam_linalg::matrix::SymmetricMatrix;
5224 use std::mem::size_of;
5225 match m {
5226 SymmetricMatrix::Dense(a) => a.len() * size_of::<f64>(),
5227 SymmetricMatrix::Sparse(s) => {
5228 let (symbolic, values) = s.parts();
5230 values.len() * (size_of::<f64>() + size_of::<usize>())
5231 + std::mem::size_of_val(symbolic.col_ptr())
5232 }
5233 }
5234}
5235
5236pub(crate) const OUTER_EVAL_LRU_CAPACITY: usize = 8;
5244
5245pub(crate) struct OuterEvalLru {
5258 capacity: usize,
5259 entries: std::collections::VecDeque<(Vec<u64>, OuterEval)>,
5261}
5262
5263impl OuterEvalLru {
5264 pub(crate) fn new(capacity: usize) -> Self {
5265 Self {
5266 capacity: capacity.max(1),
5267 entries: std::collections::VecDeque::new(),
5268 }
5269 }
5270
5271 pub(crate) fn get(&mut self, key: &[u64]) -> Option<OuterEval> {
5275 let pos = self.entries.iter().position(|(k, _)| k.as_slice() == key)?;
5276 let entry = self.entries.remove(pos)?;
5277 let eval = entry.1.clone();
5278 self.entries.push_back(entry);
5279 Some(eval)
5280 }
5281
5282 pub(crate) fn insert(&mut self, key: Vec<u64>, eval: OuterEval) {
5285 if let Some(pos) = self
5286 .entries
5287 .iter()
5288 .position(|(k, _)| k.as_slice() == key.as_slice())
5289 {
5290 self.entries.remove(pos);
5291 }
5292 self.entries.push_back((key, eval));
5293 while self.entries.len() > self.capacity {
5294 self.entries.pop_front();
5295 }
5296 }
5297
5298 pub(crate) fn clear(&mut self) {
5299 self.entries.clear();
5300 }
5301}
5302
5303pub(crate) struct EvalCacheManager {
5308 pub(crate) pirls_cache: RwLock<PirlsLruCache>,
5309 pub(crate) penalty_subspace_cache: RwLock<PenaltySubspaceCache>,
5310 pub(crate) current_eval_bundle: RwLock<Option<EvalShared>>,
5311 pub(crate) current_outer_eval: RwLock<Option<(Vec<u64>, OuterEval)>>,
5315 pub(crate) outer_eval_lru: RwLock<OuterEvalLru>,
5330 pub(crate) pirls_cache_enabled: AtomicBool,
5331}
5332
5333impl EvalCacheManager {
5334 pub(crate) fn new() -> Self {
5335 Self {
5336 pirls_cache: RwLock::new(PirlsLruCache::new(PIRLS_CACHE_BYTE_BUDGET)),
5337 penalty_subspace_cache: RwLock::new(PenaltySubspaceCache::new()),
5338 current_eval_bundle: RwLock::new(None),
5339 current_outer_eval: RwLock::new(None),
5340 outer_eval_lru: RwLock::new(OuterEvalLru::new(OUTER_EVAL_LRU_CAPACITY)),
5341 pirls_cache_enabled: AtomicBool::new(true),
5342 }
5343 }
5344
5345 pub(super) fn cached_penalty_subspace<F>(
5352 &self,
5353 e_transformed: &ndarray::Array2<f64>,
5354 ridge_passport: &gam_problem::RidgePassport,
5355 build: F,
5356 ) -> Result<Arc<outer_eval::PenaltySubspace>, EstimationError>
5357 where
5358 F: FnOnce() -> Result<outer_eval::PenaltySubspace, EstimationError>,
5359 {
5360 let key = PenaltySubspaceCacheKey::from_inputs(e_transformed, ridge_passport);
5361 if let Some(hit) = self.penalty_subspace_cache.read().unwrap().get(&key) {
5362 return Ok(hit);
5363 }
5364 let value = Arc::new(build()?);
5365 self.penalty_subspace_cache
5366 .write()
5367 .unwrap()
5368 .insert(key, value.clone());
5369 Ok(value)
5370 }
5371
5372 pub(crate) fn cached_eval_bundle(&self, key: &Option<Vec<u64>>) -> Option<EvalShared> {
5373 let guard = self.current_eval_bundle.read().unwrap();
5374 let bundle: &EvalShared = guard.as_ref()?;
5375 bundle.matches(key).then(|| bundle.clone())
5376 }
5377
5378 pub(crate) fn store_eval_bundle(&self, bundle: EvalShared) {
5379 *self.current_eval_bundle.write().unwrap() = Some(bundle);
5380 }
5381
5382 pub(crate) fn cached_outer_eval(&self, key: &Option<Vec<u64>>) -> Option<OuterEval> {
5383 let key = key.as_ref()?;
5384 self.outer_eval_lru.write().unwrap().get(key)
5391 }
5392
5393 pub(crate) fn store_outer_eval(&self, key: &Option<Vec<u64>>, eval: &OuterEval) {
5394 if let Some(key) = key.clone() {
5395 *self.current_outer_eval.write().unwrap() = Some((key.clone(), eval.clone()));
5399 self.outer_eval_lru
5400 .write()
5401 .unwrap()
5402 .insert(key, eval.clone());
5403 }
5404 }
5405
5406 pub(crate) fn invalidate_eval_bundle(&self) {
5407 self.current_eval_bundle.write().unwrap().take();
5408 self.current_outer_eval.write().unwrap().take();
5409 self.outer_eval_lru.write().unwrap().clear();
5410 }
5411
5412 pub(crate) fn clear_eval_and_factor_caches(&self) {
5413 self.invalidate_eval_bundle();
5414 self.penalty_subspace_cache.write().unwrap().clear();
5415 }
5416}
5417
5418pub(crate) struct RemlArena {
5421 pub(crate) cost_eval_count: RwLock<u64>,
5422 pub(crate) inner_pirls_solve_count: AtomicU64,
5435 pub(crate) lastgradient_used_stochastic_fallback: AtomicBool,
5436}
5437
5438impl RemlArena {
5439 pub(crate) fn new() -> Self {
5440 Self {
5441 cost_eval_count: RwLock::new(0),
5442 inner_pirls_solve_count: AtomicU64::new(0),
5443 lastgradient_used_stochastic_fallback: AtomicBool::new(false),
5444 }
5445 }
5446}
5447
5448pub(crate) struct RemlState<'a> {
5449 pub(crate) y: ArrayView1<'a, f64>,
5450 pub(crate) x: DesignMatrix,
5451 pub(crate) weights: ArrayView1<'a, f64>,
5452 pub(crate) offset: Array1<f64>,
5453 pub(crate) canonical_penalties: Arc<Vec<gam_terms::construction::CanonicalPenalty>>,
5457 pub(crate) balanced_penalty_root: Array2<f64>,
5458 pub(crate) reparam_invariant: ReparamInvariant,
5459 pub(crate) sparse_penalty_block_count: Option<usize>,
5460 pub(crate) p: usize,
5461 pub(crate) config: Arc<RemlConfig>,
5462 pub(crate) runtime_mixture_link_state: Option<gam_problem::MixtureLinkState>,
5463 pub(crate) runtime_sas_link_state: Option<SasLinkState>,
5464 pub(crate) nullspace_dims: Vec<usize>,
5465 pub(crate) coefficient_lower_bounds: Option<Array1<f64>>,
5466 pub(crate) linear_constraints: Option<crate::pirls::LinearInequalityConstraints>,
5467 pub(crate) penalty_shrinkage_floor: Option<f64>,
5469 pub(crate) rho_prior: gam_problem::RhoPrior,
5471
5472 pub(crate) cache_manager: EvalCacheManager,
5473 pub(crate) arena: RemlArena,
5474 pub(crate) warm_start_beta: RwLock<Option<Coefficients>>,
5475 pub(crate) warm_start_rho: RwLock<Option<Array1<f64>>>,
5485 pub(crate) prev_warm_start_beta: RwLock<Option<Coefficients>>,
5486 pub(crate) prev_warm_start_rho: RwLock<Option<Array1<f64>>>,
5487 pub(crate) warm_start_enabled: AtomicBool,
5488 pub(crate) screening_max_inner_iterations: Arc<AtomicUsize>,
5489 pub(crate) outer_inner_cap: Arc<AtomicUsize>,
5504
5505 pub(crate) last_inner_iters: Arc<AtomicUsize>,
5518 pub(crate) last_inner_converged: Arc<AtomicBool>,
5519
5520 pub(crate) ift_warm_start_cache: RwLock<Option<IftWarmStartCache>>,
5536
5537 pub(crate) last_pirls_lm_lambda: Arc<AtomicU64>,
5549
5550 pub(crate) frozen_negbin_theta: Arc<AtomicU64>,
5562
5563 pub(crate) frozen_tweedie_phi: Arc<AtomicU64>,
5577
5578 pub(crate) frozen_gamma_shape: Arc<AtomicU64>,
5595
5596 pub(crate) frozen_beta_phi: Arc<AtomicU64>,
5614
5615 pub(crate) last_ift_prediction_residual: Arc<AtomicU64>,
5637
5638 pub(crate) last_pirls_accept_rho: Arc<AtomicU64>,
5653
5654 pub(crate) ift_cached_factor: RwLock<Option<Arc<dyn gam_linalg::matrix::FactorizedSystem>>>,
5665
5666 pub(crate) kronecker_penalty_system: Option<gam_terms::smooth::KroneckerPenaltySystem>,
5670 pub(crate) kronecker_factored: Option<gam_terms::basis::KroneckerFactoredBasis>,
5673
5674 pub(crate) gaussian_fixed_cache: RwLock<Option<Arc<crate::pirls::GaussianFixedCache>>>,
5684 pub(crate) gaussian_psi_gram_deriv:
5695 RwLock<Option<Arc<(ndarray::Array2<f64>, ndarray::Array1<f64>)>>>,
5696 pub(crate) glm_psi_gram_deriv:
5714 RwLock<Option<Arc<(ndarray::Array2<f64>, ndarray::Array1<f64>)>>>,
5715 pub(crate) glm_first_step_gram: RwLock<Option<Arc<ndarray::Array2<f64>>>>,
5734 pub(crate) flat_glm_first_step_gram:
5749 RwLock<Option<gam_runtime::resource::Governed<Arc<ndarray::Array2<f64>>>>>,
5750 pub(crate) persistent_warm_start_key: RwLock<Option<String>>,
5753 pub(crate) persistent_latent_values_fingerprint: Option<u64>,
5754 pub(crate) persistent_latent_values_cache: RwLock<PersistentLatentValuesCache>,
5755 pub(crate) analytic_penalty_registry_fingerprint: u64,
5756 pub(crate) persistent_warm_start_loaded: AtomicBool,
5758 pub(crate) persistent_warm_start_store_suppression: AtomicUsize,
5762 pub(crate) persistent_warm_start_disk_enabled: AtomicBool,
5776 pub(crate) gaussian_weight_log_sum_half_cache: std::sync::OnceLock<f64>,
5788 pub(crate) gaussian_dp_floor_scale_cache: std::sync::OnceLock<f64>,
5789}