Skip to main content

gam_models/
marginal_slope_shared.rs

1//! Shared kernels and outer-evaluation infrastructure for the
2//! marginal-slope family of GAMs (BMS, survival, latent survival).
3//!
4//! # Outer-row subsampling
5//!
6//! At large scale (n ≥ tens of thousands) the outer rho-gradient is
7//! a sum-over-rows trace whose per-row cost is dominated by the cubic
8//! cell-moment kernel. The pieces here — [`AutoOuterSubsampleOptions`],
9//! [`auto_outer_score_subsample`], [`maybe_install_auto_outer_subsample`],
10//! and [`build_outer_score_subsample`] — implement a stratified
11//! Horvitz–Thompson estimator that replaces the full row sum with an
12//! unbiased sample, gated by
13//! [`crate::custom_family::BlockwiseFitOptions::auto_outer_subsample`]
14//! and enabled by default for large marginal-slope fits.
15//!
16//! `maybe_install_auto_outer_subsample` is the entry point family
17//! impls call: it consults the per-family phase counter and the
18//! per-family last-ρ mutex (used to detect distinct outer steps),
19//! installs a stratified mask for the first
20//! `BMS_AUTO_SUBSAMPLE_PHASE1_BUDGET` (or family analog) outer
21//! evaluations, and reverts to full data afterward so the BFGS/ARC
22//! convergence target `outer_tol` is reached on exact gradients
23//! rather than chasing the stochastic noise floor.
24//!
25//! This subsampling is **complementary** to the trace-estimator tier
26//! system documented at the top of `solver::reml::reml_outer_engine` (exact /
27//! Hutchinson multi-target / Hutch++ single-target). They operate on
28//! orthogonal axes — the trace estimators reduce work *within* the
29//! Hessian structure for a fixed row set; subsampling reduces the row
30//! set itself for the family-specific row-trace path.
31
32use crate::cubic_cell_kernel::{self, DenestedPartitionCell, LocalSpanCubic};
33use crate::custom_family::{CustomFamilyBlockPsiDerivative, ParameterBlockSpec};
34use crate::outer_subsample::{OuterScoreSubsample, WeightedOuterRow};
35use gam_math::jet_scalar::{JetScalar, OneSeed, Order2, TwoSeed};
36use gam_math::nested_dual::JetField;
37use ndarray::{Array1, Array2, Axis};
38use std::ops::Range;
39use std::sync::Arc;
40
41/// Canonical inner-cache `beta_seed` validator passed to the generic
42/// outer-engine (`optimize_spatial_length_scale_exact_joint`).
43///
44/// The outer solver hands back the converged inner `beta` at each accepted
45/// ρ-step so the next inner solve can warm-start from it. This guards that
46/// cached vector for non-finite entries (which would poison the warm start)
47/// and, when clean, stashes it into the caller's `pending` cell.
48///
49/// This is the single source of truth for the seed callback: every family
50/// that wires up the exact-joint outer engine (survival location-scale,
51/// bernoulli marginal-slope, survival marginal-slope) routes through here
52/// instead of re-deriving the identical closure, which previously drifted in
53/// error construction (`EstimationError::InvalidInput(...)` vs
54/// `bail_invalid_estim!`).
55pub fn make_beta_seed_validator(
56    pending: &std::cell::RefCell<Option<Array1<f64>>>,
57) -> impl FnMut(
58    &Array1<f64>,
59) -> Result<gam_solve::rho_optimizer::SeedOutcome, crate::model_types::EstimationError>
60+ '_ {
61    move |beta: &Array1<f64>| {
62        bail_if_cached_beta_non_finite(beta)?;
63        // Stage the seed for promotion at the next eval, where the freshly
64        // built per-block widths are known. A width mismatch is reconciled
65        // there (the eval's `from_cached_beta` logs and falls back to a cold
66        // β for that step) — never an error that aborts the fit. Staging a
67        // finite β always succeeds, so the contract reply is `Installed`.
68        pending.replace(Some(beta.clone()));
69        Ok(gam_solve::rho_optimizer::SeedOutcome::Installed)
70    }
71}
72
73/// Canonical non-finite guard on a cached inner `beta`.
74///
75/// Single source of truth for the `"cached inner beta contains non-finite
76/// entries"` check + error: the full seed closure
77/// ([`make_beta_seed_validator`]) and the bare warm-start length-then-finite
78/// guards in `custom_family` all route through this so the predicate and the
79/// error construction (`EstimationError::InvalidInput`) never drift apart.
80pub use gam_problem::bail_if_cached_beta_non_finite;
81
82#[inline]
83pub const fn eval_coeff4_at(coefficients: &[f64; 4], z: f64) -> f64 {
84    ((coefficients[3] * z + coefficients[2]) * z + coefficients[1]) * z + coefficients[0]
85}
86
87#[inline]
88pub fn add_scaled_coeff4(target: &mut [f64; 4], source: &[f64; 4], scale: f64) {
89    for j in 0..4 {
90        target[j] += scale * source[j];
91    }
92}
93
94#[inline]
95fn coeff4_dot(left: &[f64; 4], right: &[f64; 4]) -> f64 {
96    left[0] * right[0] + left[1] * right[1] + left[2] * right[2] + left[3] * right[3]
97}
98
99#[inline]
100pub const fn scale_coeff4(source: [f64; 4], scale: f64) -> [f64; 4] {
101    [
102        source[0] * scale,
103        source[1] * scale,
104        source[2] * scale,
105        source[3] * scale,
106    ]
107}
108
109pub fn probit_frailty_scale(gaussian_frailty_sd: Option<f64>) -> f64 {
110    let sigma = gaussian_frailty_sd.unwrap_or(0.0);
111    if sigma <= 0.0 {
112        1.0
113    } else {
114        crate::survival::lognormal_kernel::ProbitFrailtyScaleJet::from_log_sigma(sigma.ln()).s
115    }
116}
117
118/// One auxiliary-parameter channel's objective, full primary gradient, and
119/// symmetric primary Hessian.
120pub(crate) struct DirectionalPrimaryTerms {
121    pub(crate) objective: f64,
122    pub(crate) grad: Array1<f64>,
123    pub(crate) hess: Array2<f64>,
124}
125
126fn order2_primary_terms<const K: usize>(channel: Order2<K>) -> DirectionalPrimaryTerms {
127    let gradient = channel.g();
128    let hessian = channel.h();
129    DirectionalPrimaryTerms {
130        objective: channel.value(),
131        grad: Array1::from_vec(gradient.to_vec()),
132        hess: Array2::from_shape_fn((K, K), |(row, column)| hessian[row][column]),
133    }
134}
135
136/// Differentiate a generic order-two primary program once with respect to one
137/// auxiliary scalar parameter.
138///
139/// The primary variables carry their ordinary value/gradient/Hessian seeds in
140/// the [`OneSeed::base`] channel while `parameter` carries only the auxiliary
141/// derivative in [`OneSeed::eps`]. The resulting epsilon `Order2` therefore is
142/// the auxiliary derivative of the objective, its complete primary gradient,
143/// and its complete primary Hessian. This replaces the old
144/// `1 + K + K(K+1)/2` separate bitmask-jet evaluations with one evaluation of
145/// the same row expression.
146pub(crate) fn first_parameter_order2_terms<const K: usize, Eval>(
147    primaries: [f64; K],
148    parameter: f64,
149    parameter_first: f64,
150    eval: Eval,
151) -> Result<DirectionalPrimaryTerms, String>
152where
153    Eval: FnOnce(&[OneSeed<K>; K], &OneSeed<K>) -> Result<OneSeed<K>, String>,
154{
155    let zero = <Order2<K> as JetScalar<K>>::constant(0.0);
156    let variables = std::array::from_fn(|axis| OneSeed {
157        base: <Order2<K> as JetScalar<K>>::variable(primaries[axis], axis),
158        eps: zero,
159    });
160    let parameter_jet = OneSeed {
161        base: <Order2<K> as JetScalar<K>>::constant(parameter),
162        eps: <Order2<K> as JetScalar<K>>::constant(parameter_first),
163    };
164    eval(&variables, &parameter_jet).map(|output| order2_primary_terms(output.eps))
165}
166
167/// Differentiate a generic order-two primary program once to second order in
168/// one auxiliary scalar parameter.
169///
170/// Both nilpotent directions represent the same external coordinate. Seeding
171/// `eps_del` with the parameter's own second derivative supplies the complete
172/// chain rule, and the output `eps_del` channel contains the second auxiliary
173/// derivative of `(objective, primary gradient, primary Hessian)`.
174pub(crate) fn second_parameter_order2_terms<const K: usize, Eval>(
175    primaries: [f64; K],
176    parameter: f64,
177    parameter_first: f64,
178    parameter_second: f64,
179    eval: Eval,
180) -> Result<DirectionalPrimaryTerms, String>
181where
182    Eval: FnOnce(&[TwoSeed<K>; K], &TwoSeed<K>) -> Result<TwoSeed<K>, String>,
183{
184    let zero = <Order2<K> as JetScalar<K>>::constant(0.0);
185    let variables = std::array::from_fn(|axis| TwoSeed {
186        base: <Order2<K> as JetScalar<K>>::variable(primaries[axis], axis),
187        eps: zero,
188        del: zero,
189        eps_del: zero,
190    });
191    let parameter_jet = TwoSeed {
192        base: <Order2<K> as JetScalar<K>>::constant(parameter),
193        eps: <Order2<K> as JetScalar<K>>::constant(parameter_first),
194        del: <Order2<K> as JetScalar<K>>::constant(parameter_first),
195        eps_del: <Order2<K> as JetScalar<K>>::constant(parameter_second),
196    };
197    eval(&variables, &parameter_jet).map(|output| order2_primary_terms(output.eps_del))
198}
199
200/// Differentiate an order-two primary program once in an auxiliary parameter
201/// and once along a primary-space direction, in one [`TwoSeed`] evaluation.
202/// The mixed `eps_del` `Order2` supplies the directional derivative of the
203/// auxiliary objective/gradient/Hessian without rebuilding the row program for
204/// every unit primary axis.
205pub(crate) fn first_parameter_directional_order2_terms<const K: usize, Eval>(
206    primaries: [f64; K],
207    direction: &[f64; K],
208    parameter: f64,
209    parameter_first: f64,
210    eval: Eval,
211) -> Result<DirectionalPrimaryTerms, String>
212where
213    Eval: FnOnce(&[TwoSeed<K>; K], &TwoSeed<K>) -> Result<TwoSeed<K>, String>,
214{
215    let zero = <Order2<K> as JetScalar<K>>::constant(0.0);
216    let variables = std::array::from_fn(|axis| TwoSeed {
217        base: <Order2<K> as JetScalar<K>>::variable(primaries[axis], axis),
218        eps: zero,
219        del: <Order2<K> as JetScalar<K>>::constant(direction[axis]),
220        eps_del: zero,
221    });
222    let parameter_jet = TwoSeed {
223        base: <Order2<K> as JetScalar<K>>::constant(parameter),
224        eps: <Order2<K> as JetScalar<K>>::constant(parameter_first),
225        del: zero,
226        eps_del: zero,
227    };
228    eval(&variables, &parameter_jet).map(|output| order2_primary_terms(output.eps_del))
229}
230
231fn zero_local_span_cubic() -> LocalSpanCubic {
232    LocalSpanCubic {
233        left: 0.0,
234        right: 1.0,
235        c0: 0.0,
236        c1: 0.0,
237        c2: 0.0,
238        c3: 0.0,
239    }
240}
241
242pub(crate) fn build_denested_partition_cells(
243    a: f64,
244    b: f64,
245    score_warp: Option<&crate::bms::DeviationRuntime>,
246    beta_h: Option<&Array1<f64>>,
247    link_dev: Option<&crate::bms::DeviationRuntime>,
248    beta_w: Option<&Array1<f64>>,
249    scale: f64,
250) -> Result<Vec<DenestedPartitionCell>, String> {
251    let score_breaks = score_warp
252        .map(|runtime| runtime.breakpoints().to_vec())
253        .unwrap_or_default();
254    let link_breaks = link_dev
255        .map(|runtime| runtime.breakpoints().to_vec())
256        .unwrap_or_default();
257
258    let mut cells = cubic_cell_kernel::build_denested_partition_cells_with_tails(
259        a,
260        b,
261        &score_breaks,
262        &link_breaks,
263        |z| {
264            if let (Some(runtime), Some(beta)) = (score_warp, beta_h) {
265                runtime.local_cubic_at(beta.view(), z)
266            } else {
267                Ok(zero_local_span_cubic())
268            }
269        },
270        |u| {
271            if let (Some(runtime), Some(beta)) = (link_dev, beta_w) {
272                runtime.local_cubic_at(beta.view(), u)
273            } else {
274                Ok(zero_local_span_cubic())
275            }
276        },
277    )?;
278    if scale != 1.0 {
279        for partition_cell in &mut cells {
280            partition_cell.cell.c0 *= scale;
281            partition_cell.cell.c1 *= scale;
282            partition_cell.cell.c2 *= scale;
283            partition_cell.cell.c3 *= scale;
284        }
285    }
286    Ok(cells)
287}
288
289pub(crate) struct ObservedDenestedCellPartials {
290    pub(crate) coeff: [f64; 4],
291    pub(crate) dc_da: [f64; 4],
292    pub(crate) dc_db: [f64; 4],
293    pub(crate) dc_daa: [f64; 4],
294    pub(crate) dc_dab: [f64; 4],
295    pub(crate) dc_dbb: [f64; 4],
296    pub(crate) dc_daaa: [f64; 4],
297    pub(crate) dc_daab: [f64; 4],
298    pub(crate) dc_dabb: [f64; 4],
299    pub(crate) dc_dbbb: [f64; 4],
300}
301
302pub(crate) fn observed_denested_cell_partials(
303    z_obs: f64,
304    a: f64,
305    b: f64,
306    score_warp: Option<&crate::bms::DeviationRuntime>,
307    beta_h: Option<&Array1<f64>>,
308    link_dev: Option<&crate::bms::DeviationRuntime>,
309    beta_w: Option<&Array1<f64>>,
310    scale: f64,
311) -> Result<ObservedDenestedCellPartials, String> {
312    let zero_score_span = zero_local_span_cubic();
313    let zero_link_span = zero_local_span_cubic();
314    let u_obs = a + b * z_obs;
315    let score_span_obs = if let (Some(runtime), Some(beta_h)) = (score_warp, beta_h) {
316        runtime.local_cubic_at(beta_h.view(), z_obs)?
317    } else {
318        zero_score_span
319    };
320    let link_span_obs = if let (Some(runtime), Some(beta_w)) = (link_dev, beta_w) {
321        runtime.local_cubic_at(beta_w.view(), u_obs)?
322    } else {
323        zero_link_span
324    };
325    let coeff = scale_coeff4(
326        cubic_cell_kernel::denested_cell_coefficients(score_span_obs, link_span_obs, a, b),
327        scale,
328    );
329    let (dc_da_raw, dc_db_raw) =
330        cubic_cell_kernel::denested_cell_coefficient_partials(score_span_obs, link_span_obs, a, b);
331    let (dc_daa_raw, dc_dab_raw, dc_dbb_raw) =
332        cubic_cell_kernel::denested_cell_second_partials(score_span_obs, link_span_obs, a, b);
333    let (dc_daaa, dc_daab, dc_dabb, dc_dbbb) =
334        cubic_cell_kernel::denested_cell_third_partials(link_span_obs);
335    Ok(ObservedDenestedCellPartials {
336        coeff,
337        dc_da: scale_coeff4(dc_da_raw, scale),
338        dc_db: scale_coeff4(dc_db_raw, scale),
339        dc_daa: scale_coeff4(dc_daa_raw, scale),
340        dc_dab: scale_coeff4(dc_dab_raw, scale),
341        dc_dbb: scale_coeff4(dc_dbb_raw, scale),
342        dc_daaa: scale_coeff4(dc_daaa, scale),
343        dc_daab: scale_coeff4(dc_daab, scale),
344        dc_dabb: scale_coeff4(dc_dabb, scale),
345        dc_dbbb: scale_coeff4(dc_dbbb, scale),
346    })
347}
348
349pub(crate) fn add_two_surface_psi_outer(
350    block_i: usize,
351    psi_row_i: &Array1<f64>,
352    block_j: usize,
353    psi_row_j: &Array1<f64>,
354    alpha: f64,
355    marginal_block: usize,
356    logslope_block: usize,
357    h_mm: &mut Array2<f64>,
358    h_gg: &mut Array2<f64>,
359    h_mg: &mut Array2<f64>,
360) {
361    if alpha == 0.0 {
362        return;
363    }
364    let col_i = psi_row_i.view().insert_axis(Axis(1));
365    let row_j = psi_row_j.view().insert_axis(Axis(0));
366
367    if block_i == block_j {
368        let col_j = psi_row_j.view().insert_axis(Axis(1));
369        let row_i = psi_row_i.view().insert_axis(Axis(0));
370        let target = match block_i {
371            b if b == marginal_block => h_mm,
372            b if b == logslope_block => h_gg,
373            _ => return,
374        };
375        ndarray::linalg::general_mat_mul(alpha, &col_i, &row_j, 1.0, target);
376        ndarray::linalg::general_mat_mul(alpha, &col_j, &row_i, 1.0, target);
377    } else {
378        let (marginal_row, logslope_row) = if block_i == marginal_block {
379            (psi_row_i, psi_row_j)
380        } else {
381            (psi_row_j, psi_row_i)
382        };
383        let m_col = marginal_row.view().insert_axis(Axis(1));
384        let g_row = logslope_row.view().insert_axis(Axis(0));
385        ndarray::linalg::general_mat_mul(alpha, &m_col, &g_row, 1.0, h_mg);
386    }
387}
388
389pub(crate) fn add_optional_vector(left: &mut Option<Array1<f64>>, right: &Option<Array1<f64>>) {
390    if let (Some(left), Some(right)) = (left.as_mut(), right.as_ref()) {
391        *left += right;
392    }
393}
394
395pub(crate) fn add_optional_matrix(left: &mut Option<Array2<f64>>, right: &Option<Array2<f64>>) {
396    if let (Some(left), Some(right)) = (left.as_mut(), right.as_ref()) {
397        *left += right;
398    }
399}
400
401pub(crate) fn psi_derivative_location(
402    derivative_blocks: &[Vec<CustomFamilyBlockPsiDerivative>],
403    psi_index: usize,
404) -> Option<(usize, usize)> {
405    let mut cursor = 0usize;
406    for (block_idx, block) in derivative_blocks.iter().enumerate() {
407        if psi_index < cursor + block.len() {
408            return Some((block_idx, psi_index - cursor));
409        }
410        cursor += block.len();
411    }
412    None
413}
414
415/// Predicate used by every marginal-slope family's persistent-warm-start
416/// fingerprint guard: the caller's parameter blocks must each have row count
417/// matching the family's `n`, and the list must be non-empty.
418#[inline]
419pub(crate) fn parameter_block_specs_match_rows(
420    specs: &[ParameterBlockSpec],
421    expected_n: usize,
422) -> bool {
423    !specs.is_empty()
424        && specs
425            .iter()
426            .all(|spec| spec.design.nrows() == expected_n && spec.offset.len() == expected_n)
427}
428
429#[derive(Clone, Copy)]
430pub(crate) struct CoeffSupport {
431    pub(crate) include_primary: bool,
432    pub(crate) include_h: bool,
433    pub(crate) include_w: bool,
434}
435
436impl CoeffSupport {
437    #[inline]
438    pub(crate) fn without_primary(self) -> Self {
439        Self {
440            include_primary: false,
441            ..self
442        }
443    }
444}
445
446pub(crate) struct SparsePrimaryCoeffJetView<'a> {
447    primary_index: usize,
448    h_range: Option<Range<usize>>,
449    w_range: Option<Range<usize>>,
450    pub(crate) first: &'a [[f64; 4]],
451    pub(crate) a_first: &'a [[f64; 4]],
452    pub(crate) b_first: &'a [[f64; 4]],
453    pub(crate) aa_first: &'a [[f64; 4]],
454    pub(crate) ab_first: &'a [[f64; 4]],
455    pub(crate) bb_first: &'a [[f64; 4]],
456    pub(crate) aaa_first: &'a [[f64; 4]],
457    pub(crate) aab_first: &'a [[f64; 4]],
458    pub(crate) abb_first: &'a [[f64; 4]],
459    pub(crate) bbb_first: &'a [[f64; 4]],
460}
461
462impl<'a> SparsePrimaryCoeffJetView<'a> {
463    pub(crate) fn new(
464        primary_index: usize,
465        h_range: Option<&Range<usize>>,
466        w_range: Option<&Range<usize>>,
467        first: &'a [[f64; 4]],
468        a_first: &'a [[f64; 4]],
469        b_first: &'a [[f64; 4]],
470        aa_first: &'a [[f64; 4]],
471        ab_first: &'a [[f64; 4]],
472        bb_first: &'a [[f64; 4]],
473        aaa_first: &'a [[f64; 4]],
474        aab_first: &'a [[f64; 4]],
475        abb_first: &'a [[f64; 4]],
476        bbb_first: &'a [[f64; 4]],
477    ) -> Self {
478        Self {
479            primary_index,
480            h_range: h_range.cloned(),
481            w_range: w_range.cloned(),
482            first,
483            a_first,
484            b_first,
485            aa_first,
486            ab_first,
487            bb_first,
488            aaa_first,
489            aab_first,
490            abb_first,
491            bbb_first,
492        }
493    }
494
495    #[inline]
496    fn in_h_range(&self, idx: usize) -> bool {
497        self.h_range
498            .as_ref()
499            .map(|range| range.contains(&idx))
500            .unwrap_or(false)
501    }
502
503    #[inline]
504    fn in_w_range(&self, idx: usize) -> bool {
505        self.w_range
506            .as_ref()
507            .map(|range| range.contains(&idx))
508            .unwrap_or(false)
509    }
510
511    #[inline]
512    fn param_supported(&self, idx: usize, support: CoeffSupport) -> bool {
513        (support.include_primary && idx == self.primary_index)
514            || (support.include_h && self.in_h_range(idx))
515            || (support.include_w && self.in_w_range(idx))
516    }
517
518    pub(crate) fn directional_family(
519        &self,
520        family: &[[f64; 4]],
521        dir: &Array1<f64>,
522        support: CoeffSupport,
523    ) -> [f64; 4] {
524        let mut out = [0.0; 4];
525        if support.include_primary {
526            add_scaled_coeff4(
527                &mut out,
528                &family[self.primary_index],
529                dir[self.primary_index],
530            );
531        }
532        if support.include_h
533            && let Some(h_range) = self.h_range.as_ref()
534        {
535            for idx in h_range.clone() {
536                add_scaled_coeff4(&mut out, &family[idx], dir[idx]);
537            }
538        }
539        if support.include_w
540            && let Some(w_range) = self.w_range.as_ref()
541        {
542            for idx in w_range.clone() {
543                add_scaled_coeff4(&mut out, &family[idx], dir[idx]);
544            }
545        }
546        out
547    }
548
549    pub(crate) fn add_directional_family_adjoint(
550        &self,
551        family: &[[f64; 4]],
552        coeff_adjoint: &[f64; 4],
553        support: CoeffSupport,
554        direction_adjoint: &mut [f64],
555    ) {
556        assert!(direction_adjoint.len() > self.primary_index);
557        if support.include_primary {
558            direction_adjoint[self.primary_index] +=
559                coeff4_dot(coeff_adjoint, &family[self.primary_index]);
560        }
561        if support.include_h
562            && let Some(h_range) = self.h_range.as_ref()
563        {
564            for idx in h_range.clone() {
565                direction_adjoint[idx] += coeff4_dot(coeff_adjoint, &family[idx]);
566            }
567        }
568        if support.include_w
569            && let Some(w_range) = self.w_range.as_ref()
570        {
571            for idx in w_range.clone() {
572                direction_adjoint[idx] += coeff4_dot(coeff_adjoint, &family[idx]);
573            }
574        }
575    }
576
577    pub(crate) fn mixed_directional_from_b_family(
578        &self,
579        family: &[[f64; 4]],
580        dir_u: &Array1<f64>,
581        dir_v: &Array1<f64>,
582        support: CoeffSupport,
583    ) -> [f64; 4] {
584        let mut out = [0.0; 4];
585        let dir_u_primary = dir_u[self.primary_index];
586        let dir_v_primary = dir_v[self.primary_index];
587        if support.include_primary {
588            add_scaled_coeff4(
589                &mut out,
590                &family[self.primary_index],
591                dir_u_primary * dir_v_primary,
592            );
593        }
594        if support.include_h
595            && let Some(h_range) = self.h_range.as_ref()
596        {
597            for idx in h_range.clone() {
598                add_scaled_coeff4(
599                    &mut out,
600                    &family[idx],
601                    dir_u_primary * dir_v[idx] + dir_v_primary * dir_u[idx],
602                );
603            }
604        }
605        if support.include_w
606            && let Some(w_range) = self.w_range.as_ref()
607        {
608            for idx in w_range.clone() {
609                add_scaled_coeff4(
610                    &mut out,
611                    &family[idx],
612                    dir_u_primary * dir_v[idx] + dir_v_primary * dir_u[idx],
613                );
614            }
615        }
616        out
617    }
618
619    pub(crate) fn param_directional_from_b_family(
620        &self,
621        family: &[[f64; 4]],
622        param: usize,
623        dir: &Array1<f64>,
624        support: CoeffSupport,
625    ) -> [f64; 4] {
626        if param == self.primary_index {
627            return self.directional_family(family, dir, support);
628        }
629        if self.param_supported(param, support.without_primary()) {
630            let mut out = [0.0; 4];
631            add_scaled_coeff4(&mut out, &family[param], dir[self.primary_index]);
632            return out;
633        }
634        [0.0; 4]
635    }
636
637    pub(crate) fn add_param_directional_from_b_family_adjoint(
638        &self,
639        family: &[[f64; 4]],
640        param: usize,
641        coeff_adjoint: &[f64; 4],
642        support: CoeffSupport,
643        direction_adjoint: &mut [f64],
644    ) {
645        assert!(direction_adjoint.len() > self.primary_index);
646        if param == self.primary_index {
647            self.add_directional_family_adjoint(family, coeff_adjoint, support, direction_adjoint);
648        } else if self.param_supported(param, support.without_primary()) {
649            direction_adjoint[self.primary_index] += coeff4_dot(coeff_adjoint, &family[param]);
650        }
651    }
652
653    pub(crate) fn param_mixed_from_bb_family(
654        &self,
655        family: &[[f64; 4]],
656        param: usize,
657        dir_u: &Array1<f64>,
658        dir_v: &Array1<f64>,
659        support: CoeffSupport,
660    ) -> [f64; 4] {
661        if param == self.primary_index {
662            return self.mixed_directional_from_b_family(family, dir_u, dir_v, support);
663        }
664        if self.param_supported(param, support.without_primary()) {
665            let mut out = [0.0; 4];
666            add_scaled_coeff4(
667                &mut out,
668                &family[param],
669                dir_u[self.primary_index] * dir_v[self.primary_index],
670            );
671            return out;
672        }
673        [0.0; 4]
674    }
675
676    pub(crate) fn pair_from_b_family(
677        &self,
678        family: &[[f64; 4]],
679        u: usize,
680        v: usize,
681        support: CoeffSupport,
682    ) -> [f64; 4] {
683        if u == self.primary_index && v == self.primary_index {
684            if support.include_primary {
685                return family[self.primary_index];
686            }
687            return [0.0; 4];
688        }
689        if u == self.primary_index && self.param_supported(v, support.without_primary()) {
690            return family[v];
691        }
692        if v == self.primary_index && self.param_supported(u, support.without_primary()) {
693            return family[u];
694        }
695        [0.0; 4]
696    }
697
698    pub(crate) fn pair_directional_from_bb_family(
699        &self,
700        family: &[[f64; 4]],
701        u: usize,
702        v: usize,
703        dir: &Array1<f64>,
704        support: CoeffSupport,
705    ) -> [f64; 4] {
706        if u == self.primary_index && v == self.primary_index {
707            return self.directional_family(family, dir, support);
708        }
709        if u == self.primary_index && self.param_supported(v, support.without_primary()) {
710            let mut out = [0.0; 4];
711            add_scaled_coeff4(&mut out, &family[v], dir[self.primary_index]);
712            return out;
713        }
714        if v == self.primary_index && self.param_supported(u, support.without_primary()) {
715            let mut out = [0.0; 4];
716            add_scaled_coeff4(&mut out, &family[u], dir[self.primary_index]);
717            return out;
718        }
719        [0.0; 4]
720    }
721
722    pub(crate) fn add_pair_directional_from_bb_family_adjoint(
723        &self,
724        family: &[[f64; 4]],
725        u: usize,
726        v: usize,
727        coeff_adjoint: &[f64; 4],
728        support: CoeffSupport,
729        direction_adjoint: &mut [f64],
730    ) {
731        assert!(direction_adjoint.len() > self.primary_index);
732        if u == self.primary_index && v == self.primary_index {
733            self.add_directional_family_adjoint(family, coeff_adjoint, support, direction_adjoint);
734        } else if u == self.primary_index && self.param_supported(v, support.without_primary()) {
735            direction_adjoint[self.primary_index] += coeff4_dot(coeff_adjoint, &family[v]);
736        } else if v == self.primary_index && self.param_supported(u, support.without_primary()) {
737            direction_adjoint[self.primary_index] += coeff4_dot(coeff_adjoint, &family[u]);
738        }
739    }
740
741    pub(crate) fn pair_mixed_from_bbb_family(
742        &self,
743        family: &[[f64; 4]],
744        u: usize,
745        v: usize,
746        dir_u: &Array1<f64>,
747        dir_v: &Array1<f64>,
748        support: CoeffSupport,
749    ) -> [f64; 4] {
750        if u == self.primary_index && v == self.primary_index {
751            return self.mixed_directional_from_b_family(family, dir_u, dir_v, support);
752        }
753        if u == self.primary_index && self.param_supported(v, support.without_primary()) {
754            let mut out = [0.0; 4];
755            add_scaled_coeff4(
756                &mut out,
757                &family[v],
758                dir_u[self.primary_index] * dir_v[self.primary_index],
759            );
760            return out;
761        }
762        if v == self.primary_index && self.param_supported(u, support.without_primary()) {
763            let mut out = [0.0; 4];
764            add_scaled_coeff4(
765                &mut out,
766                &family[u],
767                dir_u[self.primary_index] * dir_v[self.primary_index],
768            );
769            return out;
770        }
771        [0.0; 4]
772    }
773}
774
775// ---------------------------------------------------------------------------
776// Outer-only stratified row subsample (Phase 1 scaffolding).
777//
778// The large-scale outer-loop score/gradient passes do O(n) work per outer
779// evaluation, which dominates wall-clock once n grows past ~10^5. To keep
780// outer-loop iterations tractable while leaving the inner PIRLS solve and the
781// final covariance assembly untouched, outer-only hot loops can be redirected
782// to iterate over a small stratified subsample with a constant rescaling
783// factor, sampled once per fit and shared via `Arc`. The subsample is
784// stratified by event/outcome × z-deciles (≤ 200 strata) so that the rescaled
785// estimator inherits the same support coverage as the full-data estimator.
786//
787// This module defines only the types and helpers; Phase 2 wires them into
788// per-row hot loops. Default state (`outer_score_subsample = None`) keeps the
789// legacy full-data behavior bit-for-bit.
790
791/// Splitmix64: deterministic single-u64 expansion. Thin wrapper over the
792/// canonical implementation in [`gam_linalg::utils::splitmix64`].
793#[inline]
794const fn splitmix64(state: &mut u64) -> u64 {
795    gam_linalg::utils::splitmix64(state)
796}
797
798/// Configuration for the automatic outer-score subsampler.
799///
800/// At large scale (n ≥ tens of thousands) the marginal-slope outer
801/// rho-gradient computes a sum-over-rows trace
802/// `tr(F Fᵀ M_k) = Σ_i row_i(k)` whose per-row work is dominated by
803/// the cell-moment kernel. Stratified Horvitz–Thompson subsampling
804/// replaces the full sum with an unbiased estimator using `K` of `N`
805/// rows; the trace cost drops from `O(N · cell_work)` to
806/// `O(K · cell_work)`.
807///
808/// # Math
809///
810/// Estimator `T̂ = Σ_{i∈S} w_i · row_i` with HT weights
811/// `w_i = N_h / K_h` (per-stratum) is unbiased: `E[T̂] = T`.
812///
813/// Variance under stratified SRS without replacement:
814/// `Var(T̂) = Σ_h N_h² (1 − K_h/N_h) S_h² / K_h`
815/// where `S_h²` is the within-stratum variance of per-row contributions.
816/// With proportional allocation `K_h = K · N_h/N`, the standard deviation
817/// of `T̂` relative to `T` is roughly
818/// `σ(T̂)/T ≈ (1/√K) · √(1 − K/N) · cv_within`
819/// where `cv_within` is the within-stratum coefficient of variation.
820///
821/// The defaults are tuned so that the relative gradient-noise σ stays
822/// below ≈ 1 % across realistic `n` ∈ [30 000, 300 000+], assuming
823/// `cv_within ≲ 1` (which holds for marginal-slope contributions
824/// because the z-decile stratification absorbs the dominant
825/// inhomogeneity).
826#[derive(Clone, Debug)]
827pub struct AutoOuterSubsampleOptions {
828    /// Below this `n`, the auto-subsampler always returns `None` (use
829    /// full data). Default 30 000.
830    pub min_n_for_auto: usize,
831    /// Floor on `K`, so the relative gradient noise stays bounded
832    /// even when the target fraction would round to a smaller `K`.
833    /// `K = max(min_k, round(n · target_fraction))`. Default 10 000
834    /// gives `σ/T ≤ 1 %` for cv_within ≤ 1 and any `n ≥ min_n_for_auto`.
835    pub min_k: usize,
836    /// Target ratio `K / n` once `n ≫ min_k`. Default 0.10.
837    pub target_fraction: f64,
838    /// RNG seed for stratified mask construction. Default
839    /// `0xA075_8AMP_LE_5UB5` (deterministic across runs at the same
840    /// `n`, so CRN holds across BFGS iterations).
841    pub seed: u64,
842    /// Family-supplied **per-unit-of-K** outer-derivative work cost.
843    ///
844    /// Despite the historical name, this is *not* a per-row quantity.
845    /// It is `predicted_outer_gradient_work / K` evaluated at the
846    /// family's reference operating point — i.e. how many work units
847    /// each additional row in the K-subsample contributes summed over
848    /// all n. The auto schedule caps `K` by
849    /// `K_work = AUTO_OUTER_WORK_BUDGET / outer_work_per_k_unit`,
850    /// guaranteeing a single outer evaluation never exceeds
851    /// [`AUTO_OUTER_WORK_BUDGET`] work units regardless of the
852    /// noise-only target. Default `1` (no effective work cap beyond
853    /// `K ≤ n`); families with measurable per-K cost (survival
854    /// marginal-slope, BMS) overwrite at the call site.
855    ///
856    /// Calibration recipe: from a profiled run,
857    ///     outer_work_per_k_unit = predicted_gradient_work / K.
858    /// For the large-scale survival marginal-slope reference
859    /// (predicted_gradient_work ≈ 4.33×10⁹ at K=19_661), this gives
860    /// ~220_000; we use 250_000 as a conservative upper bound. With
861    /// `AUTO_OUTER_WORK_BUDGET = 5×10⁸` that caps K at ~2_000.
862    pub outer_work_per_k_unit: u64,
863    /// Absolute floor on the chosen K after the noise/work caps are combined.
864    /// Default [`AUTO_OUTER_MIN_K_FLOOR`].
865    pub min_k_floor: usize,
866}
867
868/// Half-billion outer-derivative work units per evaluation. Picked so the
869/// rigid survival marginal-slope pilot Newton cycle (which previously ran
870/// ~57 min at n≈2e5 with `K=19_661`) finishes in a minute or two on
871/// commodity hardware once `K` is capped by this budget.
872pub const AUTO_OUTER_WORK_BUDGET: u64 = 500_000_000;
873
874/// Absolute floor on `K` chosen by the auto schedule. Even when the work
875/// budget would drive `K` to a handful of rows the stratified mask cannot
876/// usefully shrink below `MIN_K_FLOOR` without collapsing entire deciles
877/// of `z`-strata. Set so the resulting gradient noise (~3 %) is still
878/// usable for BFGS Phase 1 progress when the family is very expensive.
879pub const AUTO_OUTER_MIN_K_FLOOR: usize = 1_000;
880
881/// L2 distance below which two outer ρ keys are treated as the *same* outer
882/// step (a line-search retry, not a fresh outer iteration). Well below any
883/// meaningful BFGS step on log-scale ρ, well above float-noise from cloning
884/// the ρ vector. Used to keep the phase-1 budget counting outer iterations
885/// rather than per-step function evaluations.
886const AUTO_OUTER_DISTINCT_STEP_L2_TOL: f64 = 1e-10;
887
888/// Reason the auto schedule chose the reported `K`. Used by the
889/// `[family auto-subsample]` log line so operators can tell whether the
890/// noise model, the work budget, the `MIN_K_FLOOR`, or `n` itself
891/// determined the subsample size.
892#[derive(Clone, Copy, Debug, PartialEq, Eq)]
893pub enum AutoOuterCapReason {
894    Noise,
895    Work,
896    Floor,
897    NFull,
898}
899
900impl AutoOuterCapReason {
901    pub fn as_str(self) -> &'static str {
902        match self {
903            AutoOuterCapReason::Noise => "noise",
904            AutoOuterCapReason::Work => "work",
905            AutoOuterCapReason::Floor => "floor",
906            AutoOuterCapReason::NFull => "n",
907        }
908    }
909}
910
911impl Default for AutoOuterSubsampleOptions {
912    fn default() -> Self {
913        Self {
914            min_n_for_auto: 30_000,
915            min_k: 10_000,
916            target_fraction: 0.10,
917            seed: 0xA075_8A8B_1ED5_5B5C,
918            outer_work_per_k_unit: 1,
919            min_k_floor: AUTO_OUTER_MIN_K_FLOOR,
920        }
921    }
922}
923
924/// Outcome of [`AutoOuterSubsampleOptions::target_k_detailed`]: the
925/// chosen `K`, the underlying noise-only choice, the work-budget cap,
926/// and which constraint won.
927#[derive(Clone, Copy, Debug)]
928pub struct AutoOuterKChoice {
929    pub k: usize,
930    pub k_noise: usize,
931    pub k_work: usize,
932    pub cap_reason: AutoOuterCapReason,
933}
934
935impl AutoOuterSubsampleOptions {
936    /// Compute the K that this configuration would pick for a given n.
937    /// Returns `None` if `n < min_n_for_auto` (caller should not subsample).
938    pub fn target_k(&self, n: usize) -> Option<usize> {
939        self.target_k_detailed(n).map(|choice| choice.k)
940    }
941
942    /// Same as `target_k` but also reports the noise-only `K`, the
943    /// work-budget cap, and which constraint set the final value. Used by
944    /// [`maybe_install_auto_outer_subsample`] to surface a `cap_reason`
945    /// in the auto-subsample log line.
946    pub fn target_k_detailed(&self, n: usize) -> Option<AutoOuterKChoice> {
947        if n < self.min_n_for_auto {
948            return None;
949        }
950        let k_noise_raw = ((n as f64) * self.target_fraction).round() as usize;
951        let k_noise = k_noise_raw.max(self.min_k);
952        // Work-budget cap. `outer_work_per_k_unit == 1` is the
953        // default-1-work-unit signal that the family has not measured
954        // its per-K cost, in which case the work cap is `WORK_BUDGET`
955        // and typically dominated by `n`.
956        let work_per_k = self.outer_work_per_k_unit.max(1);
957        let k_work_u64 = AUTO_OUTER_WORK_BUDGET / work_per_k;
958        let k_work = usize::try_from(k_work_u64).unwrap_or(usize::MAX);
959        // Combine noise + work + n + floor in a single comparison so we
960        // can attribute the binding constraint exactly once.
961        let mut k = k_noise.min(k_work);
962        let mut cap_reason = if k_work < k_noise {
963            AutoOuterCapReason::Work
964        } else {
965            AutoOuterCapReason::Noise
966        };
967        if k < self.min_k_floor {
968            k = self.min_k_floor;
969            cap_reason = AutoOuterCapReason::Floor;
970        }
971        if k > n {
972            k = n;
973            cap_reason = AutoOuterCapReason::NFull;
974        }
975        if k >= n {
976            // Borderline: the auto schedule would cover the whole
977            // dataset. Subsampling buys nothing.
978            return None;
979        }
980        Some(AutoOuterKChoice {
981            k,
982            k_noise,
983            k_work,
984            cap_reason,
985        })
986    }
987}
988
989/// Build a stratified outer-score subsample automatically from problem
990/// characteristics. Returns `None` for problems too small to benefit
991/// (the caller should fall back to the full-data path).
992///
993/// Stratification matches `build_outer_score_subsample`: 100 z-deciles
994/// × the supplied secondary stratum (typically the {0, 1} response
995/// indicator). When `stratum_secondary` is `None` the secondary
996/// dimension collapses to a single bin.
997///
998/// The returned mask carries proper Horvitz–Thompson weights so that
999/// `Σ_{i ∈ mask} weight_i · row_i` is an unbiased estimate of the
1000/// full row sum.
1001pub fn auto_outer_score_subsample(
1002    z: &[f64],
1003    stratum_secondary: Option<&[u8]>,
1004    options: &AutoOuterSubsampleOptions,
1005) -> Option<OuterScoreSubsample> {
1006    let n = z.len();
1007    let k = options.target_k(n)?;
1008    let secondary_storage;
1009    let secondary: &[u8] = if let Some(s) = stratum_secondary {
1010        if s.len() != n {
1011            // Caller error; fall through to no-subsample rather than panic.
1012            return None;
1013        }
1014        s
1015    } else {
1016        secondary_storage = vec![0u8; n];
1017        &secondary_storage
1018    };
1019    Some(build_outer_score_subsample(z, secondary, k, options.seed))
1020}
1021
1022/// Two-phase auto-subsample guard shared across marginal-slope families.
1023///
1024/// Returns `Some(cloned_options)` carrying a freshly stratified
1025/// Horvitz-Thompson mask when `options.auto_outer_subsample` is enabled, the
1026/// caller has not already supplied a mask, and
1027/// the per-family phase counter is below `phase1_budget`. Returns `None`
1028/// when the caller's options should be used unchanged (either subsample
1029/// is disabled / pre-installed, the budget is exhausted, or the problem
1030/// is too small for `auto_outer_score_subsample` to find a benefit).
1031///
1032/// The `phase_counter` and `last_rho` pair together implement
1033/// distinct-step detection: line searches re-call the family at the
1034/// same ρ during step-size retries, but the budget is meant to count
1035/// outer iterations, not function evaluations. The counter only ticks
1036/// when the incoming ρ differs from the last observed ρ in L2 by
1037/// > 1e-10 — well below any meaningful BFGS step on log-scale ρ, well
1038/// > above float-noise from cloning. The mutex around `last_rho` is the
1039/// > minimal coordination needed: `(counter, last_rho)` must update
1040/// > together so two threads cannot both decide "new ρ" and double-bump.
1041///
1042/// The transition at `phase_idx == phase1_budget` is logged exactly
1043/// once via `log::info!` with the supplied `family_label`. Each phase-1
1044/// install also logs the planned mask size and predicted gradient
1045/// noise. Callers running with auto-subsample disabled see no logging.
1046pub fn maybe_install_auto_outer_subsample(
1047    options: &crate::custom_family::BlockwiseFitOptions,
1048    z: &[f64],
1049    stratum_secondary: Option<&[u8]>,
1050    outer_rho_key: &[f64],
1051    phase_counter: &Arc<std::sync::atomic::AtomicUsize>,
1052    last_rho: &Arc<std::sync::Mutex<Option<Array1<f64>>>>,
1053    phase1_budget: usize,
1054    family_label: &'static str,
1055    outer_work_per_k_unit: u64,
1056    min_n_for_auto: usize,
1057    min_k: usize,
1058    min_k_floor: usize,
1059) -> Option<crate::custom_family::BlockwiseFitOptions> {
1060    if options.outer_score_subsample.is_some() || !options.auto_outer_subsample {
1061        return None;
1062    }
1063    // Establish that this problem will actually use a row sample before
1064    // advancing the pilot counter.  The exact-polish lifecycle treats a zero
1065    // counter as proof that no approximate derivative measure ran; counting a
1066    // small-n no-op here would otherwise force a redundant second optimization.
1067    let auto_options = AutoOuterSubsampleOptions {
1068        min_n_for_auto,
1069        min_k,
1070        min_k_floor,
1071        outer_work_per_k_unit: outer_work_per_k_unit.max(1),
1072        ..AutoOuterSubsampleOptions::default()
1073    };
1074    let choice = auto_options.target_k_detailed(z.len())?;
1075    let phase_idx = {
1076        let mut guard = last_rho
1077            .lock()
1078            .expect("auto_subsample_last_rho mutex poisoned");
1079        let new_step = match guard.as_ref() {
1080            None => true,
1081            Some(prev) if prev.len() != outer_rho_key.len() => true,
1082            Some(prev) => {
1083                let mut sq = 0.0_f64;
1084                for (a, b) in outer_rho_key.iter().zip(prev.iter()) {
1085                    let d = a - b;
1086                    sq += d * d;
1087                }
1088                sq.sqrt() > AUTO_OUTER_DISTINCT_STEP_L2_TOL
1089            }
1090        };
1091        if new_step {
1092            *guard = Some(Array1::from(outer_rho_key.to_vec()));
1093            phase_counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst)
1094        } else {
1095            let current = phase_counter.load(std::sync::atomic::Ordering::SeqCst);
1096            // The generic runner can promote an early-stopped pilot directly
1097            // to `phase1_budget` while remaining at the same rho checkpoint.
1098            // Preserve that exact-phase marker; subtract one only while the
1099            // counter still denotes an ordinary repeated Phase-1 evaluation.
1100            if current >= phase1_budget {
1101                current
1102            } else {
1103                current.saturating_sub(1)
1104            }
1105        }
1106    };
1107    if phase_idx >= phase1_budget {
1108        // Mark the exact phase explicitly. A raw counter equal to the budget
1109        // can also mean "the last sampled evaluation just completed"; the
1110        // post-budget sentinel lets the generic runner distinguish that state
1111        // from a full-data evaluation that has already occurred.
1112        phase_counter.fetch_max(
1113            phase1_budget.saturating_add(1),
1114            std::sync::atomic::Ordering::SeqCst,
1115        );
1116        if phase_idx == phase1_budget {
1117            log::info!(
1118                "[{family_label} auto-subsample] Phase 1 budget exhausted after {} evals; \
1119                 Phase 2 (full data) for remaining iterations",
1120                phase1_budget
1121            );
1122        }
1123        return None;
1124    }
1125    let mask = auto_outer_score_subsample(z, stratum_secondary, &auto_options)?;
1126    let n_full = mask.n_full;
1127    let k = mask.len();
1128    log::info!(
1129        "[{family_label} auto-subsample] phase=1 eval={}/{} n={} K={} fraction={:.3} expected_grad_noise={:.2}% work_per_k_unit={} k_noise={} k_work={} cap_reason={}",
1130        phase_idx + 1,
1131        phase1_budget,
1132        n_full,
1133        k,
1134        k as f64 / n_full.max(1) as f64,
1135        100.0 * (1.0 / (k as f64).sqrt()) * (1.0 - k as f64 / n_full.max(1) as f64).sqrt(),
1136        outer_work_per_k_unit,
1137        choice.k_noise,
1138        choice.k_work,
1139        choice.cap_reason.as_str(),
1140    );
1141    let mut cloned = options.clone();
1142    cloned.outer_score_subsample = Some(Arc::new(mask));
1143    Some(cloned)
1144}
1145
1146/// Build a deterministic stratified row subsample of size ≥ `k` from
1147/// `(z, stratum_secondary)`.
1148///
1149/// Stratification: 100 z-deciles × distinct values of `stratum_secondary`
1150/// (typically the {0,1} event/outcome indicator, giving ≤ 200 strata).
1151/// Each non-empty stratum contributes `ceil(k * stratum_size / n)` rows
1152/// drawn via a splitmix64-keyed Fisher-Yates partial shuffle so the result
1153/// is reproducible from `(seed, stratum_id)`.
1154///
1155/// The returned mask is sorted, deduplicated, and never empty when `n > 0`.
1156/// Per-row weights `w_i = N_h / k_h` (Horvitz-Thompson inverse-inclusion
1157/// weights for the stratum the row came from) are assigned to
1158/// `OuterScoreSubsample::rows`, and `weight_scale` is reported as the mean
1159/// of those weights for diagnostics only.
1160///
1161/// Panics if `z.len() != stratum_secondary.len()`.
1162pub fn build_outer_score_subsample(
1163    z: &[f64],
1164    stratum_secondary: &[u8],
1165    k: usize,
1166    seed: u64,
1167) -> OuterScoreSubsample {
1168    let n = z.len();
1169    assert_eq!(
1170        n,
1171        stratum_secondary.len(),
1172        "build_outer_score_subsample: z and stratum_secondary must have equal length",
1173    );
1174
1175    if n == 0 {
1176        return OuterScoreSubsample::with_uniform_weight(Vec::new(), 0, seed, 1.0);
1177    }
1178
1179    // If the requested subsample covers the full dataset (or more), short-
1180    // circuit to the full row set with weight 1.0 — this is a no-op
1181    // relative to the legacy full-data path.
1182    if k >= n {
1183        let mask: Vec<usize> = (0..n).collect();
1184        return OuterScoreSubsample::with_uniform_weight(mask, n, seed, 1.0);
1185    }
1186
1187    // Q = 100 z-deciles. Sort indices by z and split into Q ~equal chunks.
1188    const Q: usize = 100;
1189    let mut z_order: Vec<usize> = (0..n).collect();
1190    z_order.sort_by(|&a, &b| z[a].partial_cmp(&z[b]).unwrap_or(std::cmp::Ordering::Equal));
1191    // decile[i] = bin index in 0..Q for row i
1192    let mut decile = vec![0u16; n];
1193    for (rank, &row) in z_order.iter().enumerate() {
1194        // Map rank in 0..n to bin in 0..Q. Using floor((rank * Q) / n)
1195        // keeps bin sizes within ±1 row of n/Q.
1196        let bin = (rank * Q) / n;
1197        let bin = bin.min(Q - 1);
1198        decile[row] = bin as u16;
1199    }
1200
1201    // Distinct secondary values (the canonical use case is {0,1}, but the
1202    // general u8 alphabet is supported transparently).
1203    let mut distinct_secondary: Vec<u8> = stratum_secondary.to_vec();
1204    distinct_secondary.sort_unstable();
1205    distinct_secondary.dedup();
1206    // stratum index = secondary_rank * Q + decile, where secondary_rank is
1207    // the position of the row's secondary value in `distinct_secondary`.
1208    let mut secondary_rank = vec![0u16; 256];
1209    for (rank, &val) in distinct_secondary.iter().enumerate() {
1210        secondary_rank[val as usize] = rank as u16;
1211    }
1212    let n_strata = distinct_secondary.len() * Q;
1213
1214    // Bucket rows by stratum.
1215    let mut strata: Vec<Vec<usize>> = vec![Vec::new(); n_strata];
1216    for i in 0..n {
1217        let s = secondary_rank[stratum_secondary[i] as usize] as usize * Q + decile[i] as usize;
1218        strata[s].push(i);
1219    }
1220
1221    // For each non-empty stratum, draw ceil(k * stratum_size / n) rows and
1222    // tag each retained row with its HT weight w_h = N_h / k_h.
1223    let mut picked: Vec<WeightedOuterRow> = Vec::with_capacity(k + n_strata);
1224    for (stratum_id, rows) in strata.iter().enumerate() {
1225        if rows.is_empty() {
1226            continue;
1227        }
1228        let take = (k as u128 * rows.len() as u128).div_ceil(n as u128) as usize;
1229        let take = take.max(1).min(rows.len());
1230        // HT inverse-inclusion weight for this stratum: w_h = N_h / k_h.
1231        // Identical for every row drawn from `stratum_id`.
1232        let w_h = rows.len() as f64 / take as f64;
1233        let stratum_tag = stratum_id as u32;
1234
1235        // Deterministic key from (seed, stratum_id).
1236        let mut state = seed ^ (stratum_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
1237        // Mix once so even seed=0, stratum_id=0 produces a non-trivial state.
1238        splitmix64(&mut state);
1239
1240        if take == rows.len() {
1241            for &index in rows.iter() {
1242                picked.push(WeightedOuterRow {
1243                    index,
1244                    weight: w_h,
1245                    stratum: stratum_tag,
1246                });
1247            }
1248        } else {
1249            // Fisher-Yates partial shuffle: produce `take` distinct rows.
1250            let mut buf: Vec<usize> = rows.clone();
1251            let m = buf.len();
1252            for i in 0..take {
1253                let r = splitmix64(&mut state);
1254                let j = i + (r as usize) % (m - i);
1255                buf.swap(i, j);
1256            }
1257            for &index in &buf[..take] {
1258                picked.push(WeightedOuterRow {
1259                    index,
1260                    weight: w_h,
1261                    stratum: stratum_tag,
1262                });
1263            }
1264        }
1265    }
1266
1267    // `from_weighted_rows` sorts + dedups by index. Strata are disjoint by
1268    // construction so dedup is a no-op, but we route through the constructor
1269    // so the OuterScoreSubsample contract stays in one place.
1270    OuterScoreSubsample::from_weighted_rows(picked, n, seed)
1271}
1272
1273// ---------------------------------------------------------------------------
1274// Outer-row iteration helpers.
1275//
1276// These wrap the choice between "iterate 0..n" (default) and "iterate
1277// `subsample.mask`" so per-row hot loops in Phase 2 can call a single helper
1278// rather than branch by hand. We expose both an enum that callers can match
1279// on directly (cheap path: a `Range` plus a `Arc<Vec<usize>>`) and a
1280// `Vec<usize>`-returning convenience that satisfies
1281// `IntoParallelIterator<Item = usize>` via `Vec`'s rayon impl.
1282
1283/// Row-index iteration choice for outer-only score/gradient passes.
1284#[derive(Debug, Clone)]
1285pub enum OuterRowIter {
1286    /// Full data: iterate `0..n`.
1287    All { n: usize },
1288    /// Subsample: iterate `subsample.mask`.
1289    Subset { mask: Arc<Vec<usize>> },
1290}
1291
1292impl OuterRowIter {
1293    /// Number of rows this iterator covers.
1294    #[inline]
1295    pub fn len(&self) -> usize {
1296        match self {
1297            OuterRowIter::All { n } => *n,
1298            OuterRowIter::Subset { mask } => mask.len(),
1299        }
1300    }
1301
1302    #[inline]
1303    pub fn is_empty(&self) -> bool {
1304        self.len() == 0
1305    }
1306
1307    /// Materialize the row indices as a `Vec<usize>`. Useful for callers
1308    /// that want a `IntoParallelIterator<Item = usize>` source — `Vec<usize>`
1309    /// satisfies that trait via rayon's blanket impl.
1310    pub fn to_vec(&self) -> Vec<usize> {
1311        match self {
1312            OuterRowIter::All { n } => (0..*n).collect(),
1313            OuterRowIter::Subset { mask } => mask.as_ref().clone(),
1314        }
1315    }
1316}
1317
1318/// Choose the row-iteration strategy for an outer-only pass. When
1319/// `opts.outer_score_subsample` is `Some`, returns the subsample mask;
1320/// otherwise returns the full range `0..n`.
1321///
1322/// Callers using this helper iterate over row indices and must additionally
1323/// consult [`outer_row_weights_by_index`] (or [`outer_weighted_rows`]) for
1324/// per-row HT weights — a single global rescale is biased under stratified
1325/// sampling and is no longer exposed.
1326pub fn outer_row_indices(
1327    opts: &crate::custom_family::BlockwiseFitOptions,
1328    n: usize,
1329) -> OuterRowIter {
1330    match opts.outer_score_subsample.as_ref() {
1331        Some(s) => OuterRowIter::Subset {
1332            mask: Arc::clone(&s.mask),
1333        },
1334        None => OuterRowIter::All { n },
1335    }
1336}
1337
1338/// Per-row HT-weighted iteration: returns one `WeightedOuterRow` per
1339/// retained row when a subsample is active; otherwise returns
1340/// `(index, weight = 1.0, stratum = 0)` for every row in `0..n`.
1341pub fn outer_weighted_rows(
1342    opts: &crate::custom_family::BlockwiseFitOptions,
1343    n: usize,
1344) -> Vec<WeightedOuterRow> {
1345    match opts.outer_score_subsample.as_ref() {
1346        Some(s) => s.rows.as_ref().clone(),
1347        None => (0..n)
1348            .map(|index| WeightedOuterRow {
1349                index,
1350                weight: 1.0,
1351                stratum: 0,
1352            })
1353            .collect(),
1354    }
1355}
1356
1357/// Dense-by-row HT weights of length `n`. Masked rows carry their HT
1358/// weight; unmasked rows default to 1.0 so that callers who index by row
1359/// regardless of subsampling still get a valid scalar (the consumer is
1360/// expected to iterate only over `outer_row_indices`).
1361pub fn outer_row_weights_by_index(
1362    opts: &crate::custom_family::BlockwiseFitOptions,
1363    n: usize,
1364) -> Vec<f64> {
1365    match opts.outer_score_subsample.as_ref() {
1366        Some(s) => {
1367            let mut weights = vec![1.0; n];
1368            for r in s.rows.iter() {
1369                if r.index < n {
1370                    weights[r.index] = r.weight;
1371                }
1372            }
1373            weights
1374        }
1375        None => vec![1.0; n],
1376    }
1377}
1378
1379/// Shared monotonicity line-search safeguard for linear inequality constraints
1380/// `A·beta >= b` — the barrier-hook entry point both survival families use.
1381///
1382/// The GEOMETRY is not this function's to define. It is
1383/// [`gam_problem::LinearInequalityConstraints::max_contract_feasible_step`],
1384/// the single fraction-to-boundary rule denominated in the unit-normalized row
1385/// metric and at the `PRIMAL_FEASIBILITY_TOL` the solver certifies its own
1386/// iterates against. What lives here is the globalization policy layered on
1387/// top: the boundary backoff, which keeps a clipped iterate off the face by
1388/// enough that the next cycle's feasibility gate cannot reject a point round-off
1389/// left balanced on it — see `apply_feasible_step_boundary_backoff` for why
1390/// that retreat is ABSOLUTE and not a fraction of the step.
1391///
1392/// Before gam#2719 this function carried a rule of its own, and the two halves
1393/// of that rule disagreed: the ITERATE got a `1e-8` band (a slack of `-1e-9`
1394/// counted as "at the boundary", gam#797's fix) while the STEP got none, so
1395/// `slack == 0` with ANY negative drift produced `alpha = 0`. Measured on the
1396/// survival location-scale linkwiggle witness, 314 of 379 refusals were of
1397/// steps whose endpoint violated nothing at the declared tolerance, with drifts
1398/// as small as `-3.3e-18`.
1399///
1400/// A returned `alpha` of `0.0` is an ANSWER, not a failure: a row is active at
1401/// `beta` and `direction` points out of it by more than round-off. The caller
1402/// must restore feasibility by projecting onto that face — no smaller step can
1403/// help, because `slack / -drift` is invariant under `direction -> c·direction`
1404/// once its numerator is zero.
1405pub fn feasible_step_fraction(
1406    constraints: &gam_problem::LinearInequalityConstraints,
1407    beta: &Array1<f64>,
1408    direction: &Array1<f64>,
1409) -> Result<f64, gam_problem::ContractFeasibleStepError> {
1410    let limit = constraints.max_contract_feasible_step(beta.view(), direction.view())?;
1411    Ok(apply_feasible_step_boundary_backoff(&limit))
1412}
1413
1414/// The same barrier-hook rule against a [`gam_problem::ConstraintSet`] carrier
1415/// rather than a bare dense system, for blocks whose declared constraints are
1416/// held in that form.
1417pub fn feasible_step_fraction_in_set(
1418    constraints: &gam_problem::ConstraintSet,
1419    beta: &Array1<f64>,
1420    direction: &Array1<f64>,
1421) -> Result<f64, gam_problem::ContractFeasibleStepError> {
1422    let limit = constraints.max_contract_feasible_step(beta.view(), direction.view())?;
1423    Ok(apply_feasible_step_boundary_backoff(&limit))
1424}
1425
1426/// Backoff applied when a binding constraint clipped the step, keeping the new
1427/// iterate off the face by enough that round-off cannot leave it balanced on
1428/// one. A step that was not clipped at all is taken whole — backing off an
1429/// unconstrained step would shorten every Newton step in the fit for nothing.
1430///
1431/// # Why the retreat is ABSOLUTE and not a fraction of the step (gam#2695)
1432///
1433/// The concern the backoff answers is round-off in an exact ratio test: the
1434/// endpoint `β + α·δ` at `α = slack/−drift` is the face up to the arithmetic
1435/// that computed it. That is a statement about resolution, in the scaled-slack
1436/// metric the contract is denominated in. It says nothing about how far the step
1437/// travelled — so the retreat must not be proportional to that distance.
1438///
1439/// The rule here used to be `α ← 0.995·α`, and multiplying by a constant is
1440/// exactly that proportionality. Its effect on a coefficient walking to its own
1441/// bound is closed-form: the surviving slack after a clipped step is
1442/// `s + α·d = s − 0.995·s = 0.005·s`, so every clipped cycle keeps `1/200` of
1443/// the slack and **no finite number of cycles reaches the face**. Measured on
1444/// the #2695 witness, at the degree the composed warp is now built at:
1445///
1446/// ```text
1447///   cycle=390  accepted  ρ=+1.000e0  Δobj=+0.000e0  |δ|∞=9.154e-165  |prop|∞=1.554e-2
1448///   cycle=391  accepted  ρ=+1.000e0  Δobj=+0.000e0  |δ|∞=4.577e-167  |prop|∞=1.554e-2
1449///   cycle=392  accepted  ρ=+1.000e0  Δobj=+0.000e0  |δ|∞=2.289e-169  |prop|∞=1.554e-2
1450/// ```
1451///
1452/// — exactly `200×` per cycle, for 400 cycles, with the QP's proposal constant,
1453/// the joint trust radius held and the objective change exactly zero. The
1454/// solve spends its entire budget walking one warp coefficient from `1e-3` to
1455/// `1e-163` while the row it is approaching never becomes active, so the
1456/// projected-KKT certificate that would end the solve never applies.
1457///
1458/// A backoff denominated in [`gam_problem::PRIMAL_FEASIBILITY_TOL`] instead has
1459/// both halves right, and needs no constant of its own:
1460///
1461/// * a step with room to spare stops one feasibility tolerance short of the
1462///   face — the round-off margin the backoff exists for, at the resolution the
1463///   solver certifies its own iterates at; and
1464/// * a step whose remaining slack is already within that tolerance yields
1465///   `α ≤ 0`, which the contract reports as `BlockedByActiveFace` and the caller
1466///   answers with a projection onto that face. The row becomes ACTIVE, in ONE
1467///   cycle, instead of being approached geometrically forever.
1468///
1469/// Landing on the face is not a hazard for these constraints. The row programs
1470/// that evaluate a logarithm at a bounded quantity carry their own guard —
1471/// `log g` below the event-Jacobian floor is a continued logarithm (gam#2695),
1472/// finite and differentiable at the guard — so the interior-point rationale
1473/// that would justify stopping strictly short does not apply here, and the
1474/// active-set solver these hooks feed needs the face to be reachable.
1475fn apply_feasible_step_boundary_backoff(limit: &gam_problem::ContractFeasibleStep) -> f64 {
1476    if limit.fraction >= 1.0 {
1477        return 1.0;
1478    }
1479    let drift = -limit.blocking_scaled_drift;
1480    if !(drift > 0.0) || !drift.is_finite() {
1481        // No row is recorded as blocking, or its drift is not usable as a
1482        // denominator. There is nothing to retreat ALONG, so retreating by a
1483        // fraction of the step would be inventing a distance; take the
1484        // contract's own answer.
1485        return limit.fraction.clamp(0.0, 1.0);
1486    }
1487    let retreat = gam_problem::PRIMAL_FEASIBILITY_TOL / drift;
1488    (limit.fraction - retreat).clamp(0.0, 1.0)
1489}
1490
1491/// Family-specific ψ-calculus hooks for the shared exact-Newton joint-ψ
1492/// workspace.
1493///
1494/// The two marginal-slope families (Bernoulli marginal-slope and survival
1495/// marginal-slope) build an `ExactNewtonJointPsiWorkspace` whose four methods
1496/// share a single skeleton: a σ-auxiliary (log-σ frailty) dispatch branch on
1497/// top of a family-specific non-σ row pass. The skeleton lives once in
1498/// [`MarginalSlopeExactNewtonPsiWorkspace`]; each family supplies only the
1499/// resolved per-call operations here, holding its own block states, specs,
1500/// derivative blocks, cache and outer-subsample options internally.
1501///
1502/// Implementors own all workspace state, so every hook takes only the ψ index /
1503/// pair / direction. The two genuine per-family policy differences in the
1504/// second-order σ-aux branch are encoded as
1505/// [`both_sigma_aux_second_order`](Self::both_sigma_aux_second_order) (which
1506/// pure-σ pairs are admissible) and
1507/// [`mixed_sigma_aux_second_order`](Self::mixed_sigma_aux_second_order) (how a
1508/// mixed σ / non-σ pair is handled) rather than being harmonized away.
1509pub trait MarginalSlopePsiFamily: Send + Sync {
1510    /// True when ψ index `psi_index` addresses the log-σ frailty auxiliary
1511    /// parameter rather than a spatial / spline derivative axis.
1512    fn is_sigma_aux(&self, psi_index: usize) -> bool;
1513
1514    /// First-order joint-ψ terms for the σ-auxiliary parameter.
1515    fn sigma_first_order_terms(
1516        &self,
1517    ) -> Result<Option<gam_problem::ExactNewtonJointPsiTerms>, String>;
1518
1519    /// First-order joint-ψ terms for a non-σ derivative axis `psi_index`.
1520    fn psi_first_order_terms(
1521        &self,
1522        psi_index: usize,
1523    ) -> Result<Option<gam_problem::ExactNewtonJointPsiTerms>, String>;
1524
1525    /// Batched first-order joint-ψ terms over all derivative axes (used by the
1526    /// outer score sweep). Returns `Ok(None)` when the batched fast path is
1527    /// unavailable for the current configuration so the caller falls back to
1528    /// per-axis evaluation.
1529    fn psi_first_order_terms_all(
1530        &self,
1531    ) -> Result<Option<Vec<gam_problem::ExactNewtonJointPsiTerms>>, String>;
1532
1533    /// Whether the σ-aux second-order branch should treat `(psi_i, psi_j)` as a
1534    /// pure-σ pair (dispatching to [`sigma_second_order_terms`](Self::sigma_second_order_terms)).
1535    /// Any σ-touching pair that is not pure-σ routes through
1536    /// [`mixed_sigma_aux_second_order`](Self::mixed_sigma_aux_second_order).
1537    fn both_sigma_aux_second_order(&self, psi_i: usize, psi_j: usize) -> bool;
1538
1539    /// Second-order joint-ψ terms for a pure σ / σ pair.
1540    fn sigma_second_order_terms(
1541        &self,
1542    ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String>;
1543
1544    /// Per-family policy for a mixed σ / non-σ second-order pair: one family
1545    /// rejects it (no cross auxiliary terms available), the other returns
1546    /// `Ok(None)`.
1547    fn mixed_sigma_aux_second_order(
1548        &self,
1549    ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String>;
1550
1551    /// Second-order joint-ψ terms for a non-σ derivative-axis pair.
1552    fn psi_second_order_terms(
1553        &self,
1554        psi_i: usize,
1555        psi_j: usize,
1556    ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String>;
1557
1558    /// Direction-contracted second-order ψ terms over the non-σ derivative
1559    /// axes (#740). `alpha_psi` is the full ψ-block weight vector; the
1560    /// contraction is against the combined non-σ direction
1561    /// `ψ(α) = Σ_j alpha_psi[j] · ψ_j`, streaming the family's rows ONCE so the
1562    /// profiled θ-HVP operator applies one combined-direction n-pass per matvec
1563    /// instead of `K²` per-pair [`Self::psi_second_order_terms`] passes.
1564    ///
1565    /// Default `None` keeps the family on the exact per-pair path. The generic
1566    /// workspace only calls this when no σ-auxiliary axis carries weight (a σ
1567    /// term routes the whole direction back to the per-pair fallback), so an
1568    /// override only handles the pure non-σ derivative axes — the same domain
1569    /// as [`Self::psi_second_order_terms`].
1570    fn psi_second_order_terms_contracted(
1571        &self,
1572        _: &[f64],
1573    ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderContracted>, String> {
1574        // Default implementation ignores this parameter.
1575        Ok(None)
1576    }
1577
1578    /// Hessian directional derivative for the σ-auxiliary parameter, returned
1579    /// as a dense matrix (the generic wraps it into
1580    /// [`DriftDerivResult::Dense`](gam_problem::DriftDerivResult::Dense)).
1581    fn sigma_hessian_directional_derivative(
1582        &self,
1583        d_beta_flat: &Array1<f64>,
1584    ) -> Result<Option<Array2<f64>>, String>;
1585
1586    /// Hessian directional derivative for a non-σ derivative axis, returned as
1587    /// a hyper-operator (the generic wraps it into
1588    /// [`DriftDerivResult::Operator`](gam_problem::DriftDerivResult::Operator)).
1589    fn psi_hessian_directional_derivative(
1590        &self,
1591        psi_index: usize,
1592        d_beta_flat: &Array1<f64>,
1593    ) -> Result<Option<Arc<dyn gam_problem::HyperOperator>>, String>;
1594}
1595
1596/// Generic exact-Newton joint-ψ workspace shared by the marginal-slope
1597/// families. Owns the σ-auxiliary dispatch skeleton and delegates every
1598/// family-specific operation to its [`MarginalSlopePsiFamily`] impl.
1599pub struct MarginalSlopeExactNewtonPsiWorkspace<F: MarginalSlopePsiFamily> {
1600    family: F,
1601}
1602
1603impl<F: MarginalSlopePsiFamily> MarginalSlopeExactNewtonPsiWorkspace<F> {
1604    pub fn new(family: F) -> Self {
1605        Self { family }
1606    }
1607}
1608
1609impl<F: MarginalSlopePsiFamily> gam_problem::ExactNewtonJointPsiWorkspace
1610    for MarginalSlopeExactNewtonPsiWorkspace<F>
1611{
1612    fn first_order_terms(
1613        &self,
1614        psi_index: usize,
1615    ) -> Result<Option<gam_problem::ExactNewtonJointPsiTerms>, String> {
1616        if self.family.is_sigma_aux(psi_index) {
1617            return self.family.sigma_first_order_terms();
1618        }
1619        self.family.psi_first_order_terms(psi_index)
1620    }
1621
1622    fn first_order_terms_all(
1623        &self,
1624    ) -> Result<Option<Vec<gam_problem::ExactNewtonJointPsiTerms>>, String> {
1625        self.family.psi_first_order_terms_all()
1626    }
1627
1628    fn second_order_terms(
1629        &self,
1630        psi_i: usize,
1631        psi_j: usize,
1632    ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String> {
1633        if self.family.is_sigma_aux(psi_i) || self.family.is_sigma_aux(psi_j) {
1634            if self.family.both_sigma_aux_second_order(psi_i, psi_j) {
1635                return self.family.sigma_second_order_terms();
1636            }
1637            return self.family.mixed_sigma_aux_second_order();
1638        }
1639        self.family.psi_second_order_terms(psi_i, psi_j)
1640    }
1641
1642    fn second_order_terms_contracted(
1643        &self,
1644        alpha_psi: &[f64],
1645    ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderContracted>, String> {
1646        // The σ-auxiliary axes do not participate in the family's combined
1647        // non-σ row stream (their second-order terms come from a separate
1648        // σ/σ and mixed-σ path with no directional row kernel). If any
1649        // σ-aux axis carries weight in this applied direction, decline the
1650        // contracted fast path entirely so the caller keeps the exact
1651        // per-pair assembly — the contracted hook is a representation/cost
1652        // choice, never an approximation, so falling back is the correct
1653        // behaviour rather than dropping the σ contribution.
1654        for (j, &weight) in alpha_psi.iter().enumerate() {
1655            if weight != 0.0 && self.family.is_sigma_aux(j) {
1656                return Ok(None);
1657            }
1658        }
1659        self.family.psi_second_order_terms_contracted(alpha_psi)
1660    }
1661
1662    fn hessian_directional_derivative(
1663        &self,
1664        psi_index: usize,
1665        d_beta_flat: &Array1<f64>,
1666    ) -> Result<Option<gam_problem::DriftDerivResult>, String> {
1667        if self.family.is_sigma_aux(psi_index) {
1668            return self
1669                .family
1670                .sigma_hessian_directional_derivative(d_beta_flat)
1671                .map(|result| result.map(gam_problem::DriftDerivResult::Dense));
1672        }
1673        self.family
1674            .psi_hessian_directional_derivative(psi_index, d_beta_flat)
1675            .map(|result| result.map(gam_problem::DriftDerivResult::Operator))
1676    }
1677}
1678
1679/// Process-stable worker-count estimate used for **chunk-boundary sizing only**.
1680///
1681/// Reproducibility contract (#1045): the boundaries of the row-reduction chunks
1682/// — and therefore the floating-point association of the per-chunk sums that
1683/// feed the marginal-slope REML optimum — must NOT depend on the size of the
1684/// rayon worker pool that happens to be executing the fit. Sizing chunks to the
1685/// live `rayon::current_num_threads()` broke that contract: installing a
1686/// narrower worker pool (exactly the #1045 perf lever — shrink the pool so the
1687/// per-fit `crossbeam_epoch` bookkeeping stops dominating small-`n` loops)
1688/// regrouped the per-chunk row sums and, through the iterative REML optimizer
1689/// near a flat optimum, steered the fit to a different `(ρ, λ)`. That is a
1690/// reproducibility defect, not a perf win.
1691///
1692/// This returns the machine parallelism captured once for the process — a fixed
1693/// deployment property, independent of how many workers a scoped
1694/// `ThreadPool::install` exposes — so shrinking (or widening) the executing
1695/// pool leaves the chunk boundaries, the reduction tree, and hence the fit
1696/// unchanged. rayon still fans the fixed chunks across whatever workers are
1697/// present, so parallelism is fully preserved. In production, where gam owns a
1698/// single global pool sized to the machine, this equals the previous
1699/// `current_num_threads()` value, so the fit's numerics are preserved.
1700pub(crate) fn reproducible_chunk_parallelism() -> usize {
1701    use std::sync::OnceLock;
1702    static CACHED: OnceLock<usize> = OnceLock::new();
1703    *CACHED.get_or_init(|| {
1704        std::thread::available_parallelism()
1705            .map(|n| n.get())
1706            .unwrap_or(1)
1707            .max(1)
1708    })
1709}
1710
1711/// Deterministic-order parallel reduction over a row-index slice.
1712///
1713/// Splits `rows` into contiguous chunks sized to saturate the rayon pool
1714/// (several chunks per worker, floored so small `n` stays coarse), processes
1715/// each chunk sequentially in parallel via `process_row`, and combines the
1716/// per-chunk accumulators in chunk-index order via `combine` on the calling
1717/// thread. The chunk count is a pure function of `(rows.len(),
1718/// reproducible_chunk_parallelism())` — the latter a process constant, NOT the
1719/// live scoped-pool worker count — so the reduction tree is fixed across calls
1720/// and across pool sizes regardless of rayon's work-stealing decisions.
1721///
1722/// `try_fold/try_reduce` over `rows.into_par_iter()` does **not** have
1723/// this property: rayon's adaptive splitter sets chunk boundaries based
1724/// on `current_num_threads()` and runtime work-stealing, so two calls
1725/// with identical inputs can return ULP-different floating-point sums
1726/// when the rayon pool has different concurrent activity. Tests that
1727/// compare two reductions and rely on bit-for-bit equality flake under
1728/// load with that pattern. This primitive is the per-family deterministic
1729/// row-reduction that the bernoulli / survival sigma-ψ paths funnel
1730/// through; their per-row contributions are the dominant non-deterministic
1731/// source in the marginal-slope outer-loop score / Hessian sums.
1732pub(crate) fn chunked_row_reduction<Item, Acc, Init, Process, Combine>(
1733    rows: &[Item],
1734    init: Init,
1735    process_row: Process,
1736    mut combine: Combine,
1737) -> Result<Acc, String>
1738where
1739    Item: Sync + Copy,
1740    Acc: Send,
1741    Init: Fn() -> Acc + Sync,
1742    Process: Fn(Item, &mut Acc) -> Result<(), String> + Sync,
1743    Combine: FnMut(&mut Acc, Acc),
1744{
1745    use rayon::iter::{IntoParallelIterator, ParallelIterator};
1746    let n = rows.len();
1747    if n == 0 {
1748        return Ok(init());
1749    }
1750    // The chunk count is sized so the heavy reduction phases actually saturate
1751    // the rayon pool: a fixed `32` left half of a 64-core box idle whenever the
1752    // pool had more than 32 workers, capping utilization at ~50% on the biobank
1753    // coord-corrections / row-stream phases. Targeting several chunks per worker
1754    // keeps load balanced across an uneven row-cost tail (work-stealing still
1755    // moves whole chunks, never partial sums) without flooding the sequential
1756    // `combine` with tiny partials. The count is a pure function of
1757    // `(rows.len(), reproducible_chunk_parallelism())`; the latter is the
1758    // process-stable machine parallelism (NOT the live scoped-pool worker
1759    // count), so chunk boundaries are invariant to the executing pool size and
1760    // the ordered `Vec` collect + sequential `combine` keep the reduction
1761    // bit-for-bit deterministic regardless of pool size or work-stealing.
1762    const CHUNKS_PER_WORKER: usize = 4;
1763    const MIN_CHUNK_COUNT: usize = 32;
1764    const MIN_ROWS_PER_CHUNK: usize = 64;
1765    let workers = reproducible_chunk_parallelism();
1766    let target_chunk_count = workers
1767        .saturating_mul(CHUNKS_PER_WORKER)
1768        .max(MIN_CHUNK_COUNT);
1769    // Never carve chunks below `MIN_ROWS_PER_CHUNK` rows: for small `n` the
1770    // scheduler/partial-accumulator overhead would dominate the row arithmetic.
1771    let chunk_count = target_chunk_count
1772        .min(n.div_ceil(MIN_ROWS_PER_CHUNK))
1773        .max(1);
1774    let chunk_size = n.div_ceil(chunk_count).max(1);
1775    let n_chunks = n.div_ceil(chunk_size);
1776    // `(0..n_chunks).into_par_iter()` is `IndexedParallelIterator`, so the
1777    // `.collect::<Vec<_>>()` below preserves chunk-index order regardless
1778    // of work-stealing. That ordered `Vec` is what makes the sequential
1779    // `combine` deterministic.
1780    let chunk_states: Vec<Acc> = (0..n_chunks)
1781        .into_par_iter()
1782        .map(|chunk_idx| -> Result<Acc, String> {
1783            let start = chunk_idx * chunk_size;
1784            let end = (start + chunk_size).min(n);
1785            let mut acc = init();
1786            for &item in &rows[start..end] {
1787                process_row(item, &mut acc)?;
1788            }
1789            Ok(acc)
1790        })
1791        .collect::<Result<Vec<Acc>, String>>()?;
1792    let mut total = init();
1793    for chunk in chunk_states {
1794        combine(&mut total, chunk);
1795    }
1796    Ok(total)
1797}
1798
1799#[cfg(test)]
1800mod tests {
1801    use super::*;
1802
1803    fn unit_box() -> gam_problem::LinearInequalityConstraints {
1804        gam_problem::LinearInequalityConstraints::new(
1805            ndarray::array![[1.0, 0.0], [0.0, 1.0]],
1806            ndarray::array![0.0, 0.0],
1807        )
1808        .expect("constraint construction")
1809    }
1810
1811    #[test]
1812    fn feasible_step_fraction_refuses_a_non_finite_direction_2721() {
1813        let constraints = unit_box();
1814        let beta = ndarray::array![1.0, 1.0];
1815        // Positive control: a finite BINDING direction is evaluated and clipped,
1816        // so the refusal below is about finiteness and not about this fixture
1817        // failing to reach the rule at all.
1818        let bounded =
1819            feasible_step_fraction(&constraints, &beta, &ndarray::array![-2.0, 0.0])
1820                .expect("a finite direction must be evaluated");
1821        assert!(
1822            bounded > 0.0 && bounded < 1.0,
1823            "a binding finite direction should clip the step, got {bounded}"
1824        );
1825        // The defect (gam#2721): `drift < 0.0` is false for NaN, so the row
1826        // contributed nothing to the minimum and this returned Ok(1.0) -- a
1827        // non-finite step certified as fully feasible.
1828        let refusal =
1829            feasible_step_fraction(&constraints, &beta, &ndarray::array![f64::NAN, 0.0])
1830                .expect_err("a non-finite direction component must be refused");
1831        match refusal {
1832            gam_problem::ContractFeasibleStepError::NonFinite { row, .. } => {
1833                assert_eq!(row, 0, "the refusal must name the offending row");
1834            }
1835            other => panic!("the refusal must name the non-finite quantity, got: {other:?}"),
1836        }
1837    }
1838
1839    /// gam#2719. The step rule and the point rule are the same rule: a step
1840    /// whose endpoint the constraint carrier calls feasible must not be
1841    /// refused. At `beta` exactly on a face, a drift far below the
1842    /// primal-feasibility contract leaves the whole step admissible.
1843    #[test]
1844    fn feasible_step_fraction_admits_a_sub_tolerance_drift_off_an_active_row() {
1845        let constraints = unit_box();
1846        let on_the_face = ndarray::array![0.0, 1.0];
1847        let admitted = feasible_step_fraction(
1848            &constraints,
1849            &on_the_face,
1850            &ndarray::array![-3.291_437e-18, 0.5],
1851        )
1852        .expect("an in-band drift keeps a feasible origin");
1853        assert_eq!(
1854            admitted, 1.0,
1855            "a drift ten orders below the contract must not limit the step"
1856        );
1857        // Positive control on the same fixture: a drift ABOVE the contract still
1858        // blocks, and blocks completely — the slack is exactly zero.
1859        let blocked =
1860            feasible_step_fraction(&constraints, &on_the_face, &ndarray::array![-1.0e-6, 0.5])
1861                .expect("an out-of-band drift is an answer, not an error");
1862        assert_eq!(blocked, 0.0);
1863    }
1864
1865    /// The blocked answer is invariant under shrinking the direction, which is
1866    /// why answering it with a trust-radius shrink cannot converge: quartering
1867    /// the step quarters the drift and leaves `0 / -drift` at zero. This is the
1868    /// mechanism behind the witness fit's 24-attempts-per-cycle ladder.
1869    ///
1870    /// The ladder terminates for one reason only — the drift itself eventually
1871    /// falls inside the primal-feasibility contract, at which point the step is
1872    /// no longer a violation and is admitted whole. With `|drift| = 4^-k` that
1873    /// is the first `k` with `4^-k <= 1e-8`, i.e. `k = 14`. Pinning the exact
1874    /// crossing is what makes this a bounded relief and not a widened
1875    /// tolerance: rung 13 still blocks.
1876    #[test]
1877    fn a_zero_numerator_ratio_is_invariant_under_shrinking_the_step() {
1878        let constraints = unit_box();
1879        let on_the_face = ndarray::array![0.0, 1.0];
1880        let mut direction = ndarray::array![-1.0, 0.5];
1881        let mut first_admitting_rung = None;
1882        for attempt in 0..24 {
1883            let alpha = feasible_step_fraction(&constraints, &on_the_face, &direction)
1884                .expect("a feasible origin at every rung of the ladder");
1885            if alpha == 0.0 {
1886                assert!(
1887                    first_admitting_rung.is_none(),
1888                    "the answer must not oscillate: rung {attempt} blocks after rung {:?} admitted",
1889                    first_admitting_rung
1890                );
1891            } else {
1892                assert_eq!(alpha, 1.0, "rung {attempt} must admit the step whole");
1893                first_admitting_rung.get_or_insert(attempt);
1894            }
1895            direction.mapv_inplace(|value| value * 0.25);
1896        }
1897        assert_eq!(
1898            first_admitting_rung,
1899            Some(14),
1900            "the ladder must re-derive the same zero until the drift enters the \
1901             contract band, and cross exactly where 4^-k reaches 1e-8"
1902        );
1903    }
1904
1905    /// gam#2695 — the property the multiplicative backoff denied: a coefficient
1906    /// walking to its own bound REACHES it, in a bounded number of clipped
1907    /// steps, so the row can become active.
1908    ///
1909    /// This is the regression test in its most direct form. Under `α ← 0.995·α`
1910    /// the surviving slack is `0.005·s` after every clipped step, so the
1911    /// sequence is `s·200^{-k}` and no `k` reaches the face — the witness fit
1912    /// spent 400 cycles walking one warp coefficient from `1e-3` to `1e-163`.
1913    /// The loop below reproduces exactly that walk and asserts it terminates.
1914    #[test]
1915    fn a_coefficient_walking_to_its_bound_reaches_it_in_bounded_steps_2695() {
1916        let constraints = unit_box();
1917        let mut beta = ndarray::array![1.0e-3, 1.0];
1918        // A fixed direction driving coordinate 0 to its `>= 0` bound, exactly
1919        // the shape of the link-wiggle cone's binding coordinate.
1920        let direction = ndarray::array![-1.0e-2, 0.0];
1921        let mut clipped_steps = 0usize;
1922        let mut blocked = false;
1923        for _ in 0..64 {
1924            let alpha = feasible_step_fraction(&constraints, &beta, &direction)
1925                .expect("a feasible origin at every step");
1926            if alpha <= 0.0 {
1927                blocked = true;
1928                break;
1929            }
1930            beta = &beta + &(&direction * alpha);
1931            clipped_steps += 1;
1932            assert!(
1933                beta[0] >= -gam_problem::PRIMAL_FEASIBILITY_TOL,
1934                "a clipped step must never leave the contract band, got β₀ = {:.3e}",
1935                beta[0]
1936            );
1937        }
1938        assert!(
1939            blocked,
1940            "the walk must reach the face and report BlockedByActiveFace (α = 0);              after {clipped_steps} clipped steps β₀ is still {:.3e}",
1941            beta[0],
1942        );
1943        // The bound is not decoration. Under the old multiplicative rule this
1944        // needed 80 steps to fall from 1e-3 to 1e-8 and never blocked at all;
1945        // an absolute retreat gets there in one.
1946        assert!(
1947            clipped_steps <= 2,
1948            "an absolute retreat reaches the face immediately; took {clipped_steps}              clipped steps"
1949        );
1950    }
1951
1952    /// The other half of the same rule, and the half the backoff exists for: a
1953    /// step with room to spare is NOT left balanced on the face. It stops one
1954    /// primal-feasibility tolerance short of it — an absolute margin in the
1955    /// scaled-slack metric, so it does not depend on how far the step travelled.
1956    #[test]
1957    fn a_clipped_step_stops_one_tolerance_short_of_the_face_2695() {
1958        let constraints = unit_box();
1959        let beta = ndarray::array![1.0, 1.0];
1960        // Two directions with the SAME slack to cover and very different
1961        // lengths. A multiplicative backoff leaves `0.005·slack` behind in both,
1962        // i.e. margins that differ by the length ratio; an absolute one leaves
1963        // the same margin.
1964        let mut margins = Vec::new();
1965        for scale in [1.0_f64, 1.0e3] {
1966            let direction = ndarray::array![-2.0 * scale, 0.0];
1967            let alpha = feasible_step_fraction(&constraints, &beta, &direction)
1968                .expect("a binding direction is clipped, not refused");
1969            let landed = &beta + &(&direction * alpha);
1970            assert!(
1971                landed[0] >= 0.0,
1972                "the clipped endpoint must stay inside the cone, got {:.3e}",
1973                landed[0]
1974            );
1975            margins.push(landed[0]);
1976        }
1977        for margin in &margins {
1978            assert!(
1979                (margin - gam_problem::PRIMAL_FEASIBILITY_TOL).abs()
1980                    <= 1.0e-3 * gam_problem::PRIMAL_FEASIBILITY_TOL,
1981                "the surviving margin must be one primal-feasibility tolerance, got {margin:.6e}"
1982            );
1983        }
1984        assert!(
1985            (margins[0] - margins[1]).abs() <= 1.0e-3 * gam_problem::PRIMAL_FEASIBILITY_TOL,
1986            "the margin must not depend on the direction's length: {:.6e} vs {:.6e}",
1987            margins[0],
1988            margins[1],
1989        );
1990    }
1991
1992    #[test]
1993    fn auto_outer_score_subsample_skips_small_problems() {
1994        let n = 1000;
1995        let z: Vec<f64> = (0..n).map(|i| i as f64).collect();
1996        let opts = AutoOuterSubsampleOptions::default();
1997        assert!(
1998            auto_outer_score_subsample(&z, None, &opts).is_none(),
1999            "n={n} below default min_n_for_auto=30000 should not subsample"
2000        );
2001    }
2002
2003    #[test]
2004    fn auto_outer_score_subsample_returns_target_k_above_threshold() {
2005        let n = 60_000;
2006        let z: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
2007        let opts = AutoOuterSubsampleOptions::default();
2008        let mask = auto_outer_score_subsample(&z, None, &opts)
2009            .expect("n=60000 should auto-subsample with default options");
2010        // Default target_fraction=0.10 and min_k=10000 → K = max(10000, 6000) = 10000.
2011        assert_eq!(mask.n_full, n);
2012        assert!(
2013            mask.len() >= 9_900 && mask.len() <= 10_200,
2014            "expected K≈10_000, got {}",
2015            mask.len()
2016        );
2017        // HT weights should reconstruct n_full in expectation: sum of
2018        // per-row weights ≈ n_full (allowing for small allocation rounding).
2019        let weight_sum: f64 = mask.rows.iter().map(|r| r.weight).sum();
2020        let rel_err = (weight_sum - n as f64).abs() / n as f64;
2021        assert!(
2022            rel_err < 0.02,
2023            "HT weight sum {weight_sum:.3} should ≈ n_full={n}, rel_err={rel_err:.4}"
2024        );
2025    }
2026
2027    #[test]
2028    fn sampled_outer_schedule_promotes_same_checkpoint_to_exact_measure_979() {
2029        let options = crate::custom_family::BlockwiseFitOptions::default();
2030        let phase_counter = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2031        let last_rho = Arc::new(std::sync::Mutex::new(None));
2032        let phase_budget = 12;
2033        let rho = [0.25, -0.5];
2034
2035        // A small problem never installs a sample and therefore must not ask
2036        // the generic runner for a redundant exact-polish solve.
2037        let small_z: Vec<f64> = (0..1_000).map(|i| i as f64).collect();
2038        assert!(
2039            maybe_install_auto_outer_subsample(
2040                &options,
2041                &small_z,
2042                None,
2043                &rho,
2044                &phase_counter,
2045                &last_rho,
2046                phase_budget,
2047                "test-small",
2048                1,
2049                30_000,
2050                10_000,
2051                1_000,
2052            )
2053            .is_none()
2054        );
2055        let schedule = crate::custom_family::OuterDerivativePilotSchedule::new(
2056            Arc::clone(&phase_counter),
2057            phase_budget,
2058        );
2059        assert!(!schedule.enter_exact_phase());
2060        assert_eq!(phase_counter.load(std::sync::atomic::Ordering::SeqCst), 0);
2061
2062        // Once a large problem actually installs its sampled measure, the
2063        // transition is single-shot and the SAME checkpoint rho immediately
2064        // evaluates on full data (no one-evaluation sampled leak into polish).
2065        let large_z: Vec<f64> = (0..40_000).map(|i| (i as f64).sin()).collect();
2066        assert!(
2067            maybe_install_auto_outer_subsample(
2068                &options,
2069                &large_z,
2070                None,
2071                &rho,
2072                &phase_counter,
2073                &last_rho,
2074                phase_budget,
2075                "test-large",
2076                1,
2077                30_000,
2078                10_000,
2079                1_000,
2080            )
2081            .is_some()
2082        );
2083        assert!(schedule.enter_exact_phase());
2084        assert!(!schedule.enter_exact_phase());
2085        assert_eq!(
2086            phase_counter.load(std::sync::atomic::Ordering::SeqCst),
2087            phase_budget + 1
2088        );
2089
2090        // Exact boundary regression: `counter == budget` is still the state
2091        // immediately after the last sampled point, not proof that a full-data
2092        // derivative has run. It must request one exact-polish continuation.
2093        let boundary_counter = Arc::new(std::sync::atomic::AtomicUsize::new(phase_budget));
2094        let boundary_schedule = crate::custom_family::OuterDerivativePilotSchedule::new(
2095            Arc::clone(&boundary_counter),
2096            phase_budget,
2097        );
2098        assert!(boundary_schedule.enter_exact_phase());
2099        assert_eq!(
2100            boundary_counter.load(std::sync::atomic::Ordering::SeqCst),
2101            phase_budget + 1
2102        );
2103        assert!(!boundary_schedule.enter_exact_phase());
2104        assert!(
2105            maybe_install_auto_outer_subsample(
2106                &options,
2107                &large_z,
2108                None,
2109                &rho,
2110                &phase_counter,
2111                &last_rho,
2112                phase_budget,
2113                "test-large",
2114                1,
2115                30_000,
2116                10_000,
2117                1_000,
2118            )
2119            .is_none(),
2120            "the first exact-polish evaluation at the pilot checkpoint must use full data",
2121        );
2122    }
2123
2124    #[test]
2125    fn auto_outer_score_subsample_horvitz_thompson_unbiased() {
2126        // On a synthetic per-row contribution `t_i = z_i² + 1`, verify
2127        // the HT-weighted sum over the auto-mask matches the full sum
2128        // within 3 standard deviations of the predicted estimator
2129        // variance. This guards against silent regressions in either
2130        // the stratified mask construction or the weight assignment.
2131        let n = 50_000;
2132        let z: Vec<f64> = (0..n)
2133            .map(|i| ((i as f64) / n as f64) * 2.0 - 1.0)
2134            .collect();
2135        let stratum: Vec<u8> = (0..n).map(|i| if i % 3 == 0 { 1 } else { 0 }).collect();
2136        let opts = AutoOuterSubsampleOptions {
2137            seed: 0xC0FFEE,
2138            ..AutoOuterSubsampleOptions::default()
2139        };
2140        let t: Vec<f64> = z.iter().map(|zi| zi * zi + 1.0).collect();
2141        let exact: f64 = t.iter().sum();
2142        let mask = auto_outer_score_subsample(&z, Some(&stratum), &opts)
2143            .expect("n=50000 should auto-subsample");
2144        let estimate: f64 = mask.rows.iter().map(|r| r.weight * t[r.index]).sum();
2145        // Predicted standard error: σ ≈ (1/√K) · √(1 − K/N) · cv · |T|.
2146        // For t_i ∈ [1, 2], cv ≲ 0.4. Be generous (factor 5) to keep
2147        // the test robust against PRNG-dependent allocation jitter.
2148        let k = mask.len();
2149        let predicted_se =
2150            exact * 0.4 * (1.0 / (k as f64).sqrt()) * (1.0 - k as f64 / n as f64).sqrt();
2151        let observed_err = (estimate - exact).abs();
2152        assert!(
2153            observed_err < 5.0 * predicted_se.max(1.0),
2154            "HT estimate {estimate:.3} vs exact {exact:.3}: err={observed_err:.3} exceeds 5×predicted_se={:.3}",
2155            predicted_se
2156        );
2157    }
2158
2159    #[test]
2160    fn subsample_full_n_equals_no_subsample() {
2161        // mask = (0..n) — the all-rows subsample should have weight_scale 1.0
2162        // and outer_row_indices should yield the same sorted set in both
2163        // Some(mask=full) and None modes.
2164        let n: usize = 1024;
2165        let z: Vec<f64> = (0..n).map(|i| i as f64).collect();
2166        let secondary: Vec<u8> = (0..n).map(|i| (i % 2) as u8).collect();
2167        let s = build_outer_score_subsample(&z, &secondary, n, 0xDEADBEEF);
2168        assert_eq!(s.len(), n);
2169        assert!((s.weight_scale - 1.0).abs() < 1e-12);
2170
2171        let mut full = crate::custom_family::BlockwiseFitOptions::default();
2172        let from_none = outer_row_indices(&full, n).to_vec();
2173        full.outer_score_subsample = Some(Arc::new(s));
2174        let from_some = outer_row_indices(&full, n).to_vec();
2175
2176        let mut a = from_none.clone();
2177        let mut b = from_some.clone();
2178        a.sort_unstable();
2179        b.sort_unstable();
2180        assert_eq!(a, b);
2181        assert_eq!(a, (0..n).collect::<Vec<_>>());
2182    }
2183
2184    #[test]
2185    fn stratification_covers_all_strata() {
2186        // Synthetic with 2 secondary classes × 100 z-deciles. Every
2187        // non-empty (secondary, decile) stratum must contribute ≥ 1 row.
2188        let n: usize = 20_000;
2189        let z: Vec<f64> = (0..n).map(|i| (i as f64) * 0.001).collect();
2190        let secondary: Vec<u8> = (0..n).map(|i| (i % 2) as u8).collect();
2191        let k = 2_000;
2192        let s = build_outer_score_subsample(&z, &secondary, k, 12345);
2193        assert!(s.len() >= k, "subsample size {} < k {}", s.len(), k);
2194
2195        // Recompute deciles to label rows.
2196        let mut order: Vec<usize> = (0..n).collect();
2197        order.sort_by(|&a, &b| z[a].partial_cmp(&z[b]).unwrap());
2198        let mut decile = vec![0usize; n];
2199        for (rank, &row) in order.iter().enumerate() {
2200            decile[row] = ((rank * 100) / n).min(99);
2201        }
2202        // For each (sec, dec), is there at least one row in mask?
2203        let mut covered = [false; 200];
2204        for &row in s.mask.iter() {
2205            let stratum = secondary[row] as usize * 100 + decile[row];
2206            covered[stratum] = true;
2207        }
2208        // All 200 strata are non-empty in this synthetic, so all must be
2209        // covered.
2210        for (stratum, &c) in covered.iter().enumerate() {
2211            assert!(c, "stratum {} uncovered", stratum);
2212        }
2213    }
2214
2215    #[test]
2216    fn deterministic_seed() {
2217        // Same inputs + seed must produce identical masks; different seeds
2218        // produce different masks (with overwhelming probability for these
2219        // sizes).
2220        let n: usize = 5_000;
2221        let z: Vec<f64> = (0..n).map(|i| (i as f64).sin()).collect();
2222        let secondary: Vec<u8> = (0..n).map(|i| (i % 2) as u8).collect();
2223        let k = 800;
2224        let a = build_outer_score_subsample(&z, &secondary, k, 0xABCDEF);
2225        let b = build_outer_score_subsample(&z, &secondary, k, 0xABCDEF);
2226        let c = build_outer_score_subsample(&z, &secondary, k, 0xFEDCBA);
2227        assert_eq!(a.mask.as_ref(), b.mask.as_ref());
2228        assert_ne!(a.mask.as_ref(), c.mask.as_ref());
2229    }
2230
2231    #[test]
2232    fn weight_scale_correct() {
2233        // n=10000, k=2000 → weight_scale ≈ 5.0 (allow small overshoot from
2234        // ceil(k * stratum_size / n) summed across strata).
2235        let n: usize = 10_000;
2236        let z: Vec<f64> = (0..n).map(|i| i as f64).collect();
2237        let secondary: Vec<u8> = (0..n).map(|i| (i % 2) as u8).collect();
2238        let k = 2_000;
2239        let s = build_outer_score_subsample(&z, &secondary, k, 7);
2240        assert!(s.len() >= k);
2241        // overshoot bounded by number of strata (one extra row per stratum
2242        // from the ceil); for 2 × 100 = 200 strata, overshoot ≤ 200.
2243        assert!(
2244            s.len() <= k + 200,
2245            "subsample {} much larger than expected",
2246            s.len()
2247        );
2248        let scale = s.weight_scale;
2249        // expected ≈ 5.0; allow ±10% for the ceiling overshoot.
2250        assert!(
2251            (scale - 5.0).abs() < 0.5,
2252            "weight_scale {} not near 5.0",
2253            scale
2254        );
2255    }
2256}