gam_problem/psi_terms.rs
1//! Exact-Newton joint-ψ term carriers and the joint-ψ workspace trait.
2//!
3//! These ψ-hyperparameter term types and the [`ExactNewtonJointPsiWorkspace`]
4//! trait are neutral carriers in the criterion contract: they reference only
5//! [`HyperOperator`] / [`DriftDerivResult`] (defined in this crate) and ndarray
6//! arrays, so `gam-problem` is their public namespace.
7
8use crate::{DriftDerivResult, HyperOperator};
9use ndarray::{Array1, Array2};
10use std::sync::Arc;
11
12#[derive(Clone)]
13pub struct ExactNewtonJointPsiTerms {
14 pub objective_psi: f64,
15 pub score_psi: Array1<f64>,
16 pub hessian_psi: Array2<f64>,
17 pub hessian_psi_operator: Option<Arc<dyn HyperOperator>>,
18}
19
20impl std::fmt::Debug for ExactNewtonJointPsiTerms {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 f.debug_struct("ExactNewtonJointPsiTerms")
23 .field("objective_psi", &self.objective_psi)
24 .field("score_psi", &self.score_psi)
25 .field("hessian_psi", &self.hessian_psi)
26 .field(
27 "hessian_psi_operator",
28 &self.hessian_psi_operator.as_ref().map(|_| "<operator>"),
29 )
30 .finish()
31 }
32}
33
34impl ExactNewtonJointPsiTerms {
35 pub fn zeros(total: usize) -> Self {
36 Self {
37 objective_psi: 0.0,
38 score_psi: Array1::zeros(total),
39 hessian_psi: Array2::zeros((total, total)),
40 hessian_psi_operator: None,
41 }
42 }
43}
44
45#[derive(Clone)]
46pub struct ExactNewtonJointPsiSecondOrderTerms {
47 pub objective_psi_psi: f64,
48 pub score_psi_psi: Array1<f64>,
49 pub hessian_psi_psi: Array2<f64>,
50 pub hessian_psi_psi_operator: Option<Arc<dyn HyperOperator>>,
51}
52
53/// Direction-contracted second-order ψ terms for the profiled θ-HVP (#740).
54///
55/// The per-pair [`ExactNewtonJointPsiSecondOrderTerms`] are the `(ψ_i, ψ_j)`
56/// entries of the joint hyper-Hessian; assembling the full outer Hessian from
57/// them costs one O(n) family row pass per pair, i.e. `K²·n`. A matrix-free
58/// profiled θ-HVP never needs the individual pairs — it needs, for one applied
59/// outer direction with ψ-weights `α_ψ`, the `α`-contraction of those pairs
60/// against the combined ψ-direction `ψ(α) = Σ_j α_j ψ_j`:
61///
62/// ```text
63/// objective[i] = Σ_j α_j V_{ψ_i ψ_j}
64/// score[i] = Σ_j α_j g_{ψ_i ψ_j} (a p-vector per output row i)
65/// hessian[i] = Σ_j α_j D²_β H_L[ψ_i, ψ_j]
66/// = D²_β H_L[ψ_i, ψ(α)] (bilinearity)
67/// ```
68///
69/// All `psi_dim` output rows share the SAME contracted second leg `ψ(α)`, so a
70/// family that streams its rows once over `ψ(α)` (carrying every fixed first
71/// leg `ψ_i` as a batched factor column) produces every row in a SINGLE n-pass.
72/// That is the cost the profiled θ-HVP turns into `K·n`-to-densify /
73/// `m·n`-in-CG instead of the dense path's `K²·n`.
74///
75/// Indexing is over the flattened ψ coordinates in the same order as
76/// [`ExactNewtonJointPsiWorkspace::second_order_terms`]; `hessian[i]` carries
77/// the `D²_β H_L[ψ_i, ψ(α)]` drift as a [`DriftDerivResult`] (dense or
78/// operator-backed) plus any block-local `S_{ψ_i ψ_j}` penalty motion folded by
79/// the family, exactly mirroring the per-pair `hessian_psi_psi(_operator)`.
80pub struct ExactNewtonJointPsiSecondOrderContracted {
81 /// `objective[i] = Σ_j α_j V_{ψ_i ψ_j}`, one scalar per ψ output row.
82 pub objective: Array1<f64>,
83 /// `score[i] = Σ_j α_j g_{ψ_i ψ_j}`, the `psi_dim × total` matrix whose
84 /// row `i` is the contracted fixed-β score derivative for output row `i`.
85 pub score: Array2<f64>,
86 /// `hessian[i] = D²_β H_L[ψ_i, ψ(α)]` for each ψ output row `i`.
87 pub hessian: Vec<DriftDerivResult>,
88}
89
90pub trait ExactNewtonJointPsiWorkspace: Send + Sync {
91 fn first_order_terms(
92 &self,
93 psi_index: usize,
94 ) -> Result<Option<ExactNewtonJointPsiTerms>, String> {
95 // A workspace that materializes every axis in one pass still answers
96 // the per-index query: select row `psi_index` out of that batch. A
97 // workspace that implements neither method falls through to `Ok(None)`
98 // and the caller uses the family's own per-index path.
99 let Some(all) = self.first_order_terms_all()? else {
100 return Ok(None);
101 };
102 let materialized = all.len();
103 match all.into_iter().nth(psi_index) {
104 Some(terms) => Ok(Some(terms)),
105 None => Err(format!(
106 "ExactNewtonJointPsiWorkspace: psi index {psi_index} is out of range for the \
107 {materialized} axes this workspace materialized"
108 )),
109 }
110 }
111
112 fn first_order_terms_all(&self) -> Result<Option<Vec<ExactNewtonJointPsiTerms>>, String> {
113 Ok(None)
114 }
115
116 fn second_order_terms(
117 &self,
118 psi_i: usize,
119 psi_j: usize,
120 ) -> Result<Option<ExactNewtonJointPsiSecondOrderTerms>, String>;
121
122 /// Direction-contracted second-order ψ terms for the profiled θ-HVP (#740).
123 ///
124 /// Given the ψ-block weights `alpha_psi` (length `psi_dim`, the ψ slice of
125 /// one applied outer direction α), return the `α`-contraction of every
126 /// `(ψ_i, ψ_j)` second-order term against the combined ψ-direction
127 /// `ψ(α) = Σ_j alpha_psi[j] · ψ_j`, as
128 /// [`ExactNewtonJointPsiSecondOrderContracted`]. A family that can stream
129 /// its rows once over `ψ(α)` overrides this so the profiled outer-Hessian
130 /// operator applies one combined-direction n-pass per matvec instead of the
131 /// dense path's `K²` per-pair [`Self::second_order_terms`] passes.
132 ///
133 /// Default returns `None`: the profiled θ-HVP operator is then not built and
134 /// the evaluator keeps the exact per-pair assembly (dense
135 /// `compute_outer_hessian` / `build_outer_hessian_operator`). Overriding
136 /// this method is purely a representation/cost choice — it must produce the
137 /// exact same contraction the per-pair terms would, which the
138 /// `profiled_theta_hvp_outer_hessian_fd` finite-difference cross-check
139 /// guards.
140 fn second_order_terms_contracted(
141 &self,
142 _: &[f64],
143 ) -> Result<Option<ExactNewtonJointPsiSecondOrderContracted>, String> {
144 // Default implementation ignores this parameter.
145 Ok(None)
146 }
147
148 fn hessian_directional_derivative(
149 &self,
150 psi_index: usize,
151 d_beta_flat: &Array1<f64>,
152 ) -> Result<Option<DriftDerivResult>, String>;
153
154 /// Materialize the psi-Hessian derivative along each joint coefficient
155 /// basis vector, in coefficient order. Row-streaming workspaces can compute
156 /// this same tensor in one pass by implementing this method directly.
157 fn hessian_directional_derivatives_all_beta_axes(
158 &self,
159 psi_index: usize,
160 total: usize,
161 ) -> Result<Option<Vec<DriftDerivResult>>, String> {
162 per_axis_psi_hessian_directional_derivatives(self, psi_index, total)
163 }
164
165 /// {D_beta_axis D_beta_direction D_psi H}, under this workspace's row measure.
166 fn hessian_second_directional_derivative_all_beta_axes(
167 &self,
168 psi_index: usize,
169 d_beta_flat: &Array1<f64>,
170 ) -> Result<Option<Vec<Array2<f64>>>, String> {
171 Err(format!("exact third information derivatives are unavailable for psi axis {psi_index} and coefficient direction of length {}", d_beta_flat.len()))
172 }
173
174 /// {D_beta_axis D_psi_i D_psi_j H}, under this workspace's row measure.
175 fn second_order_hessian_directional_derivative_all_beta_axes(
176 &self,
177 psi_i: usize,
178 psi_j: usize,
179 ) -> Result<Option<Vec<Array2<f64>>>, String> {
180 Err(format!("exact third information derivatives are unavailable for psi pair ({psi_i}, {psi_j})"))
181 }
182}
183
184/// Assemble the coefficient-axis tensor from exact directional derivatives.
185/// A missing axis makes the whole tensor unavailable; errors propagate intact.
186pub fn per_axis_psi_hessian_directional_derivatives(
187 workspace: &(impl ExactNewtonJointPsiWorkspace + ?Sized),
188 psi_index: usize,
189 total: usize,
190) -> Result<Option<Vec<DriftDerivResult>>, String> {
191 let mut axes = Vec::with_capacity(total);
192 let mut direction = Array1::<f64>::zeros(total);
193 for axis in 0..total {
194 direction[axis] = 1.0;
195 let Some(derivative) = workspace.hessian_directional_derivative(psi_index, &direction)?
196 else {
197 return Ok(None);
198 };
199 axes.push(derivative);
200 direction[axis] = 0.0;
201 }
202 Ok(Some(axes))
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 #[test]
210 fn zeros_has_zero_objective() {
211 let t = ExactNewtonJointPsiTerms::zeros(3);
212 assert_eq!(t.objective_psi, 0.0);
213 }
214
215 #[test]
216 fn zeros_has_correct_score_dimension() {
217 let t = ExactNewtonJointPsiTerms::zeros(5);
218 assert_eq!(t.score_psi.len(), 5);
219 assert!(t.score_psi.iter().all(|&v| v == 0.0));
220 }
221
222 #[test]
223 fn zeros_has_square_hessian_of_correct_size() {
224 let t = ExactNewtonJointPsiTerms::zeros(4);
225 assert_eq!(t.hessian_psi.nrows(), 4);
226 assert_eq!(t.hessian_psi.ncols(), 4);
227 assert!(t.hessian_psi.iter().all(|&v| v == 0.0));
228 }
229
230 #[test]
231 fn zeros_has_no_operator() {
232 let t = ExactNewtonJointPsiTerms::zeros(2);
233 assert!(t.hessian_psi_operator.is_none());
234 }
235
236 #[test]
237 fn zeros_with_dimension_zero_does_not_panic() {
238 let t = ExactNewtonJointPsiTerms::zeros(0);
239 assert_eq!(t.score_psi.len(), 0);
240 assert_eq!(t.hessian_psi.nrows(), 0);
241 }
242}