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, GaussianVarianceModel, HmcGlmBackend,
29    HmcOptions, InferenceBackend, InferenceDiagnostics, LaplaceGlmBackend, LaplaceWorkspace,
30    PosteriorBatch, 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    /// Set the inference backend kind (conjugate Gaussian, Laplace, or HMC).
457    #[must_use]
458    pub const fn with_backend(mut self, backend: BayesianBackendKind) -> Self {
459        self.backend = backend;
460        self
461    }
462
463    /// Set the likelihood used for the Laplace / HMC backends.
464    ///
465    /// Ignored by [`BayesianBackendKind::ConjugateGaussian`], which always forces
466    /// [`BayesLikelihood::GaussianIdentity`].
467    #[must_use]
468    pub const fn with_likelihood(mut self, likelihood: BayesLikelihood) -> Self {
469        self.likelihood = likelihood;
470        self
471    }
472
473    /// Set the target draw count.
474    ///
475    /// Defaults to 1000. [`BayesianBackendKind::Hmc`] floors this at the schedule needed to
476    /// clear the Ř≤1.01 / ESS≥100 publication gate on typical Gaussian GLMs.
477    #[must_use]
478    pub const fn with_n_draws(mut self, n_draws: usize) -> Self {
479        self.n_draws = n_draws;
480        self
481    }
482
483    /// Set the RNG seed used for sampling / MVN draws.
484    #[must_use]
485    pub const fn with_seed(mut self, seed: u64) -> Self {
486        self.seed = seed;
487        self
488    }
489
490    /// Set the overlap policy. `prepare` requires [`OverlapPolicy::ExplicitOverride`].
491    #[must_use]
492    pub const fn with_overlap(mut self, overlap: OverlapPolicy) -> Self {
493        self.overlap = overlap;
494        self
495    }
496
497    /// Set the isotropic Gaussian coefficient prior scale (weakly informative default 10).
498    ///
499    /// Ignored when [`Self::with_prior`] supplies an explicit coefficient prior.
500    #[must_use]
501    pub const fn with_prior_scale(mut self, prior_scale: f64) -> Self {
502        self.prior_scale = prior_scale;
503        self
504    }
505
506    /// Set an explicit coefficient prior (e.g. hydrated from a previous posterior via
507    /// [`hydrate_prior_from_posterior`]), overriding [`Self::with_prior_scale`].
508    #[must_use]
509    pub fn with_prior(mut self, prior: PriorSet) -> Self {
510        self.prior = Some(prior);
511        self
512    }
513
514    /// Prepare from data + identified estimand (same IR as frequentist adjustment).
515    ///
516    /// # Errors
517    ///
518    /// Overlap / estimand / data failures.
519    pub fn prepare(
520        &self,
521        data: &TabularData,
522        estimand: &IdentifiedEstimand,
523        query: &AverageEffectQuery,
524    ) -> Result<PreparedBayesianProblem, EstimationError> {
525        require_explicit_override(
526            self.overlap,
527            "BayesianGComputationAte requires ExplicitOverride overlap policy",
528        )?;
529        if !matches!(
530            estimand.method_kind().ok(),
531            Some(
532                antecedent_expr::EstimandMethod::BackdoorAdjustment
533                    | antecedent_expr::EstimandMethod::BackdoorEfficient
534            )
535        ) {
536            return Err(EstimationError::IncompatibleEstimand {
537                message: "BayesianGComputationAte expects backdoor.adjustment/efficient",
538            });
539        }
540        query.validate()?;
541        if !query.effect_modifiers.is_empty() {
542            return Err(EstimationError::unsupported(
543                "Bayesian g-comp does not support effect modifiers",
544            ));
545        }
546        if query.target_population != TargetPopulation::AllObserved {
547            return Err(EstimationError::unsupported(
548                "Bayesian g-comp only supports TargetPopulation::AllObserved",
549            ));
550        }
551        let active = intervention_f64(&query.active)?;
552        let control = intervention_f64(&query.control)?;
553        if (active - control).abs() < f64::EPSILON {
554            return Err(EstimationError::unsupported(
555                "active and control treatment levels must differ",
556            ));
557        }
558
559        let treatment = query.treatment;
560        let outcome = query.outcome;
561        let mut ids = Vec::with_capacity(2 + estimand.adjustment_set.len());
562        ids.push(treatment);
563        ids.push(outcome);
564        ids.extend_from_slice(&estimand.adjustment_set);
565        let row_mask = data.complete_case_mask(&ids).map_err(EstimationError::from)?;
566        let t = data.float64_masked(treatment, &row_mask).map_err(EstimationError::from)?;
567        let y = data.float64_masked(outcome, &row_mask).map_err(EstimationError::from)?;
568        let mut covs: Vec<(VariableId, Vec<f64>)> = Vec::new();
569        for &z in estimand.adjustment_set.iter() {
570            covs.push((z, data.float64_masked(z, &row_mask).map_err(EstimationError::from)?));
571        }
572        let cov_refs: Vec<(VariableId, &[f64])> =
573            covs.iter().map(|(id, v)| (*id, v.as_slice())).collect();
574        let selected_rows: Vec<usize> =
575            row_mask.iter().enumerate().filter_map(|(i, keep)| keep.then_some(i)).collect();
576        let design = CompiledDesign::linear_adjustment(&t, &cov_refs, &y, &selected_rows)
577            .map_err(EstimationError::from)?;
578        let schema = data.schema();
579        let treatment_name = schema.get(treatment).map(|v| v.name.as_ref()).unwrap_or("treatment");
580        let coef_names = coefficient_names_from_design(&design, treatment_name, |id| {
581            schema.get(id).ok().map(|v| Arc::clone(&v.name))
582        });
583        Ok(PreparedBayesianProblem {
584            design,
585            method: Arc::clone(&estimand.method),
586            adjustment_set: Arc::clone(&estimand.adjustment_set),
587            active,
588            control,
589            overlap: self.overlap,
590            coef_names: Some(coef_names),
591        })
592    }
593
594    /// Adapt a frequentist prepared design (e.g. lag-aligned temporal) for Bayesian fit.
595    ///
596    /// Used by the temporal pulse/sustained path: prepare via
597    /// [`crate::TemporalLinearAdjustment`], then fit with this estimator.
598    #[must_use]
599    pub fn from_prepared_estimation(prep: &PreparedEstimationProblem) -> PreparedBayesianProblem {
600        PreparedBayesianProblem {
601            design: prep.design.clone(),
602            method: Arc::clone(&prep.method),
603            adjustment_set: Arc::clone(&prep.adjustment_set),
604            active: prep.active,
605            control: prep.control,
606            overlap: prep.overlap,
607            coef_names: None,
608        }
609    }
610
611    /// Fit mechanism + evaluate ATE g-computation posterior.
612    ///
613    /// `identification` is recorded as-is; informative priors never change it.
614    ///
615    /// # Errors
616    ///
617    /// Backend / evaluation failures.
618    pub fn fit(
619        &self,
620        problem: &PreparedBayesianProblem,
621        identification: IdentificationStatus,
622        workspace: &mut BayesianGCompWorkspace,
623        ctx: &ExecutionContext,
624    ) -> Result<CausalPosterior, EstimationError> {
625        let sequential = self.prior.is_some();
626        let prior = if let Some(p) = &self.prior {
627            if let Some(coef) = p.gaussian_coefficients() {
628                if coef.len() != problem.design.ncols {
629                    return Err(EstimationError::stats_msg(format!(
630                        "sequential prior coefficient dimension {} != design ncols {}",
631                        coef.len(),
632                        problem.design.ncols
633                    )));
634                }
635            } else {
636                return Err(EstimationError::stats_msg(
637                    "sequential prior missing GaussianCoefficients entry",
638                ));
639            }
640            p.clone()
641        } else {
642            PriorSet {
643                specs: vec![PriorSpec::GaussianCoefficients(
644                    antecedent_prob::GaussianCoefficientPrior::isotropic(
645                        problem.design.ncols,
646                        self.prior_scale,
647                    ),
648                )],
649                contrast: None,
650                categorical: Vec::new(),
651                restrictions: Vec::new(),
652            }
653        };
654        let mut assumptions = AssumptionSet::new();
655        let source = if sequential {
656            AssumptionSource::Artifact
657        } else {
658            AssumptionSource::AlgorithmDefault { algorithm: Arc::from("bayesian_gcomp") }
659        };
660        for spec in &prior.specs {
661            let mut pa = spec.as_assumption();
662            if sequential {
663                pa.description = Arc::from(format!(
664                    "{} (sequential prior from posterior artifact)",
665                    pa.description
666                ));
667            }
668            assumptions.push(AssumptionRecord {
669                assumption: Assumption::PriorRestriction(pa),
670                source: source.clone(),
671                scope: AssumptionScope::Estimation,
672                status: AssumptionStatus::Untestable,
673            });
674        }
675        for pa in &prior.restrictions {
676            assumptions.push(AssumptionRecord {
677                assumption: Assumption::PriorRestriction(pa.clone()),
678                source: AssumptionSource::Artifact,
679                scope: AssumptionScope::Estimation,
680                status: AssumptionStatus::Untestable,
681            });
682        }
683
684        let likelihood = match self.backend {
685            BayesianBackendKind::ConjugateGaussian => BayesLikelihood::GaussianIdentity,
686            BayesianBackendKind::Laplace | BayesianBackendKind::Hmc => self.likelihood,
687        };
688        // HMC publication (Ř≤1.01, ESS≥100) needs a longer schedule than the
689        // Laplace/conjugate default of 1000 draws; floor so under-specified
690        // callers still clear the gate rather than refuse with near-miss Ř.
691        let max_draws = match self.backend {
692            BayesianBackendKind::Hmc => self.n_draws.max(HMC_MIN_DRAWS),
693            _ => self.n_draws.max(1),
694        };
695        let adaptive = ctx.adaptive_draws;
696        // Adaptive redraws append samples from the fitted Gaussian covariance. Unknown-variance
697        // GaussianIdentity is routed by the Laplace backend to the exact conjugate NIG posterior,
698        // whose marginal coefficient law is Student-t and has no Gaussian covariance artifact.
699        // Materialize the full requested NIG draw count instead of mixing it with MVN redraws.
700        let laplace_mvn_redraw_supported = match likelihood {
701            BayesLikelihood::GaussianIdentity => matches!(
702                GaussianVarianceModel::from_prior_set(&prior).map_err(prob_err)?,
703                GaussianVarianceModel::Known { .. }
704            ),
705            _ => true,
706        };
707        let laplace_adaptive = adaptive.enabled
708            && matches!(self.backend, BayesianBackendKind::Laplace)
709            && laplace_mvn_redraw_supported
710            && max_draws > adaptive.min_draws.max(2);
711        let initial_draws =
712            if laplace_adaptive { adaptive.min_draws.max(2).min(max_draws) } else { max_draws };
713        let opts = BayesFitOptions {
714            n_draws: initial_draws,
715            seed: self.seed,
716            ..BayesFitOptions::default()
717        };
718        let design_ref = BayesDesignRef {
719            x_colmajor: &problem.design.matrix,
720            nrows: problem.design.nrows,
721            ncols: problem.design.ncols,
722            y: &problem.design.outcome,
723            weights: None,
724            offsets: None,
725        };
726
727        let fit = match self.backend {
728            BayesianBackendKind::ConjugateGaussian => ConjugateGaussianBackend.fit(
729                likelihood,
730                design_ref,
731                &prior,
732                &opts,
733                &mut workspace.laplace,
734                ctx,
735            ),
736            BayesianBackendKind::Laplace => LaplaceGlmBackend.fit(
737                likelihood,
738                design_ref,
739                &prior,
740                &opts,
741                &mut workspace.laplace,
742                ctx,
743            ),
744            BayesianBackendKind::Hmc => {
745                // Floor warmup at the oracle schedule that clears Ř≤1.01 /
746                // ESS≥100 on Gaussian GLMs; scale with kept draws above that.
747                let n_warmup = max_draws.max(1_500);
748                HmcGlmBackend::new()
749                    .with_options(HmcOptions {
750                        n_chains: 4,
751                        n_warmup,
752                        leapfrog_steps: 16,
753                        step_size: 0.04,
754                        target_accept: 0.85,
755                        ..HmcOptions::default()
756                    })
757                    .fit(likelihood, design_ref, &prior, &opts, &mut workspace.laplace, ctx)
758            }
759        }
760        .map_err(prob_err)?;
761
762        if !fit.diagnostics.allows_posterior() {
763            return Err(EstimationError::stats_msg("Bayesian fit refused without diagnostics"));
764        }
765
766        let t_col = problem
767            .design
768            .treatment_column()
769            .ok_or_else(|| EstimationError::stats_msg("missing treatment column"))?;
770
771        let glm_family = likelihood_to_glm_family(likelihood);
772        let evaluator = GCompAteEvaluator {
773            family: glm_family,
774            treatment_col: t_col,
775            active: problem.active,
776            control: problem.control,
777            nrows: problem.design.nrows,
778            ncols: problem.design.ncols,
779            matrix: Arc::clone(&problem.design.matrix),
780        };
781        let compiled = evaluator.compile()?;
782
783        let mut early_stopped = false;
784        let mut n_draws = fit.draws.n_draws;
785        // Adaptive MVN sampling uses the β-block covariance only; drop residual-variance
786        // columns so batch merges match `PosteriorSchema::coefficients`.
787        let coef_draws = coefficient_only_draws(&fit.draws)?;
788
789        if laplace_adaptive {
790            let cov = fit.cov.as_ref().ok_or_else(|| {
791                EstimationError::stats_msg("Laplace adaptive draws require posterior covariance")
792            })?;
793            let map = fit.map.clone();
794            let batch = 32usize;
795            let mut effect_acc: Vec<f64> = Vec::with_capacity(max_draws);
796            let mut extra_blocks: Vec<PosteriorDraws> = Vec::new();
797            let mut width_prev: Option<f64> = None;
798
799            // Evaluate initial block.
800            {
801                workspace.eval.prepare(n_draws, problem.design.ncols);
802                let mut effect_out = EffectBatch::default();
803                effect_out.prepare(n_draws);
804                let batch_view = coef_draws.batch(0, n_draws).map_err(EstimationError::from)?;
805                evaluator.evaluate_batch(
806                    &compiled,
807                    batch_view,
808                    &mut effect_out,
809                    &mut workspace.eval,
810                    ctx,
811                )?;
812                effect_acc.extend_from_slice(&effect_out.values[..n_draws]);
813            }
814
815            loop {
816                let width = quantile_width_95(&effect_acc);
817                let ess = effect_acc.len() as f64; // independent MVN draws
818                if effect_acc.len() >= adaptive.min_draws.max(2) {
819                    let width_ok = width_prev.is_some_and(|prev| {
820                        let rel = (width - prev).abs() / prev.abs().max(1e-12);
821                        rel < adaptive.quantile_width_rel_epsilon
822                    });
823                    if width_ok || ess >= adaptive.ess_target {
824                        early_stopped = n_draws < max_draws;
825                        break;
826                    }
827                }
828                width_prev = Some(width);
829                if n_draws >= max_draws {
830                    break;
831                }
832                let next = (n_draws + batch).min(max_draws);
833                let add = next - n_draws;
834                let extra = sample_gaussian_mvn(
835                    &map,
836                    cov,
837                    add,
838                    self.seed.wrapping_add(n_draws as u64),
839                    &mut workspace.laplace,
840                )
841                .map_err(EstimationError::from)?;
842                let extra_draws = PosteriorDraws::from_column_major(
843                    PosteriorSchema::coefficients(problem.design.ncols),
844                    add,
845                    extra,
846                )
847                .map_err(EstimationError::from)?;
848                workspace.eval.prepare(add, problem.design.ncols);
849                let mut effect_out = EffectBatch::default();
850                effect_out.prepare(add);
851                let batch_view = extra_draws.batch(0, add).map_err(EstimationError::from)?;
852                evaluator.evaluate_batch(
853                    &compiled,
854                    batch_view,
855                    &mut effect_out,
856                    &mut workspace.eval,
857                    ctx,
858                )?;
859                effect_acc.extend_from_slice(&effect_out.values[..add]);
860                // Accumulate blocks; one concatenation after the loop replaces
861                // the former per-batch merge + clone (O(D²) copying in D draws).
862                extra_blocks.push(extra_draws);
863                n_draws = next;
864            }
865
866            // Rebuild combined posterior from accumulated effects + final coef draws.
867            let mechanism_draws = concat_coefficient_draws(&coef_draws, &extra_blocks)?;
868            let mut quantities = mechanism_draws.schema.quantities.to_vec();
869            quantities.retain(|q| !matches!(q, PosteriorQuantityKind::ResidualVariance));
870            let effect_idx = quantities.len();
871            quantities.push(PosteriorQuantityKind::Effect { name: Arc::from("ate") });
872            let n_q = quantities.len();
873            let mut values = vec![0.0; n_draws * n_q];
874            for (qi, q) in mechanism_draws.schema.quantities.iter().enumerate() {
875                if matches!(q, PosteriorQuantityKind::ResidualVariance) {
876                    continue;
877                }
878                let dest = quantities.iter().position(|qq| qq == q).ok_or_else(|| {
879                    EstimationError::stats_msg(format!(
880                        "posterior quantity missing from schema: {q:?}"
881                    ))
882                })?;
883                let coef_col = mechanism_draws.column(qi).map_err(EstimationError::from)?;
884                values[dest * n_draws..(dest + 1) * n_draws].copy_from_slice(coef_col);
885            }
886            values[effect_idx * n_draws..(effect_idx + 1) * n_draws]
887                .copy_from_slice(&effect_acc[..n_draws]);
888            if let Some(names) = problem.coef_names.as_ref() {
889                apply_coefficient_names(&mut quantities, names);
890            }
891            let draws = PosteriorDraws::from_column_major(
892                PosteriorSchema { quantities: Arc::from(quantities) },
893                n_draws,
894                values,
895            )
896            .map_err(EstimationError::from)?;
897            let summaries = draws.summarize();
898            return Ok(CausalPosterior {
899                draws,
900                summaries,
901                identification,
902                prior_sensitivity: None,
903                conflict_summary: None,
904                diagnostics: fit.diagnostics,
905                assumptions,
906                unidentified_mass: 0.0,
907                early_stopped,
908            });
909        }
910
911        let mechanism = BayesianGlmMechanism {
912            coefficient_draws: coef_draws,
913            map: fit.map,
914            likelihood,
915            diagnostics: fit.diagnostics.clone(),
916            design: problem.design.clone(),
917            treatment_col: t_col,
918            active: problem.active,
919            control: problem.control,
920        };
921
922        workspace.eval.prepare(n_draws, problem.design.ncols);
923        let mut effect_out = EffectBatch::default();
924        effect_out.prepare(n_draws);
925        let batch = mechanism.coefficient_draws.batch(0, n_draws).map_err(EstimationError::from)?;
926        evaluator.evaluate_batch(&compiled, batch, &mut effect_out, &mut workspace.eval, ctx)?;
927
928        let mut quantities = mechanism.coefficient_draws.schema.quantities.to_vec();
929        // Drop residual variance column from combined effect artifact if present — keep coefs + effect.
930        quantities.retain(|q| !matches!(q, PosteriorQuantityKind::ResidualVariance));
931        let effect_idx = quantities.len();
932        quantities.push(PosteriorQuantityKind::Effect { name: Arc::from("ate") });
933        let n_q = quantities.len();
934        let mut values = vec![0.0; n_draws * n_q];
935        for (qi, q) in mechanism.coefficient_draws.schema.quantities.iter().enumerate() {
936            if matches!(q, PosteriorQuantityKind::ResidualVariance) {
937                continue;
938            }
939            let dest = quantities.iter().position(|qq| qq == q).ok_or_else(|| {
940                EstimationError::stats_msg(format!("posterior quantity missing from schema: {q:?}"))
941            })?;
942            let col = mechanism.coefficient_draws.column(qi).map_err(EstimationError::from)?;
943            values[dest * n_draws..(dest + 1) * n_draws].copy_from_slice(col);
944        }
945        values[effect_idx * n_draws..(effect_idx + 1) * n_draws]
946            .copy_from_slice(&effect_out.values[..n_draws]);
947
948        if let Some(names) = problem.coef_names.as_ref() {
949            apply_coefficient_names(&mut quantities, names);
950        }
951
952        let draws = PosteriorDraws::from_column_major(
953            PosteriorSchema { quantities: Arc::from(quantities) },
954            n_draws,
955            values,
956        )
957        .map_err(EstimationError::from)?;
958        let summaries = draws.summarize();
959
960        let _ = mechanism;
961        Ok(CausalPosterior {
962            draws,
963            summaries,
964            identification,
965            prior_sensitivity: None,
966            conflict_summary: None,
967            diagnostics: fit.diagnostics,
968            assumptions,
969            unidentified_mass: 0.0,
970            early_stopped: false,
971        })
972    }
973}
974
975/// Bayesian g-computation on a lag-aligned temporal design.
976///
977/// Prepare with [`crate::TemporalLinearAdjustment::prepare`], convert via
978/// [`BayesianGComputationAte::from_prepared_estimation`], then [`BayesianGComputationAte::fit`].
979/// This type documents the temporal entry point; fitting delegates to [`BayesianGComputationAte`].
980#[derive(Clone, Debug, Default)]
981pub struct BayesianTemporalGcomp {
982    /// Shared Bayesian estimator configuration.
983    pub inner: BayesianGComputationAte,
984}
985
986impl BayesianTemporalGcomp {
987    /// Laplace Gaussian defaults.
988    #[must_use]
989    pub fn new() -> Self {
990        Self { inner: BayesianGComputationAte::new() }
991    }
992
993    /// Conjugate Gaussian linear path.
994    #[must_use]
995    pub fn conjugate() -> Self {
996        Self { inner: BayesianGComputationAte::conjugate() }
997    }
998
999    /// Set the shared Bayesian g-computation configuration.
1000    #[must_use]
1001    pub fn with_inner(mut self, inner: BayesianGComputationAte) -> Self {
1002        self.inner = inner;
1003        self
1004    }
1005
1006    /// Convert a temporal prepared design for Bayesian fit.
1007    #[must_use]
1008    pub fn from_prepared_estimation(prep: &PreparedEstimationProblem) -> PreparedBayesianProblem {
1009        BayesianGComputationAte::from_prepared_estimation(prep)
1010    }
1011
1012    /// Fit on a prepared Bayesian problem (typically from a temporal design).
1013    ///
1014    /// # Errors
1015    ///
1016    /// Backend / evaluation failures.
1017    pub fn fit(
1018        &self,
1019        problem: &PreparedBayesianProblem,
1020        identification: IdentificationStatus,
1021        workspace: &mut BayesianGCompWorkspace,
1022        ctx: &ExecutionContext,
1023    ) -> Result<CausalPosterior, EstimationError> {
1024        self.inner.fit(problem, identification, workspace, ctx)
1025    }
1026}
1027
1028/// Durable coefficient names from a design + schema name resolver.
1029///
1030/// Convention: `intercept`, `coef_{treatment}`, `coef_{covariate}`.
1031#[must_use]
1032pub fn coefficient_names_from_design(
1033    design: &CompiledDesign,
1034    treatment_name: &str,
1035    covariate_name: impl Fn(VariableId) -> Option<Arc<str>>,
1036) -> Arc<[Arc<str>]> {
1037    let names: Vec<Arc<str>> = design
1038        .columns
1039        .iter()
1040        .map(|col| match col.role {
1041            DesignColumnRole::Intercept => Arc::from("intercept"),
1042            DesignColumnRole::Treatment => Arc::from(format!("coef_{treatment_name}")),
1043            DesignColumnRole::Covariate(id) => covariate_name(id).map_or_else(
1044                || Arc::from(format!("coef_var_{}", id.raw())),
1045                |n| Arc::from(format!("coef_{n}")),
1046            ),
1047        })
1048        .collect();
1049    Arc::from(names)
1050}
1051
1052/// Apply durable names onto coefficient quantities (in place).
1053fn apply_coefficient_names(quantities: &mut [PosteriorQuantityKind], names: &[Arc<str>]) {
1054    for q in quantities {
1055        if let PosteriorQuantityKind::Coefficient { index, name } = q {
1056            if let Some(n) = names.get(*index) {
1057                *name = Some(Arc::clone(n));
1058            }
1059        }
1060    }
1061}
1062
1063/// Prepared Bayesian g-comp problem.
1064#[derive(Clone, Debug)]
1065pub struct PreparedBayesianProblem {
1066    /// Design.
1067    pub design: CompiledDesign,
1068    /// Estimand method.
1069    pub method: Arc<str>,
1070    /// Adjustment set.
1071    pub adjustment_set: Arc<[VariableId]>,
1072    /// Active treatment.
1073    pub active: f64,
1074    /// Control treatment.
1075    pub control: f64,
1076    /// Overlap.
1077    pub overlap: OverlapPolicy,
1078    /// Optional durable coefficient names aligned to design columns.
1079    pub coef_names: Option<Arc<[Arc<str>]>>,
1080}
1081
1082/// Workspace for Bayesian g-comp.
1083#[derive(Clone, Debug, Default)]
1084pub struct BayesianGCompWorkspace {
1085    /// Laplace / conjugate workspace.
1086    pub laplace: LaplaceWorkspace,
1087    /// Posterior functional eval scratch.
1088    pub eval: PosteriorEvalWorkspace,
1089}
1090
1091/// Trait for batched posterior functional evaluation.
1092pub trait PosteriorFunctionalEvaluator {
1093    /// Compiled plan type.
1094    type Compiled;
1095
1096    /// Compile against a posterior schema.
1097    ///
1098    /// # Errors
1099    ///
1100    /// Incompatible schema.
1101    fn compile(&self) -> Result<Self::Compiled, EstimationError>;
1102
1103    /// Evaluate a batch of coefficient draws into effects.
1104    ///
1105    /// # Errors
1106    ///
1107    /// Shape / numerical failures.
1108    fn evaluate_batch(
1109        &self,
1110        compiled: &Self::Compiled,
1111        posterior: PosteriorBatch<'_>,
1112        output: &mut EffectBatch,
1113        workspace: &mut PosteriorEvalWorkspace,
1114        ctx: &ExecutionContext,
1115    ) -> Result<(), EstimationError>;
1116}
1117
1118/// Compiled g-comp ATE evaluator (finite-difference mean contrast).
1119#[derive(Clone, Debug)]
1120pub struct GCompAteEvaluator {
1121    /// Mean family.
1122    pub family: GlmFamily,
1123    /// Treatment column.
1124    pub treatment_col: usize,
1125    /// Active level.
1126    pub active: f64,
1127    /// Control level.
1128    pub control: f64,
1129    /// Rows.
1130    pub nrows: usize,
1131    /// Cols.
1132    pub ncols: usize,
1133    /// Design matrix (column-major).
1134    pub matrix: Arc<[f64]>,
1135}
1136
1137/// Empty compiled marker (evaluator is self-contained).
1138#[derive(Clone, Copy, Debug, Default)]
1139pub struct CompiledGCompAte;
1140
1141impl PosteriorFunctionalEvaluator for GCompAteEvaluator {
1142    type Compiled = CompiledGCompAte;
1143
1144    fn compile(&self) -> Result<Self::Compiled, EstimationError> {
1145        if self.treatment_col >= self.ncols {
1146            return Err(EstimationError::stats_msg("treatment column out of range"));
1147        }
1148        Ok(CompiledGCompAte)
1149    }
1150
1151    fn evaluate_batch(
1152        &self,
1153        _compiled: &Self::Compiled,
1154        posterior: PosteriorBatch<'_>,
1155        output: &mut EffectBatch,
1156        workspace: &mut PosteriorEvalWorkspace,
1157        _ctx: &ExecutionContext,
1158    ) -> Result<(), EstimationError> {
1159        let n_draws = posterior.len;
1160        workspace.prepare(n_draws, self.ncols);
1161        output.prepare(n_draws);
1162
1163        // Coefficient columns 0..ncols from the batch (ignore extra quantities).
1164        let mut coef_cols: Vec<&[f64]> = Vec::with_capacity(self.ncols);
1165        for c in 0..self.ncols {
1166            let col = posterior.column(c).map_err(EstimationError::from)?;
1167            coef_cols.push(col);
1168        }
1169
1170        for d in 0..n_draws {
1171            for c in 0..self.ncols {
1172                workspace.row[c] = coef_cols[c][d];
1173            }
1174            let beta = &workspace.row[..self.ncols];
1175            let mut sum = 0.0;
1176            for r in 0..self.nrows {
1177                let mu_a = predict_row(
1178                    self.family,
1179                    &self.matrix,
1180                    self.nrows,
1181                    self.ncols,
1182                    self.treatment_col,
1183                    beta,
1184                    r,
1185                    self.active,
1186                );
1187                let mu_c = predict_row(
1188                    self.family,
1189                    &self.matrix,
1190                    self.nrows,
1191                    self.ncols,
1192                    self.treatment_col,
1193                    beta,
1194                    r,
1195                    self.control,
1196                );
1197                sum += mu_a - mu_c;
1198            }
1199            output.values[d] = sum / self.nrows as f64;
1200        }
1201        Ok(())
1202    }
1203}
1204
1205fn predict_row(
1206    family: GlmFamily,
1207    matrix: &[f64],
1208    nrows: usize,
1209    ncols: usize,
1210    t_col: usize,
1211    beta: &[f64],
1212    row: usize,
1213    t_value: f64,
1214) -> f64 {
1215    let mut eta = 0.0;
1216    for c in 0..ncols {
1217        let x = if c == t_col { t_value } else { matrix[c * nrows + row] };
1218        eta += x * beta[c];
1219    }
1220    family.mean_from_eta(eta)
1221}
1222
1223fn likelihood_to_glm_family(l: BayesLikelihood) -> GlmFamily {
1224    match l {
1225        BayesLikelihood::GaussianIdentity => GlmFamily::GaussianIdentity,
1226        BayesLikelihood::BernoulliLogit => GlmFamily::BinomialLogit,
1227        BayesLikelihood::BernoulliProbit => GlmFamily::BinomialProbit,
1228        BayesLikelihood::PoissonLog => GlmFamily::PoissonLog,
1229    }
1230}
1231
1232fn prob_err(e: antecedent_prob::ProbError) -> EstimationError {
1233    EstimationError::from(e)
1234}
1235
1236/// 95% quantile width of a scalar draw vector.
1237fn quantile_width_95(values: &[f64]) -> f64 {
1238    if values.len() < 2 {
1239        return f64::NAN;
1240    }
1241    // Reuse posterior summarization for consistent quantiles.
1242    let schema = PosteriorSchema {
1243        quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("w") }]),
1244    };
1245    let Ok(draws) = PosteriorDraws::from_column_major(schema, values.len(), values.to_vec()) else {
1246        return f64::NAN;
1247    };
1248    let s = draws.summarize();
1249    s.q975[0] - s.q025[0]
1250}
1251
1252/// Concatenate two coefficient-only posterior draw tables (same schema).
1253/// Concatenate an initial draw block with follow-on blocks in one pass.
1254///
1255/// Column-major layout identical to pairwise-merging the blocks in order,
1256/// without the quadratic intermediate copies.
1257fn concat_coefficient_draws(
1258    first: &PosteriorDraws,
1259    rest: &[PosteriorDraws],
1260) -> Result<PosteriorDraws, EstimationError> {
1261    if rest.is_empty() {
1262        return Ok(first.clone());
1263    }
1264    for block in rest {
1265        if block.schema != first.schema {
1266            return Err(EstimationError::stats_msg("concat_coefficient_draws: schema mismatch"));
1267        }
1268    }
1269    let n_q = first.schema.quantities.len();
1270    let n = first.n_draws + rest.iter().map(|b| b.n_draws).sum::<usize>();
1271    let mut values = vec![0.0; n * n_q];
1272    for q in 0..n_q {
1273        let mut offset = q * n;
1274        let col = first.column(q).map_err(EstimationError::from)?;
1275        values[offset..offset + first.n_draws].copy_from_slice(col);
1276        offset += first.n_draws;
1277        for block in rest {
1278            let col = block.column(q).map_err(EstimationError::from)?;
1279            values[offset..offset + block.n_draws].copy_from_slice(col);
1280            offset += block.n_draws;
1281        }
1282    }
1283    PosteriorDraws::from_column_major(first.schema.clone(), n, values)
1284        .map_err(EstimationError::from)
1285}
1286
1287/// Keep coefficient columns only (drop residual-variance / other non-β quantities).
1288fn coefficient_only_draws(draws: &PosteriorDraws) -> Result<PosteriorDraws, EstimationError> {
1289    let coef_idx: Vec<usize> = draws
1290        .schema
1291        .quantities
1292        .iter()
1293        .enumerate()
1294        .filter_map(|(i, q)| matches!(q, PosteriorQuantityKind::Coefficient { .. }).then_some(i))
1295        .collect();
1296    if coef_idx.is_empty() {
1297        return Err(EstimationError::stats_msg(
1298            "coefficient_only_draws: no coefficient quantities",
1299        ));
1300    }
1301    if coef_idx.len() == draws.schema.quantities.len() {
1302        return Ok(draws.clone());
1303    }
1304    let n = draws.n_draws;
1305    let n_q = coef_idx.len();
1306    let mut quantities = Vec::with_capacity(n_q);
1307    let mut values = vec![0.0; n * n_q];
1308    for (dest, &src) in coef_idx.iter().enumerate() {
1309        quantities.push(draws.schema.quantities[src].clone());
1310        let col = draws.column(src).map_err(EstimationError::from)?;
1311        values[dest * n..(dest + 1) * n].copy_from_slice(col);
1312    }
1313    PosteriorDraws::from_column_major(
1314        PosteriorSchema { quantities: Arc::from(quantities) },
1315        n,
1316        values,
1317    )
1318    .map_err(EstimationError::from)
1319}
1320
1321/// Build a non-identified posterior artifact that still records priors (exit criterion #2).
1322///
1323/// Samples prior-predictive draws for a scalar effect mean (isotropic Gaussian / weakly
1324/// informative scale from `prior`) so Bayesian envelopes can surface uncertainty without
1325/// inventing identification. Status remains [`IdentificationStatus::NotIdentified`].
1326#[must_use]
1327pub fn nonidentified_with_prior(
1328    prior: &PriorSet,
1329    diagnostics: InferenceDiagnostics,
1330    n_draws: usize,
1331    seed: u64,
1332) -> CausalPosterior {
1333    let mut assumptions = AssumptionSet::new();
1334    for spec in &prior.specs {
1335        assumptions.push(AssumptionRecord {
1336            assumption: Assumption::PriorRestriction(spec.as_assumption()),
1337            source: AssumptionSource::UserDeclared,
1338            scope: AssumptionScope::Estimation,
1339            status: AssumptionStatus::Untestable,
1340        });
1341    }
1342    let schema = PosteriorSchema {
1343        quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1344    };
1345    let (mean, scale) = prior_predictive_effect_params(prior);
1346    let n = n_draws.max(1);
1347    let mut values = vec![0.0; n];
1348    let mut rng = ExecutionContext::for_tests(seed).rng.stream(0xBA7E_u64);
1349    for v in &mut values {
1350        *v = mean + scale * antecedent_kernels::standard_normal(&mut rng);
1351    }
1352    let draws = PosteriorDraws::from_column_major(schema, n, Arc::<[f64]>::from(values))
1353        .unwrap_or_else(|_| PosteriorDraws {
1354            schema: PosteriorSchema {
1355                quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1356            },
1357            n_draws: 0,
1358            values: Arc::from([]),
1359        });
1360    let summaries = draws.summarize();
1361    CausalPosterior {
1362        draws,
1363        summaries,
1364        identification: IdentificationStatus::NotIdentified,
1365        prior_sensitivity: None,
1366        conflict_summary: None,
1367        diagnostics,
1368        assumptions,
1369        unidentified_mass: 1.0,
1370        early_stopped: false,
1371    }
1372}
1373
1374fn prior_predictive_effect_params(prior: &PriorSet) -> (f64, f64) {
1375    if let Some(g) = prior.gaussian_coefficients() {
1376        let mean = g.mean.first().copied().unwrap_or(0.0);
1377        let var = g.variance.first().copied().unwrap_or(100.0).max(1e-12);
1378        return (mean, var.sqrt());
1379    }
1380    (0.0, 10.0)
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385    use super::*;
1386    use antecedent_core::{
1387        CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
1388    };
1389    use antecedent_data::column::{Float64Column, ValidityBitmap};
1390    use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
1391    use antecedent_expr::{ExprId, IdentifiedEstimand};
1392    use antecedent_prob::InferenceDiagnostics;
1393
1394    fn linear_scm_table(n: usize) -> (TabularData, VariableId, VariableId, VariableId) {
1395        let mut b = CausalSchemaBuilder::new();
1396        b.add_variable(
1397            "Z",
1398            ValueType::Continuous,
1399            SmallRoleSet::from_hint(RoleHint::Context),
1400            None,
1401            None,
1402            MeasurementSpec::default(),
1403        )
1404        .unwrap();
1405        b.add_variable(
1406            "T",
1407            ValueType::Continuous,
1408            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1409            None,
1410            None,
1411            MeasurementSpec::default(),
1412        )
1413        .unwrap();
1414        b.add_variable(
1415            "Y",
1416            ValueType::Continuous,
1417            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1418            None,
1419            None,
1420            MeasurementSpec::default(),
1421        )
1422        .unwrap();
1423        let schema = b.build().unwrap();
1424        let z = VariableId::from_raw(0);
1425        let t = VariableId::from_raw(1);
1426        let y = VariableId::from_raw(2);
1427        let mut zv = vec![0.0; n];
1428        let mut tv = vec![0.0; n];
1429        let mut yv = vec![0.0; n];
1430        for i in 0..n {
1431            zv[i] = (i as f64) * 0.1;
1432            tv[i] = if i % 2 == 0 { 1.0 } else { 0.0 };
1433            yv[i] = 2.0 * tv[i] + 0.5 * zv[i];
1434        }
1435        let validity = ValidityBitmap::all_valid(n);
1436        let cols = vec![
1437            OwnedColumn::Float64(Float64Column::new(z, Arc::from(zv), validity.clone()).unwrap()),
1438            OwnedColumn::Float64(Float64Column::new(t, Arc::from(tv), validity.clone()).unwrap()),
1439            OwnedColumn::Float64(Float64Column::new(y, Arc::from(yv), validity).unwrap()),
1440        ];
1441        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1442        (TabularData::new(storage), t, y, z)
1443    }
1444
1445    #[test]
1446    fn bayesian_and_frequentist_share_ate() {
1447        let n = 80;
1448        let (data, t, y, z) = linear_scm_table(n);
1449        let estimand = IdentifiedEstimand::backdoor(
1450            "backdoor.adjustment",
1451            Arc::from(vec![z]),
1452            ExprId::from_raw(0),
1453        );
1454        let query = AverageEffectQuery::binary_ate(t, y);
1455
1456        let freq = crate::adjustment::LinearAdjustmentAte {
1457            bootstrap_replicates: 0,
1458            ..crate::adjustment::LinearAdjustmentAte::new()
1459        };
1460        let prep = freq.prepare(&data, &estimand, &query).unwrap();
1461        let mut ws = crate::adjustment::EstimationWorkspace::default();
1462        let freq_est = freq
1463            .fit(&prep, &mut ws, &ExecutionContext::for_tests(1), AssumptionSet::new())
1464            .unwrap();
1465
1466        let bayes = BayesianGComputationAte {
1467            backend: BayesianBackendKind::ConjugateGaussian,
1468            n_draws: 400,
1469            seed: 5,
1470            prior_scale: 100.0,
1471            ..BayesianGComputationAte::new()
1472        };
1473        let bprep = bayes.prepare(&data, &estimand, &query).unwrap();
1474        let mut bws = BayesianGCompWorkspace::default();
1475        let post = bayes
1476            .fit(
1477                &bprep,
1478                IdentificationStatus::NonparametricallyIdentified,
1479                &mut bws,
1480                &ExecutionContext::for_tests(1),
1481            )
1482            .unwrap();
1483        let eq = post.effect_column().unwrap();
1484        let mean = post.summaries.mean[eq];
1485        assert!((freq_est.ate - 2.0).abs() < 1e-6, "frequentist ate={}", freq_est.ate);
1486        assert!((mean - freq_est.ate).abs() < 0.05, "bayes={mean} freq={}", freq_est.ate);
1487        assert_eq!(post.identification, IdentificationStatus::NonparametricallyIdentified);
1488        let coef_names: Vec<_> = post
1489            .draws
1490            .schema
1491            .quantities
1492            .iter()
1493            .filter_map(|q| match q {
1494                PosteriorQuantityKind::Coefficient { name, .. } => name.as_ref().map(AsRef::as_ref),
1495                _ => None,
1496            })
1497            .collect();
1498        assert!(coef_names.contains(&"intercept"), "{coef_names:?}");
1499        assert!(coef_names.iter().any(|n| n.starts_with("coef_")), "{coef_names:?}");
1500    }
1501
1502    #[test]
1503    fn adaptive_laplace_unknown_variance_keeps_exact_nig_draws() {
1504        let (data, t, y, z) = linear_scm_table(80);
1505        let estimand = IdentifiedEstimand::backdoor(
1506            "backdoor.adjustment",
1507            Arc::from(vec![z]),
1508            ExprId::from_raw(0),
1509        );
1510        let query = AverageEffectQuery::binary_ate(t, y);
1511        let estimator =
1512            BayesianGComputationAte { n_draws: 96, seed: 17, ..BayesianGComputationAte::new() };
1513        let prepared = estimator.prepare(&data, &estimand, &query).unwrap();
1514        let mut workspace = BayesianGCompWorkspace::default();
1515        let posterior = estimator
1516            .fit(
1517                &prepared,
1518                IdentificationStatus::NonparametricallyIdentified,
1519                &mut workspace,
1520                &ExecutionContext::production(17, 1),
1521            )
1522            .unwrap();
1523
1524        assert_eq!(posterior.diagnostics.backend_id.as_ref(), "conjugate_gaussian");
1525        assert_eq!(posterior.draws.n_draws, 96, "NIG draws must not be replaced by MVN redraws");
1526        assert!(!posterior.early_stopped, "exact NIG sampling materializes the requested draws");
1527    }
1528
1529    #[test]
1530    fn prior_does_not_create_identification() {
1531        let prior = PriorSet::weakly_informative(3);
1532        let post = nonidentified_with_prior(&prior, InferenceDiagnostics::analytic("none"), 64, 1);
1533        assert_eq!(post.identification, IdentificationStatus::NotIdentified);
1534        assert!(!post.assumptions.is_empty());
1535        assert!((post.unidentified_mass - 1.0).abs() < 1e-12);
1536        assert!(post.draws.n_draws > 0, "prior-predictive draws required");
1537    }
1538
1539    #[test]
1540    fn temporal_prepared_design_conjugate_recovers_pulse() {
1541        use antecedent_core::{
1542            CausalSchemaBuilder, Lag, MeasurementSpec, RoleHint, SmallRoleSet, TemporalEffectQuery,
1543            TemporalPolicy, ValueType,
1544        };
1545        use antecedent_data::{
1546            Float64Column, OwnedColumn, OwnedColumnarStorage, SamplingRegularity, TimeIndex,
1547            TimeSeriesData, ValidityBitmap,
1548        };
1549        use antecedent_graph::{TemporalDag, ensure_lagged};
1550        use antecedent_identify::TemporalBackdoorIdentifier;
1551
1552        use crate::temporal_adjustment::TemporalLinearAdjustment;
1553
1554        let n = 300usize;
1555        let mut b = CausalSchemaBuilder::new();
1556        b.add_variable(
1557            "x",
1558            ValueType::Continuous,
1559            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1560            None,
1561            None,
1562            MeasurementSpec::default(),
1563        )
1564        .unwrap();
1565        b.add_variable(
1566            "y",
1567            ValueType::Continuous,
1568            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1569            None,
1570            None,
1571            MeasurementSpec::default(),
1572        )
1573        .unwrap();
1574        let schema = b.build().unwrap();
1575        let mut x = vec![0.0; n];
1576        let mut y = vec![0.0; n];
1577        for t in 1..n {
1578            x[t] = ((t as f64) * 0.07).sin();
1579            y[t] = 0.8 * x[t - 1];
1580        }
1581        let cols = vec![
1582            OwnedColumn::Float64(
1583                Float64Column::new(
1584                    VariableId::from_raw(0),
1585                    Arc::from(x),
1586                    ValidityBitmap::all_valid(n),
1587                )
1588                .unwrap(),
1589            ),
1590            OwnedColumn::Float64(
1591                Float64Column::new(
1592                    VariableId::from_raw(1),
1593                    Arc::from(y),
1594                    ValidityBitmap::all_valid(n),
1595                )
1596                .unwrap(),
1597            ),
1598        ];
1599        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1600        let data = TimeSeriesData::try_new(
1601            storage,
1602            TimeIndex { regularity: SamplingRegularity::Regular { interval_ns: 1 }, length: n },
1603        )
1604        .unwrap();
1605        let mut g = TemporalDag::empty();
1606        let x1 = ensure_lagged(&mut g, VariableId::from_raw(0), Lag::from_raw(1)).unwrap();
1607        let y0 = ensure_lagged(&mut g, VariableId::from_raw(1), Lag::CONTEMPORANEOUS).unwrap();
1608        g.insert_directed(x1, y0).unwrap();
1609
1610        let q = TemporalEffectQuery::pulse(VariableId::from_raw(0), VariableId::from_raw(1), 1.0)
1611            .with_policy(TemporalPolicy::pulse(-1))
1612            .with_horizon_steps(1)
1613            .with_max_history_lag(Some(1));
1614        let id_res = TemporalBackdoorIdentifier::new().identify_temporal(&g, &q).unwrap();
1615        let estimand = id_res.result.estimands.first().unwrap();
1616        let temporal = TemporalLinearAdjustment::new();
1617        let prep = temporal
1618            .prepare(
1619                &data,
1620                estimand,
1621                &q,
1622                &id_res.indexer,
1623                None,
1624                &ExecutionContext::for_tests(1).kernel_policy,
1625            )
1626            .unwrap();
1627        let bayes = BayesianTemporalGcomp {
1628            inner: BayesianGComputationAte {
1629                backend: BayesianBackendKind::ConjugateGaussian,
1630                n_draws: 200,
1631                seed: 7,
1632                prior_scale: 100.0,
1633                ..BayesianGComputationAte::new()
1634            },
1635        };
1636        let bprep = BayesianTemporalGcomp::from_prepared_estimation(&prep);
1637        let mut ws = BayesianGCompWorkspace::default();
1638        let post = bayes
1639            .fit(
1640                &bprep,
1641                IdentificationStatus::NonparametricallyIdentified,
1642                &mut ws,
1643                &ExecutionContext::for_tests(1),
1644            )
1645            .unwrap();
1646        let eq = post.effect_column().unwrap();
1647        let mean = post.summaries.mean[eq];
1648        assert!((mean - 0.8).abs() < 0.05, "bayesian temporal pulse mean={mean}");
1649        assert!(post.probability_below(0.0).unwrap().is_finite());
1650    }
1651
1652    #[test]
1653    fn hydrate_prior_from_posterior_and_refit() {
1654        let n = 60;
1655        let (data, t, y, z) = linear_scm_table(n);
1656        let estimand = IdentifiedEstimand::backdoor(
1657            "backdoor.adjustment",
1658            Arc::from(vec![z]),
1659            ExprId::from_raw(0),
1660        );
1661        let query = AverageEffectQuery::binary_ate(t, y);
1662        let bayes = BayesianGComputationAte {
1663            backend: BayesianBackendKind::ConjugateGaussian,
1664            n_draws: 200,
1665            seed: 3,
1666            prior_scale: 10.0,
1667            ..BayesianGComputationAte::new()
1668        };
1669        let prep = bayes.prepare(&data, &estimand, &query).unwrap();
1670        let mut ws = BayesianGCompWorkspace::default();
1671        let post = bayes
1672            .fit(
1673                &prep,
1674                IdentificationStatus::NonparametricallyIdentified,
1675                &mut ws,
1676                &ExecutionContext::for_tests(1),
1677            )
1678            .unwrap();
1679        let prior = hydrate_prior_from_posterior(&post, Some(prep.design.ncols)).unwrap();
1680        assert_eq!(prior.gaussian_coefficients().unwrap().len(), prep.design.ncols);
1681        assert!(hydrate_prior_from_posterior(&post, Some(prep.design.ncols + 1)).is_err());
1682
1683        let sequential = BayesianGComputationAte { prior: Some(prior), ..bayes };
1684        let post2 = sequential
1685            .fit(
1686                &prep,
1687                IdentificationStatus::NonparametricallyIdentified,
1688                &mut ws,
1689                &ExecutionContext::for_tests(1),
1690            )
1691            .unwrap();
1692        assert!(post2.assumptions.entries.iter().any(|a| {
1693            matches!(a.source, AssumptionSource::Artifact)
1694                && matches!(&a.assumption, Assumption::PriorRestriction(pa) if pa.description.contains("sequential"))
1695        }));
1696        let eq = post2.effect_column().unwrap();
1697        assert!(post2.summaries.mean[eq].is_finite());
1698    }
1699
1700    #[test]
1701    fn hydrate_effect_functional_maps_treatment_coef() {
1702        let quantities = vec![
1703            PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1704            PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1705            PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1706        ];
1707        let mean = vec![0.1, 0.5, 2.0];
1708        let sd = vec![1.0, 1.0, 0.4];
1709        let names: Vec<Arc<str>> =
1710            vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_z")];
1711        let baseline = PriorSet::weakly_informative(3);
1712        let prior = hydrate_prior(
1713            &HydrateMapping::EffectFunctional { source_quantity: "ate".into() },
1714            &quantities,
1715            &mean,
1716            &sd,
1717            &baseline,
1718            &names,
1719            Some(1),
1720        )
1721        .unwrap();
1722        let coef = prior.gaussian_coefficients().unwrap();
1723        assert!((coef.mean[1] - 2.0).abs() < 1e-12);
1724        assert!((coef.variance[1] - 0.16).abs() < 1e-12);
1725        // Unmapped dims keep baseline (isotropic scale 10 → var 100).
1726        assert!((coef.mean[0] - 0.0).abs() < 1e-12);
1727        assert!((coef.variance[0] - 100.0).abs() < 1e-12);
1728        assert!((coef.variance[2] - 100.0).abs() < 1e-12);
1729        assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_effect_prior"));
1730    }
1731
1732    #[test]
1733    fn hydrate_mapping_hard_errors() {
1734        let quantities = vec![
1735            PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1736            PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1737            PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1738        ];
1739        let mean = vec![0.0, 1.0, 2.0];
1740        let sd = vec![1.0, 1.0, 0.5];
1741        let names2: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1742        let baseline2 = PriorSet::weakly_informative(2);
1743        // Identical with wrong expected dim via target names of different length than source coefs.
1744        let names3: Vec<Arc<str>> =
1745            vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_w")];
1746        let baseline3 = PriorSet::weakly_informative(3);
1747        assert!(
1748            hydrate_prior(
1749                &HydrateMapping::IdenticalCoefficientSubspace,
1750                &quantities,
1751                &mean,
1752                &sd,
1753                &baseline3,
1754                &names3,
1755                None,
1756            )
1757            .is_err()
1758        );
1759
1760        assert!(
1761            hydrate_prior(
1762                &HydrateMapping::EffectFunctional { source_quantity: "missing".into() },
1763                &quantities,
1764                &mean,
1765                &sd,
1766                &baseline2,
1767                &names2,
1768                Some(1),
1769            )
1770            .is_err()
1771        );
1772
1773        assert!(
1774            hydrate_prior(
1775                &HydrateMapping::NamedParameters {
1776                    pairs: vec![("ate".into(), "no_such_coef".into())],
1777                },
1778                &quantities,
1779                &mean,
1780                &sd,
1781                &baseline2,
1782                &names2,
1783                None,
1784            )
1785            .is_err()
1786        );
1787
1788        assert!(
1789            hydrate_prior(
1790                &HydrateMapping::NamedParameters {
1791                    pairs: vec![("no_src".into(), "coef_t".into())],
1792                },
1793                &quantities,
1794                &mean,
1795                &sd,
1796                &baseline2,
1797                &names2,
1798                None,
1799            )
1800            .is_err()
1801        );
1802    }
1803
1804    #[test]
1805    fn hydrate_named_parameters_overwrites_target() {
1806        let quantities = vec![
1807            PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1808            PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1809            PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1810        ];
1811        let mean = vec![0.0, 0.0, 1.5];
1812        let sd = vec![1.0, 1.0, 0.2];
1813        let names: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1814        let baseline = PriorSet::weakly_informative(2);
1815        let prior = hydrate_prior(
1816            &HydrateMapping::NamedParameters { pairs: vec![("ate".into(), "coef_t".into())] },
1817            &quantities,
1818            &mean,
1819            &sd,
1820            &baseline,
1821            &names,
1822            None,
1823        )
1824        .unwrap();
1825        let coef = prior.gaussian_coefficients().unwrap();
1826        assert!((coef.mean[1] - 1.5).abs() < 1e-12);
1827        assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_named_prior"));
1828    }
1829}