Skip to main content

gam_problem/
psi_design_contract.rs

1//! The neutral ψ (hyperparameter) design-derivative contract carriers and
2//! operator traits shared by the `CustomFamily` trait layer (`gam-model-api`)
3//! and the solver: the per-block ψ-derivative carrier, the matrix-free
4//! `CustomFamilyPsiDerivativeOperator` trait (+ its dense-materialization
5//! extension), and the joint-Hessian source-preference / materialization-intent
6//! enums.
7//!
8//! These carry no dependency on the `CustomFamily` trait itself, so they live
9//! in the neutral `gam-problem` crate and are re-exported upward, keeping a
10//! single definition shared across crates.
11
12use crate::{BasisError, PenaltyMatrix};
13use ndarray::{Array1, Array2, ArrayView1, ArrayViewMut1};
14use std::any::Any;
15use std::ops::Range;
16use std::sync::Arc;
17
18#[derive(Clone)]
19pub struct CustomFamilyBlockPsiDerivative {
20    pub penalty_index: Option<usize>,
21    pub x_psi: Array2<f64>,
22    pub s_psi: Array2<f64>,
23    pub s_psi_components: Option<Vec<(usize, Array2<f64>)>>,
24    pub s_psi_penalty_components: Option<Vec<(usize, PenaltyMatrix)>>,
25    pub x_psi_psi: Option<Vec<Array2<f64>>>,
26    pub s_psi_psi: Option<Vec<Array2<f64>>>,
27    pub s_psi_psi_components: Option<Vec<Vec<(usize, Array2<f64>)>>>,
28    pub s_psi_psi_penalty_components: Option<Vec<Vec<(usize, PenaltyMatrix)>>>,
29    pub implicit_operator: Option<Arc<dyn CustomFamilyPsiDerivativeOperator>>,
30    pub implicit_axis: usize,
31    pub implicit_group_id: Option<usize>,
32}
33
34/// Identity of one coordinate in the custom-family hyperparameter surface.
35///
36/// The global coordinate order is deliberately structural rather than inferred
37/// from empty derivative matrices:
38///
39/// 1. design/penalty coordinates, grouped in block order; then
40/// 2. family-owned coordinates, in family-local axis order.
41///
42/// `derivative_index` addresses the derivative within `block` in the layout's
43/// [`CustomFamilyHyperLayout::design_derivative_blocks`] storage.  A family
44/// coordinate has no fictitious block owner and therefore cannot accidentally
45/// participate in generic `X_psi`/`S_psi` assembly.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum CustomFamilyHyperAxis {
48    DesignPenalty {
49        block: usize,
50        derivative_index: usize,
51    },
52    Family {
53        family_axis: usize,
54    },
55}
56
57/// Validated global layout for exact custom-family hyperparameter calculus.
58///
59/// Family-owned axes are represented explicitly.  In particular, an empty
60/// `X_psi`/`S_psi` pair is still a design/penalty axis; it is never interpreted
61/// as an auxiliary family parameter.  This makes first-order, pairwise, and
62/// mixed-beta dispatch use the same coordinate identity.
63#[derive(Clone)]
64pub struct CustomFamilyHyperLayout {
65    design_derivative_blocks: Vec<Vec<CustomFamilyBlockPsiDerivative>>,
66    family_axes: Vec<usize>,
67    values: Array1<f64>,
68    design_axis_count: usize,
69    axis_count: usize,
70}
71
72impl CustomFamilyHyperLayout {
73    /// Construct a layout with an explicit list of family-local axes.
74    ///
75    /// `family_axes` must be exactly `0..family_axes.len()`.  Requiring the
76    /// caller to provide that list makes adding a family parameter an explicit
77    /// breaking change at the evaluation site, while the validation prevents
78    /// duplicate, missing, or reordered family identities.
79    pub fn new(
80        design_derivative_blocks: Vec<Vec<CustomFamilyBlockPsiDerivative>>,
81        family_axes: Vec<usize>,
82        values: Array1<f64>,
83    ) -> Result<Self, String> {
84        for (expected, &actual) in family_axes.iter().enumerate() {
85            if actual != expected {
86                return Err(format!(
87                    "custom-family hyper layout family axes must be contiguous and ordered: \
88                     position {expected} carries family axis {actual}"
89                ));
90            }
91        }
92        let design_axis_count =
93            design_derivative_blocks
94                .iter()
95                .try_fold(0usize, |count, derivatives| {
96                    count.checked_add(derivatives.len()).ok_or_else(|| {
97                        "custom-family hyper layout design-axis count exceeds usize".to_string()
98                    })
99                })?;
100        let axis_count = design_axis_count
101            .checked_add(family_axes.len())
102            .ok_or_else(|| "custom-family hyper layout axis count exceeds usize".to_string())?;
103        if values.len() != axis_count {
104            return Err(format!(
105                "custom-family hyper layout value length mismatch: got {}, expected {axis_count}",
106                values.len()
107            ));
108        }
109        if let Some((axis, value)) = values
110            .iter()
111            .copied()
112            .enumerate()
113            .find(|(_, value)| !value.is_finite())
114        {
115            return Err(format!(
116                "custom-family hyper layout axis {axis} has non-finite value {value}"
117            ));
118        }
119        Ok(Self {
120            design_derivative_blocks,
121            family_axes,
122            values,
123            design_axis_count,
124            axis_count,
125        })
126    }
127
128    pub fn block_count(&self) -> usize {
129        self.design_derivative_blocks.len()
130    }
131
132    pub fn design_axis_count(&self) -> usize {
133        self.design_axis_count
134    }
135
136    pub fn family_axis_count(&self) -> usize {
137        self.family_axes.len()
138    }
139
140    pub fn len(&self) -> usize {
141        self.axis_count
142    }
143
144    pub fn is_empty(&self) -> bool {
145        self.len() == 0
146    }
147
148    pub fn design_derivative_blocks(&self) -> &[Vec<CustomFamilyBlockPsiDerivative>] {
149        &self.design_derivative_blocks
150    }
151
152    pub fn family_axes(&self) -> &[usize] {
153        &self.family_axes
154    }
155
156    /// Exact non-rho coordinate values used to realize this manifest.
157    ///
158    /// The vector is aligned one-to-one with [`Self::axis`] and is part of the
159    /// immutable evaluation identity carried into the owned coefficient mode.
160    pub fn values(&self) -> &Array1<f64> {
161        &self.values
162    }
163
164    /// Resolve a global hyperparameter coordinate to its typed identity.
165    pub fn axis(&self, global_index: usize) -> Option<CustomFamilyHyperAxis> {
166        if global_index < self.design_axis_count {
167            let mut remaining = global_index;
168            return self.design_derivative_blocks.iter().enumerate().find_map(
169                |(block, derivatives)| {
170                    if remaining < derivatives.len() {
171                        Some(CustomFamilyHyperAxis::DesignPenalty {
172                            block,
173                            derivative_index: remaining,
174                        })
175                    } else {
176                        remaining -= derivatives.len();
177                        None
178                    }
179                },
180            );
181        }
182        let family_offset = global_index.checked_sub(self.design_axis_count)?;
183        self.family_axes
184            .get(family_offset)
185            .copied()
186            .map(|family_axis| CustomFamilyHyperAxis::Family { family_axis })
187    }
188
189    pub fn design_derivative(
190        &self,
191        global_index: usize,
192    ) -> Option<(usize, usize, &CustomFamilyBlockPsiDerivative)> {
193        match self.axis(global_index)? {
194            CustomFamilyHyperAxis::DesignPenalty {
195                block,
196                derivative_index,
197            } => self
198                .design_derivative_blocks
199                .get(block)?
200                .get(derivative_index)
201                .map(|derivative| (block, derivative_index, derivative)),
202            CustomFamilyHyperAxis::Family { .. } => None,
203        }
204    }
205
206    pub fn family_axis(&self, global_index: usize) -> Option<usize> {
207        match self.axis(global_index)? {
208            CustomFamilyHyperAxis::Family { family_axis } => Some(family_axis),
209            CustomFamilyHyperAxis::DesignPenalty { .. } => None,
210        }
211    }
212}
213
214pub type SharedCustomFamilyHyperLayout = Arc<CustomFamilyHyperLayout>;
215
216impl CustomFamilyBlockPsiDerivative {
217    /// Public constructor for use in tests and external consumers.
218    /// Sets `implicit_operator` to `None`.
219    pub fn new(
220        penalty_index: Option<usize>,
221        x_psi: Array2<f64>,
222        s_psi: Array2<f64>,
223        s_psi_components: Option<Vec<(usize, Array2<f64>)>>,
224        x_psi_psi: Option<Vec<Array2<f64>>>,
225        s_psi_psi: Option<Vec<Array2<f64>>>,
226        s_psi_psi_components: Option<Vec<Vec<(usize, Array2<f64>)>>>,
227    ) -> Self {
228        Self {
229            penalty_index,
230            x_psi,
231            s_psi,
232            s_psi_components,
233            s_psi_penalty_components: None,
234            x_psi_psi,
235            s_psi_psi,
236            s_psi_psi_components,
237            s_psi_psi_penalty_components: None,
238            implicit_operator: None,
239            implicit_axis: 0,
240            implicit_group_id: None,
241        }
242    }
243}
244
245pub trait CustomFamilyPsiDerivativeOperator: Send + Sync + Any {
246    fn as_any(&self) -> &dyn Any;
247    fn n_data(&self) -> usize;
248    fn p_out(&self) -> usize;
249    fn transpose_mul(
250        &self,
251        axis: usize,
252        v: &ArrayView1<'_, f64>,
253    ) -> Result<Array1<f64>, BasisError>;
254    fn forward_mul(&self, axis: usize, u: &ArrayView1<'_, f64>) -> Result<Array1<f64>, BasisError>;
255    fn transpose_mul_second_diag(
256        &self,
257        axis: usize,
258        v: &ArrayView1<'_, f64>,
259    ) -> Result<Array1<f64>, BasisError>;
260    fn transpose_mul_second_cross(
261        &self,
262        axis_d: usize,
263        axis_e: usize,
264        v: &ArrayView1<'_, f64>,
265    ) -> Result<Array1<f64>, BasisError>;
266    fn forward_mul_second_diag(
267        &self,
268        axis: usize,
269        u: &ArrayView1<'_, f64>,
270    ) -> Result<Array1<f64>, BasisError>;
271    fn forward_mul_second_cross(
272        &self,
273        axis_d: usize,
274        axis_e: usize,
275        u: &ArrayView1<'_, f64>,
276    ) -> Result<Array1<f64>, BasisError>;
277    fn row_chunk_first(&self, axis: usize, rows: Range<usize>) -> Result<Array2<f64>, BasisError>;
278    /// Single-row specialization of `row_chunk_first`. Default implementation
279    /// delegates to `row_chunk_first(axis, row..row+1)` and copies the
280    /// resulting row into the output buffer; implementations that can avoid
281    /// the temporary matrix allocation should override this method.
282    fn row_vector_first_into(
283        &self,
284        axis: usize,
285        row: usize,
286        mut out: ArrayViewMut1<'_, f64>,
287    ) -> Result<(), BasisError> {
288        let chunk = self.row_chunk_first(axis, row..row + 1)?;
289        out.assign(&chunk.row(0));
290        Ok(())
291    }
292    fn row_chunk_second_diag(
293        &self,
294        axis: usize,
295        rows: Range<usize>,
296    ) -> Result<Array2<f64>, BasisError>;
297    fn row_chunk_second_cross(
298        &self,
299        axis_d: usize,
300        axis_e: usize,
301        rows: Range<usize>,
302    ) -> Result<Array2<f64>, BasisError>;
303
304    /// Optional upcast to the dense materialization surface. Production exact
305    /// paths should prefer the analytic matvec / row-chunk methods above and
306    /// avoid forming the full derivative matrix; implementations that *do*
307    /// support dense materialization (used by diagnostics, tests, and
308    /// small-data fallbacks) should override this to return `Some(self)`.
309    fn as_materializable(&self) -> Option<&dyn MaterializablePsiDerivativeOperator> {
310        None
311    }
312}
313
314/// Diagnostic / small-data extension that exposes dense materialization of
315/// `\partial X / \partial \psi`. Production exact-Hessian code MUST NOT depend
316/// on dense second-derivative materialization; second-order paths use the
317/// row-chunk and matvec methods on [`CustomFamilyPsiDerivativeOperator`].
318pub trait MaterializablePsiDerivativeOperator: CustomFamilyPsiDerivativeOperator {
319    fn materialize_first(&self, axis: usize) -> Result<Array2<f64>, BasisError>;
320}
321
322#[derive(Clone, Copy, Debug, PartialEq, Eq)]
323pub enum JointHessianSourcePreference {
324    Dense,
325    Operator,
326}
327
328/// What the consumer is going to *do* with the joint Hessian. This is the
329/// intent half of #738's capability-vs-representation split: the call site
330/// states what it needs, and the workspace picks the cheapest representation
331/// that serves that need (rather than a single per-workspace preference being
332/// applied uniformly regardless of how the result is consumed).
333///
334/// The distinction matters because the same workspace serves several
335/// consumers with opposite ideal representations:
336/// - the inner Newton/PCG solve only ever applies `H · v`, so a matrix-free
337///   HVP (`Operator`) is ideal and a dense build is pure waste;
338/// - the REML logdet term factorizes `H + S_λ` (Cholesky / eigendecomposition),
339///   so it must hold a dense matrix anyway — handing it an `Operator` only
340///   forces an immediate column-basis (or `dense_forced`) re-materialization,
341///   so a workspace with a structural direct-dense build should answer `Dense`
342///   here and skip the operator wrapper entirely.
343///
344/// Workspaces refine their representation choice per intent via
345/// `ExactNewtonJointHessianWorkspace::hessian_source_preference_for_intent`;
346/// the default keeps the legacy single-preference behaviour so existing
347/// workspaces are unchanged.
348#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub enum MaterializationIntent {
350    /// Inner Newton / PCG solve — only applies `H · v`. Matrix-free is ideal.
351    InnerSolve,
352    /// REML/LAML logdet term — factorizes `H + S_λ`, needs a dense matrix.
353    LogdetFactorization,
354    /// Outer-Hessian / EFS evaluation — builds the joint hyper terms; today
355    /// these route through the same source as the gradient path.
356    OuterEvaluation,
357    /// Outer-gradient / IFT term assembly.
358    OuterGradient,
359}