1use crate::cubic_cell_kernel as exact_kernel;
2use crate::custom_family::{
3 BatchedOuterGradientTerms, BlockEffectiveJacobian, BlockWorkingSet, BlockwiseFitOptions,
4 CustomFamily, CustomFamilyJointHyperModeSelection, CustomFamilyWarmStart, EvalMode,
5 ExactNewtonJointGradientEvaluation, ExactNewtonJointHessianWorkspace, FamilyEvaluation,
6 FamilyLinearizationState, ParameterBlockSpec, ParameterBlockState, PenaltyMatrix,
7 custom_family_outer_derivatives, evaluate_custom_family_joint_hyper_best_mode_shared,
8 fit_custom_family, fit_custom_family_fixed_log_lambdas_from_mode_selection,
9 joint_hyper_options_for_outer_tolerance,
10};
11use crate::exact_mode_branch::ExactCoefficientModeBranch;
12use crate::fit_orchestration::drivers::{
13 ExactJointEfsEvaluation, ExactJointEvaluation, ExactJointHyperSetup, SpatialFitProvenance,
14 apply_spatial_anisotropy_pilot_initializer, build_term_collection_designs_and_freeze_joint,
15 optimize_spatial_length_scale_exact_joint, spatial_length_scale_term_indices,
16};
17use crate::marginal_slope_shared::{
18 CoeffSupport, ObservedDenestedCellPartials, SparsePrimaryCoeffJetView, add_optional_matrix,
19 add_optional_vector, add_two_surface_psi_outer,
20 build_denested_partition_cells as shared_denested_partition_cells, chunked_row_reduction,
21 eval_coeff4_at, first_parameter_directional_order2_terms, first_parameter_order2_terms,
22 observed_denested_cell_partials as shared_observed_denested_cell_partials, outer_row_indices,
23 outer_weighted_rows, parameter_block_specs_match_rows, probit_frailty_scale,
24 psi_derivative_location, scale_coeff4, second_parameter_order2_terms,
25};
26use crate::model_types::UnifiedFitResult;
27use crate::outer_subsample::WeightedOuterRow;
28use crate::parameter_block::ParameterBlockInput;
29use crate::probability::{
30 normal_cdf, normal_logcdf, normal_pdf, signed_probit_logcdf_and_mills_ratio,
31 standard_normal_quantile,
32};
33use crate::row_kernel::{
34 RowKernel, RowKernelHessianWorkspace, build_row_kernel_cache, row_kernel_gradient,
35 row_kernel_hessian_dense, row_kernel_log_likelihood,
36};
37use crate::spatial_psi_bridge::{
38 CoefficientSpatialPsiBlockTransform, build_block_spatial_psi_derivatives,
39 build_block_spatial_psi_derivatives_with_transform,
40};
41use crate::survival::lognormal_kernel::{FrailtyScale, FrailtySpec};
42use crate::wiggle::initializewiggle_knots_from_seed;
43use gam_linalg::matrix::{DesignMatrix, SymmetricMatrix};
44use gam_problem::{
45 ExactNewtonJointPsiSecondOrderTerms, ExactNewtonJointPsiTerms, ExactNewtonJointPsiWorkspace,
46 HyperOperator, InverseLink, StandardLink, WigglePenaltyConfig,
47};
48use gam_solve::estimate::reml::reml_outer_engine::{DenseSpectralOperator, HessianFactorization};
49use gam_solve::pirls::LinearInequalityConstraints;
50use gam_terms::smooth::{
51 SpatialLengthScaleOptimizationOptions, SpatialLogKappaCoords, TermCollectionDesign,
52 TermCollectionSpec,
53};
54use ndarray::{Array1, Array2, ArrayView1, ArrayView2, ArrayViewMut1, s};
55use rayon::iter::{IntoParallelIterator, IntoParallelRefIterator, ParallelIterator};
56use serde::{Deserialize, Serialize};
57use std::cell::RefCell;
58use std::collections::HashMap;
59use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
60use std::sync::{Arc, Mutex, OnceLock};
61
62mod alo_replay;
63pub mod deviation_runtime;
64pub mod gpu;
65pub(crate) use alo_replay::exact_runtime_from_saved;
66pub use alo_replay::{
67 BernoulliMarginalSlopeAloRowGeometry, BernoulliMarginalSlopeAloRowInput,
68 BernoulliMarginalSlopeSavedAloReplay, BernoulliMarginalSlopeSavedAloRowGeometry,
69 bernoulli_marginal_slope_alo_row_geometry,
70};
71pub(crate) use alo_replay::{
72 BernoulliMarginalSlopeSavedAloReplayInput, replay_saved_bernoulli_marginal_slope_alo,
73};
74pub use deviation_runtime::DeviationRuntime;
75pub use deviation_runtime::ParametricAnchorBlock;
76
77pub(crate) const BMS_FLEX_SPATIAL_OUTER_PILOT_ROW_THRESHOLD: usize = 50_000;
82
83#[derive(Clone, Debug)]
84pub struct DeviationBlockConfig {
85 pub degree: usize,
86 pub num_internal_knots: usize,
87 pub penalty_order: usize,
88 pub penalty_orders: Vec<usize>,
89 pub double_penalty: bool,
90 pub monotonicity_eps: f64,
91}
92
93impl Default for DeviationBlockConfig {
94 fn default() -> Self {
95 WigglePenaltyConfig::cubic_triple_operator_default().into()
96 }
97}
98
99impl DeviationBlockConfig {
100 pub fn triple_penalty_default() -> Self {
101 Self::default()
102 }
103}
104
105impl From<WigglePenaltyConfig> for DeviationBlockConfig {
106 fn from(cfg: WigglePenaltyConfig) -> Self {
107 let penalty_order = *cfg.penalty_orders.iter().max().unwrap_or(&2);
108 Self {
109 degree: cfg.degree,
110 num_internal_knots: cfg.num_internal_knots,
111 penalty_order,
112 penalty_orders: cfg.penalty_orders,
113 double_penalty: cfg.double_penalty,
114 monotonicity_eps: cfg.monotonicity_eps,
115 }
116 }
117}
118
119#[derive(Clone)]
120pub(crate) struct DeviationPrepared {
121 pub(crate) block: ParameterBlockInput,
122 pub(crate) runtime: DeviationRuntime,
123}
124
125impl std::fmt::Debug for DeviationPrepared {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 f.debug_struct("DeviationPrepared").finish_non_exhaustive()
128 }
129}
130
131#[derive(Clone)]
132pub struct BernoulliMarginalSlopeTermSpec {
133 pub y: Array1<f64>,
134 pub weights: Array1<f64>,
135 pub z: Array1<f64>,
136 pub base_link: InverseLink,
137 pub marginalspec: TermCollectionSpec,
138 pub logslopespec: TermCollectionSpec,
139 pub marginal_offset: Array1<f64>,
140 pub logslope_offset: Array1<f64>,
141 pub frailty: FrailtySpec,
153 pub score_warp: Option<DeviationBlockConfig>,
154 pub link_dev: Option<DeviationBlockConfig>,
155 pub latent_z_policy: LatentZPolicy,
156 pub score_influence_jacobian: Option<Array2<f64>>,
165}
166
167pub struct BernoulliMarginalSlopeFitResult {
168 pub fit: UnifiedFitResult,
169 pub marginalspec_resolved: TermCollectionSpec,
170 pub logslopespec_resolved: TermCollectionSpec,
171 pub marginal_design: TermCollectionDesign,
172 pub logslope_design: TermCollectionDesign,
173 pub baseline_marginal: f64,
174 pub baseline_logslope: f64,
175 pub z_normalization: LatentZNormalization,
176 pub latent_measure: LatentMeasureKind,
177 pub score_warp_runtime: Option<DeviationRuntime>,
178 pub link_dev_runtime: Option<DeviationRuntime>,
179 pub gaussian_frailty_sd: Option<f64>,
181 pub cross_block_warnings: Vec<CrossBlockIdentifiabilityWarning>,
188 pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
205 pub latent_z_conditional_calibration: Option<LatentZConditionalCalibration>,
216}
217
218#[derive(Clone, Debug)]
219pub enum LatentZCheckMode {
220 Strict,
221 WarnOnly,
222 Off,
223}
224
225#[derive(Clone, Debug)]
226pub enum LatentZNormalizationMode {
227 None,
228 FitWeighted,
229 Frozen { mean: f64, sd: f64 },
230}
231
232pub const DEFAULT_EMPIRICAL_LATENT_GRID_SIZE: usize = 65;
233pub(crate) const AUTO_Z_NORMAL_SKEW_TOL: f64 = 0.10;
234pub(crate) const AUTO_Z_NORMAL_KURT_TOL: f64 = 0.25;
235pub(crate) const AUTO_Z_NORMAL_KS_TOL: f64 = 0.025;
236pub(crate) const AUTO_Z_NORMAL_MAX_ABS: f64 = 8.0;
237pub(crate) const AUTO_Z_NORMAL_TAIL_SIGMA_INNER: f64 = 4.0;
242pub(crate) const AUTO_Z_NORMAL_TAIL_SIGMA_OUTER: f64 = 6.0;
245pub(crate) const AUTO_Z_NORMAL_TAIL_MASS_SLACK: f64 = 2.0;
249pub(crate) const AUTO_Z_NORMAL_TAIL_FLOOR_INNER: f64 = 1e-5;
252pub(crate) const AUTO_Z_NORMAL_TAIL_FLOOR_OUTER: f64 = 1e-8;
255pub(crate) const AUTO_Z_CONDITIONAL_RAO_ALPHA: f64 = 1.0e-3;
264pub(crate) const AUTO_Z_CONDITIONAL_RIDGE_REL: f64 = 1.0e-8;
270pub(crate) const AUTO_Z_CONDITIONAL_VAR_FLOOR_FRAC: f64 = 1.0e-3;
275
276#[derive(Clone, Copy, Debug, PartialEq, Eq)]
277pub enum LatentMeasureSpec {
278 Auto { grid_size: usize },
279 StandardNormal,
280 GlobalEmpirical { grid_size: usize },
281}
282
283impl LatentMeasureSpec {
284 pub fn auto_default() -> Self {
285 Self::Auto {
286 grid_size: DEFAULT_EMPIRICAL_LATENT_GRID_SIZE,
287 }
288 }
289}
290
291impl Default for LatentMeasureSpec {
292 fn default() -> Self {
293 Self::auto_default()
294 }
295}
296
297#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
298pub struct EmpiricalZGrid {
299 pub nodes: Vec<f64>,
300 pub weights: Vec<f64>,
301}
302
303impl EmpiricalZGrid {
304 pub fn new(nodes: Vec<f64>, weights: Vec<f64>, context: &str) -> Result<Self, String> {
312 validate_empirical_z_grid(&nodes, &weights, context)?;
313 Ok(Self { nodes, weights })
314 }
315
316 #[inline]
320 pub fn pairs(&self) -> impl Iterator<Item = (f64, f64)> + '_ {
321 self.nodes.iter().copied().zip(self.weights.iter().copied())
322 }
323}
324
325#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
326#[serde(tag = "kind", rename_all = "kebab-case")]
327#[derive(Default)]
328pub enum LatentMeasureKind {
329 #[default]
330 StandardNormal,
331 GlobalEmpirical {
332 grid: EmpiricalZGrid,
333 },
334 LocalEmpirical {
335 feature_cols: Vec<usize>,
336 #[serde(default)]
337 input_scales: Option<Vec<f64>>,
338 centers: Vec<Vec<f64>>,
339 grids: Vec<EmpiricalZGrid>,
340 top_k: usize,
341 bandwidth: f64,
342 #[serde(skip)]
343 train_row_mixtures: Arc<Vec<Vec<(usize, f64)>>>,
344 },
345}
346
347impl LatentMeasureKind {
348 pub fn validate(&self, context: &str) -> Result<(), String> {
349 match self {
350 Self::StandardNormal => Ok(()),
351 Self::GlobalEmpirical { grid } => {
352 validate_empirical_z_grid(&grid.nodes, &grid.weights, context)
353 }
354 Self::LocalEmpirical {
355 feature_cols,
356 input_scales,
357 centers,
358 grids,
359 top_k,
360 bandwidth,
361 ..
362 } => {
363 if feature_cols.is_empty() {
364 return Err(format!(
365 "{context} local empirical latent measure needs feature columns"
366 ));
367 }
368 if centers.is_empty() {
369 return Err(format!(
370 "{context} local empirical latent measure needs centers"
371 ));
372 }
373 if centers.len() != grids.len() {
374 return Err(format!(
375 "{context} local empirical latent measure center/grid length mismatch: centers={}, grids={}",
376 centers.len(),
377 grids.len()
378 ));
379 }
380 if *top_k == 0 || *top_k > centers.len() {
381 return Err(format!(
382 "{context} local empirical latent measure top_k must be in 1..={}, got {top_k}",
383 centers.len()
384 ));
385 }
386 if !(*bandwidth).is_finite() || *bandwidth <= 0.0 {
387 return Err(format!(
388 "{context} local empirical latent measure bandwidth must be finite and positive, got {bandwidth}"
389 ));
390 }
391 if let Some(scales) = input_scales.as_ref() {
392 if scales.len() != feature_cols.len() {
393 return Err(format!(
394 "{context} local empirical latent measure input scale dimension mismatch: scales={}, features={}",
395 scales.len(),
396 feature_cols.len()
397 ));
398 }
399 for (scale_idx, scale) in scales.iter().enumerate() {
400 if !(scale.is_finite() && *scale > 0.0) {
401 return Err(format!(
402 "{context} local empirical latent measure input scale {scale_idx} must be finite and positive, got {scale}"
403 ));
404 }
405 }
406 }
407 for (center_idx, center) in centers.iter().enumerate() {
408 if center.len() != feature_cols.len() {
409 return Err(format!(
410 "{context} local empirical latent center {center_idx} dimension mismatch: got {}, expected {}",
411 center.len(),
412 feature_cols.len()
413 ));
414 }
415 if center.iter().any(|value| !value.is_finite()) {
416 return Err(format!(
417 "{context} local empirical latent center {center_idx} has non-finite coordinates"
418 ));
419 }
420 }
421 for (grid_idx, grid) in grids.iter().enumerate() {
422 validate_empirical_z_grid(
423 &grid.nodes,
424 &grid.weights,
425 &format!("{context} local empirical grid {grid_idx}"),
426 )?;
427 }
428 Ok(())
429 }
430 }
431 }
432
433 pub(crate) fn is_empirical(&self) -> bool {
434 matches!(
435 self,
436 Self::GlobalEmpirical { .. } | Self::LocalEmpirical { .. }
437 )
438 }
439
440 pub(crate) fn empirical_grid_for_training_row(
448 &self,
449 row: usize,
450 ) -> Result<Option<std::borrow::Cow<'_, EmpiricalZGrid>>, String> {
451 match self {
452 Self::StandardNormal => Ok(None),
453 Self::GlobalEmpirical { grid } => Ok(Some(std::borrow::Cow::Borrowed(grid))),
454 Self::LocalEmpirical {
455 grids,
456 train_row_mixtures,
457 ..
458 } => {
459 let mixture = train_row_mixtures.get(row).ok_or_else(|| {
460 format!(
461 "local empirical latent measure is missing training mixture for row {row}"
462 )
463 })?;
464 Ok(Some(std::borrow::Cow::Owned(combine_empirical_grids(
465 grids, mixture,
466 )?)))
467 }
468 }
469 }
470}
471
472fn sort_empirical_node_weight_pairs(nodes: &mut [f64], weights: &mut [f64]) {
478 assert_eq!(
479 nodes.len(),
480 weights.len(),
481 "empirical grid nodes and weights must remain parallel"
482 );
483 fn sift_down(nodes: &mut [f64], weights: &mut [f64], mut root: usize, end: usize) {
484 loop {
485 let mut child = 2 * root + 1;
486 if child >= end {
487 return;
488 }
489 if child + 1 < end && nodes[child].total_cmp(&nodes[child + 1]).is_lt() {
490 child += 1;
491 }
492 if !nodes[root].total_cmp(&nodes[child]).is_lt() {
493 return;
494 }
495 nodes.swap(root, child);
496 weights.swap(root, child);
497 root = child;
498 }
499 }
500
501 let len = nodes.len();
502 for root in (0..len / 2).rev() {
503 sift_down(nodes, weights, root, len);
504 }
505 for end in (1..len).rev() {
506 nodes.swap(0, end);
507 weights.swap(0, end);
508 sift_down(nodes, weights, 0, end);
509 }
510}
511
512pub(crate) fn validate_empirical_z_grid(
513 nodes: &[f64],
514 weights: &[f64],
515 context: &str,
516) -> Result<(), String> {
517 if nodes.len() != weights.len() {
518 return Err(format!(
519 "{context} empirical latent measure node/weight length mismatch: nodes={}, weights={}",
520 nodes.len(),
521 weights.len()
522 ));
523 }
524 if nodes.len() < 2 {
525 return Err(format!(
526 "{context} empirical latent measure requires at least two nodes"
527 ));
528 }
529 let mut total = 0.0;
530 let mut previous_node = f64::NEG_INFINITY;
531 for (idx, (&node, &weight)) in nodes.iter().zip(weights.iter()).enumerate() {
532 if !node.is_finite() {
533 return Err(format!(
534 "{context} empirical latent measure node {idx} is non-finite ({node})"
535 ));
536 }
537 if !(weight.is_finite() && weight > 0.0) {
538 return Err(format!(
539 "{context} empirical latent measure weight {idx} must be finite and positive, got {weight}"
540 ));
541 }
542 if node < previous_node {
543 return Err(format!(
544 "{context} empirical latent measure nodes must be sorted ascending, but node {idx} ({node}) is below node {} ({previous_node})",
545 idx - 1
546 ));
547 }
548 previous_node = node;
549 total += weight;
550 }
551 if !(total.is_finite() && (total - 1.0).abs() <= 1e-8) {
552 return Err(format!(
553 "{context} empirical latent measure weights must sum to 1, got {total}"
554 ));
555 }
556 Ok(())
557}
558
559pub(crate) fn combine_empirical_grids(
560 grids: &[EmpiricalZGrid],
561 mixture: &[(usize, f64)],
562) -> Result<EmpiricalZGrid, String> {
563 if mixture.is_empty() {
564 return Err("local empirical latent measure row mixture is empty".to_string());
565 }
566 let mut nodes = Vec::new();
567 let mut weights = Vec::new();
568 for &(grid_idx, grid_weight) in mixture {
569 if !grid_weight.is_finite() || grid_weight <= 0.0 {
570 return Err(format!(
571 "local empirical latent mixture weight must be finite and positive, got {grid_weight}"
572 ));
573 }
574 let grid = grids.get(grid_idx).ok_or_else(|| {
575 format!("local empirical latent mixture references missing grid {grid_idx}")
576 })?;
577 for (node, weight) in grid.pairs() {
578 nodes.push(node);
579 weights.push(grid_weight * weight);
580 }
581 }
582 let total = weights.iter().copied().sum::<f64>();
583 if !(total.is_finite() && total > 0.0) {
584 return Err(
585 "local empirical latent combined grid has non-positive total weight".to_string(),
586 );
587 }
588 for weight in &mut weights {
589 *weight /= total;
590 }
591 sort_empirical_node_weight_pairs(&mut nodes, &mut weights);
592 validate_empirical_z_grid(&nodes, &weights, "local empirical latent combined grid")?;
593 Ok(EmpiricalZGrid { nodes, weights })
594}
595
596#[derive(Clone, Debug)]
597pub struct LatentZPolicy {
598 pub check_mode: LatentZCheckMode,
599 pub normalization: LatentZNormalizationMode,
600 pub latent_measure: LatentMeasureSpec,
601 pub mean_tol_multiplier: f64,
602 pub sd_tol_multiplier: f64,
603 pub max_abs_skew: f64,
604 pub max_abs_excess_kurtosis: f64,
605}
606
607impl LatentZPolicy {
608 pub fn frozen_transformation_normal() -> Self {
609 Self {
622 check_mode: LatentZCheckMode::WarnOnly,
623 normalization: LatentZNormalizationMode::Frozen { mean: 0.0, sd: 1.0 },
624 latent_measure: LatentMeasureSpec::auto_default(),
625 mean_tol_multiplier: 4.0,
626 sd_tol_multiplier: 4.0,
627 max_abs_skew: 4.0,
628 max_abs_excess_kurtosis: 20.0,
629 }
630 }
631
632 pub fn exploratory_fit_weighted() -> Self {
633 Self {
634 check_mode: LatentZCheckMode::WarnOnly,
635 normalization: LatentZNormalizationMode::FitWeighted,
636 latent_measure: LatentMeasureSpec::auto_default(),
637 mean_tol_multiplier: 8.0,
638 sd_tol_multiplier: 8.0,
639 max_abs_skew: 4.0,
640 max_abs_excess_kurtosis: 20.0,
641 }
642 }
643}
644
645impl Default for LatentZPolicy {
646 fn default() -> Self {
647 Self::frozen_transformation_normal()
648 }
649}
650
651#[derive(Clone, Copy, Debug, PartialEq)]
652pub struct LatentZNormalization {
653 pub mean: f64,
654 pub sd: f64,
655}
656
657impl LatentZNormalization {
658 pub fn apply(&self, z: &Array1<f64>, context: &str) -> Result<Array1<f64>, String> {
659 if !(self.mean.is_finite() && self.sd.is_finite() && self.sd > BMS_VARIANCE_FLOOR) {
660 return Err(format!(
661 "{context} requires finite latent z normalization with sd > {BMS_VARIANCE_FLOOR:e}; got mean={} sd={}",
662 self.mean, self.sd
663 ));
664 }
665 if z.iter().any(|value| !value.is_finite()) {
666 return Err(format!("{context} requires finite z values"));
667 }
668 Ok(z.mapv(|zi| (zi - self.mean) / self.sd))
669 }
670}
671
672#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
707pub struct LatentZRankIntCalibration {
708 pub sorted_z: Vec<f64>,
712 pub weighted_cdf: Vec<f64>,
717 pub post_mean: f64,
720 pub post_sd: f64,
723}
724
725impl LatentZRankIntCalibration {
726 pub fn fit(z: &Array1<f64>, weights: &Array1<f64>) -> Result<Self, String> {
746 if z.len() != weights.len() {
747 return Err(format!(
748 "rank-INT calibration: z length {} != weights length {}",
749 z.len(),
750 weights.len()
751 ));
752 }
753 if z.is_empty() {
754 return Err("rank-INT calibration requires at least one observation".to_string());
755 }
756 let w_total = weights.iter().copied().sum::<f64>();
757 if !(w_total.is_finite() && w_total > 0.0) {
758 return Err(format!(
759 "rank-INT calibration requires positive finite total weight, got {w_total}"
760 ));
761 }
762 for (idx, value) in z.iter().enumerate() {
763 if !value.is_finite() {
764 return Err(format!(
765 "rank-INT calibration: z[{idx}] = {value} not finite"
766 ));
767 }
768 }
769 for (idx, weight) in weights.iter().enumerate() {
770 if !(weight.is_finite() && *weight >= 0.0) {
771 return Err(format!(
772 "rank-INT calibration: weight[{idx}] = {weight} not finite/non-negative"
773 ));
774 }
775 }
776 let mut order: Vec<usize> = (0..z.len()).collect();
777 order.sort_by(|&a, &b| z[a].partial_cmp(&z[b]).unwrap_or(std::cmp::Ordering::Equal));
778
779 let mut sorted_z: Vec<f64> = Vec::with_capacity(z.len());
780 let mut weighted_cdf: Vec<f64> = Vec::with_capacity(z.len());
781 let mut cum_before = 0.0_f64;
789 let mut pos = 0usize;
790 while pos < order.len() {
791 let zi = z[order[pos]];
792 let mut w_group = 0.0_f64;
793 let mut end = pos;
794 while end < order.len() && z[order[end]] == zi {
795 w_group += weights[order[end]];
796 end += 1;
797 }
798 if w_group > 0.0 {
799 sorted_z.push(zi);
800 weighted_cdf.push((cum_before + 0.5 * w_group) / w_total);
801 cum_before += w_group;
802 }
803 pos = end;
804 }
805 if sorted_z.is_empty() {
806 return Err(
807 "rank-INT calibration requires at least one positive-weight observation"
808 .to_string(),
809 );
810 }
811
812 let mut sum_wz = 0.0_f64;
815 let mut sum_w = 0.0_f64;
816 for &idx in &order {
817 let zi = z[idx];
818 let calibrated = Self::apply_with_knots(zi, &sorted_z, &weighted_cdf);
819 sum_wz += weights[idx] * calibrated;
820 sum_w += weights[idx];
821 }
822 let post_mean = if sum_w > 0.0 { sum_wz / sum_w } else { 0.0 };
823 let mut sum_w_dev = 0.0_f64;
824 for &idx in &order {
825 let zi = z[idx];
826 let calibrated = Self::apply_with_knots(zi, &sorted_z, &weighted_cdf);
827 let d = calibrated - post_mean;
828 sum_w_dev += weights[idx] * d * d;
829 }
830 let post_sd = if sum_w > 0.0 {
831 (sum_w_dev / sum_w).sqrt()
832 } else {
833 1.0
834 };
835
836 Ok(Self {
837 sorted_z,
838 weighted_cdf,
839 post_mean,
840 post_sd,
841 })
842 }
843
844 pub fn apply_to_training(&self, z: &Array1<f64>) -> Result<Array1<f64>, String> {
848 if self.sorted_z.is_empty() {
849 return Err("rank-INT calibration has no knots".to_string());
850 }
851 let mut out = Array1::<f64>::zeros(z.len());
852 for (idx, &zi) in z.iter().enumerate() {
853 if !zi.is_finite() {
854 return Err(format!(
855 "rank-INT calibration apply: z[{idx}] = {zi} not finite"
856 ));
857 }
858 out[idx] = self.apply_at_predict(zi);
859 }
860 Ok(out)
861 }
862
863 pub fn apply_at_predict(&self, z: f64) -> f64 {
871 Self::apply_with_knots(z, &self.sorted_z, &self.weighted_cdf)
872 }
873
874 pub(crate) fn apply_with_knots(z: f64, sorted_z: &[f64], weighted_cdf: &[f64]) -> f64 {
875 assert_eq!(sorted_z.len(), weighted_cdf.len());
876 assert!(!sorted_z.is_empty());
877 let n = sorted_z.len();
878 let p = if z <= sorted_z[0] {
879 weighted_cdf[0]
880 } else if z >= sorted_z[n - 1] {
881 weighted_cdf[n - 1]
882 } else {
883 let mut lo = 0usize;
885 let mut hi = n - 1;
886 while hi - lo > 1 {
887 let mid = (lo + hi) / 2;
888 if sorted_z[mid] <= z {
889 lo = mid;
890 } else {
891 hi = mid;
892 }
893 }
894 let z_lo = sorted_z[lo];
895 let z_hi = sorted_z[hi];
896 let p_lo = weighted_cdf[lo];
897 let p_hi = weighted_cdf[hi];
898 if z_hi == z_lo {
899 p_hi
900 } else {
901 let t = (z - z_lo) / (z_hi - z_lo);
902 p_lo + t * (p_hi - p_lo)
903 }
904 };
905 standard_normal_quantile(p).unwrap_or_else(|_| if p < 0.5 { -8.0 } else { 8.0 })
907 }
908}
909
910#[derive(Clone, Debug)]
915pub enum LatentMeasureCalibration {
916 None,
917 RankInverseNormal(LatentZRankIntCalibration),
918 ConditionalLocationScale(LatentZConditionalCalibration),
919}
920
921#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
949pub struct LatentZConditionalCalibration {
950 pub mean_coeffs: Vec<f64>,
954 pub var_coeffs: Vec<f64>,
959 pub basis_ncols: usize,
963 pub var_floor: f64,
966 pub global_var: f64,
969 pub post_mean: f64,
971 pub post_sd: f64,
973 pub mean_cov: Array2<f64>,
981 pub var_cov: Array2<f64>,
986}
987
988impl LatentZConditionalCalibration {
989 #[inline]
990 pub(crate) fn affine(coeffs: &[f64], a_row: ArrayView1<'_, f64>) -> f64 {
991 let mut acc = coeffs[0];
992 for (c, &x) in coeffs[1..].iter().zip(a_row.iter()) {
993 acc += c * x;
994 }
995 acc
996 }
997
998 pub(crate) fn conditional_mean(&self, a_row: ArrayView1<'_, f64>) -> f64 {
999 Self::affine(&self.mean_coeffs, a_row)
1000 }
1001
1002 pub(crate) fn conditional_var(&self, a_row: ArrayView1<'_, f64>) -> f64 {
1003 if self.var_coeffs.is_empty() {
1004 self.global_var.max(self.var_floor)
1005 } else {
1006 Self::affine(&self.var_coeffs, a_row).max(self.var_floor)
1007 }
1008 }
1009
1010 pub fn apply(
1014 &self,
1015 z: ArrayView1<'_, f64>,
1016 a_block: ArrayView2<'_, f64>,
1017 ) -> Result<Array1<f64>, String> {
1018 if a_block.ncols() != self.basis_ncols {
1019 return Err(format!(
1020 "conditional latent calibration expects {} basis columns, got {}",
1021 self.basis_ncols,
1022 a_block.ncols()
1023 ));
1024 }
1025 if a_block.nrows() != z.len() {
1026 return Err(format!(
1027 "conditional latent calibration row mismatch: z={}, basis rows={}",
1028 z.len(),
1029 a_block.nrows()
1030 ));
1031 }
1032 if self.mean_coeffs.len() != self.basis_ncols + 1 {
1033 return Err(format!(
1034 "conditional latent calibration mean coefficient length {} != basis_ncols+1 ({})",
1035 self.mean_coeffs.len(),
1036 self.basis_ncols + 1
1037 ));
1038 }
1039 let mut out = Array1::<f64>::zeros(z.len());
1040 for i in 0..z.len() {
1041 let a_row = a_block.row(i);
1042 if !z[i].is_finite() {
1043 return Err(format!(
1044 "conditional latent calibration: z[{i}] = {} not finite",
1045 z[i]
1046 ));
1047 }
1048 let m = self.conditional_mean(a_row);
1049 let v = self.conditional_var(a_row);
1050 if !(v.is_finite() && v > 0.0) {
1051 return Err(format!(
1052 "conditional latent calibration produced non-positive variance {v} at row {i}"
1053 ));
1054 }
1055 let zeta = (z[i] - m) / v.sqrt();
1056 if !zeta.is_finite() {
1057 return Err(format!(
1058 "conditional latent calibration produced non-finite zeta at row {i}"
1059 ));
1060 }
1061 out[i] = zeta;
1062 }
1063 Ok(out)
1064 }
1065
1066 pub fn theta1_dim(&self) -> usize {
1071 self.mean_coeffs.len() + self.var_coeffs.len()
1072 }
1073
1074 pub fn zeta_theta1_jacobian_row(&self, z: f64, a_row: ArrayView1<'_, f64>) -> Vec<f64> {
1087 let m = self.conditional_mean(a_row);
1088 let v = self.conditional_var(a_row);
1089 let inv_sqrt_v = 1.0 / v.sqrt();
1090 let mut out = Vec::with_capacity(self.theta1_dim());
1092 let dzeta_dm = -inv_sqrt_v;
1093 out.push(dzeta_dm); for &x in a_row.iter() {
1095 out.push(dzeta_dm * x);
1096 }
1097 if !self.var_coeffs.is_empty() {
1098 let raw_v = Self::affine(&self.var_coeffs, a_row);
1101 let dzeta_dv = if raw_v > self.var_floor {
1102 let zeta = (z - m) * inv_sqrt_v;
1103 -zeta / (2.0 * v)
1104 } else {
1105 0.0
1106 };
1107 out.push(dzeta_dv);
1108 for &x in a_row.iter() {
1109 out.push(dzeta_dv * x);
1110 }
1111 }
1112 out
1113 }
1114
1115 pub fn theta1_covariance(&self) -> Array2<f64> {
1123 let dm = self.mean_coeffs.len();
1124 let dv = self.var_coeffs.len();
1125 let mut v1 = Array2::<f64>::zeros((dm + dv, dm + dv));
1126 v1.slice_mut(s![..dm, ..dm]).assign(&self.mean_cov);
1127 if dv > 0 {
1128 v1.slice_mut(s![dm.., dm..]).assign(&self.var_cov);
1129 }
1130 v1
1131 }
1132
1133 pub fn generated_regressor_term(&self, hbeta_inv_g: ArrayView2<'_, f64>) -> Array2<f64> {
1147 let v1 = self.theta1_covariance();
1148 hbeta_inv_g.dot(&v1).dot(&hbeta_inv_g.t())
1149 }
1150
1151 pub fn generated_regressor_correction(
1183 &self,
1184 score_zeta_sensitivity: ArrayView2<'_, f64>,
1185 z: ArrayView1<'_, f64>,
1186 a_block: ArrayView2<'_, f64>,
1187 vb: ArrayView2<'_, f64>,
1188 ) -> Result<Array2<f64>, String> {
1189 let n = score_zeta_sensitivity.nrows();
1190 let p_beta = score_zeta_sensitivity.ncols();
1191 if z.len() != n || a_block.nrows() != n {
1192 return Err(format!(
1193 "generated_regressor_correction row mismatch: score_zeta_sensitivity rows={n}, \
1194 z={}, a_block rows={}",
1195 z.len(),
1196 a_block.nrows()
1197 ));
1198 }
1199 if a_block.ncols() != self.basis_ncols {
1200 return Err(format!(
1201 "generated_regressor_correction expects {} basis columns, got {}",
1202 self.basis_ncols,
1203 a_block.ncols()
1204 ));
1205 }
1206 if vb.nrows() != p_beta || vb.ncols() != p_beta {
1207 return Err(format!(
1208 "generated_regressor_correction: vb must be {p_beta}×{p_beta}, got {}×{}",
1209 vb.nrows(),
1210 vb.ncols()
1211 ));
1212 }
1213 let j_mat = self.build_zeta_theta1_jacobian(z, a_block);
1224 let vb_g = self.beta_theta1_sensitivity(score_zeta_sensitivity, j_mat.view(), vb)?;
1225 Ok(self.generated_regressor_term(vb_g.view()))
1226 }
1227
1228 fn build_zeta_theta1_jacobian(
1233 &self,
1234 z: ArrayView1<'_, f64>,
1235 a_block: ArrayView2<'_, f64>,
1236 ) -> Array2<f64> {
1237 let n = a_block.nrows();
1238 let dim_theta1 = self.theta1_dim();
1239 let mut j_mat = Array2::<f64>::zeros((n, dim_theta1));
1240 for i in 0..n {
1241 let j_zeta_row = self.zeta_theta1_jacobian_row(z[i], a_block.row(i));
1242 assert_eq!(
1243 j_zeta_row.len(),
1244 dim_theta1,
1245 "J_zeta row width must match the first-stage hyperparameter dimension"
1246 );
1247 let mut dst = j_mat.row_mut(i);
1248 for (slot, jz) in dst.iter_mut().zip(j_zeta_row.into_iter()) {
1249 *slot = jz;
1250 }
1251 }
1252 j_mat
1253 }
1254
1255 fn beta_theta1_sensitivity(
1270 &self,
1271 score_zeta_sensitivity: ArrayView2<'_, f64>,
1272 j_zeta: ArrayView2<'_, f64>,
1273 vb: ArrayView2<'_, f64>,
1274 ) -> Result<Array2<f64>, String> {
1275 let g = gam_linalg::faer_ndarray::fast_atb(&score_zeta_sensitivity, &j_zeta);
1277 Ok(vb.dot(&g))
1280 }
1281}
1282
1283pub(crate) fn weighted_ridge_sandwich_cov(
1302 basis: ArrayView2<'_, f64>,
1303 residuals: &[f64],
1304 weights: ArrayView1<'_, f64>,
1305 normal_matrix: &Array2<f64>,
1306) -> Result<Array2<f64>, String> {
1307 let n = basis.nrows();
1308 let p = basis.ncols();
1309 if residuals.len() != n || weights.len() != n {
1310 return Err(format!(
1311 "weighted ridge sandwich length mismatch: rows={n}, residuals={}, weights={}",
1312 residuals.len(),
1313 weights.len()
1314 ));
1315 }
1316 if normal_matrix.nrows() != p || normal_matrix.ncols() != p {
1317 return Err(format!(
1318 "weighted ridge sandwich normal-matrix shape mismatch: basis cols={p}, normal {}x{}",
1319 normal_matrix.nrows(),
1320 normal_matrix.ncols()
1321 ));
1322 }
1323 let mut b = basis.to_owned();
1330 for i in 0..n {
1331 let wi = weights[i];
1332 let ri = residuals[i];
1333 let scale = wi * ri;
1334 if scale == 0.0 {
1335 b.row_mut(i).fill(0.0);
1336 continue;
1337 }
1338 b.row_mut(i).iter_mut().for_each(|value| *value *= scale);
1339 }
1340 let meat = gam_linalg::faer_ndarray::fast_ata(&b);
1341 let mut m_sym = normal_matrix.clone();
1345 gam_linalg::matrix::symmetrize_in_place(&mut m_sym);
1346 let scale: Vec<f64> = (0..p)
1369 .map(|j| 1.0 / m_sym[[j, j]].max(f64::MIN_POSITIVE).sqrt())
1370 .collect();
1371 let mut m_scaled = m_sym;
1372 let mut meat_scaled = meat;
1373 for i in 0..p {
1374 for j in 0..p {
1375 let s = scale[i] * scale[j];
1376 m_scaled[[i, j]] *= s;
1377 meat_scaled[[i, j]] *= s;
1378 }
1379 }
1380 let m_pinv = gam_linalg::utils::rank_certified_psd_pseudoinverse(&m_scaled, 1.0e-10)
1381 .map_err(|e| format!("conditional latent calibration sandwich pseudo-inverse failed: {e}"))?
1382 .into_pseudoinverse();
1383 let mut cov = m_pinv.dot(&meat_scaled).dot(&m_pinv);
1384 for i in 0..p {
1386 for j in 0..p {
1387 cov[[i, j]] *= scale[i] * scale[j];
1388 }
1389 }
1390 if cov.iter().any(|v| !v.is_finite()) {
1391 return Err("conditional latent calibration sandwich covariance is non-finite".to_string());
1392 }
1393 Ok(cov)
1394}
1395
1396pub(crate) fn weighted_mean(
1398 values: &[f64],
1399 weights: ArrayView1<'_, f64>,
1400 total_weight: f64,
1401) -> f64 {
1402 values
1403 .iter()
1404 .zip(weights.iter())
1405 .map(|(&v, &w)| w * v)
1406 .sum::<f64>()
1407 / total_weight
1408}
1409
1410pub(crate) fn robust_conditional_score_pvalue(
1421 a_centered: ArrayView2<'_, f64>,
1422 u: &[f64],
1423 weights: ArrayView1<'_, f64>,
1424) -> Result<Option<f64>, String> {
1425 let n = a_centered.nrows();
1426 let r = a_centered.ncols();
1427 if r == 0 || n == 0 {
1428 return Ok(None);
1429 }
1430 if u.len() != n || weights.len() != n {
1431 return Err(format!(
1432 "conditional score test length mismatch: rows={n}, u={}, weights={}",
1433 u.len(),
1434 weights.len()
1435 ));
1436 }
1437 let mut b = a_centered.to_owned();
1448 for i in 0..n {
1449 let wi = weights[i];
1450 let scale = if wi > 0.0 { wi * u[i] } else { 0.0 };
1451 if scale == 0.0 {
1452 b.row_mut(i).fill(0.0);
1453 continue;
1454 }
1455 b.row_mut(i).iter_mut().for_each(|value| *value *= scale);
1456 }
1457 let s = b.sum_axis(ndarray::Axis(0));
1458 let omega = gam_linalg::faer_ndarray::fast_ata(&b);
1459 if !s.iter().all(|v| v.is_finite()) || !omega.iter().all(|v| v.is_finite()) {
1460 return Ok(None);
1461 }
1462 let omega_geometry = gam_linalg::utils::rank_certified_psd_pseudoinverse(&omega, 1.0e-10)
1463 .map_err(|e| format!("conditional score test pseudo-inverse failed: {e}"))?;
1464 let rank = omega_geometry.rank();
1465 let omega_pinv = omega_geometry.into_pseudoinverse();
1466 if rank == 0 {
1467 return Ok(None);
1468 }
1469 let d_stat = s.dot(&omega_pinv.dot(&s));
1470 if !(d_stat.is_finite() && d_stat >= 0.0) {
1471 return Ok(None);
1472 }
1473 let p_lower = statrs::function::gamma::gamma_lr(rank as f64 / 2.0, d_stat / 2.0);
1475 let p_value = (1.0 - p_lower).clamp(0.0, 1.0);
1476 Ok(Some(p_value))
1477}
1478
1479pub(crate) fn fit_conditional_latent_calibration_if_needed(
1486 z: &Array1<f64>,
1487 weights: &Array1<f64>,
1488 a_block: ArrayView2<'_, f64>,
1489) -> Result<Option<LatentZConditionalCalibration>, String> {
1490 let n = z.len();
1491 let p = a_block.ncols();
1492 if n != weights.len() {
1493 return Err(format!(
1494 "conditional latent gate length mismatch: z={n}, weights={}",
1495 weights.len()
1496 ));
1497 }
1498 if a_block.nrows() != n {
1499 return Err(format!(
1500 "conditional latent gate row mismatch: z={n}, basis rows={}",
1501 a_block.nrows()
1502 ));
1503 }
1504 if p == 0 {
1505 return Ok(None);
1506 }
1507 let total_weight = weights.iter().copied().sum::<f64>();
1508 if !(total_weight.is_finite() && total_weight > 0.0) {
1509 return Ok(None);
1510 }
1511 if z.iter().any(|v| !v.is_finite()) || a_block.iter().any(|v| !v.is_finite()) {
1512 return Ok(None);
1513 }
1514
1515 let z_mean = z
1516 .iter()
1517 .zip(weights.iter())
1518 .map(|(&zi, &wi)| wi * zi)
1519 .sum::<f64>()
1520 / total_weight;
1521 let global_var = z
1522 .iter()
1523 .zip(weights.iter())
1524 .map(|(&zi, &wi)| wi * (zi - z_mean) * (zi - z_mean))
1525 .sum::<f64>()
1526 / total_weight;
1527 if !(global_var.is_finite() && global_var > 0.0) {
1528 return Ok(None);
1529 }
1530
1531 let mut a_centered = a_block.to_owned();
1536 for j in 0..p {
1537 let col = a_block.column(j);
1538 let col_mean = col
1539 .iter()
1540 .zip(weights.iter())
1541 .map(|(&v, &w)| w * v)
1542 .sum::<f64>()
1543 / total_weight;
1544 a_centered.column_mut(j).mapv_inplace(|v| v - col_mean);
1545 }
1546
1547 let u_mean: Vec<f64> = z.iter().map(|&zi| zi - z_mean).collect();
1549 let p_mean = robust_conditional_score_pvalue(a_centered.view(), &u_mean, weights.view())?;
1550 let u_var: Vec<f64> = u_mean.iter().map(|&e| e * e - global_var).collect();
1552 let p_var = robust_conditional_score_pvalue(a_centered.view(), &u_var, weights.view())?;
1553
1554 let mean_fires = p_mean.is_some_and(|p| p < AUTO_Z_CONDITIONAL_RAO_ALPHA);
1555 let var_fires = p_var.is_some_and(|p| p < AUTO_Z_CONDITIONAL_RAO_ALPHA);
1556 if !mean_fires && !var_fires {
1557 return Ok(None);
1558 }
1559
1560 let basis = build_intercept_basis(a_block);
1567 let mut penalty = Array2::<f64>::zeros((basis.ncols(), basis.ncols()));
1574 for j in 0..basis.ncols() {
1575 let diag_jj = basis
1576 .column(j)
1577 .iter()
1578 .zip(weights.iter())
1579 .map(|(&x, &w)| w * x * x)
1580 .sum::<f64>()
1581 .max(f64::MIN_POSITIVE);
1582 penalty[[j, j]] = diag_jj;
1583 }
1584 let z_col = z.view().insert_axis(ndarray::Axis(1));
1585 let (mean_coeffs_mat, mean_fitted) = gam_linalg::utils::gaussian_weighted_ridge(
1586 basis.view(),
1587 z_col,
1588 penalty.view(),
1589 weights.view(),
1590 AUTO_Z_CONDITIONAL_RIDGE_REL,
1591 )?;
1592 let mean_coeffs: Vec<f64> = mean_coeffs_mat.column(0).to_vec();
1593
1594 let normal_matrix = {
1600 let mut wa = basis.to_owned();
1601 for i in 0..wa.nrows() {
1602 let wi = weights[i];
1603 wa.row_mut(i).iter_mut().for_each(|value| *value *= wi);
1604 }
1605 let mut m = basis.t().dot(&wa);
1606 m += &(penalty.to_owned() * AUTO_Z_CONDITIONAL_RIDGE_REL);
1607 m
1608 };
1609 let mean_residuals: Vec<f64> = z
1610 .iter()
1611 .zip(mean_fitted.column(0).iter())
1612 .map(|(&zi, &mi)| zi - mi)
1613 .collect();
1614 let mean_cov = weighted_ridge_sandwich_cov(
1615 basis.view(),
1616 &mean_residuals,
1617 weights.view(),
1618 &normal_matrix,
1619 )?;
1620
1621 let var_floor = (AUTO_Z_CONDITIONAL_VAR_FLOOR_FRAC * global_var).max(f64::MIN_POSITIVE);
1622 let (var_coeffs, var_cov): (Vec<f64>, Array2<f64>) = if var_fires {
1623 let resid_sq: Array1<f64> = mean_residuals.iter().map(|&e| e * e).collect();
1626 let resid_col = resid_sq.view().insert_axis(ndarray::Axis(1));
1627 let (var_coeffs_mat, var_fitted) = gam_linalg::utils::gaussian_weighted_ridge(
1628 basis.view(),
1629 resid_col,
1630 penalty.view(),
1631 weights.view(),
1632 AUTO_Z_CONDITIONAL_RIDGE_REL,
1633 )?;
1634 let var_residuals: Vec<f64> = resid_sq
1639 .iter()
1640 .zip(var_fitted.column(0).iter())
1641 .map(|(&si, &vi)| si - vi)
1642 .collect();
1643 let cov = weighted_ridge_sandwich_cov(
1644 basis.view(),
1645 &var_residuals,
1646 weights.view(),
1647 &normal_matrix,
1648 )?;
1649 (var_coeffs_mat.column(0).to_vec(), cov)
1650 } else {
1651 (Vec::new(), Array2::<f64>::zeros((0, 0)))
1652 };
1653
1654 let mut calibration = LatentZConditionalCalibration {
1655 mean_coeffs,
1656 var_coeffs,
1657 basis_ncols: p,
1658 var_floor,
1659 global_var,
1660 post_mean: 0.0,
1661 post_sd: 1.0,
1662 mean_cov,
1663 var_cov,
1664 };
1665
1666 let calibrated = calibration.apply(z.view(), a_block)?;
1668 let post_mean = weighted_mean(calibrated.as_slice().unwrap(), weights.view(), total_weight);
1669 let post_var = calibrated
1670 .iter()
1671 .zip(weights.iter())
1672 .map(|(&zi, &wi)| wi * (zi - post_mean) * (zi - post_mean))
1673 .sum::<f64>()
1674 / total_weight;
1675 calibration.post_mean = post_mean;
1676 calibration.post_sd = post_var.max(0.0).sqrt();
1677
1678 Ok(Some(calibration))
1679}
1680
1681pub(crate) fn build_intercept_basis(a_block: ArrayView2<'_, f64>) -> Array2<f64> {
1684 let n = a_block.nrows();
1685 let p = a_block.ncols();
1686 let mut basis = Array2::<f64>::ones((n, p + 1));
1687 basis.slice_mut(s![.., 1..]).assign(&a_block);
1688 basis
1689}
1690
1691pub(crate) fn build_latent_measure_with_geometry(
1692 z: &Array1<f64>,
1693 weights: &Array1<f64>,
1694 policy: &LatentZPolicy,
1695 conditioning: Option<ArrayView2<'_, f64>>,
1696) -> Result<(LatentMeasureKind, LatentMeasureCalibration), String> {
1697 match policy.latent_measure {
1698 LatentMeasureSpec::Auto { grid_size } => {
1699 if let Some(a_block) = conditioning
1706 && let Some(cal) =
1707 fit_conditional_latent_calibration_if_needed(z, weights, a_block)?
1708 {
1709 let zeta = cal.apply(z.view(), a_block)?;
1718 let residual_is_standard_normal =
1719 latent_z_is_standard_normal_enough(&zeta, weights, policy)?;
1720 let kind = if residual_is_standard_normal {
1721 LatentMeasureKind::StandardNormal
1722 } else {
1723 build_global_empirical_latent_measure(&zeta, weights, grid_size)?
1724 };
1725 log::info!(
1726 "[BMS latent-z] conditional location-scale calibrated: basis_ncols={} var_active={} post_mean={:.3e} post_sd={:.3e} residual_measure={} (E[z|C]/Var(z|C) Rao gate fired)",
1727 cal.basis_ncols,
1728 !cal.var_coeffs.is_empty(),
1729 cal.post_mean,
1730 cal.post_sd,
1731 if residual_is_standard_normal {
1732 "standard-normal"
1733 } else {
1734 "global-empirical"
1735 },
1736 );
1737 return Ok((
1738 kind,
1739 LatentMeasureCalibration::ConditionalLocationScale(cal),
1740 ));
1741 }
1742 if latent_z_is_standard_normal_enough(z, weights, policy)? {
1743 Ok((
1744 LatentMeasureKind::StandardNormal,
1745 LatentMeasureCalibration::None,
1746 ))
1747 } else {
1748 let calibration = LatentZRankIntCalibration::fit(z, weights)?;
1759 let calibrated = calibration.apply_to_training(z)?;
1760 if latent_z_is_standard_normal_enough(&calibrated, weights, policy)? {
1761 log::info!(
1762 "[BMS latent-z] rank-INT calibrated: post_mean={:.3e} post_sd={:.3e} knots={}",
1763 calibration.post_mean,
1764 calibration.post_sd,
1765 calibration.sorted_z.len(),
1766 );
1767 Ok((
1768 LatentMeasureKind::StandardNormal,
1769 LatentMeasureCalibration::RankInverseNormal(calibration),
1770 ))
1771 } else {
1772 log::info!(
1773 "[BMS latent-z] rank-INT output failed the standard-normal adequacy gate (post_mean={:.3e} post_sd={:.3e} knots={}); using the global-empirical latent measure",
1774 calibration.post_mean,
1775 calibration.post_sd,
1776 calibration.sorted_z.len(),
1777 );
1778 Ok((
1779 build_global_empirical_latent_measure(z, weights, grid_size)?,
1780 LatentMeasureCalibration::None,
1781 ))
1782 }
1783 }
1784 }
1785 LatentMeasureSpec::StandardNormal => Ok((
1786 LatentMeasureKind::StandardNormal,
1787 LatentMeasureCalibration::None,
1788 )),
1789 LatentMeasureSpec::GlobalEmpirical { grid_size } => {
1790 let kind = build_global_empirical_latent_measure(z, weights, grid_size)?;
1791 Ok((kind, LatentMeasureCalibration::None))
1792 }
1793 }
1794}
1795
1796pub(crate) fn latent_z_is_standard_normal_enough(
1797 z: &Array1<f64>,
1798 weights: &Array1<f64>,
1799 policy: &LatentZPolicy,
1800) -> Result<bool, String> {
1801 if z.len() != weights.len() {
1802 return Err(format!(
1803 "latent-measure auto-detection length mismatch: z={}, weights={}",
1804 z.len(),
1805 weights.len()
1806 ));
1807 }
1808 let weight_sum = weights.iter().copied().sum::<f64>();
1809 let weight_sq_sum = weights.iter().map(|&w| w * w).sum::<f64>();
1810 if !(weight_sum.is_finite()
1811 && weight_sum > 0.0
1812 && weight_sq_sum.is_finite()
1813 && weight_sq_sum > 0.0)
1814 {
1815 return Err("latent-measure auto-detection requires positive finite weights".to_string());
1816 }
1817 let effective_n = weight_sum * weight_sum / weight_sq_sum;
1818 if !(effective_n.is_finite() && effective_n > 1.0) {
1819 return Err(
1820 "latent-measure auto-detection requires at least two effective observations"
1821 .to_string(),
1822 );
1823 }
1824 let mean = z
1825 .iter()
1826 .zip(weights.iter())
1827 .map(|(&zi, &wi)| wi * zi)
1828 .sum::<f64>()
1829 / weight_sum;
1830 let var = z
1831 .iter()
1832 .zip(weights.iter())
1833 .map(|(&zi, &wi)| wi * (zi - mean) * (zi - mean))
1834 .sum::<f64>()
1835 / weight_sum;
1836 let sd = var.sqrt();
1837 if !(mean.is_finite() && sd.is_finite() && sd > 0.0) {
1838 return Ok(false);
1839 }
1840 let skew = z
1841 .iter()
1842 .zip(weights.iter())
1843 .map(|(&zi, &wi)| {
1844 let centered = (zi - mean) / sd;
1845 wi * centered.powi(3)
1846 })
1847 .sum::<f64>()
1848 / weight_sum;
1849 let excess_kurtosis = z
1850 .iter()
1851 .zip(weights.iter())
1852 .map(|(&zi, &wi)| {
1853 let centered = (zi - mean) / sd;
1854 wi * centered.powi(4)
1855 })
1856 .sum::<f64>()
1857 / weight_sum
1858 - 3.0;
1859 let mean_tol = policy.mean_tol_multiplier / effective_n.sqrt();
1860 let sd_tol = policy.sd_tol_multiplier / (2.0 * (effective_n - 1.0).max(1.0)).sqrt();
1861 let ks_to_normal = weighted_ks_to_standard_normal(z, weights, weight_sum)?;
1862 let tail_mass_4 = weighted_tail_mass(z, weights, weight_sum, AUTO_Z_NORMAL_TAIL_SIGMA_INNER);
1863 let tail_mass_6 = weighted_tail_mass(z, weights, weight_sum, AUTO_Z_NORMAL_TAIL_SIGMA_OUTER);
1864 let max_abs_z = z.iter().fold(0.0_f64, |acc, &zi| acc.max(zi.abs()));
1865 let normal_tail_4 = 2.0 * (1.0 - normal_cdf(AUTO_Z_NORMAL_TAIL_SIGMA_INNER));
1866 let normal_tail_6 = 2.0 * (1.0 - normal_cdf(AUTO_Z_NORMAL_TAIL_SIGMA_OUTER));
1867 Ok(mean.abs() <= mean_tol
1868 && (sd - 1.0).abs() <= sd_tol
1869 && skew.is_finite()
1870 && skew.abs() <= policy.max_abs_skew.min(AUTO_Z_NORMAL_SKEW_TOL)
1871 && excess_kurtosis.is_finite()
1872 && excess_kurtosis.abs() <= policy.max_abs_excess_kurtosis.min(AUTO_Z_NORMAL_KURT_TOL)
1873 && ks_to_normal.is_finite()
1874 && ks_to_normal <= AUTO_Z_NORMAL_KS_TOL
1875 && tail_mass_4
1876 <= AUTO_Z_NORMAL_TAIL_MASS_SLACK * normal_tail_4 + AUTO_Z_NORMAL_TAIL_FLOOR_INNER
1877 && tail_mass_6
1878 <= AUTO_Z_NORMAL_TAIL_MASS_SLACK * normal_tail_6 + AUTO_Z_NORMAL_TAIL_FLOOR_OUTER
1879 && max_abs_z < AUTO_Z_NORMAL_MAX_ABS)
1880}
1881
1882pub(crate) fn build_global_empirical_latent_measure(
1883 z: &Array1<f64>,
1884 weights: &Array1<f64>,
1885 grid_size: usize,
1886) -> Result<LatentMeasureKind, String> {
1887 let grid = build_empirical_z_grid(z, weights, grid_size, "empirical latent measure")?;
1888 let measure = LatentMeasureKind::GlobalEmpirical { grid };
1889 measure.validate("empirical latent measure")?;
1890 Ok(measure)
1891}
1892
1893pub(crate) fn weighted_ks_to_standard_normal(
1894 z: &Array1<f64>,
1895 weights: &Array1<f64>,
1896 total_weight: f64,
1897) -> Result<f64, String> {
1898 let mut pairs = Vec::<(f64, f64)>::with_capacity(z.len());
1899 for (&zi, &wi) in z.iter().zip(weights.iter()) {
1900 if !zi.is_finite() || !wi.is_finite() || wi < 0.0 {
1901 return Err(
1902 "latent-measure KS diagnostic requires finite z and non-negative finite weights"
1903 .to_string(),
1904 );
1905 }
1906 if wi > 0.0 {
1907 pairs.push((zi, wi));
1908 }
1909 }
1910 pairs.sort_by(|left, right| {
1911 left.0
1912 .partial_cmp(&right.0)
1913 .expect("validated latent z values are finite")
1914 });
1915 let mut prev = 0.0;
1916 let mut ks = 0.0_f64;
1917 for (zi, wi) in pairs {
1918 let cdf = normal_cdf(zi);
1919 let next = prev + wi / total_weight;
1920 ks = ks.max((cdf - prev).abs()).max((cdf - next).abs());
1921 prev = next;
1922 }
1923 Ok(ks)
1924}
1925
1926pub(crate) fn weighted_tail_mass(
1927 z: &Array1<f64>,
1928 weights: &Array1<f64>,
1929 total_weight: f64,
1930 cutoff: f64,
1931) -> f64 {
1932 z.iter()
1933 .zip(weights.iter())
1934 .filter(|&(&zi, _)| zi.abs() > cutoff)
1935 .map(|(_, &wi)| wi)
1936 .sum::<f64>()
1937 / total_weight
1938}
1939
1940pub(crate) fn build_empirical_z_grid(
1941 z: &Array1<f64>,
1942 weights: &Array1<f64>,
1943 grid_size: usize,
1944 context: &str,
1945) -> Result<EmpiricalZGrid, String> {
1946 if grid_size < 3 {
1947 return Err(format!(
1948 "empirical latent measure grid_size must be at least 3, got {grid_size}"
1949 ));
1950 }
1951 if z.len() != weights.len() {
1952 return Err(format!(
1953 "{context} length mismatch: z={}, weights={}",
1954 z.len(),
1955 weights.len()
1956 ));
1957 }
1958 let mut pairs = Vec::<(f64, f64)>::with_capacity(z.len());
1959 for (idx, (&zi, &wi)) in z.iter().zip(weights.iter()).enumerate() {
1960 if !zi.is_finite() {
1961 return Err(format!(
1962 "{context} z value at row {idx} is non-finite ({zi})"
1963 ));
1964 }
1965 if !wi.is_finite() || wi < 0.0 {
1966 return Err(format!(
1967 "{context} weight at row {idx} must be finite and non-negative, got {wi}"
1968 ));
1969 }
1970 if wi > 0.0 {
1971 pairs.push((zi, wi));
1972 }
1973 }
1974 if pairs.len() < 2 {
1975 return Err(format!(
1976 "{context} requires at least two positive-weight rows"
1977 ));
1978 }
1979 pairs.sort_by(|left, right| {
1980 left.0
1981 .partial_cmp(&right.0)
1982 .expect("validated empirical latent z values are finite")
1983 });
1984 let total_weight = pairs.iter().map(|(_, weight)| *weight).sum::<f64>();
1985 if !(total_weight.is_finite() && total_weight > 0.0) {
1986 return Err(format!("{context} requires positive finite total weight"));
1987 }
1988
1989 let m = grid_size.min(pairs.len());
1990 let mut nodes = Vec::with_capacity(m);
1991 let mut out_weights = Vec::with_capacity(m);
1992 let bin_weight_target = total_weight / (m as f64);
1993 let mut cursor = 0usize;
1994 let mut remaining = pairs[0].1;
1995 for _ in 0..m {
1996 let mut need = bin_weight_target;
1997 let mut bin_weight = 0.0;
1998 let mut bin_sum = 0.0;
1999 while need > EMPIRICAL_GRID_WEIGHT_EXHAUSTED_REL_TOL * bin_weight_target
2000 && cursor < pairs.len()
2001 {
2002 let take = remaining.min(need);
2003 bin_sum += take * pairs[cursor].0;
2004 bin_weight += take;
2005 need -= take;
2006 remaining -= take;
2007 if remaining <= EMPIRICAL_GRID_WEIGHT_EXHAUSTED_REL_TOL * pairs[cursor].1 {
2008 cursor += 1;
2009 if cursor < pairs.len() {
2010 remaining = pairs[cursor].1;
2011 }
2012 }
2013 }
2014 if bin_weight > 0.0 {
2015 nodes.push(bin_sum / bin_weight);
2016 out_weights.push(bin_weight / total_weight);
2017 }
2018 }
2019 if nodes.len() < 2 {
2020 return Err(format!(
2021 "{context} compression produced fewer than two nodes"
2022 ));
2023 }
2024 recenter_rescale_empirical_grid(&mut nodes, &out_weights);
2025 let total = out_weights.iter().sum::<f64>();
2026 if total.is_finite() && total > 0.0 {
2027 for weight in &mut out_weights {
2028 *weight /= total;
2029 }
2030 }
2031 validate_empirical_z_grid(&nodes, &out_weights, context)?;
2032 Ok(EmpiricalZGrid {
2033 nodes,
2034 weights: out_weights,
2035 })
2036}
2037
2038pub(crate) fn recenter_rescale_empirical_grid(nodes: &mut [f64], weights: &[f64]) {
2039 let total = weights.iter().sum::<f64>();
2040 if !(total.is_finite() && total > 0.0) {
2041 return;
2042 }
2043 let mean = nodes
2044 .iter()
2045 .zip(weights.iter())
2046 .map(|(&node, &weight)| weight * node)
2047 .sum::<f64>()
2048 / total;
2049 let var = nodes
2050 .iter()
2051 .zip(weights.iter())
2052 .map(|(&node, &weight)| weight * (node - mean).powi(2))
2053 .sum::<f64>()
2054 / total;
2055 let sd = var.sqrt();
2056 if sd.is_finite() && sd > BMS_VARIANCE_FLOOR {
2057 for node in nodes {
2058 *node = (*node - mean) / sd;
2059 }
2060 }
2061}
2062
2063pub(super) const BMS_AUTO_SUBSAMPLE_PHASE1_BUDGET: usize = 12;
2068pub(super) const BERNOULLI_LINK_PROBABILITY_EPS: f64 = 1e-12;
2069pub(super) const BMS_VARIANCE_FLOOR: f64 = 1e-12;
2070pub(super) const BMS_DERIV_TOL: f64 = 1e-8;
2071pub(super) const EMPIRICAL_GRID_WEIGHT_EXHAUSTED_REL_TOL: f64 = 1e-14;
2077pub(super) const ROW_CHUNK_SIZE: usize = 1024;
2086pub(super) const ROW_CHUNK_MIN: usize = 64;
2090pub(super) const ROW_CHUNKS_PER_WORKER: usize = 4;
2098
2099#[inline]
2128pub(super) fn bms_row_chunk_size(n: usize) -> usize {
2129 if n == 0 {
2130 return ROW_CHUNK_SIZE;
2131 }
2132 let workers = crate::marginal_slope_shared::reproducible_chunk_parallelism();
2133 let target_chunks = workers.saturating_mul(ROW_CHUNKS_PER_WORKER).max(1);
2134 n.div_ceil(target_chunks)
2137 .clamp(ROW_CHUNK_MIN, ROW_CHUNK_SIZE)
2138}
2139pub(super) const EXACT_WORK_LOG_MIN_ROWS: usize = 50_000;
2140pub(super) const BMS_ROW_PRIMARY_HESSIAN_EXPECTED_REUSE_PASSES: usize = 3;
2141pub(super) const BMS_ROW_PRIMARY_HESSIAN_MIN_REUSE_PASSES: usize = 2;
2142pub(super) const BMS_ROW_PRIMARY_HESSIAN_TILE_ROWS: usize = 8192;
2143pub(super) const BMS_ROW_PRIMARY_HESSIAN_SINGLE_FRACTION_NUM: u64 = 1;
2144pub(super) const BMS_ROW_PRIMARY_HESSIAN_SINGLE_FRACTION_DEN: u64 = 4;
2145pub(super) const BMS_ROW_PRIMARY_HESSIAN_GLOBAL_FRACTION_NUM: u64 = 1;
2146pub(super) const BMS_ROW_PRIMARY_HESSIAN_GLOBAL_FRACTION_DEN: u64 = 2;
2147pub(super) const BERNOULLI_MARGSLOPE_LINE_SEARCH_EARLY_EXIT_CHUNK_ROWS: usize = 10_000;
2148
2149pub(crate) mod block_specs;
2153pub(crate) mod exact_eval_cache;
2154pub(crate) mod family;
2155pub(crate) mod flex_row_program;
2156pub(crate) mod gradient_paths;
2157pub(crate) mod hessian_paths;
2158pub(crate) mod install_flex;
2159pub(crate) mod row_kernel;
2160#[cfg(test)]
2161mod tests {
2162 include!("../../../../tests/src_modules/misc/families_bms_identifiability_rigid_tests.rs");
2163 include!(
2164 "../../../../tests/src_modules/optimization/families_bms_joint_hessian_hvp_correction_tests.rs"
2165 );
2166
2167 #[test]
2168 fn empirical_grid_constructor_preserves_canonical_node_order() {
2169 let grid = EmpiricalZGrid::new(
2170 vec![-2.0, 0.5, 1.0],
2171 vec![0.3, 0.5, 0.2],
2172 "sorted-grid invariant",
2173 )
2174 .expect("canonical sorted grid");
2175 assert_eq!(grid.nodes, vec![-2.0, 0.5, 1.0]);
2176 assert_eq!(grid.weights, vec![0.3, 0.5, 0.2]);
2177 }
2178
2179 #[test]
2180 fn empirical_grid_constructor_rejects_noncanonical_node_order() {
2181 let err = EmpiricalZGrid::new(vec![0.0, -1.0], vec![0.5, 0.5], "sorted-grid invariant")
2182 .expect_err("constructed grids must already be canonical");
2183 assert!(err.contains("nodes must be sorted ascending"), "{err}");
2184 }
2185}
2186pub(crate) mod axis_direction_search;
2187pub(crate) mod cell_moment_assembly;
2188#[cfg(test)]
2193mod test_support;
2194pub(crate) mod custom_family_impl;
2203#[cfg(test)]
2204mod flex_verify_932_tests;
2205#[cfg(test)]
2210mod flex_measure_932_tests;
2211pub(crate) mod row_primary_hessian;
2212
2213pub use block_specs::fit_bernoulli_marginal_slope_terms;
2214pub use gradient_paths::{
2215 MarginalSlopeCovariance, MarginalSlopeCovarianceShape, marginal_slope_covariance_from_scores,
2216 marginal_slope_preserving_scale, marginal_slope_probit_eta, padded_deviation_seed,
2217};
2218pub use install_flex::CrossBlockIdentifiabilityWarning;
2219pub(crate) use install_flex::FlexCompileOutcome;
2220
2221pub(crate) use block_specs::push_deviation_aux_blockspecs;
2223pub use block_specs::{BmsLogslopeJacobian, BmsMarginalJacobian};
2224pub(crate) use family::{
2225 BernoulliMarginalLinkMap, bernoulli_marginal_link_map,
2226 build_link_deviation_block_from_knots_design_seed_and_weights,
2227 build_score_warp_deviation_block_from_seed,
2228};
2229pub(crate) use gradient_paths::MarginalSlopeCovarianceRef;
2230pub(crate) use gradient_paths::signed_probit_neglog_unary_stack;
2231pub(crate) use gradient_paths::standardize_latent_z_with_policy;
2232pub(crate) use gradient_paths::{
2233 empirical_intercept_from_marginal, signed_probit_neglog_derivatives_up_to_fourth,
2234 unary_derivatives_log, unary_derivatives_log_normal_pdf, unary_derivatives_neglog_phi,
2235 unary_derivatives_sqrt,
2236};
2237pub(crate) use install_flex::{
2238 install_compiled_flex_block_into_runtime, project_monotone_feasible_beta,
2239};