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}