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    /// Exact non-rho coordinate values used to realize this manifest.
153    ///
154    /// The vector is aligned one-to-one with [`Self::axis`] and is part of the
155    /// immutable evaluation identity carried into the owned coefficient mode.
156    pub fn values(&self) -> &Array1<f64> {
157        &self.values
158    }
159
160    /// Resolve a global hyperparameter coordinate to its typed identity.
161    pub fn axis(&self, global_index: usize) -> Option<CustomFamilyHyperAxis> {
162        if global_index < self.design_axis_count {
163            let mut remaining = global_index;
164            return self.design_derivative_blocks.iter().enumerate().find_map(
165                |(block, derivatives)| {
166                    if remaining < derivatives.len() {
167                        Some(CustomFamilyHyperAxis::DesignPenalty {
168                            block,
169                            derivative_index: remaining,
170                        })
171                    } else {
172                        remaining -= derivatives.len();
173                        None
174                    }
175                },
176            );
177        }
178        let family_offset = global_index.checked_sub(self.design_axis_count)?;
179        self.family_axes
180            .get(family_offset)
181            .copied()
182            .map(|family_axis| CustomFamilyHyperAxis::Family { family_axis })
183    }
184
185    pub fn design_derivative(
186        &self,
187        global_index: usize,
188    ) -> Option<(usize, usize, &CustomFamilyBlockPsiDerivative)> {
189        match self.axis(global_index)? {
190            CustomFamilyHyperAxis::DesignPenalty {
191                block,
192                derivative_index,
193            } => self
194                .design_derivative_blocks
195                .get(block)?
196                .get(derivative_index)
197                .map(|derivative| (block, derivative_index, derivative)),
198            CustomFamilyHyperAxis::Family { .. } => None,
199        }
200    }
201
202    pub fn family_axis(&self, global_index: usize) -> Option<usize> {
203        match self.axis(global_index)? {
204            CustomFamilyHyperAxis::Family { family_axis } => Some(family_axis),
205            CustomFamilyHyperAxis::DesignPenalty { .. } => None,
206        }
207    }
208}
209
210pub type SharedCustomFamilyHyperLayout = Arc<CustomFamilyHyperLayout>;
211
212impl CustomFamilyBlockPsiDerivative {
213    /// Public constructor for use in tests and external consumers.
214    /// Sets `implicit_operator` to `None`.
215    pub fn new(
216        penalty_index: Option<usize>,
217        x_psi: Array2<f64>,
218        s_psi: Array2<f64>,
219        s_psi_components: Option<Vec<(usize, Array2<f64>)>>,
220        x_psi_psi: Option<Vec<Array2<f64>>>,
221        s_psi_psi: Option<Vec<Array2<f64>>>,
222        s_psi_psi_components: Option<Vec<Vec<(usize, Array2<f64>)>>>,
223    ) -> Self {
224        Self {
225            penalty_index,
226            x_psi,
227            s_psi,
228            s_psi_components,
229            s_psi_penalty_components: None,
230            x_psi_psi,
231            s_psi_psi,
232            s_psi_psi_components,
233            s_psi_psi_penalty_components: None,
234            implicit_operator: None,
235            implicit_axis: 0,
236            implicit_group_id: None,
237        }
238    }
239}
240
241pub trait CustomFamilyPsiDerivativeOperator: Send + Sync + Any {
242    fn as_any(&self) -> &dyn Any;
243    fn n_data(&self) -> usize;
244    fn p_out(&self) -> usize;
245    fn transpose_mul(
246        &self,
247        axis: usize,
248        v: &ArrayView1<'_, f64>,
249    ) -> Result<Array1<f64>, BasisError>;
250    fn forward_mul(&self, axis: usize, u: &ArrayView1<'_, f64>) -> Result<Array1<f64>, BasisError>;
251    fn transpose_mul_second_diag(
252        &self,
253        axis: usize,
254        v: &ArrayView1<'_, f64>,
255    ) -> Result<Array1<f64>, BasisError>;
256    fn transpose_mul_second_cross(
257        &self,
258        axis_d: usize,
259        axis_e: usize,
260        v: &ArrayView1<'_, f64>,
261    ) -> Result<Array1<f64>, BasisError>;
262    fn forward_mul_second_diag(
263        &self,
264        axis: usize,
265        u: &ArrayView1<'_, f64>,
266    ) -> Result<Array1<f64>, BasisError>;
267    fn forward_mul_second_cross(
268        &self,
269        axis_d: usize,
270        axis_e: usize,
271        u: &ArrayView1<'_, f64>,
272    ) -> Result<Array1<f64>, BasisError>;
273    fn row_chunk_first(&self, axis: usize, rows: Range<usize>) -> Result<Array2<f64>, BasisError>;
274    /// Single-row specialization of `row_chunk_first`. Default implementation
275    /// delegates to `row_chunk_first(axis, row..row+1)` and copies the
276    /// resulting row into the output buffer; implementations that can avoid
277    /// the temporary matrix allocation should override this method.
278    fn row_vector_first_into(
279        &self,
280        axis: usize,
281        row: usize,
282        mut out: ArrayViewMut1<'_, f64>,
283    ) -> Result<(), BasisError> {
284        let chunk = self.row_chunk_first(axis, row..row + 1)?;
285        out.assign(&chunk.row(0));
286        Ok(())
287    }
288    fn row_chunk_second_diag(
289        &self,
290        axis: usize,
291        rows: Range<usize>,
292    ) -> Result<Array2<f64>, BasisError>;
293    fn row_chunk_second_cross(
294        &self,
295        axis_d: usize,
296        axis_e: usize,
297        rows: Range<usize>,
298    ) -> Result<Array2<f64>, BasisError>;
299
300    /// Optional upcast to the dense materialization surface. Production exact
301    /// paths should prefer the analytic matvec / row-chunk methods above and
302    /// avoid forming the full derivative matrix; implementations that *do*
303    /// support dense materialization (used by diagnostics, tests, and
304    /// small-data fallbacks) should override this to return `Some(self)`.
305    fn as_materializable(&self) -> Option<&dyn MaterializablePsiDerivativeOperator> {
306        None
307    }
308}
309
310/// Diagnostic / small-data extension that exposes dense materialization of
311/// `\partial X / \partial \psi`. Production exact-Hessian code MUST NOT depend
312/// on dense second-derivative materialization; second-order paths use the
313/// row-chunk and matvec methods on [`CustomFamilyPsiDerivativeOperator`].
314pub trait MaterializablePsiDerivativeOperator: CustomFamilyPsiDerivativeOperator {
315    fn materialize_first(&self, axis: usize) -> Result<Array2<f64>, BasisError>;
316}
317
318#[derive(Clone, Copy, Debug, PartialEq, Eq)]
319pub enum JointHessianSourcePreference {
320    Dense,
321    Operator,
322}
323
324/// What the consumer is going to *do* with the joint Hessian. This is the
325/// intent half of #738's capability-vs-representation split: the call site
326/// states what it needs, and the workspace picks the cheapest representation
327/// that serves that need (rather than a single per-workspace preference being
328/// applied uniformly regardless of how the result is consumed).
329///
330/// The distinction matters because the same workspace serves several
331/// consumers with opposite ideal representations:
332/// - the inner Newton/PCG solve only ever applies `H · v`, so a matrix-free
333///   HVP (`Operator`) is ideal and a dense build is pure waste;
334/// - the REML logdet term factorizes `H + S_λ` (Cholesky / eigendecomposition),
335///   so it must hold a dense matrix anyway — handing it an `Operator` only
336///   forces an immediate column-basis (or `dense_forced`) re-materialization,
337///   so a workspace with a structural direct-dense build should answer `Dense`
338///   here and skip the operator wrapper entirely.
339///
340/// Workspaces refine their representation choice per intent via
341/// `ExactNewtonJointHessianWorkspace::hessian_source_preference_for_intent`;
342/// the default keeps the legacy single-preference behaviour so existing
343/// workspaces are unchanged.
344#[derive(Clone, Copy, Debug, PartialEq, Eq)]
345pub enum MaterializationIntent {
346    /// Inner Newton / PCG solve — only applies `H · v`. Matrix-free is ideal.
347    InnerSolve,
348    /// REML/LAML logdet term — factorizes `H + S_λ`, needs a dense matrix.
349    LogdetFactorization,
350    /// Outer-Hessian / EFS evaluation — builds the joint hyper terms; today
351    /// these route through the same source as the gradient path.
352    OuterEvaluation,
353    /// Outer-gradient / IFT term assembly.
354    OuterGradient,
355}