Skip to main content

gam_models/bms/
mod.rs

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
77/// Above this size, FLEX spatial length-scale optimization uses the pilot
78/// geometry initializer and skips the iterative joint κ/ψ outer loop. This is
79/// a spatial-optimizer policy only; it must not gate exact outer Hessian
80/// capability or row-cell moment materialization.
81pub(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    /// GaussianShift frailty on the final probit index: U ~ N(0, σ²) added
142    /// to the scalar argument of Φ.  This is exact because the sextic
143    /// microcell kernel is preserved — the Gaussian-decoupling identity
144    /// E[Φ(η + U)] = Φ(η / √(1+σ²)) rescales the index by 1/τ where
145    /// τ = √(1+σ²), and every derivative chain rule factor is polynomial
146    /// in τ, so all six kernel derivatives remain closed-form.
147    ///
148    /// **HazardMultiplier frailty is NOT supported in this family.**
149    /// HazardMultiplier frailty + score_warp/linkwiggle cubic marginal-slope
150    /// is not finite-state exact.  For hazard-multiplier frailty, use the
151    /// standalone LatentCloglogBinomial / LatentSurvival families instead.
152    pub frailty: FrailtySpec,
153    pub score_warp: Option<DeviationBlockConfig>,
154    pub link_dev: Option<DeviationBlockConfig>,
155    pub latent_z_policy: LatentZPolicy,
156    /// Out-of-fold Stage-1 score-influence Jacobian `J = ∂z/∂θ₁` (n × p₁)
157    /// from cross-fitting a CTN transformation-normal Stage-1 model (#461).
158    /// When `Some`, the realized leakage directions `Z_infl = diag(s_f·β̂₀)·J`
159    /// are absorbed as a null-penalized block so the joint solve makes the
160    /// β estimating equation orthogonal to `span(Z_infl)` — the x-dependent
161    /// realization of `ψ − Π_η[ψ]`. `None` ⇒ raw `--z-column` with no CTN
162    /// Stage-1, in which case the free 1-D `score_warp` spline is the
163    /// fallback basis (it spans only the x-free leakage column).
164    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    /// Learned or fixed Gaussian-shift frailty SD.  `None` = no frailty.
180    pub gaussian_frailty_sd: Option<f64>,
181    /// Structured warnings emitted during fit-time setup when a flex
182    /// block was fully aliased by its anchor union and got dropped. The
183    /// fit proceeds without the dropped block (its contribution to the
184    /// joint design was numerically reproducible by the anchor span, so
185    /// keeping it would leave the joint Hessian rank-deficient). Empty
186    /// for fits where every flex block carried independent directions.
187    pub cross_block_warnings: Vec<CrossBlockIdentifiabilityWarning>,
188    /// Optional weighted rank inverse-normal (Blom rankit) calibration
189    /// installed at fit time when the auto latent-z normality check
190    /// failed. `Some(_)` ⇒ the training z was transformed in place via
191    /// [`LatentZRankIntCalibration::apply_to_training`] before any
192    /// downstream consumer (pooled probit baseline, term-collection
193    /// designs, family PIRLS loops) saw it, and the rigid kernel
194    /// routes through the standard-normal closed-form path on the
195    /// calibrated scale. `None` ⇒ no calibration was applied (training
196    /// z already passed the standard-normal diagnostics, or the caller
197    /// explicitly selected a non-Auto `LatentMeasureSpec`).
198    ///
199    /// Persisted to disk so prediction applies the same monotone map
200    /// via [`LatentZRankIntCalibration::apply_at_predict`] to incoming
201    /// z before the standard-normal kernel runs. The public field name
202    /// is `latent_z_rank_int_calibration` — Agent D's persistence
203    /// pipeline reads it under that exact identifier.
204    pub latent_z_rank_int_calibration: Option<LatentZRankIntCalibration>,
205    /// Optional conditional location-scale calibration of the latent score
206    /// (#905). `Some(_)` ⇒ the Auto path's conditional `E[z|C]`/`Var(z|C)` Rao
207    /// gate detected PC/grouping-dependence that the pooled-marginal gate
208    /// cannot see, so the training z was replaced in place by
209    /// `ζ = (z − m(C))/√v(C)` (via [`LatentZConditionalCalibration::apply`])
210    /// before any downstream consumer saw it. Mutually exclusive with
211    /// `latent_z_rank_int_calibration`: rank-INT fixes a pooled-marginal
212    /// defect, the conditional correction fixes a conditional-shift defect that
213    /// rank-INT provably cannot. Persisted so prediction rebuilds `a(C)` from
214    /// the (reproducible) marginal design and applies the identical map.
215    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;
237/// Inner σ level at which the empirical tail mass of latent z is compared
238/// against the standard normal's theoretical two-sided tail in the auto
239/// normality gate. Chosen well inside `AUTO_Z_NORMAL_MAX_ABS` so a fat inner
240/// tail is caught before any single observation trips the hard `max |z|` bound.
241pub(crate) const AUTO_Z_NORMAL_TAIL_SIGMA_INNER: f64 = 4.0;
242/// Outer σ level for the same tail-mass comparison; catches heavier far-tail
243/// excess that the inner level can miss.
244pub(crate) const AUTO_Z_NORMAL_TAIL_SIGMA_OUTER: f64 = 6.0;
245/// Multiplier applied to the normal's theoretical tail mass before comparison:
246/// the empirical tail may be up to this many times the Gaussian tail at the
247/// same σ before the gate fails, allowing for finite-sample sampling noise.
248pub(crate) const AUTO_Z_NORMAL_TAIL_MASS_SLACK: f64 = 2.0;
249/// Absolute additive floor on the inner-σ tail comparison, so the gate does
250/// not fail on round-off when the Gaussian tail itself is already tiny.
251pub(crate) const AUTO_Z_NORMAL_TAIL_FLOOR_INNER: f64 = 1e-5;
252/// Absolute additive floor on the outer-σ tail comparison; smaller than the
253/// inner floor because the 6σ Gaussian tail is many orders smaller than 4σ.
254pub(crate) const AUTO_Z_NORMAL_TAIL_FLOOR_OUTER: f64 = 1e-8;
255/// Significance level for the conditional `E[z|C]` / `Var(z|C)` Rao gate in the
256/// core Auto path (#905). When the latent score's conditional mean or variance
257/// on the marginal-index span `a(C)` is significant at this level, the Auto
258/// path escalates from the pooled-marginal rank-INT to a conditional
259/// location-scale correction. Chosen small (0.1%) so the escalation fires only
260/// on clear conditional structure, not finite-sample noise — the gate runs once
261/// over the whole training sample, so a per-test α this tight still has ample
262/// power against the grouping mean-shift the issue names.
263pub(crate) const AUTO_Z_CONDITIONAL_RAO_ALPHA: f64 = 1.0e-3;
264/// Relative ridge added to the weighted normal equations when regressing the
265/// latent score on the marginal-index span for the conditional correction.
266/// Stabilizes the solve when `a(C)` is rank-deficient or collinear (penalized
267/// spline marginal indices routinely are) without materially biasing the
268/// conditional mean/variance fit.
269pub(crate) const AUTO_Z_CONDITIONAL_RIDGE_REL: f64 = 1.0e-8;
270/// Floor on the fitted conditional variance `v(C)`, as a fraction of the global
271/// weighted variance of the latent score. Keeps `ζ = (z−m)/√v` finite and
272/// well-scaled where the linear variance model would otherwise fit a
273/// non-positive or vanishing conditional variance.
274pub(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    /// Construct a grid whose node/weight invariants (equal length ≥ 2, finite
305    /// ascending nodes, finite positive weights, weights summing to 1 within
306    /// 1e-8) are enforced up-front. Sorted order is part of the input contract
307    /// so hot denested-cell kernels can consume contiguous buckets without a
308    /// constructor-side reorder or allocation. Prefer this over building the
309    /// struct literally; every code path that goes through `new` satisfies the
310    /// same contract that `validate_empirical_z_grid` checks on read.
311    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    /// Iterate over co-indexed `(node, weight)` pairs. Use this instead of
317    /// reading `.nodes`/`.weights` separately whenever a loop wants both
318    /// arrays in lockstep — eliminates the chance of mismatched indexing.
319    #[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    /// Per-row empirical latent grid, borrowed where possible. This sits in
441    /// the innermost per-row loops of every criterion/gradient/Hessian
442    /// evaluation, so the global grid MUST come back as a borrow — the old
443    /// `grid.clone()` here allocated two `grid_size`-length vectors per row
444    /// per evaluation across the whole fit. Only the local-mixture path,
445    /// which genuinely synthesizes a new grid per row, returns an owned
446    /// value.
447    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
472/// Allocation-free heapsort of parallel empirical node/weight storage.
473/// Used by the local-mixture constructor, whose concatenated sorted component
474/// grids are not globally ordered. Moving pairs in place avoids the third
475/// temporary allocation that a `Vec<(node, weight)>` canonicalization would
476/// add to that per-row path.
477fn 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        // Defaults relaxed to `WarnOnly` with the same thresholds the
610        // exploratory-weighted preset uses (skew ≤ 4.0, |excess kurt| ≤ 20.0).
611        // Rationale: the upstream conditional transformation-normal
612        // preprocessor may be fit isotropically (no per-axis κ). At large-scale
613        // dimensionality (16 PCs, 15 ancestries) an isotropic fit can leave
614        // the global latent-z distribution mildly heavy-tailed (skew ≈ 4,
615        // excess kurt ≈ 30–40 in synthetic studies) without violating per-
616        // grouping mean/variance calibration. The downstream marginal-slope
617        // model still uses the latent-Gaussian probit/score-warp link; the
618        // emitted warning makes the deviation visible without aborting the
619        // fit. Callers that need strict enforcement can construct a custom
620        // `LatentZPolicy` with `check_mode: LatentZCheckMode::Strict`.
621        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/// Weighted mid-distribution rank inverse-normal transform for the
673/// latent score.
674///
675/// When the latent z fails the standard-normal auto-detection
676/// ([`latent_z_is_standard_normal_enough`]), the BMS family applied to
677/// pretend the score is N(0,1) anyway would distort the closed-form
678/// probit log-CDF kernel. The historical fallback (local- or
679/// global-empirical latent measure) is *mathematically correct* but
680/// triggers the per-row intercept Newton solve in the empirical-grid
681/// closed-form kernels (`empirical_rigid_primary_grad_hess_closed_form`
682/// and its higher-order siblings); at large scale that is the dominant
683/// cost.
684///
685/// **Rank-INT is a modeling choice, not a reparameterisation.** The
686/// rigid BMS predictor is *affine* in the latent score
687/// (`η = q·√(1+b²) + b·z`), so a nonlinear monotone map of `z` changes
688/// the model class and its likelihood — it does not leave them
689/// invariant. Applying the calibration redefines the latent axis: the
690/// affine model is *specified on the calibrated score* `T(z)`. Nor is
691/// the calibrated training sample exactly N(0,1): a finite set of
692/// normal scores is discrete, and with heavy ties it can stay far from
693/// Gaussian. The closed-form standard-normal kernel is therefore
694/// adequate only when the calibrated sample itself passes the same
695/// standard-normal adequacy gate applied to raw z
696/// ([`latent_z_is_standard_normal_enough`]);
697/// [`build_latent_measure_with_geometry`] re-checks the calibrated
698/// sample and falls back to the mathematically exact global-empirical
699/// latent measure when that re-check fails. On the passing path the
700/// kept work is the same closed-form
701/// `signed_probit_logcdf_and_mills_ratio` evaluation as the
702/// no-calibration path; the dropped work is the empirical-grid jet
703/// machinery. Persisted to disk so prediction applies the same
704/// monotone map to incoming z and re-routes through the closed-form
705/// kernel.
706#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
707pub struct LatentZRankIntCalibration {
708    /// Sorted unique positive-mass z values seen during training, ascending.
709    /// Knot table for `apply_to_training` / `apply_at_predict`. Zero-weight
710    /// knots carry no probability mass and are not stored.
711    pub sorted_z: Vec<f64>,
712    /// Weighted mid-distribution rank `(W_before + w_knot/2) / W_total` at
713    /// each `sorted_z` knot. Strictly increasing, strictly inside `(0, 1)`
714    /// (each knot is bounded away from the endpoints by half its own mass),
715    /// and invariant to a common rescaling of the weights.
716    pub weighted_cdf: Vec<f64>,
717    /// Weighted mean of the calibrated training sample. Used as a
718    /// sanity-check value on `fit`; should be very close to zero.
719    pub post_mean: f64,
720    /// Weighted SD of the calibrated training sample. Used as a
721    /// sanity-check value on `fit`; should be very close to one.
722    pub post_sd: f64,
723}
724
725impl LatentZRankIntCalibration {
726    /// Fit the weighted rank-INT calibration from training z and weights.
727    ///
728    /// Algorithm:
729    /// 1. Sort rows by ascending z and merge ties into one knot per unique
730    ///    z with the tie group's total weight `w_g`; discard zero-mass
731    ///    knots.
732    /// 2. Weighted mid-distribution rank at each knot:
733    ///    `p_g = (W_before + w_g/2) / W_total`,
734    ///    with `W_before` the cumulative weight strictly below the knot.
735    /// 3. Store `(sorted_z, weighted_cdf = p_g)`.
736    ///
737    /// The mid-rank depends only on *relative* weights (rescaling every
738    /// weight by a common factor leaves every `p_g` unchanged), is strictly
739    /// increasing across knots, and lies strictly inside `(0, 1)` — each
740    /// knot is separated from the endpoints by half its own mass, so no
741    /// ad-hoc clamp is needed and `Φ⁻¹(p_g)` is always finite.
742    ///
743    /// Returns the calibration plus the post-transform sample's weighted
744    /// mean / SD for sanity-check logging.
745    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        // Merge ties into one knot per unique z, then assign the weighted
782        // mid-distribution rank p_g = (W_before + w_g/2) / W_total. This is
783        // the mid-point of the tie group's probability mass, so it depends
784        // only on relative weights, is strictly increasing, and sits
785        // strictly inside (0, 1) without any clamp. Zero-mass tie groups
786        // are not knots of the weighted empirical distribution and are
787        // dropped.
788        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        // Compute sanity-check post-mean and post-sd on the transformed
813        // sample, weighted by the original weights.
814        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    /// Apply the calibration to the full training z vector, returning the
845    /// calibrated sample. Equivalent to mapping each row's z through
846    /// [`Self::apply_at_predict`], but vectorised.
847    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    /// Apply the calibration to a single z at predict time.
864    ///
865    /// Linear interpolation on `(sorted_z, weighted_cdf)` to obtain
866    /// `p ∈ [eps, 1 − eps]`, then `Φ⁻¹(p)` via
867    /// [`standard_normal_quantile`]. Out-of-range z's clip to the
868    /// boundary CDF before the quantile, so the calibration extrapolates
869    /// monotonically beyond the training support.
870    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            // Binary search for the right knot.
884            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        // Φ⁻¹(p); clip away from {0, 1} to keep the quantile finite.
906        standard_normal_quantile(p).unwrap_or_else(|_| if p < 0.5 { -8.0 } else { 8.0 })
907    }
908}
909
910/// Optional calibration applied to the latent score before the BMS
911/// kernel runs. When `RankInverseNormal`, both the training and predict
912/// paths route the input z through [`LatentZRankIntCalibration::apply_*`]
913/// before the standard-normal closed-form kernel is invoked.
914#[derive(Clone, Debug)]
915pub enum LatentMeasureCalibration {
916    None,
917    RankInverseNormal(LatentZRankIntCalibration),
918    ConditionalLocationScale(LatentZConditionalCalibration),
919}
920
921/// Conditional location-scale calibration of the latent score (#905).
922///
923/// The marginal-slope Auto trigger's pooled-z gate (KS / skewness / kurtosis +
924/// the rank inverse-normal transform) only inspects the **marginal** law of
925/// `z`. A conditional shift `E[z | C] = m(C) ≠ 0` — the allele-frequency-driven
926/// grouping mean shift — passes the marginal gate while leaving `z | C`
927/// off-center, so the slope contribution `b(C)·m(C)` leaks into the influence
928/// channel `q`. Rank-INT provably cannot fix this: no transform `T` depending
929/// only on the marginal `F_Z` can enforce `E[T(Z) | C] ≡ const` for all joint
930/// laws.
931///
932/// The unique Fisher-orthogonal location-scale correction (for the Gaussian
933/// working metric the closed-form probit kernel assumes) is
934/// `ζ = (z − m(C)) / √v(C)`, where `m(C) = E[z|C]` and `v(C) = Var(z|C)` are
935/// estimated by weighted ridge regression of `z` (and its squared residual) on
936/// the marginal-index span `a(C) = [1 | X_marginal]`. The corrected `ζ` is
937/// conditionally centered (and homoskedastic when the variance block is
938/// active) by construction, so the `b(C)·m(C)` leakage vanishes. Matching the
939/// first two conditional moments does **not** by itself make `ζ` standard
940/// normal (a two-point residual law survives location-scale correction
941/// unchanged in shape), so [`build_latent_measure_with_geometry`] re-checks
942/// the calibrated sample against the standard-normal adequacy gate and
943/// retains an empirical latent measure for the residual distribution when
944/// that re-check fails; only a passing `ζ` uses the closed-form
945/// standard-normal kernel. Persisted so prediction rebuilds `a(C)` from the
946/// (reproducible) marginal design and applies the identical map to incoming
947/// z.
948#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
949pub struct LatentZConditionalCalibration {
950    /// Coefficients for the conditional mean `m(C) = β_m·[1 | a(C)]` over the
951    /// basis `[1 | marginal-design row]`. Length `1 + basis_ncols` (leading
952    /// entry is the intercept).
953    pub mean_coeffs: Vec<f64>,
954    /// Coefficients for the conditional variance
955    /// `v(C) = max(β_v·[1 | a(C)], var_floor)`. Length `1 + basis_ncols`, or
956    /// empty when the conditional-variance block of the Rao gate was not
957    /// significant (mean-only correction); then `v(C) ≡ global_var`.
958    pub var_coeffs: Vec<f64>,
959    /// Number of marginal-design columns in the basis (excludes the leading
960    /// intercept). The predict-time marginal design must present exactly this
961    /// many columns.
962    pub basis_ncols: usize,
963    /// Floor on the fitted conditional variance, in the (normalized)
964    /// latent-score scale (= `AUTO_Z_CONDITIONAL_VAR_FLOOR_FRAC · global_var`).
965    pub var_floor: f64,
966    /// Global weighted variance of the (normalized) training latent score. Used
967    /// as `v(C)` when `var_coeffs` is empty.
968    pub global_var: f64,
969    /// Weighted mean of the calibrated training sample (sanity-check, ≈ 0).
970    pub post_mean: f64,
971    /// Weighted SD of the calibrated training sample (sanity-check, ≈ 1).
972    pub post_sd: f64,
973    /// First-stage (generated-regressor) sandwich covariance of `mean_coeffs`,
974    /// `V₁ᵐ = M⁻¹ (Σ_i w_i² û_i² A_i A_iᵀ) M⁻¹` with `A = [1 | a(C)]`,
975    /// `M = AᵀWA + λR` (the same weighted-ridge normal matrix that produced
976    /// `mean_coeffs`), `û_i = z_i − m̂(C_i)` the HC0 mean residual, and
977    /// `W = diag(w_i)`. Shape `(1+basis_ncols) × (1+basis_ncols)`. This is the
978    /// closed-form estimation uncertainty of `m(C)` that the second stage
979    /// (Murphy–Topel) needs; see [`Self::generated_regressor_term`].
980    pub mean_cov: Array2<f64>,
981    /// First-stage sandwich covariance of `var_coeffs`, computed identically on
982    /// the squared-mean-residual response. Empty (`0 × 0`) exactly when
983    /// `var_coeffs` is empty (mean-only correction; `v(C) ≡ global_var` is a
984    /// constant carrying no first-stage slope uncertainty).
985    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    /// Apply `ζ = (z − m(C))/√v(C)` to a batch. `a_block` is the marginal
1011    /// design (`n × basis_ncols`); `z` is the (normalized) latent score. Used
1012    /// at both training and predict time, so the map is identical.
1013    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    /// Dimension of the first-stage parameter vector `θ₁ = (mean_coeffs,
1067    /// var_coeffs)` whose estimation uncertainty the generated-regressor
1068    /// correction propagates. Equals `len(mean_coeffs)` when the variance block
1069    /// is inactive, otherwise `len(mean_coeffs) + len(var_coeffs)`.
1070    pub fn theta1_dim(&self) -> usize {
1071        self.mean_coeffs.len() + self.var_coeffs.len()
1072    }
1073
1074    /// Per-row sensitivity `∂ζ_i/∂θ₁` of the calibrated score to the first-stage
1075    /// calibration coefficients, stacked as `[∂ζ/∂mean_coeffs | ∂ζ/∂var_coeffs]`
1076    /// (length [`Self::theta1_dim`]). With `ζ = (z − m(C))/√v(C)`,
1077    /// `A_i = [1 | a(C_i)]`, `m = A_iᵀ·mean_coeffs`, `v = A_iᵀ·var_coeffs`:
1078    ///
1079    ///   `∂ζ/∂m = −1/√v`,  `∂ζ/∂v = −(z − m)/(2 v^{3/2}) = −ζ/(2v)`,
1080    ///
1081    /// and by the chain rule through the affine basis
1082    /// `∂ζ/∂mean_coeffs = (∂ζ/∂m)·A_i`, `∂ζ/∂var_coeffs = (∂ζ/∂v)·A_i`. The
1083    /// variance block contributes only when `var_coeffs` is active AND the
1084    /// fitted `v(C_i)` is above the floor (a floored row has `∂v/∂var_coeffs = 0`
1085    /// in the applied map). `z` is the (normalized) raw latent score at this row.
1086    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        // Intercept-augmented basis row A_i = [1 | a(C_i)].
1091        let mut out = Vec::with_capacity(self.theta1_dim());
1092        let dzeta_dm = -inv_sqrt_v;
1093        out.push(dzeta_dm); // intercept column of A
1094        for &x in a_row.iter() {
1095            out.push(dzeta_dm * x);
1096        }
1097        if !self.var_coeffs.is_empty() {
1098            // ∂ζ/∂v active only off the floor; on the floor the applied v(C) is
1099            // constant in var_coeffs, so the variance sensitivity is exactly 0.
1100            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    /// Block-diagonal first-stage covariance `V₁ = blkdiag(mean_cov, var_cov)`
1116    /// of `θ₁`, ordered to match [`Self::zeta_theta1_jacobian_row`]. The two
1117    /// stages are fit on (asymptotically) uncorrelated estimating equations
1118    /// (the mean score `Σ w û A` and the Breusch–Pagan variance score
1119    /// `Σ w (û² − v) A` are orthogonal under the Gaussian working model), so the
1120    /// joint first-stage covariance is block-diagonal to first order — the same
1121    /// approximation the Rao gate above uses.
1122    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    /// Murphy–Topel generated-regressor correction term for the second-stage
1134    /// slope covariance. Given the second-stage information `H_β` (the penalized
1135    /// joint Hessian of the slope fit, whose inverse is the naive `V_β`) and the
1136    /// cross-derivative `G = ∂(score_β)/∂θ₁` (`p_β × dim θ₁`), the corrected
1137    /// covariance is
1138    ///
1139    ///   `V_β = V_β^naive + (H_β⁻¹ G) V₁ (H_β⁻¹ G)ᵀ`.
1140    ///
1141    /// This returns the additive rank-`dim θ₁` term `(H_β⁻¹ G) V₁ (H_β⁻¹ G)ᵀ`
1142    /// given the already-formed `hbeta_inv_g = H_β⁻¹ G` (`p_β × dim θ₁`). The
1143    /// caller forms `G` by accumulating the per-row slope-score sensitivity to
1144    /// `ζ_i` times [`Self::zeta_theta1_jacobian_row`] (chain rule
1145    /// `∂score_β/∂θ₁ = Σ_i (∂score_β/∂ζ_i) (∂ζ_i/∂θ₁)`).
1146    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    /// Assemble the full Murphy–Topel generated-regressor correction
1152    /// `(Vb·G)·V₁·(Vb·G)ᵀ` for the second-stage slope covariance, given the ONE
1153    /// engine-side quantity it cannot reconstruct post-fit: the per-row
1154    /// reduced-frame slope-score sensitivity to the calibrated score,
1155    /// `s_i = ∂score_β,i/∂ζ_i` (a `p_β`-vector in the joint flat-β reduced frame
1156    /// `solved_fit.beta_covariance()` lives in). With `score_β,i = ∂ℓ_i/∂β`,
1157    /// `s_i = ∂²ℓ_i/∂β∂ζ_i = J_iᵀ·(∂²ℓ_i/∂η_i∂ζ_i)` is the mixed `(β, ζ)`
1158    /// second derivative of the warped row kernel contracted through the slope
1159    /// design Jacobian `J_i` — exactly the #932 `RowProgram` z-jet
1160    /// channel (`z` is already a row-program input; one extra mixed `(β, z)` jet
1161    /// channel reads off `∂²ℓ/∂β∂z`). It must be evaluated at the converged `β̂`
1162    /// in the SAME reduced frame as `vb`.
1163    ///
1164    /// Everything else is built here from the stored first-stage quantities and
1165    /// the second-stage fit, dissolving the post-fit-reconstruction blocker:
1166    ///   - `G = Σ_i s_i · (∂ζ_i/∂θ₁)ᵀ` (`p_β × dim θ₁`), the chain-rule outer
1167    ///     product accumulated row-by-row with `∂ζ_i/∂θ₁ =
1168    ///     `[`Self::zeta_theta1_jacobian_row`]`(z_i, a_row_i)` (exact-zero on
1169    ///     floored rows, so floored rows contribute nothing — `G`'s support is
1170    ///     the gate-fired rows);
1171    ///   - `Vb·G = vb·G` since the naive second-stage covariance `vb` IS
1172    ///     `H_β⁻¹` (the coordinator's `H_β⁻¹ G = Vb.dot(G)`);
1173    ///   - the term `(Vb·G)·V₁·(Vb·G)ᵀ` via [`Self::generated_regressor_term`].
1174    ///
1175    /// `score_zeta_sensitivity` is `n × p_β` (row `i` = `s_i`); `z` is the
1176    /// per-row normalized latent score (`n`); `a_block` is the marginal design
1177    /// `n × basis_ncols` whose rows feed `zeta_theta1_jacobian_row`; `vb` is the
1178    /// naive reduced-frame slope covariance `n_β × n_β`. The returned term is
1179    /// PSD (a congruence of the PSD `V₁`), so adding it to `vb` makes the
1180    /// corrected slope SE strictly ≥ the naive SE whenever the gate fires
1181    /// (`G ≠ 0`) and exactly equal when every row is floored (`G = 0`).
1182    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        // G = Σ_i s_i ⊗ (∂ζ_i/∂θ₁)  (p_β × dim θ₁). Each row contributes the
1214        // rank-1 outer product `s_i ⊗ J_zeta_i`, so summed over the n rows this
1215        // is exactly the cross product `G = Sᵀ·J` of the score-sensitivity
1216        // matrix `S` (`n × p_β`, supplied) and the per-row ζ-Jacobian matrix
1217        // `J` (`n × dim θ₁`). Forming `J` row-by-row is O(n·dim θ₁); the cross
1218        // product is then a single BLAS-3 GEMM rather than the O(n·p_β·dim θ₁)
1219        // scalar triple loop (≈1.5e9 FMA at biobank scale, n≈194k, the dominant
1220        // ~13s/disease cost of the SE correction). Floored rows yield an exact
1221        // all-zero `J` row, so they contribute zero to the GEMM — bit-identical
1222        // to skipping them, no approximation.
1223        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    /// Per-row ζ-Jacobian matrix `J` (`n × dim θ₁`, row `i` = `∂ζ_i/∂θ₁`) built
1229    /// row-by-row from [`Self::zeta_theta1_jacobian_row`]. Floored rows yield an
1230    /// exact all-zero row, so they contribute nothing to the `G = Sᵀ·J` cross
1231    /// product (bit-identical to skipping them).
1232    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    /// Signed first-order sensitivity `∂β̂/∂θ₁ = Vb·G` (`p_β × dim θ₁`) of the
1256    /// converged second-stage slope to the first-stage calibration parameters,
1257    /// the SIGNED quantity the Murphy–Topel correction is built from.
1258    ///
1259    /// `G = Sᵀ·J = Σ_i s_i ⊗ (∂ζ_i/∂θ₁)` with `s_i = ∂score_β,i/∂ζ_i` the
1260    /// LOG-LIKELIHOOD-score sensitivity (the sign convention #1131 fixes at the
1261    /// source in [`gradient_paths::rigid_standard_normal_mixed_z_sensitivity`]),
1262    /// and `Vb = H_β⁻¹` the NLL-Hessian inverse. Under this convention the
1263    /// implicit-function theorem on `∂(log L)/∂β = 0` gives
1264    /// `∂β̂/∂θ₁ = +H_β⁻¹·G = +Vb·G`, so the returned matrix matches the finite
1265    /// difference of the refit slope in θ₁ in BOTH sign and magnitude — unlike
1266    /// the PSD correction term [`Self::generated_regressor_correction`], which is
1267    /// invariant to this sign. `j_zeta` is the per-row ζ-Jacobian matrix
1268    /// (`n × dim θ₁`, row `i` = `∂ζ_i/∂θ₁`).
1269    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        // G = Sᵀ·J (p_β × dim θ₁) via the SIMD/GPU-routed cross product.
1276        let g = gam_linalg::faer_ndarray::fast_atb(&score_zeta_sensitivity, &j_zeta);
1277        // Vb·G = H_β⁻¹·G (vb is the naive reduced-frame covariance the fit
1278        // already produced — reused, never recomputed).
1279        Ok(vb.dot(&g))
1280    }
1281}
1282
1283/// First-stage robust (HC0) sandwich covariance of a weighted-ridge coefficient
1284/// vector: `V₁ = M⁺ (Σ_i w_i² û_i² A_i A_iᵀ) M⁺` with `M = AᵀWA + λR` the
1285/// ridge normal matrix that produced the coefficients, `W = diag(weights)`,
1286/// `û_i` the per-row residual, and `A` the regression basis (here `[1 | a(C)]`).
1287/// `M⁺` is the Moore–Penrose pseudo-inverse via eigendecomposition with a
1288/// relative tolerance: identifiable directions get the usual `(λ_eff)⁻¹` weight,
1289/// and rank-deficient directions (where some θ₁ components are not identified
1290/// by `A`) are zeroed — they carry no asymptotic distribution, so V₁ in those
1291/// directions is zero, and the Murphy–Topel propagation through identifiable
1292/// functionals of β remains finite and consistent. Using the ordinary inverse
1293/// here let the unregularized direction's `1/ε` blow `M⁻¹·meat·M⁻¹` through
1294/// the f64 range whenever the wide marginal-index span had a near-null
1295/// direction (the bug behind "conditional latent calibration sandwich
1296/// covariance is non-finite" on wide rank-deficient duchon/spline conditioning).
1297/// The meat is formed as `BᵀB` with `B_i = w_i û_i A_iᵀ` (signed) so the
1298/// fused-multiply GEMM is the same SIMD path used everywhere else in the
1299/// codebase, instead of a hand-rolled triple loop whose partial sums could
1300/// overflow on a single pathological row of the basis.
1301pub(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    // Robust HC0 meat as a Gram: build `B` with `B_i = (w_i û_i) A_iᵀ` (rows of
1324    // basis scaled by `w_i û_i`, sign carried), so `meat = BᵀB = Σ_i w_i² û_i²
1325    // A_i A_iᵀ` from one BLAS Gramian. Identical math to the per-row outer-
1326    // product accumulation, but the GEMM path keeps partial sums vectorized
1327    // and is less sensitive to a single pathological row producing an
1328    // intermediate that overflows f64 before the column-wise reduction cancels.
1329    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    // SPD pseudo-inverse of `M = AᵀWA + λR` via eigendecomposition with a
1342    // relative tolerance; symmetrize first to absorb floating-point asymmetry
1343    // accumulated in the AᵀWA assembly.
1344    let mut m_sym = normal_matrix.clone();
1345    gam_linalg::matrix::symmetrize_in_place(&mut m_sym);
1346    // Jacobi (symmetric diagonal) preconditioning. When the conditioning basis
1347    // spans many orders of magnitude — a power-9 Duchon RBF over 16 standardized
1348    // PCs produces columns differing by ~30 decades — `M` and `meat` live on
1349    // wildly different per-column scales, and the eigendecomposition behind
1350    // `M⁺ meat M⁺` loses all accuracy: the relative truncation tolerance is set
1351    // by `λ_max(M)` (dominated by the largest-scale column), so a genuinely
1352    // identified small-scale direction can be dropped while a near-null one is
1353    // kept, and the surviving `1/λ` then multiplies the huge `meat` straight
1354    // through the f64 range. Precondition by `D = diag(√M_jj)`. Because the ridge
1355    // penalty diagonal is built as the weighted Gram diagonal itself
1356    // (`penalty_jj = Σ_i w_i a_ij² = (AᵀWA)_jj`), `M_jj = (1+ρ)(AᵀWA)_jj`, so
1357    // `M̃ = D⁻¹ M D⁻¹` has EXACT unit diagonal and `M̃ = C + (ρ/(1+ρ))·I` with
1358    // `C` the basis correlation matrix (PSD). Hence `λ_min(M̃) ≥ ρ/(1+ρ) ≈ 1e-8`
1359    // even for a fully collinear basis, which clears the pseudo-inverse's
1360    // relative tolerance `≈ 1e-10·λ_max(M̃)` for the conditioning widths that
1361    // occur here: no direction is spuriously dropped, so `M̃⁺ = M̃⁻¹ = D M⁻¹ D`
1362    // and `cov = D⁻¹ (M̃⁻¹ meat̃ M̃⁻¹) D⁻¹ = M⁻¹ meat M⁻¹` EXACTLY — the scaling
1363    // cancels, this is the same sandwich, only computed on a well-conditioned
1364    // matrix. (Should a pure-ridge direction ever fall under tolerance at very
1365    // large width, dropping it is the correct scale-invariant identifiability
1366    // call.) `meat̃ = D⁻¹ meat D⁻¹`; `M_jj > 0` (Gram diagonal floored positive)
1367    // so `D` is always finite and invertible.
1368    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    // Undo the symmetric scaling: cov_raw = D⁻¹ cov_scaled D⁻¹.
1385    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
1396/// Weighted mean of a slice of values.
1397pub(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
1410/// Robust (heteroskedasticity-consistent) Rao/LM score-test p-value for the
1411/// null that the centered basis columns `ã(C)` carry no information about the
1412/// centered response `u`. This is the LAN locally-optimal statistic the issue
1413/// names: `s = Σ_i w_i u_i ã(C_i)`, `Ω̂ = Σ_i w_i² u_i² ã(C_i)ã(C_i)ᵀ`,
1414/// `D = sᵀ Ω̂⁺ s ⟶ χ²_{rank Ω̂}`. Both the conditional-mean test
1415/// (`u_i = z_i − z̄`) and the conditional-variance / Breusch-Pagan test
1416/// (`u_i = (z_i − z̄)² − σ̂²`) are this statistic with the same centered basis.
1417///
1418/// Returns `None` when the test is degenerate (no usable basis directions),
1419/// otherwise the asymptotic p-value.
1420pub(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    // Build the per-row scaled basis `B` with `B_i = (w_i u_i) ã_i` once, then
1438    // recover both the score and the HC0 robust meat from it with two BLAS-3
1439    // GEMMs over chunked row-blocks instead of an `O(n · r²)` per-row scatter:
1440    //   • score  `s   = ãᵀ (w ∘ u) = Bᵀ 1`     (column sums of `B`),
1441    //   • meat   `Ω̂  = Σ_i w_i² u_i² ã_i ã_iᵀ = BᵀB` since `(w_i u_i)² = w_i² u_i²`.
1442    // A non-positive weight zeroes that row of `B` (its score and meat
1443    // contributions both vanish), reproducing the `wi <= 0.0` skip EXACTLY.
1444    // `fast_ata` is the same parallel Gramian the second-stage sandwich uses, so
1445    // the statistic is numerically identical to the row-accumulated form up to
1446    // the deterministic GEMM reduction order.
1447    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    // p = 1 − CDF_{χ²_rank}(D) = 1 − P(rank/2, D/2) (regularized lower gamma).
1474    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
1479/// Fit the conditional location-scale calibration (#905) if the conditional
1480/// `E[z|C]`/`Var(z|C)` Rao gate fires on the marginal-index basis `a_block`.
1481///
1482/// Returns `None` when there is no conditional structure to correct (the gate
1483/// does not fire, or the basis is degenerate) — in that case the caller falls
1484/// back to the existing pooled-marginal gate (rank-INT or no calibration).
1485pub(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    // Center each basis column by its weighted mean so the score test is about
1532    // conditional structure *beyond* the global level (the intercept nuisance).
1533    // A constant marginal-design column collapses to ~0 and is dropped by the
1534    // pseudo-inverse rank, so an intercept already present in a(C) is harmless.
1535    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    // Conditional-mean Rao test: u = z − z̄.
1548    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    // Conditional-variance (Breusch-Pagan) Rao test: u = (z − z̄)² − σ̂².
1551    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    // Escalation fires. Fit the conditional mean over the full basis
1561    // [1 | a(C)] via a weighted ridge (the ridge stabilizes a rank-deficient
1562    // marginal-index span; it does not meaningfully shrink the few directions
1563    // that triggered the gate). The conditional-mean correction is applied
1564    // whenever the gate fires (a pure-variance trigger leaves the C-slopes of
1565    // m(C) ≈ 0, so it reduces to harmless global centering).
1566    let basis = build_intercept_basis(a_block);
1567    // Per-column Tikhonov penalty scaled by the weighted Gram diagonal, so the
1568    // ridge is *relative* to each column's scale (a 1e-8 absolute ridge would
1569    // be negligible against an O(n) Gram and would not stabilize a
1570    // rank-deficient penalized-spline marginal index). `diag_jj = Σ_i w_i a_ij²`;
1571    // floored positive so the all-zero (already-dropped) directions still
1572    // receive a finite ridge and the factorization cannot fail.
1573    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    // First-stage (generated-regressor) normal matrix `M = AᵀWA + λR`, the same
1595    // weighted-ridge system `gaussian_weighted_ridge` factorizes internally;
1596    // rebuilt here so its inverse can form the closed-form coefficient sandwich
1597    // `V₁` that the second-stage Murphy–Topel correction consumes. `p` is the
1598    // marginal-index width (small), so this is a cheap dense `(p+1)²` form.
1599    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        // Conditional-variance correction: regress the squared mean-residual on
1624        // the same basis. Fitted values are floored at `var_floor` when applied.
1625        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        // First-stage sandwich for the variance coefficients on the same ridge
1635        // normal matrix `M` (the basis and weights are identical; only the
1636        // response — and hence the residual — differs). `û_i = (z−m̂)²_i − v̂_i`
1637        // is the Breusch–Pagan residual.
1638        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    // Sanity-check post-correction moments on the training sample.
1667    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
1681/// Prepend a column of ones to `a_block`, producing the `[1 | a(C)]` regression
1682/// basis used by the conditional location-scale fit.
1683pub(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            // #905: conditional `E[z|C]`/`Var(z|C)` Rao gate. Inspect the latent
1700            // score's conditional moments on the marginal-index span a(C)
1701            // BEFORE the pooled-marginal gate. A significant conditional shift
1702            // is the `b(C)·m(C)` leakage the pooled gate cannot see and that
1703            // rank-INT provably cannot fix, so it takes precedence: route to the
1704            // conditional location-scale correction `ζ = (z−m(C))/√v(C)`.
1705            if let Some(a_block) = conditioning
1706                && let Some(cal) =
1707                    fit_conditional_latent_calibration_if_needed(z, weights, a_block)?
1708            {
1709                // Matching the first two conditional moments does not
1710                // establish Gaussianity of the residual ζ (a two-point
1711                // residual law survives location-scale correction unchanged
1712                // in shape). The closed-form standard-normal kernel is only
1713                // admissible when the calibrated sample passes the same
1714                // pooled adequacy gate raw z faces; otherwise retain an
1715                // empirical latent measure built from ζ, so the residual
1716                // distribution stays the one the data show.
1717                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                // P4: route bad-normal latent z through a weighted
1749                // mid-distribution-rank inverse-normal transform. Rank-INT
1750                // redefines the latent axis (the affine rigid model is
1751                // specified on the calibrated score); it makes the calibrated
1752                // sample approximately — not exactly — N(0,1), so the
1753                // closed-form standard-normal kernel is admitted only when
1754                // the calibrated sample itself passes the adequacy gate.
1755                // When it cannot (heavy ties leave the calibrated law
1756                // discrete), fall back to the mathematically exact
1757                // global-empirical latent measure on the raw score.
1758                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
2063// ---------------------------------------------------------------------------
2064// Cross-module constants — declared here so all submodules can reach them
2065// via `use super::*` without promoting implementation details to pub(crate).
2066// ---------------------------------------------------------------------------
2067pub(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;
2071/// Relative tolerance below which a residual weight is treated as exhausted in
2072/// the equal-mass empirical-grid compression loop. Used both for the per-bin
2073/// "need" remaining (relative to the target bin weight) and for the per-pair
2074/// remainder (relative to that pair's weight), so a pair/bin that is filled to
2075/// within a few ulps advances the cursor instead of spinning on round-off.
2076pub(super) const EMPIRICAL_GRID_WEIGHT_EXHAUSTED_REL_TOL: f64 = 1e-14;
2077/// Upper bound (and large-`n` default) for rows-per-chunk in the parallel
2078/// row-accumulation phases.
2079///
2080/// This is also a hard *ceiling* the [`bms_row_chunk_size`] chunk sizing must
2081/// respect: several per-chunk fast paths (block-Hessian / block-gradient
2082/// assembly) allocate fixed `[0.0f64; ROW_CHUNK_SIZE]` stack buffers and index
2083/// them by the chunk's local row position, so a chunk may never carry more than
2084/// `ROW_CHUNK_SIZE` rows.
2085pub(super) const ROW_CHUNK_SIZE: usize = 1024;
2086/// Floor for rows-per-chunk: below it the per-chunk scratch allocation +
2087/// scheduler hand-off cost dominates the row arithmetic. Small enough that a
2088/// moderate `n` on a many-core box still carves several chunks per worker.
2089pub(super) const ROW_CHUNK_MIN: usize = 64;
2090/// Target number of row-chunks per rayon worker for the BMS exact-Newton
2091/// row-fan-out phases (gradient / HVP / diagonal directional-derivative sweeps).
2092///
2093/// Several chunks per worker keeps the pool load-balanced across the uneven
2094/// per-row cost tail (work-stealing moves whole chunks, never partial sums) so
2095/// the heavy coord-corrections / row-stream phases saturate the cores instead
2096/// of stranding the tail on one worker.
2097pub(super) const ROW_CHUNKS_PER_WORKER: usize = 4;
2098
2099/// Pool-aware rows-per-chunk for the BMS exact-Newton row fan-outs.
2100///
2101/// A *fixed* `ROW_CHUNK_SIZE` divisor makes the chunk **count** scale with `n`,
2102/// so at moderate `n` (e.g. `n = 10·ROW_CHUNK_SIZE` on a 64-core box) the
2103/// `into_par_iter` over `⌈n/ROW_CHUNK_SIZE⌉` chunks has far fewer tasks than
2104/// workers and most cores idle — the measured ~30-90% core utilization on the
2105/// biobank coord-corrections / row-stream phases. This sizes the chunk so the
2106/// chunk count targets `ROW_CHUNKS_PER_WORKER × worker_count` (the same policy
2107/// `chunked_row_reduction` uses), clamped to `[ROW_CHUNK_MIN, ROW_CHUNK_SIZE]`:
2108///
2109/// * the `ROW_CHUNK_SIZE` ceiling is mandatory — the block-assembly fast paths
2110///   index fixed `[…; ROW_CHUNK_SIZE]` stack buffers by local row, so a chunk
2111///   can never exceed it. At large `n` the per-1024-row count already exceeds
2112///   the worker count, so the clamp costs nothing there;
2113/// * the `ROW_CHUNK_MIN` floor stops sub-floor fan-out at tiny `n`.
2114///
2115/// Reproducibility contract (#1045): the worker count used here is the
2116/// process-stable machine parallelism (`reproducible_chunk_parallelism`), NOT
2117/// the live `rayon::current_num_threads()` of the executing (possibly scoped,
2118/// possibly shrunk) pool. Keying the chunk *count* — and hence the chunk
2119/// boundaries `chunk_idx·chunk → (chunk_idx+1)·chunk` — to the transient pool
2120/// size made the per-chunk row sums regroup when the pool was narrowed, so a
2121/// fit reduced over these chunks and fed into the iterative REML optimizer moved
2122/// its `(ρ, λ)` selection with the pool size. Anchoring to a process constant
2123/// makes the boundaries — and therefore the `try_fold`/`try_reduce` reduction
2124/// tree that round-trips through them — invariant to how many workers run the
2125/// fit, while rayon still fans the chunks across whatever workers exist. For a
2126/// given `n` the returned chunk size is stable across calls and pool sizes.
2127#[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    // Rows per chunk that yields ≈ `target_chunks` chunks, clamped into
2135    // `[ROW_CHUNK_MIN, ROW_CHUNK_SIZE]`.
2136    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
2149// ---------------------------------------------------------------------------
2150// Submodule declarations
2151// ---------------------------------------------------------------------------
2152pub(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// #932 BMS flex single-source jet substrate (runtime-dimension `Jet2` + IFT
2189// lift + cell base-moment jets). A bare `#[cfg(test)] mod` with an allowed name
2190// so the build.rs ban-scanner exempts it; shared by its own FD gates and the
2191// `cell_moment_assembly` flex-fixture oracle gate as a private child of `bms`.
2192#[cfg(test)]
2193mod test_support;
2194// #932 INDEPENDENT adversarial verifier (bms-flex-verify): a high-order
2195// finite-difference oracle on the production compiled lowering
2196// `lower_bms_flex_row_order2_from_parts` of the canonical BMS FLEX program,
2197// plus a moving-edge Leibniz
2198// cross-check + a planted-corruption tripwire. Bare `#[cfg(test)] mod` with the
2199// allowed `*_tests` name so the build.rs ban-scanner exempts it; owned solely by
2200// the verifier (never edits the implementer's row_primary_hessian /
2201// gradient_paths / cell_moment_assembly).
2202pub(crate) mod custom_family_impl;
2203#[cfg(test)]
2204mod flex_verify_932_tests;
2205// #932 direct production-path measurement: forced 65-node empirical grid,
2206// warmed/cold row-op allocation counting + ns/row diagnostics for the MSI
2207// A/B ledger. The asserted gate is per-row allocation calls (deterministic);
2208// timing is eprintln-only per the SPEC ban on wall-clock correctness budgets.
2209#[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
2221// pub(crate) re-exports for internal callers:
2222pub(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};