gam_model_api/families/custom_family/options.rs
1//! Fit-time configuration and cost accounting: `BlockwiseFitOptions`, the
2//! outer-derivative policy + order selection, coefficient cost models, and the
3//! argument-validation asserts shared by the solver entry points.
4
5use crate::families::custom_family::family_trait::{CustomFamily, OuterEvalContext};
6use crate::families::custom_family::psi_design::{
7 CustomFamilyHyperLayout, ExactNewtonJointHessianWorkspace,
8};
9use gam_linalg::RidgePolicy;
10use gam_problem::{ParameterBlockSpec, ParameterBlockState};
11use ndarray::Array1;
12use std::ops::Range;
13use std::sync::Arc;
14use std::sync::atomic::AtomicUsize;
15
16// Moved to `gam-problem` (#1521 CustomFamily-cone inversion): the neutral,
17// dependency-free outer-objective + exact-derivative-order capability enums now
18// live in `gam_problem::family_options` and are re-exported here so every
19// `custom_family::ExactNewtonOuterObjective` / `ExactOuterDerivativeOrder` path
20// keeps resolving byte-for-byte.
21pub use gam_problem::{ExactNewtonOuterObjective, ExactOuterDerivativeOrder};
22
23// The block-spec consistency validator is neutral (no `CustomFamily`
24// dependency), so it lives in `gam-problem`. Coefficient damping
25// deliberately has no model-level default: the custom-family solver derives
26// any transient shift from the current curvature and must converge the
27// undamped score equation.
28pub use gam_problem::validate_blockspec_consistency;
29
30/// Exact outer derivative order for families that expose second-order
31/// coefficient geometry.
32///
33/// This used to be a cost gate that demoted large large-scale problems to
34/// first-order BFGS. That was a policy leak into the math layer: if the family
35/// supplies analytic dense Hessian blocks or an analytic profiled-Hessian HVP,
36/// the outer optimizer should see the exact second-order objective. Runtime
37/// representation choices (dense vs operator) belong below this declaration,
38/// not in a first-order downgrade.
39/// Precondition check for the family capability / operator hooks (e.g.
40/// `batched_outer_hessian_terms`, `outer_hyper_hessian_operator`).
41///
42/// These hooks operate on whatever block geometry the caller has assembled and
43/// must validate the *consistency* of the specs they are handed — never the
44/// fit-level "at least one block" precondition, which belongs to the fit entry
45/// points (`validate_blockspecs`). An empty, self-consistent argument set is a
46/// valid no-op probe of the operator path (the operator may ignore the specs
47/// entirely), so it must not panic here.
48pub(crate) fn assert_valid_blockspecs(specs: &[ParameterBlockSpec], context: &str) {
49 assert!(
50 validate_blockspec_consistency(specs).is_ok(),
51 "{context}: inconsistent parameter block specs"
52 );
53}
54
55pub(crate) fn assert_valid_options(options: &BlockwiseFitOptions, context: &str) {
56 assert!(
57 options.inner_tol.is_finite() && options.inner_tol >= 0.0,
58 "{context}: inner_tol must be finite and non-negative"
59 );
60 assert!(
61 options.outer_tol.is_finite() && options.outer_tol >= 0.0,
62 "{context}: outer_tol must be finite and non-negative"
63 );
64 assert!(
65 options.ridge_floor.is_finite() && options.ridge_floor >= 0.0,
66 "{context}: ridge_floor must be finite and non-negative"
67 );
68 if let Some(threshold) = options.early_exit_threshold {
69 assert!(
70 threshold.is_finite(),
71 "{context}: early_exit_threshold must be finite"
72 );
73 }
74}
75
76pub(crate) fn assert_states_match_specs(
77 states: &[ParameterBlockState],
78 specs: &[ParameterBlockSpec],
79 context: &str,
80) {
81 assert_eq!(
82 states.len(),
83 specs.len(),
84 "{context}: state/spec block count mismatch"
85 );
86 for (block, (state, spec)) in states.iter().zip(specs).enumerate() {
87 assert_eq!(
88 state.beta.len(),
89 spec.design.ncols(),
90 "{context}: beta length mismatch in block {block}"
91 );
92 // `state.eta` is produced from `solver_design()` (see
93 // `refresh_all_block_etas`), which is `stacked_design` when set
94 // (3·n_obs rows for survival LS time-varying blocks) and `design`
95 // (n_obs rows) otherwise. Use the same accessor here.
96 assert_eq!(
97 state.eta.len(),
98 spec.solver_design().nrows(),
99 "{context}: eta length mismatch in block {block}"
100 );
101 }
102}
103
104/// Precondition check for the hooks that receive block states *without* the
105/// matching specs (`exact_newton_outer_curvature`,
106/// `exact_newton_joint_loglik_gradient`, ...).
107///
108/// Those hooks are asked for curvature/score *at the supplied point*, so the
109/// point has to be one: a NaN beta or eta is not an iterate, and a family that
110/// returns `None` (the trait default) would otherwise let the NaN travel on to
111/// whichever path the caller falls back to, surfacing as an unattributable
112/// non-finite outer derivative several layers up.
113pub(crate) fn assert_blockstates_are_a_point(states: &[ParameterBlockState], context: &str) {
114 for (block, state) in states.iter().enumerate() {
115 assert!(
116 state.beta.iter().all(|v| !v.is_nan()),
117 "{context}: NaN beta in block {block}"
118 );
119 assert!(
120 state.eta.iter().all(|v| !v.is_nan()),
121 "{context}: NaN eta in block {block}"
122 );
123 }
124}
125
126/// Precondition check for the per-block hooks that take `(states, block_index,
127/// spec)`: the index must select a block of `states`, and the spec handed
128/// alongside it must be *that* block's spec, not a different block's.
129///
130/// The index and the spec are two independent ways of naming the same block, so
131/// a caller that advances one without the other silently asks the family to
132/// project block `i`'s coefficients onto block `j`'s geometry.
133pub(crate) fn assert_block_index_matches_spec(
134 states: &[ParameterBlockState],
135 block_index: usize,
136 spec: &ParameterBlockSpec,
137 context: &str,
138) {
139 assert!(
140 block_index < states.len(),
141 "{context}: block index {block_index} out of range for {} blocks",
142 states.len()
143 );
144 assert_eq!(
145 states[block_index].beta.len(),
146 spec.design.ncols(),
147 "{context}: spec does not describe block {block_index}"
148 );
149}
150
151/// Precondition check for the per-block hooks that take `(states, block_index,
152/// direction)` where `direction` lives in the block's *coefficient* space
153/// (`exact_newton_hessian_directional_derivative`, `max_feasible_step_size`, ...).
154pub(crate) fn assert_block_local_beta_direction(
155 states: &[ParameterBlockState],
156 block_index: usize,
157 direction: &Array1<f64>,
158 context: &str,
159) {
160 assert!(
161 block_index < states.len(),
162 "{context}: block index {block_index} out of range for {} blocks",
163 states.len()
164 );
165 assert_eq!(
166 direction.len(),
167 states[block_index].beta.len(),
168 "{context}: direction is not in block {block_index}'s coefficient space"
169 );
170 assert!(
171 direction.iter().all(|v| !v.is_nan()),
172 "{context}: NaN entry in block {block_index} coefficient direction"
173 );
174}
175
176/// Precondition check for the per-block hooks that take `(states, block_index,
177/// d_eta)` where the direction lives in the block's *predictor* space
178/// (`diagonalworking_weights_directional_derivative` and its second-order
179/// sibling): `dw` is indexed by row, so a coefficient-space vector slipped in
180/// here would silently produce a weight derivative of the wrong length.
181pub(crate) fn assert_block_local_eta_direction(
182 states: &[ParameterBlockState],
183 block_index: usize,
184 d_eta: &Array1<f64>,
185 context: &str,
186) {
187 assert!(
188 block_index < states.len(),
189 "{context}: block index {block_index} out of range for {} blocks",
190 states.len()
191 );
192 assert_eq!(
193 d_eta.len(),
194 states[block_index].eta.len(),
195 "{context}: direction is not in block {block_index}'s predictor space"
196 );
197 assert!(
198 d_eta.iter().all(|v| !v.is_nan()),
199 "{context}: NaN entry in block {block_index} predictor direction"
200 );
201}
202
203/// Precondition check for the psi hooks that name an outer coordinate by its
204/// global index into the hyper-layout: the index must resolve to a real axis,
205/// otherwise the family is being asked to differentiate a coordinate that the
206/// layout does not carry.
207pub(crate) fn assert_psi_index_in_layout(
208 hyper_layout: &CustomFamilyHyperLayout,
209 psi_index: usize,
210 context: &str,
211) {
212 assert!(
213 hyper_layout.axis(psi_index).is_some(),
214 "{context}: psi index {psi_index} is not an axis of a layout with {} coordinates",
215 hyper_layout.len()
216 );
217}
218
219pub(crate) fn assert_hyper_layout_matches_specs(
220 hyper_layout: &CustomFamilyHyperLayout,
221 specs: &[ParameterBlockSpec],
222 context: &str,
223) {
224 assert_eq!(
225 hyper_layout.block_count(),
226 specs.len(),
227 "{context}: hyper-layout/spec block count mismatch"
228 );
229}
230
231pub(crate) fn assert_rho_matches_specs(
232 rho: &Array1<f64>,
233 specs: &[ParameterBlockSpec],
234 context: &str,
235) {
236 let expected = specs.iter().map(|spec| spec.penalties.len()).sum::<usize>();
237 assert_eq!(
238 rho.len(),
239 expected,
240 "{context}: rho length does not match penalty count"
241 );
242}
243
244pub(crate) fn validate_hessian_workspace_ready(
245 hessian_workspace: &Option<Arc<dyn ExactNewtonJointHessianWorkspace>>,
246 context: &str,
247 eval_mode: gam_problem::EvalMode,
248) -> Result<(), String> {
249 if let Some(workspace) = hessian_workspace.as_ref() {
250 workspace
251 .warm_up_outer_caches_for_mode(eval_mode)
252 .map_err(|err| format!("{context}: failed to warm Hessian workspace caches: {err}"))?;
253 }
254 Ok(())
255}
256
257pub fn exact_outer_order_from_capability(
258 specs: &[ParameterBlockSpec],
259 coefficient_cost: u64,
260) -> ExactOuterDerivativeOrder {
261 assert_valid_blockspecs(specs, "exact outer derivative order");
262 match coefficient_cost {
263 0 => ExactOuterDerivativeOrder::Second,
264 _ => ExactOuterDerivativeOrder::Second,
265 }
266}
267
268/// Capability-aware variant of [`exact_outer_order_from_capability`].
269///
270/// Kept as the public declaration helper for existing family impls, but it no
271/// longer gates by cost. Once a caller has established dense or HVP analytic
272/// second-order support, the correct derivative order is `Second`.
273pub fn exact_outer_order_with_outer_hvp(
274 specs: &[ParameterBlockSpec],
275 coefficient_cost: u64,
276 outer_hyper_hessian_hvp_available: bool,
277) -> ExactOuterDerivativeOrder {
278 if outer_hyper_hessian_hvp_available {
279 assert_valid_blockspecs(specs, "exact outer derivative order with HVP");
280 match coefficient_cost {
281 0 => ExactOuterDerivativeOrder::Second,
282 _ => ExactOuterDerivativeOrder::Second,
283 }
284 } else {
285 exact_outer_order_from_capability(specs, coefficient_cost)
286 }
287}
288
289/// Realized outer-derivative policy at the current problem size.
290///
291/// Capability (the family can produce exact second-order calculus) controls
292/// whether the Hessian is declared. Runtime cost controls only representation
293/// and staging choices below this layer. Large problems must stay on the exact
294/// analytic Hessian path and use an operator representation when dense assembly
295/// is too expensive; they are not demoted to first-order BFGS here.
296///
297/// `OuterDerivativePolicy` records the family's *capability*, the *predicted
298/// per-eval cost* for both gradient-only and Hessian paths, and exposes the
299/// two policy queries the outer optimizer actually needs:
300///
301/// * [`order_for_evaluation`](Self::order_for_evaluation) — clamp a requested
302/// evaluation order against the policy gate.
303/// * [`declared_hessian_form`](Self::declared_hessian_form) — what shape the
304/// outer-strategy planner should declare to its plan ladder.
305/// * [`should_use_staged_kappa`](Self::should_use_staged_kappa) — auto-route
306/// the κ optimizer through the pilot/polish schedule at large `n`.
307///
308/// All thresholds are *const* — no env vars, no CLI flags. The cost model is
309/// the family's own `coefficient_gradient_cost` / `coefficient_hessian_cost`
310/// scaled by the joint outer-coordinate dimension, with `saturating_mul` so
311/// overflow rounds up to the budget ceiling rather than wrapping silently.
312#[derive(Clone, Copy, Debug)]
313pub struct OuterDerivativePolicy {
314 /// What exact calculus the family advertises in principle.
315 pub capability: ExactOuterDerivativeOrder,
316 /// Predicted per-eval work for one `ValueGradientHessian` evaluation.
317 /// Rounded conservatively *up* via `saturating_mul`. Informational for
318 /// representation and diagnostics; it does not disable Hessian capability.
319 pub predicted_hessian_work: u128,
320 /// Predicted per-eval work for one `ValueAndGradient` evaluation.
321 /// Rounded conservatively *up* via `saturating_mul`.
322 pub predicted_gradient_work: u128,
323 /// True when the family's outer-only paths consume
324 /// [`BlockwiseFitOptions::outer_score_subsample`] and produce
325 /// Horvitz-Thompson-weighted partial sums (i.e. the family overrides
326 /// `log_likelihood_only_with_options`,
327 /// `exact_newton_joint_psi_workspace_with_options`, and any other
328 /// outer-only hooks reached by `evaluate_custom_family_joint_hyper`).
329 ///
330 /// Determines whether the κ optimizer's pilot/polish staging schedule
331 /// engages: when this is `false`, [`Self::should_use_staged_kappa`]
332 /// returns `false` regardless of `n`. Engaging the schedule on a
333 /// family that ignores the subsample is strictly worse than not
334 /// engaging it — the schedule builds a `RowSet::Subsample` and the
335 /// boundary plumbing installs an `OuterScoreSubsample` on options,
336 /// but the family's default outer-only paths fall back to full-data
337 /// sums, so the pilot evaluation costs the same as the polish but
338 /// adds a Vec allocation per eval.
339 ///
340 /// Families that do **not** consume the subsample (default for new
341 /// implementations, including the GAMLSS location-scale families
342 /// today) leave this `false`. Families that do consume (today:
343 /// `BernoulliMarginalSlopeFamily`) override `outer_derivative_policy`
344 /// to set this `true`.
345 pub subsample_capable: bool,
346}
347
348impl OuterDerivativePolicy {
349 /// Per-eval gradient work ceiling above which the κ schedule switches
350 /// to the staged pilot/polish path. At large scale (n ≳ 100 k) even
351 /// the gradient sweep takes minutes per outer iter; subsampling the
352 /// pilot stage cuts that to seconds and leaves the final polish on
353 /// full data to recover the MLE.
354 pub const OUTER_GRADIENT_WORK_BUDGET: u128 = 50_000_000_000;
355
356 /// Pilot subsample auto-engages when full-data `n` exceeds this. Below
357 /// this the κ schedule collapses to a single full-data stage —
358 /// behaviour identical to the pre-P7 path.
359 pub const STAGED_KAPPA_TRIGGER_N: usize = 30_000;
360
361 /// Clamp a requested evaluation order against the policy gate.
362 ///
363 /// Returns the highest order this policy permits for the requested order:
364 /// * `ValueGradientHessian` requested → keep only if `declared_hessian_form`
365 /// is something other than `Unavailable`.
366 /// * `ValueAndGradient` requested → always permitted (gradient-only is
367 /// universal).
368 pub fn order_for_evaluation(&self, requested: crate::OuterEvalOrder) -> crate::OuterEvalOrder {
369 use crate::OuterEvalOrder;
370 match requested {
371 // Value-only is universal: every policy can evaluate the bare
372 // objective, so the request passes through unclamped.
373 OuterEvalOrder::Value => OuterEvalOrder::Value,
374 OuterEvalOrder::ValueAndGradient => OuterEvalOrder::ValueAndGradient,
375 OuterEvalOrder::ValueGradientHessian => {
376 if matches!(
377 self.declared_hessian_form(),
378 gam_problem::DeclaredHessianForm::Unavailable
379 ) {
380 OuterEvalOrder::ValueAndGradient
381 } else {
382 OuterEvalOrder::ValueGradientHessian
383 }
384 }
385 }
386 }
387
388 /// Outer Hessian declaration for the outer-strategy planner.
389 ///
390 /// `Either` ⇔ capability has Hessian. Work estimates select dense vs
391 /// operator assembly later; they must not erase analytic second-order
392 /// capability from the planner.
393 pub fn declared_hessian_form(&self) -> gam_problem::DeclaredHessianForm {
394 use gam_problem::DeclaredHessianForm;
395 if !self.capability.has_hessian() {
396 return DeclaredHessianForm::Unavailable;
397 }
398 DeclaredHessianForm::Either
399 }
400
401 /// True when the κ optimizer should auto-route through the staged
402 /// pilot/polish schedule. Triggers when **either** the data is big
403 /// (`n ≥ STAGED_KAPPA_TRIGGER_N`) **or** the per-eval gradient work
404 /// exceeds `OUTER_GRADIENT_WORK_BUDGET`. The second clause catches
405 /// problems with moderate `n` but very wide design (large `p_total`
406 /// or `psi_dim`) where a single full-data gradient sweep still
407 /// dominates the κ trajectory.
408 pub fn should_use_staged_kappa(&self, n: usize) -> bool {
409 if !self.subsample_capable {
410 // Family does not consume `outer_score_subsample` on its
411 // outer-only paths. Engaging the schedule would build a
412 // pilot `RowSet::Subsample` whose only effect is per-eval
413 // Vec/Arc bookkeeping — the underlying coefficient gradient
414 // would still sum every row. Gate the schedule off until
415 // the family override declares consumption.
416 return false;
417 }
418 n >= Self::STAGED_KAPPA_TRIGGER_N
419 || self.predicted_gradient_work > Self::OUTER_GRADIENT_WORK_BUDGET
420 }
421}
422
423/// Total outer-coordinate dimensionality used by the default policy work
424/// model: `rho_dim + psi_dim`. Each outer evaluation propagates one
425/// directional derivative per outer coordinate through the inner solve.
426#[inline]
427pub(crate) fn outer_coord_dim_for_policy(specs: &[ParameterBlockSpec], psi_dim: usize) -> u128 {
428 let rho_total: u128 = specs
429 .iter()
430 .map(|s| s.penalties.len() as u128)
431 .fold(0u128, |acc, k| acc.saturating_add(k));
432 rho_total.saturating_add(psi_dim as u128)
433}
434
435/// Default predicted-cost model for [`OuterDerivativePolicy`]:
436///
437/// * gradient work ≈ `coefficient_gradient_cost · (rho_dim + psi_dim)`
438/// * Hessian work ≈ `coefficient_hessian_cost · (rho_dim + psi_dim)`
439///
440/// Each outer coordinate triggers one analytic directional derivative
441/// through the inner solve; the dense Hessian assembly carries the extra
442/// `O(p_total)` factor already captured by `coefficient_hessian_cost`.
443///
444/// All multiplications saturate so an overflow rounds *up* to the gate
445/// ceiling: we'd rather drop one Hessian evaluation that we could have
446/// afforded than crash on a 600 s eval.
447pub fn default_outer_derivative_policy_costs(
448 specs: &[ParameterBlockSpec],
449 psi_dim: usize,
450 grad_cost: u64,
451 hess_cost: u64,
452) -> (u128, u128) {
453 let k = outer_coord_dim_for_policy(specs, psi_dim);
454 let grad = (grad_cost as u128).saturating_mul(k.max(1));
455 let hess = (hess_cost as u128).saturating_mul(k.max(1));
456 (grad, hess)
457}
458
459/// Default coefficient-space Hessian cost: `Σ_b n_b · p_b²`, summed across
460/// blocks. Represents the work to assemble or apply the dense block-diagonal
461/// inner Hessian once.
462pub fn default_coefficient_hessian_cost(specs: &[ParameterBlockSpec]) -> u64 {
463 specs
464 .iter()
465 .map(|s| {
466 let n = s.design.nrows() as u64;
467 let p = s.design.ncols() as u64;
468 n.saturating_mul(p.saturating_mul(p))
469 })
470 .fold(0u64, |acc, c| acc.saturating_add(c))
471}
472
473/// Joint-coupled coefficient-space Hessian cost: `n · (Σ_b p_b)²`. The honest
474/// per-evaluation work for any family whose row likelihood couples every block
475/// (every observation contributes a rank-`m` outer-product update to the full
476/// joint Hessian over `Σ p_b` coefficients), as opposed to the block-diagonal
477/// `default_coefficient_hessian_cost` which assumes each `X_b' W_b X_b` is
478/// assembled independently.
479///
480/// Used by all GAMLSS, marginal-slope, and joint-latent families. CTN does
481/// not delegate here — it uses its Khatri–Rao factor dimensions internally.
482pub fn joint_coupled_coefficient_hessian_cost(n: u64, specs: &[ParameterBlockSpec]) -> u64 {
483 let p_total: u64 = specs
484 .iter()
485 .map(|s| s.design.ncols() as u64)
486 .fold(0u64, |acc, p| acc.saturating_add(p));
487 n.saturating_mul(p_total.saturating_mul(p_total))
488}
489
490/// Default coefficient-space gradient cost: half the Hessian cost.
491///
492/// The first-order analytic gradient in the unified evaluator runs the same
493/// inner Newton solve as the second-order path but skips the `K`-fold
494/// pairwise Hessian assembly (`B_{j,k}` blocks) and the `K`-fold inner
495/// derivative solves; what remains is the inner solve plus a single
496/// gradient-only sweep through the data. Empirically this is roughly half
497/// the per-evaluation arithmetic of forming the dense Hessian, hence the
498/// `/2` default. Families whose gradient assembly differs structurally
499/// (e.g. matrix-free Hv operators with no dense Hessian assembly to halve)
500/// should override [`CustomFamily::coefficient_gradient_cost`] explicitly.
501pub fn default_coefficient_gradient_cost(specs: &[ParameterBlockSpec]) -> u64 {
502 default_coefficient_hessian_cost(specs) / 2
503}
504
505/// Compute β-block column ranges from a slice of `ParameterBlockSpec`s.
506///
507/// Returns one `Range<usize>` per spec, covering the spec's columns in the
508/// concatenated β vector (i.e. `offset .. offset + p_block` where `p_block =
509/// spec.design.ncols()`). The ranges are non-overlapping, sorted, and their
510/// union covers `0..Σ p_block`.
511///
512/// This is the canonical source of `block_offsets` for every
513/// `crate::solver::arrow_schur::ArrowSchurSystem` built for a custom family
514/// (survival, GAMLSS, transformation-normal, latent-survival, marginal-slope,
515/// …). Pass the result to
516/// `crate::solver::arrow_schur::ArrowSchurSystem::set_block_offsets` before
517/// calling `solve` or `solve_with_options` whenever the system will use
518/// `crate::solver::arrow_schur::ArrowSolverMode::InexactPCG`.
519///
520/// Specs with zero columns produce a zero-width range; callers that want to
521/// skip trivial blocks may filter on `r.start < r.end` after calling this
522/// function.
523pub fn block_offsets_from_specs(specs: &[ParameterBlockSpec]) -> Arc<[Range<usize>]> {
524 let mut ranges: Vec<Range<usize>> = Vec::with_capacity(specs.len());
525 let mut cursor = 0usize;
526 for spec in specs {
527 let p = spec.design.ncols();
528 ranges.push(cursor..cursor + p);
529 cursor += p;
530 }
531 Arc::from(ranges.into_boxed_slice())
532}
533
534/// Local trust budget for first-order outer BFGS on log-smoothing parameters.
535///
536/// One unit in `rho = log(lambda)` is an `e`-fold smoothing-parameter change.
537/// Previously this cap was `1.0`, which throttled BFGS to ~1/5 of its
538/// quasi-Newton step on flat REML surfaces (the natural BFGS direction has
539/// `|d|_inf` of ~5 in log-λ for large-scale survival fits). Probes whose
540/// `step_inf > cap` are rejected for free in `OuterFirstOrderBridge::eval_cost`
541/// (returning `BFGS_LINE_SEARCH_REJECT_COST` without running an inner solve),
542/// so a larger cap costs nothing on rejection — it only lets Strong-Wolfe
543/// accept bigger steps that the inner-PIRLS divergence guard can already
544/// validate. `5.0` allows up to `e^5 ≈ 148`-fold smoothing-parameter change
545/// per accepted outer iter, which matches the typical quasi-Newton direction
546/// magnitude while still bounding pathological probes.
547pub const FIRST_ORDER_BFGS_LOGLAMBDA_STEP_CAP: f64 = 5.0;
548
549pub fn exact_newton_outer_geometry_supports_second_order_solver<F: CustomFamily + ?Sized>(
550 family: &F,
551) -> bool {
552 family.exact_newton_outerobjective() == ExactNewtonOuterObjective::StrictPseudoLaplace
553}
554
555/// Stable public API for installing outer-score subsampling.
556#[derive(Clone)]
557pub struct BlockwiseFitOptions {
558 pub inner_max_cycles: usize,
559 pub inner_tol: f64,
560 pub outer_max_iter: usize,
561 pub outer_tol: f64,
562 /// Optional override for the OUTER smoothing optimizer's
563 /// *relative-cost-decrease* convergence stop, decoupled from `outer_tol`.
564 ///
565 /// The outer convergence test derives BOTH the absolute projected-gradient
566 /// floor (`max(outer_tol, n·1e-9)`) AND the relative-cost stop
567 /// (`rel_cost = outer_tol`) from the single `outer_tol`. A caller that needs
568 /// a *tight absolute floor* to resolve λ to the genuine REML optimum at
569 /// large `n` (where the floor is `n·1e-9`) is then forced to also accept a
570 /// *tight rel-cost stop*, which on a flat REML ridge never trips and grinds
571 /// the optimizer to `outer_max_iter` — dozens of surplus O(D·p³)
572 /// Laplace-derivative outer iterations (the #1082 multinomial
573 /// smooth-by-factor wall-clock blow-up). When `Some(r)`, the rel-cost stop
574 /// uses `r` while the absolute floor keeps using `outer_tol`, so accuracy
575 /// (absolute floor) and perf (loose rel-cost) are selected independently.
576 /// `None` preserves the legacy coupling (`rel_cost = outer_tol`) for every
577 /// existing caller byte-for-byte.
578 pub outer_rel_cost_tol: Option<f64>,
579 /// Lower box bound for smoothing coordinates ρ = log λ.
580 ///
581 /// The default preserves the historical custom-family domain
582 /// `λ >= exp(-10)`. Families with known calibration failures at the
583 /// near-zero penalty boundary can raise this lower bound without changing
584 /// the upper effective-df cap or adding family-specific branches inside the
585 /// optimizer.
586 pub rho_lower_bound: f64,
587 /// Optional seed for transient solver damping. The default is zero and the
588 /// default [`RidgePolicy`] excludes every damping shift from the quadratic
589 /// objective, penalty determinant, and Laplace Hessian. A nonzero value is
590 /// therefore a numerical step-control request unless a caller explicitly
591 /// selects an objective-including policy.
592 pub ridge_floor: f64,
593 /// Shared ridge semantics used by solve/quadratic/logdet terms. Defaults to
594 /// solver-only damping so the converged estimand is the stationary point of
595 /// the undamped statistical objective.
596 pub ridge_policy: RidgePolicy,
597 /// If true, outer smoothing optimization uses a Laplace/REML-style objective:
598 /// -loglik + penalty + 0.5(log|H| - log|S|_+)
599 /// where H is blockwise working curvature and S is blockwise penalty.
600 pub use_remlobjective: bool,
601 /// If false, the outer smoothing optimizer uses exact gradients but does
602 /// not request an analytic outer Hessian from the family.
603 pub use_outer_hessian: bool,
604 /// If false, skip post-fit joint covariance assembly.
605 pub compute_covariance: bool,
606 /// Shared cap engaged during seed screening so cost-only evaluations can
607 /// stop inner iterations early without affecting the full solve.
608 pub screening_max_inner_iterations: Option<Arc<AtomicUsize>>,
609 /// Shared cap engaged during regular outer iterations. Unlike screening,
610 /// this is only a budget: capped solves still have to earn the ordinary
611 /// KKT certificate before derivatives may be exposed.
612 pub outer_inner_max_iterations: Option<Arc<AtomicUsize>>,
613 /// Optional line-search objective ceiling for lazy log-likelihood-only
614 /// evaluations. Families whose per-row log-likelihood contributions are
615 /// non-positive may stop once the partial negative log-likelihood is already
616 /// above this ceiling, because the unvisited rows cannot improve the trial
617 /// objective enough to be accepted. Default `None` preserves exact full-sum
618 /// behavior and is the only mode used outside backtracking rejection tests.
619 pub early_exit_threshold: Option<f64>,
620 /// Stable public API for installing outer-score subsampling.
621 ///
622 /// Optional stratified row subsample used by outer-only score/gradient
623 /// passes. When `Some(s)`, outer score/gradient hot loops should iterate
624 /// only over `s.rows` and multiply each contribution by that row's
625 /// Horvitz-Thompson inverse-inclusion weight. Inner-PIRLS and final
626 /// covariance passes always run on the full data, so this field is
627 /// consulted only by outer-only call sites. Default `None` preserves the
628 /// full-data behavior. Wrapping in `Arc` keeps `Clone` cheap across the
629 /// many places `BlockwiseFitOptions` is duplicated per-eval.
630 pub outer_score_subsample: Option<Arc<crate::OuterScoreSubsample>>,
631 /// Gate for marginal-slope families to auto-derive a stratified
632 /// outer-score subsample at large scale (see
633 /// `crate::families::marginal_slope_shared::auto_outer_score_subsample`).
634 ///
635 /// **Default `true`.** Auto-subsampling makes the early rho-gradient
636 /// evaluations unbiased stochastic estimators with bounded relative
637 /// variance (≈ 1 % at the conservative defaults), then the family switches
638 /// back to full-data gradients for the remaining outer iterations. That
639 /// keeps large marginal-slope fits fast during the high-motion part of the
640 /// trajectory while preserving the default tight `outer_tol` polish on
641 /// exact gradients. For small datasets the auto path declines to install a
642 /// mask and the fit remains full-data throughout.
643 ///
644 /// When `outer_score_subsample` is already `Some(...)` the auto
645 /// path is bypassed entirely (caller-provided masks always win).
646 pub auto_outer_subsample: bool,
647 /// Outer-evaluation context populated by the smoothing optimizer at
648 /// the top of each real outer derivative evaluation. Used by
649 /// auto-subsample install paths to key the stratified mask on the
650 /// outer ρ rather than the inner β proxy: during the inner trust-
651 /// region / coefficient line search β changes on every trial step,
652 /// so keying on β re-fires phase prints (and re-shuffles the mask)
653 /// inside a single outer eval. Keying on (rho, eval_id) instead
654 /// keeps the mask stable across the inner Newton at one ρ, and
655 /// suppresses auto-subsample entirely on inner trial evaluations via
656 /// the `EvalScope::InnerCoefficient` tag set by
657 /// `coefficient_line_search_options`.
658 ///
659 /// `None` preserves legacy behavior (no context — install paths fall
660 /// back to "no auto-subsample"). Default `None`.
661 pub outer_eval_context: Option<OuterEvalContext>,
662 /// Optional persistent warm-start cache session. When `Some`, the
663 /// outer smoothing optimizer consults the on-disk cache before
664 /// starting (to seed θ from the last accepted iterate) and writes
665 /// checkpoints + a final entry on completion. When `None`, the fit
666 /// runs cold and writes nothing — the default for unit tests and
667 /// any caller that pinned a deterministic optimum.
668 ///
669 /// Callers that need cross-process reuse must supply the session
670 /// explicitly; ordinary workflow fits leave this empty so refit-heavy
671 /// loops do not touch the shared on-disk store.
672 pub cache_session: Option<Arc<gam_runtime::warm_start::Session>>,
673 /// Explicit fit-owned cross-process store. Unlike `cache_session`, which is
674 /// one caller-keyed outer-iterate stream, this capability owns the shared
675 /// response-keyed record and descriptor-keyed artifact namespaces too.
676 ///
677 /// `None` is disk-silent. The high-level configuration constructs this
678 /// lazily from an explicit root; no custom-family fit discovers ambient
679 /// temp/cache state.
680 pub persistent_warm_start_store: Option<gam_runtime::warm_start::ConfiguredWarmStartStore>,
681 /// Optional mirror sessions that receive a copy of the final-result
682 /// finalize() write. Callers can use this to broadcast a converged ρ to
683 /// additional keyspace(s) so future fits with related structure can
684 /// warm-start from this run. Writes still pass through the session rate
685 /// limiter, so mirroring checkpoints does not add unbounded I/O.
686 pub cache_mirror_sessions: Vec<Arc<gam_runtime::warm_start::Session>>,
687 /// Optional bundle of cross-block (full-width) penalties, paired with
688 /// their current `log λ` values from the outer ρ vector. When `Some`,
689 /// the inner joint-Newton primitives add the contributions
690 ///
691 /// * objective: `½ Σ_j exp(ρ_j) βᵀ S_j β`
692 /// * gradient: `Σ_j exp(ρ_j) S_j β`
693 /// * Hessian: `Σ_j exp(ρ_j) S_j`
694 ///
695 /// in addition to the per-block penalty stack assembled from
696 /// `ParameterBlockSpec.penalties`. The per-block path is unchanged.
697 /// `None` preserves legacy behaviour for every existing caller.
698 pub joint_penalties: Option<Arc<crate::JointPenaltyBundle>>,
699 /// Precision labels whose per-block penalty components are INDEPENDENT
700 /// Gaussian prior factors (the hierarchical coefficient-group priors from
701 /// `realize_coefficient_groups_for_custom_family`; copy its
702 /// `independent_prior_factor_labels` here), as opposed to additive pieces
703 /// of one Gaussian smooth prior.
704 ///
705 /// The distinction matters only for the evidence normalizer. A
706 /// multi-penalty smooth is ONE Gaussian with precision `Σ_k λ_k S_k`, so
707 /// its normalizer is the coalesced `½ log|Σ_k λ_k S_k|₊`. A product of
708 /// independent group factors `∏_k N(0, (λ_k S_k)⁻¹)` instead contributes
709 /// `Σ_k ½ (rank S_k · log λ_k + log|S_k|₊)` — and the two disagree
710 /// exactly when factors overlap (two factors with precision λ on one
711 /// scalar coefficient carry `λ^{1/2}·λ^{1/2} = λ`, but the coalesced form
712 /// `½ log(2λ)` loses `½ log λ` up to constants), which biases the outer
713 /// ρ-posterior and the hierarchical Gamma precision exponent. Labels
714 /// listed here get the per-factor normalizer in the outer objective.
715 ///
716 /// **Default empty** — every penalty coalesces per block, the correct
717 /// convention for ordinary (tensor/multi-penalty) smooths.
718 pub independent_prior_factor_labels: Vec<String>,
719 /// Whether the outer smoothing optimizer screens the explicit
720 /// `initial_rho` seed through the seed-screening cascade before the
721 /// solver starts.
722 ///
723 /// **Default `true`** — the general path benefits from ranking the
724 /// initial seed against the generated exploration seeds via cheap
725 /// capped proxy fits.
726 ///
727 /// A caller sets this `false` when `initial_rho` is already the correct,
728 /// identified optimum for its regime so that re-screening it adds only
729 /// cost. The survival location-scale constant-scale (parametric-AFT)
730 /// path uses this: its time-warp ρ seed is pinned AT the inner ρ box
731 /// bound (the affine-baseline limit), where the REML/LAML profile is a
732 /// dead-flat unidentified ridge. Running the screening cascade there
733 /// drives each proxy fit (and, when every capped stage collapses to
734 /// non-finite cost, the uncapped final stage) into a full inner solve on
735 /// the near-singular flat Hessian — the source of the multi-minute
736 /// no-iteration-log stall (#736, #735, #721). Skipping screening lets the
737 /// already-correct seed flow straight to the outer solver, which certifies
738 /// box-constraint stationarity at iteration 0. Genuinely flexible regimes
739 /// (smooth scale / spatial) leave this `true` and keep full screening.
740 pub screen_initial_rho: bool,
741 /// Set ONLY while the inner solve is invoked from the seed-screening proxy
742 /// (`custom_family_seed_screening_proxy_labeled`), which RANKS candidate
743 /// seeds by their penalized objective and never produces the final fit.
744 ///
745 /// When `true`, the inner joint-Newton skips the full per-axis
746 /// Jeffreys/Firth curvature (`custom_family_joint_jeffreys_term`'s
747 /// `for k in 0..p` directional-derivative loop, O(p · per-axis-Hdot) per
748 /// cycle), keeping ONLY the cheap value-only Jeffreys term
749 /// (`custom_family_joint_jeffreys_value`, one reduced-info eigendecomposition)
750 /// in the screening score. The per-axis gradient/curvature is what the inner
751 /// Newton step needs to *converge* a near-separating fit; the screening proxy
752 /// is capped and only ranks, so it does not need step convergence — it needs
753 /// a finite, separation-aware score cheaply. For a K-block coupled family
754 /// (Dirichlet/multinomial) each per-axis directional derivative is itself
755 /// O(K²·n·p), so running the full term for every cascade candidate over the
756 /// joint width `p` is the wrong cost class and made the coupled fit
757 /// non-completing during screening alone (gam#729/#808). The actual fit
758 /// (after a seed is selected) runs with this `false`, so the load-bearing
759 /// Firth curvature is fully present where it matters.
760 ///
761 /// **Default `false`** — only the screening proxy sets it `true`.
762 pub seed_screening: bool,
763}
764
765pub const DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES: usize = 1200;
766
767impl Default for BlockwiseFitOptions {
768 fn default() -> Self {
769 Self {
770 // Large-scale custom-family marginal-slope fits can have a
771 // long, monotone joint-Newton tail: objective and step size keep
772 // shrinking, but the exact KKT residual may need several hundred
773 // additional cycles after the old 300-cycle cap. The outer
774 // REML/LAML derivative path is correct only at a stationary inner
775 // mode, so a merely descended iterate must not be accepted as
776 // converged. Use a production-sized cap by default and rely on the
777 // KKT/objective certificates to exit early for well-conditioned
778 // Gaussian, logistic, and small-n fits.
779 inner_max_cycles: DEFAULT_CUSTOM_FAMILY_INNER_MAX_CYCLES,
780 inner_tol: 1e-6,
781 outer_max_iter: 60,
782 outer_tol: 1e-5,
783 outer_rel_cost_tol: None,
784 rho_lower_bound: -10.0,
785 // Conditioning is solver state, not a coefficient prior. Start at
786 // the exact Hessian (zero shift); rank/curvature-aware damping may
787 // regularize rejected Newton steps, but none of it enters the
788 // objective or its derivatives and convergence is certified on the
789 // undamped KKT residual.
790 ridge_floor: 0.0,
791 ridge_policy: RidgePolicy::solver_only(),
792 use_remlobjective: true,
793 // Default ON: families expose exact outer Hessians whenever their
794 // analytic dense or operator representation is implemented.
795 use_outer_hessian: true,
796 compute_covariance: false,
797 screening_max_inner_iterations: None,
798 outer_inner_max_iterations: None,
799 seed_screening: false,
800 early_exit_threshold: None,
801 outer_score_subsample: None,
802 auto_outer_subsample: true,
803 outer_eval_context: None,
804 cache_session: None,
805 persistent_warm_start_store: None,
806 cache_mirror_sessions: Vec::new(),
807 joint_penalties: None,
808 independent_prior_factor_labels: Vec::new(),
809 screen_initial_rho: true,
810 }
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817 use gam_linalg::matrix::DesignMatrix;
818 use ndarray::Array2;
819
820 fn make_spec(nrows: usize, ncols: usize) -> ParameterBlockSpec {
821 ParameterBlockSpec {
822 design: DesignMatrix::from(Array2::<f64>::zeros((nrows, ncols))),
823 ..ParameterBlockSpec::defaults()
824 }
825 }
826
827 // -----------------------------------------------------------------------
828 // default_coefficient_hessian_cost
829 // -----------------------------------------------------------------------
830
831 #[test]
832 fn hessian_cost_empty_specs_is_zero() {
833 assert_eq!(default_coefficient_hessian_cost(&[]), 0);
834 }
835
836 #[test]
837 fn hessian_cost_single_block() {
838 // n=10, p=3 → 10 * 3^2 = 90
839 let spec = make_spec(10, 3);
840 assert_eq!(default_coefficient_hessian_cost(&[spec]), 90);
841 }
842
843 #[test]
844 fn hessian_cost_two_blocks_sum() {
845 // n=10, p=3 → 90; n=5, p=4 → 5*16=80; total=170
846 let specs = [make_spec(10, 3), make_spec(5, 4)];
847 assert_eq!(default_coefficient_hessian_cost(&specs), 170);
848 }
849
850 // -----------------------------------------------------------------------
851 // default_coefficient_gradient_cost
852 // -----------------------------------------------------------------------
853
854 #[test]
855 fn gradient_cost_is_half_hessian_cost() {
856 let specs = [make_spec(10, 3)];
857 let hess = default_coefficient_hessian_cost(&specs);
858 assert_eq!(default_coefficient_gradient_cost(&specs), hess / 2);
859 }
860
861 // -----------------------------------------------------------------------
862 // joint_coupled_coefficient_hessian_cost
863 // -----------------------------------------------------------------------
864
865 #[test]
866 fn joint_coupled_cost_empty_specs_is_zero() {
867 assert_eq!(joint_coupled_coefficient_hessian_cost(100, &[]), 0);
868 }
869
870 #[test]
871 fn joint_coupled_cost_two_blocks() {
872 // n=10, p_total = 3+4=7 → 10 * 49 = 490
873 let specs = [make_spec(99, 3), make_spec(99, 4)];
874 assert_eq!(joint_coupled_coefficient_hessian_cost(10, &specs), 490);
875 }
876
877 // -----------------------------------------------------------------------
878 // block_offsets_from_specs
879 // -----------------------------------------------------------------------
880
881 #[test]
882 fn block_offsets_empty_is_empty() {
883 let offsets = block_offsets_from_specs(&[]);
884 assert_eq!(offsets.len(), 0);
885 }
886
887 #[test]
888 fn block_offsets_three_blocks() {
889 // p = [2, 3, 1] → [0..2, 2..5, 5..6]
890 let specs = [make_spec(1, 2), make_spec(1, 3), make_spec(1, 1)];
891 let offsets = block_offsets_from_specs(&specs);
892 assert_eq!(&offsets[0], &(0..2));
893 assert_eq!(&offsets[1], &(2..5));
894 assert_eq!(&offsets[2], &(5..6));
895 }
896
897 #[test]
898 fn block_offsets_zero_width_block() {
899 // p = [2, 0, 1] → [0..2, 2..2, 2..3]
900 let specs = [make_spec(1, 2), make_spec(1, 0), make_spec(1, 1)];
901 let offsets = block_offsets_from_specs(&specs);
902 assert_eq!(&offsets[0], &(0..2));
903 assert_eq!(&offsets[1], &(2..2));
904 assert_eq!(&offsets[2], &(2..3));
905 }
906
907 #[test]
908 fn default_custom_family_objective_is_coefficient_ridge_free() {
909 let options = BlockwiseFitOptions::default();
910 assert_eq!(options.ridge_floor, 0.0);
911 assert!(!options.ridge_policy.accounts_for_objective());
912 }
913}