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}