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}