Skip to main content

tacet_core/adaptive/
posterior.rs

1//! Posterior distribution representation for Bayesian inference.
2//!
3//! The posterior is Gaussian: δ | Δ ~ N(δ_post, Λ_post) where:
4//! - δ ∈ ℝ⁹ is the per-decile timing difference vector
5//! - Δ is the observed quantile difference vector
6//!
7//! For interpretability, the 9D posterior is projected to 2D (shift, tail)
8//! using GLS: β_proj = (X'Σ_n⁻¹X)⁻¹ X'Σ_n⁻¹ δ_post
9
10extern crate alloc;
11use alloc::vec::Vec;
12
13use crate::math::sqrt;
14use crate::result::{EffectEstimate, EffectPattern, MeasurementQuality};
15use crate::types::{Matrix2, Matrix9, Vector2, Vector9};
16
17// Note: classify_pattern from analysis::effect is no longer used here.
18// We now use classify_pattern_from_draws() which uses β draws directly.
19
20/// Posterior distribution parameters for the 9D effect vector δ.
21///
22/// The posterior is Gaussian: δ | Δ ~ N(δ_post, Λ_post) where each δ_k
23/// represents the timing difference at decile k.
24///
25/// The 2D projection β_proj = (μ, τ) provides shift/tail decomposition.
26///
27/// Uses Student's t prior (ν=4) via Gibbs sampling for robust inference.
28#[derive(Clone, Debug)]
29pub struct Posterior {
30    /// 9D posterior mean δ_post in nanoseconds.
31    pub delta_post: Vector9,
32
33    /// 9D posterior covariance Λ_post.
34    pub lambda_post: Matrix9,
35
36    /// 2D GLS projection β = (μ, τ) for interpretability.
37    /// - β[0] = μ (uniform shift)
38    /// - β[1] = τ (tail effect)
39    pub beta_proj: Vector2,
40
41    /// 2D projection covariance (empirical, from draws).
42    pub beta_proj_cov: Matrix2,
43
44    /// Retained β draws for dominance-based classification (spec §3.4.6).
45    /// Each draw is β^(s) = A·δ^(s) where A is the GLS projection matrix.
46    pub beta_draws: Vec<Vector2>,
47
48    /// Leak probability: P(max_k |δ_k| > θ | Δ).
49    /// Computed via Monte Carlo integration over the 9D posterior.
50    pub leak_probability: f64,
51
52    /// Projection mismatch Q statistic.
53    /// Q = r'Σ_n⁻¹r where r = δ_post - X β_proj.
54    /// High values indicate the 2D summary is approximate.
55    pub projection_mismatch_q: f64,
56
57    /// Number of samples used in this posterior computation.
58    pub n: usize,
59
60    // ==================== Gibbs sampler fields ====================
61    /// Posterior mean of latent scale λ.
62    /// `None` if using simple posterior (no Gibbs sampler).
63    pub lambda_mean: Option<f64>,
64
65    /// Whether the Gibbs sampler's lambda chain mixed well.
66    /// `None` if using simple posterior.
67    /// When `Some(false)`, indicates potential posterior unreliability.
68    pub lambda_mixing_ok: Option<bool>,
69
70    /// Posterior mean of likelihood precision κ.
71    /// `None` if using simple posterior.
72    pub kappa_mean: Option<f64>,
73
74    /// Coefficient of variation of κ.
75    /// `None` if using simple posterior.
76    pub kappa_cv: Option<f64>,
77
78    /// Effective sample size of κ chain.
79    /// `None` if using simple posterior.
80    pub kappa_ess: Option<f64>,
81
82    /// Whether the Gibbs sampler's kappa chain mixed well.
83    /// `None` if using simple posterior.
84    pub kappa_mixing_ok: Option<bool>,
85}
86
87impl Posterior {
88    /// Create a new posterior with given parameters.
89    #[allow(clippy::too_many_arguments)]
90    pub fn new(
91        delta_post: Vector9,
92        lambda_post: Matrix9,
93        beta_proj: Vector2,
94        beta_proj_cov: Matrix2,
95        beta_draws: Vec<Vector2>,
96        leak_probability: f64,
97        projection_mismatch_q: f64,
98        n: usize,
99    ) -> Self {
100        Self {
101            delta_post,
102            lambda_post,
103            beta_proj,
104            beta_proj_cov,
105            beta_draws,
106            leak_probability,
107            projection_mismatch_q,
108            n,
109            lambda_mean: None,      // v5.4: no Gibbs sampler
110            lambda_mixing_ok: None, // v5.4: no Gibbs sampler
111            kappa_mean: None,       // v5.6: no Gibbs sampler
112            kappa_cv: None,         // v5.6: no Gibbs sampler
113            kappa_ess: None,        // v5.6: no Gibbs sampler
114            kappa_mixing_ok: None,  // v5.6: no Gibbs sampler
115        }
116    }
117
118    /// Create a new posterior with Gibbs sampler diagnostics (v5.4, v5.6).
119    #[allow(clippy::too_many_arguments)]
120    pub fn new_with_gibbs(
121        delta_post: Vector9,
122        lambda_post: Matrix9,
123        beta_proj: Vector2,
124        beta_proj_cov: Matrix2,
125        beta_draws: Vec<Vector2>,
126        leak_probability: f64,
127        projection_mismatch_q: f64,
128        n: usize,
129        lambda_mean: f64,
130        lambda_mixing_ok: bool,
131        kappa_mean: f64,
132        kappa_cv: f64,
133        kappa_ess: f64,
134        kappa_mixing_ok: bool,
135    ) -> Self {
136        Self {
137            delta_post,
138            lambda_post,
139            beta_proj,
140            beta_proj_cov,
141            beta_draws,
142            leak_probability,
143            projection_mismatch_q,
144            n,
145            lambda_mean: Some(lambda_mean),
146            lambda_mixing_ok: Some(lambda_mixing_ok),
147            kappa_mean: Some(kappa_mean),
148            kappa_cv: Some(kappa_cv),
149            kappa_ess: Some(kappa_ess),
150            kappa_mixing_ok: Some(kappa_mixing_ok),
151        }
152    }
153
154    /// Get the shift component (μ) from the 2D projection.
155    #[inline]
156    pub fn shift_ns(&self) -> f64 {
157        self.beta_proj[0]
158    }
159
160    /// Get the tail component (τ) from the 2D projection.
161    #[inline]
162    pub fn tail_ns(&self) -> f64 {
163        self.beta_proj[1]
164    }
165
166    /// Get the standard error of the shift component.
167    #[inline]
168    pub fn shift_se(&self) -> f64 {
169        sqrt(self.beta_proj_cov[(0, 0)])
170    }
171
172    /// Get the standard error of the tail component.
173    #[inline]
174    pub fn tail_se(&self) -> f64 {
175        sqrt(self.beta_proj_cov[(1, 1)])
176    }
177
178    /// Get the max absolute effect across all deciles.
179    pub fn max_effect_ns(&self) -> f64 {
180        self.delta_post
181            .iter()
182            .map(|x| x.abs())
183            .fold(0.0_f64, f64::max)
184    }
185
186    /// Build an EffectEstimate from this posterior.
187    ///
188    /// This computes the credible interval and classifies the effect pattern
189    /// using dominance-based classification from the β draws (spec §3.4.6).
190    pub fn to_effect_estimate(&self) -> EffectEstimate {
191        // Classify pattern using dominance-based approach from draws
192        let pattern = self.classify_pattern_from_draws();
193
194        // Compute credible interval from posterior covariance
195        let shift_std = self.shift_se();
196        let tail_std = self.tail_se();
197        let total_effect =
198            sqrt(self.shift_ns() * self.shift_ns() + self.tail_ns() * self.tail_ns());
199        let total_std = sqrt((shift_std * shift_std + tail_std * tail_std) / 2.0);
200
201        let ci_low = (total_effect - 1.96 * total_std).max(0.0);
202        let ci_high = total_effect + 1.96 * total_std;
203
204        EffectEstimate {
205            shift_ns: self.shift_ns(),
206            tail_ns: self.tail_ns(),
207            credible_interval_ns: (ci_low, ci_high),
208            pattern,
209            interpretation_caveat: None,
210        }
211    }
212
213    /// Classify effect pattern using dominance-based approach from β draws (spec §3.4.6).
214    ///
215    /// Uses posterior draws to compute dominance probabilities rather than
216    /// relying on "statistical significance" (|effect| > 2×SE), which is
217    /// brittle under covariance regularization.
218    ///
219    /// Classification rules (dominance is primary):
220    /// - UniformShift: shift dominates tail (|μ| ≥ 5|τ| with ≥80% probability)
221    /// - TailEffect: tail dominates shift (|τ| ≥ 5|μ| with ≥80% probability)
222    /// - Mixed: both practically significant and neither dominates
223    /// - Indeterminate: effect too small or uncertain to classify
224    fn classify_pattern_from_draws(&self) -> EffectPattern {
225        // Dominance ratio threshold: one effect must be 5x larger to dominate
226        const DOMINANCE_RATIO: f64 = 5.0;
227        // Probability threshold for dominance classification
228        const DOMINANCE_PROB: f64 = 0.80;
229        // Threshold for effect to be "practically significant" (absolute, in ns)
230        // Effects smaller than this are considered noise
231        const MIN_SIGNIFICANT_NS: f64 = 10.0;
232
233        if self.beta_draws.is_empty() {
234            // Fallback to point estimate if no draws available
235            return self.classify_pattern_point_estimate();
236        }
237
238        let n = self.beta_draws.len() as f64;
239
240        // Count draws where each condition holds
241        let mut shift_significant_count = 0;
242        let mut tail_significant_count = 0;
243        let mut shift_dominates_count = 0;
244        let mut tail_dominates_count = 0;
245
246        for beta in &self.beta_draws {
247            let shift_abs = beta[0].abs();
248            let tail_abs = beta[1].abs();
249
250            // Check if components are practically significant (absolute threshold)
251            if shift_abs > MIN_SIGNIFICANT_NS {
252                shift_significant_count += 1;
253            }
254            if tail_abs > MIN_SIGNIFICANT_NS {
255                tail_significant_count += 1;
256            }
257
258            // Check dominance (with safety for near-zero values)
259            if tail_abs < 1e-12 || shift_abs >= DOMINANCE_RATIO * tail_abs {
260                shift_dominates_count += 1;
261            }
262            if shift_abs < 1e-12 || tail_abs >= DOMINANCE_RATIO * shift_abs {
263                tail_dominates_count += 1;
264            }
265        }
266
267        // Compute probabilities
268        let p_shift_significant = shift_significant_count as f64 / n;
269        let p_tail_significant = tail_significant_count as f64 / n;
270        let p_shift_dominates = shift_dominates_count as f64 / n;
271        let p_tail_dominates = tail_dominates_count as f64 / n;
272
273        // Classification: dominance is the primary criterion
274        // If one component dominates in most draws, that determines the pattern
275        if p_shift_dominates >= DOMINANCE_PROB {
276            EffectPattern::UniformShift
277        } else if p_tail_dominates >= DOMINANCE_PROB {
278            EffectPattern::TailEffect
279        } else if p_shift_significant >= DOMINANCE_PROB && p_tail_significant >= DOMINANCE_PROB {
280            // Neither dominates but both are significant -> Mixed
281            EffectPattern::Mixed
282        } else {
283            EffectPattern::Indeterminate
284        }
285    }
286
287    /// Fallback classification using point estimates when no draws available.
288    fn classify_pattern_point_estimate(&self) -> EffectPattern {
289        const DOMINANCE_RATIO: f64 = 5.0;
290
291        let shift_abs = self.shift_ns().abs();
292        let tail_abs = self.tail_ns().abs();
293        let shift_se = self.shift_se();
294        let tail_se = self.tail_se();
295
296        // Check statistical significance (|effect| > 2×SE)
297        let shift_significant = shift_abs > 2.0 * shift_se;
298        let tail_significant = tail_abs > 2.0 * tail_se;
299
300        match (shift_significant, tail_significant) {
301            (true, false) => EffectPattern::UniformShift,
302            (false, true) => EffectPattern::TailEffect,
303            (true, true) => {
304                // Both significant - check dominance
305                if shift_abs > tail_abs * DOMINANCE_RATIO {
306                    EffectPattern::UniformShift
307                } else if tail_abs > shift_abs * DOMINANCE_RATIO {
308                    EffectPattern::TailEffect
309                } else {
310                    EffectPattern::Mixed
311                }
312            }
313            (false, false) => EffectPattern::Indeterminate,
314        }
315    }
316
317    /// Get measurement quality based on the effect standard error.
318    ///
319    /// Quality is determined by the minimum detectable effect (MDE),
320    /// which is approximately 2x the effect standard error.
321    pub fn measurement_quality(&self) -> MeasurementQuality {
322        let effect_se = self.shift_se();
323        MeasurementQuality::from_mde_ns(effect_se * 2.0)
324    }
325
326    /// Convert to an FFI-friendly summary containing only scalar fields.
327    ///
328    /// This uses the canonical effect pattern classification from draws (spec §3.4.6)
329    /// rather than the simpler heuristics that were previously duplicated in bindings.
330    pub fn to_summary(&self) -> crate::ffi_summary::PosteriorSummary {
331        let effect = self.to_effect_estimate();
332
333        crate::ffi_summary::PosteriorSummary {
334            shift_ns: self.shift_ns(),
335            tail_ns: self.tail_ns(),
336            shift_se: self.shift_se(),
337            tail_se: self.tail_se(),
338            ci_low_ns: effect.credible_interval_ns.0,
339            ci_high_ns: effect.credible_interval_ns.1,
340            pattern: effect.pattern,
341            leak_probability: self.leak_probability,
342            projection_mismatch_q: self.projection_mismatch_q,
343            n: self.n,
344            lambda_mean: self.lambda_mean.unwrap_or(1.0),
345            lambda_mixing_ok: self.lambda_mixing_ok.unwrap_or(true),
346            kappa_mean: self.kappa_mean.unwrap_or(1.0),
347            kappa_cv: self.kappa_cv.unwrap_or(0.0),
348            kappa_ess: self.kappa_ess.unwrap_or(0.0),
349            kappa_mixing_ok: self.kappa_mixing_ok.unwrap_or(true),
350        }
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_posterior_accessors() {
360        let delta_post =
361            Vector9::from_row_slice(&[10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0]);
362        let lambda_post = Matrix9::identity();
363        let beta_proj = Vector2::new(10.0, 0.0);
364        let beta_proj_cov = Matrix2::new(4.0, 0.0, 0.0, 1.0);
365
366        let posterior = Posterior::new(
367            delta_post,
368            lambda_post,
369            beta_proj,
370            beta_proj_cov,
371            Vec::new(), // beta_draws
372            0.75,
373            0.5,
374            1000,
375        );
376
377        assert_eq!(posterior.shift_ns(), 10.0);
378        assert_eq!(posterior.tail_ns(), 0.0);
379        assert_eq!(posterior.shift_se(), 2.0); // sqrt(4.0)
380        assert_eq!(posterior.tail_se(), 1.0); // sqrt(1.0)
381        assert_eq!(posterior.leak_probability, 0.75);
382        assert_eq!(posterior.n, 1000);
383        assert!((posterior.max_effect_ns() - 10.0).abs() < 1e-10);
384    }
385
386    #[test]
387    fn test_posterior_clone() {
388        let delta_post = Vector9::from_row_slice(&[5.0; 9]);
389        let lambda_post = Matrix9::identity();
390        let beta_proj = Vector2::new(5.0, 3.0);
391        let beta_proj_cov = Matrix2::identity();
392
393        let posterior = Posterior::new(
394            delta_post,
395            lambda_post,
396            beta_proj,
397            beta_proj_cov,
398            Vec::new(), // beta_draws
399            0.5,
400            1.0,
401            500,
402        );
403
404        let cloned = posterior.clone();
405        assert_eq!(cloned.shift_ns(), posterior.shift_ns());
406        assert_eq!(cloned.tail_ns(), posterior.tail_ns());
407        assert_eq!(cloned.leak_probability, posterior.leak_probability);
408    }
409}