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