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