1use 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
40pub 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 pending.replace(Some(beta.clone()));
68 Ok(gam_solve::rho_optimizer::SeedOutcome::Installed)
69 }
70}
71
72pub 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#[derive(Clone)]
144pub(crate) struct DirectionalScaleJets {
145 pub(crate) obj: Option<MultiDirJet>,
146 pub(crate) grad: MultiDirJet,
147 pub(crate) hess: MultiDirJet,
148}
149
150pub(crate) struct DirectionalPrimaryTerms {
153 pub(crate) objective: f64,
154 pub(crate) grad: Array1<f64>,
155 pub(crate) hess: Array2<f64>,
156}
157
158pub(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#[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#[inline]
811const fn splitmix64(state: &mut u64) -> u64 {
812 gam_linalg::utils::splitmix64(state)
813}
814
815#[derive(Clone, Debug)]
844pub struct AutoOuterSubsampleOptions {
845 pub min_n_for_auto: usize,
848 pub min_k: usize,
853 pub target_fraction: f64,
855 pub seed: u64,
859 pub outer_work_per_k_unit: u64,
880 pub min_k_floor: usize,
883}
884
885pub const AUTO_OUTER_WORK_BUDGET: u64 = 500_000_000;
890
891pub const AUTO_OUTER_MIN_K_FLOOR: usize = 1_000;
897
898const AUTO_OUTER_DISTINCT_STEP_L2_TOL: f64 = 1e-10;
904
905#[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#[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 pub fn target_k(&self, n: usize) -> Option<usize> {
956 self.target_k_detailed(n).map(|choice| choice.k)
957 }
958
959 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 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 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 return None;
996 }
997 Some(AutoOuterKChoice {
998 k,
999 k_noise,
1000 k_work,
1001 cap_reason,
1002 })
1003 }
1004}
1005
1006pub 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 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
1039pub 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 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 if current >= phase1_budget {
1118 current
1119 } else {
1120 current.saturating_sub(1)
1121 }
1122 }
1123 };
1124 if phase_idx >= phase1_budget {
1125 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
1163pub 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 k >= n {
1200 let mask: Vec<usize> = (0..n).collect();
1201 return OuterScoreSubsample::with_uniform_weight(mask, n, seed, 1.0);
1202 }
1203
1204 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 let mut decile = vec![0u16; n];
1210 for (rank, &row) in z_order.iter().enumerate() {
1211 let bin = (rank * Q) / n;
1214 let bin = bin.min(Q - 1);
1215 decile[row] = bin as u16;
1216 }
1217
1218 let mut distinct_secondary: Vec<u8> = stratum_secondary.to_vec();
1221 distinct_secondary.sort_unstable();
1222 distinct_secondary.dedup();
1223 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 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 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 let w_h = rows.len() as f64 / take as f64;
1250 let stratum_tag = stratum_id as u32;
1251
1252 let mut state = seed ^ (stratum_id as u64).wrapping_mul(0x9E3779B97F4A7C15);
1254 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 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 OuterScoreSubsample::from_weighted_rows(picked, n, seed)
1288}
1289
1290#[derive(Debug, Clone)]
1302pub enum OuterRowIter {
1303 All { n: usize },
1305 Subset { mask: Arc<Vec<usize>> },
1307}
1308
1309impl OuterRowIter {
1310 #[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 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
1335pub 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
1355pub 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
1374pub 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
1396pub 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 const FEASIBLE_STEP_VIOLATION_TOL: f64 = 1e-8;
1437 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 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
1464pub trait MarginalSlopePsiFamily: Send + Sync {
1483 fn is_sigma_aux(&self, psi_index: usize) -> bool;
1486
1487 fn sigma_first_order_terms(
1489 &self,
1490 ) -> Result<Option<gam_problem::ExactNewtonJointPsiTerms>, String>;
1491
1492 fn psi_first_order_terms(
1494 &self,
1495 psi_index: usize,
1496 ) -> Result<Option<gam_problem::ExactNewtonJointPsiTerms>, String>;
1497
1498 fn psi_first_order_terms_all(
1503 &self,
1504 ) -> Result<Option<Vec<gam_problem::ExactNewtonJointPsiTerms>>, String>;
1505
1506 fn both_sigma_aux_second_order(&self, psi_i: usize, psi_j: usize) -> bool;
1511
1512 fn sigma_second_order_terms(
1514 &self,
1515 ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String>;
1516
1517 fn mixed_sigma_aux_second_order(
1521 &self,
1522 ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String>;
1523
1524 fn psi_second_order_terms(
1526 &self,
1527 psi_i: usize,
1528 psi_j: usize,
1529 ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderTerms>, String>;
1530
1531 fn psi_second_order_terms_contracted(
1544 &self,
1545 _: &[f64],
1546 ) -> Result<Option<gam_problem::ExactNewtonJointPsiSecondOrderContracted>, String> {
1547 Ok(None)
1549 }
1550
1551 fn sigma_hessian_directional_derivative(
1555 &self,
1556 d_beta_flat: &Array1<f64>,
1557 ) -> Result<Option<Array2<f64>>, String>;
1558
1559 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
1569pub 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 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
1652pub(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
1684pub(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 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 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 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 use gam_math::jet_partitions::MultiDirJet;
1799
1800 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 for (stratum, &c) in covered.iter().enumerate() {
2235 assert!(c, "stratum {} uncovered", stratum);
2236 }
2237 }
2238
2239 #[test]
2240 fn deterministic_seed() {
2241 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 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 assert!(
2268 s.len() <= k + 200,
2269 "subsample {} much larger than expected",
2270 s.len()
2271 );
2272 let scale = s.weight_scale;
2273 assert!(
2275 (scale - 5.0).abs() < 0.5,
2276 "weight_scale {} not near 5.0",
2277 scale
2278 );
2279 }
2280}