Skip to main content

gam_problem/
laplace_sampler_contract.rs

1//! Laplace-correction / mode-posterior sampler contract (trait-inversion #1521).
2//!
3//! gam-solve's REML inner loop (`#784` block-local quadrature correction)
4//! and the custom-family never-fail covariance path call into the
5//! gam-inference-tier NUTS / importance-sampling engine (`inference::hmc_io`,
6//! ~8k lines) — an UP-edge that keeps gam-solve in the inference SCC.
7//!
8//! The COMPUTATION (NUTS, importance sampling, the directional-cubic eigen
9//! diagnostic) is irreducibly above gam-solve and STAYS UP in `hmc_io`. Only
10//! the neutral surface is contract-downed here, mirroring the `rho_posterior`
11//! data-down (#1521):
12//!
13//! * the plain-DATA result carriers gam-solve reads
14//!   ([`BlockQuadratureMarginal`], [`BlockQuadratureMoments`],
15//!   [`LaplaceTrustworthiness`]);
16//! * the caller-supplied [`BlockExcessTarget`] evaluator gam-solve IMPLEMENTS
17//!   (its `Gam784BlockTarget`), so the trait must live below both;
18//! * the CORRECTOR TRAIT [`LaplaceMarginalCorrector`] gam-solve calls THROUGH; the
19//!   monolith / gam-inference implements it over `hmc_io` and injects the impl
20//!   via the process-level registry below.
21//!
22//! The pure threshold math ([`laplace_skewness_threshold`],
23//! [`laplace_trustworthiness_from_skewness`]) has no sampler dependency, so it is
24//! moved down outright (gam-solve calls it directly).
25//!
26//! When no impl is registered (e.g. a build that never links the sampler tier)
27//! the sampler getters return `None` and gam-solve degrades to its existing
28//! decline paths — the `#784` correction returns zero (already a frequent
29//! decline outcome) and the never-fail covariance path keeps the
30//! optimizer-conditional covariance (already the `Err(reason)` fallback). The
31//! contract therefore introduces no behavioral cliff and no stub.
32
33use std::sync::OnceLock;
34
35use gam_linalg::matrix::DesignMatrix;
36use ndarray::{Array1, Array2};
37
38// ───────────────────────── data carriers (contract-down) ─────────────────────
39
40/// Adaptive, block-local Laplace-trustworthiness verdict (issue #784): which
41/// curvature directions are too non-Gaussian for the plain Laplace summary.
42///
43/// Field-for-field the monolith `hmc_io` type; that module re-exports this so
44/// its construction sites name it unchanged.
45#[derive(Clone, Debug)]
46pub struct LaplaceTrustworthiness {
47    /// Per-direction standardized skewness `γ_r`.
48    pub directional_skewness: Array1<f64>,
49    /// Indices of the directions whose skewness exceeds the auto-derived
50    /// validity threshold (the curvature-heavy, non-Gaussian block).
51    pub untrustworthy_directions: Vec<usize>,
52    /// The auto-derived per-direction skewness threshold `τ(n)` actually used.
53    pub threshold: f64,
54    /// `max_r |γ_r|` across all directions (the global non-Gaussianity scale).
55    pub max_abs_skewness: f64,
56}
57
58impl LaplaceTrustworthiness {
59    /// Whether any curvature direction is too non-Gaussian for the plain
60    /// Laplace summary, i.e. whether the higher-order correction / directional
61    /// sampling fallback should engage at all.
62    pub fn fallback_required(&self) -> bool {
63        !self.untrustworthy_directions.is_empty()
64    }
65}
66
67/// Quadrature-weighted moments of the per-node gradient channels — the
68/// integration-side half of the #784 exact-gradient seam. All expectations are
69/// under `p ∝ q·e^{−ΔF}` over the SAME deterministic nodes that produced the
70/// value, so the spliced value and its assembled gradient cannot desync (#901).
71#[derive(Clone, Debug)]
72pub struct BlockQuadratureMoments {
73    /// `E_p[t]`, length `m`.
74    pub e_t: Array1<f64>,
75    /// `E_p[t tᵀ]`, shape `m × m`.
76    pub e_tt: Array2<f64>,
77    /// `E_p[ngs(η̂+s)]`, length n — the displaced per-row score moment.
78    pub e_neg_score: Array1<f64>,
79    /// Column `r` = `E_p[t_r · ngs(η̂+s)]`, shape `n × m`.
80    pub e_t_neg_score: Array2<f64>,
81}
82
83/// Block-local deterministic quadrature correction (issue #784).
84///
85/// `value` is `Δ_b` (added to the block marginal log-likelihood, subtracted from
86/// the REML/LAML cost); `rho_gradient` is the explicit penalty-score channel (a)
87/// of the gradient exactness contract; `moments` carries the channels (b)–(d) the
88/// gam-solve assembly contracts against fields it already owns.
89#[derive(Clone, Debug)]
90pub struct BlockQuadratureMarginal {
91    /// `Δ_b`: additive correction to the block marginal log-likelihood.
92    pub value: f64,
93    /// `∂Δ_b/∂ρ`, length `rho_dim()` — explicit channel (a) ONLY.
94    pub rho_gradient: Array1<f64>,
95    /// Absolute difference between the five-node and three-node product rules,
96    /// in the same log-likelihood units as `value`.
97    pub quadrature_error: f64,
98    /// Number of nodes in the fine product rule.
99    pub node_count: usize,
100    /// Gradient-channel moments for the exact (b)–(d) assembly; `None` only when
101    /// the block is empty (`m == 0`, where the correction is zero).
102    pub moments: Option<BlockQuadratureMoments>,
103}
104
105/// Maximum curvature-heavy block dimension for deterministic product
106/// Gauss–Hermite quadrature. The fine rule has five nodes per axis, so the cap
107/// follows from the 4096-node work ceiling: `5^5 = 3125`, while `5^6 = 15625`.
108pub const BLOCK_GH_MAX_DIM: usize = 5;
109
110// ───────────────────────── pure threshold math (moved down) ──────────────────
111
112/// Auto-derive the per-direction skewness threshold `τ(n)` separating
113/// Laplace-trustworthy directions from those that need the higher-order
114/// correction / sampling fallback. Derived purely from the effective sample
115/// size, no tunable flag: `(5/24)γ_r² > 1/n_eff ⇔ |γ_r| > sqrt((24/5)/n_eff)`.
116pub fn laplace_skewness_threshold(n_eff: f64) -> f64 {
117    if !(n_eff > 0.0) {
118        return f64::INFINITY;
119    }
120    ((24.0 / 5.0) / n_eff).sqrt()
121}
122
123/// Adaptive, block-local Laplace-trustworthiness verdict (issue #784): flag the
124/// directions whose standardized skewness exceeds [`laplace_skewness_threshold`].
125/// No linear algebra of its own — consumes the directional cubic diagnostic.
126pub fn laplace_trustworthiness_from_skewness(
127    directional_skewness: &Array1<f64>,
128    n_eff: f64,
129) -> LaplaceTrustworthiness {
130    let threshold = laplace_skewness_threshold(n_eff);
131    let mut untrustworthy_directions = Vec::new();
132    let mut max_abs_skewness = 0.0_f64;
133    for (r, &gamma) in directional_skewness.iter().enumerate() {
134        let abs_gamma = if gamma.is_finite() { gamma.abs() } else { 0.0 };
135        max_abs_skewness = max_abs_skewness.max(abs_gamma);
136        if abs_gamma > threshold {
137            untrustworthy_directions.push(r);
138        }
139    }
140    LaplaceTrustworthiness {
141        directional_skewness: directional_skewness.clone(),
142        untrustworthy_directions,
143        threshold,
144        max_abs_skewness,
145    }
146}
147
148// ───────────────────────── caller-supplied excess evaluator ──────────────────
149
150/// Caller-supplied evaluator for the non-Gaussian remainder `ΔF(t)` of the local
151/// log-posterior, restricted to the curvature-heavy block subspace (issue #784).
152///
153/// Implemented by gam-solve's `Gam784BlockTarget`; consumed by
154/// [`LaplaceMarginalCorrector::block_quadrature_marginal_correction`]. Lives in this
155/// neutral crate so both the implementor (gam-solve) and the sampler impl (the
156/// gam-inference monolith) name the same trait without an SCC edge.
157pub trait BlockExcessTarget {
158    /// Dimension `m` of the block subspace (number of untrustworthy directions
159    /// being integrated).
160    fn block_dim(&self) -> usize;
161    /// Number of outer ρ coordinates the gradient is reported against.
162    fn rho_dim(&self) -> usize;
163    /// Block curvatures `λ_r` (the H-eigenvalues of the integrated directions),
164    /// length `block_dim()`.
165    fn block_curvatures(&self) -> &Array1<f64>;
166    /// Non-Gaussian remainder `ΔF(t)` at whitened block displacement `t`
167    /// (length `block_dim()`).
168    fn excess(&self, t: &Array1<f64>) -> f64;
169    /// ρ-gradient `∂ΔF/∂ρ_k` at the same `t`, length `rho_dim()` — the explicit
170    /// penalty-score channel (a).
171    fn excess_rho_gradient(&self, t: &Array1<f64>) -> Array1<f64>;
172    /// Per-row displaced score `∂(D(η̂+s(t))/2φ)/∂η` evaluated at `η̂ + s(t)`
173    /// (length = number of observation rows): the only per-draw ingredient of
174    /// the exact-gradient channels (b)–(d) the assembly side cannot reconstruct.
175    /// A row-domain failure rejects the complete score atomically.
176    fn displaced_neg_score(&self, t: &Array1<f64>) -> Result<Array1<f64>, String>;
177    /// The same per-row score channel at the undisplaced mode `η̂`.
178    fn base_neg_score(&self) -> Result<Array1<f64>, String>;
179
180    /// Fused `(excess(t), displaced_neg_score(t))`. The returned score is `None`
181    /// exactly when the excess is non-finite (an infeasible draw the sampler
182    /// discards before reading the score). The default preserves the two-call
183    /// behavior; implementors override to share the displacement + jet.
184    fn excess_with_displaced_neg_score(&self, t: &Array1<f64>) -> (f64, Option<Array1<f64>>) {
185        let excess = self.excess(t);
186        if excess.is_finite() {
187            match self.displaced_neg_score(t) {
188                Ok(score) => (excess, Some(score)),
189                Err(_) => (f64::INFINITY, None),
190            }
191        } else {
192            (excess, None)
193        }
194    }
195
196    /// Batched [`Self::excess_with_displaced_neg_score`] over many whitened draws
197    /// (one draw per COLUMN, shape `block_dim() × n_draws`). Batching may only
198    /// change HOW the shared linear algebra is computed (one BLAS-3 product over
199    /// all columns), never WHAT is computed. The default preserves the per-column
200    /// behavior exactly; the GLM implementor overrides it.
201    fn excess_with_displaced_neg_score_batch(
202        &self,
203        draws: &Array2<f64>,
204    ) -> Vec<(f64, Option<Array1<f64>>)> {
205        let n_draws = draws.ncols();
206        let mut out = Vec::with_capacity(n_draws);
207        let mut t = Array1::<f64>::zeros(draws.nrows());
208        for s in 0..n_draws {
209            t.assign(&draws.column(s));
210            out.push(self.excess_with_displaced_neg_score(&t));
211        }
212        out
213    }
214
215    /// Batched excess-only evaluation for a matrix of quadrature nodes. The
216    /// coarse error rule does not consume score moments, so requiring them
217    /// would duplicate the expensive row-score work solely to discard it.
218    fn excess_batch(&self, nodes: &Array2<f64>) -> Vec<f64> {
219        let mut out = Vec::with_capacity(nodes.ncols());
220        let mut t = Array1::<f64>::zeros(nodes.nrows());
221        for column in nodes.columns() {
222            t.assign(&column);
223            out.push(self.excess(&t));
224        }
225        out
226    }
227}
228
229// ───────────────────────── injected sampler traits ───────────────────────────
230
231/// The gam-inference-tier sampler for the #784 block-local Laplace correction.
232///
233/// Implementable UP in an inference tier over
234/// (`laplace_directional_cubic_diagnostic` + `block_quadrature_marginal_correction`)
235/// and injected DOWN via [`set_laplace_marginal_corrector`]. The standard
236/// estimator installs the deterministic quadrature implementation; alternate
237/// embeddings may install another implementation before process initialization.
238pub trait LaplaceMarginalCorrector: Send + Sync {
239    /// Per-direction standardized cubic skewness `γ_r` of the local posterior:
240    /// returns `(max_r |γ_r|, γ)`. Pure eigen-diagnostic (no sampling), but kept
241    /// behind the trait because it lives in the sampler module up-tier.
242    fn directional_cubic_diagnostic(
243        &self,
244        hessian: &Array2<f64>,
245        design: &DesignMatrix,
246        c_weights: &Array1<f64>,
247        refine_supremum: bool,
248    ) -> Result<(f64, Array1<f64>), String>;
249
250    /// Integrate `Δ_b` and its ρ-gradient against the local Laplace Gaussian,
251    /// contracting the caller-supplied [`BlockExcessTarget`].
252    fn block_quadrature_marginal_correction(
253        &self,
254        target: &dyn BlockExcessTarget,
255    ) -> Result<BlockQuadratureMarginal, String>;
256}
257
258// ───────────────────────── process-level injection registry ──────────────────
259
260static LAPLACE_MARGINAL_CORRECTOR: OnceLock<Box<dyn LaplaceMarginalCorrector>> = OnceLock::new();
261
262/// Register the #784 block-local Laplace corrector. First writer wins; a later
263/// call is ignored so a re-init can never swap a live criterion mid-run.
264pub fn set_laplace_marginal_corrector(
265    corrector: Box<dyn LaplaceMarginalCorrector>,
266) -> Result<(), Box<dyn LaplaceMarginalCorrector>> {
267    LAPLACE_MARGINAL_CORRECTOR.set(corrector)
268}
269
270/// The registered #784 block-local Laplace corrector, or `None` when the
271/// embedding has not initialized the inference tier.
272pub fn laplace_marginal_corrector() -> Option<&'static dyn LaplaceMarginalCorrector> {
273    LAPLACE_MARGINAL_CORRECTOR.get().map(|b| b.as_ref())
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use ndarray::array;
280
281    // ── laplace_skewness_threshold ────────────────────────────────────────────
282
283    #[test]
284    fn threshold_is_infinity_for_zero_n_eff() {
285        assert_eq!(laplace_skewness_threshold(0.0), f64::INFINITY);
286    }
287
288    #[test]
289    fn threshold_is_infinity_for_negative_n_eff() {
290        assert_eq!(laplace_skewness_threshold(-5.0), f64::INFINITY);
291    }
292
293    #[test]
294    fn threshold_known_value() {
295        // n_eff = 24/5 → sqrt((24/5) / (24/5)) = 1.0
296        let n_eff = 24.0 / 5.0;
297        let t = laplace_skewness_threshold(n_eff);
298        assert!((t - 1.0).abs() < 1e-14, "threshold={t}");
299    }
300
301    #[test]
302    fn threshold_decreases_as_n_eff_increases() {
303        let t_small = laplace_skewness_threshold(10.0);
304        let t_large = laplace_skewness_threshold(1000.0);
305        assert!(
306            t_large < t_small,
307            "threshold should decrease with more data"
308        );
309    }
310
311    // ── laplace_trustworthiness_from_skewness ─────────────────────────────────
312
313    #[test]
314    fn all_small_skewness_gives_no_untrustworthy_directions() {
315        // With n_eff=1000, threshold ≈ 0.069; all |γ| < that
316        let skewness = array![0.01_f64, -0.02, 0.005];
317        let result = laplace_trustworthiness_from_skewness(&skewness, 1000.0);
318        assert!(result.untrustworthy_directions.is_empty());
319        assert!(!result.fallback_required());
320    }
321
322    #[test]
323    fn large_skewness_flagged_as_untrustworthy() {
324        // With n_eff=10, threshold ≈ 0.693; γ=2.0 exceeds it
325        let skewness = array![0.1_f64, 2.0];
326        let result = laplace_trustworthiness_from_skewness(&skewness, 10.0);
327        assert!(result.untrustworthy_directions.contains(&1));
328        assert!(!result.untrustworthy_directions.contains(&0));
329        assert!(result.fallback_required());
330    }
331
332    #[test]
333    fn max_abs_skewness_is_largest_abs_value() {
334        let skewness = array![1.5_f64, -3.0, 2.0];
335        let result = laplace_trustworthiness_from_skewness(&skewness, 1.0);
336        assert!((result.max_abs_skewness - 3.0).abs() < 1e-14);
337    }
338
339    #[test]
340    fn non_finite_skewness_treated_as_zero_for_max_abs() {
341        let skewness = array![f64::NAN, 1.0];
342        let result = laplace_trustworthiness_from_skewness(&skewness, 1.0);
343        // NaN is treated as 0 in the loop; max_abs comes from 1.0
344        assert!((result.max_abs_skewness - 1.0).abs() < 1e-14);
345    }
346
347    // ── LaplaceTrustworthiness::fallback_required ─────────────────────────────
348
349    #[test]
350    fn fallback_required_true_when_directions_nonempty() {
351        let lt = LaplaceTrustworthiness {
352            directional_skewness: array![1.0_f64],
353            untrustworthy_directions: vec![0],
354            threshold: 0.5,
355            max_abs_skewness: 1.0,
356        };
357        assert!(lt.fallback_required());
358    }
359
360    #[test]
361    fn fallback_required_false_when_directions_empty() {
362        let lt = LaplaceTrustworthiness {
363            directional_skewness: array![0.1_f64],
364            untrustworthy_directions: vec![],
365            threshold: 0.5,
366            max_abs_skewness: 0.1,
367        };
368        assert!(!lt.fallback_required());
369    }
370}