Skip to main content

gam_problem/
rho_posterior.rs

1//! `ρ`-posterior certificate / escalation DATA types (contract-down #1521).
2//!
3//! These are the plain-data carriers that a fit result STORES
4//! (`UnifiedFitResult::rho_posterior_{certificate,escalation}`) and that the
5//! gam-solve REML evaluator returns. The COMPUTATION that produces them — the
6//! PSIS certificate, the Tier-1 Gauss-Hermite quadrature, and the Tier-2 NUTS
7//! escalation (which pulls the gam-inference `hmc_io` sampler) — stays UP in the
8//! monolith `inference::rho_posterior`, which re-exports these types so its
9//! construction sites name them unchanged. Contract-downed here (the neutral
10//! criterion-contract crate) so gam-solve can store/return them without a
11//! back-edge into gam-inference.
12
13use ndarray::{Array1, Array2};
14use std::sync::OnceLock;
15
16/// Reliability tier read off the Pareto tail-shape `k̂` of the `ρ`-importance
17/// weights.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum RhoCertificate {
20    /// `k̂ < 0.5`: the Laplace proposal is excellent — the plug-in (REML
21    /// conditional) intervals plus the first-order `V_ρ` correction are
22    /// certified adequate; `ρ`-uncertainty does not need a heavier treatment.
23    PlugInCertified,
24    /// `0.5 ≤ k̂ ≤ 0.7`: the proposal is usable but the self-normalized
25    /// importance weights should be used to correct moments.
26    ImportanceCorrect,
27    /// `k̂ > 0.7`: the Laplace proposal poorly captures `π(ρ|y)`; escalate to
28    /// quadrature (small `K`) or NUTS over `ρ`.
29    Escalate,
30}
31
32/// `k̂` above which the self-normalized importance estimator's central limit
33/// theorem no longer applies and the proposal must be replaced rather than
34/// reweighted (Vehtari, Simpson, Gelman, Yao & Gabry, *Pareto smoothed
35/// importance sampling*, JMLR 25(72), 2024, §3): for `k > 0.7` the practical
36/// pre-asymptotic convergence rate of PSIS collapses and the estimate is
37/// declared unreliable. How finely a given draw count can resolve this
38/// boundary is NOT a property of the cutoff: `gam_solve::psis::shape_resolution`
39/// reports the standard error of the fitted shape, and a verdict against this
40/// cutoff only means something when the truth sits several such standard errors
41/// away from it.
42pub const ESCALATE_K_HAT: f64 = 0.7;
43/// `k̂` below which the proposal's importance weights have finite variance
44/// (`k < 1/2` ⇒ the generalized-Pareto tail has a second moment), so the
45/// plug-in answer plus the first-order correction needs no reweighting.
46pub const PLUG_IN_CERTIFIED_K_HAT: f64 = 0.5;
47
48impl RhoCertificate {
49    pub fn from_k_hat(k_hat: f64) -> Self {
50        if !k_hat.is_finite() || k_hat > ESCALATE_K_HAT {
51            RhoCertificate::Escalate
52        } else if k_hat < PLUG_IN_CERTIFIED_K_HAT {
53            RhoCertificate::PlugInCertified
54        } else {
55            RhoCertificate::ImportanceCorrect
56        }
57    }
58}
59
60/// The Tier-0 `ρ`-uncertainty certificate for a fit.
61#[derive(Debug, Clone)]
62pub struct RhoPosteriorCertificate {
63    /// Pareto tail-shape of the importance weights — the reliability diagnostic.
64    pub k_hat: f64,
65    /// The reliability tier derived from `k_hat`.
66    pub certificate: RhoCertificate,
67    /// Number of proposal draws `M`.
68    pub n_samples: usize,
69    /// Self-normalized importance weights (length `M`), Pareto-smoothed. These
70    /// turn the `M` conditional Gaussians into a free self-normalized mixture
71    /// when the tier is `ImportanceCorrect`.
72    pub weights: Array1<f64>,
73    /// Kish effective sample size `(Σw)² / Σw²` — how many of the `M` draws are
74    /// "really" contributing after importance weighting.
75    pub effective_sample_size: f64,
76}
77
78/// One node of the criterion-closure Tier-1 mixture (#938): a `ρ` location, its
79/// normalized posterior mass, and the exact profiled criterion value there.
80#[derive(Debug, Clone)]
81pub struct RhoMixtureNode {
82    /// Smoothing parameters at this node.
83    pub rho: Array1<f64>,
84    /// Normalized node probability `w_m ∝ exp(−criterion(ρ_m) + criterion(ρ̂)) ×
85    /// GH weight × exp(½‖z_m‖²)`.
86    pub weight: f64,
87    /// Normalized log node probability.
88    pub log_weight: f64,
89    /// Exact profiled criterion value at the node (`+∞` for infeasible nodes,
90    /// which carry zero weight).
91    pub cost: f64,
92}
93
94/// Tier-1 deliverable (#938): `π(ρ|y)` as a discrete mixture of conditional
95/// Gaussians, with the posterior moment summary of `ρ` itself.
96///
97/// The conditional Gaussian at each node is exactly what the engine already
98/// produces at fixed `ρ`; this struct owns the node locations and weights, and
99/// `mixture_coefficient_covariance` (monolith `inference::rho_posterior`)
100/// assembles the mixture-corrected coefficient covariance from per-node
101/// conditionals supplied by the caller.
102#[derive(Debug, Clone)]
103pub struct RhoPosteriorMixture {
104    /// Quadrature nodes with normalized weights (weights sum to 1).
105    pub nodes: Vec<RhoMixtureNode>,
106    /// Posterior mean of `ρ`: `Σ_m w_m ρ_m`.
107    pub mean: Array1<f64>,
108    /// Posterior covariance of `ρ`: `Σ_m w_m (ρ_m−ρ̄)(ρ_m−ρ̄)ᵀ`.
109    pub covariance: Array2<f64>,
110    /// Kish ESS of the node weights `(Σw)²/Σw²` — how non-Gaussian the exact
111    /// posterior is relative to the Laplace proposal (max = node count).
112    pub effective_sample_size: f64,
113}
114
115/// Tier-2 deliverable (#938): `π(ρ|y)` draws from NUTS with the exact profiled
116/// gradient, whitened by the exact outer Hessian at `ρ̂`.
117#[derive(Debug, Clone)]
118pub struct RhoPosteriorSamples {
119    /// Draws in ρ space: `(n_draws, K)`.
120    pub samples: Array2<f64>,
121    /// Posterior mean of `ρ`.
122    pub mean: Array1<f64>,
123    /// Posterior covariance of `ρ` (sample covariance of the draws).
124    pub covariance: Array2<f64>,
125    /// Split-chain R̂ mixing diagnostic.
126    pub rhat: f64,
127    /// Effective sample size.
128    pub ess: f64,
129    /// Whether the chains mixed (R̂ < 1.1).
130    pub converged: bool,
131}
132
133/// The auto-selected escalation outcome when the Tier-0 certificate reads
134/// [`RhoCertificate::Escalate`] (#938): Tier 1 (deterministic quadrature) for
135/// `K ≤ 4`, Tier 2 (NUTS over `ρ`) for `K ≤ 16`, and an HONEST report that
136/// escalation is unavailable beyond that — never a silently-degraded answer.
137#[derive(Debug, Clone)]
138pub enum RhoPosteriorEscalation {
139    /// Tier 1: deterministic Gauss-Hermite mixture (`K ≤ 4`).
140    Quadrature(RhoPosteriorMixture),
141    /// Tier 2: NUTS draws with the exact profiled gradient (`5 ≤ K ≤ 16`).
142    Nuts(RhoPosteriorSamples),
143    /// Escalation could not run (dimension beyond the NUTS cap, or the chosen
144    /// tier failed); intervals remain plug-in + first-order corrected, and the
145    /// fit reports WHY.
146    Unavailable { n_params: usize, reason: String },
147}
148
149// ───────────────────────── injected escalator trait (#1521) ──────────────────
150
151/// The gam-inference-tier producer of the Tier-0 `ρ`-certificate and the
152/// auto-selected Tier-1/Tier-2 escalation (trait-inversion #1521).
153///
154/// The COMPUTATION — the PSIS certificate, the Gauss-Hermite quadrature, and
155/// the Tier-2 NUTS over `ρ` — pulls the gam-inference `hmc_io` sampler, so it
156/// STAYS UP in the monolith `inference::rho_posterior`. That module implements
157/// this trait over its real `rho_posterior_certificate` / `escalate_rho_posterior`
158/// functions and injects the impl DOWN via [`set_rho_posterior_escalator`];
159/// gam-solve's REML evaluator calls THROUGH [`rho_posterior_escalator`]. Only
160/// neutral types (ndarray + the contract-downed `ρ`-posterior carriers) and
161/// caller-supplied criterion closures cross this surface — no gam-inference type
162/// is threaded, so the trait can live in this neutral crate.
163///
164/// When no impl is registered (a build that never links the sampler tier) the
165/// getter returns `None` and gam-solve declines the certificate/escalation
166/// entirely (`(None, None)`), leaving the plug-in + first-order intervals — its
167/// existing decline outcome, no behavioral cliff and no stub.
168pub trait RhoPosteriorEscalator: Send + Sync {
169    /// Tier-0 PSIS `ρ`-certificate. `criterion` evaluates the outer criterion
170    /// `−log π(ρ|y)` at a trial `ρ` (`None` for infeasible `ρ`). Returns `None`
171    /// when the certificate cannot be formed (see the monolith implementation).
172    fn rho_posterior_certificate(
173        &self,
174        rho_hat: &Array1<f64>,
175        outer_hessian: &Array2<f64>,
176        criterion: &dyn Fn(&Array1<f64>) -> Option<f64>,
177        n_samples: Option<usize>,
178    ) -> Option<RhoPosteriorCertificate>;
179
180    /// Auto-selected escalation (Tier-1 quadrature / Tier-2 NUTS / honest
181    /// `Unavailable`). `criterion` returns the exact profiled criterion value,
182    /// `criterion_and_grad` the value plus the exact LAML `ρ`-gradient; both are
183    /// `None` for infeasible `ρ`.
184    fn escalate_rho_posterior(
185        &self,
186        rho_hat: &Array1<f64>,
187        outer_hessian: &Array2<f64>,
188        criterion: &mut dyn FnMut(&Array1<f64>) -> Option<f64>,
189        criterion_and_grad: &mut (dyn FnMut(&Array1<f64>) -> Option<(f64, Array1<f64>)> + Send),
190    ) -> RhoPosteriorEscalation;
191}
192
193static RHO_POSTERIOR_ESCALATOR: OnceLock<Box<dyn RhoPosteriorEscalator>> = OnceLock::new();
194
195/// Register the monolith's `hmc_io`-backed `ρ`-posterior certificate/escalation
196/// producer. Called once at process init by the gam-inference tier. First writer
197/// wins; a later call is ignored (returns `Err` with the boxed value) so a
198/// re-init can never swap a live producer mid-run.
199pub fn set_rho_posterior_escalator(
200    escalator: Box<dyn RhoPosteriorEscalator>,
201) -> Result<(), Box<dyn RhoPosteriorEscalator>> {
202    RHO_POSTERIOR_ESCALATOR.set(escalator)
203}
204
205/// The registered `ρ`-posterior certificate/escalation producer, or `None` when
206/// the sampler tier is not linked / not yet initialized (gam-solve then declines
207/// the certificate and escalation — a safe no-op leaving plug-in intervals).
208pub fn rho_posterior_escalator() -> Option<&'static dyn RhoPosteriorEscalator> {
209    RHO_POSTERIOR_ESCALATOR.get().map(|b| b.as_ref())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn from_k_hat_below_half_is_plug_in_certified() {
218        assert_eq!(
219            RhoCertificate::from_k_hat(0.0),
220            RhoCertificate::PlugInCertified
221        );
222        assert_eq!(
223            RhoCertificate::from_k_hat(0.499),
224            RhoCertificate::PlugInCertified
225        );
226    }
227
228    #[test]
229    fn from_k_hat_between_half_and_point_seven_is_importance_correct() {
230        assert_eq!(
231            RhoCertificate::from_k_hat(0.5),
232            RhoCertificate::ImportanceCorrect
233        );
234        assert_eq!(
235            RhoCertificate::from_k_hat(0.7),
236            RhoCertificate::ImportanceCorrect
237        );
238        assert_eq!(
239            RhoCertificate::from_k_hat(0.65),
240            RhoCertificate::ImportanceCorrect
241        );
242    }
243
244    #[test]
245    fn from_k_hat_above_point_seven_is_escalate() {
246        assert_eq!(RhoCertificate::from_k_hat(0.701), RhoCertificate::Escalate);
247        assert_eq!(RhoCertificate::from_k_hat(10.0), RhoCertificate::Escalate);
248    }
249
250    #[test]
251    fn from_k_hat_nan_is_escalate() {
252        assert_eq!(
253            RhoCertificate::from_k_hat(f64::NAN),
254            RhoCertificate::Escalate
255        );
256    }
257
258    #[test]
259    fn from_k_hat_infinity_is_escalate() {
260        assert_eq!(
261            RhoCertificate::from_k_hat(f64::INFINITY),
262            RhoCertificate::Escalate
263        );
264    }
265
266    #[test]
267    fn rho_posterior_escalator_returns_none_when_unregistered() {
268        // In tests, the monolith escalator is never injected — expect None.
269        assert!(rho_posterior_escalator().is_none());
270    }
271}