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::riesz::RieszSensitivity;
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    /// Riesz-representer sensitivity.
116    Riesz,
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    /// Falsifiers of the causal claim (placebo, dummy outcome, RCC, UCC, overlap, E-value,
191    /// sensitivity, Riesz). Does not include sampling-stability checks.
192    #[must_use]
193    pub fn falsification_effect() -> Self {
194        Self::new()
195            .with(ValidatorId::Placebo)
196            .with(ValidatorId::RandomCommonCause)
197            .with(ValidatorId::UnobservedCommonCause)
198            .with(ValidatorId::Overlap)
199            .with(ValidatorId::OverlapRule)
200            .with(ValidatorId::DummyOutcome)
201            .with(ValidatorId::EValue)
202            .with(ValidatorId::LinearSensitivity)
203            .with(ValidatorId::PartialLinearSensitivity)
204            .with(ValidatorId::NonparametricSensitivity)
205            .with(ValidatorId::Riesz)
206    }
207
208    /// Stability / sampling-variability diagnostics (not claim falsifiers).
209    #[must_use]
210    pub fn stability_effect() -> Self {
211        Self::new()
212            .with(ValidatorId::Bootstrap)
213            .with(ValidatorId::DataSubset)
214            .with(ValidatorId::Graph)
215    }
216
217    /// Full effect-validation set: [`Self::falsification_effect`] then [`Self::stability_effect`].
218    #[must_use]
219    pub fn full_effect() -> Self {
220        let mut s = Self::falsification_effect();
221        s.validators.extend(Self::stability_effect().validators);
222        s
223    }
224
225    /// Run all configured validators.
226    ///
227    /// # Errors
228    ///
229    /// Propagates hard failures from applicable validators (not `NotApplicable` skips).
230    pub fn run(
231        &self,
232        problem: &RefutationProblem<'_>,
233        workspace: &mut EstimationWorkspace,
234        ctx: &ExecutionContext,
235    ) -> Result<Vec<ValidationOutcome>, ValidationError> {
236        let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
237        for &id in &self.validators {
238            out.push(self.run_one(id, problem, workspace, ctx)?);
239        }
240        for custom in &self.custom {
241            out.push(ValidationOutcome::Report(custom.validate(problem, ctx)?));
242        }
243        Ok(out)
244    }
245
246    /// Like [`Self::run`], reusing one warmed propensity workspace across every validator
247    /// that fits a diagnostic propensity (Overlap, `OverlapRule`, Riesz), so the suite warms
248    /// the GLM buffers once instead of fitting cold per refuter. Workspace reuse only:
249    /// each refuter still runs its own fit over its own mask, so scores are never shared.
250    ///
251    /// # Errors
252    ///
253    /// Propagates hard failures from applicable validators.
254    pub fn run_with_propensity(
255        &self,
256        problem: &RefutationProblem<'_>,
257        workspace: &mut EstimationWorkspace,
258        propensity: &mut antecedent_stats::PropensityWorkspace,
259        ctx: &ExecutionContext,
260    ) -> Result<Vec<ValidationOutcome>, ValidationError> {
261        let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
262        for &id in &self.validators {
263            // Temporal designs fall through to `run_one`, which returns the same
264            // `NotApplicable` outcomes as the plain `run` path for these validators.
265            let outcome = match id {
266                ValidatorId::Overlap => ValidationOutcome::Report(
267                    crate::overlap::OverlapRefuter::new()
268                        .refute_with_propensity(problem, propensity)?,
269                ),
270                ValidatorId::OverlapRule if problem.temporal.is_none() => {
271                    ValidationOutcome::Report(
272                        OverlapRuleRefuter::new().refute_with_propensity(problem, propensity)?,
273                    )
274                }
275                ValidatorId::Riesz if problem.temporal.is_none() => ValidationOutcome::Report(
276                    RieszSensitivity::new().refute_with_propensity(problem, propensity)?,
277                ),
278                _ => self.run_one(id, problem, workspace, ctx)?,
279            };
280            out.push(outcome);
281        }
282        for custom in &self.custom {
283            out.push(ValidationOutcome::Report(custom.validate(problem, ctx)?));
284        }
285        Ok(out)
286    }
287
288    /// Collect only successful [`RefutationReport`]s (drops `NotApplicable`).
289    #[must_use]
290    pub fn reports_only(outcomes: &[ValidationOutcome]) -> Vec<RefutationReport> {
291        outcomes
292            .iter()
293            .filter_map(|o| match o {
294                ValidationOutcome::Report(r) => Some(r.clone()),
295                ValidationOutcome::NotApplicable { .. } => None,
296            })
297            .collect()
298    }
299
300    /// Run Bayesian validators that need a fitted posterior / prepared design.
301    ///
302    /// Frequentist `run` leaves Prior/Posterior predictive and `PriorSensitivity` as
303    /// [`ValidationOutcome::NotApplicable`]; call this path from Bayesian execute.
304    ///
305    /// # Errors
306    ///
307    /// Propagates hard failures from applicable Bayesian validators.
308    pub fn run_bayesian(
309        &self,
310        bayes: &mut BayesianSuiteContext<'_>,
311        ctx: &ExecutionContext,
312    ) -> Result<Vec<ValidationOutcome>, ValidationError> {
313        let mut out = Vec::with_capacity(self.validators.len() + self.custom.len());
314        for &id in &self.validators {
315            out.push(self.run_one_bayesian(id, bayes, ctx)?);
316        }
317        // Custom validators need a RefutationProblem; Bayesian path leaves them unused here.
318        let _ = &self.custom;
319        Ok(out)
320    }
321
322    fn run_one(
323        &self,
324        id: ValidatorId,
325        problem: &RefutationProblem<'_>,
326        workspace: &mut EstimationWorkspace,
327        ctx: &ExecutionContext,
328    ) -> Result<ValidationOutcome, ValidationError> {
329        let method = problem.estimand.method_kind().ok();
330        let static_linear = method == Some(antecedent_expr::EstimandMethod::BackdoorAdjustment)
331            && problem.estimator.is_none_or(|e| e == "linear.adjustment.ate");
332        let temporal_linear = method
333            == Some(antecedent_expr::EstimandMethod::TemporalBackdoorUnfolded)
334            && problem.temporal.is_some()
335            && problem.estimator.is_none_or(|e| {
336                matches!(e, "temporal.linear.adjustment" | "bayesian.temporal.gcomp")
337            });
338        let linear_ok = static_linear || temporal_linear;
339        match id {
340            ValidatorId::Placebo => {
341                if !linear_ok {
342                    return Ok(na(
343                        id,
344                        "PlaceboTreatment requires backdoor.adjustment + linear path \
345                         (or temporal.backdoor.unfolded + temporal linear path)",
346                    ));
347                }
348                Ok(ValidationOutcome::Report(run_validator(
349                    &PlaceboTreatment::new(),
350                    problem,
351                    workspace,
352                    ctx,
353                )?))
354            }
355            ValidatorId::RandomCommonCause => {
356                if !linear_ok {
357                    return Ok(na(
358                        id,
359                        "RandomCommonCause requires backdoor.adjustment + linear path \
360                         (or temporal.backdoor.unfolded + temporal linear path)",
361                    ));
362                }
363                Ok(ValidationOutcome::Report(run_validator(
364                    &RandomCommonCause::new(),
365                    problem,
366                    workspace,
367                    ctx,
368                )?))
369            }
370            ValidatorId::Bootstrap => {
371                if !linear_ok {
372                    return Ok(na(
373                        id,
374                        "BootstrapCiCoverage requires backdoor.adjustment + linear path \
375                         (or temporal.backdoor.unfolded + temporal linear path)",
376                    ));
377                }
378                Ok(ValidationOutcome::Report(run_validator(
379                    &BootstrapRefute::new(),
380                    problem,
381                    workspace,
382                    ctx,
383                )?))
384            }
385            ValidatorId::UnobservedCommonCause => {
386                if !linear_ok {
387                    return Ok(na(
388                        id,
389                        "UnobservedCommonCause requires backdoor.adjustment or temporal.backdoor.unfolded",
390                    ));
391                }
392                Ok(ValidationOutcome::Report(run_validator(
393                    &UnobservedCommonCause::new(),
394                    problem,
395                    workspace,
396                    ctx,
397                )?))
398            }
399            ValidatorId::Overlap => {
400                if problem.temporal.is_some() {
401                    return Ok(na(
402                        id,
403                        "OverlapRefuter not applicable to temporal unfolded designs \
404                         (propensity uses schema adjustment columns)",
405                    ));
406                }
407                Ok(ValidationOutcome::Report(run_validator(
408                    &OverlapRefuter::new(),
409                    problem,
410                    workspace,
411                    ctx,
412                )?))
413            }
414            ValidatorId::OverlapRule => {
415                if problem.temporal.is_some() {
416                    return Ok(na(
417                        id,
418                        "OverlapRuleRefuter not applicable to temporal unfolded designs",
419                    ));
420                }
421                Ok(ValidationOutcome::Report(run_validator(
422                    &OverlapRuleRefuter::new(),
423                    problem,
424                    workspace,
425                    ctx,
426                )?))
427            }
428            ValidatorId::DataSubset => {
429                if !linear_ok {
430                    return Ok(na(
431                        id,
432                        "DataSubsetRefuter requires backdoor.adjustment + linear path \
433                         (or temporal.backdoor.unfolded + temporal linear path)",
434                    ));
435                }
436                Ok(ValidationOutcome::Report(run_validator(
437                    &DataSubsetRefuter::new(),
438                    problem,
439                    workspace,
440                    ctx,
441                )?))
442            }
443            ValidatorId::DummyOutcome => {
444                if !linear_ok {
445                    return Ok(na(
446                        id,
447                        "DummyOutcome requires backdoor.adjustment + linear path \
448                         (or temporal.backdoor.unfolded + temporal linear path)",
449                    ));
450                }
451                Ok(ValidationOutcome::Report(run_validator(
452                    &DummyOutcome::new(),
453                    problem,
454                    workspace,
455                    ctx,
456                )?))
457            }
458            ValidatorId::EValue => Ok(ValidationOutcome::Report(run_validator(
459                &EValue::new(),
460                problem,
461                workspace,
462                ctx,
463            )?)),
464            ValidatorId::Graph => {
465                // Temporal unfolded adjustment ids are not schema drop-covariate targets.
466                if !static_linear {
467                    return Ok(na(
468                        id,
469                        "DropAdjustmentCovariate requires static backdoor.adjustment + linear path \
470                         (not applicable to temporal unfolded designs)",
471                    ));
472                }
473                Ok(ValidationOutcome::Report(run_validator(
474                    &GraphRefuter::new(),
475                    problem,
476                    workspace,
477                    ctx,
478                )?))
479            }
480            ValidatorId::LinearSensitivity => {
481                if !linear_ok {
482                    return Ok(na(
483                        id,
484                        "LinearSensitivity requires backdoor.adjustment or temporal.backdoor.unfolded",
485                    ));
486                }
487                Ok(ValidationOutcome::Report(run_validator(
488                    &LinearSensitivity::new(),
489                    problem,
490                    workspace,
491                    ctx,
492                )?))
493            }
494            ValidatorId::PartialLinearSensitivity => {
495                if !linear_ok {
496                    return Ok(na(
497                        id,
498                        "PartialLinearSensitivity requires backdoor.adjustment or temporal.backdoor.unfolded",
499                    ));
500                }
501                Ok(ValidationOutcome::Report(run_validator(
502                    &PartialLinearSensitivity::new(),
503                    problem,
504                    workspace,
505                    ctx,
506                )?))
507            }
508            ValidatorId::NonparametricSensitivity => Ok(ValidationOutcome::Report(run_validator(
509                &NonparametricSensitivity::new(),
510                problem,
511                workspace,
512                ctx,
513            )?)),
514            ValidatorId::Riesz => {
515                if problem.temporal.is_some() {
516                    return Ok(na(
517                        id,
518                        "RieszSensitivity not applicable to temporal unfolded designs",
519                    ));
520                }
521                Ok(ValidationOutcome::Report(run_validator(
522                    &RieszSensitivity::new(),
523                    problem,
524                    workspace,
525                    ctx,
526                )?))
527            }
528            ValidatorId::PriorPredictive
529            | ValidatorId::PosteriorPredictive
530            | ValidatorId::PriorSensitivity
531            | ValidatorId::McmcDiagnostics => Ok(na(
532                id,
533                "Bayesian PPC/prior-sensitivity/MCMC diagnostics require ValidationSuite::run_bayesian with a fitted posterior",
534            )),
535        }
536    }
537
538    fn run_one_bayesian(
539        &self,
540        id: ValidatorId,
541        bayes: &mut BayesianSuiteContext<'_>,
542        ctx: &ExecutionContext,
543    ) -> Result<ValidationOutcome, ValidationError> {
544        match id {
545            ValidatorId::PriorPredictive => {
546                let check = PriorPredictiveCheck {
547                    n_sims: 200,
548                    seed: ctx.rng.master_seed(),
549                    ..PriorPredictiveCheck::new()
550                };
551                let rep = check.check(bayes.prepared, ctx)?;
552                Ok(ValidationOutcome::Report(
553                    rep.to_refutation_report(bayes.original_ate, bayes.ppc_alpha),
554                ))
555            }
556            ValidatorId::PosteriorPredictive => {
557                let check = PosteriorPredictiveCheck::new();
558                let rep = check.check(bayes.prepared, bayes.posterior)?;
559                Ok(ValidationOutcome::Report(
560                    rep.to_refutation_report(bayes.original_ate, bayes.ppc_alpha),
561                ))
562            }
563            ValidatorId::PriorSensitivity => {
564                let sens = PriorSensitivity::standard_grid();
565                let (summary, _posts) = sens.evaluate(
566                    bayes.estimator,
567                    bayes.prepared,
568                    bayes.identification,
569                    bayes.workspace,
570                    ctx,
571                )?;
572                Ok(ValidationOutcome::Report(sens.to_report(&summary, bayes.original_ate)))
573            }
574            ValidatorId::McmcDiagnostics => {
575                match McmcDiagnosticsCheck::new().check(bayes.posterior) {
576                    Some(rep) => Ok(ValidationOutcome::Report(rep)),
577                    None => Ok(na(
578                        ValidatorId::McmcDiagnostics,
579                        "MCMC diagnostics require an HMC/SMC posterior (Laplace/conjugate NotApplicable)",
580                    )),
581                }
582            }
583            other => {
584                Ok(na(other, "validator is not a Bayesian diagnostic; use ValidationSuite::run"))
585            }
586        }
587    }
588
589    /// Bayesian diagnostics suite identifiers.
590    #[must_use]
591    pub fn bayesian_diagnostics() -> Self {
592        Self::new()
593            .with(ValidatorId::PriorPredictive)
594            .with(ValidatorId::PosteriorPredictive)
595            .with(ValidatorId::PriorSensitivity)
596            .with(ValidatorId::McmcDiagnostics)
597    }
598
599    /// Prior predictive check only (cheap; no fitted posterior required beyond prepare).
600    #[must_use]
601    pub fn prior_predictive() -> Self {
602        Self::new().with(ValidatorId::PriorPredictive)
603    }
604}
605
606fn na(id: ValidatorId, reason: &str) -> ValidationOutcome {
607    ValidationOutcome::NotApplicable { validator: id, reason: Arc::from(reason) }
608}
609
610#[cfg(test)]
611mod tests {
612    use antecedent_core::{
613        AssumptionSet, AverageEffectQuery, CausalSchemaBuilder, ExecutionContext, MeasurementSpec,
614        RoleHint, SmallRoleSet, ValueType, VariableId,
615    };
616    use antecedent_data::{
617        Float64Column, OwnedColumn, OwnedColumnarStorage, TabularData, ValidityBitmap,
618    };
619    use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
620    use antecedent_expr::ExprId;
621    use antecedent_identify::IdentifiedEstimand;
622
623    use super::*;
624    use crate::common::RefutationProblem;
625
626    fn toy() -> (TabularData, IdentifiedEstimand) {
627        let n = 120usize;
628        let mut b = CausalSchemaBuilder::new();
629        b.add_variable(
630            "t",
631            ValueType::Continuous,
632            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
633            None,
634            None,
635            MeasurementSpec::default(),
636        )
637        .unwrap();
638        b.add_variable(
639            "y",
640            ValueType::Continuous,
641            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
642            None,
643            None,
644            MeasurementSpec::default(),
645        )
646        .unwrap();
647        b.add_variable(
648            "z",
649            ValueType::Continuous,
650            SmallRoleSet::from_hint(RoleHint::Context),
651            None,
652            None,
653            MeasurementSpec::default(),
654        )
655        .unwrap();
656        let schema = b.build().unwrap();
657        let t: Vec<f64> = (0..n).map(|i| (i % 2) as f64).collect();
658        let z: Vec<f64> = (0..n).map(|i| (i as f64) / n as f64).collect();
659        let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + z[i]).collect();
660        let cols = vec![
661            OwnedColumn::Float64(
662                Float64Column::new(
663                    VariableId::from_raw(0),
664                    Arc::from(t),
665                    ValidityBitmap::all_valid(n),
666                )
667                .unwrap(),
668            ),
669            OwnedColumn::Float64(
670                Float64Column::new(
671                    VariableId::from_raw(1),
672                    Arc::from(y),
673                    ValidityBitmap::all_valid(n),
674                )
675                .unwrap(),
676            ),
677            OwnedColumn::Float64(
678                Float64Column::new(
679                    VariableId::from_raw(2),
680                    Arc::from(z),
681                    ValidityBitmap::all_valid(n),
682                )
683                .unwrap(),
684            ),
685        ];
686        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
687        let estimand = IdentifiedEstimand::backdoor(
688            "backdoor.adjustment",
689            Arc::from([VariableId::from_raw(2)]),
690            ExprId::from_raw(0),
691        );
692        (TabularData::new(storage), estimand)
693    }
694
695    #[test]
696    fn full_suite_runs_applicable_validators() {
697        let (data, estimand) = toy();
698        let query =
699            AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
700        let est = LinearAdjustmentAte { bootstrap_replicates: 0, ..LinearAdjustmentAte::new() };
701        let prep = est.prepare(&data, &estimand, &query).unwrap();
702        let mut ws = EstimationWorkspace::default();
703        let ctx = ExecutionContext::for_tests(2);
704        let original = est.fit(&prep, &mut ws, &ctx, AssumptionSet::new()).unwrap();
705        let problem = RefutationProblem::new(
706            &data,
707            &estimand,
708            &query,
709            &original,
710            Some("linear.adjustment.ate"),
711            None,
712        );
713        let outcomes = ValidationSuite::full_effect().run(&problem, &mut ws, &ctx).unwrap();
714        assert_eq!(outcomes.len(), 14);
715        let reports = ValidationSuite::reports_only(&outcomes);
716        assert!(reports.len() >= 10, "reports={}", reports.len());
717    }
718}