Skip to main content

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