Skip to main content

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}