Skip to main content

antecedent_validate/
suite.rs

1//! Validation suite orchestration.
2//!
3//! Runs requested validators, returning explicit [`ValidationOutcome::NotApplicable`] when a
4//! check is incompatible with the estimator/estimand (rather than failing the whole suite).
5//!
6//! SPDX-License-Identifier: MIT OR Apache-2.0
7
8#![allow(
9    clippy::cast_precision_loss,
10    clippy::many_single_char_names,
11    clippy::unused_self,
12    clippy::too_many_lines
13)]
14
15use std::sync::Arc;
16
17use antecedent_core::ExecutionContext;
18use antecedent_estimate::EstimationWorkspace;
19
20use crate::bayesian_checks::{
21    McmcDiagnosticsCheck, PosteriorPredictiveCheck, PriorPredictiveCheck, PriorSensitivity,
22};
23use crate::bootstrap_refute::BootstrapRefute;
24use crate::common::{RefutationProblem, RefutationReport};
25use crate::custom::CustomEffectValidator;
26use crate::data_subset::DataSubsetRefuter;
27use crate::dummy_outcome::DummyOutcome;
28use crate::error::ValidationError;
29use crate::evalue::EValue;
30use crate::graph_refute::GraphRefuter;
31use crate::overlap::OverlapRefuter;
32use crate::overlap_rule::OverlapRuleRefuter;
33use crate::placebo::PlaceboTreatment;
34use crate::rcc::RandomCommonCause;
35use crate::reisz::ReiszSensitivity;
36use crate::sensitivity::{LinearSensitivity, NonparametricSensitivity, PartialLinearSensitivity};
37use crate::unobserved_common_cause::UnobservedCommonCause;
38use crate::validator::run_validator;
39
40use antecedent_estimate::{
41    BayesianGCompWorkspace, BayesianGComputationAte, CausalPosterior, PreparedBayesianProblem,
42};
43use antecedent_identify::IdentificationStatus;
44
45/// Context required to run Bayesian PPC / prior-sensitivity validators.
46pub struct BayesianSuiteContext<'a> {
47    /// Fitted Bayesian estimator configuration.
48    pub estimator: &'a BayesianGComputationAte,
49    /// Prepared design used for the primary fit.
50    pub prepared: &'a PreparedBayesianProblem,
51    /// Primary posterior (used for posterior predictive).
52    pub posterior: &'a CausalPosterior,
53    /// Identification status passed to sensitivity refits.
54    pub identification: IdentificationStatus,
55    /// Workspace for sensitivity refits.
56    pub workspace: &'a mut BayesianGCompWorkspace,
57    /// Original effect estimate (ATE) for report comparison.
58    pub original_ate: f64,
59    /// Two-sided α for predictive-check pass/fail (default 0.05).
60    pub ppc_alpha: f64,
61}
62
63impl<'a> BayesianSuiteContext<'a> {
64    /// Build with default PPC α = 0.05.
65    #[must_use]
66    pub fn new(
67        estimator: &'a BayesianGComputationAte,
68        prepared: &'a PreparedBayesianProblem,
69        posterior: &'a CausalPosterior,
70        identification: IdentificationStatus,
71        workspace: &'a mut BayesianGCompWorkspace,
72        original_ate: f64,
73    ) -> Self {
74        Self {
75            estimator,
76            prepared,
77            posterior,
78            identification,
79            workspace,
80            original_ate,
81            ppc_alpha: 0.05,
82        }
83    }
84}
85
86/// Named validators that can be attached to a [`ValidationSuite`].
87#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
88pub enum ValidatorId {
89    /// Placebo treatment.
90    Placebo,
91    /// Random common cause.
92    RandomCommonCause,
93    /// Bootstrap CI coverage of the point estimate (not placebo falsification).
94    Bootstrap,
95    /// Unobserved common cause.
96    UnobservedCommonCause,
97    /// Overlap / positivity assessment.
98    Overlap,
99    /// Overlap-rule / trimming assessment.
100    OverlapRule,
101    /// Data subset.
102    DataSubset,
103    /// Dummy outcome.
104    DummyOutcome,
105    /// E-value.
106    EValue,
107    /// Leave-one-out adjustment-set sensitivity (drop covariates; not DAG edits).
108    Graph,
109    /// Linear sensitivity.
110    LinearSensitivity,
111    /// Partial-linear sensitivity.
112    PartialLinearSensitivity,
113    /// Nonparametric sensitivity.
114    NonparametricSensitivity,
115    /// Reisz-representer sensitivity.
116    Reisz,
117    /// Prior predictive check (Bayesian).
118    PriorPredictive,
119    /// Posterior predictive check (Bayesian).
120    PosteriorPredictive,
121    /// Prior sensitivity grid (Bayesian).
122    PriorSensitivity,
123    /// MCMC ESS / R-hat / divergence diagnostics (Bayesian HMC/SMC).
124    McmcDiagnostics,
125}
126
127/// Outcome of one validator in a suite.
128#[derive(Clone, Debug)]
129pub enum ValidationOutcome {
130    /// Validator ran and produced a report.
131    Report(RefutationReport),
132    /// Validator was requested but is incompatible with this problem.
133    NotApplicable {
134        /// Validator id.
135        validator: ValidatorId,
136        /// Why it was skipped.
137        reason: Arc<str>,
138    },
139}
140
141/// Ordered suite of validators .
142#[derive(Clone, Default)]
143pub struct ValidationSuite {
144    validators: Vec<ValidatorId>,
145    custom: Vec<Arc<dyn CustomEffectValidator>>,
146}
147
148impl std::fmt::Debug for ValidationSuite {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("ValidationSuite")
151            .field("validators", &self.validators)
152            .field("custom", &self.custom.len())
153            .finish()
154    }
155}
156
157impl ValidationSuite {
158    /// Empty suite.
159    #[must_use]
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// Append a validator (order preserved).
165    #[must_use]
166    pub fn with(mut self, id: ValidatorId) -> Self {
167        self.validators.push(id);
168        self
169    }
170
171    /// Append a custom (dyn) effect validator; runs after built-ins.
172    #[must_use]
173    pub fn with_custom(mut self, validator: Arc<dyn CustomEffectValidator>) -> Self {
174        self.custom.push(validator);
175        self
176    }
177
178    /// Placebo + RCC (legacy default).
179    #[must_use]
180    pub fn placebo_and_rcc() -> Self {
181        Self::new().with(ValidatorId::Placebo).with(ValidatorId::RandomCommonCause)
182    }
183
184    /// Cheap interactive validators: overlap / positivity + E-value only.
185    #[must_use]
186    pub fn overlap_and_evalue() -> Self {
187        Self::new().with(ValidatorId::Overlap).with(ValidatorId::EValue)
188    }
189
190    /// Full effect-validation set.
191    #[must_use]
192    pub fn full_effect() -> Self {
193        Self::new()
194            .with(ValidatorId::Placebo)
195            .with(ValidatorId::RandomCommonCause)
196            .with(ValidatorId::Bootstrap)
197            .with(ValidatorId::UnobservedCommonCause)
198            .with(ValidatorId::Overlap)
199            .with(ValidatorId::OverlapRule)
200            .with(ValidatorId::DataSubset)
201            .with(ValidatorId::DummyOutcome)
202            .with(ValidatorId::EValue)
203            .with(ValidatorId::Graph)
204            .with(ValidatorId::LinearSensitivity)
205            .with(ValidatorId::PartialLinearSensitivity)
206            .with(ValidatorId::NonparametricSensitivity)
207            .with(ValidatorId::Reisz)
208    }
209
210    /// Run all configured validators.
211    ///
212    /// # Errors
213    ///
214    /// Propagates hard failures from applicable validators (not `NotApplicable` skips).
215    pub fn run(
216        &self,
217        problem: &RefutationProblem<'_>,
218        workspace: &mut EstimationWorkspace,
219        ctx: &ExecutionContext,
220    ) -> Result<Vec<ValidationOutcome>, ValidationError> {
221        let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
222        for &id in &self.validators {
223            out.push(self.run_one(id, problem, workspace, ctx)?);
224        }
225        for custom in &self.custom {
226            out.push(ValidationOutcome::Report(custom.validate(problem, ctx)?));
227        }
228        Ok(out)
229    }
230
231    /// Like [`Self::run`], reusing a warmed propensity workspace for overlap diagnostics.
232    ///
233    /// # Errors
234    ///
235    /// Propagates hard failures from applicable validators.
236    pub fn run_with_propensity(
237        &self,
238        problem: &RefutationProblem<'_>,
239        workspace: &mut EstimationWorkspace,
240        propensity: &mut antecedent_stats::PropensityWorkspace,
241        ctx: &ExecutionContext,
242    ) -> Result<Vec<ValidationOutcome>, ValidationError> {
243        let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
244        for &id in &self.validators {
245            if id == ValidatorId::Overlap {
246                out.push(ValidationOutcome::Report(
247                    crate::overlap::OverlapRefuter::new()
248                        .refute_with_propensity(problem, propensity)?,
249                ));
250            } else {
251                out.push(self.run_one(id, problem, workspace, ctx)?);
252            }
253        }
254        for custom in &self.custom {
255            out.push(ValidationOutcome::Report(custom.validate(problem, ctx)?));
256        }
257        Ok(out)
258    }
259
260    /// Collect only successful [`RefutationReport`]s (drops `NotApplicable`).
261    #[must_use]
262    pub fn reports_only(outcomes: &[ValidationOutcome]) -> Vec<RefutationReport> {
263        outcomes
264            .iter()
265            .filter_map(|o| match o {
266                ValidationOutcome::Report(r) => Some(r.clone()),
267                ValidationOutcome::NotApplicable { .. } => None,
268            })
269            .collect()
270    }
271
272    /// Run Bayesian validators that need a fitted posterior / prepared design.
273    ///
274    /// Frequentist `run` leaves Prior/Posterior predictive and `PriorSensitivity` as
275    /// [`ValidationOutcome::NotApplicable`]; call this path from Bayesian execute.
276    ///
277    /// # Errors
278    ///
279    /// Propagates hard failures from applicable Bayesian validators.
280    pub fn run_bayesian(
281        &self,
282        bayes: &mut BayesianSuiteContext<'_>,
283        ctx: &ExecutionContext,
284    ) -> Result<Vec<ValidationOutcome>, ValidationError> {
285        let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
286        for &id in &self.validators {
287            out.push(self.run_one_bayesian(id, bayes, ctx)?);
288        }
289        // Custom validators need a RefutationProblem; Bayesian path leaves them unused here.
290        let _ = &self.custom;
291        Ok(out)
292    }
293
294    fn run_one(
295        &self,
296        id: ValidatorId,
297        problem: &RefutationProblem<'_>,
298        workspace: &mut EstimationWorkspace,
299        ctx: &ExecutionContext,
300    ) -> Result<ValidationOutcome, ValidationError> {
301        let method = problem.estimand.method_kind().ok();
302        let static_linear = method == Some(antecedent_expr::EstimandMethod::BackdoorAdjustment)
303            && problem.estimator.is_none_or(|e| e == "linear.adjustment.ate");
304        let temporal_linear = method
305            == Some(antecedent_expr::EstimandMethod::TemporalBackdoorUnfolded)
306            && problem.temporal.is_some()
307            && problem.estimator.is_none_or(|e| {
308                matches!(e, "temporal.linear.adjustment" | "bayesian.temporal.gcomp")
309            });
310        let linear_ok = static_linear || temporal_linear;
311        match id {
312            ValidatorId::Placebo => {
313                if !linear_ok {
314                    return Ok(na(
315                        id,
316                        "PlaceboTreatment requires backdoor.adjustment + linear path \
317                         (or temporal.backdoor.unfolded + temporal linear path)",
318                    ));
319                }
320                Ok(ValidationOutcome::Report(run_validator(
321                    &PlaceboTreatment::new(),
322                    problem,
323                    workspace,
324                    ctx,
325                )?))
326            }
327            ValidatorId::RandomCommonCause => {
328                if !linear_ok {
329                    return Ok(na(
330                        id,
331                        "RandomCommonCause requires backdoor.adjustment + linear path \
332                         (or temporal.backdoor.unfolded + temporal linear path)",
333                    ));
334                }
335                Ok(ValidationOutcome::Report(run_validator(
336                    &RandomCommonCause::new(),
337                    problem,
338                    workspace,
339                    ctx,
340                )?))
341            }
342            ValidatorId::Bootstrap => {
343                if !linear_ok {
344                    return Ok(na(
345                        id,
346                        "BootstrapCiCoverage requires backdoor.adjustment + linear path \
347                         (or temporal.backdoor.unfolded + temporal linear path)",
348                    ));
349                }
350                Ok(ValidationOutcome::Report(run_validator(
351                    &BootstrapRefute::new(),
352                    problem,
353                    workspace,
354                    ctx,
355                )?))
356            }
357            ValidatorId::UnobservedCommonCause => {
358                if !linear_ok {
359                    return Ok(na(
360                        id,
361                        "UnobservedCommonCause requires backdoor.adjustment or temporal.backdoor.unfolded",
362                    ));
363                }
364                Ok(ValidationOutcome::Report(run_validator(
365                    &UnobservedCommonCause::new(),
366                    problem,
367                    workspace,
368                    ctx,
369                )?))
370            }
371            ValidatorId::Overlap => {
372                if problem.temporal.is_some() {
373                    return Ok(na(
374                        id,
375                        "OverlapRefuter not applicable to temporal unfolded designs \
376                         (propensity uses schema adjustment columns)",
377                    ));
378                }
379                Ok(ValidationOutcome::Report(run_validator(
380                    &OverlapRefuter::new(),
381                    problem,
382                    workspace,
383                    ctx,
384                )?))
385            }
386            ValidatorId::OverlapRule => {
387                if problem.temporal.is_some() {
388                    return Ok(na(
389                        id,
390                        "OverlapRuleRefuter not applicable to temporal unfolded designs",
391                    ));
392                }
393                Ok(ValidationOutcome::Report(run_validator(
394                    &OverlapRuleRefuter::new(),
395                    problem,
396                    workspace,
397                    ctx,
398                )?))
399            }
400            ValidatorId::DataSubset => {
401                if !linear_ok {
402                    return Ok(na(
403                        id,
404                        "DataSubsetRefuter requires backdoor.adjustment + linear path \
405                         (or temporal.backdoor.unfolded + temporal linear path)",
406                    ));
407                }
408                Ok(ValidationOutcome::Report(run_validator(
409                    &DataSubsetRefuter::new(),
410                    problem,
411                    workspace,
412                    ctx,
413                )?))
414            }
415            ValidatorId::DummyOutcome => {
416                if !linear_ok {
417                    return Ok(na(
418                        id,
419                        "DummyOutcome requires backdoor.adjustment + linear path \
420                         (or temporal.backdoor.unfolded + temporal linear path)",
421                    ));
422                }
423                Ok(ValidationOutcome::Report(run_validator(
424                    &DummyOutcome::new(),
425                    problem,
426                    workspace,
427                    ctx,
428                )?))
429            }
430            ValidatorId::EValue => Ok(ValidationOutcome::Report(run_validator(
431                &EValue::new(),
432                problem,
433                workspace,
434                ctx,
435            )?)),
436            ValidatorId::Graph => {
437                // Temporal unfolded adjustment ids are not schema drop-covariate targets.
438                if !static_linear {
439                    return Ok(na(
440                        id,
441                        "DropAdjustmentCovariate requires static backdoor.adjustment + linear path \
442                         (not applicable to temporal unfolded designs)",
443                    ));
444                }
445                Ok(ValidationOutcome::Report(run_validator(
446                    &GraphRefuter::new(),
447                    problem,
448                    workspace,
449                    ctx,
450                )?))
451            }
452            ValidatorId::LinearSensitivity => {
453                if !linear_ok {
454                    return Ok(na(
455                        id,
456                        "LinearSensitivity requires backdoor.adjustment or temporal.backdoor.unfolded",
457                    ));
458                }
459                Ok(ValidationOutcome::Report(run_validator(
460                    &LinearSensitivity::new(),
461                    problem,
462                    workspace,
463                    ctx,
464                )?))
465            }
466            ValidatorId::PartialLinearSensitivity => {
467                if !linear_ok {
468                    return Ok(na(
469                        id,
470                        "PartialLinearSensitivity requires backdoor.adjustment or temporal.backdoor.unfolded",
471                    ));
472                }
473                Ok(ValidationOutcome::Report(run_validator(
474                    &PartialLinearSensitivity::new(),
475                    problem,
476                    workspace,
477                    ctx,
478                )?))
479            }
480            ValidatorId::NonparametricSensitivity => Ok(ValidationOutcome::Report(run_validator(
481                &NonparametricSensitivity::new(),
482                problem,
483                workspace,
484                ctx,
485            )?)),
486            ValidatorId::Reisz => {
487                if problem.temporal.is_some() {
488                    return Ok(na(
489                        id,
490                        "ReiszSensitivity not applicable to temporal unfolded designs",
491                    ));
492                }
493                Ok(ValidationOutcome::Report(run_validator(
494                    &ReiszSensitivity::new(),
495                    problem,
496                    workspace,
497                    ctx,
498                )?))
499            }
500            ValidatorId::PriorPredictive
501            | ValidatorId::PosteriorPredictive
502            | ValidatorId::PriorSensitivity
503            | ValidatorId::McmcDiagnostics => Ok(na(
504                id,
505                "Bayesian PPC/prior-sensitivity/MCMC diagnostics require ValidationSuite::run_bayesian with a fitted posterior",
506            )),
507        }
508    }
509
510    fn run_one_bayesian(
511        &self,
512        id: ValidatorId,
513        bayes: &mut BayesianSuiteContext<'_>,
514        ctx: &ExecutionContext,
515    ) -> Result<ValidationOutcome, ValidationError> {
516        match id {
517            ValidatorId::PriorPredictive => {
518                let check = PriorPredictiveCheck {
519                    n_sims: 200,
520                    seed: ctx.rng.master_seed(),
521                    ..PriorPredictiveCheck::new()
522                };
523                let rep = check.check(bayes.prepared, ctx)?;
524                Ok(ValidationOutcome::Report(
525                    rep.to_refutation_report(bayes.original_ate, bayes.ppc_alpha),
526                ))
527            }
528            ValidatorId::PosteriorPredictive => {
529                let check = PosteriorPredictiveCheck::new();
530                let rep = check.check(bayes.prepared, bayes.posterior)?;
531                Ok(ValidationOutcome::Report(
532                    rep.to_refutation_report(bayes.original_ate, bayes.ppc_alpha),
533                ))
534            }
535            ValidatorId::PriorSensitivity => {
536                let sens = PriorSensitivity::standard_grid();
537                let (summary, _posts) = sens.evaluate(
538                    bayes.estimator,
539                    bayes.prepared,
540                    bayes.identification,
541                    bayes.workspace,
542                    ctx,
543                )?;
544                Ok(ValidationOutcome::Report(sens.to_report(&summary, bayes.original_ate)))
545            }
546            ValidatorId::McmcDiagnostics => {
547                match McmcDiagnosticsCheck::new().check(bayes.posterior) {
548                    Some(rep) => Ok(ValidationOutcome::Report(rep)),
549                    None => Ok(na(
550                        ValidatorId::McmcDiagnostics,
551                        "MCMC diagnostics require an HMC/SMC posterior (Laplace/conjugate NotApplicable)",
552                    )),
553                }
554            }
555            other => {
556                Ok(na(other, "validator is not a Bayesian diagnostic; use ValidationSuite::run"))
557            }
558        }
559    }
560
561    /// Bayesian diagnostics suite identifiers.
562    #[must_use]
563    pub fn bayesian_diagnostics() -> Self {
564        Self::new()
565            .with(ValidatorId::PriorPredictive)
566            .with(ValidatorId::PosteriorPredictive)
567            .with(ValidatorId::PriorSensitivity)
568            .with(ValidatorId::McmcDiagnostics)
569    }
570
571    /// Prior predictive check only (cheap; no fitted posterior required beyond prepare).
572    #[must_use]
573    pub fn prior_predictive() -> Self {
574        Self::new().with(ValidatorId::PriorPredictive)
575    }
576}
577
578fn na(id: ValidatorId, reason: &str) -> ValidationOutcome {
579    ValidationOutcome::NotApplicable { validator: id, reason: Arc::from(reason) }
580}
581
582#[cfg(test)]
583mod tests {
584    use antecedent_core::{
585        AssumptionSet, AverageEffectQuery, CausalSchemaBuilder, ExecutionContext, MeasurementSpec,
586        RoleHint, SmallRoleSet, ValueType, VariableId,
587    };
588    use antecedent_data::{
589        Float64Column, OwnedColumn, OwnedColumnarStorage, TabularData, ValidityBitmap,
590    };
591    use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
592    use antecedent_expr::ExprId;
593    use antecedent_identify::IdentifiedEstimand;
594
595    use super::*;
596    use crate::common::RefutationProblem;
597
598    fn toy() -> (TabularData, IdentifiedEstimand) {
599        let n = 120usize;
600        let mut b = CausalSchemaBuilder::new();
601        b.add_variable(
602            "t",
603            ValueType::Continuous,
604            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
605            None,
606            None,
607            MeasurementSpec::default(),
608        )
609        .unwrap();
610        b.add_variable(
611            "y",
612            ValueType::Continuous,
613            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
614            None,
615            None,
616            MeasurementSpec::default(),
617        )
618        .unwrap();
619        b.add_variable(
620            "z",
621            ValueType::Continuous,
622            SmallRoleSet::from_hint(RoleHint::Context),
623            None,
624            None,
625            MeasurementSpec::default(),
626        )
627        .unwrap();
628        let schema = b.build().unwrap();
629        let t: Vec<f64> = (0..n).map(|i| (i % 2) as f64).collect();
630        let z: Vec<f64> = (0..n).map(|i| (i as f64) / n as f64).collect();
631        let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + z[i]).collect();
632        let cols = vec![
633            OwnedColumn::Float64(
634                Float64Column::new(
635                    VariableId::from_raw(0),
636                    Arc::from(t),
637                    ValidityBitmap::all_valid(n),
638                )
639                .unwrap(),
640            ),
641            OwnedColumn::Float64(
642                Float64Column::new(
643                    VariableId::from_raw(1),
644                    Arc::from(y),
645                    ValidityBitmap::all_valid(n),
646                )
647                .unwrap(),
648            ),
649            OwnedColumn::Float64(
650                Float64Column::new(
651                    VariableId::from_raw(2),
652                    Arc::from(z),
653                    ValidityBitmap::all_valid(n),
654                )
655                .unwrap(),
656            ),
657        ];
658        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
659        let estimand = IdentifiedEstimand::backdoor(
660            "backdoor.adjustment",
661            Arc::from([VariableId::from_raw(2)]),
662            ExprId::from_raw(0),
663        );
664        (TabularData::new(storage), estimand)
665    }
666
667    #[test]
668    fn full_suite_runs_applicable_validators() {
669        let (data, estimand) = toy();
670        let query =
671            AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
672        let est = LinearAdjustmentAte { bootstrap_replicates: 0, ..LinearAdjustmentAte::new() };
673        let prep = est.prepare(&data, &estimand, &query).unwrap();
674        let mut ws = EstimationWorkspace::default();
675        let ctx = ExecutionContext::for_tests(2);
676        let original = est.fit(&prep, &mut ws, &ctx, AssumptionSet::new()).unwrap();
677        let problem = RefutationProblem {
678            data: &data,
679            estimand: &estimand,
680            query: &query,
681            original: &original,
682            estimator: Some("linear.adjustment.ate"),
683            temporal: None,
684        };
685        let outcomes = ValidationSuite::full_effect().run(&problem, &mut ws, &ctx).unwrap();
686        assert_eq!(outcomes.len(), 14);
687        let reports = ValidationSuite::reports_only(&outcomes);
688        assert!(reports.len() >= 10, "reports={}", reports.len());
689    }
690}