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}