Skip to main content

antecedent_estimate/
bayesian.rs

1//! Bayesian mechanisms, g-computation, and posterior functional evaluation .
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_arguments,
10    clippy::too_many_lines,
11    clippy::needless_pass_by_value,
12    clippy::doc_markdown,
13    clippy::many_single_char_names
14)]
15
16use std::sync::Arc;
17
18use antecedent_core::IdentificationStatus;
19use antecedent_core::{
20    Assumption, AssumptionRecord, AssumptionScope, AssumptionSet, AssumptionSource,
21    AssumptionStatus, AverageEffectQuery, ExecutionContext, PriorAssumption, TargetPopulation,
22    VariableId,
23};
24use antecedent_data::{TableView, TabularData};
25use antecedent_expr::IdentifiedEstimand;
26use antecedent_prob::{
27    BayesDesignRef, BayesFitOptions, BayesLikelihood, ConflictSummary, ConjugateGaussianBackend,
28    EffectBatch, EffectPrior, GaussianCoefficientPrior, HmcGlmBackend, HmcOptions,
29    InferenceBackend, InferenceDiagnostics, LaplaceGlmBackend, LaplaceWorkspace, PosteriorBatch,
30    PosteriorDraws, PosteriorEvalWorkspace, PosteriorQuantityKind, PosteriorSchema,
31    PosteriorSummary, PriorSensitivitySummary, PriorSet, PriorSpec, sample_gaussian_mvn,
32};
33use antecedent_stats::{CompiledDesign, DesignColumnRole, GlmFamily};
34
35use crate::adjustment::{PreparedEstimationProblem, intervention_f64};
36use crate::error::EstimationError;
37use crate::overlap::OverlapPolicy;
38use crate::util::require_explicit_override;
39
40/// Minimum kept draws for HMC so the MCMC publication gate (Ř≤1.01, ESS≥100)
41/// is reachable on typical Gaussian GLMs.
42const HMC_MIN_DRAWS: usize = 3_000;
43
44/// Causal posterior over an identified functional.
45#[derive(Clone, Debug)]
46pub struct CausalPosterior {
47    /// Columnar effect (and optional coefficient) draws.
48    pub draws: PosteriorDraws,
49    /// Summary of `draws`.
50    pub summaries: PosteriorSummary,
51    /// Identification status — priors never upgrade this.
52    pub identification: IdentificationStatus,
53    /// Optional prior-sensitivity grid.
54    pub prior_sensitivity: Option<PriorSensitivitySummary>,
55    /// Optional external-prior conflict shrink summary.
56    pub conflict_summary: Option<ConflictSummary>,
57    /// Inference diagnostics.
58    pub diagnostics: InferenceDiagnostics,
59    /// Assumptions including prior restrictions.
60    pub assumptions: AssumptionSet,
61    /// Unidentified graph mass retained when aggregating envelopes (0 if single graph).
62    pub unidentified_mass: f64,
63    /// Adaptive draw early-stop (Laplace / conjugate Gaussian redraw path).
64    pub early_stopped: bool,
65}
66
67impl CausalPosterior {
68    /// Primary effect column index (first `Effect` quantity), if any.
69    #[must_use]
70    pub fn effect_column(&self) -> Option<usize> {
71        self.draws
72            .schema
73            .quantities
74            .iter()
75            .position(|q| matches!(q, PosteriorQuantityKind::Effect { .. }))
76    }
77
78    /// Empirical P(effect < threshold) for the primary effect column.
79    ///
80    /// # Errors
81    ///
82    /// Missing effect column.
83    pub fn probability_below(&self, threshold: f64) -> Result<f64, EstimationError> {
84        let q = self
85            .effect_column()
86            .ok_or_else(|| EstimationError::stats_msg("CausalPosterior has no effect column"))?;
87        self.draws.probability_below(q, threshold).map_err(EstimationError::from)
88    }
89}
90
91/// Minimum coefficient prior variance when hydrating from a posterior (numerical floor).
92const HYDRATE_VAR_FLOOR: f64 = 1e-12;
93
94/// Build a Gaussian coefficient [`PriorSet`] from posterior quantity summaries.
95///
96/// Uses coefficient-column posterior means and SDs (index-aligned). Effect /
97/// residual columns are ignored. When `expected_n_coef` is `Some`, it must match
98/// the number of coefficient columns.
99///
100/// # Errors
101///
102/// No coefficient columns, non-finite summaries, non-contiguous indices, or
103/// dimension mismatch vs `expected_n_coef`.
104pub fn hydrate_prior_from_quantity_summaries(
105    quantities: &[PosteriorQuantityKind],
106    mean: &[f64],
107    sd: &[f64],
108    expected_n_coef: Option<usize>,
109) -> Result<PriorSet, EstimationError> {
110    if mean.len() != quantities.len() || sd.len() != quantities.len() {
111        return Err(EstimationError::stats_msg(
112            "hydrate_prior: mean/sd length must match quantities",
113        ));
114    }
115    let mut coef_cols: Vec<(usize, usize)> = quantities
116        .iter()
117        .enumerate()
118        .filter_map(|(col, q)| match q {
119            PosteriorQuantityKind::Coefficient { index, .. } => Some((*index, col)),
120            _ => None,
121        })
122        .collect();
123    coef_cols.sort_by_key(|(index, _)| *index);
124    let n_coef = coef_cols.len();
125    if n_coef == 0 {
126        return Err(EstimationError::stats_msg(
127            "hydrate_prior_from_posterior: no coefficient columns in posterior",
128        ));
129    }
130    if let Some(expected) = expected_n_coef {
131        if n_coef != expected {
132            return Err(EstimationError::stats_msg(format!(
133                "posterior coefficient dimension {n_coef} != expected n_coef {expected}"
134            )));
135        }
136    }
137    for (i, (index, _)) in coef_cols.iter().enumerate() {
138        if *index != i {
139            return Err(EstimationError::stats_msg(format!(
140                "posterior coefficient indices are not contiguous (expected {i}, got {index})"
141            )));
142        }
143    }
144    let mut means = Vec::with_capacity(n_coef);
145    let mut variance = Vec::with_capacity(n_coef);
146    for (_, col) in &coef_cols {
147        let m = mean[*col];
148        let s = sd[*col];
149        if !m.is_finite() || !s.is_finite() {
150            return Err(EstimationError::stats_msg(
151                "posterior coefficient summary is non-finite; cannot hydrate prior",
152            ));
153        }
154        means.push(m);
155        variance.push((s * s).max(HYDRATE_VAR_FLOOR));
156    }
157    let coef = GaussianCoefficientPrior { mean: Arc::from(means), variance: Arc::from(variance) };
158    coef.validate().map_err(EstimationError::from)?;
159    Ok(PriorSet {
160        specs: vec![PriorSpec::GaussianCoefficients(coef)],
161        contrast: None,
162        categorical: Vec::new(),
163        restrictions: Vec::new(),
164    })
165}
166
167/// Build a Gaussian coefficient [`PriorSet`] from a fitted posterior (sequential Bayes).
168///
169/// # Errors
170///
171/// See [`hydrate_prior_from_quantity_summaries`].
172pub fn hydrate_prior_from_posterior(
173    posterior: &CausalPosterior,
174    expected_n_coef: Option<usize>,
175) -> Result<PriorSet, EstimationError> {
176    hydrate_prior_from_quantity_summaries(
177        &posterior.draws.schema.quantities,
178        &posterior.summaries.mean,
179        &posterior.summaries.sd,
180        expected_n_coef,
181    )
182}
183
184/// Bridge from a banked posterior into a target design's coefficient prior.
185///
186/// Mirrors [`antecedent_io::PriorMapping`] without depending on `antecedent-io` (avoids a
187/// cycle). Convert at the facade.
188#[derive(Clone, Debug, PartialEq, Eq, Hash)]
189pub enum HydrateMapping {
190    /// Identical coefficient subspace (P1-C sequential Bayes).
191    IdenticalCoefficientSubspace,
192    /// Effect-functional transfer via a named source quantity (e.g. `"ate"`).
193    EffectFunctional {
194        /// Source effect / quantity name.
195        source_quantity: String,
196    },
197    /// Explicit source→target quantity name pairs.
198    NamedParameters {
199        /// `(source_name, target_name)` pairs.
200        pairs: Vec<(String, String)>,
201    },
202}
203
204/// Build a coefficient [`PriorSet`] under a declared [`HydrateMapping`].
205///
206/// - [`HydrateMapping::IdenticalCoefficientSubspace`]: full coef hydrate; hard-errors
207///   when source coef count ≠ baseline length.
208/// - [`HydrateMapping::EffectFunctional`]: maps source effect moments onto the
209///   treatment coefficient (identity-link ATE bridge); other dims keep `baseline`.
210/// - [`HydrateMapping::NamedParameters`]: maps named source moments onto named
211///   target coefficients; unmapped dims keep `baseline`.
212///
213/// Records `external_effect_prior` / `external_named_prior` on
214/// [`PriorSet::restrictions`].
215///
216/// # Errors
217///
218/// Dimension mismatch, missing effect column, unknown names, or invalid baseline.
219pub fn hydrate_prior(
220    mapping: &HydrateMapping,
221    quantities: &[PosteriorQuantityKind],
222    mean: &[f64],
223    sd: &[f64],
224    baseline: &PriorSet,
225    target_coef_names: &[Arc<str>],
226    treatment_col: Option<usize>,
227) -> Result<PriorSet, EstimationError> {
228    if mean.len() != quantities.len() || sd.len() != quantities.len() {
229        return Err(EstimationError::stats_msg(
230            "hydrate_prior: mean/sd length must match quantities",
231        ));
232    }
233    let n_target = target_coef_names.len();
234    let base_coef = baseline.gaussian_coefficients().ok_or_else(|| {
235        EstimationError::stats_msg("hydrate_prior: baseline missing GaussianCoefficients")
236    })?;
237    if base_coef.len() != n_target {
238        return Err(EstimationError::stats_msg(format!(
239            "hydrate_prior: baseline n_coef {} != target_coef_names {}",
240            base_coef.len(),
241            n_target
242        )));
243    }
244
245    match mapping {
246        HydrateMapping::IdenticalCoefficientSubspace => {
247            let mut prior =
248                hydrate_prior_from_quantity_summaries(quantities, mean, sd, Some(n_target))?;
249            // Preserve residual specs from baseline when present.
250            merge_baseline_residuals(&mut prior, baseline);
251            Ok(prior)
252        }
253        HydrateMapping::EffectFunctional { source_quantity } => {
254            let t_col = treatment_col.ok_or_else(|| {
255                EstimationError::stats_msg("hydrate_prior: EffectFunctional requires treatment_col")
256            })?;
257            if t_col >= n_target {
258                return Err(EstimationError::stats_msg(format!(
259                    "hydrate_prior: treatment_col {t_col} out of range for {n_target} coefs"
260                )));
261            }
262            let (m, s) = quantity_moments(quantities, mean, sd, source_quantity.as_str())?;
263            let effect = EffectPrior::new(m, s.max(HYDRATE_VAR_FLOOR.sqrt()))
264                .map_err(EstimationError::from)?;
265            let mut means: Vec<f64> = base_coef.mean.to_vec();
266            let mut vars: Vec<f64> = base_coef.variance.to_vec();
267            means[t_col] = effect.mean;
268            vars[t_col] = (effect.sd * effect.sd).max(HYDRATE_VAR_FLOOR);
269            let coef =
270                GaussianCoefficientPrior { mean: Arc::from(means), variance: Arc::from(vars) };
271            coef.validate().map_err(EstimationError::from)?;
272            let mut prior = PriorSet {
273                specs: vec![PriorSpec::GaussianCoefficients(coef)],
274                contrast: baseline.contrast,
275                categorical: baseline.categorical.clone(),
276                restrictions: vec![PriorAssumption {
277                    id: Arc::from("external_effect_prior"),
278                    description: Arc::from(format!(
279                        "external effect-functional prior from quantity `{source_quantity}` onto treatment coefficient"
280                    )),
281                }],
282            };
283            merge_baseline_residuals(&mut prior, baseline);
284            Ok(prior)
285        }
286        HydrateMapping::NamedParameters { pairs } => {
287            if pairs.is_empty() {
288                return Err(EstimationError::stats_msg(
289                    "hydrate_prior: NamedParameters requires at least one pair",
290                ));
291            }
292            let mut means: Vec<f64> = base_coef.mean.to_vec();
293            let mut vars: Vec<f64> = base_coef.variance.to_vec();
294            let name_index: std::collections::HashMap<&str, usize> =
295                target_coef_names.iter().enumerate().map(|(i, n)| (n.as_ref(), i)).collect();
296            for (src, tgt) in pairs {
297                let (m, s) = quantity_moments(quantities, mean, sd, src)?;
298                let Some(&idx) = name_index.get(tgt.as_str()) else {
299                    return Err(EstimationError::stats_msg(format!(
300                        "hydrate_prior: unknown target coefficient name `{tgt}`"
301                    )));
302                };
303                means[idx] = m;
304                vars[idx] = (s * s).max(HYDRATE_VAR_FLOOR);
305            }
306            let coef =
307                GaussianCoefficientPrior { mean: Arc::from(means), variance: Arc::from(vars) };
308            coef.validate().map_err(EstimationError::from)?;
309            let pair_desc =
310                pairs.iter().map(|(a, b)| format!("{a}->{b}")).collect::<Vec<_>>().join(", ");
311            let mut prior = PriorSet {
312                specs: vec![PriorSpec::GaussianCoefficients(coef)],
313                contrast: baseline.contrast,
314                categorical: baseline.categorical.clone(),
315                restrictions: vec![PriorAssumption {
316                    id: Arc::from("external_named_prior"),
317                    description: Arc::from(format!("external named-parameter prior ({pair_desc})")),
318                }],
319            };
320            merge_baseline_residuals(&mut prior, baseline);
321            Ok(prior)
322        }
323    }
324}
325
326fn merge_baseline_residuals(prior: &mut PriorSet, baseline: &PriorSet) {
327    for spec in &baseline.specs {
328        match spec {
329            PriorSpec::ResidualInvGamma(_) | PriorSpec::KnownResidualVariance(_) => {
330                if !prior.specs.iter().any(|s| {
331                    matches!(
332                        s,
333                        PriorSpec::ResidualInvGamma(_) | PriorSpec::KnownResidualVariance(_)
334                    )
335                }) {
336                    prior.specs.push(spec.clone());
337                }
338            }
339            PriorSpec::GaussianCoefficients(_) => {}
340        }
341    }
342}
343
344fn quantity_moments(
345    quantities: &[PosteriorQuantityKind],
346    mean: &[f64],
347    sd: &[f64],
348    name: &str,
349) -> Result<(f64, f64), EstimationError> {
350    for (i, q) in quantities.iter().enumerate() {
351        let q_name = match q {
352            PosteriorQuantityKind::Effect { name: n }
353            | PosteriorQuantityKind::Scalar { name: n } => Some(n.as_ref()),
354            PosteriorQuantityKind::Coefficient { name: n, .. } => {
355                n.as_ref().map(std::convert::AsRef::as_ref)
356            }
357            PosteriorQuantityKind::ResidualVariance => Some("residual_variance"),
358        };
359        if q_name == Some(name) {
360            let m = mean[i];
361            let s = sd[i];
362            if !m.is_finite() || !s.is_finite() {
363                return Err(EstimationError::stats_msg(format!(
364                    "hydrate_prior: non-finite summary for quantity `{name}`"
365                )));
366            }
367            return Ok((m, s.max(HYDRATE_VAR_FLOOR.sqrt())));
368        }
369    }
370    Err(EstimationError::stats_msg(format!("hydrate_prior: missing quantity `{name}`")))
371}
372
373/// Bayesian linear / GLM mechanism fit (coefficient posterior).
374#[derive(Clone, Debug)]
375pub struct BayesianGlmMechanism {
376    /// Fitted coefficient draws (columnar).
377    pub coefficient_draws: PosteriorDraws,
378    /// MAP / posterior mode coefficients.
379    pub map: Vec<f64>,
380    /// Likelihood used.
381    pub likelihood: BayesLikelihood,
382    /// Diagnostics.
383    pub diagnostics: InferenceDiagnostics,
384    /// Compiled design retained for g-computation.
385    pub design: CompiledDesign,
386    /// Treatment column index in the design.
387    pub treatment_col: usize,
388    /// Active / control levels.
389    pub active: f64,
390    /// Control level.
391    pub control: f64,
392}
393
394/// Which inference backend to use for Bayesian g-computation.
395#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
396pub enum BayesianBackendKind {
397    /// Analytic conjugate Gaussian (identity link only).
398    ConjugateGaussian,
399    /// Native Laplace GLM.
400    Laplace,
401    /// Native HMC GLM (multi-chain; ESS / R-hat gated).
402    Hmc,
403}
404
405/// Bayesian g-computation ATE estimator.
406#[derive(Clone, Debug)]
407pub struct BayesianGComputationAte {
408    /// Backend kind.
409    pub backend: BayesianBackendKind,
410    /// Likelihood (Laplace); conjugate forces GaussianIdentity.
411    pub likelihood: BayesLikelihood,
412    /// Draw count.
413    pub n_draws: usize,
414    /// RNG seed.
415    pub seed: u64,
416    /// Overlap policy (must be ExplicitOverride).
417    pub overlap: OverlapPolicy,
418    /// Prior scale for isotropic Gaussian coefficients (weakly informative default 10).
419    pub prior_scale: f64,
420    /// Optional explicit coefficient prior (e.g. hydrated from a previous posterior).
421    /// When set, overrides isotropic [`Self::prior_scale`].
422    pub prior: Option<PriorSet>,
423}
424
425impl Default for BayesianGComputationAte {
426    fn default() -> Self {
427        Self::new()
428    }
429}
430
431impl BayesianGComputationAte {
432    /// Laplace Gaussian defaults.
433    #[must_use]
434    pub fn new() -> Self {
435        Self {
436            backend: BayesianBackendKind::Laplace,
437            likelihood: BayesLikelihood::GaussianIdentity,
438            n_draws: 1000,
439            seed: 0,
440            overlap: OverlapPolicy::ExplicitOverride,
441            prior_scale: 10.0,
442            prior: None,
443        }
444    }
445
446    /// Conjugate Gaussian linear path.
447    #[must_use]
448    pub fn conjugate() -> Self {
449        Self {
450            backend: BayesianBackendKind::ConjugateGaussian,
451            likelihood: BayesLikelihood::GaussianIdentity,
452            ..Self::new()
453        }
454    }
455
456    /// Prepare from data + identified estimand (same IR as frequentist adjustment).
457    ///
458    /// # Errors
459    ///
460    /// Overlap / estimand / data failures.
461    pub fn prepare(
462        &self,
463        data: &TabularData,
464        estimand: &IdentifiedEstimand,
465        query: &AverageEffectQuery,
466    ) -> Result<PreparedBayesianProblem, EstimationError> {
467        require_explicit_override(
468            self.overlap,
469            "BayesianGComputationAte requires ExplicitOverride overlap policy",
470        )?;
471        if !matches!(
472            estimand.method_kind().ok(),
473            Some(
474                antecedent_expr::EstimandMethod::BackdoorAdjustment
475                    | antecedent_expr::EstimandMethod::BackdoorEfficient
476            )
477        ) {
478            return Err(EstimationError::IncompatibleEstimand {
479                message: "BayesianGComputationAte expects backdoor.adjustment/efficient",
480            });
481        }
482        query.validate()?;
483        if !query.effect_modifiers.is_empty() {
484            return Err(EstimationError::unsupported(
485                "Bayesian g-comp does not support effect modifiers",
486            ));
487        }
488        if query.target_population != TargetPopulation::AllObserved {
489            return Err(EstimationError::unsupported(
490                "Bayesian g-comp only supports TargetPopulation::AllObserved",
491            ));
492        }
493        let active = intervention_f64(&query.active)?;
494        let control = intervention_f64(&query.control)?;
495        if (active - control).abs() < f64::EPSILON {
496            return Err(EstimationError::unsupported(
497                "active and control treatment levels must differ",
498            ));
499        }
500
501        let treatment = query.treatment;
502        let outcome = query.outcome;
503        let mut ids = Vec::with_capacity(2 + estimand.adjustment_set.len());
504        ids.push(treatment);
505        ids.push(outcome);
506        ids.extend_from_slice(&estimand.adjustment_set);
507        let row_mask = data.complete_case_mask(&ids).map_err(EstimationError::from)?;
508        let t = data.float64_masked(treatment, &row_mask).map_err(EstimationError::from)?;
509        let y = data.float64_masked(outcome, &row_mask).map_err(EstimationError::from)?;
510        let mut covs: Vec<(VariableId, Vec<f64>)> = Vec::new();
511        for &z in estimand.adjustment_set.iter() {
512            covs.push((z, data.float64_masked(z, &row_mask).map_err(EstimationError::from)?));
513        }
514        let cov_refs: Vec<(VariableId, &[f64])> =
515            covs.iter().map(|(id, v)| (*id, v.as_slice())).collect();
516        let selected_rows: Vec<usize> =
517            row_mask.iter().enumerate().filter_map(|(i, keep)| keep.then_some(i)).collect();
518        let design = CompiledDesign::linear_adjustment(&t, &cov_refs, &y, &selected_rows)
519            .map_err(EstimationError::from)?;
520        let schema = data.schema();
521        let treatment_name = schema.get(treatment).map(|v| v.name.as_ref()).unwrap_or("treatment");
522        let coef_names = coefficient_names_from_design(&design, treatment_name, |id| {
523            schema.get(id).ok().map(|v| Arc::clone(&v.name))
524        });
525        Ok(PreparedBayesianProblem {
526            design,
527            method: Arc::clone(&estimand.method),
528            adjustment_set: Arc::clone(&estimand.adjustment_set),
529            active,
530            control,
531            overlap: self.overlap,
532            coef_names: Some(coef_names),
533        })
534    }
535
536    /// Adapt a frequentist prepared design (e.g. lag-aligned temporal) for Bayesian fit.
537    ///
538    /// Used by the temporal pulse/sustained path: prepare via
539    /// [`crate::TemporalLinearAdjustment`], then fit with this estimator.
540    #[must_use]
541    pub fn from_prepared_estimation(prep: &PreparedEstimationProblem) -> PreparedBayesianProblem {
542        PreparedBayesianProblem {
543            design: prep.design.clone(),
544            method: Arc::clone(&prep.method),
545            adjustment_set: Arc::clone(&prep.adjustment_set),
546            active: prep.active,
547            control: prep.control,
548            overlap: prep.overlap,
549            coef_names: None,
550        }
551    }
552
553    /// Fit mechanism + evaluate ATE g-computation posterior.
554    ///
555    /// `identification` is recorded as-is; informative priors never change it.
556    ///
557    /// # Errors
558    ///
559    /// Backend / evaluation failures.
560    pub fn fit(
561        &self,
562        problem: &PreparedBayesianProblem,
563        identification: IdentificationStatus,
564        workspace: &mut BayesianGCompWorkspace,
565        ctx: &ExecutionContext,
566    ) -> Result<CausalPosterior, EstimationError> {
567        let sequential = self.prior.is_some();
568        let prior = if let Some(p) = &self.prior {
569            if let Some(coef) = p.gaussian_coefficients() {
570                if coef.len() != problem.design.ncols {
571                    return Err(EstimationError::stats_msg(format!(
572                        "sequential prior coefficient dimension {} != design ncols {}",
573                        coef.len(),
574                        problem.design.ncols
575                    )));
576                }
577            } else {
578                return Err(EstimationError::stats_msg(
579                    "sequential prior missing GaussianCoefficients entry",
580                ));
581            }
582            p.clone()
583        } else {
584            PriorSet {
585                specs: vec![PriorSpec::GaussianCoefficients(
586                    antecedent_prob::GaussianCoefficientPrior::isotropic(
587                        problem.design.ncols,
588                        self.prior_scale,
589                    ),
590                )],
591                contrast: None,
592                categorical: Vec::new(),
593                restrictions: Vec::new(),
594            }
595        };
596        let mut assumptions = AssumptionSet::new();
597        let source = if sequential {
598            AssumptionSource::Artifact
599        } else {
600            AssumptionSource::AlgorithmDefault { algorithm: Arc::from("bayesian_gcomp") }
601        };
602        for spec in &prior.specs {
603            let mut pa = spec.as_assumption();
604            if sequential {
605                pa.description = Arc::from(format!(
606                    "{} (sequential prior from posterior artifact)",
607                    pa.description
608                ));
609            }
610            assumptions.push(AssumptionRecord {
611                assumption: Assumption::PriorRestriction(pa),
612                source: source.clone(),
613                scope: AssumptionScope::Estimation,
614                status: AssumptionStatus::Untestable,
615            });
616        }
617        for pa in &prior.restrictions {
618            assumptions.push(AssumptionRecord {
619                assumption: Assumption::PriorRestriction(pa.clone()),
620                source: AssumptionSource::Artifact,
621                scope: AssumptionScope::Estimation,
622                status: AssumptionStatus::Untestable,
623            });
624        }
625
626        let likelihood = match self.backend {
627            BayesianBackendKind::ConjugateGaussian => BayesLikelihood::GaussianIdentity,
628            BayesianBackendKind::Laplace | BayesianBackendKind::Hmc => self.likelihood,
629        };
630        // HMC publication (Ř≤1.01, ESS≥100) needs a longer schedule than the
631        // Laplace/conjugate default of 1000 draws; floor so under-specified
632        // callers still clear the gate rather than refuse with near-miss Ř.
633        let max_draws = match self.backend {
634            BayesianBackendKind::Hmc => self.n_draws.max(HMC_MIN_DRAWS),
635            _ => self.n_draws.max(1),
636        };
637        let adaptive = ctx.adaptive_draws;
638        let laplace_adaptive = adaptive.enabled
639            && matches!(self.backend, BayesianBackendKind::Laplace)
640            && max_draws > adaptive.min_draws.max(2);
641        let initial_draws =
642            if laplace_adaptive { adaptive.min_draws.max(2).min(max_draws) } else { max_draws };
643        let opts = BayesFitOptions {
644            n_draws: initial_draws,
645            seed: self.seed,
646            ..BayesFitOptions::default()
647        };
648        let design_ref = BayesDesignRef {
649            x_colmajor: &problem.design.matrix,
650            nrows: problem.design.nrows,
651            ncols: problem.design.ncols,
652            y: &problem.design.outcome,
653            weights: None,
654            offsets: None,
655        };
656
657        let mut fit = match self.backend {
658            BayesianBackendKind::ConjugateGaussian => ConjugateGaussianBackend.fit(
659                likelihood,
660                design_ref,
661                &prior,
662                &opts,
663                &mut workspace.laplace,
664                ctx,
665            ),
666            BayesianBackendKind::Laplace => LaplaceGlmBackend.fit(
667                likelihood,
668                design_ref,
669                &prior,
670                &opts,
671                &mut workspace.laplace,
672                ctx,
673            ),
674            BayesianBackendKind::Hmc => {
675                // Floor warmup at the oracle schedule that clears Ř≤1.01 /
676                // ESS≥100 on Gaussian GLMs; scale with kept draws above that.
677                let n_warmup = max_draws.max(1_500);
678                HmcGlmBackend::new()
679                    .with_options(HmcOptions {
680                        n_chains: 4,
681                        n_warmup,
682                        leapfrog_steps: 16,
683                        step_size: 0.04,
684                        target_accept: 0.85,
685                        ..HmcOptions::default()
686                    })
687                    .fit(likelihood, design_ref, &prior, &opts, &mut workspace.laplace, ctx)
688            }
689        }
690        .map_err(prob_err)?;
691
692        if !fit.diagnostics.allows_posterior() {
693            return Err(EstimationError::stats_msg("Bayesian fit refused without diagnostics"));
694        }
695
696        let t_col = problem
697            .design
698            .treatment_column()
699            .ok_or_else(|| EstimationError::stats_msg("missing treatment column"))?;
700
701        let glm_family = likelihood_to_glm_family(likelihood);
702        let evaluator = GCompAteEvaluator {
703            family: glm_family,
704            treatment_col: t_col,
705            active: problem.active,
706            control: problem.control,
707            nrows: problem.design.nrows,
708            ncols: problem.design.ncols,
709            matrix: Arc::clone(&problem.design.matrix),
710        };
711        let compiled = evaluator.compile()?;
712
713        let mut early_stopped = false;
714        let mut n_draws = fit.draws.n_draws;
715        // Adaptive MVN sampling uses the β-block covariance only; drop residual-variance
716        // columns so batch merges match `PosteriorSchema::coefficients`.
717        let mut coef_draws = coefficient_only_draws(&fit.draws)?;
718
719        if laplace_adaptive {
720            let cov = fit.cov.as_ref().ok_or_else(|| {
721                EstimationError::stats_msg("Laplace adaptive draws require posterior covariance")
722            })?;
723            let map = fit.map.clone();
724            let batch = 32usize;
725            let mut effect_acc: Vec<f64> = Vec::with_capacity(max_draws);
726            let mut width_prev: Option<f64> = None;
727
728            // Evaluate initial block.
729            {
730                workspace.eval.prepare(n_draws, problem.design.ncols);
731                let mut effect_out = EffectBatch::default();
732                effect_out.prepare(n_draws);
733                let batch_view = coef_draws.batch(0, n_draws).map_err(EstimationError::from)?;
734                evaluator.evaluate_batch(
735                    &compiled,
736                    batch_view,
737                    &mut effect_out,
738                    &mut workspace.eval,
739                    ctx,
740                )?;
741                effect_acc.extend_from_slice(&effect_out.values[..n_draws]);
742            }
743
744            loop {
745                let width = quantile_width_95(&effect_acc);
746                let ess = effect_acc.len() as f64; // independent MVN draws
747                if effect_acc.len() >= adaptive.min_draws.max(2) {
748                    let width_ok = width_prev.is_some_and(|prev| {
749                        let rel = (width - prev).abs() / prev.abs().max(1e-12);
750                        rel < adaptive.quantile_width_rel_epsilon
751                    });
752                    if width_ok || ess >= adaptive.ess_target {
753                        early_stopped = n_draws < max_draws;
754                        break;
755                    }
756                }
757                width_prev = Some(width);
758                if n_draws >= max_draws {
759                    break;
760                }
761                let next = (n_draws + batch).min(max_draws);
762                let add = next - n_draws;
763                let extra = sample_gaussian_mvn(
764                    &map,
765                    cov,
766                    add,
767                    self.seed.wrapping_add(n_draws as u64),
768                    &mut workspace.laplace,
769                )
770                .map_err(EstimationError::from)?;
771                let extra_draws = PosteriorDraws::from_column_major(
772                    PosteriorSchema::coefficients(problem.design.ncols),
773                    add,
774                    extra,
775                )
776                .map_err(EstimationError::from)?;
777                workspace.eval.prepare(add, problem.design.ncols);
778                let mut effect_out = EffectBatch::default();
779                effect_out.prepare(add);
780                let batch_view = extra_draws.batch(0, add).map_err(EstimationError::from)?;
781                evaluator.evaluate_batch(
782                    &compiled,
783                    batch_view,
784                    &mut effect_out,
785                    &mut workspace.eval,
786                    ctx,
787                )?;
788                effect_acc.extend_from_slice(&effect_out.values[..add]);
789                coef_draws = merge_coefficient_draws(&coef_draws, &extra_draws)?;
790                n_draws = next;
791                fit.draws = coef_draws.clone();
792            }
793
794            // Rebuild combined posterior from accumulated effects + final coef draws.
795            let mechanism_draws = coef_draws;
796            let mut quantities = mechanism_draws.schema.quantities.to_vec();
797            quantities.retain(|q| !matches!(q, PosteriorQuantityKind::ResidualVariance));
798            let effect_idx = quantities.len();
799            quantities.push(PosteriorQuantityKind::Effect { name: Arc::from("ate") });
800            let n_q = quantities.len();
801            let mut values = vec![0.0; n_draws * n_q];
802            for (qi, q) in mechanism_draws.schema.quantities.iter().enumerate() {
803                if matches!(q, PosteriorQuantityKind::ResidualVariance) {
804                    continue;
805                }
806                let dest = quantities.iter().position(|qq| qq == q).ok_or_else(|| {
807                    EstimationError::stats_msg(format!(
808                        "posterior quantity missing from schema: {q:?}"
809                    ))
810                })?;
811                let coef_col = mechanism_draws.column(qi).map_err(EstimationError::from)?;
812                values[dest * n_draws..(dest + 1) * n_draws].copy_from_slice(coef_col);
813            }
814            values[effect_idx * n_draws..(effect_idx + 1) * n_draws]
815                .copy_from_slice(&effect_acc[..n_draws]);
816            if let Some(names) = problem.coef_names.as_ref() {
817                apply_coefficient_names(&mut quantities, names);
818            }
819            let draws = PosteriorDraws::from_column_major(
820                PosteriorSchema { quantities: Arc::from(quantities) },
821                n_draws,
822                values,
823            )
824            .map_err(EstimationError::from)?;
825            let summaries = draws.summarize();
826            return Ok(CausalPosterior {
827                draws,
828                summaries,
829                identification,
830                prior_sensitivity: None,
831                conflict_summary: None,
832                diagnostics: fit.diagnostics,
833                assumptions,
834                unidentified_mass: 0.0,
835                early_stopped,
836            });
837        }
838
839        let mechanism = BayesianGlmMechanism {
840            coefficient_draws: coef_draws,
841            map: fit.map,
842            likelihood,
843            diagnostics: fit.diagnostics.clone(),
844            design: problem.design.clone(),
845            treatment_col: t_col,
846            active: problem.active,
847            control: problem.control,
848        };
849
850        workspace.eval.prepare(n_draws, problem.design.ncols);
851        let mut effect_out = EffectBatch::default();
852        effect_out.prepare(n_draws);
853        let batch = mechanism.coefficient_draws.batch(0, n_draws).map_err(EstimationError::from)?;
854        evaluator.evaluate_batch(&compiled, batch, &mut effect_out, &mut workspace.eval, ctx)?;
855
856        let mut quantities = mechanism.coefficient_draws.schema.quantities.to_vec();
857        // Drop residual variance column from combined effect artifact if present — keep coefs + effect.
858        quantities.retain(|q| !matches!(q, PosteriorQuantityKind::ResidualVariance));
859        let effect_idx = quantities.len();
860        quantities.push(PosteriorQuantityKind::Effect { name: Arc::from("ate") });
861        let n_q = quantities.len();
862        let mut values = vec![0.0; n_draws * n_q];
863        for (qi, q) in mechanism.coefficient_draws.schema.quantities.iter().enumerate() {
864            if matches!(q, PosteriorQuantityKind::ResidualVariance) {
865                continue;
866            }
867            let dest = quantities.iter().position(|qq| qq == q).ok_or_else(|| {
868                EstimationError::stats_msg(format!("posterior quantity missing from schema: {q:?}"))
869            })?;
870            let col = mechanism.coefficient_draws.column(qi).map_err(EstimationError::from)?;
871            values[dest * n_draws..(dest + 1) * n_draws].copy_from_slice(col);
872        }
873        values[effect_idx * n_draws..(effect_idx + 1) * n_draws]
874            .copy_from_slice(&effect_out.values[..n_draws]);
875
876        if let Some(names) = problem.coef_names.as_ref() {
877            apply_coefficient_names(&mut quantities, names);
878        }
879
880        let draws = PosteriorDraws::from_column_major(
881            PosteriorSchema { quantities: Arc::from(quantities) },
882            n_draws,
883            values,
884        )
885        .map_err(EstimationError::from)?;
886        let summaries = draws.summarize();
887
888        let _ = mechanism;
889        Ok(CausalPosterior {
890            draws,
891            summaries,
892            identification,
893            prior_sensitivity: None,
894            conflict_summary: None,
895            diagnostics: fit.diagnostics,
896            assumptions,
897            unidentified_mass: 0.0,
898            early_stopped: false,
899        })
900    }
901}
902
903/// Bayesian g-computation on a lag-aligned temporal design.
904///
905/// Prepare with [`crate::TemporalLinearAdjustment::prepare`], convert via
906/// [`BayesianGComputationAte::from_prepared_estimation`], then [`BayesianGComputationAte::fit`].
907/// This type documents the temporal entry point; fitting delegates to [`BayesianGComputationAte`].
908#[derive(Clone, Debug, Default)]
909pub struct BayesianTemporalGcomp {
910    /// Shared Bayesian estimator configuration.
911    pub inner: BayesianGComputationAte,
912}
913
914impl BayesianTemporalGcomp {
915    /// Laplace Gaussian defaults.
916    #[must_use]
917    pub fn new() -> Self {
918        Self { inner: BayesianGComputationAte::new() }
919    }
920
921    /// Conjugate Gaussian linear path.
922    #[must_use]
923    pub fn conjugate() -> Self {
924        Self { inner: BayesianGComputationAte::conjugate() }
925    }
926
927    /// Convert a temporal prepared design for Bayesian fit.
928    #[must_use]
929    pub fn from_prepared_estimation(prep: &PreparedEstimationProblem) -> PreparedBayesianProblem {
930        BayesianGComputationAte::from_prepared_estimation(prep)
931    }
932
933    /// Fit on a prepared Bayesian problem (typically from a temporal design).
934    ///
935    /// # Errors
936    ///
937    /// Backend / evaluation failures.
938    pub fn fit(
939        &self,
940        problem: &PreparedBayesianProblem,
941        identification: IdentificationStatus,
942        workspace: &mut BayesianGCompWorkspace,
943        ctx: &ExecutionContext,
944    ) -> Result<CausalPosterior, EstimationError> {
945        self.inner.fit(problem, identification, workspace, ctx)
946    }
947}
948
949/// Durable coefficient names from a design + schema name resolver.
950///
951/// Convention: `intercept`, `coef_{treatment}`, `coef_{covariate}`.
952#[must_use]
953pub fn coefficient_names_from_design(
954    design: &CompiledDesign,
955    treatment_name: &str,
956    covariate_name: impl Fn(VariableId) -> Option<Arc<str>>,
957) -> Arc<[Arc<str>]> {
958    let names: Vec<Arc<str>> = design
959        .columns
960        .iter()
961        .map(|col| match col.role {
962            DesignColumnRole::Intercept => Arc::from("intercept"),
963            DesignColumnRole::Treatment => Arc::from(format!("coef_{treatment_name}")),
964            DesignColumnRole::Covariate(id) => covariate_name(id).map_or_else(
965                || Arc::from(format!("coef_var_{}", id.raw())),
966                |n| Arc::from(format!("coef_{n}")),
967            ),
968        })
969        .collect();
970    Arc::from(names)
971}
972
973/// Apply durable names onto coefficient quantities (in place).
974fn apply_coefficient_names(quantities: &mut [PosteriorQuantityKind], names: &[Arc<str>]) {
975    for q in quantities {
976        if let PosteriorQuantityKind::Coefficient { index, name } = q {
977            if let Some(n) = names.get(*index) {
978                *name = Some(Arc::clone(n));
979            }
980        }
981    }
982}
983
984/// Prepared Bayesian g-comp problem.
985#[derive(Clone, Debug)]
986pub struct PreparedBayesianProblem {
987    /// Design.
988    pub design: CompiledDesign,
989    /// Estimand method.
990    pub method: Arc<str>,
991    /// Adjustment set.
992    pub adjustment_set: Arc<[VariableId]>,
993    /// Active treatment.
994    pub active: f64,
995    /// Control treatment.
996    pub control: f64,
997    /// Overlap.
998    pub overlap: OverlapPolicy,
999    /// Optional durable coefficient names aligned to design columns.
1000    pub coef_names: Option<Arc<[Arc<str>]>>,
1001}
1002
1003/// Workspace for Bayesian g-comp.
1004#[derive(Clone, Debug, Default)]
1005pub struct BayesianGCompWorkspace {
1006    /// Laplace / conjugate workspace.
1007    pub laplace: LaplaceWorkspace,
1008    /// Posterior functional eval scratch.
1009    pub eval: PosteriorEvalWorkspace,
1010}
1011
1012/// Trait for batched posterior functional evaluation.
1013pub trait PosteriorFunctionalEvaluator {
1014    /// Compiled plan type.
1015    type Compiled;
1016
1017    /// Compile against a posterior schema.
1018    ///
1019    /// # Errors
1020    ///
1021    /// Incompatible schema.
1022    fn compile(&self) -> Result<Self::Compiled, EstimationError>;
1023
1024    /// Evaluate a batch of coefficient draws into effects.
1025    ///
1026    /// # Errors
1027    ///
1028    /// Shape / numerical failures.
1029    fn evaluate_batch(
1030        &self,
1031        compiled: &Self::Compiled,
1032        posterior: PosteriorBatch<'_>,
1033        output: &mut EffectBatch,
1034        workspace: &mut PosteriorEvalWorkspace,
1035        ctx: &ExecutionContext,
1036    ) -> Result<(), EstimationError>;
1037}
1038
1039/// Compiled g-comp ATE evaluator (finite-difference mean contrast).
1040#[derive(Clone, Debug)]
1041pub struct GCompAteEvaluator {
1042    /// Mean family.
1043    pub family: GlmFamily,
1044    /// Treatment column.
1045    pub treatment_col: usize,
1046    /// Active level.
1047    pub active: f64,
1048    /// Control level.
1049    pub control: f64,
1050    /// Rows.
1051    pub nrows: usize,
1052    /// Cols.
1053    pub ncols: usize,
1054    /// Design matrix (column-major).
1055    pub matrix: Arc<[f64]>,
1056}
1057
1058/// Empty compiled marker (evaluator is self-contained).
1059#[derive(Clone, Copy, Debug, Default)]
1060pub struct CompiledGCompAte;
1061
1062impl PosteriorFunctionalEvaluator for GCompAteEvaluator {
1063    type Compiled = CompiledGCompAte;
1064
1065    fn compile(&self) -> Result<Self::Compiled, EstimationError> {
1066        if self.treatment_col >= self.ncols {
1067            return Err(EstimationError::stats_msg("treatment column out of range"));
1068        }
1069        Ok(CompiledGCompAte)
1070    }
1071
1072    fn evaluate_batch(
1073        &self,
1074        _compiled: &Self::Compiled,
1075        posterior: PosteriorBatch<'_>,
1076        output: &mut EffectBatch,
1077        workspace: &mut PosteriorEvalWorkspace,
1078        _ctx: &ExecutionContext,
1079    ) -> Result<(), EstimationError> {
1080        let n_draws = posterior.len;
1081        workspace.prepare(n_draws, self.ncols);
1082        output.prepare(n_draws);
1083
1084        // Coefficient columns 0..ncols from the batch (ignore extra quantities).
1085        let mut coef_cols: Vec<&[f64]> = Vec::with_capacity(self.ncols);
1086        for c in 0..self.ncols {
1087            let col = posterior.column(c).map_err(EstimationError::from)?;
1088            coef_cols.push(col);
1089        }
1090
1091        for d in 0..n_draws {
1092            for c in 0..self.ncols {
1093                workspace.row[c] = coef_cols[c][d];
1094            }
1095            let beta = &workspace.row[..self.ncols];
1096            let mut sum = 0.0;
1097            for r in 0..self.nrows {
1098                let mu_a = predict_row(
1099                    self.family,
1100                    &self.matrix,
1101                    self.nrows,
1102                    self.ncols,
1103                    self.treatment_col,
1104                    beta,
1105                    r,
1106                    self.active,
1107                );
1108                let mu_c = predict_row(
1109                    self.family,
1110                    &self.matrix,
1111                    self.nrows,
1112                    self.ncols,
1113                    self.treatment_col,
1114                    beta,
1115                    r,
1116                    self.control,
1117                );
1118                sum += mu_a - mu_c;
1119            }
1120            output.values[d] = sum / self.nrows as f64;
1121        }
1122        Ok(())
1123    }
1124}
1125
1126fn predict_row(
1127    family: GlmFamily,
1128    matrix: &[f64],
1129    nrows: usize,
1130    ncols: usize,
1131    t_col: usize,
1132    beta: &[f64],
1133    row: usize,
1134    t_value: f64,
1135) -> f64 {
1136    let mut eta = 0.0;
1137    for c in 0..ncols {
1138        let x = if c == t_col { t_value } else { matrix[c * nrows + row] };
1139        eta += x * beta[c];
1140    }
1141    family.mean_from_eta(eta)
1142}
1143
1144fn likelihood_to_glm_family(l: BayesLikelihood) -> GlmFamily {
1145    match l {
1146        BayesLikelihood::GaussianIdentity => GlmFamily::GaussianIdentity,
1147        BayesLikelihood::BernoulliLogit => GlmFamily::BinomialLogit,
1148        BayesLikelihood::BernoulliProbit => GlmFamily::BinomialProbit,
1149        BayesLikelihood::PoissonLog => GlmFamily::PoissonLog,
1150    }
1151}
1152
1153fn prob_err(e: antecedent_prob::ProbError) -> EstimationError {
1154    EstimationError::from(e)
1155}
1156
1157/// 95% quantile width of a scalar draw vector.
1158fn quantile_width_95(values: &[f64]) -> f64 {
1159    if values.len() < 2 {
1160        return f64::NAN;
1161    }
1162    // Reuse posterior summarization for consistent quantiles.
1163    let schema = PosteriorSchema {
1164        quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("w") }]),
1165    };
1166    let Ok(draws) = PosteriorDraws::from_column_major(schema, values.len(), values.to_vec()) else {
1167        return f64::NAN;
1168    };
1169    let s = draws.summarize();
1170    s.q975[0] - s.q025[0]
1171}
1172
1173/// Concatenate two coefficient-only posterior draw tables (same schema).
1174fn merge_coefficient_draws(
1175    a: &PosteriorDraws,
1176    b: &PosteriorDraws,
1177) -> Result<PosteriorDraws, EstimationError> {
1178    if a.schema != b.schema {
1179        return Err(EstimationError::stats_msg("merge_coefficient_draws: schema mismatch"));
1180    }
1181    let n_q = a.schema.quantities.len();
1182    let n = a.n_draws + b.n_draws;
1183    let mut values = vec![0.0; n * n_q];
1184    for q in 0..n_q {
1185        let col_a = a.column(q).map_err(EstimationError::from)?;
1186        let col_b = b.column(q).map_err(EstimationError::from)?;
1187        values[q * n..q * n + a.n_draws].copy_from_slice(col_a);
1188        values[q * n + a.n_draws..(q + 1) * n].copy_from_slice(col_b);
1189    }
1190    PosteriorDraws::from_column_major(a.schema.clone(), n, values).map_err(EstimationError::from)
1191}
1192
1193/// Keep coefficient columns only (drop residual-variance / other non-β quantities).
1194fn coefficient_only_draws(draws: &PosteriorDraws) -> Result<PosteriorDraws, EstimationError> {
1195    let coef_idx: Vec<usize> = draws
1196        .schema
1197        .quantities
1198        .iter()
1199        .enumerate()
1200        .filter_map(|(i, q)| matches!(q, PosteriorQuantityKind::Coefficient { .. }).then_some(i))
1201        .collect();
1202    if coef_idx.is_empty() {
1203        return Err(EstimationError::stats_msg(
1204            "coefficient_only_draws: no coefficient quantities",
1205        ));
1206    }
1207    if coef_idx.len() == draws.schema.quantities.len() {
1208        return Ok(draws.clone());
1209    }
1210    let n = draws.n_draws;
1211    let n_q = coef_idx.len();
1212    let mut quantities = Vec::with_capacity(n_q);
1213    let mut values = vec![0.0; n * n_q];
1214    for (dest, &src) in coef_idx.iter().enumerate() {
1215        quantities.push(draws.schema.quantities[src].clone());
1216        let col = draws.column(src).map_err(EstimationError::from)?;
1217        values[dest * n..(dest + 1) * n].copy_from_slice(col);
1218    }
1219    PosteriorDraws::from_column_major(
1220        PosteriorSchema { quantities: Arc::from(quantities) },
1221        n,
1222        values,
1223    )
1224    .map_err(EstimationError::from)
1225}
1226
1227/// Build a non-identified posterior artifact that still records priors (exit criterion #2).
1228///
1229/// Samples prior-predictive draws for a scalar effect mean (isotropic Gaussian / weakly
1230/// informative scale from `prior`) so Bayesian envelopes can surface uncertainty without
1231/// inventing identification. Status remains [`IdentificationStatus::NotIdentified`].
1232#[must_use]
1233pub fn nonidentified_with_prior(
1234    prior: &PriorSet,
1235    diagnostics: InferenceDiagnostics,
1236    n_draws: usize,
1237    seed: u64,
1238) -> CausalPosterior {
1239    let mut assumptions = AssumptionSet::new();
1240    for spec in &prior.specs {
1241        assumptions.push(AssumptionRecord {
1242            assumption: Assumption::PriorRestriction(spec.as_assumption()),
1243            source: AssumptionSource::UserDeclared,
1244            scope: AssumptionScope::Estimation,
1245            status: AssumptionStatus::Untestable,
1246        });
1247    }
1248    let schema = PosteriorSchema {
1249        quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1250    };
1251    let (mean, scale) = prior_predictive_effect_params(prior);
1252    let n = n_draws.max(1);
1253    let mut values = vec![0.0; n];
1254    let mut rng = ExecutionContext::for_tests(seed).rng.stream(0xBA7E_u64);
1255    for v in &mut values {
1256        *v = mean + scale * antecedent_kernels::standard_normal(&mut rng);
1257    }
1258    let draws = PosteriorDraws::from_column_major(schema, n, Arc::<[f64]>::from(values))
1259        .unwrap_or_else(|_| PosteriorDraws {
1260            schema: PosteriorSchema {
1261                quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1262            },
1263            n_draws: 0,
1264            values: Arc::from([]),
1265        });
1266    let summaries = draws.summarize();
1267    CausalPosterior {
1268        draws,
1269        summaries,
1270        identification: IdentificationStatus::NotIdentified,
1271        prior_sensitivity: None,
1272        conflict_summary: None,
1273        diagnostics,
1274        assumptions,
1275        unidentified_mass: 1.0,
1276        early_stopped: false,
1277    }
1278}
1279
1280fn prior_predictive_effect_params(prior: &PriorSet) -> (f64, f64) {
1281    if let Some(g) = prior.gaussian_coefficients() {
1282        let mean = g.mean.first().copied().unwrap_or(0.0);
1283        let var = g.variance.first().copied().unwrap_or(100.0).max(1e-12);
1284        return (mean, var.sqrt());
1285    }
1286    (0.0, 10.0)
1287}
1288
1289#[cfg(test)]
1290mod tests {
1291    use super::*;
1292    use antecedent_core::{
1293        CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
1294    };
1295    use antecedent_data::column::{Float64Column, ValidityBitmap};
1296    use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
1297    use antecedent_expr::{ExprId, IdentifiedEstimand};
1298    use antecedent_prob::InferenceDiagnostics;
1299
1300    fn linear_scm_table(n: usize) -> (TabularData, VariableId, VariableId, VariableId) {
1301        let mut b = CausalSchemaBuilder::new();
1302        b.add_variable(
1303            "Z",
1304            ValueType::Continuous,
1305            SmallRoleSet::from_hint(RoleHint::Context),
1306            None,
1307            None,
1308            MeasurementSpec::default(),
1309        )
1310        .unwrap();
1311        b.add_variable(
1312            "T",
1313            ValueType::Continuous,
1314            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1315            None,
1316            None,
1317            MeasurementSpec::default(),
1318        )
1319        .unwrap();
1320        b.add_variable(
1321            "Y",
1322            ValueType::Continuous,
1323            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1324            None,
1325            None,
1326            MeasurementSpec::default(),
1327        )
1328        .unwrap();
1329        let schema = b.build().unwrap();
1330        let z = VariableId::from_raw(0);
1331        let t = VariableId::from_raw(1);
1332        let y = VariableId::from_raw(2);
1333        let mut zv = vec![0.0; n];
1334        let mut tv = vec![0.0; n];
1335        let mut yv = vec![0.0; n];
1336        for i in 0..n {
1337            zv[i] = (i as f64) * 0.1;
1338            tv[i] = if i % 2 == 0 { 1.0 } else { 0.0 };
1339            yv[i] = 2.0 * tv[i] + 0.5 * zv[i];
1340        }
1341        let validity = ValidityBitmap::all_valid(n);
1342        let cols = vec![
1343            OwnedColumn::Float64(Float64Column::new(z, Arc::from(zv), validity.clone()).unwrap()),
1344            OwnedColumn::Float64(Float64Column::new(t, Arc::from(tv), validity.clone()).unwrap()),
1345            OwnedColumn::Float64(Float64Column::new(y, Arc::from(yv), validity).unwrap()),
1346        ];
1347        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1348        (TabularData::new(storage), t, y, z)
1349    }
1350
1351    #[test]
1352    fn bayesian_and_frequentist_share_ate() {
1353        let n = 80;
1354        let (data, t, y, z) = linear_scm_table(n);
1355        let estimand = IdentifiedEstimand::backdoor(
1356            "backdoor.adjustment",
1357            Arc::from(vec![z]),
1358            ExprId::from_raw(0),
1359        );
1360        let query = AverageEffectQuery::binary_ate(t, y);
1361
1362        let freq = crate::adjustment::LinearAdjustmentAte {
1363            bootstrap_replicates: 0,
1364            ..crate::adjustment::LinearAdjustmentAte::new()
1365        };
1366        let prep = freq.prepare(&data, &estimand, &query).unwrap();
1367        let mut ws = crate::adjustment::EstimationWorkspace::default();
1368        let freq_est = freq
1369            .fit(&prep, &mut ws, &ExecutionContext::for_tests(1), AssumptionSet::new())
1370            .unwrap();
1371
1372        let bayes = BayesianGComputationAte {
1373            backend: BayesianBackendKind::ConjugateGaussian,
1374            n_draws: 400,
1375            seed: 5,
1376            prior_scale: 100.0,
1377            ..BayesianGComputationAte::new()
1378        };
1379        let bprep = bayes.prepare(&data, &estimand, &query).unwrap();
1380        let mut bws = BayesianGCompWorkspace::default();
1381        let post = bayes
1382            .fit(
1383                &bprep,
1384                IdentificationStatus::NonparametricallyIdentified,
1385                &mut bws,
1386                &ExecutionContext::for_tests(1),
1387            )
1388            .unwrap();
1389        let eq = post.effect_column().unwrap();
1390        let mean = post.summaries.mean[eq];
1391        assert!((freq_est.ate - 2.0).abs() < 1e-6, "frequentist ate={}", freq_est.ate);
1392        assert!((mean - freq_est.ate).abs() < 0.05, "bayes={mean} freq={}", freq_est.ate);
1393        assert_eq!(post.identification, IdentificationStatus::NonparametricallyIdentified);
1394        let coef_names: Vec<_> = post
1395            .draws
1396            .schema
1397            .quantities
1398            .iter()
1399            .filter_map(|q| match q {
1400                PosteriorQuantityKind::Coefficient { name, .. } => name.as_ref().map(AsRef::as_ref),
1401                _ => None,
1402            })
1403            .collect();
1404        assert!(coef_names.contains(&"intercept"), "{coef_names:?}");
1405        assert!(coef_names.iter().any(|n| n.starts_with("coef_")), "{coef_names:?}");
1406    }
1407
1408    #[test]
1409    fn prior_does_not_create_identification() {
1410        let prior = PriorSet::weakly_informative(3);
1411        let post = nonidentified_with_prior(&prior, InferenceDiagnostics::analytic("none"), 64, 1);
1412        assert_eq!(post.identification, IdentificationStatus::NotIdentified);
1413        assert!(!post.assumptions.is_empty());
1414        assert!((post.unidentified_mass - 1.0).abs() < 1e-12);
1415        assert!(post.draws.n_draws > 0, "prior-predictive draws required");
1416    }
1417
1418    #[test]
1419    fn temporal_prepared_design_conjugate_recovers_pulse() {
1420        use antecedent_core::{
1421            CausalSchemaBuilder, Lag, MeasurementSpec, RoleHint, SmallRoleSet, TemporalEffectQuery,
1422            TemporalPolicy, ValueType,
1423        };
1424        use antecedent_data::{
1425            Float64Column, OwnedColumn, OwnedColumnarStorage, SamplingRegularity, TimeIndex,
1426            TimeSeriesData, ValidityBitmap,
1427        };
1428        use antecedent_graph::{TemporalDag, ensure_lagged};
1429        use antecedent_identify::TemporalBackdoorIdentifier;
1430
1431        use crate::temporal_adjustment::TemporalLinearAdjustment;
1432
1433        let n = 300usize;
1434        let mut b = CausalSchemaBuilder::new();
1435        b.add_variable(
1436            "x",
1437            ValueType::Continuous,
1438            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1439            None,
1440            None,
1441            MeasurementSpec::default(),
1442        )
1443        .unwrap();
1444        b.add_variable(
1445            "y",
1446            ValueType::Continuous,
1447            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1448            None,
1449            None,
1450            MeasurementSpec::default(),
1451        )
1452        .unwrap();
1453        let schema = b.build().unwrap();
1454        let mut x = vec![0.0; n];
1455        let mut y = vec![0.0; n];
1456        for t in 1..n {
1457            x[t] = ((t as f64) * 0.07).sin();
1458            y[t] = 0.8 * x[t - 1];
1459        }
1460        let cols = vec![
1461            OwnedColumn::Float64(
1462                Float64Column::new(
1463                    VariableId::from_raw(0),
1464                    Arc::from(x),
1465                    ValidityBitmap::all_valid(n),
1466                )
1467                .unwrap(),
1468            ),
1469            OwnedColumn::Float64(
1470                Float64Column::new(
1471                    VariableId::from_raw(1),
1472                    Arc::from(y),
1473                    ValidityBitmap::all_valid(n),
1474                )
1475                .unwrap(),
1476            ),
1477        ];
1478        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1479        let data = TimeSeriesData::try_new(
1480            storage,
1481            TimeIndex { regularity: SamplingRegularity::Regular { interval_ns: 1 }, length: n },
1482        )
1483        .unwrap();
1484        let mut g = TemporalDag::empty();
1485        let x1 = ensure_lagged(&mut g, VariableId::from_raw(0), Lag::from_raw(1)).unwrap();
1486        let y0 = ensure_lagged(&mut g, VariableId::from_raw(1), Lag::CONTEMPORANEOUS).unwrap();
1487        g.insert_directed(x1, y0).unwrap();
1488
1489        let q = TemporalEffectQuery::pulse(VariableId::from_raw(0), VariableId::from_raw(1), 1.0)
1490            .with_policy(TemporalPolicy::pulse(-1))
1491            .with_horizon_steps(1)
1492            .with_max_history_lag(Some(1));
1493        let id_res = TemporalBackdoorIdentifier::new().identify_temporal(&g, &q).unwrap();
1494        let estimand = id_res.result.estimands.first().unwrap();
1495        let temporal = TemporalLinearAdjustment::new();
1496        let prep = temporal
1497            .prepare(
1498                &data,
1499                estimand,
1500                &q,
1501                &id_res.indexer,
1502                None,
1503                &ExecutionContext::for_tests(1).kernel_policy,
1504            )
1505            .unwrap();
1506        let bayes = BayesianTemporalGcomp {
1507            inner: BayesianGComputationAte {
1508                backend: BayesianBackendKind::ConjugateGaussian,
1509                n_draws: 200,
1510                seed: 7,
1511                prior_scale: 100.0,
1512                ..BayesianGComputationAte::new()
1513            },
1514        };
1515        let bprep = BayesianTemporalGcomp::from_prepared_estimation(&prep);
1516        let mut ws = BayesianGCompWorkspace::default();
1517        let post = bayes
1518            .fit(
1519                &bprep,
1520                IdentificationStatus::NonparametricallyIdentified,
1521                &mut ws,
1522                &ExecutionContext::for_tests(1),
1523            )
1524            .unwrap();
1525        let eq = post.effect_column().unwrap();
1526        let mean = post.summaries.mean[eq];
1527        assert!((mean - 0.8).abs() < 0.05, "bayesian temporal pulse mean={mean}");
1528        assert!(post.probability_below(0.0).unwrap().is_finite());
1529    }
1530
1531    #[test]
1532    fn hydrate_prior_from_posterior_and_refit() {
1533        let n = 60;
1534        let (data, t, y, z) = linear_scm_table(n);
1535        let estimand = IdentifiedEstimand::backdoor(
1536            "backdoor.adjustment",
1537            Arc::from(vec![z]),
1538            ExprId::from_raw(0),
1539        );
1540        let query = AverageEffectQuery::binary_ate(t, y);
1541        let bayes = BayesianGComputationAte {
1542            backend: BayesianBackendKind::ConjugateGaussian,
1543            n_draws: 200,
1544            seed: 3,
1545            prior_scale: 10.0,
1546            ..BayesianGComputationAte::new()
1547        };
1548        let prep = bayes.prepare(&data, &estimand, &query).unwrap();
1549        let mut ws = BayesianGCompWorkspace::default();
1550        let post = bayes
1551            .fit(
1552                &prep,
1553                IdentificationStatus::NonparametricallyIdentified,
1554                &mut ws,
1555                &ExecutionContext::for_tests(1),
1556            )
1557            .unwrap();
1558        let prior = hydrate_prior_from_posterior(&post, Some(prep.design.ncols)).unwrap();
1559        assert_eq!(prior.gaussian_coefficients().unwrap().len(), prep.design.ncols);
1560        assert!(hydrate_prior_from_posterior(&post, Some(prep.design.ncols + 1)).is_err());
1561
1562        let sequential = BayesianGComputationAte { prior: Some(prior), ..bayes };
1563        let post2 = sequential
1564            .fit(
1565                &prep,
1566                IdentificationStatus::NonparametricallyIdentified,
1567                &mut ws,
1568                &ExecutionContext::for_tests(1),
1569            )
1570            .unwrap();
1571        assert!(post2.assumptions.entries.iter().any(|a| {
1572            matches!(a.source, AssumptionSource::Artifact)
1573                && matches!(&a.assumption, Assumption::PriorRestriction(pa) if pa.description.contains("sequential"))
1574        }));
1575        let eq = post2.effect_column().unwrap();
1576        assert!(post2.summaries.mean[eq].is_finite());
1577    }
1578
1579    #[test]
1580    fn hydrate_effect_functional_maps_treatment_coef() {
1581        let quantities = vec![
1582            PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1583            PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1584            PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1585        ];
1586        let mean = vec![0.1, 0.5, 2.0];
1587        let sd = vec![1.0, 1.0, 0.4];
1588        let names: Vec<Arc<str>> =
1589            vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_z")];
1590        let baseline = PriorSet::weakly_informative(3);
1591        let prior = hydrate_prior(
1592            &HydrateMapping::EffectFunctional { source_quantity: "ate".into() },
1593            &quantities,
1594            &mean,
1595            &sd,
1596            &baseline,
1597            &names,
1598            Some(1),
1599        )
1600        .unwrap();
1601        let coef = prior.gaussian_coefficients().unwrap();
1602        assert!((coef.mean[1] - 2.0).abs() < 1e-12);
1603        assert!((coef.variance[1] - 0.16).abs() < 1e-12);
1604        // Unmapped dims keep baseline (isotropic scale 10 → var 100).
1605        assert!((coef.mean[0] - 0.0).abs() < 1e-12);
1606        assert!((coef.variance[0] - 100.0).abs() < 1e-12);
1607        assert!((coef.variance[2] - 100.0).abs() < 1e-12);
1608        assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_effect_prior"));
1609    }
1610
1611    #[test]
1612    fn hydrate_mapping_hard_errors() {
1613        let quantities = vec![
1614            PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1615            PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1616            PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1617        ];
1618        let mean = vec![0.0, 1.0, 2.0];
1619        let sd = vec![1.0, 1.0, 0.5];
1620        let names2: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1621        let baseline2 = PriorSet::weakly_informative(2);
1622        // Identical with wrong expected dim via target names of different length than source coefs.
1623        let names3: Vec<Arc<str>> =
1624            vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_w")];
1625        let baseline3 = PriorSet::weakly_informative(3);
1626        assert!(
1627            hydrate_prior(
1628                &HydrateMapping::IdenticalCoefficientSubspace,
1629                &quantities,
1630                &mean,
1631                &sd,
1632                &baseline3,
1633                &names3,
1634                None,
1635            )
1636            .is_err()
1637        );
1638
1639        assert!(
1640            hydrate_prior(
1641                &HydrateMapping::EffectFunctional { source_quantity: "missing".into() },
1642                &quantities,
1643                &mean,
1644                &sd,
1645                &baseline2,
1646                &names2,
1647                Some(1),
1648            )
1649            .is_err()
1650        );
1651
1652        assert!(
1653            hydrate_prior(
1654                &HydrateMapping::NamedParameters {
1655                    pairs: vec![("ate".into(), "no_such_coef".into())],
1656                },
1657                &quantities,
1658                &mean,
1659                &sd,
1660                &baseline2,
1661                &names2,
1662                None,
1663            )
1664            .is_err()
1665        );
1666
1667        assert!(
1668            hydrate_prior(
1669                &HydrateMapping::NamedParameters {
1670                    pairs: vec![("no_src".into(), "coef_t".into())],
1671                },
1672                &quantities,
1673                &mean,
1674                &sd,
1675                &baseline2,
1676                &names2,
1677                None,
1678            )
1679            .is_err()
1680        );
1681    }
1682
1683    #[test]
1684    fn hydrate_named_parameters_overwrites_target() {
1685        let quantities = vec![
1686            PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1687            PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1688            PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1689        ];
1690        let mean = vec![0.0, 0.0, 1.5];
1691        let sd = vec![1.0, 1.0, 0.2];
1692        let names: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1693        let baseline = PriorSet::weakly_informative(2);
1694        let prior = hydrate_prior(
1695            &HydrateMapping::NamedParameters { pairs: vec![("ate".into(), "coef_t".into())] },
1696            &quantities,
1697            &mean,
1698            &sd,
1699            &baseline,
1700            &names,
1701            None,
1702        )
1703        .unwrap();
1704        let coef = prior.gaussian_coefficients().unwrap();
1705        assert!((coef.mean[1] - 1.5).abs() < 1e-12);
1706        assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_named_prior"));
1707    }
1708}