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