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//! [`HessianOperator`] trait that families implement, the [`OuterEval`] result
10//! they return, the [`EfsEval`] step bundle, and the capability enums
11//! ([`Derivative`], [`DeclaredHessianForm`], [`HessianMaterialization`]) plus
12//! GAM-specific outer-strategy errors ([`OuterStrategyError`]). The generic
13//! Hessian contract and payload are owned by `opt` and re-exported here.
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 ndarray::Array1;
22pub use opt::{HessianMaterialization, HessianOperator, HessianValue, ObjectiveEvalError};
23
24/// Typed error for the outer-strategy Hessian-operator surface.
25///
26/// All construction sites inside `outer_strategy` build one of these variants
27/// instead of an ad-hoc `String`; the historical `Result<_, String>` boundary
28/// at the family/solver boundary.
29#[derive(Debug, Clone)]
30pub enum OuterStrategyError {
31    /// Shape / dimension violation of a rho-block additive Hessian update.
32    RhoBlockShape { reason: String },
33}
34
35impl_reason_error_boilerplate! {
36    OuterStrategyError {
37        RhoBlockShape,
38    }
39}
40
41/// Whether an analytic derivative is available for a given order.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum Derivative {
44    /// Exact analytic derivative implemented and available.
45    Analytic,
46    /// No analytic derivative; must be approximated or skipped.
47    Unavailable,
48}
49
50/// Capability-time declaration of what shape the outer Hessian takes.
51/// Replaces the binary `Derivative` for the Hessian field on
52/// `OuterCapability`: callers that know the shape upfront declare
53/// it here, and the planner routes between dense ARC and matrix-free
54/// trust-region *before* seed evaluation rather than dynamically
55/// branching on `seed_eval.hessian` at runtime.
56///
57/// Variants:
58/// - `Dense`: the family always returns `HessianValue::Dense(_)`.
59///   The planner picks dense ARC; matrix-free TR is never engaged.
60/// - `Operator { materialization, estimated_materialization_cost }`:
61///   the family always returns `HessianValue::Operator(_)`. The
62///   planner picks matrix-free TR unless `materialization` advertises
63///   `Explicit`/`BatchedHvp` cheaply enough that materializing once
64///   per outer iter (opt 0.4.2 `with_materialize_when_cheap`) wins.
65///   `estimated_materialization_cost` is reserved for a future cost
66///   model; today it is purely informational.
67/// - `Either`: the family may return either shape; the runner inspects
68///   the seed eval and locks the route then. This is the historical
69///   default for code paths where `Derivative::Analytic` made the
70///   declaration and the seed loop branched on `seed_eval.hessian`.
71/// - `Unavailable`: no analytic Hessian. The planner picks BFGS / EFS
72///   per the gradient declaration and the rest of the capability.
73#[derive(Clone, Copy, Debug, PartialEq)]
74pub enum DeclaredHessianForm {
75    Dense,
76    Operator {
77        materialization: HessianMaterialization,
78        estimated_materialization_cost: Option<f64>,
79    },
80    Either,
81    Unavailable,
82}
83
84impl DeclaredHessianForm {
85    /// Coarse "is an analytic Hessian declared?" projection. `true`
86    /// for `Dense` / `Operator` / `Either`; `false` for `Unavailable`.
87    /// Used by `plan` to keep the existing `Derivative`-based match
88    /// arms while richer routing decisions consult the form directly.
89    pub const fn is_analytic(self) -> bool {
90        !matches!(self, DeclaredHessianForm::Unavailable)
91    }
92
93    /// True when the declaration commits to a matrix-free path.
94    pub const fn is_operator_only(self) -> bool {
95        matches!(self, DeclaredHessianForm::Operator { .. })
96    }
97
98    /// True when the declaration commits to a dense path.
99    pub const fn is_dense_only(self) -> bool {
100        matches!(self, DeclaredHessianForm::Dense)
101    }
102}
103
104/// Shared outer-objective result used by optimizer-facing objective
105/// implementations.
106pub struct OuterEval {
107    pub cost: f64,
108    pub gradient: Array1<f64>,
109    pub hessian: HessianValue,
110    /// Optional inner-solver iterate at this rho. Families whose inner solve
111    /// produces a PIRLS beta populate this so the persistent-cache layer can
112    /// store `(rho, beta)` together.
113    pub inner_beta_hint: Option<Array1<f64>>,
114}
115
116impl OuterEval {
117    /// Conventional representation of an infeasible trial point.
118    pub fn infeasible(n_params: usize) -> Self {
119        Self {
120            cost: f64::INFINITY,
121            gradient: Array1::zeros(n_params),
122            hessian: HessianValue::Unavailable,
123            inner_beta_hint: None,
124        }
125    }
126
127    pub fn value_only(cost: f64, n_params: usize, inner_beta_hint: Option<Array1<f64>>) -> Self {
128        Self {
129            cost,
130            gradient: Array1::zeros(n_params),
131            hessian: HessianValue::Unavailable,
132            inner_beta_hint,
133        }
134    }
135}
136
137impl Clone for OuterEval {
138    fn clone(&self) -> Self {
139        Self {
140            cost: self.cost,
141            gradient: self.gradient.clone(),
142            hessian: self.hessian.clone(),
143            inner_beta_hint: self.inner_beta_hint.clone(),
144        }
145    }
146}
147
148impl std::fmt::Debug for OuterEval {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("OuterEval")
151            .field("cost", &self.cost)
152            .field("gradient", &self.gradient)
153            .field("hessian", &self.hessian)
154            .finish()
155    }
156}
157
158/// Result bundle returned by the EFS (extended Fellner–Schall) evaluation
159/// path. Pure data: families compute the additive step and the optional
160/// curvature/gradient diagnostics; the solver consumes them.
161#[derive(Clone, Debug)]
162pub struct EfsEval {
163    /// REML/LAML cost at the current rho (for convergence monitoring and
164    /// comparing candidates).
165    pub cost: f64,
166    /// Additive steps. Length = n_rho + n_ext_coords.
167    ///
168    /// For pure EFS: steps for non-penalty-like coordinates are 0.0.
169    /// For hybrid EFS: ρ-coords get standard EFS multiplicative steps,
170    /// ψ-coords get preconditioned gradient steps `Δψ = -α G⁺ g_ψ`.
171    pub steps: Vec<f64>,
172    /// Current coefficient vector β̂ from the inner P-IRLS solve.
173    /// Used by the EFS loop for the runtime barrier-curvature significance
174    /// check when monotonicity constraints are present.
175    pub beta: Option<Array1<f64>>,
176    /// Raw REML/LAML gradient restricted to the ψ block (design-moving coords).
177    ///
178    /// Present only when the hybrid EFS strategy is active. Used by the
179    /// outer iteration for backtracking on the ψ step: if the combined
180    /// (ρ-EFS, ψ-gradient) step does not decrease V(θ), the ψ step size
181    /// α is halved while keeping the ρ-EFS step fixed.
182    ///
183    /// This avoids re-evaluating the gradient during backtracking since
184    /// the gradient was already computed as part of the hybrid EFS eval.
185    pub psi_gradient: Option<Array1<f64>>,
186    /// Indices into the full θ vector that correspond to ψ (design-moving)
187    /// coordinates. Used by the backtracking logic to selectively scale
188    /// only the ψ portion of the step.
189    pub psi_indices: Option<Vec<usize>>,
190    /// Inner-Hessian curvature scale captured during the EFS eval, used to
191    /// condition the ψ preconditioner across outer iterations.
192    pub inner_hessian_scale: Option<f64>,
193    /// Logdet enclosure gap diagnostic (lower/upper bound spread) captured at
194    /// this EFS evaluation when the bounded-logdet path is active.
195    pub logdet_enclosure_gap: Option<f64>,
196    /// Number of consecutive successful inner solves that returned to the same
197    /// banked incumbent after a non-monotone boundary mutation.
198    ///
199    /// `None` means the objective has no restored-incumbent certificate.  `Some`
200    /// is reset to zero whenever the objective banks a genuinely better model.
201    /// Two consecutive restorations are the minimal evidence of recurrence: one
202    /// restoration can be a one-off repair, while the second establishes that
203    /// changing the outer coordinate has returned to the same stationary fitted
204    /// state again.  Fixed-point runners may terminate on that certificate even
205    /// when the raw update keeps moving along an objective-flat parameter ridge.
206    pub consecutive_restored_incumbents: Option<usize>,
207}
208
209impl EfsEval {
210    pub fn with_logdet_enclosure_gap(mut self, gap: Option<f64>) -> Self {
211        self.logdet_enclosure_gap = gap;
212        self
213    }
214}
215
216/// One coordinate of an objective-supplied final fixed-point certificate.
217///
218/// `Covered` means the objective has an analytic update equation whose zero is
219/// equivalent to stationarity for this coordinate. `update` is the signed
220/// feasible-descent update in the coordinate's native parameterization and
221/// `scale` makes its residual dimensionless. A guarded zero, an unavailable
222/// trace, or a coordinate with no fixed-point equation must be `Uncovered`;
223/// representing any of those as `Covered { update: 0, .. }` would fabricate a
224/// convergence certificate.
225#[derive(Clone, Debug)]
226pub enum FixedPointCoordinateCertificate {
227    Covered { update: f64, scale: f64 },
228    Uncovered { reason: String },
229}
230
231impl FixedPointCoordinateCertificate {
232    pub fn covered(update: f64, scale: f64) -> Self {
233        Self::Covered { update, scale }
234    }
235
236    pub fn uncovered(reason: impl Into<String>) -> Self {
237        Self::Uncovered {
238            reason: reason.into(),
239        }
240    }
241}
242
243/// Objective-owned proof sample used only for final fixed-point certification.
244///
245/// This is intentionally separate from [`EfsEval`]. The iteration step may be
246/// zero because a guard held or an update is undefined; only this explicit hook
247/// may assert that every coordinate carries a root-equivalent analytic residual.
248#[derive(Clone, Debug)]
249pub struct FixedPointCertificateEval {
250    pub cost: f64,
251    pub coordinates: Vec<FixedPointCoordinateCertificate>,
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    // ── DeclaredHessianForm ───────────────────────────────────────────────────
259
260    #[test]
261    fn declared_unavailable_is_not_analytic() {
262        assert!(!DeclaredHessianForm::Unavailable.is_analytic());
263    }
264
265    #[test]
266    fn declared_dense_is_analytic() {
267        assert!(DeclaredHessianForm::Dense.is_analytic());
268    }
269
270    #[test]
271    fn declared_either_is_analytic() {
272        assert!(DeclaredHessianForm::Either.is_analytic());
273    }
274
275    #[test]
276    fn declared_operator_is_analytic() {
277        let form = DeclaredHessianForm::Operator {
278            materialization: HessianMaterialization::Explicit,
279            estimated_materialization_cost: None,
280        };
281        assert!(form.is_analytic());
282    }
283
284    #[test]
285    fn only_operator_variant_is_operator_only() {
286        let form = DeclaredHessianForm::Operator {
287            materialization: HessianMaterialization::RepeatedHvp,
288            estimated_materialization_cost: Some(1.0),
289        };
290        assert!(form.is_operator_only());
291        assert!(!DeclaredHessianForm::Dense.is_operator_only());
292        assert!(!DeclaredHessianForm::Either.is_operator_only());
293        assert!(!DeclaredHessianForm::Unavailable.is_operator_only());
294    }
295
296    #[test]
297    fn only_dense_variant_is_dense_only() {
298        assert!(DeclaredHessianForm::Dense.is_dense_only());
299        assert!(!DeclaredHessianForm::Either.is_dense_only());
300        assert!(!DeclaredHessianForm::Unavailable.is_dense_only());
301    }
302
303    // ── OuterEval ─────────────────────────────────────────────────────────────
304
305    #[test]
306    fn infeasible_eval_has_infinity_cost() {
307        let eval = OuterEval::infeasible(3);
308        assert_eq!(eval.cost, f64::INFINITY);
309        assert_eq!(eval.gradient.len(), 3);
310    }
311
312    #[test]
313    fn value_only_eval_has_specified_cost() {
314        let eval = OuterEval::value_only(42.5, 2, None);
315        assert_eq!(eval.cost, 42.5);
316        assert_eq!(eval.gradient.len(), 2);
317        assert!(eval.inner_beta_hint.is_none());
318    }
319}