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}