Skip to main content

antecedent/
lib.rs

1//! Unified static/temporal `CausalAnalysis` facade (identify → estimate → refute).
2//!
3//! Antecedent is an identification-first causal inference engine: identification
4//! is evaluated before estimation, refuters run against the estimate, and
5//! uncertainty about causal structure (equivalence classes, graph posteriors) is
6//! retained rather than silently resolved.
7//!
8//! # Quick start
9//!
10//! ```
11//! use antecedent::prelude::*;
12//! use antecedent::RefuteSuite;
13//!
14//! let schema = CausalSchemaBuilder::new()
15//!     .continuous("t")
16//!     .treatment()
17//!     .continuous("y")
18//!     .outcome()
19//!     .continuous("z")
20//!     .context()
21//!     .build()
22//!     .unwrap();
23//! let data = TabularData::from_f64_columns([
24//!     ("t", &[0.0_f64, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0][..]),
25//!     ("y", &[1.0_f64, 3.1, 1.2, 2.9, 0.9, 3.0, 1.1, 3.2][..]),
26//!     ("z", &[0.0_f64, 1.0, 0.2, 0.8, 0.1, 0.9, 0.3, 0.7][..]),
27//! ])
28//! .unwrap();
29//! let dag = Dag::from_named_edges(&schema, &[("z", "t"), ("z", "y"), ("t", "y")]).unwrap();
30//! let q = AverageEffectQuery::binary_ate(schema.id_of("t").unwrap(), schema.id_of("y").unwrap());
31//! let result = CausalAnalysis::builder()
32//!     .data(data)
33//!     .graph(dag)
34//!     .query(q)
35//!     .refute(RefuteSuite::None)
36//!     .bootstrap_replicates(0)
37//!     .build()
38//!     .unwrap()
39//!     .run(&ExecutionContext::for_tests(1))
40//!     .unwrap();
41//! assert!(result.effect().is_finite());
42//! ```
43//!
44//! Prefer [`prelude`] for day-1 imports. Component crates remain available for stage-specific work.
45//!
46//! SPDX-License-Identifier: MIT OR Apache-2.0
47
48#![forbid(unsafe_code)]
49#![deny(missing_docs)]
50#![warn(clippy::missing_errors_doc, clippy::missing_panics_doc)]
51
52pub mod analysis;
53pub mod callback_plan;
54pub mod design;
55pub mod discovery;
56pub mod discovery_defaults;
57pub mod error;
58pub mod gcm;
59pub mod inference;
60pub mod options;
61pub mod planner;
62pub mod prelude;
63pub mod result;
64pub mod review;
65pub mod state;
66pub mod strategy_table;
67
68pub mod estimate;
69pub mod graph;
70pub mod identify;
71pub mod io;
72pub mod query;
73pub mod validate;
74
75// --- Day-1 crate-root surface (stage depth lives under modules) ---
76pub use analysis::{
77    AnalysisStageEvent, BatchAnalysis, CausalAnalysis, CausalAnalysisBuilder, ComputeBudget,
78    LatencyMode, PreparedAnalysis, RdConfig, RefuteSuite, StageResultSink,
79};
80#[allow(deprecated)]
81pub use error::AnalysisError;
82pub use error::CausalError;
83pub use estimate::{CausalPosterior, EffectEstimate, EstimatorId, IdentifierId};
84pub use graph::{Dag, DenseNodeId, TemporalDag};
85pub use inference::{BayesianConfig, InferenceMode};
86pub use options::{DiscoveryAccept, FdrControl};
87pub use planner::{CompiledAnalysis, GraphInput};
88pub use query::*;
89pub use result::CausalAnalysisResult;
90
91// Strategy helpers and peer APIs: use `antecedent::estimate`, `antecedent::discovery`,
92// `antecedent::gcm`, `antecedent::io`, `antecedent::design`, `antecedent::state`, `antecedent::graph`,
93// `antecedent::identify`, `antecedent::review`, `antecedent::validate`, `antecedent::planner`.
94
95#[cfg(test)]
96#[allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
97mod tests {
98    use std::sync::Arc;
99
100    use antecedent_core::{
101        AverageEffectQuery, CausalQuery, CausalSchemaBuilder, ExecutionContext, Intervention,
102        InterventionalDistributionQuery, MeasurementSpec, PathSpecificEffectQuery, RoleHint,
103        SmallRoleSet, Value, ValueType, VariableId,
104    };
105    use antecedent_data::{
106        Float64Column, OwnedColumn, OwnedColumnarStorage, TabularData, ValidityBitmap,
107    };
108    use antecedent_graph::{Dag, DenseNodeId};
109    use antecedent_kernels::standard_normal;
110
111    use super::*;
112    use crate::validate::PredictiveCheckKind;
113
114    fn scm() -> (TabularData, Dag, AverageEffectQuery) {
115        let n = 200usize;
116        let mut b = CausalSchemaBuilder::new();
117        b.add_variable(
118            "t",
119            ValueType::Continuous,
120            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
121            None,
122            None,
123            MeasurementSpec::default(),
124        )
125        .unwrap();
126        b.add_variable(
127            "y",
128            ValueType::Continuous,
129            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
130            None,
131            None,
132            MeasurementSpec::default(),
133        )
134        .unwrap();
135        b.add_variable(
136            "z",
137            ValueType::Continuous,
138            SmallRoleSet::from_hint(RoleHint::Context),
139            None,
140            None,
141            MeasurementSpec::default(),
142        )
143        .unwrap();
144        let schema = b.build().unwrap();
145        let z: Vec<f64> = (0..n).map(|i| (i as f64) / n as f64).collect();
146        let t: Vec<f64> = (0..n).map(|i| if z[i] > 0.5 { 1.0 } else { 0.0 }).collect();
147        let y: Vec<f64> = (0..n).map(|i| 1.0 + 2.0 * t[i] + 3.0 * z[i]).collect();
148        let cols = vec![
149            OwnedColumn::Float64(
150                Float64Column::new(
151                    VariableId::from_raw(0),
152                    Arc::from(t),
153                    ValidityBitmap::all_valid(n),
154                )
155                .unwrap(),
156            ),
157            OwnedColumn::Float64(
158                Float64Column::new(
159                    VariableId::from_raw(1),
160                    Arc::from(y),
161                    ValidityBitmap::all_valid(n),
162                )
163                .unwrap(),
164            ),
165            OwnedColumn::Float64(
166                Float64Column::new(
167                    VariableId::from_raw(2),
168                    Arc::from(z),
169                    ValidityBitmap::all_valid(n),
170                )
171                .unwrap(),
172            ),
173        ];
174        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
175        let mut dag = Dag::with_variables(3);
176        // z -> t, z -> y, t -> y
177        let z_id = DenseNodeId::from_raw(2);
178        let t_id = DenseNodeId::from_raw(0);
179        let y_id = DenseNodeId::from_raw(1);
180        dag.insert_directed(z_id, t_id).unwrap();
181        dag.insert_directed(z_id, y_id).unwrap();
182        dag.insert_directed(t_id, y_id).unwrap();
183        let query =
184            AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
185        (TabularData::new(storage), dag, query)
186    }
187
188    #[test]
189    fn end_to_end_ate() {
190        let (data, graph, query) = scm();
191        let analysis = CausalAnalysis::builder()
192            .data(data)
193            .graph(graph)
194            .query(query)
195            .refute(RefuteSuite::PlaceboAndRcc)
196            .bootstrap_replicates(30)
197            .build()
198            .unwrap();
199        let ctx = ExecutionContext::for_tests(3);
200        let result = analysis.run(&ctx).unwrap();
201        assert!((result.estimate.ate - 2.0).abs() < 1e-6);
202        assert!(result.distribution.is_none());
203        assert_eq!(result.refutations.len(), 2);
204        assert!(!result.provenance.is_empty());
205        assert!(!result.identification.derivation.steps.is_empty());
206
207        let trace = result.analysis_trace_wire();
208        assert_eq!(&*trace.method, "backdoor.adjustment");
209        assert!(!trace.assumptions.is_empty());
210        assert!(!trace.derivation.is_empty());
211        let bytes = antecedent_io::to_cbor(&trace).unwrap();
212        let round: antecedent_io::AnalysisTraceWire = antecedent_io::from_cbor(&bytes).unwrap();
213        assert_eq!(round.method, trace.method);
214    }
215
216    #[test]
217    fn bayesian_ate_attaches_prior_and_posterior_predictive() {
218        let (data, graph, query) = scm();
219        let analysis = CausalAnalysis::builder()
220            .data(data)
221            .graph(graph)
222            .query(query)
223            .inference(InferenceMode::Bayesian(BayesianConfig::conjugate().n_draws(64)))
224            .refute(RefuteSuite::PlaceboAndRcc)
225            .build()
226            .unwrap();
227        let ctx = ExecutionContext::for_tests(1);
228        let result = analysis.run(&ctx).unwrap();
229        assert!(result.posterior.is_some());
230        assert!(
231            result.predictive_checks.iter().any(|c| c.kind == PredictiveCheckKind::Prior),
232            "expected prior predictive check on Bayesian facade path"
233        );
234        assert!(
235            result.predictive_checks.iter().any(|c| c.kind == PredictiveCheckKind::Posterior),
236            "expected posterior predictive check on Bayesian facade path"
237        );
238        let prior =
239            result.predictive_checks.iter().find(|c| c.kind == PredictiveCheckKind::Prior).unwrap();
240        assert!(prior.p_value.is_finite());
241        assert!(prior.predictive_sd.is_finite());
242        let post_ppc = result
243            .predictive_checks
244            .iter()
245            .find(|c| c.kind == PredictiveCheckKind::Posterior)
246            .unwrap();
247        assert!(post_ppc.p_value.is_finite());
248        assert!(post_ppc.predictive_sd.is_finite());
249        assert!(result.refutations.iter().any(|r| r.refuter.as_ref() == "prior_predictive"));
250        assert!(result.refutations.iter().any(|r| r.refuter.as_ref() == "posterior_predictive"));
251    }
252
253    #[test]
254    fn bayesian_exact_dag_posterior_effect_envelope() {
255        let (data, _graph, query) = scm();
256        let analysis = CausalAnalysis::builder()
257            .data(data)
258            .discover_exact_dag_posterior()
259            .query(query)
260            .inference(InferenceMode::Bayesian(
261                BayesianConfig::conjugate().n_draws(80).prior_scale(100.0),
262            ))
263            .refute(RefuteSuite::None)
264            .build()
265            .unwrap();
266        let ctx = ExecutionContext::for_tests(1);
267        let result = analysis.run(&ctx).unwrap();
268        let post = result.posterior.expect("mixture posterior");
269        assert!((0.0..=1.0).contains(&post.unidentified_mass));
270        let eq = post.effect_column().unwrap();
271        assert!(post.summaries.mean[eq].is_finite());
272        assert!(post.summaries.sd[eq].is_finite());
273        assert!(post.draws.n_draws > 0);
274    }
275
276    #[test]
277    fn graph_posterior_discovery_rejects_frequentist() {
278        let (data, _graph, query) = scm();
279        let err = CausalAnalysis::builder()
280            .data(data)
281            .discover_exact_dag_posterior()
282            .query(query)
283            .inference(InferenceMode::Frequentist)
284            .refute(RefuteSuite::None)
285            .build()
286            .unwrap()
287            .compile(&ExecutionContext::for_tests(1))
288            .unwrap_err();
289        let msg = err.to_string();
290        assert!(
291            msg.contains("Bayesian") || msg.contains("graph-posterior"),
292            "unexpected error: {msg}"
293        );
294    }
295
296    #[test]
297    fn end_to_end_interventional_distribution() {
298        // Discrete confounding table matching functional_distribution unit test.
299        let mut b = CausalSchemaBuilder::new();
300        for name in ["t", "y", "z"] {
301            b.add_variable(
302                name,
303                ValueType::Continuous,
304                SmallRoleSet::from_hint(RoleHint::Context),
305                None,
306                None,
307                MeasurementSpec::default(),
308            )
309            .unwrap();
310        }
311        let schema = b.build().unwrap();
312        let combos = [
313            (0.0, 0.0, 0.0, 21),
314            (0.0, 0.0, 1.0, 9),
315            (0.0, 1.0, 0.0, 4),
316            (0.0, 1.0, 1.0, 16),
317            (1.0, 0.0, 0.0, 12),
318            (1.0, 0.0, 1.0, 3),
319            (1.0, 1.0, 0.0, 14),
320            (1.0, 1.0, 1.0, 21),
321        ];
322        let mut t_vals = Vec::new();
323        let mut y_vals = Vec::new();
324        let mut z_vals = Vec::new();
325        for (z, t, y, count) in combos {
326            for _ in 0..count {
327                z_vals.push(z);
328                t_vals.push(t);
329                y_vals.push(y);
330            }
331        }
332        let n = t_vals.len();
333        let cols = vec![
334            OwnedColumn::Float64(
335                Float64Column::new(
336                    VariableId::from_raw(0),
337                    Arc::from(t_vals),
338                    ValidityBitmap::all_valid(n),
339                )
340                .unwrap(),
341            ),
342            OwnedColumn::Float64(
343                Float64Column::new(
344                    VariableId::from_raw(1),
345                    Arc::from(y_vals),
346                    ValidityBitmap::all_valid(n),
347                )
348                .unwrap(),
349            ),
350            OwnedColumn::Float64(
351                Float64Column::new(
352                    VariableId::from_raw(2),
353                    Arc::from(z_vals),
354                    ValidityBitmap::all_valid(n),
355                )
356                .unwrap(),
357            ),
358        ];
359        let data =
360            TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
361        let mut dag = Dag::with_variables(3);
362        dag.insert_directed(DenseNodeId::from_raw(2), DenseNodeId::from_raw(0)).unwrap();
363        dag.insert_directed(DenseNodeId::from_raw(2), DenseNodeId::from_raw(1)).unwrap();
364        dag.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
365        let query = InterventionalDistributionQuery::new(
366            VariableId::from_raw(1),
367            [Intervention::set(VariableId::from_raw(0), Value::f64(1.0))],
368        );
369        let analysis = CausalAnalysis::builder()
370            .data(data)
371            .graph(dag)
372            .query(CausalQuery::Distribution(query))
373            .identifier(IdentifierId::GeneralId)
374            .estimator(EstimatorId::FunctionalDistribution)
375            .build()
376            .unwrap();
377        let result = analysis.run(&ExecutionContext::for_tests(0)).unwrap();
378        let dist = result.distribution.expect("distribution payload");
379        assert!((dist.mean - 0.7).abs() < 0.05, "mean={}", dist.mean);
380        assert!(result.estimate.ate.is_finite());
381    }
382
383    #[test]
384    fn end_to_end_path_specific_natural_effect() {
385        // Discrete chain T → M → Y (no direct edge): path via M is the total effect.
386        let mut b = CausalSchemaBuilder::new();
387        for name in ["t", "m", "y"] {
388            b.add_variable(
389                name,
390                ValueType::Continuous,
391                SmallRoleSet::from_hint(RoleHint::Context),
392                None,
393                None,
394                MeasurementSpec::default(),
395            )
396            .unwrap();
397        }
398        let schema = b.build().unwrap();
399        // Deterministic: T~Bernoulli(0.5), M=T, Y=M  → ATE = 1
400        let mut t_vals = Vec::new();
401        let mut m_vals = Vec::new();
402        let mut y_vals = Vec::new();
403        for t in [0.0, 1.0] {
404            for _ in 0..50 {
405                t_vals.push(t);
406                m_vals.push(t);
407                y_vals.push(t);
408            }
409        }
410        let n = t_vals.len();
411        let cols = vec![
412            OwnedColumn::Float64(
413                Float64Column::new(
414                    VariableId::from_raw(0),
415                    Arc::from(t_vals),
416                    ValidityBitmap::all_valid(n),
417                )
418                .unwrap(),
419            ),
420            OwnedColumn::Float64(
421                Float64Column::new(
422                    VariableId::from_raw(1),
423                    Arc::from(m_vals),
424                    ValidityBitmap::all_valid(n),
425                )
426                .unwrap(),
427            ),
428            OwnedColumn::Float64(
429                Float64Column::new(
430                    VariableId::from_raw(2),
431                    Arc::from(y_vals),
432                    ValidityBitmap::all_valid(n),
433                )
434                .unwrap(),
435            ),
436        ];
437        let data =
438            TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
439        let mut dag = Dag::with_variables(3);
440        dag.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
441        dag.insert_directed(DenseNodeId::from_raw(1), DenseNodeId::from_raw(2)).unwrap();
442        let query =
443            PathSpecificEffectQuery::binary(VariableId::from_raw(0), VariableId::from_raw(2))
444                .with_path_nodes([VariableId::from_raw(1)]);
445        let analysis = CausalAnalysis::builder()
446            .data(data)
447            .graph(dag)
448            .query(CausalQuery::PathSpecific(query))
449            .identifier(IdentifierId::PathSpecificNatural)
450            .estimator(EstimatorId::FunctionalEffect)
451            .build()
452            .unwrap();
453        let result = analysis.run(&ExecutionContext::for_tests(0)).unwrap();
454        assert!((result.estimate.ate - 1.0).abs() < 0.05, "ate={}", result.estimate.ate);
455        assert_eq!(result.estimand.method.as_ref(), "path_specific.natural");
456    }
457
458    /// Confounded SCM: `Z ~ N(0,1)`, `T ~ Bernoulli(logit(-0.5 + Z))`, `Y = 2T + Z + noise`.
459    /// True ATE = 2; OLS-on-observables is biased here unless `Z` is adjusted for, so this
460    /// exercises the propensity path independent of the linear-adjustment default.
461    fn confounded_scm(n: usize, seed: u64) -> (TabularData, Dag, AverageEffectQuery) {
462        let mut rng = ExecutionContext::for_tests(seed).rng.stream(0x1234_u64);
463        let mut z = vec![0.0; n];
464        let mut t = vec![0.0; n];
465        let mut y = vec![0.0; n];
466        for i in 0..n {
467            let zi = standard_normal(&mut rng);
468            let logit = -0.5 + zi;
469            let p = 1.0 / (1.0 + (-logit).exp());
470            let ti = if rng.next_f64() < p { 1.0 } else { 0.0 };
471            let noise = standard_normal(&mut rng) * 0.5;
472            z[i] = zi;
473            t[i] = ti;
474            y[i] = 2.0 * ti + zi + noise;
475        }
476        let mut b = CausalSchemaBuilder::new();
477        b.add_variable(
478            "t",
479            ValueType::Continuous,
480            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
481            None,
482            None,
483            MeasurementSpec::default(),
484        )
485        .unwrap();
486        b.add_variable(
487            "y",
488            ValueType::Continuous,
489            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
490            None,
491            None,
492            MeasurementSpec::default(),
493        )
494        .unwrap();
495        b.add_variable(
496            "z",
497            ValueType::Continuous,
498            SmallRoleSet::from_hint(RoleHint::Context),
499            None,
500            None,
501            MeasurementSpec::default(),
502        )
503        .unwrap();
504        let schema = b.build().unwrap();
505        let cols = vec![
506            OwnedColumn::Float64(
507                Float64Column::new(
508                    VariableId::from_raw(0),
509                    Arc::from(t),
510                    ValidityBitmap::all_valid(n),
511                )
512                .unwrap(),
513            ),
514            OwnedColumn::Float64(
515                Float64Column::new(
516                    VariableId::from_raw(1),
517                    Arc::from(y),
518                    ValidityBitmap::all_valid(n),
519                )
520                .unwrap(),
521            ),
522            OwnedColumn::Float64(
523                Float64Column::new(
524                    VariableId::from_raw(2),
525                    Arc::from(z),
526                    ValidityBitmap::all_valid(n),
527                )
528                .unwrap(),
529            ),
530        ];
531        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
532        let mut dag = Dag::with_variables(3);
533        let z_id = DenseNodeId::from_raw(2);
534        let t_id = DenseNodeId::from_raw(0);
535        let y_id = DenseNodeId::from_raw(1);
536        dag.insert_directed(z_id, t_id).unwrap();
537        dag.insert_directed(z_id, y_id).unwrap();
538        dag.insert_directed(t_id, y_id).unwrap();
539        let query =
540            AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
541        (TabularData::new(storage), dag, query)
542    }
543
544    #[test]
545    fn end_to_end_propensity_weighting_recovers_confounded_effect() {
546        // Dual of python/tests/test_analyze_ate_estimate.py
547        // (`test_analyze_propensity_weighting_recovers_ate_and_overlap`): true ATE=2;
548        // Rust band |ate−2|<0.3; shared cross-language floor is 0.4.
549        let (data, graph, query) = confounded_scm(800, 1);
550        let analysis = CausalAnalysis::builder()
551            .data(data)
552            .graph(graph)
553            .query(query)
554            .identifier("backdoor.adjustment")
555            .estimator("propensity.weighting")
556            .bootstrap_replicates(30)
557            .build()
558            .unwrap();
559        let ctx = ExecutionContext::for_tests(11);
560        let result = analysis.run(&ctx).unwrap();
561        assert!((result.estimate.ate - 2.0).abs() < 0.3, "ate={}", result.estimate.ate);
562        // Placebo/RCC refuters are hardwired to LinearAdjustmentAte and are skipped for
563        // every non-default estimator (see `CausalAnalysis::execute_static`).
564        assert!(result.refutations.is_empty());
565        assert_eq!(result.logical_plan.estimator.as_deref(), Some("propensity.weighting"));
566    }
567
568    /// `Z ∈ {0,1} → T → Y` with `U` confounding `T`–`Y` (unobserved, not in the graph).
569    /// True structural effect = 2.0.
570    fn iv_scm(n: usize, seed: u64) -> (TabularData, Dag, AverageEffectQuery) {
571        let mut rng = ExecutionContext::for_tests(seed).rng.stream(0x1E71_u64);
572        let mut z = vec![0.0; n];
573        let mut t = vec![0.0; n];
574        let mut y = vec![0.0; n];
575        for i in 0..n {
576            let zi = (i % 2) as f64;
577            let u = standard_normal(&mut rng);
578            let ti = 0.5 * zi + u + 0.1 * standard_normal(&mut rng);
579            let yi = 2.0 * ti + u + 0.1 * standard_normal(&mut rng);
580            z[i] = zi;
581            t[i] = ti;
582            y[i] = yi;
583        }
584        let mut b = CausalSchemaBuilder::new();
585        b.add_variable(
586            "t",
587            ValueType::Continuous,
588            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
589            None,
590            None,
591            MeasurementSpec::default(),
592        )
593        .unwrap();
594        b.add_variable(
595            "y",
596            ValueType::Continuous,
597            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
598            None,
599            None,
600            MeasurementSpec::default(),
601        )
602        .unwrap();
603        b.add_variable(
604            "z",
605            ValueType::Continuous,
606            SmallRoleSet::from_hint(RoleHint::Context),
607            None,
608            None,
609            MeasurementSpec::default(),
610        )
611        .unwrap();
612        let schema = b.build().unwrap();
613        let cols = vec![
614            OwnedColumn::Float64(
615                Float64Column::new(
616                    VariableId::from_raw(0),
617                    Arc::from(t),
618                    ValidityBitmap::all_valid(n),
619                )
620                .unwrap(),
621            ),
622            OwnedColumn::Float64(
623                Float64Column::new(
624                    VariableId::from_raw(1),
625                    Arc::from(y),
626                    ValidityBitmap::all_valid(n),
627                )
628                .unwrap(),
629            ),
630            OwnedColumn::Float64(
631                Float64Column::new(
632                    VariableId::from_raw(2),
633                    Arc::from(z),
634                    ValidityBitmap::all_valid(n),
635                )
636                .unwrap(),
637            ),
638        ];
639        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
640        let mut dag = Dag::with_variables(3);
641        let z_id = DenseNodeId::from_raw(2);
642        let t_id = DenseNodeId::from_raw(0);
643        let y_id = DenseNodeId::from_raw(1);
644        dag.insert_directed(z_id, t_id).unwrap();
645        dag.insert_directed(t_id, y_id).unwrap();
646        let query = AverageEffectQuery::with_levels(
647            VariableId::from_raw(0),
648            VariableId::from_raw(1),
649            0.0,
650            1.0,
651        );
652        (TabularData::new(storage), dag, query)
653    }
654
655    #[test]
656    fn end_to_end_iv_two_stage_least_squares() {
657        let (data, graph, query) = iv_scm(4000, 5);
658        let analysis = CausalAnalysis::builder()
659            .data(data)
660            .graph(graph)
661            .query(query)
662            .identifier("iv")
663            .estimator("iv.2sls")
664            .bootstrap_replicates(30)
665            .build()
666            .unwrap();
667        let ctx = ExecutionContext::for_tests(21);
668        let result = analysis.run(&ctx).unwrap();
669        assert!((result.estimate.ate - 2.0).abs() < 0.6, "ate={}", result.estimate.ate);
670        assert!(result.refutations.is_empty());
671    }
672
673    #[test]
674    fn end_to_end_temporal_effect() {
675        use antecedent_core::{Lag, TemporalEffectQuery, TemporalPolicy};
676        use antecedent_data::{SamplingRegularity, TimeIndex, TimeSeriesData};
677        use antecedent_graph::{TemporalDag, ensure_lagged};
678
679        let n = 250usize;
680        let mut b = CausalSchemaBuilder::new();
681        b.add_variable(
682            "pressure",
683            ValueType::Continuous,
684            SmallRoleSet::from_hint(RoleHint::TreatmentCandidate),
685            None,
686            None,
687            MeasurementSpec::default(),
688        )
689        .unwrap();
690        b.add_variable(
691            "defect",
692            ValueType::Continuous,
693            SmallRoleSet::from_hint(RoleHint::OutcomeCandidate),
694            None,
695            None,
696            MeasurementSpec::default(),
697        )
698        .unwrap();
699        let schema = b.build().unwrap();
700        let mut x = vec![0.0; n];
701        let mut y = vec![0.0; n];
702        for t in 1..n {
703            x[t] = ((t as f64) * 0.05).sin();
704            y[t] = 0.75 * x[t - 1];
705        }
706        let cols = vec![
707            OwnedColumn::Float64(
708                Float64Column::new(
709                    VariableId::from_raw(0),
710                    Arc::from(x),
711                    ValidityBitmap::all_valid(n),
712                )
713                .unwrap(),
714            ),
715            OwnedColumn::Float64(
716                Float64Column::new(
717                    VariableId::from_raw(1),
718                    Arc::from(y),
719                    ValidityBitmap::all_valid(n),
720                )
721                .unwrap(),
722            ),
723        ];
724        let storage = OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap();
725        let series = TimeSeriesData::try_new(
726            storage,
727            TimeIndex { regularity: SamplingRegularity::Regular { interval_ns: 1 }, length: n },
728        )
729        .unwrap();
730        let mut g = TemporalDag::empty();
731        let x1 = ensure_lagged(&mut g, VariableId::from_raw(0), Lag::from_raw(1)).unwrap();
732        let y0 = ensure_lagged(&mut g, VariableId::from_raw(1), Lag::CONTEMPORANEOUS).unwrap();
733        g.insert_directed(x1, y0).unwrap();
734        let q = TemporalEffectQuery::pulse(VariableId::from_raw(0), VariableId::from_raw(1), 1.0)
735            .with_policy(TemporalPolicy::pulse(-1))
736            .with_horizon_steps(1);
737        let analysis = CausalAnalysis::builder()
738            .series(series)
739            .temporal_graph(g)
740            .temporal_query(q)
741            .bootstrap_replicates(0)
742            .build()
743            .unwrap();
744        let ctx = ExecutionContext::for_tests(7);
745        let result = analysis.run(&ctx).unwrap();
746        assert!((result.estimate.ate - 0.75).abs() < 0.08, "ate={}", result.estimate.ate);
747        assert_eq!(&*result.logical_plan.plan_id, "temporal_effect");
748        assert!(result.physical_plan.estimated_peak_memory_bytes.is_some());
749        assert!(result.physical_plan.estimated_copy_bytes.is_some());
750        assert!(!result.physical_plan.task_schedule.is_empty());
751        assert!(!result.physical_plan.materializations.is_empty());
752
753        let compiled = analysis.compile(&ctx).unwrap();
754        match compiled {
755            CompiledAnalysis::Ready(plan) => {
756                assert!(plan.temporal_graph().is_some());
757                assert_eq!(plan.record.batch_size, Some(250));
758            }
759            CompiledAnalysis::ReviewRequired(_)
760            | CompiledAnalysis::ReviewRequiredCpdag(_)
761            | CompiledAnalysis::ReviewRequiredStaticCpdag(_)
762            | CompiledAnalysis::ReviewRequiredStaticDag(_)
763            | CompiledAnalysis::ReviewRequiredPag(_)
764            | CompiledAnalysis::ReviewRequiredStaticPag(_) => {
765                panic!("expected Ready")
766            }
767        }
768    }
769}