#![allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
use std::sync::Arc;
use antecedent::{CausalAnalysis, LatencyMode, RefuteSuite};
use antecedent_core::{
AverageEffectQuery, CausalRng, CausalSchemaBuilder, ExecutionContext, MeasurementSpec,
RoleHint, SmallRoleSet, ValueType, VariableId,
};
use antecedent_data::{
Float64Column, OwnedColumn, OwnedColumnarStorage, TabularData, ValidityBitmap,
};
use antecedent_graph::{Dag, DenseNodeId};
fn confounded_scm(n: usize, seed: u64) -> (TabularData, Dag, AverageEffectQuery) {
let mut rng = CausalRng::from_seed(seed);
let mut t = Vec::with_capacity(n);
let mut y = Vec::with_capacity(n);
let mut z = Vec::with_capacity(n);
for _ in 0..n {
let u1 = rng.next_f64().max(1e-12);
let u2 = rng.next_f64();
let zi = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
let logit = -0.4 + 0.9 * zi;
let p = 1.0 / (1.0 + (-logit).exp());
let ti = if rng.next_f64() < p { 1.0 } else { 0.0 };
let e = (-2.0 * rng.next_f64().max(1e-12).ln()).sqrt()
* (2.0 * std::f64::consts::PI * rng.next_f64()).cos()
* 0.4;
z.push(zi);
t.push(ti);
y.push(2.0 * ti + zi + e);
}
let mut b = CausalSchemaBuilder::new();
for (name, role) in [
("t", RoleHint::TreatmentCandidate),
("y", RoleHint::OutcomeCandidate),
("z", RoleHint::Context),
] {
b.add_variable(
name,
ValueType::Continuous,
SmallRoleSet::from_hint(role),
None,
None,
MeasurementSpec::default(),
)
.unwrap();
}
let schema = b.build().unwrap();
let cols = vec![
OwnedColumn::Float64(
Float64Column::new(VariableId::from_raw(0), Arc::from(t), ValidityBitmap::all_valid(n))
.unwrap(),
),
OwnedColumn::Float64(
Float64Column::new(VariableId::from_raw(1), Arc::from(y), ValidityBitmap::all_valid(n))
.unwrap(),
),
OwnedColumn::Float64(
Float64Column::new(VariableId::from_raw(2), Arc::from(z), ValidityBitmap::all_valid(n))
.unwrap(),
),
];
let data = TabularData::new(OwnedColumnarStorage::try_new(schema, cols, None, None).unwrap());
let mut dag = Dag::with_variables(3);
dag.insert_directed(DenseNodeId::from_raw(2), DenseNodeId::from_raw(0)).unwrap();
dag.insert_directed(DenseNodeId::from_raw(2), DenseNodeId::from_raw(1)).unwrap();
dag.insert_directed(DenseNodeId::from_raw(0), DenseNodeId::from_raw(1)).unwrap();
let query = AverageEffectQuery::binary_ate(VariableId::from_raw(0), VariableId::from_raw(1));
(data, dag, query)
}
#[test]
fn prepared_refute_second_click_preserves_ate() {
let (data, dag, query) = confounded_scm(400, 11);
let ctx = ExecutionContext::for_tests(5);
let prepared = CausalAnalysis::builder()
.data(data.clone())
.graph(dag.clone())
.query(query.clone())
.latency_mode(LatencyMode::Interactive)
.refute(RefuteSuite::None)
.bootstrap_replicates(0)
.build()
.unwrap()
.prepare(&ctx)
.unwrap();
let first = prepared.estimate(&data, &ctx).unwrap();
assert!(first.refutations.is_empty());
let ate = first.estimate.ate;
let second = prepared.refute(&first, &data, RefuteSuite::PlaceboAndRcc, &ctx).unwrap();
assert!((second.estimate.ate - ate).abs() < 1e-15);
assert!(!second.refutations.is_empty());
assert!(second.diagnostics.iter().any(|d| d.code.as_ref() == "exec.refute.second_click"));
let one_shot = CausalAnalysis::builder()
.data(data)
.graph(dag)
.query(query)
.refute(RefuteSuite::PlaceboAndRcc)
.bootstrap_replicates(0)
.build()
.unwrap()
.run(&ctx)
.unwrap();
assert_eq!(second.refutations.len(), one_shot.refutations.len());
}