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}
94
95/// Shared outer-objective result used by optimizer-facing objective
96/// implementations.
97pub struct OuterEval {
98    pub cost: f64,
99    pub gradient: Array1<f64>,
100    pub hessian: HessianValue,
101    /// Optional inner-solver iterate at this rho. Families whose inner solve
102    /// produces a PIRLS beta populate this so the persistent-cache layer can
103    /// store `(rho, beta)` together.
104    pub inner_beta_hint: Option<Array1<f64>>,
105}
106
107impl OuterEval {
108    /// Conventional representation of an infeasible trial point.
109    pub fn infeasible(n_params: usize) -> Self {
110        Self {
111            cost: f64::INFINITY,
112            gradient: Array1::zeros(n_params),
113            hessian: HessianValue::Unavailable,
114            inner_beta_hint: None,
115        }
116    }
117
118    pub fn value_only(cost: f64, n_params: usize, inner_beta_hint: Option<Array1<f64>>) -> Self {
119        Self {
120            cost,
121            gradient: Array1::zeros(n_params),
122            hessian: HessianValue::Unavailable,
123            inner_beta_hint,
124        }
125    }
126}
127
128impl Clone for OuterEval {
129    fn clone(&self) -> Self {
130        Self {
131            cost: self.cost,
132            gradient: self.gradient.clone(),
133            hessian: self.hessian.clone(),
134            inner_beta_hint: self.inner_beta_hint.clone(),
135        }
136    }
137}
138
139impl std::fmt::Debug for OuterEval {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        f.debug_struct("OuterEval")
142            .field("cost", &self.cost)
143            .field("gradient", &self.gradient)
144            .field("hessian", &self.hessian)
145            .finish()
146    }
147}
148
149/// Result bundle returned by the EFS (extended Fellner–Schall) evaluation
150/// path. Pure data: families compute the additive step and the optional
151/// curvature/gradient diagnostics; the solver consumes them.
152#[derive(Clone, Debug)]
153pub struct EfsEval {
154    /// REML/LAML cost at the current rho (for convergence monitoring and
155    /// comparing candidates).
156    pub cost: f64,
157    /// Additive steps. Length = n_rho + n_ext_coords.
158    ///
159    /// For pure EFS: steps for non-penalty-like coordinates are 0.0.
160    /// For hybrid EFS: ρ-coords get standard EFS multiplicative steps,
161    /// ψ-coords get preconditioned gradient steps `Δψ = -α G⁺ g_ψ`.
162    pub steps: Vec<f64>,
163    /// Current coefficient vector β̂ from the inner P-IRLS solve.
164    /// Used by the EFS loop for the runtime barrier-curvature significance
165    /// check when monotonicity constraints are present.
166    pub beta: Option<Array1<f64>>,
167    /// Raw REML/LAML gradient restricted to the ψ block (design-moving coords).
168    ///
169    /// Present only when the hybrid EFS strategy is active. Used by the
170    /// outer iteration for backtracking on the ψ step: if the combined
171    /// (ρ-EFS, ψ-gradient) step does not decrease V(θ), the ψ step size
172    /// α is halved while keeping the ρ-EFS step fixed.
173    ///
174    /// This avoids re-evaluating the gradient during backtracking since
175    /// the gradient was already computed as part of the hybrid EFS eval.
176    pub psi_gradient: Option<Array1<f64>>,
177    /// Indices into the full θ vector that correspond to ψ (design-moving)
178    /// coordinates. Used by the backtracking logic to selectively scale
179    /// only the ψ portion of the step.
180    pub psi_indices: Option<Vec<usize>>,
181    /// Inner-Hessian curvature scale captured during the EFS eval, used to
182    /// condition the ψ preconditioner across outer iterations.
183    pub inner_hessian_scale: Option<f64>,
184    /// Logdet enclosure gap diagnostic (lower/upper bound spread) captured at
185    /// this EFS evaluation when the bounded-logdet path is active.
186    pub logdet_enclosure_gap: Option<f64>,
187    /// Number of consecutive successful inner solves that returned to the same
188    /// banked incumbent after a non-monotone boundary mutation.
189    ///
190    /// `None` means the objective has no restored-incumbent certificate.  `Some`
191    /// is reset to zero whenever the objective banks a genuinely better model.
192    /// Two consecutive restorations are the minimal evidence of recurrence: one
193    /// restoration can be a one-off repair, while the second establishes that
194    /// changing the outer coordinate has returned to the same stationary fitted
195    /// state again.  Fixed-point runners may terminate on that certificate even
196    /// when the raw update keeps moving along an objective-flat parameter ridge.
197    pub consecutive_restored_incumbents: Option<usize>,
198}
199
200/// One coordinate of an objective-supplied final fixed-point certificate.
201///
202/// `Covered` means the objective has an analytic update equation whose zero is
203/// equivalent to stationarity for this coordinate. `update` is the signed
204/// feasible-descent update in the coordinate's native parameterization and
205/// `scale` makes its residual dimensionless. A guarded zero, an unavailable
206/// trace, or a coordinate with no fixed-point equation must be `Uncovered`;
207/// representing any of those as `Covered { update: 0, .. }` would fabricate a
208/// convergence certificate.
209#[derive(Clone, Debug)]
210pub enum FixedPointCoordinateCertificate {
211    Covered { update: f64, scale: f64 },
212    Uncovered { reason: String },
213}
214
215impl FixedPointCoordinateCertificate {
216    pub fn covered(update: f64, scale: f64) -> Self {
217        Self::Covered { update, scale }
218    }
219
220    pub fn uncovered(reason: impl Into<String>) -> Self {
221        Self::Uncovered {
222            reason: reason.into(),
223        }
224    }
225}
226
227/// Objective-owned proof sample used only for final fixed-point certification.
228///
229/// This is intentionally separate from [`EfsEval`]. The iteration step may be
230/// zero because a guard held or an update is undefined; only this explicit hook
231/// may assert that every coordinate carries a root-equivalent analytic residual.
232#[derive(Clone, Debug)]
233pub struct FixedPointCertificateEval {
234    pub cost: f64,
235    pub coordinates: Vec<FixedPointCoordinateCertificate>,
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    // ── DeclaredHessianForm ───────────────────────────────────────────────────
243
244    #[test]
245    fn declared_unavailable_is_not_analytic() {
246        assert!(!DeclaredHessianForm::Unavailable.is_analytic());
247    }
248
249    #[test]
250    fn declared_dense_is_analytic() {
251        assert!(DeclaredHessianForm::Dense.is_analytic());
252    }
253
254    #[test]
255    fn declared_either_is_analytic() {
256        assert!(DeclaredHessianForm::Either.is_analytic());
257    }
258
259    #[test]
260    fn declared_operator_is_analytic() {
261        let form = DeclaredHessianForm::Operator {
262            materialization: HessianMaterialization::Explicit,
263            estimated_materialization_cost: None,
264        };
265        assert!(form.is_analytic());
266    }
267
268    // ── OuterEval ─────────────────────────────────────────────────────────────
269
270    #[test]
271    fn infeasible_eval_has_infinity_cost() {
272        let eval = OuterEval::infeasible(3);
273        assert_eq!(eval.cost, f64::INFINITY);
274        assert_eq!(eval.gradient.len(), 3);
275    }
276
277    #[test]
278    fn value_only_eval_has_specified_cost() {
279        let eval = OuterEval::value_only(42.5, 2, None);
280        assert_eq!(eval.cost, 42.5);
281        assert_eq!(eval.gradient.len(), 2);
282        assert!(eval.inner_beta_hint.is_none());
283    }
284}