Skip to main content

antecedent_validate/
bayesian_checks.rs

1//! Prior/posterior predictive checks and prior sensitivity.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5#![allow(
6    clippy::cast_precision_loss,
7    clippy::cast_possible_truncation,
8    clippy::needless_range_loop,
9    clippy::too_many_lines,
10    clippy::many_single_char_names
11)]
12
13use std::sync::Arc;
14
15use antecedent_core::{CausalRng, ExecutionContext, KernelPolicy};
16use antecedent_estimate::{
17    BayesianGCompWorkspace, BayesianGComputationAte, CausalPosterior, PreparedBayesianProblem,
18};
19use antecedent_identify::IdentificationStatus;
20use antecedent_kernels::{PosteriorReduceOp, reduce_posterior_draws, standard_normal};
21use antecedent_prob::{
22    BayesDesignRef, BayesFitOptions, BayesLikelihood, ExternalPriorSource, HessianFactorization,
23    InferenceBackend, LaplaceGlmBackend, LaplaceWorkspace, PriorSensitivitySummary, PriorSet,
24    compose_external_priors_with_alphas,
25};
26use antecedent_stats::GlmFamily;
27
28use crate::common::RefutationReport;
29use crate::error::ValidationError;
30
31/// Result of a prior or posterior predictive check.
32#[derive(Clone, Debug)]
33pub struct PredictiveCheckReport {
34    /// Check kind.
35    pub kind: PredictiveCheckKind,
36    /// Observed summary statistic (e.g. outcome mean).
37    pub observed: f64,
38    /// Mean of the predictive summary across simulations.
39    pub predictive_mean: f64,
40    /// SD of the predictive summary.
41    pub predictive_sd: f64,
42    /// Two-sided tail probability of `observed` under the predictive distribution.
43    pub p_value: f64,
44    /// Number of predictive simulations.
45    pub n_sims: u32,
46}
47
48impl PredictiveCheckReport {
49    /// Convert to a suite [`RefutationReport`] using a two-sided α threshold on `p_value`.
50    #[must_use]
51    pub fn to_refutation_report(&self, original_ate: f64, alpha: f64) -> RefutationReport {
52        let name = match self.kind {
53            PredictiveCheckKind::Prior => "prior_predictive",
54            PredictiveCheckKind::Posterior => "posterior_predictive",
55        };
56        let passed = self.p_value.is_finite() && self.p_value >= alpha;
57        RefutationReport {
58            refuter: Arc::from(name),
59            original_ate,
60            refuted_ate: self.predictive_mean,
61            comparison: self.p_value,
62            informative: true,
63            passed,
64            failure_condition: if passed {
65                None
66            } else {
67                Some(Arc::from(format!(
68                    "predictive check failed (p={} < alpha={alpha})",
69                    self.p_value
70                )))
71            },
72            replicates: self.n_sims,
73        }
74    }
75}
76
77/// Prior vs posterior predictive.
78#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
79pub enum PredictiveCheckKind {
80    /// Simulate from the prior predictive.
81    Prior,
82    /// Simulate from the posterior predictive.
83    Posterior,
84}
85
86/// Prior predictive check using coefficient draws from a prior (no data update)
87/// vs observed outcome mean.
88#[derive(Clone, Debug)]
89pub struct PriorPredictiveCheck {
90    /// Simulations.
91    pub n_sims: u32,
92    /// RNG seed.
93    pub seed: u64,
94    /// Mean family (inverse link applied to η before summarizing).
95    pub family: GlmFamily,
96}
97
98impl Default for PriorPredictiveCheck {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104impl PriorPredictiveCheck {
105    /// Default 200 sims, Gaussian identity.
106    #[must_use]
107    pub fn new() -> Self {
108        Self { n_sims: 200, seed: 0, family: GlmFamily::GaussianIdentity }
109    }
110
111    /// Run against a prepared Bayesian design with a weakly informative prior.
112    ///
113    /// Prefer [`Self::check_with_prior`] when an analysis / composed prior is known.
114    ///
115    /// # Errors
116    ///
117    /// Empty design.
118    pub fn check(
119        &self,
120        problem: &PreparedBayesianProblem,
121        ctx: &ExecutionContext,
122    ) -> Result<PredictiveCheckReport, ValidationError> {
123        let p = problem.design.ncols;
124        let prior = PriorSet::weakly_informative(p);
125        self.check_with_prior(problem, &prior, ctx)
126    }
127
128    /// Run prior predictive check under an explicit coefficient prior.
129    ///
130    /// # Errors
131    ///
132    /// Empty design or missing Gaussian coefficient prior.
133    pub fn check_with_prior(
134        &self,
135        problem: &PreparedBayesianProblem,
136        prior: &PriorSet,
137        _ctx: &ExecutionContext,
138    ) -> Result<PredictiveCheckReport, ValidationError> {
139        let n = problem.design.nrows;
140        let p = problem.design.ncols;
141        if n == 0 || p == 0 {
142            return Err(ValidationError::estimation_msg("empty design for PPC"));
143        }
144        let observed = problem.design.outcome.iter().sum::<f64>() / n as f64;
145        let mut rng = CausalRng::from_seed(self.seed);
146        let coef_prior = prior.gaussian_coefficients().ok_or_else(|| {
147            ValidationError::estimation_msg("prior missing Gaussian coefficients for PPC")
148        })?;
149        if coef_prior.len() != p {
150            return Err(ValidationError::estimation_msg(
151                "prior coefficient dimension mismatch for PPC",
152            ));
153        }
154        let mut summaries = Vec::with_capacity(self.n_sims as usize);
155        let mut beta = vec![0.0; p];
156        for _ in 0..self.n_sims {
157            // Draw β ~ prior once per simulation, then μ_i = g^{-1}(x_i'β).
158            for c in 0..p {
159                beta[c] =
160                    coef_prior.mean[c] + coef_prior.variance[c].sqrt() * standard_normal(&mut rng);
161            }
162            let mut mean_y = 0.0;
163            for r in 0..n {
164                let mut eta = 0.0;
165                for c in 0..p {
166                    eta += problem.design.matrix[c * n + r] * beta[c];
167                }
168                mean_y += self.family.mean_from_eta(eta);
169            }
170            summaries.push(mean_y / n as f64);
171        }
172        Ok(summarize_check(PredictiveCheckKind::Prior, observed, &summaries, self.n_sims))
173    }
174}
175
176/// Posterior predictive check: resample outcome means from posterior coefficient draws.
177#[derive(Clone, Debug)]
178pub struct PosteriorPredictiveCheck {
179    /// Number of posterior draws to use (capped by available).
180    pub n_sims: u32,
181    /// Mean family (inverse link applied to η before summarizing).
182    pub family: GlmFamily,
183}
184
185impl Default for PosteriorPredictiveCheck {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191impl PosteriorPredictiveCheck {
192    /// Default Gaussian identity.
193    #[must_use]
194    pub fn new() -> Self {
195        Self { n_sims: 200, family: GlmFamily::GaussianIdentity }
196    }
197
198    /// Check using a fitted [`CausalPosterior`] that includes coefficient columns.
199    ///
200    /// # Errors
201    ///
202    /// Missing coefficients / empty draws.
203    pub fn check(
204        &self,
205        problem: &PreparedBayesianProblem,
206        posterior: &CausalPosterior,
207    ) -> Result<PredictiveCheckReport, ValidationError> {
208        let n = problem.design.nrows;
209        let p = problem.design.ncols;
210        let observed = problem.design.outcome.iter().sum::<f64>() / n as f64;
211        let n_draws = posterior.draws.n_draws.min(self.n_sims as usize);
212        if n_draws == 0 {
213            return Err(ValidationError::estimation_msg("no posterior draws for PPC"));
214        }
215        let mut summaries = Vec::with_capacity(n_draws);
216        for d in 0..n_draws {
217            let mut mean_y = 0.0;
218            for r in 0..n {
219                let mut eta = 0.0;
220                for c in 0..p {
221                    let x = problem.design.matrix[c * n + r];
222                    let b = posterior.draws.get(d, c).map_err(ValidationError::from)?;
223                    eta += x * b;
224                }
225                mean_y += self.family.mean_from_eta(eta);
226            }
227            summaries.push(mean_y / n as f64);
228        }
229        Ok(summarize_check(PredictiveCheckKind::Posterior, observed, &summaries, n_draws as u32))
230    }
231}
232
233/// Default max relative range of effect means across the prior-sensitivity grid.
234pub const DEFAULT_MAX_RELATIVE_PRIOR_RANGE: f64 = 0.5;
235
236/// Prior sensitivity grid: isotropic scales **or** external α multipliers.
237#[derive(Clone, Debug)]
238pub struct PriorSensitivity {
239    /// Prior scales (σ of isotropic Gaussian coefficient prior). Empty in α mode.
240    pub scales: Arc<[f64]>,
241    /// Multipliers on post-conflict applied alphas. Empty in isotropic scale mode.
242    pub alphas: Arc<[f64]>,
243    /// Fail when `(max−min) / scale` exceeds this, where `scale` is
244    /// `max(|means…|, |original_ate|, ε)`.
245    pub max_relative_range: f64,
246}
247
248/// Inputs for external α-multiplier prior sensitivity.
249#[derive(Clone, Copy, Debug)]
250pub struct ExternalAlphaSensitivity<'a> {
251    /// Hydrated external sources (same order as composition).
252    pub sources: &'a [ExternalPriorSource],
253    /// Post-conflict applied alphas (length must match `sources`).
254    pub alphas_applied: &'a [f64],
255}
256
257impl Default for PriorSensitivity {
258    fn default() -> Self {
259        Self::standard_grid()
260    }
261}
262
263impl PriorSensitivity {
264    /// Standard isotropic grid `{0.5, 1, 2, 5, 10, 20}` with [`DEFAULT_MAX_RELATIVE_PRIOR_RANGE`].
265    #[must_use]
266    pub fn standard_grid() -> Self {
267        Self {
268            scales: Arc::from(vec![0.5, 1.0, 2.0, 5.0, 10.0, 20.0]),
269            alphas: Arc::from([]),
270            max_relative_range: DEFAULT_MAX_RELATIVE_PRIOR_RANGE,
271        }
272    }
273
274    /// Standard external-α multiplier grid `{0, 0.25, 0.5, 0.75, 1}`.
275    ///
276    /// Multiplier `0` is baseline-only; `1` uses full post-conflict applied alphas.
277    #[must_use]
278    pub fn standard_alpha_grid() -> Self {
279        Self {
280            scales: Arc::from([]),
281            alphas: Arc::from(vec![0.0, 0.25, 0.5, 0.75, 1.0]),
282            max_relative_range: DEFAULT_MAX_RELATIVE_PRIOR_RANGE,
283        }
284    }
285
286    fn grid_len(&self) -> usize {
287        if self.alphas.is_empty() { self.scales.len() } else { self.alphas.len() }
288    }
289
290    /// Refit Bayesian g-comp at each prior scale; return sensitivity summary.
291    ///
292    /// # Errors
293    ///
294    /// Fit failures or empty scale grid.
295    pub fn evaluate(
296        &self,
297        estimator: &BayesianGComputationAte,
298        problem: &PreparedBayesianProblem,
299        identification: IdentificationStatus,
300        workspace: &mut BayesianGCompWorkspace,
301        ctx: &ExecutionContext,
302    ) -> Result<(PriorSensitivitySummary, Vec<CausalPosterior>), ValidationError> {
303        if self.scales.is_empty() {
304            return Err(ValidationError::estimation_msg(
305                "prior sensitivity scale grid is empty (use evaluate_external_alpha for α mode)",
306            ));
307        }
308        let mut means = Vec::with_capacity(self.scales.len());
309        let mut sds = Vec::with_capacity(self.scales.len());
310        let mut posts = Vec::with_capacity(self.scales.len());
311        for &scale in self.scales.iter() {
312            let est = BayesianGComputationAte {
313                prior_scale: scale,
314                n_draws: estimator.n_draws.min(200),
315                seed: estimator.seed,
316                backend: estimator.backend,
317                likelihood: estimator.likelihood,
318                overlap: estimator.overlap,
319                prior: None,
320            };
321            let post = est.fit(problem, identification, workspace, ctx).map_err(|e| {
322                ValidationError::estimation_msg(format!("prior sensitivity fit failed: {e}"))
323            })?;
324            let eq = post.effect_column().ok_or_else(|| {
325                ValidationError::estimation_msg("missing effect column in sensitivity fit")
326            })?;
327            means.push(post.summaries.mean[eq]);
328            sds.push(post.summaries.sd[eq]);
329            posts.push(post);
330        }
331        Ok((
332            PriorSensitivitySummary {
333                prior_scales: Arc::clone(&self.scales),
334                alphas: Arc::from([]),
335                effect_means: Arc::from(means),
336                effect_sds: Arc::from(sds),
337            },
338            posts,
339        ))
340    }
341
342    /// Refit at each α-multiplier on post-conflict applied alphas (external prior bank).
343    ///
344    /// For multiplier `m`, composed alphas are `m * alphas_applied[k]` (clamped to `[0, 1]`).
345    ///
346    /// # Errors
347    ///
348    /// Empty α grid, length mismatch, compose failures, or fit failures.
349    pub fn evaluate_external_alpha(
350        &self,
351        estimator: &BayesianGComputationAte,
352        problem: &PreparedBayesianProblem,
353        identification: IdentificationStatus,
354        workspace: &mut BayesianGCompWorkspace,
355        ctx: &ExecutionContext,
356        external: ExternalAlphaSensitivity<'_>,
357    ) -> Result<(PriorSensitivitySummary, Vec<CausalPosterior>), ValidationError> {
358        if self.alphas.is_empty() {
359            return Err(ValidationError::estimation_msg("prior sensitivity alpha grid is empty"));
360        }
361        if external.sources.len() != external.alphas_applied.len() {
362            return Err(ValidationError::estimation_msg(
363                "evaluate_external_alpha: sources / alphas_applied length mismatch",
364            ));
365        }
366        let n_coef = problem.design.ncols;
367        let baseline = PriorSet::weakly_informative(n_coef);
368        let requested: Vec<f64> = external.sources.iter().map(|s| s.weight.alpha).collect();
369        let mut means = Vec::with_capacity(self.alphas.len());
370        let mut sds = Vec::with_capacity(self.alphas.len());
371        let mut posts = Vec::with_capacity(self.alphas.len());
372        for &mult in self.alphas.iter() {
373            if !mult.is_finite() || !(0.0..=1.0).contains(&mult) {
374                return Err(ValidationError::estimation_msg(
375                    "prior sensitivity alpha multiplier must be finite and in [0, 1]",
376                ));
377            }
378            let scaled: Vec<f64> =
379                external.alphas_applied.iter().map(|&a| (a * mult).clamp(0.0, 1.0)).collect();
380            let composed = compose_external_priors_with_alphas(
381                external.sources,
382                &requested,
383                &scaled,
384                &baseline,
385            )
386            .map_err(|e| {
387                ValidationError::estimation_msg(format!("prior sensitivity compose failed: {e}"))
388            })?;
389            let est = BayesianGComputationAte {
390                prior_scale: estimator.prior_scale,
391                n_draws: estimator.n_draws.min(200),
392                seed: estimator.seed,
393                backend: estimator.backend,
394                likelihood: estimator.likelihood,
395                overlap: estimator.overlap,
396                prior: Some(composed.prior),
397            };
398            let post = est.fit(problem, identification, workspace, ctx).map_err(|e| {
399                ValidationError::estimation_msg(format!("prior sensitivity α fit failed: {e}"))
400            })?;
401            let eq = post.effect_column().ok_or_else(|| {
402                ValidationError::estimation_msg("missing effect column in α sensitivity fit")
403            })?;
404            means.push(post.summaries.mean[eq]);
405            sds.push(post.summaries.sd[eq]);
406            posts.push(post);
407        }
408        Ok((
409            PriorSensitivitySummary {
410                prior_scales: Arc::from([]),
411                alphas: Arc::clone(&self.alphas),
412                effect_means: Arc::from(means),
413                effect_sds: Arc::from(sds),
414            },
415            posts,
416        ))
417    }
418
419    /// Convert sensitivity range into a refutation-style report.
420    ///
421    /// Passes when the relative range of effect means is finite and
422    /// `≤ max_relative_range`.
423    #[must_use]
424    pub fn to_report(
425        &self,
426        summary: &PriorSensitivitySummary,
427        original_ate: f64,
428    ) -> RefutationReport {
429        let min = summary.effect_means.iter().copied().fold(f64::INFINITY, f64::min);
430        let max = summary.effect_means.iter().copied().fold(f64::NEG_INFINITY, f64::max);
431        let range = max - min;
432        let denom = summary
433            .effect_means
434            .iter()
435            .copied()
436            .map(f64::abs)
437            .fold(original_ate.abs(), f64::max)
438            .max(1e-8);
439        let relative = range / denom;
440        let passed = relative.is_finite() && relative <= self.max_relative_range;
441        let kind =
442            if summary.alphas.is_empty() { "prior_sensitivity" } else { "prior_sensitivity_alpha" };
443        RefutationReport {
444            refuter: Arc::from(kind),
445            original_ate,
446            refuted_ate: summary.effect_means.last().copied().unwrap_or(original_ate),
447            comparison: relative,
448            informative: true,
449            passed,
450            failure_condition: if passed {
451                None
452            } else {
453                Some(Arc::from(format!(
454                    "prior sensitivity relative range {relative} exceeds max {}",
455                    self.max_relative_range
456                )))
457            },
458            replicates: u32::try_from(self.grid_len()).unwrap_or(u32::MAX),
459        }
460    }
461}
462
463fn summarize_check(
464    kind: PredictiveCheckKind,
465    observed: f64,
466    summaries: &[f64],
467    n_sims: u32,
468) -> PredictiveCheckReport {
469    let policy = KernelPolicy::default_policy();
470    let mean = reduce_posterior_draws(summaries, PosteriorReduceOp::Mean, &policy).unwrap_or(0.0);
471    let sd = reduce_posterior_draws(summaries, PosteriorReduceOp::Std, &policy).unwrap_or(0.0);
472    let n = summaries.len() as f64;
473    let below = summaries.iter().filter(|&&x| x <= observed).count() as f64;
474    let p = (2.0 * (below / n.max(1.0)).min(1.0 - below / n.max(1.0))).min(1.0);
475    PredictiveCheckReport {
476        kind,
477        observed,
478        predictive_mean: mean,
479        predictive_sd: sd,
480        p_value: p,
481        n_sims,
482    }
483}
484
485/// Attach prior sensitivity onto a [`CausalPosterior`].
486#[must_use]
487pub fn with_prior_sensitivity(
488    mut posterior: CausalPosterior,
489    summary: PriorSensitivitySummary,
490) -> CausalPosterior {
491    posterior.prior_sensitivity = Some(summary);
492    posterior
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use antecedent_core::{
499        AverageEffectQuery, CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet,
500        ValueType, VariableId,
501    };
502    use antecedent_data::{
503        Float64Column, OwnedColumn, OwnedColumnarStorage, TabularData, ValidityBitmap,
504    };
505    use antecedent_estimate::{BayesianBackendKind, BayesianGComputationAte};
506    use antecedent_expr::{ExprId, IdentifiedEstimand};
507    use antecedent_identify::IdentificationStatus;
508    use antecedent_prob::{ExternalPriorWeight, GaussianCoefficientPrior, PriorSpec};
509
510    fn toy() -> (TabularData, IdentifiedEstimand, AverageEffectQuery) {
511        let n = 60usize;
512        let mut b = CausalSchemaBuilder::new();
513        b.add_variable(
514            "t",
515            ValueType::Continuous,
516            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
517            None,
518            None,
519            MeasurementSpec::default(),
520        )
521        .unwrap();
522        b.add_variable(
523            "y",
524            ValueType::Continuous,
525            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
526            None,
527            None,
528            MeasurementSpec::default(),
529        )
530        .unwrap();
531        b.add_variable(
532            "z",
533            ValueType::Continuous,
534            SmallRoleSet::from_hint(RoleHint::Context),
535            None,
536            None,
537            MeasurementSpec::default(),
538        )
539        .unwrap();
540        let schema = b.build().unwrap();
541        let t: Vec<f64> = (0..n).map(|i| (i % 2) as f64).collect();
542        let z: Vec<f64> = (0..n).map(|i| i as f64 * 0.05).collect();
543        let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + 0.3 * z[i]).collect();
544        let cols = vec![
545            OwnedColumn::Float64(
546                Float64Column::new(
547                    VariableId::from_raw(0),
548                    Arc::from(t),
549                    ValidityBitmap::all_valid(n),
550                )
551                .unwrap(),
552            ),
553            OwnedColumn::Float64(
554                Float64Column::new(
555                    VariableId::from_raw(1),
556                    Arc::from(y),
557                    ValidityBitmap::all_valid(n),
558                )
559                .unwrap(),
560            ),
561            OwnedColumn::Float64(
562                Float64Column::new(
563                    VariableId::from_raw(2),
564                    Arc::from(z),
565                    ValidityBitmap::all_valid(n),
566                )
567                .unwrap(),
568            ),
569        ];
570        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
571        let estimand = IdentifiedEstimand::backdoor(
572            "backdoor.adjustment",
573            Arc::from([VariableId::from_raw(2)]),
574            ExprId::from_raw(0),
575        );
576        let query =
577            AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
578        (TabularData::new(storage), estimand, query)
579    }
580
581    #[test]
582    fn prior_and_posterior_ppc_run() {
583        let (data, estimand, query) = toy();
584        let bayes = BayesianGComputationAte {
585            backend: BayesianBackendKind::ConjugateGaussian,
586            n_draws: 100,
587            seed: 2,
588            prior_scale: 10.0,
589            ..BayesianGComputationAte::new()
590        };
591        let prep = bayes.prepare(&data, &estimand, &query).unwrap();
592        let ctx = ExecutionContext::for_tests(1);
593        let prior_rep = PriorPredictiveCheck { n_sims: 50, seed: 3, ..PriorPredictiveCheck::new() }
594            .check(&prep, &ctx)
595            .unwrap();
596        assert_eq!(prior_rep.kind, PredictiveCheckKind::Prior);
597        assert!(prior_rep.p_value.is_finite());
598
599        let mut ws = BayesianGCompWorkspace::default();
600        let post = bayes
601            .fit(&prep, IdentificationStatus::NonparametricallyIdentified, &mut ws, &ctx)
602            .unwrap();
603        let post_rep = PosteriorPredictiveCheck { n_sims: 50, ..PosteriorPredictiveCheck::new() }
604            .check(&prep, &post)
605            .unwrap();
606        assert_eq!(post_rep.kind, PredictiveCheckKind::Posterior);
607    }
608
609    #[test]
610    fn prior_sensitivity_grid() {
611        let (data, estimand, query) = toy();
612        let bayes = BayesianGComputationAte {
613            backend: BayesianBackendKind::ConjugateGaussian,
614            n_draws: 80,
615            seed: 4,
616            ..BayesianGComputationAte::new()
617        };
618        let prep = bayes.prepare(&data, &estimand, &query).unwrap();
619        let mut ws = BayesianGCompWorkspace::default();
620        let ctx = ExecutionContext::for_tests(1);
621        let sens = PriorSensitivity {
622            scales: Arc::from(vec![1.0, 10.0, 50.0]),
623            alphas: Arc::from([]),
624            max_relative_range: DEFAULT_MAX_RELATIVE_PRIOR_RANGE,
625        };
626        let (summary, posts) = sens
627            .evaluate(
628                &bayes,
629                &prep,
630                IdentificationStatus::NonparametricallyIdentified,
631                &mut ws,
632                &ctx,
633            )
634            .unwrap();
635        assert_eq!(summary.prior_scales.len(), 3);
636        assert!(summary.alphas.is_empty());
637        assert_eq!(posts.len(), 3);
638        let rep =
639            sens.to_report(&summary, posts[0].summaries.mean[posts[0].effect_column().unwrap()]);
640        assert!(rep.passed);
641    }
642
643    #[test]
644    fn prior_sensitivity_external_alpha_pulls_toward_source() {
645        let fixture: serde_json::Value = serde_json::from_str(include_str!(
646            "../../../conformance/validate/bayesian_checks/expected.json"
647        ))
648        .unwrap();
649        assert!(
650            fixture["contracts"]["prior_sensitivity_full_trust_moves_toward_source"]
651                .as_bool()
652                .unwrap()
653        );
654
655        let (data, estimand, query) = toy();
656        // Data ATE ≈ 2; bank a tight prior with treatment coef mean = 8.
657        let bayes = BayesianGComputationAte {
658            backend: BayesianBackendKind::ConjugateGaussian,
659            n_draws: 120,
660            seed: 7,
661            ..BayesianGComputationAte::new()
662        };
663        let prep = bayes.prepare(&data, &estimand, &query).unwrap();
664        let n = prep.design.ncols;
665        let t_col = prep.design.treatment_column().expect("treatment column");
666        let mut mean = vec![0.0; n];
667        mean[t_col] = 8.0;
668        let mut source_prior = PriorSet::new();
669        source_prior.push(PriorSpec::GaussianCoefficients(GaussianCoefficientPrior {
670            mean: Arc::from(mean),
671            variance: Arc::from(vec![0.05; n]),
672        }));
673        let sources = [ExternalPriorSource {
674            id: Arc::from("survey_a"),
675            prior: source_prior,
676            weight: ExternalPriorWeight::power(1.0).unwrap(),
677        }];
678        let alphas_applied = [1.0_f64];
679        let mut ws = BayesianGCompWorkspace::default();
680        let ctx = ExecutionContext::for_tests(1);
681        let sens = PriorSensitivity::standard_alpha_grid();
682        let (summary, _) = sens
683            .evaluate_external_alpha(
684                &bayes,
685                &prep,
686                IdentificationStatus::NonparametricallyIdentified,
687                &mut ws,
688                &ctx,
689                ExternalAlphaSensitivity { sources: &sources, alphas_applied: &alphas_applied },
690            )
691            .unwrap();
692        assert_eq!(summary.alphas.len(), 5);
693        assert!(summary.prior_scales.is_empty());
694        assert!(summary.effect_means.iter().all(|m| m.is_finite()));
695        let m0 = summary.effect_means[0];
696        let m1 = *summary.effect_means.last().unwrap();
697        // Full trust (m=1) should sit closer to the banked treatment mean than baseline (m=0).
698        assert!(
699            (m1 - 8.0).abs() < (m0 - 8.0).abs(),
700            "m=1 mean {m1} should be closer to 8 than m=0 mean {m0}"
701        );
702        let rep = sens.to_report(&summary, m1);
703        assert_eq!(rep.refuter.as_ref(), "prior_sensitivity_alpha");
704        assert!(rep.informative);
705        assert!(rep.comparison.is_finite() && rep.comparison > 0.0);
706    }
707}
708
709/// MCMC chain diagnostics gate (ESS / R-hat / divergences).
710///
711/// Applicable only when the posterior was produced by an MCMC backend
712/// (`InferenceDiagnostics::factorization == Mcmc`).
713#[derive(Clone, Copy, Debug)]
714pub struct McmcDiagnosticsCheck {
715    /// Maximum acceptable split-Ř.
716    pub max_rhat: f64,
717    /// Minimum acceptable bulk ESS.
718    pub min_ess: f64,
719    /// Maximum acceptable divergence count.
720    pub max_divergences: u32,
721}
722
723impl Default for McmcDiagnosticsCheck {
724    fn default() -> Self {
725        Self { max_rhat: 1.05, min_ess: 10.0, max_divergences: u32::MAX / 4 }
726    }
727}
728
729impl McmcDiagnosticsCheck {
730    /// Construct with defaults.
731    #[must_use]
732    pub fn new() -> Self {
733        Self::default()
734    }
735
736    /// Evaluate against a fitted posterior's diagnostics.
737    ///
738    /// Returns `None` when the posterior is not MCMC (caller should emit `NotApplicable`).
739    #[must_use]
740    pub fn check(&self, posterior: &CausalPosterior) -> Option<RefutationReport> {
741        let d = &posterior.diagnostics;
742        if d.factorization != HessianFactorization::Mcmc {
743            return None;
744        }
745        let rhat = d.rhat_max.unwrap_or(f64::INFINITY);
746        let ess = d.ess_bulk_min.unwrap_or(0.0);
747        let divs = d.n_divergences.unwrap_or(u32::MAX);
748        let passed = rhat.is_finite()
749            && rhat <= self.max_rhat
750            && ess >= self.min_ess
751            && divs <= self.max_divergences
752            && d.allows_posterior();
753        let ate = posterior
754            .effect_column()
755            .and_then(|c| posterior.summaries.mean.get(c).copied())
756            .unwrap_or(f64::NAN);
757        Some(RefutationReport {
758            refuter: Arc::from("mcmc_diagnostics"),
759            original_ate: ate,
760            refuted_ate: ate,
761            comparison: rhat,
762            informative: true,
763            passed,
764            failure_condition: if passed {
765                None
766            } else {
767                Some(Arc::from(format!(
768                    "MCMC diagnostics failed: rhat={rhat:.4} ess={ess:.1} divergences={divs}"
769                )))
770            },
771            replicates: d.n_chains.unwrap_or(0),
772        })
773    }
774}
775
776/// Simulation-based calibration ranks for a scalar posterior functional.
777///
778/// For each replicate: draw θ* from the prior predictive, simulate data, refit, and
779/// record the rank of θ* among posterior draws of the primary effect.
780#[derive(Clone, Debug)]
781pub struct SimulationBasedCalibration {
782    /// Number of SBC replicates.
783    pub n_reps: u32,
784    /// Draws per refit.
785    pub n_draws: usize,
786    /// RNG seed.
787    pub seed: u64,
788}
789
790impl Default for SimulationBasedCalibration {
791    fn default() -> Self {
792        Self { n_reps: 50, n_draws: 100, seed: 0 }
793    }
794}
795
796/// SBC report.
797#[derive(Clone, Debug)]
798pub struct SbcReport {
799    /// Rank of the prior draw in each replicate (`0..=n_draws`).
800    pub ranks: Arc<[u32]>,
801    /// Mean rank / `n_draws` (≈ 0.5 when calibrated).
802    pub mean_rank_frac: f64,
803    /// Chi² uniformity diagnostic on coarse bins (lower is better).
804    pub uniformity_stat: f64,
805}
806
807impl SimulationBasedCalibration {
808    /// Construct.
809    #[must_use]
810    pub fn new(n_reps: u32) -> Self {
811        Self { n_reps: n_reps.max(1), ..Self::default() }
812    }
813
814    /// Run SBC: draw θ from the prior, simulate `y` from the prior predictive under
815    /// the fixed design matrix, refit the Bayesian g-computation estimator, and
816    /// rank the true ATE among posterior effect draws.
817    ///
818    /// # Errors
819    ///
820    /// Fit failures.
821    pub fn check(
822        &self,
823        estimator: &BayesianGComputationAte,
824        problem: &PreparedBayesianProblem,
825        identification: IdentificationStatus,
826        workspace: &mut BayesianGCompWorkspace,
827        ctx: &ExecutionContext,
828    ) -> Result<SbcReport, ValidationError> {
829        let mut rng = CausalRng::from_seed(self.seed);
830        let n = problem.design.nrows;
831        let p = problem.design.ncols;
832        let t_col = problem
833            .design
834            .treatment_column()
835            .ok_or_else(|| ValidationError::estimation_msg("SBC: missing treatment column"))?;
836        let mut ranks = Vec::with_capacity(self.n_reps as usize);
837        let mut est = estimator.clone();
838        est.n_draws = self.n_draws;
839        let scale = estimator.prior_scale.max(1e-6);
840
841        for rep in 0..self.n_reps {
842            let mut beta = vec![0.0; p];
843            for c in 0..p {
844                beta[c] = scale * standard_normal(&mut rng);
845            }
846            let true_effect = (problem.active - problem.control) * beta[t_col];
847            let mut y_rep = vec![0.0; n];
848            for r in 0..n {
849                let mut eta = 0.0;
850                for c in 0..p {
851                    eta += problem.design.matrix[c * n + r] * beta[c];
852                }
853                y_rep[r] = eta + standard_normal(&mut rng);
854            }
855            let mut sim_problem = problem.clone();
856            let mut design = sim_problem.design.clone();
857            design.outcome = Arc::from(y_rep);
858            sim_problem.design = design;
859            est.seed = self.seed ^ (u64::from(rep).wrapping_mul(0x9E37));
860            let post = est
861                .fit(&sim_problem, identification, workspace, ctx)
862                .map_err(|e| ValidationError::estimation_msg(format!("SBC refit failed: {e}")))?;
863            let col = post
864                .effect_column()
865                .ok_or_else(|| ValidationError::estimation_msg("SBC: no effect column"))?;
866            let draws = post
867                .draws
868                .column(col)
869                .map_err(|e| ValidationError::estimation_msg(format!("SBC draws: {e}")))?;
870            let mut rank = 0u32;
871            for &d in draws {
872                if d < true_effect {
873                    rank += 1;
874                }
875            }
876            ranks.push(rank);
877        }
878
879        let n_d = self.n_draws.max(1) as f64;
880        let fracs: Vec<f64> = ranks.iter().map(|&r| f64::from(r) / n_d).collect();
881        let mean_rank_frac =
882            reduce_posterior_draws(&fracs, PosteriorReduceOp::Mean, &ctx.kernel_policy)
883                .unwrap_or(0.5);
884        let bins = 10usize;
885        let mut counts = vec![0.0; bins];
886        let n_draws_u = u64::try_from(self.n_draws.max(1)).unwrap_or(1);
887        let bins_u = u64::try_from(bins).unwrap_or(1);
888        for &r in &ranks {
889            let b = usize::try_from(u64::from(r) * bins_u / n_draws_u).unwrap_or(0).min(bins - 1);
890            counts[b] += 1.0;
891        }
892        let expected = f64::from(self.n_reps) / bins as f64;
893        let mut chi2 = 0.0;
894        for c in counts {
895            let d = c - expected;
896            chi2 += d * d / expected.max(1.0);
897        }
898        Ok(SbcReport { ranks: Arc::from(ranks), mean_rank_frac, uniformity_stat: chi2 })
899    }
900
901    /// Convert to a refutation report (passes when mean rank fraction ∈ [0.35, 0.65]).
902    #[must_use]
903    pub fn to_report(&self, report: &SbcReport, original_ate: f64) -> RefutationReport {
904        let passed = (0.35..=0.65).contains(&report.mean_rank_frac);
905        RefutationReport {
906            refuter: Arc::from("sbc"),
907            original_ate,
908            refuted_ate: report.mean_rank_frac,
909            comparison: report.uniformity_stat,
910            informative: true,
911            passed,
912            failure_condition: if passed {
913                None
914            } else {
915                Some(Arc::from(format!(
916                    "SBC mean rank frac {:.3} outside [0.35, 0.65]",
917                    report.mean_rank_frac
918                )))
919            },
920            replicates: self.n_reps,
921        }
922    }
923}
924
925/// Likelihood-family comparison via leave-one-out log predictive density gap.
926#[derive(Clone, Copy, Debug, Default)]
927pub struct LikelihoodFamilyComparison {
928    /// Reserved (API stability).
929    pub n_placeholder: u8,
930}
931
932impl LikelihoodFamilyComparison {
933    /// Compare Gaussian vs Bernoulli logit Laplace fits using a LOO predictive
934    /// score (higher is better). Gap is best − second.
935    ///
936    /// # Errors
937    ///
938    /// Fit failures.
939    pub fn compare(
940        &self,
941        problem: &PreparedBayesianProblem,
942        ctx: &ExecutionContext,
943    ) -> Result<(Arc<str>, f64), ValidationError> {
944        let _ = self;
945        let design = BayesDesignRef {
946            x_colmajor: &problem.design.matrix,
947            nrows: problem.design.nrows,
948            ncols: problem.design.ncols,
949            y: &problem.design.outcome,
950            weights: None,
951            offsets: None,
952        };
953        let prior = PriorSet::weakly_informative(problem.design.ncols);
954        let opts = BayesFitOptions { n_draws: 80, seed: 1, ..BayesFitOptions::default() };
955        let mut ws = LaplaceWorkspace::default();
956        let g = LaplaceGlmBackend
957            .fit(BayesLikelihood::GaussianIdentity, design, &prior, &opts, &mut ws, ctx)
958            .map_err(|e| ValidationError::estimation_msg(format!("Gaussian fit: {e}")))?;
959        let g_score = loo_gaussian_lpd(
960            &g.map,
961            &problem.design.matrix,
962            problem.design.nrows,
963            problem.design.ncols,
964            &problem.design.outcome,
965        );
966
967        let binary = problem
968            .design
969            .outcome
970            .iter()
971            .all(|&y| (y - 0.0).abs() < f64::EPSILON || (y - 1.0).abs() < f64::EPSILON);
972        if !binary {
973            return Ok((Arc::from("gaussian_identity"), 0.0));
974        }
975        let b = LaplaceGlmBackend
976            .fit(BayesLikelihood::BernoulliLogit, design, &prior, &opts, &mut ws, ctx)
977            .map_err(|e| ValidationError::estimation_msg(format!("Bernoulli fit: {e}")))?;
978        let b_score = loo_bernoulli_lpd(
979            &b.map,
980            &problem.design.matrix,
981            problem.design.nrows,
982            problem.design.ncols,
983            &problem.design.outcome,
984        );
985        if b_score >= g_score {
986            Ok((Arc::from("bernoulli_logit"), b_score - g_score))
987        } else {
988            Ok((Arc::from("gaussian_identity"), g_score - b_score))
989        }
990    }
991}
992
993fn loo_gaussian_lpd(map: &[f64], x: &[f64], n: usize, p: usize, y: &[f64]) -> f64 {
994    let mut resid = vec![0.0; n];
995    let mut rss = 0.0;
996    for r in 0..n {
997        let mut eta = 0.0;
998        for c in 0..p {
999            eta += x[c * n + r] * map.get(c).copied().unwrap_or(0.0);
1000        }
1001        resid[r] = y[r] - eta;
1002        rss += resid[r] * resid[r];
1003    }
1004    let sigma2 = (rss / n.max(1) as f64).max(1e-8);
1005    let mut lpd = 0.0;
1006    for r in 0..n {
1007        let s2 = sigma2 * n as f64 / (n.saturating_sub(1)).max(1) as f64;
1008        lpd += -0.5
1009            * (s2.ln()
1010                + resid[r] * resid[r] / s2
1011                + std::f64::consts::LN_2
1012                + std::f64::consts::PI.ln());
1013    }
1014    lpd
1015}
1016
1017fn loo_bernoulli_lpd(map: &[f64], x: &[f64], n: usize, p: usize, y: &[f64]) -> f64 {
1018    let mut lpd = 0.0;
1019    for r in 0..n {
1020        let mut eta = 0.0;
1021        for c in 0..p {
1022            eta += x[c * n + r] * map.get(c).copied().unwrap_or(0.0);
1023        }
1024        let prob = 1.0 / (1.0 + (-eta).exp());
1025        lpd += if y[r] > 0.5 { prob.max(1e-12).ln() } else { (1.0 - prob).max(1e-12).ln() };
1026    }
1027    lpd
1028}
1029
1030/// Posterior calibration on synthetic SCMs: known-ATE credible-interval coverage.
1031#[derive(Clone, Debug)]
1032pub struct PosteriorCalibrationOnSyntheticScm {
1033    /// Monte Carlo replicates.
1034    pub n_reps: u32,
1035    /// Draws per fit.
1036    pub n_draws: usize,
1037    /// Nominal coverage level (e.g. 0.9).
1038    pub level: f64,
1039    /// RNG seed.
1040    pub seed: u64,
1041}
1042
1043impl Default for PosteriorCalibrationOnSyntheticScm {
1044    fn default() -> Self {
1045        Self { n_reps: 40, n_draws: 100, level: 0.9, seed: 0 }
1046    }
1047}
1048
1049/// Report for [`PosteriorCalibrationOnSyntheticScm`].
1050#[derive(Clone, Debug)]
1051pub struct PosteriorCalibrationReport {
1052    /// Empirical coverage of equal-tailed credible intervals.
1053    pub coverage: f64,
1054    /// Mean absolute error of posterior means vs true ATE.
1055    pub mean_abs_error: f64,
1056    /// Replicates.
1057    pub n_reps: u32,
1058}
1059
1060impl PosteriorCalibrationOnSyntheticScm {
1061    /// Simulate known ATEs under the design, refit, and measure CI coverage.
1062    ///
1063    /// # Errors
1064    ///
1065    /// Fit failures.
1066    pub fn check(
1067        &self,
1068        estimator: &BayesianGComputationAte,
1069        problem: &PreparedBayesianProblem,
1070        identification: IdentificationStatus,
1071        workspace: &mut BayesianGCompWorkspace,
1072        ctx: &ExecutionContext,
1073    ) -> Result<PosteriorCalibrationReport, ValidationError> {
1074        let mut rng = CausalRng::from_seed(self.seed);
1075        let n = problem.design.nrows;
1076        let p = problem.design.ncols;
1077        let t_col = problem
1078            .design
1079            .treatment_column()
1080            .ok_or_else(|| ValidationError::estimation_msg("calibration: missing treatment"))?;
1081        let mut covered = 0u32;
1082        let mut abs_err = 0.0;
1083        let mut est = estimator.clone();
1084        est.n_draws = self.n_draws;
1085        let alpha = ((1.0 - self.level) / 2.0).clamp(0.0, 0.5);
1086
1087        for rep in 0..self.n_reps {
1088            let true_ate = standard_normal(&mut rng);
1089            let mut beta = vec![0.0; p];
1090            let diff = problem.active - problem.control;
1091            beta[t_col] = if diff.abs() > 1e-12 { true_ate / diff } else { true_ate };
1092            for c in 0..p {
1093                if c != t_col {
1094                    beta[c] = 0.5 * standard_normal(&mut rng);
1095                }
1096            }
1097            let mut y = vec![0.0; n];
1098            for r in 0..n {
1099                let mut eta = 0.0;
1100                for c in 0..p {
1101                    eta += problem.design.matrix[c * n + r] * beta[c];
1102                }
1103                y[r] = eta + standard_normal(&mut rng);
1104            }
1105            let mut sim = problem.clone();
1106            let mut design = sim.design.clone();
1107            design.outcome = Arc::from(y);
1108            sim.design = design;
1109            est.seed = self.seed ^ (u64::from(rep).wrapping_mul(0xC2B2));
1110            let post = est
1111                .fit(&sim, identification, workspace, ctx)
1112                .map_err(|e| ValidationError::estimation_msg(format!("calibration refit: {e}")))?;
1113            let col = post
1114                .effect_column()
1115                .ok_or_else(|| ValidationError::estimation_msg("calibration: no effect"))?;
1116            let mut draws = post.draws.column(col).map_err(ValidationError::from)?.to_vec();
1117            draws.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1118            let lo = quantile_sorted(&draws, alpha);
1119            let hi = quantile_sorted(&draws, 1.0 - alpha);
1120            let mean = reduce_posterior_draws(&draws, PosteriorReduceOp::Mean, &ctx.kernel_policy)
1121                .unwrap_or(0.0);
1122            abs_err += (mean - true_ate).abs();
1123            if true_ate >= lo && true_ate <= hi {
1124                covered += 1;
1125            }
1126        }
1127        Ok(PosteriorCalibrationReport {
1128            coverage: f64::from(covered) / f64::from(self.n_reps.max(1)),
1129            mean_abs_error: abs_err / f64::from(self.n_reps.max(1)),
1130            n_reps: self.n_reps,
1131        })
1132    }
1133}
1134
1135fn quantile_sorted(sorted: &[f64], q: f64) -> f64 {
1136    if sorted.is_empty() {
1137        return 0.0;
1138    }
1139    let max_idx = sorted.len() - 1;
1140    let rank = (max_idx as f64 * q.clamp(0.0, 1.0)).round();
1141    let idx = (0..=max_idx)
1142        .min_by(|&a, &b| {
1143            (a as f64 - rank)
1144                .abs()
1145                .partial_cmp(&(b as f64 - rank).abs())
1146                .unwrap_or(std::cmp::Ordering::Equal)
1147        })
1148        .unwrap_or(0);
1149    sorted[idx]
1150}