Skip to main content

gam_problem/
solver_contract.rs

1//! # Outer-objective contract (lower shared layer)
2//!
3//! The interface types that the `families` layer must *name, implement, and
4//! return* to participate in outer smoothing-parameter optimization, hosted
5//! below both `families` and `solver` so families stop importing *up* into
6//! `crate::solver::rho_optimizer` (#1135).
7//!
8//! What lives here is exactly the **family ↔ solver contract**: the matrix-free
9//! [`OuterHessianOperator`] trait that families implement, the [`OuterEval`] /
10//! [`HessianResult`] result types they return, the [`EfsEval`] step bundle, and
11//! the capability enums ([`Derivative`], [`DeclaredHessianForm`],
12//! [`OuterHessianMaterialization`]) plus the operator-shape error
13//! ([`OuterStrategyError`]).
14//!
15//! What does *not* live here is the solver's *use* of the contract — the outer
16//! runner, ARC/trust-region planning, seeding, caching, barrier configuration,
17//! and `OuterProblem` — all of which stay in `crate::solver::rho_optimizer` and
18//! depend downward on this module. `crate::solver::rho_optimizer` re-exports
19//! these names so existing `crate::solver::rho_optimizer::*` paths keep working.
20
21use std::sync::Arc;
22
23use ndarray::{Array1, Array2, ArrayView2};
24
25/// Exact dense-materialization route exposed by an outer Hessian operator.
26///
27/// The optimizer uses this as a work-model contract before turning a
28/// matrix-free analytic Hessian into a dense ARC model. `Unavailable` means
29/// callers must stay matrix-free; the remaining variants are all analytic
30/// but differ in how much per-column HVP overhead they imply.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum OuterHessianMaterialization {
33    /// Dense materialization is not part of this operator's contract.
34    Unavailable,
35    /// Materialization is exact but implemented by cheap repeated HVP probes.
36    RepeatedHvp,
37    /// Materialization is exact and can apply many HVP directions together.
38    BatchedHvp,
39    /// Materialization is exact and can be assembled without basis probing.
40    Explicit,
41}
42
43impl OuterHessianMaterialization {
44    pub fn is_available(self) -> bool {
45        !matches!(self, Self::Unavailable)
46    }
47}
48
49/// Typed error for the outer-strategy Hessian-operator surface.
50///
51/// All construction sites inside `outer_strategy` build one of these variants
52/// instead of an ad-hoc `String`; the historical `Result<_, String>` boundary
53/// on the [`OuterHessianOperator`] trait (which has out-of-crate implementors
54/// in `families/*` and `solver/reml/*`) is preserved by an explicit
55/// `OuterStrategyError -> String` conversion at the leaf return points.
56#[derive(Debug, Clone)]
57pub enum OuterStrategyError {
58    /// Length / shape mismatch raised by an outer Hessian operator
59    /// (matvec input/output length, `mul_mat` factor row count,
60    /// `materialize_dense` output dimensions, etc.).
61    OperatorShape { reason: String },
62    /// Dense materialization produced non-finite entries.
63    NonFiniteHessian { reason: String },
64    /// Shape / dimension violation of a rho-block additive Hessian update.
65    RhoBlockShape { reason: String },
66}
67
68impl_reason_error_boilerplate! {
69    OuterStrategyError {
70        OperatorShape,
71        NonFiniteHessian,
72        RhoBlockShape,
73    }
74}
75
76/// Matrix-free outer Hessian operator.
77///
78/// This is the exact outer Hessian action `H_outer * v` evaluated at the
79/// current outer point, without requiring dense materialization.
80///
81/// The trait provides four increasingly materialized primitives:
82///
83/// - [`matvec`](Self::matvec) — single column, the only one implementors must
84///   provide.
85/// - [`mul_mat`](Self::mul_mat) — multi-column; the default falls back to
86///   column-by-column `matvec`. Implementors override this when they can
87///   amortize per-Hv-apply overhead (cached factorizations, parallel matvecs)
88///   across many right-hand-sides.
89/// - [`materialization_capability`](Self::materialization_capability) — an
90///   explicit work-model contract that tells ARC whether dense exact
91///   materialization is unavailable, cheap repeated-HVP, batched-HVP, or
92///   explicit.
93/// - [`materialize_dense`](Self::materialize_dense) — the special case
94///   `mul_mat(I_dim)` followed by a symmetric average of the off-diagonals to
95///   absorb round-off asymmetry. ARC callers only use this when
96///   [`materialization_capability`](Self::materialization_capability) advertises
97///   an exact dense route, preserving the no-numerical-Hessian policy.
98pub trait OuterHessianOperator: Send + Sync {
99    fn dim(&self) -> usize;
100    fn matvec(&self, v: &Array1<f64>) -> Result<Array1<f64>, String>;
101
102    /// Write `out <- H * v` into a caller-supplied buffer. Default
103    /// impl wraps `matvec` and copies; backends override for a true
104    /// zero-alloc inner-CG path. The matrix-free trust-region adapter
105    /// (`OuterToOptHessianOperator`) calls this on every CG step
106    /// inside `opt::MatrixFreeTrustRegion`, so an override compounds:
107    /// over a 50-outer-iter × 30-CG-iter solve at n=200 the default
108    /// path allocates 1500 transient `Array1<f64>` of size 200 that
109    /// the override eliminates.
110    fn apply_into(&self, v: &Array1<f64>, out: &mut Array1<f64>) -> Result<(), String> {
111        let result = self.matvec(v)?;
112        if result.len() != out.len() {
113            return Err(format!(
114                "outer Hessian operator matvec produced length {} but expected {}",
115                result.len(),
116                out.len()
117            ));
118        }
119        out.assign(&result);
120        Ok(())
121    }
122
123    /// Whether probing all basis columns is cheap enough for dense ARC.
124    ///
125    /// The default is deliberately conservative. For operator-backed Duchon,
126    /// CTN, survival, or other row-streaming kernels, `dim <= 64` does not
127    /// imply cheap materialization: each column may trigger a full data pass.
128    ///
129    /// New implementations should prefer overriding
130    /// [`materialization_capability`](Self::materialization_capability) so the
131    /// caller can distinguish cheap repeated probes from true batched/explicit
132    /// Hessian materialization.
133    fn is_cheap_to_materialize(&self) -> bool {
134        false
135    }
136
137    /// Exact dense-materialization capability for this operator.
138    ///
139    /// The default preserves the historical work-model hook: operators that
140    /// already opted into cheap probing via
141    /// [`is_cheap_to_materialize`](Self::is_cheap_to_materialize) are treated
142    /// as exact repeated-HVP materializers. Backends that can amortize or avoid
143    /// basis probes should override this to return
144    /// [`OuterHessianMaterialization::BatchedHvp`] or
145    /// [`OuterHessianMaterialization::Explicit`].
146    fn materialization_capability(&self) -> OuterHessianMaterialization {
147        if self.is_cheap_to_materialize() {
148            OuterHessianMaterialization::RepeatedHvp
149        } else {
150            OuterHessianMaterialization::Unavailable
151        }
152    }
153
154    /// Apply the operator to all `m` columns of `factor`, returning a
155    /// `dim × m` matrix whose `j`th column is `H · factor[:, j]`.
156    ///
157    /// The default implementation runs the per-column matvecs in parallel
158    /// over rayon — each matvec is independent and the K×K basis-probe used
159    /// by [`materialize_dense`](Self::materialize_dense) issues exactly `dim`
160    /// such calls.  Implementors override when batching is cheaper (cached
161    /// factorizations, BLAS-3 kernels). All
162    /// [`materialize_dense`](Self::materialize_dense) callers route through
163    /// this method, so an override automatically accelerates any
164    /// work-model-approved materialization path used by the planner.
165    fn mul_mat(&self, factor: ArrayView2<'_, f64>) -> Result<Array2<f64>, String> {
166        use rayon::iter::{IntoParallelIterator, ParallelIterator};
167        let dim = self.dim();
168        if factor.nrows() != dim {
169            return Err(OuterStrategyError::OperatorShape {
170                reason: format!(
171                    "outer Hessian operator factor row count mismatch: got {}, expected {}",
172                    factor.nrows(),
173                    dim
174                ),
175            }
176            .into());
177        }
178        let m = factor.ncols();
179        let cols: Result<Vec<Array1<f64>>, String> = (0..m)
180            .into_par_iter()
181            .map(|j| {
182                let col = factor.column(j).to_owned();
183                let hv = self.matvec(&col)?;
184                if hv.len() != dim {
185                    return Err(OuterStrategyError::OperatorShape {
186                        reason: format!(
187                            "outer Hessian operator matvec length mismatch: got {}, expected {}",
188                            hv.len(),
189                            dim
190                        ),
191                    }
192                    .into());
193                }
194                Ok(hv)
195            })
196            .collect();
197        let cols = cols?;
198        let mut out = Array2::<f64>::zeros((dim, m));
199        for (j, hv) in cols.into_iter().enumerate() {
200            out.column_mut(j).assign(&hv);
201        }
202        Ok(out)
203    }
204
205    /// Materialize the outer Hessian into a dense `dim × dim` matrix by
206    /// applying the operator to the identity in a single
207    /// [`mul_mat`](Self::mul_mat) call, then averaging the off-diagonals to
208    /// stabilize against round-off asymmetry.
209    fn materialize_dense(&self) -> Result<Array2<f64>, String> {
210        let dim = self.dim();
211        let identity = Array2::<f64>::eye(dim);
212        let mut dense = self.mul_mat(identity.view())?;
213        if dense.nrows() != dim || dense.ncols() != dim {
214            return Err(OuterStrategyError::OperatorShape {
215                reason: format!(
216                    "outer Hessian operator mul_mat returned {}x{}, expected {}x{}",
217                    dense.nrows(),
218                    dense.ncols(),
219                    dim,
220                    dim
221                ),
222            }
223            .into());
224        }
225        for row in 0..dim {
226            for col in (row + 1)..dim {
227                let sym = 0.5 * (dense[[row, col]] + dense[[col, row]]);
228                dense[[row, col]] = sym;
229                dense[[col, row]] = sym;
230            }
231        }
232        if !dense.iter().all(|value| value.is_finite()) {
233            return Err(OuterStrategyError::NonFiniteHessian {
234                reason: "outer Hessian dense materialization produced non-finite entries"
235                    .to_string(),
236            }
237            .into());
238        }
239        Ok(dense)
240    }
241}
242
243/// Whether an analytic derivative is available for a given order.
244#[derive(Clone, Copy, Debug, PartialEq, Eq)]
245pub enum Derivative {
246    /// Exact analytic derivative implemented and available.
247    Analytic,
248    /// No analytic derivative; must be approximated or skipped.
249    Unavailable,
250}
251
252/// Capability-time declaration of what shape the outer Hessian takes.
253/// Replaces the binary `Derivative` for the Hessian field on
254/// `OuterCapability`: callers that know the shape upfront declare
255/// it here, and the planner routes between dense ARC and matrix-free
256/// trust-region *before* seed evaluation rather than dynamically
257/// branching on `seed_eval.hessian` at runtime.
258///
259/// Variants:
260/// - `Dense`: the family always returns `HessianResult::Analytic(_)`.
261///   The planner picks dense ARC; matrix-free TR is never engaged.
262/// - `Operator { materialization, estimated_materialization_cost }`:
263///   the family always returns `HessianResult::Operator(_)`. The
264///   planner picks matrix-free TR unless `materialization` advertises
265///   `Explicit`/`BatchedHvp` cheaply enough that materializing once
266///   per outer iter (opt 0.4.2 `with_materialize_when_cheap`) wins.
267///   `estimated_materialization_cost` is reserved for a future cost
268///   model; today it is purely informational.
269/// - `Either`: the family may return either shape; the runner inspects
270///   the seed eval and locks the route then. This is the historical
271///   default for code paths where `Derivative::Analytic` made the
272///   declaration and the seed loop branched on `seed_eval.hessian`.
273/// - `Unavailable`: no analytic Hessian. The planner picks BFGS / EFS
274///   per the gradient declaration and the rest of the capability.
275#[derive(Clone, Copy, Debug, PartialEq)]
276pub enum DeclaredHessianForm {
277    Dense,
278    Operator {
279        materialization: OuterHessianMaterialization,
280        estimated_materialization_cost: Option<f64>,
281    },
282    Either,
283    Unavailable,
284}
285
286impl DeclaredHessianForm {
287    /// Coarse "is an analytic Hessian declared?" projection. `true`
288    /// for `Dense` / `Operator` / `Either`; `false` for `Unavailable`.
289    /// Used by `plan` to keep the existing `Derivative`-based match
290    /// arms while richer routing decisions consult the form directly.
291    pub const fn is_analytic(self) -> bool {
292        !matches!(self, DeclaredHessianForm::Unavailable)
293    }
294
295    /// True when the declaration commits to a matrix-free path.
296    pub const fn is_operator_only(self) -> bool {
297        matches!(self, DeclaredHessianForm::Operator { .. })
298    }
299
300    /// True when the declaration commits to a dense path.
301    pub const fn is_dense_only(self) -> bool {
302        matches!(self, DeclaredHessianForm::Dense)
303    }
304}
305
306/// Shared outer-objective result used by optimizer-facing objective
307/// implementations.
308pub struct OuterEval {
309    pub cost: f64,
310    pub gradient: Array1<f64>,
311    pub hessian: HessianResult,
312    /// Optional inner-solver iterate at this rho. Families whose inner solve
313    /// produces a PIRLS beta populate this so the persistent-cache layer can
314    /// store `(rho, beta)` together.
315    pub inner_beta_hint: Option<Array1<f64>>,
316}
317
318impl OuterEval {
319    /// Conventional representation of an infeasible trial point.
320    pub fn infeasible(n_params: usize) -> Self {
321        Self {
322            cost: f64::INFINITY,
323            gradient: Array1::zeros(n_params),
324            hessian: HessianResult::Unavailable,
325            inner_beta_hint: None,
326        }
327    }
328
329    pub fn value_only(cost: f64, n_params: usize, inner_beta_hint: Option<Array1<f64>>) -> Self {
330        Self {
331            cost,
332            gradient: Array1::zeros(n_params),
333            hessian: HessianResult::Unavailable,
334            inner_beta_hint,
335        }
336    }
337}
338
339/// Explicit Hessian result replacing `Option<Array2<f64>>`.
340pub enum HessianResult {
341    /// Analytic Hessian was computed and returned.
342    Analytic(Array2<f64>),
343    /// Analytic Hessian is available as an exact Hessian-vector product.
344    Operator(Arc<dyn OuterHessianOperator>),
345    /// No analytic Hessian available for this model path.
346    Unavailable,
347}
348
349impl Clone for OuterEval {
350    fn clone(&self) -> Self {
351        Self {
352            cost: self.cost,
353            gradient: self.gradient.clone(),
354            hessian: self.hessian.clone(),
355            inner_beta_hint: self.inner_beta_hint.clone(),
356        }
357    }
358}
359
360impl std::fmt::Debug for OuterEval {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        f.debug_struct("OuterEval")
363            .field("cost", &self.cost)
364            .field("gradient", &self.gradient)
365            .field("hessian", &self.hessian)
366            .finish()
367    }
368}
369
370impl Clone for HessianResult {
371    fn clone(&self) -> Self {
372        match self {
373            Self::Analytic(h) => Self::Analytic(h.clone()),
374            Self::Operator(op) => Self::Operator(Arc::clone(op)),
375            Self::Unavailable => Self::Unavailable,
376        }
377    }
378}
379
380impl std::fmt::Debug for HessianResult {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        match self {
383            Self::Analytic(h) => f
384                .debug_tuple("Analytic")
385                .field(&format!("{}x{}", h.nrows(), h.ncols()))
386                .finish(),
387            Self::Operator(op) => f
388                .debug_tuple("Operator")
389                .field(&format!("dim={}", op.dim()))
390                .finish(),
391            Self::Unavailable => f.write_str("Unavailable"),
392        }
393    }
394}
395
396impl HessianResult {
397    /// Returns `true` if an analytic Hessian is present in any exact form.
398    pub fn is_analytic(&self) -> bool {
399        matches!(
400            self,
401            HessianResult::Analytic(_) | HessianResult::Operator(_)
402        )
403    }
404
405    pub fn dim(&self) -> Option<usize> {
406        match self {
407            HessianResult::Analytic(h) => Some(h.nrows()),
408            HessianResult::Operator(op) => Some(op.dim()),
409            HessianResult::Unavailable => None,
410        }
411    }
412
413    pub fn materialize_dense(&self) -> Result<Option<Array2<f64>>, String> {
414        match self {
415            HessianResult::Analytic(h) => Ok(Some(h.clone())),
416            HessianResult::Operator(op) => op.materialize_dense().map(Some),
417            HessianResult::Unavailable => Ok(None),
418        }
419    }
420}
421
422/// Result bundle returned by the EFS (extended Fellner–Schall) evaluation
423/// path. Pure data: families compute the additive step and the optional
424/// curvature/gradient diagnostics; the solver consumes them.
425#[derive(Clone, Debug)]
426pub struct EfsEval {
427    /// REML/LAML cost at the current rho (for convergence monitoring and
428    /// comparing candidates).
429    pub cost: f64,
430    /// Additive steps. Length = n_rho + n_ext_coords.
431    ///
432    /// For pure EFS: steps for non-penalty-like coordinates are 0.0.
433    /// For hybrid EFS: ρ-coords get standard EFS multiplicative steps,
434    /// ψ-coords get preconditioned gradient steps `Δψ = -α G⁺ g_ψ`.
435    pub steps: Vec<f64>,
436    /// Current coefficient vector β̂ from the inner P-IRLS solve.
437    /// Used by the EFS loop for the runtime barrier-curvature significance
438    /// check when monotonicity constraints are present.
439    pub beta: Option<Array1<f64>>,
440    /// Raw REML/LAML gradient restricted to the ψ block (design-moving coords).
441    ///
442    /// Present only when the hybrid EFS strategy is active. Used by the
443    /// outer iteration for backtracking on the ψ step: if the combined
444    /// (ρ-EFS, ψ-gradient) step does not decrease V(θ), the ψ step size
445    /// α is halved while keeping the ρ-EFS step fixed.
446    ///
447    /// This avoids re-evaluating the gradient during backtracking since
448    /// the gradient was already computed as part of the hybrid EFS eval.
449    pub psi_gradient: Option<Array1<f64>>,
450    /// Indices into the full θ vector that correspond to ψ (design-moving)
451    /// coordinates. Used by the backtracking logic to selectively scale
452    /// only the ψ portion of the step.
453    pub psi_indices: Option<Vec<usize>>,
454    /// Inner-Hessian curvature scale captured during the EFS eval, used to
455    /// condition the ψ preconditioner across outer iterations.
456    pub inner_hessian_scale: Option<f64>,
457    /// Logdet enclosure gap diagnostic (lower/upper bound spread) captured at
458    /// this EFS evaluation when the bounded-logdet path is active.
459    pub logdet_enclosure_gap: Option<f64>,
460}
461
462impl EfsEval {
463    pub fn with_logdet_enclosure_gap(mut self, gap: Option<f64>) -> Self {
464        self.logdet_enclosure_gap = gap;
465        self
466    }
467}