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, InferenceBackend,
23    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
509    fn toy() -> (TabularData, IdentifiedEstimand, AverageEffectQuery) {
510        let n = 60usize;
511        let mut b = CausalSchemaBuilder::new();
512        b.add_variable(
513            "t",
514            ValueType::Continuous,
515            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
516            None,
517            None,
518            MeasurementSpec::default(),
519        )
520        .unwrap();
521        b.add_variable(
522            "y",
523            ValueType::Continuous,
524            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
525            None,
526            None,
527            MeasurementSpec::default(),
528        )
529        .unwrap();
530        b.add_variable(
531            "z",
532            ValueType::Continuous,
533            SmallRoleSet::from_hint(RoleHint::Context),
534            None,
535            None,
536            MeasurementSpec::default(),
537        )
538        .unwrap();
539        let schema = b.build().unwrap();
540        let t: Vec<f64> = (0..n).map(|i| (i % 2) as f64).collect();
541        let z: Vec<f64> = (0..n).map(|i| i as f64 * 0.05).collect();
542        let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + 0.3 * z[i]).collect();
543        let cols = vec![
544            OwnedColumn::Float64(
545                Float64Column::new(
546                    VariableId::from_raw(0),
547                    Arc::from(t),
548                    ValidityBitmap::all_valid(n),
549                )
550                .unwrap(),
551            ),
552            OwnedColumn::Float64(
553                Float64Column::new(
554                    VariableId::from_raw(1),
555                    Arc::from(y),
556                    ValidityBitmap::all_valid(n),
557                )
558                .unwrap(),
559            ),
560            OwnedColumn::Float64(
561                Float64Column::new(
562                    VariableId::from_raw(2),
563                    Arc::from(z),
564                    ValidityBitmap::all_valid(n),
565                )
566                .unwrap(),
567            ),
568        ];
569        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
570        let estimand = IdentifiedEstimand::backdoor(
571            "backdoor.adjustment",
572            Arc::from([VariableId::from_raw(2)]),
573            ExprId::from_raw(0),
574        );
575        let query =
576            AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
577        (TabularData::new(storage), estimand, query)
578    }
579
580    #[test]
581    fn prior_and_posterior_ppc_run() {
582        let (data, estimand, query) = toy();
583        let bayes = BayesianGComputationAte {
584            backend: BayesianBackendKind::ConjugateGaussian,
585            n_draws: 100,
586            seed: 2,
587            prior_scale: 10.0,
588            ..BayesianGComputationAte::new()
589        };
590        let prep = bayes.prepare(&data, &estimand, &query).unwrap();
591        let ctx = ExecutionContext::for_tests(1);
592        let prior_rep = PriorPredictiveCheck { n_sims: 50, seed: 3, ..PriorPredictiveCheck::new() }
593            .check(&prep, &ctx)
594            .unwrap();
595        assert_eq!(prior_rep.kind, PredictiveCheckKind::Prior);
596        assert!(prior_rep.p_value.is_finite());
597
598        let mut ws = BayesianGCompWorkspace::default();
599        let post = bayes
600            .fit(&prep, IdentificationStatus::NonparametricallyIdentified, &mut ws, &ctx)
601            .unwrap();
602        let post_rep = PosteriorPredictiveCheck { n_sims: 50, ..PosteriorPredictiveCheck::new() }
603            .check(&prep, &post)
604            .unwrap();
605        assert_eq!(post_rep.kind, PredictiveCheckKind::Posterior);
606    }
607
608    #[test]
609    fn prior_sensitivity_grid() {
610        let (data, estimand, query) = toy();
611        let bayes = BayesianGComputationAte {
612            backend: BayesianBackendKind::ConjugateGaussian,
613            n_draws: 80,
614            seed: 4,
615            ..BayesianGComputationAte::new()
616        };
617        let prep = bayes.prepare(&data, &estimand, &query).unwrap();
618        let mut ws = BayesianGCompWorkspace::default();
619        let ctx = ExecutionContext::for_tests(1);
620        let sens = PriorSensitivity {
621            scales: Arc::from(vec![1.0, 10.0, 50.0]),
622            alphas: Arc::from([]),
623            max_relative_range: DEFAULT_MAX_RELATIVE_PRIOR_RANGE,
624        };
625        let (summary, posts) = sens
626            .evaluate(
627                &bayes,
628                &prep,
629                IdentificationStatus::NonparametricallyIdentified,
630                &mut ws,
631                &ctx,
632            )
633            .unwrap();
634        assert_eq!(summary.prior_scales.len(), 3);
635        assert!(summary.alphas.is_empty());
636        assert_eq!(posts.len(), 3);
637        let rep =
638            sens.to_report(&summary, posts[0].summaries.mean[posts[0].effect_column().unwrap()]);
639        assert!(rep.passed);
640    }
641
642    #[test]
643    fn prior_sensitivity_external_alpha_pulls_toward_source() {
644        use antecedent_prob::{
645            ExternalPriorSource, ExternalPriorWeight, GaussianCoefficientPrior, PriorSpec,
646        };
647
648        let (data, estimand, query) = toy();
649        // Data ATE ≈ 2; bank a tight prior with treatment coef mean = 8.
650        let bayes = BayesianGComputationAte {
651            backend: BayesianBackendKind::ConjugateGaussian,
652            n_draws: 120,
653            seed: 7,
654            ..BayesianGComputationAte::new()
655        };
656        let prep = bayes.prepare(&data, &estimand, &query).unwrap();
657        let n = prep.design.ncols;
658        let t_col = prep.design.treatment_column().expect("treatment column");
659        let mut mean = vec![0.0; n];
660        mean[t_col] = 8.0;
661        let mut source_prior = PriorSet::new();
662        source_prior.push(PriorSpec::GaussianCoefficients(GaussianCoefficientPrior {
663            mean: Arc::from(mean),
664            variance: Arc::from(vec![0.05; n]),
665        }));
666        let sources = [ExternalPriorSource {
667            id: Arc::from("survey_a"),
668            prior: source_prior,
669            weight: ExternalPriorWeight::power(1.0).unwrap(),
670        }];
671        let alphas_applied = [1.0_f64];
672        let mut ws = BayesianGCompWorkspace::default();
673        let ctx = ExecutionContext::for_tests(1);
674        let sens = PriorSensitivity::standard_alpha_grid();
675        let (summary, _) = sens
676            .evaluate_external_alpha(
677                &bayes,
678                &prep,
679                IdentificationStatus::NonparametricallyIdentified,
680                &mut ws,
681                &ctx,
682                ExternalAlphaSensitivity { sources: &sources, alphas_applied: &alphas_applied },
683            )
684            .unwrap();
685        assert_eq!(summary.alphas.len(), 5);
686        assert!(summary.prior_scales.is_empty());
687        assert!(summary.effect_means.iter().all(|m| m.is_finite()));
688        let m0 = summary.effect_means[0];
689        let m1 = *summary.effect_means.last().unwrap();
690        // Full trust (m=1) should sit closer to the banked treatment mean than baseline (m=0).
691        assert!(
692            (m1 - 8.0).abs() < (m0 - 8.0).abs(),
693            "m=1 mean {m1} should be closer to 8 than m=0 mean {m0}"
694        );
695        let rep = sens.to_report(&summary, m1);
696        assert_eq!(rep.refuter.as_ref(), "prior_sensitivity_alpha");
697        assert!(rep.informative);
698        assert!(rep.comparison.is_finite() && rep.comparison > 0.0);
699    }
700}
701
702/// MCMC chain diagnostics gate (ESS / R-hat / divergences).
703///
704/// Applicable only when the posterior was produced by an MCMC backend
705/// (`InferenceDiagnostics::factorization == Mcmc`).
706#[derive(Clone, Copy, Debug)]
707pub struct McmcDiagnosticsCheck {
708    /// Maximum acceptable split-Ř.
709    pub max_rhat: f64,
710    /// Minimum acceptable bulk ESS.
711    pub min_ess: f64,
712    /// Maximum acceptable divergence count.
713    pub max_divergences: u32,
714}
715
716impl Default for McmcDiagnosticsCheck {
717    fn default() -> Self {
718        Self { max_rhat: 1.05, min_ess: 10.0, max_divergences: u32::MAX / 4 }
719    }
720}
721
722impl McmcDiagnosticsCheck {
723    /// Construct with defaults.
724    #[must_use]
725    pub fn new() -> Self {
726        Self::default()
727    }
728
729    /// Evaluate against a fitted posterior's diagnostics.
730    ///
731    /// Returns `None` when the posterior is not MCMC (caller should emit `NotApplicable`).
732    #[must_use]
733    pub fn check(&self, posterior: &CausalPosterior) -> Option<RefutationReport> {
734        use antecedent_prob::HessianFactorization;
735        let d = &posterior.diagnostics;
736        if d.factorization != HessianFactorization::Mcmc {
737            return None;
738        }
739        let rhat = d.rhat_max.unwrap_or(f64::INFINITY);
740        let ess = d.ess_bulk_min.unwrap_or(0.0);
741        let divs = d.n_divergences.unwrap_or(u32::MAX);
742        let passed = rhat.is_finite()
743            && rhat <= self.max_rhat
744            && ess >= self.min_ess
745            && divs <= self.max_divergences
746            && d.allows_posterior();
747        let ate = posterior
748            .effect_column()
749            .and_then(|c| posterior.summaries.mean.get(c).copied())
750            .unwrap_or(f64::NAN);
751        Some(RefutationReport {
752            refuter: Arc::from("mcmc_diagnostics"),
753            original_ate: ate,
754            refuted_ate: ate,
755            comparison: rhat,
756            informative: true,
757            passed,
758            failure_condition: if passed {
759                None
760            } else {
761                Some(Arc::from(format!(
762                    "MCMC diagnostics failed: rhat={rhat:.4} ess={ess:.1} divergences={divs}"
763                )))
764            },
765            replicates: d.n_chains.unwrap_or(0),
766        })
767    }
768}
769
770/// Simulation-based calibration ranks for a scalar posterior functional.
771///
772/// For each replicate: draw θ* from the prior predictive, simulate data, refit, and
773/// record the rank of θ* among posterior draws of the primary effect.
774#[derive(Clone, Debug)]
775pub struct SimulationBasedCalibration {
776    /// Number of SBC replicates.
777    pub n_reps: u32,
778    /// Draws per refit.
779    pub n_draws: usize,
780    /// RNG seed.
781    pub seed: u64,
782}
783
784impl Default for SimulationBasedCalibration {
785    fn default() -> Self {
786        Self { n_reps: 50, n_draws: 100, seed: 0 }
787    }
788}
789
790/// SBC report.
791#[derive(Clone, Debug)]
792pub struct SbcReport {
793    /// Rank of the prior draw in each replicate (`0..=n_draws`).
794    pub ranks: Arc<[u32]>,
795    /// Mean rank / `n_draws` (≈ 0.5 when calibrated).
796    pub mean_rank_frac: f64,
797    /// Chi² uniformity diagnostic on coarse bins (lower is better).
798    pub uniformity_stat: f64,
799}
800
801impl SimulationBasedCalibration {
802    /// Construct.
803    #[must_use]
804    pub fn new(n_reps: u32) -> Self {
805        Self { n_reps: n_reps.max(1), ..Self::default() }
806    }
807
808    /// Run SBC: draw θ from the prior, simulate `y` from the prior predictive under
809    /// the fixed design matrix, refit the Bayesian g-computation estimator, and
810    /// rank the true ATE among posterior effect draws.
811    ///
812    /// # Errors
813    ///
814    /// Fit failures.
815    pub fn check(
816        &self,
817        estimator: &BayesianGComputationAte,
818        problem: &PreparedBayesianProblem,
819        identification: IdentificationStatus,
820        workspace: &mut BayesianGCompWorkspace,
821        ctx: &ExecutionContext,
822    ) -> Result<SbcReport, ValidationError> {
823        let mut rng = CausalRng::from_seed(self.seed);
824        let n = problem.design.nrows;
825        let p = problem.design.ncols;
826        let t_col = problem
827            .design
828            .treatment_column()
829            .ok_or_else(|| ValidationError::estimation_msg("SBC: missing treatment column"))?;
830        let mut ranks = Vec::with_capacity(self.n_reps as usize);
831        let mut est = estimator.clone();
832        est.n_draws = self.n_draws;
833        let scale = estimator.prior_scale.max(1e-6);
834
835        for rep in 0..self.n_reps {
836            let mut beta = vec![0.0; p];
837            for c in 0..p {
838                beta[c] = scale * standard_normal(&mut rng);
839            }
840            let true_effect = (problem.active - problem.control) * beta[t_col];
841            let mut y_rep = vec![0.0; n];
842            for r in 0..n {
843                let mut eta = 0.0;
844                for c in 0..p {
845                    eta += problem.design.matrix[c * n + r] * beta[c];
846                }
847                y_rep[r] = eta + standard_normal(&mut rng);
848            }
849            let mut sim_problem = problem.clone();
850            let mut design = sim_problem.design.clone();
851            design.outcome = Arc::from(y_rep);
852            sim_problem.design = design;
853            est.seed = self.seed ^ (u64::from(rep).wrapping_mul(0x9E37));
854            let post = est
855                .fit(&sim_problem, identification, workspace, ctx)
856                .map_err(|e| ValidationError::estimation_msg(format!("SBC refit failed: {e}")))?;
857            let col = post
858                .effect_column()
859                .ok_or_else(|| ValidationError::estimation_msg("SBC: no effect column"))?;
860            let draws = post
861                .draws
862                .column(col)
863                .map_err(|e| ValidationError::estimation_msg(format!("SBC draws: {e}")))?;
864            let mut rank = 0u32;
865            for &d in draws {
866                if d < true_effect {
867                    rank += 1;
868                }
869            }
870            ranks.push(rank);
871        }
872
873        let n_d = self.n_draws.max(1) as f64;
874        let fracs: Vec<f64> = ranks.iter().map(|&r| f64::from(r) / n_d).collect();
875        let mean_rank_frac =
876            reduce_posterior_draws(&fracs, PosteriorReduceOp::Mean, &ctx.kernel_policy)
877                .unwrap_or(0.5);
878        let bins = 10usize;
879        let mut counts = vec![0.0; bins];
880        let n_draws_u = u64::try_from(self.n_draws.max(1)).unwrap_or(1);
881        let bins_u = u64::try_from(bins).unwrap_or(1);
882        for &r in &ranks {
883            let b = usize::try_from(u64::from(r) * bins_u / n_draws_u).unwrap_or(0).min(bins - 1);
884            counts[b] += 1.0;
885        }
886        let expected = f64::from(self.n_reps) / bins as f64;
887        let mut chi2 = 0.0;
888        for c in counts {
889            let d = c - expected;
890            chi2 += d * d / expected.max(1.0);
891        }
892        Ok(SbcReport { ranks: Arc::from(ranks), mean_rank_frac, uniformity_stat: chi2 })
893    }
894
895    /// Convert to a refutation report (passes when mean rank fraction ∈ [0.35, 0.65]).
896    #[must_use]
897    pub fn to_report(&self, report: &SbcReport, original_ate: f64) -> RefutationReport {
898        let passed = (0.35..=0.65).contains(&report.mean_rank_frac);
899        RefutationReport {
900            refuter: Arc::from("sbc"),
901            original_ate,
902            refuted_ate: report.mean_rank_frac,
903            comparison: report.uniformity_stat,
904            informative: true,
905            passed,
906            failure_condition: if passed {
907                None
908            } else {
909                Some(Arc::from(format!(
910                    "SBC mean rank frac {:.3} outside [0.35, 0.65]",
911                    report.mean_rank_frac
912                )))
913            },
914            replicates: self.n_reps,
915        }
916    }
917}
918
919/// Likelihood-family comparison via leave-one-out log predictive density gap.
920#[derive(Clone, Copy, Debug, Default)]
921pub struct LikelihoodFamilyComparison {
922    /// Reserved (API stability).
923    pub n_placeholder: u8,
924}
925
926impl LikelihoodFamilyComparison {
927    /// Compare Gaussian vs Bernoulli logit Laplace fits using a LOO predictive
928    /// score (higher is better). Gap is best − second.
929    ///
930    /// # Errors
931    ///
932    /// Fit failures.
933    pub fn compare(
934        &self,
935        problem: &PreparedBayesianProblem,
936        ctx: &ExecutionContext,
937    ) -> Result<(Arc<str>, f64), ValidationError> {
938        let _ = self;
939        let design = BayesDesignRef {
940            x_colmajor: &problem.design.matrix,
941            nrows: problem.design.nrows,
942            ncols: problem.design.ncols,
943            y: &problem.design.outcome,
944            weights: None,
945            offsets: None,
946        };
947        let prior = PriorSet::weakly_informative(problem.design.ncols);
948        let opts = BayesFitOptions { n_draws: 80, seed: 1, ..BayesFitOptions::default() };
949        let mut ws = LaplaceWorkspace::default();
950        let g = LaplaceGlmBackend
951            .fit(BayesLikelihood::GaussianIdentity, design, &prior, &opts, &mut ws, ctx)
952            .map_err(|e| ValidationError::estimation_msg(format!("Gaussian fit: {e}")))?;
953        let g_score = loo_gaussian_lpd(
954            &g.map,
955            &problem.design.matrix,
956            problem.design.nrows,
957            problem.design.ncols,
958            &problem.design.outcome,
959        );
960
961        let binary = problem
962            .design
963            .outcome
964            .iter()
965            .all(|&y| (y - 0.0).abs() < f64::EPSILON || (y - 1.0).abs() < f64::EPSILON);
966        if !binary {
967            return Ok((Arc::from("gaussian_identity"), 0.0));
968        }
969        let b = LaplaceGlmBackend
970            .fit(BayesLikelihood::BernoulliLogit, design, &prior, &opts, &mut ws, ctx)
971            .map_err(|e| ValidationError::estimation_msg(format!("Bernoulli fit: {e}")))?;
972        let b_score = loo_bernoulli_lpd(
973            &b.map,
974            &problem.design.matrix,
975            problem.design.nrows,
976            problem.design.ncols,
977            &problem.design.outcome,
978        );
979        if b_score >= g_score {
980            Ok((Arc::from("bernoulli_logit"), b_score - g_score))
981        } else {
982            Ok((Arc::from("gaussian_identity"), g_score - b_score))
983        }
984    }
985}
986
987fn loo_gaussian_lpd(map: &[f64], x: &[f64], n: usize, p: usize, y: &[f64]) -> f64 {
988    let mut resid = vec![0.0; n];
989    let mut rss = 0.0;
990    for r in 0..n {
991        let mut eta = 0.0;
992        for c in 0..p {
993            eta += x[c * n + r] * map.get(c).copied().unwrap_or(0.0);
994        }
995        resid[r] = y[r] - eta;
996        rss += resid[r] * resid[r];
997    }
998    let sigma2 = (rss / n.max(1) as f64).max(1e-8);
999    let mut lpd = 0.0;
1000    for r in 0..n {
1001        let s2 = sigma2 * n as f64 / (n.saturating_sub(1)).max(1) as f64;
1002        lpd += -0.5
1003            * (s2.ln()
1004                + resid[r] * resid[r] / s2
1005                + std::f64::consts::LN_2
1006                + std::f64::consts::PI.ln());
1007    }
1008    lpd
1009}
1010
1011fn loo_bernoulli_lpd(map: &[f64], x: &[f64], n: usize, p: usize, y: &[f64]) -> f64 {
1012    let mut lpd = 0.0;
1013    for r in 0..n {
1014        let mut eta = 0.0;
1015        for c in 0..p {
1016            eta += x[c * n + r] * map.get(c).copied().unwrap_or(0.0);
1017        }
1018        let prob = 1.0 / (1.0 + (-eta).exp());
1019        lpd += if y[r] > 0.5 { prob.max(1e-12).ln() } else { (1.0 - prob).max(1e-12).ln() };
1020    }
1021    lpd
1022}
1023
1024/// Posterior calibration on synthetic SCMs: known-ATE credible-interval coverage.
1025#[derive(Clone, Debug)]
1026pub struct PosteriorCalibrationOnSyntheticScm {
1027    /// Monte Carlo replicates.
1028    pub n_reps: u32,
1029    /// Draws per fit.
1030    pub n_draws: usize,
1031    /// Nominal coverage level (e.g. 0.9).
1032    pub level: f64,
1033    /// RNG seed.
1034    pub seed: u64,
1035}
1036
1037impl Default for PosteriorCalibrationOnSyntheticScm {
1038    fn default() -> Self {
1039        Self { n_reps: 40, n_draws: 100, level: 0.9, seed: 0 }
1040    }
1041}
1042
1043/// Report for [`PosteriorCalibrationOnSyntheticScm`].
1044#[derive(Clone, Debug)]
1045pub struct PosteriorCalibrationReport {
1046    /// Empirical coverage of equal-tailed credible intervals.
1047    pub coverage: f64,
1048    /// Mean absolute error of posterior means vs true ATE.
1049    pub mean_abs_error: f64,
1050    /// Replicates.
1051    pub n_reps: u32,
1052}
1053
1054impl PosteriorCalibrationOnSyntheticScm {
1055    /// Simulate known ATEs under the design, refit, and measure CI coverage.
1056    ///
1057    /// # Errors
1058    ///
1059    /// Fit failures.
1060    pub fn check(
1061        &self,
1062        estimator: &BayesianGComputationAte,
1063        problem: &PreparedBayesianProblem,
1064        identification: IdentificationStatus,
1065        workspace: &mut BayesianGCompWorkspace,
1066        ctx: &ExecutionContext,
1067    ) -> Result<PosteriorCalibrationReport, ValidationError> {
1068        let mut rng = CausalRng::from_seed(self.seed);
1069        let n = problem.design.nrows;
1070        let p = problem.design.ncols;
1071        let t_col = problem
1072            .design
1073            .treatment_column()
1074            .ok_or_else(|| ValidationError::estimation_msg("calibration: missing treatment"))?;
1075        let mut covered = 0u32;
1076        let mut abs_err = 0.0;
1077        let mut est = estimator.clone();
1078        est.n_draws = self.n_draws;
1079        let alpha = ((1.0 - self.level) / 2.0).clamp(0.0, 0.5);
1080
1081        for rep in 0..self.n_reps {
1082            let true_ate = standard_normal(&mut rng);
1083            let mut beta = vec![0.0; p];
1084            let diff = problem.active - problem.control;
1085            beta[t_col] = if diff.abs() > 1e-12 { true_ate / diff } else { true_ate };
1086            for c in 0..p {
1087                if c != t_col {
1088                    beta[c] = 0.5 * standard_normal(&mut rng);
1089                }
1090            }
1091            let mut y = vec![0.0; n];
1092            for r in 0..n {
1093                let mut eta = 0.0;
1094                for c in 0..p {
1095                    eta += problem.design.matrix[c * n + r] * beta[c];
1096                }
1097                y[r] = eta + standard_normal(&mut rng);
1098            }
1099            let mut sim = problem.clone();
1100            let mut design = sim.design.clone();
1101            design.outcome = Arc::from(y);
1102            sim.design = design;
1103            est.seed = self.seed ^ (u64::from(rep).wrapping_mul(0xC2B2));
1104            let post = est
1105                .fit(&sim, identification, workspace, ctx)
1106                .map_err(|e| ValidationError::estimation_msg(format!("calibration refit: {e}")))?;
1107            let col = post
1108                .effect_column()
1109                .ok_or_else(|| ValidationError::estimation_msg("calibration: no effect"))?;
1110            let mut draws = post.draws.column(col).map_err(ValidationError::from)?.to_vec();
1111            draws.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1112            let lo = quantile_sorted(&draws, alpha);
1113            let hi = quantile_sorted(&draws, 1.0 - alpha);
1114            let mean = reduce_posterior_draws(&draws, PosteriorReduceOp::Mean, &ctx.kernel_policy)
1115                .unwrap_or(0.0);
1116            abs_err += (mean - true_ate).abs();
1117            if true_ate >= lo && true_ate <= hi {
1118                covered += 1;
1119            }
1120        }
1121        Ok(PosteriorCalibrationReport {
1122            coverage: f64::from(covered) / f64::from(self.n_reps.max(1)),
1123            mean_abs_error: abs_err / f64::from(self.n_reps.max(1)),
1124            n_reps: self.n_reps,
1125        })
1126    }
1127}
1128
1129fn quantile_sorted(sorted: &[f64], q: f64) -> f64 {
1130    if sorted.is_empty() {
1131        return 0.0;
1132    }
1133    let max_idx = sorted.len() - 1;
1134    let rank = (max_idx as f64 * q.clamp(0.0, 1.0)).round();
1135    let idx = (0..=max_idx)
1136        .min_by(|&a, &b| {
1137            (a as f64 - rank)
1138                .abs()
1139                .partial_cmp(&(b as f64 - rank).abs())
1140                .unwrap_or(std::cmp::Ordering::Equal)
1141        })
1142        .unwrap_or(0);
1143    sorted[idx]
1144}