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            output.values[d] = gcomp_mean_contrast(
1176                self.family,
1177                &self.matrix,
1178                self.nrows,
1179                self.ncols,
1180                self.treatment_col,
1181                beta,
1182                self.active,
1183                self.control,
1184            );
1185        }
1186        Ok(())
1187    }
1188}
1189
1190fn gcomp_mean_contrast(
1191    family: GlmFamily,
1192    matrix: &[f64],
1193    nrows: usize,
1194    ncols: usize,
1195    t_col: usize,
1196    beta: &[f64],
1197    active: f64,
1198    control: f64,
1199) -> f64 {
1200    // Identity link: only the treatment column differs, so the row average is
1201    // exactly (active − control) β_T. Nonlinear links still need a data pass;
1202    // they share one residualized η (covariates only) instead of two full Xβ.
1203    if matches!(family, GlmFamily::GaussianIdentity) {
1204        return (active - control) * beta[t_col];
1205    }
1206    let beta_t = beta[t_col];
1207    let mut sum = 0.0;
1208    for r in 0..nrows {
1209        let mut eta = 0.0;
1210        for c in 0..ncols {
1211            if c == t_col {
1212                continue;
1213            }
1214            eta += matrix[c * nrows + r] * beta[c];
1215        }
1216        sum += family.mean_from_eta(eta + beta_t * active)
1217            - family.mean_from_eta(eta + beta_t * control);
1218    }
1219    sum / nrows as f64
1220}
1221
1222fn likelihood_to_glm_family(l: BayesLikelihood) -> GlmFamily {
1223    match l {
1224        BayesLikelihood::GaussianIdentity => GlmFamily::GaussianIdentity,
1225        BayesLikelihood::BernoulliLogit => GlmFamily::BinomialLogit,
1226        BayesLikelihood::BernoulliProbit => GlmFamily::BinomialProbit,
1227        BayesLikelihood::PoissonLog => GlmFamily::PoissonLog,
1228    }
1229}
1230
1231fn prob_err(e: antecedent_prob::ProbError) -> EstimationError {
1232    EstimationError::from(e)
1233}
1234
1235/// 95% quantile width of a scalar draw vector.
1236fn quantile_width_95(values: &[f64]) -> f64 {
1237    if values.len() < 2 {
1238        return f64::NAN;
1239    }
1240    // Reuse posterior summarization for consistent quantiles.
1241    let schema = PosteriorSchema {
1242        quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("w") }]),
1243    };
1244    let Ok(draws) = PosteriorDraws::from_column_major(schema, values.len(), values.to_vec()) else {
1245        return f64::NAN;
1246    };
1247    let s = draws.summarize();
1248    s.q975[0] - s.q025[0]
1249}
1250
1251/// Concatenate two coefficient-only posterior draw tables (same schema).
1252/// Concatenate an initial draw block with follow-on blocks in one pass.
1253///
1254/// Column-major layout identical to pairwise-merging the blocks in order,
1255/// without the quadratic intermediate copies.
1256fn concat_coefficient_draws(
1257    first: &PosteriorDraws,
1258    rest: &[PosteriorDraws],
1259) -> Result<PosteriorDraws, EstimationError> {
1260    if rest.is_empty() {
1261        return Ok(first.clone());
1262    }
1263    for block in rest {
1264        if block.schema != first.schema {
1265            return Err(EstimationError::stats_msg("concat_coefficient_draws: schema mismatch"));
1266        }
1267    }
1268    let n_q = first.schema.quantities.len();
1269    let n = first.n_draws + rest.iter().map(|b| b.n_draws).sum::<usize>();
1270    let mut values = vec![0.0; n * n_q];
1271    for q in 0..n_q {
1272        let mut offset = q * n;
1273        let col = first.column(q).map_err(EstimationError::from)?;
1274        values[offset..offset + first.n_draws].copy_from_slice(col);
1275        offset += first.n_draws;
1276        for block in rest {
1277            let col = block.column(q).map_err(EstimationError::from)?;
1278            values[offset..offset + block.n_draws].copy_from_slice(col);
1279            offset += block.n_draws;
1280        }
1281    }
1282    PosteriorDraws::from_column_major(first.schema.clone(), n, values)
1283        .map_err(EstimationError::from)
1284}
1285
1286/// Keep coefficient columns only (drop residual-variance / other non-β quantities).
1287fn coefficient_only_draws(draws: &PosteriorDraws) -> Result<PosteriorDraws, EstimationError> {
1288    let coef_idx: Vec<usize> = draws
1289        .schema
1290        .quantities
1291        .iter()
1292        .enumerate()
1293        .filter_map(|(i, q)| matches!(q, PosteriorQuantityKind::Coefficient { .. }).then_some(i))
1294        .collect();
1295    if coef_idx.is_empty() {
1296        return Err(EstimationError::stats_msg(
1297            "coefficient_only_draws: no coefficient quantities",
1298        ));
1299    }
1300    if coef_idx.len() == draws.schema.quantities.len() {
1301        return Ok(draws.clone());
1302    }
1303    let n = draws.n_draws;
1304    let n_q = coef_idx.len();
1305    let mut quantities = Vec::with_capacity(n_q);
1306    let mut values = vec![0.0; n * n_q];
1307    for (dest, &src) in coef_idx.iter().enumerate() {
1308        quantities.push(draws.schema.quantities[src].clone());
1309        let col = draws.column(src).map_err(EstimationError::from)?;
1310        values[dest * n..(dest + 1) * n].copy_from_slice(col);
1311    }
1312    PosteriorDraws::from_column_major(
1313        PosteriorSchema { quantities: Arc::from(quantities) },
1314        n,
1315        values,
1316    )
1317    .map_err(EstimationError::from)
1318}
1319
1320/// Build a non-identified posterior artifact that still records priors (exit criterion #2).
1321///
1322/// Samples prior-predictive draws for a scalar effect mean (isotropic Gaussian / weakly
1323/// informative scale from `prior`) so Bayesian envelopes can surface uncertainty without
1324/// inventing identification. Status remains [`IdentificationStatus::NotIdentified`].
1325#[must_use]
1326pub fn nonidentified_with_prior(
1327    prior: &PriorSet,
1328    diagnostics: InferenceDiagnostics,
1329    n_draws: usize,
1330    seed: u64,
1331) -> CausalPosterior {
1332    let mut assumptions = AssumptionSet::new();
1333    for spec in &prior.specs {
1334        assumptions.push(AssumptionRecord {
1335            assumption: Assumption::PriorRestriction(spec.as_assumption()),
1336            source: AssumptionSource::UserDeclared,
1337            scope: AssumptionScope::Estimation,
1338            status: AssumptionStatus::Untestable,
1339        });
1340    }
1341    let schema = PosteriorSchema {
1342        quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1343    };
1344    let (mean, scale) = prior_predictive_effect_params(prior);
1345    let n = n_draws.max(1);
1346    let mut values = vec![0.0; n];
1347    let mut rng = ExecutionContext::for_tests(seed).rng.stream(0xBA7E_u64);
1348    for v in &mut values {
1349        *v = mean + scale * antecedent_kernels::standard_normal(&mut rng);
1350    }
1351    let draws = PosteriorDraws::from_column_major(schema, n, Arc::<[f64]>::from(values))
1352        .unwrap_or_else(|_| PosteriorDraws {
1353            schema: PosteriorSchema {
1354                quantities: Arc::from([PosteriorQuantityKind::Effect { name: Arc::from("ate") }]),
1355            },
1356            n_draws: 0,
1357            values: Arc::from([]),
1358        });
1359    let summaries = draws.summarize();
1360    CausalPosterior {
1361        draws,
1362        summaries,
1363        identification: IdentificationStatus::NotIdentified,
1364        prior_sensitivity: None,
1365        conflict_summary: None,
1366        diagnostics,
1367        assumptions,
1368        unidentified_mass: 1.0,
1369        early_stopped: false,
1370    }
1371}
1372
1373fn prior_predictive_effect_params(prior: &PriorSet) -> (f64, f64) {
1374    if let Some(g) = prior.gaussian_coefficients() {
1375        let mean = g.mean.first().copied().unwrap_or(0.0);
1376        let var = g.variance.first().copied().unwrap_or(100.0).max(1e-12);
1377        return (mean, var.sqrt());
1378    }
1379    (0.0, 10.0)
1380}
1381
1382#[cfg(test)]
1383mod tests {
1384    use super::*;
1385    use antecedent_core::{
1386        CausalSchemaBuilder, MeasurementSpec, RoleHint, SmallRoleSet, ValueType, VariableId,
1387    };
1388    use antecedent_data::column::{Float64Column, ValidityBitmap};
1389    use antecedent_data::{OwnedColumn, OwnedColumnarStorage, TabularData};
1390    use antecedent_expr::{ExprId, IdentifiedEstimand};
1391    use antecedent_prob::InferenceDiagnostics;
1392
1393    #[test]
1394    fn gaussian_gcomp_contrast_is_treatment_coef_times_level_gap() {
1395        let beta = [1.0_f64, 2.5, -0.5];
1396        let matrix = [
1397            1.0, 1.0, 1.0, // intercept
1398            0.0, 1.0, 0.0, // treatment (overwritten by do())
1399            0.2, 0.4, 0.6, // covariate
1400        ];
1401        let ate =
1402            gcomp_mean_contrast(GlmFamily::GaussianIdentity, &matrix, 3, 3, 1, &beta, 1.0, 0.0);
1403        assert!((ate - 2.5).abs() < 1e-15);
1404    }
1405
1406    fn linear_scm_table(n: usize) -> (TabularData, VariableId, VariableId, VariableId) {
1407        let mut b = CausalSchemaBuilder::new();
1408        b.add_variable(
1409            "Z",
1410            ValueType::Continuous,
1411            SmallRoleSet::from_hint(RoleHint::Context),
1412            None,
1413            None,
1414            MeasurementSpec::default(),
1415        )
1416        .unwrap();
1417        b.add_variable(
1418            "T",
1419            ValueType::Continuous,
1420            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1421            None,
1422            None,
1423            MeasurementSpec::default(),
1424        )
1425        .unwrap();
1426        b.add_variable(
1427            "Y",
1428            ValueType::Continuous,
1429            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1430            None,
1431            None,
1432            MeasurementSpec::default(),
1433        )
1434        .unwrap();
1435        let schema = b.build().unwrap();
1436        let z = VariableId::from_raw(0);
1437        let t = VariableId::from_raw(1);
1438        let y = VariableId::from_raw(2);
1439        let mut zv = vec![0.0; n];
1440        let mut tv = vec![0.0; n];
1441        let mut yv = vec![0.0; n];
1442        for i in 0..n {
1443            zv[i] = (i as f64) * 0.1;
1444            tv[i] = if i % 2 == 0 { 1.0 } else { 0.0 };
1445            yv[i] = 2.0 * tv[i] + 0.5 * zv[i];
1446        }
1447        let validity = ValidityBitmap::all_valid(n);
1448        let cols = vec![
1449            OwnedColumn::Float64(Float64Column::new(z, Arc::from(zv), validity.clone()).unwrap()),
1450            OwnedColumn::Float64(Float64Column::new(t, Arc::from(tv), validity.clone()).unwrap()),
1451            OwnedColumn::Float64(Float64Column::new(y, Arc::from(yv), validity).unwrap()),
1452        ];
1453        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1454        (TabularData::new(storage), t, y, z)
1455    }
1456
1457    #[test]
1458    fn bayesian_and_frequentist_share_ate() {
1459        let n = 80;
1460        let (data, t, y, z) = linear_scm_table(n);
1461        let estimand = IdentifiedEstimand::backdoor(
1462            "backdoor.adjustment",
1463            Arc::from(vec![z]),
1464            ExprId::from_raw(0),
1465        );
1466        let query = AverageEffectQuery::binary_ate(t, y);
1467
1468        let freq = crate::adjustment::LinearAdjustmentAte {
1469            bootstrap_replicates: 0,
1470            ..crate::adjustment::LinearAdjustmentAte::new()
1471        };
1472        let prep = freq.prepare(&data, &estimand, &query).unwrap();
1473        let mut ws = crate::adjustment::EstimationWorkspace::default();
1474        let freq_est = freq
1475            .fit(&prep, &mut ws, &ExecutionContext::for_tests(1), AssumptionSet::new())
1476            .unwrap();
1477
1478        let bayes = BayesianGComputationAte {
1479            backend: BayesianBackendKind::ConjugateGaussian,
1480            n_draws: 400,
1481            seed: 5,
1482            prior_scale: 100.0,
1483            ..BayesianGComputationAte::new()
1484        };
1485        let bprep = bayes.prepare(&data, &estimand, &query).unwrap();
1486        let mut bws = BayesianGCompWorkspace::default();
1487        let post = bayes
1488            .fit(
1489                &bprep,
1490                IdentificationStatus::NonparametricallyIdentified,
1491                &mut bws,
1492                &ExecutionContext::for_tests(1),
1493            )
1494            .unwrap();
1495        let eq = post.effect_column().unwrap();
1496        let mean = post.summaries.mean[eq];
1497        assert!((freq_est.ate - 2.0).abs() < 1e-6, "frequentist ate={}", freq_est.ate);
1498        assert!((mean - freq_est.ate).abs() < 0.05, "bayes={mean} freq={}", freq_est.ate);
1499        assert_eq!(post.identification, IdentificationStatus::NonparametricallyIdentified);
1500        let coef_names: Vec<_> = post
1501            .draws
1502            .schema
1503            .quantities
1504            .iter()
1505            .filter_map(|q| match q {
1506                PosteriorQuantityKind::Coefficient { name, .. } => name.as_ref().map(AsRef::as_ref),
1507                _ => None,
1508            })
1509            .collect();
1510        assert!(coef_names.contains(&"intercept"), "{coef_names:?}");
1511        assert!(coef_names.iter().any(|n| n.starts_with("coef_")), "{coef_names:?}");
1512    }
1513
1514    #[test]
1515    fn adaptive_laplace_unknown_variance_keeps_exact_nig_draws() {
1516        let (data, t, y, z) = linear_scm_table(80);
1517        let estimand = IdentifiedEstimand::backdoor(
1518            "backdoor.adjustment",
1519            Arc::from(vec![z]),
1520            ExprId::from_raw(0),
1521        );
1522        let query = AverageEffectQuery::binary_ate(t, y);
1523        let estimator =
1524            BayesianGComputationAte { n_draws: 96, seed: 17, ..BayesianGComputationAte::new() };
1525        let prepared = estimator.prepare(&data, &estimand, &query).unwrap();
1526        let mut workspace = BayesianGCompWorkspace::default();
1527        let posterior = estimator
1528            .fit(
1529                &prepared,
1530                IdentificationStatus::NonparametricallyIdentified,
1531                &mut workspace,
1532                &ExecutionContext::production(17, 1),
1533            )
1534            .unwrap();
1535
1536        assert_eq!(posterior.diagnostics.backend_id.as_ref(), "conjugate_gaussian");
1537        assert_eq!(posterior.draws.n_draws, 96, "NIG draws must not be replaced by MVN redraws");
1538        assert!(!posterior.early_stopped, "exact NIG sampling materializes the requested draws");
1539    }
1540
1541    #[test]
1542    fn prior_does_not_create_identification() {
1543        let prior = PriorSet::weakly_informative(3);
1544        let post = nonidentified_with_prior(&prior, InferenceDiagnostics::analytic("none"), 64, 1);
1545        assert_eq!(post.identification, IdentificationStatus::NotIdentified);
1546        assert!(!post.assumptions.is_empty());
1547        assert!((post.unidentified_mass - 1.0).abs() < 1e-12);
1548        assert!(post.draws.n_draws > 0, "prior-predictive draws required");
1549    }
1550
1551    #[test]
1552    fn temporal_prepared_design_conjugate_recovers_pulse() {
1553        use antecedent_core::{
1554            CausalSchemaBuilder, Lag, MeasurementSpec, RoleHint, SmallRoleSet, TemporalEffectQuery,
1555            TemporalPolicy, ValueType,
1556        };
1557        use antecedent_data::{
1558            Float64Column, OwnedColumn, OwnedColumnarStorage, SamplingRegularity, TimeIndex,
1559            TimeSeriesData, ValidityBitmap,
1560        };
1561        use antecedent_graph::{TemporalDag, ensure_lagged};
1562        use antecedent_identify::TemporalBackdoorIdentifier;
1563
1564        use crate::temporal_adjustment::TemporalLinearAdjustment;
1565
1566        let n = 300usize;
1567        let mut b = CausalSchemaBuilder::new();
1568        b.add_variable(
1569            "x",
1570            ValueType::Continuous,
1571            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
1572            None,
1573            None,
1574            MeasurementSpec::default(),
1575        )
1576        .unwrap();
1577        b.add_variable(
1578            "y",
1579            ValueType::Continuous,
1580            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
1581            None,
1582            None,
1583            MeasurementSpec::default(),
1584        )
1585        .unwrap();
1586        let schema = b.build().unwrap();
1587        let mut x = vec![0.0; n];
1588        let mut y = vec![0.0; n];
1589        for t in 1..n {
1590            x[t] = ((t as f64) * 0.07).sin();
1591            y[t] = 0.8 * x[t - 1];
1592        }
1593        let cols = vec![
1594            OwnedColumn::Float64(
1595                Float64Column::new(
1596                    VariableId::from_raw(0),
1597                    Arc::from(x),
1598                    ValidityBitmap::all_valid(n),
1599                )
1600                .unwrap(),
1601            ),
1602            OwnedColumn::Float64(
1603                Float64Column::new(
1604                    VariableId::from_raw(1),
1605                    Arc::from(y),
1606                    ValidityBitmap::all_valid(n),
1607                )
1608                .unwrap(),
1609            ),
1610        ];
1611        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
1612        let data = TimeSeriesData::try_new(
1613            storage,
1614            TimeIndex { regularity: SamplingRegularity::Regular { interval_ns: 1 }, length: n },
1615        )
1616        .unwrap();
1617        let mut g = TemporalDag::empty();
1618        let x1 = ensure_lagged(&mut g, VariableId::from_raw(0), Lag::from_raw(1)).unwrap();
1619        let y0 = ensure_lagged(&mut g, VariableId::from_raw(1), Lag::CONTEMPORANEOUS).unwrap();
1620        g.insert_directed(x1, y0).unwrap();
1621
1622        let q = TemporalEffectQuery::pulse(VariableId::from_raw(0), VariableId::from_raw(1), 1.0)
1623            .with_policy(TemporalPolicy::pulse(-1))
1624            .with_horizon_steps(1)
1625            .with_max_history_lag(Some(1));
1626        let id_res = TemporalBackdoorIdentifier::new().identify_temporal(&g, &q).unwrap();
1627        let estimand = id_res.result.estimands.first().unwrap();
1628        let temporal = TemporalLinearAdjustment::new();
1629        let prep = temporal
1630            .prepare(
1631                &data,
1632                estimand,
1633                &q,
1634                &id_res.indexer,
1635                None,
1636                &ExecutionContext::for_tests(1).kernel_policy,
1637            )
1638            .unwrap();
1639        let bayes = BayesianTemporalGcomp {
1640            inner: BayesianGComputationAte {
1641                backend: BayesianBackendKind::ConjugateGaussian,
1642                n_draws: 200,
1643                seed: 7,
1644                prior_scale: 100.0,
1645                ..BayesianGComputationAte::new()
1646            },
1647        };
1648        let bprep = BayesianTemporalGcomp::from_prepared_estimation(&prep);
1649        let mut ws = BayesianGCompWorkspace::default();
1650        let post = bayes
1651            .fit(
1652                &bprep,
1653                IdentificationStatus::NonparametricallyIdentified,
1654                &mut ws,
1655                &ExecutionContext::for_tests(1),
1656            )
1657            .unwrap();
1658        let eq = post.effect_column().unwrap();
1659        let mean = post.summaries.mean[eq];
1660        assert!((mean - 0.8).abs() < 0.05, "bayesian temporal pulse mean={mean}");
1661        assert!(post.probability_below(0.0).unwrap().is_finite());
1662    }
1663
1664    #[test]
1665    fn hydrate_prior_from_posterior_and_refit() {
1666        let n = 60;
1667        let (data, t, y, z) = linear_scm_table(n);
1668        let estimand = IdentifiedEstimand::backdoor(
1669            "backdoor.adjustment",
1670            Arc::from(vec![z]),
1671            ExprId::from_raw(0),
1672        );
1673        let query = AverageEffectQuery::binary_ate(t, y);
1674        let bayes = BayesianGComputationAte {
1675            backend: BayesianBackendKind::ConjugateGaussian,
1676            n_draws: 200,
1677            seed: 3,
1678            prior_scale: 10.0,
1679            ..BayesianGComputationAte::new()
1680        };
1681        let prep = bayes.prepare(&data, &estimand, &query).unwrap();
1682        let mut ws = BayesianGCompWorkspace::default();
1683        let post = bayes
1684            .fit(
1685                &prep,
1686                IdentificationStatus::NonparametricallyIdentified,
1687                &mut ws,
1688                &ExecutionContext::for_tests(1),
1689            )
1690            .unwrap();
1691        let prior = hydrate_prior_from_posterior(&post, Some(prep.design.ncols)).unwrap();
1692        assert_eq!(prior.gaussian_coefficients().unwrap().len(), prep.design.ncols);
1693        assert!(hydrate_prior_from_posterior(&post, Some(prep.design.ncols + 1)).is_err());
1694
1695        let sequential = BayesianGComputationAte { prior: Some(prior), ..bayes };
1696        let post2 = sequential
1697            .fit(
1698                &prep,
1699                IdentificationStatus::NonparametricallyIdentified,
1700                &mut ws,
1701                &ExecutionContext::for_tests(1),
1702            )
1703            .unwrap();
1704        assert!(post2.assumptions.entries.iter().any(|a| {
1705            matches!(a.source, AssumptionSource::Artifact)
1706                && matches!(&a.assumption, Assumption::PriorRestriction(pa) if pa.description.contains("sequential"))
1707        }));
1708        let eq = post2.effect_column().unwrap();
1709        assert!(post2.summaries.mean[eq].is_finite());
1710    }
1711
1712    #[test]
1713    fn hydrate_effect_functional_maps_treatment_coef() {
1714        let quantities = vec![
1715            PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1716            PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1717            PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1718        ];
1719        let mean = vec![0.1, 0.5, 2.0];
1720        let sd = vec![1.0, 1.0, 0.4];
1721        let names: Vec<Arc<str>> =
1722            vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_z")];
1723        let baseline = PriorSet::weakly_informative(3);
1724        let prior = hydrate_prior(
1725            &HydrateMapping::EffectFunctional { source_quantity: "ate".into() },
1726            &quantities,
1727            &mean,
1728            &sd,
1729            &baseline,
1730            &names,
1731            Some(1),
1732        )
1733        .unwrap();
1734        let coef = prior.gaussian_coefficients().unwrap();
1735        assert!((coef.mean[1] - 2.0).abs() < 1e-12);
1736        assert!((coef.variance[1] - 0.16).abs() < 1e-12);
1737        // Unmapped dims keep baseline (isotropic scale 10 → var 100).
1738        assert!((coef.mean[0] - 0.0).abs() < 1e-12);
1739        assert!((coef.variance[0] - 100.0).abs() < 1e-12);
1740        assert!((coef.variance[2] - 100.0).abs() < 1e-12);
1741        assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_effect_prior"));
1742    }
1743
1744    #[test]
1745    fn hydrate_mapping_hard_errors() {
1746        let quantities = vec![
1747            PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1748            PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1749            PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1750        ];
1751        let mean = vec![0.0, 1.0, 2.0];
1752        let sd = vec![1.0, 1.0, 0.5];
1753        let names2: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1754        let baseline2 = PriorSet::weakly_informative(2);
1755        // Identical with wrong expected dim via target names of different length than source coefs.
1756        let names3: Vec<Arc<str>> =
1757            vec![Arc::from("intercept"), Arc::from("coef_t"), Arc::from("coef_w")];
1758        let baseline3 = PriorSet::weakly_informative(3);
1759        assert!(
1760            hydrate_prior(
1761                &HydrateMapping::IdenticalCoefficientSubspace,
1762                &quantities,
1763                &mean,
1764                &sd,
1765                &baseline3,
1766                &names3,
1767                None,
1768            )
1769            .is_err()
1770        );
1771
1772        assert!(
1773            hydrate_prior(
1774                &HydrateMapping::EffectFunctional { source_quantity: "missing".into() },
1775                &quantities,
1776                &mean,
1777                &sd,
1778                &baseline2,
1779                &names2,
1780                Some(1),
1781            )
1782            .is_err()
1783        );
1784
1785        assert!(
1786            hydrate_prior(
1787                &HydrateMapping::NamedParameters {
1788                    pairs: vec![("ate".into(), "no_such_coef".into())],
1789                },
1790                &quantities,
1791                &mean,
1792                &sd,
1793                &baseline2,
1794                &names2,
1795                None,
1796            )
1797            .is_err()
1798        );
1799
1800        assert!(
1801            hydrate_prior(
1802                &HydrateMapping::NamedParameters {
1803                    pairs: vec![("no_src".into(), "coef_t".into())],
1804                },
1805                &quantities,
1806                &mean,
1807                &sd,
1808                &baseline2,
1809                &names2,
1810                None,
1811            )
1812            .is_err()
1813        );
1814    }
1815
1816    #[test]
1817    fn hydrate_named_parameters_overwrites_target() {
1818        let quantities = vec![
1819            PosteriorQuantityKind::Coefficient { index: 0, name: Some(Arc::from("intercept")) },
1820            PosteriorQuantityKind::Coefficient { index: 1, name: Some(Arc::from("coef_t")) },
1821            PosteriorQuantityKind::Effect { name: Arc::from("ate") },
1822        ];
1823        let mean = vec![0.0, 0.0, 1.5];
1824        let sd = vec![1.0, 1.0, 0.2];
1825        let names: Vec<Arc<str>> = vec![Arc::from("intercept"), Arc::from("coef_t")];
1826        let baseline = PriorSet::weakly_informative(2);
1827        let prior = hydrate_prior(
1828            &HydrateMapping::NamedParameters { pairs: vec![("ate".into(), "coef_t".into())] },
1829            &quantities,
1830            &mean,
1831            &sd,
1832            &baseline,
1833            &names,
1834            None,
1835        )
1836        .unwrap();
1837        let coef = prior.gaussian_coefficients().unwrap();
1838        assert!((coef.mean[1] - 1.5).abs() < 1e-12);
1839        assert!(prior.restrictions.iter().any(|r| r.id.as_ref() == "external_named_prior"));
1840    }
1841}