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
45pub struct ExactNewtonJointPsiSecondOrderTerms {
46    pub objective_psi_psi: f64,
47    pub score_psi_psi: Array1<f64>,
48    pub hessian_psi_psi: Array2<f64>,
49    pub hessian_psi_psi_operator: Option<Box<dyn HyperOperator>>,
50}
51
52/// Direction-contracted second-order ψ terms for the profiled θ-HVP (#740).
53///
54/// The per-pair [`ExactNewtonJointPsiSecondOrderTerms`] are the `(ψ_i, ψ_j)`
55/// entries of the joint hyper-Hessian; assembling the full outer Hessian from
56/// them costs one O(n) family row pass per pair, i.e. `K²·n`. A matrix-free
57/// profiled θ-HVP never needs the individual pairs — it needs, for one applied
58/// outer direction with ψ-weights `α_ψ`, the `α`-contraction of those pairs
59/// against the combined ψ-direction `ψ(α) = Σ_j α_j ψ_j`:
60///
61/// ```text
62///   objective[i] = Σ_j α_j V_{ψ_i ψ_j}
63///   score[i]     = Σ_j α_j g_{ψ_i ψ_j}          (a p-vector per output row i)
64///   hessian[i]   = Σ_j α_j D²_β H_L[ψ_i, ψ_j]
65///                = D²_β H_L[ψ_i, ψ(α)]            (bilinearity)
66/// ```
67///
68/// All `psi_dim` output rows share the SAME contracted second leg `ψ(α)`, so a
69/// family that streams its rows once over `ψ(α)` (carrying every fixed first
70/// leg `ψ_i` as a batched factor column) produces every row in a SINGLE n-pass.
71/// That is the cost the profiled θ-HVP turns into `K·n`-to-densify /
72/// `m·n`-in-CG instead of the dense path's `K²·n`.
73///
74/// Indexing is over the flattened ψ coordinates in the same order as
75/// [`ExactNewtonJointPsiWorkspace::second_order_terms`]; `hessian[i]` carries
76/// the `D²_β H_L[ψ_i, ψ(α)]` drift as a [`DriftDerivResult`] (dense or
77/// operator-backed) plus any block-local `S_{ψ_i ψ_j}` penalty motion folded by
78/// the family, exactly mirroring the per-pair `hessian_psi_psi(_operator)`.
79pub struct ExactNewtonJointPsiSecondOrderContracted {
80    /// `objective[i] = Σ_j α_j V_{ψ_i ψ_j}`, one scalar per ψ output row.
81    pub objective: Array1<f64>,
82    /// `score[i] = Σ_j α_j g_{ψ_i ψ_j}`, the `psi_dim × total` matrix whose
83    /// row `i` is the contracted fixed-β score derivative for output row `i`.
84    pub score: Array2<f64>,
85    /// `hessian[i] = D²_β H_L[ψ_i, ψ(α)]` for each ψ output row `i`.
86    pub hessian: Vec<DriftDerivResult>,
87}
88
89pub trait ExactNewtonJointPsiWorkspace: Send + Sync {
90    fn first_order_terms(&self, _: usize) -> Result<Option<ExactNewtonJointPsiTerms>, String> {
91        // Default implementation ignores this parameter.
92        Ok(None)
93    }
94
95    fn first_order_terms_all(&self) -> Result<Option<Vec<ExactNewtonJointPsiTerms>>, String> {
96        Ok(None)
97    }
98
99    fn second_order_terms(
100        &self,
101        psi_i: usize,
102        psi_j: usize,
103    ) -> Result<Option<ExactNewtonJointPsiSecondOrderTerms>, String>;
104
105    /// Direction-contracted second-order ψ terms for the profiled θ-HVP (#740).
106    ///
107    /// Given the ψ-block weights `alpha_psi` (length `psi_dim`, the ψ slice of
108    /// one applied outer direction α), return the `α`-contraction of every
109    /// `(ψ_i, ψ_j)` second-order term against the combined ψ-direction
110    /// `ψ(α) = Σ_j alpha_psi[j] · ψ_j`, as
111    /// [`ExactNewtonJointPsiSecondOrderContracted`]. A family that can stream
112    /// its rows once over `ψ(α)` overrides this so the profiled outer-Hessian
113    /// operator applies one combined-direction n-pass per matvec instead of the
114    /// dense path's `K²` per-pair [`Self::second_order_terms`] passes.
115    ///
116    /// Default returns `None`: the profiled θ-HVP operator is then not built and
117    /// the evaluator keeps the exact per-pair assembly (dense
118    /// `compute_outer_hessian` / `build_outer_hessian_operator`). Overriding
119    /// this method is purely a representation/cost choice — it must produce the
120    /// exact same contraction the per-pair terms would, which the
121    /// `profiled_theta_hvp_outer_hessian_fd` finite-difference cross-check
122    /// guards.
123    fn second_order_terms_contracted(
124        &self,
125        _: &[f64],
126    ) -> Result<Option<ExactNewtonJointPsiSecondOrderContracted>, String> {
127        // Default implementation ignores this parameter.
128        Ok(None)
129    }
130
131    fn hessian_directional_derivative(
132        &self,
133        psi_index: usize,
134        d_beta_flat: &Array1<f64>,
135    ) -> Result<Option<DriftDerivResult>, String>;
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn zeros_has_zero_objective() {
144        let t = ExactNewtonJointPsiTerms::zeros(3);
145        assert_eq!(t.objective_psi, 0.0);
146    }
147
148    #[test]
149    fn zeros_has_correct_score_dimension() {
150        let t = ExactNewtonJointPsiTerms::zeros(5);
151        assert_eq!(t.score_psi.len(), 5);
152        assert!(t.score_psi.iter().all(|&v| v == 0.0));
153    }
154
155    #[test]
156    fn zeros_has_square_hessian_of_correct_size() {
157        let t = ExactNewtonJointPsiTerms::zeros(4);
158        assert_eq!(t.hessian_psi.nrows(), 4);
159        assert_eq!(t.hessian_psi.ncols(), 4);
160        assert!(t.hessian_psi.iter().all(|&v| v == 0.0));
161    }
162
163    #[test]
164    fn zeros_has_no_operator() {
165        let t = ExactNewtonJointPsiTerms::zeros(2);
166        assert!(t.hessian_psi_operator.is_none());
167    }
168
169    #[test]
170    fn zeros_with_dimension_zero_does_not_panic() {
171        let t = ExactNewtonJointPsiTerms::zeros(0);
172        assert_eq!(t.score_psi.len(), 0);
173        assert_eq!(t.hessian_psi.nrows(), 0);
174    }
175}