1use std::sync::Arc;
6
7use antecedent_core::{
8 AverageEffectQuery, ExecutionContext, KernelPolicy, TemporalEffectQuery, VariableId,
9};
10use antecedent_data::TemporalIndexer;
11use antecedent_data::{
12 ColumnView, DiscoveryEstimationSplit, PanelData, TableView, TabularData, TimeIndex,
13 TimeSeriesData, ValidityBitmap,
14};
15
16use crate::panel_slice::PanelSliceTemplate;
17use antecedent_estimate::{
18 EffectEstimate, EstimationWorkspace, IpwTarget, LinearAdjustmentAte, OverlapPolicy,
19 OverlapReport, TemporalLinearAdjustment,
20};
21use antecedent_identify::IdentifiedEstimand;
22use antecedent_kernels::erfc;
23use antecedent_stats::{
24 FaerBackend, GlmOptions, PropensityFit, PropensityWorkspace, fit_propensity_diagnostic,
25};
26
27use crate::error::ValidationError;
28
29#[derive(Clone, Copy, Debug)]
31pub struct TemporalRefitContext<'a> {
32 pub indexer: &'a TemporalIndexer,
34 pub temporal_query: &'a TemporalEffectQuery,
36 pub split: Option<&'a DiscoveryEstimationSplit>,
38 pub kernel_policy: &'a KernelPolicy,
40 pub time_index: Option<&'a TimeIndex>,
42 pub panel: Option<&'a PanelData>,
44}
45
46impl TemporalRefitContext<'_> {
47 #[must_use]
49 pub fn is_panel(&self) -> bool {
50 self.panel.is_some()
51 }
52}
53
54#[derive(Clone, Debug)]
56#[non_exhaustive]
57pub struct RefutationReport {
58 pub refuter: Arc<str>,
60 pub original_ate: f64,
62 pub refuted_ate: f64,
64 pub comparison: f64,
68 pub informative: bool,
70 pub passed: bool,
72 pub failure_condition: Option<Arc<str>>,
74 pub replicates: u32,
76}
77
78impl RefutationReport {
79 #[must_use]
81 #[allow(clippy::too_many_arguments)]
82 pub fn new(
83 refuter: impl Into<Arc<str>>,
84 original_ate: f64,
85 refuted_ate: f64,
86 comparison: f64,
87 informative: bool,
88 passed: bool,
89 failure_condition: Option<Arc<str>>,
90 replicates: u32,
91 ) -> Self {
92 Self {
93 refuter: refuter.into(),
94 original_ate,
95 refuted_ate,
96 comparison,
97 informative,
98 passed,
99 failure_condition,
100 replicates,
101 }
102 }
103}
104
105#[derive(Clone, Copy, Debug)]
107pub struct RefutationProblem<'a> {
108 pub data: &'a TabularData,
110 pub estimand: &'a IdentifiedEstimand,
112 pub query: &'a AverageEffectQuery,
114 pub original: &'a EffectEstimate,
116 pub estimator: Option<&'a str>,
118 pub temporal: Option<TemporalRefitContext<'a>>,
120 pub(crate) panel_slices: Option<&'a PanelSliceTemplate<'a>>,
124}
125
126impl<'a> RefutationProblem<'a> {
127 #[must_use]
130 pub fn new(
131 data: &'a TabularData,
132 estimand: &'a IdentifiedEstimand,
133 query: &'a AverageEffectQuery,
134 original: &'a EffectEstimate,
135 estimator: Option<&'a str>,
136 temporal: Option<TemporalRefitContext<'a>>,
137 ) -> Self {
138 Self { data, estimand, query, original, estimator, temporal, panel_slices: None }
139 }
140
141 pub(crate) fn with_panel_slices(mut self, slices: Option<&'a PanelSliceTemplate<'a>>) -> Self {
142 self.panel_slices = slices;
143 self
144 }
145}
146
147impl RefutationProblem<'_> {
148 #[must_use]
150 pub fn treatment(&self) -> VariableId {
151 self.query.treatment
152 }
153
154 #[must_use]
156 pub fn outcome(&self) -> VariableId {
157 self.query.outcome
158 }
159}
160
161pub(crate) fn with_replaced_float(
163 data: &TabularData,
164 id: VariableId,
165 values: Arc<[f64]>,
166) -> Result<TabularData, ValidationError> {
167 data.with_replaced_float(id, values).map_err(ValidationError::from)
168}
169
170pub(crate) fn with_extra_float(
172 data: &TabularData,
173 name: &str,
174 values: Arc<[f64]>,
175) -> Result<(TabularData, VariableId), ValidationError> {
176 data.with_appended_float(name, values).map_err(ValidationError::from)
177}
178
179pub(crate) fn fit_once(
181 estimator: &LinearAdjustmentAte,
182 data: &TabularData,
183 estimand: &IdentifiedEstimand,
184 query: &AverageEffectQuery,
185 workspace: &mut EstimationWorkspace,
186 ctx: &ExecutionContext,
187) -> Result<EffectEstimate, ValidationError> {
188 let prep = estimator.prepare(data, estimand, query).map_err(ValidationError::from)?;
189 estimator
190 .fit(&prep, workspace, ctx, antecedent_core::AssumptionSet::new())
191 .map_err(ValidationError::from)
192}
193
194pub(crate) fn refit_effect(
206 problem: &RefutationProblem<'_>,
207 data: &TabularData,
208 estimand: &IdentifiedEstimand,
209 extra_contemporaneous: &[VariableId],
210 caller_estimator: &LinearAdjustmentAte,
211 workspace: &mut EstimationWorkspace,
212 ctx: &ExecutionContext,
213) -> Result<EffectEstimate, ValidationError> {
214 let Some(temporal) = problem.temporal else {
215 let est = forced_refit_estimator(caller_estimator);
216 return fit_once(&est, data, estimand, problem.query, workspace, ctx);
217 };
218 let estimator =
219 TemporalLinearAdjustment::new().with_inner(forced_refit_estimator(caller_estimator));
220 if let Some(panel) = temporal.panel {
221 let owned;
222 let slices = if let Some(plan) = problem.panel_slices {
223 plan
224 } else {
225 owned = PanelSliceTemplate::from_panel(panel, problem.data)?;
226 &owned
227 };
228 let rebuilt = slices.apply_stacked(data)?;
229 let prep = if extra_contemporaneous.is_empty() {
230 let (prep, _cluster_ids, _panel_times) = estimator
231 .prepare_panel(
232 &rebuilt,
233 estimand,
234 temporal.temporal_query,
235 temporal.indexer,
236 temporal.split,
237 temporal.kernel_policy,
238 )
239 .map_err(ValidationError::from)?;
240 prep
241 } else {
242 panel_prepare_with_extras(
243 &estimator,
244 &rebuilt,
245 estimand,
246 &temporal,
247 extra_contemporaneous,
248 )?
249 };
250 return estimator
251 .fit(&prep, workspace, ctx, antecedent_core::AssumptionSet::new())
252 .map_err(ValidationError::from);
253 }
254 let time_index = temporal.time_index.ok_or(ValidationError::NotApplicable {
255 message: "temporal series refit requires time_index",
256 })?;
257 let series = TimeSeriesData::try_new(data.storage().clone(), time_index.clone())
258 .map_err(ValidationError::from)?;
259 let prep = estimator
260 .prepare_with_extras(
261 &series,
262 estimand,
263 temporal.temporal_query,
264 temporal.indexer,
265 temporal.split,
266 temporal.kernel_policy,
267 extra_contemporaneous,
268 )
269 .map_err(ValidationError::from)?;
270 estimator
271 .fit(&prep, workspace, ctx, antecedent_core::AssumptionSet::new())
272 .map_err(ValidationError::from)
273}
274
275fn panel_prepare_with_extras(
276 estimator: &TemporalLinearAdjustment,
277 panel: &PanelData,
278 estimand: &IdentifiedEstimand,
279 temporal: &TemporalRefitContext<'_>,
280 extra: &[VariableId],
281) -> Result<antecedent_estimate::PreparedEstimationProblem, ValidationError> {
282 let mut all_t = Vec::new();
284 let mut all_y = Vec::new();
285 let mut all_covs: Vec<(VariableId, Vec<f64>)> = Vec::new();
286 let mut adj_keys: Vec<VariableId> = Vec::new();
287 let mut active = 0.0;
288 let mut control = 0.0;
289 let mut treatment_delta = 0.0;
290 let mut first = true;
291 for unit in panel.units() {
292 let prep = estimator
293 .prepare_with_extras(
294 &unit.series,
295 estimand,
296 temporal.temporal_query,
297 temporal.indexer,
298 temporal.split,
299 temporal.kernel_policy,
300 extra,
301 )
302 .map_err(ValidationError::from)?;
303 if first {
304 active = prep.active;
305 control = prep.control;
306 treatment_delta = prep.treatment_delta;
307 adj_keys = prep.adjustment_set.to_vec();
308 all_covs = adj_keys.iter().map(|&id| (id, Vec::new())).collect();
309 first = false;
310 }
311 all_t.extend_from_slice(&prep.treatment);
312 all_y.extend_from_slice(&prep.design.outcome);
313 let nrows = prep.design.nrows;
314 for (i, (_id, dest)) in all_covs.iter_mut().enumerate() {
315 let base = (2 + i) * nrows;
316 dest.extend_from_slice(&prep.design.matrix[base..base + nrows]);
317 }
318 }
319 let cov_refs: Vec<(VariableId, &[f64])> =
320 all_covs.iter().map(|(id, v)| (*id, v.as_slice())).collect();
321 let selected: Vec<usize> = (0..all_t.len()).collect();
322 let design =
323 antecedent_stats::CompiledDesign::linear_adjustment(&all_t, &cov_refs, &all_y, &selected)
324 .map_err(ValidationError::from)?;
325 Ok(antecedent_estimate::PreparedEstimationProblem {
326 design,
327 method: Arc::from("temporal.linear.adjustment.panel"),
328 adjustment_set: Arc::from(adj_keys),
329 overlap: OverlapPolicy::ExplicitOverride,
330 treatment_delta,
331 target_population: antecedent_core::TargetPopulation::AllObserved,
332 treatment: Arc::from(all_t),
333 active,
334 control,
335 })
336}
337
338pub fn stack_panel_tabular(panel: &PanelData) -> Result<TabularData, ValidationError> {
340 use antecedent_data::{Float64Column, OwnedColumn, OwnedColumnarStorage};
341 let total = panel.total_rows();
342 let schema = panel.schema().clone();
343 let n_cols = schema.len();
344 let mut col_vals: Vec<Vec<f64>> = (0..n_cols).map(|_| Vec::with_capacity(total)).collect();
345 for u in panel.units() {
346 for (j, dest) in col_vals.iter_mut().enumerate() {
347 let id =
348 VariableId::from_raw(u32::try_from(j).map_err(|_| {
349 ValidationError::data_msg("panel column index exceeds u32::MAX")
350 })?);
351 let vals = u.series.float64_values(id).map_err(ValidationError::from)?;
352 dest.extend_from_slice(&vals);
353 }
354 }
355 let mut cols = Vec::with_capacity(n_cols);
356 for (j, values) in col_vals.into_iter().enumerate() {
357 let id = VariableId::from_raw(
358 u32::try_from(j)
359 .map_err(|_| ValidationError::data_msg("panel column index exceeds u32::MAX"))?,
360 );
361 cols.push(OwnedColumn::Float64(
362 Float64Column::new(id, Arc::from(values), ValidityBitmap::all_valid(total))
363 .map_err(ValidationError::from)?,
364 ));
365 }
366 let storage =
367 OwnedColumnarStorage::try_new(schema, cols, None, None).map_err(ValidationError::from)?;
368 Ok(TabularData::new(storage))
369}
370
371pub(crate) struct DiagnosticPropensityColumns {
373 pub scores: Vec<f64>,
375 pub treatment: Vec<f64>,
377 pub outcome: Option<Vec<f64>>,
379}
380
381pub(crate) fn fit_diagnostic_propensity(
385 problem: &RefutationProblem<'_>,
386 glm_options: &GlmOptions,
387 include_outcome_in_mask: bool,
388 propensity: &mut PropensityWorkspace,
389) -> Result<DiagnosticPropensityColumns, ValidationError> {
390 let mut ids = vec![problem.treatment()];
391 if include_outcome_in_mask {
392 ids.push(problem.outcome());
393 }
394 ids.extend_from_slice(&problem.estimand.adjustment_set);
395 let row_mask = problem.data.complete_case_mask(&ids).map_err(ValidationError::from)?;
396 let treatment = problem
397 .data
398 .float64_masked(problem.treatment(), &row_mask)
399 .map_err(ValidationError::from)?;
400 let outcome = if include_outcome_in_mask {
401 Some(
402 problem
403 .data
404 .float64_masked(problem.outcome(), &row_mask)
405 .map_err(ValidationError::from)?,
406 )
407 } else {
408 None
409 };
410 let nrows = treatment.len();
411 let ncols = 1 + problem.estimand.adjustment_set.len();
412 let mut design = vec![0.0; nrows * ncols];
413 for r in design.iter_mut().take(nrows) {
414 *r = 1.0;
415 }
416 for (i, &z) in problem.estimand.adjustment_set.iter().enumerate() {
417 let col = problem.data.float64_masked(z, &row_mask).map_err(ValidationError::from)?;
418 let base = (1 + i) * nrows;
419 design[base..base + nrows].copy_from_slice(&col);
420 }
421 let backend = FaerBackend;
422 let fit: PropensityFit = fit_propensity_diagnostic(
423 &design,
424 nrows,
425 ncols,
426 &treatment,
427 &backend,
428 propensity,
429 glm_options,
430 )
431 .map_err(ValidationError::from)?;
432 Ok(DiagnosticPropensityColumns { scores: fit.scores, treatment, outcome })
433}
434
435pub(crate) fn diagnostic_overlap_report_with(
438 problem: &RefutationProblem<'_>,
439 glm_options: &GlmOptions,
440 policy: OverlapPolicy,
441 propensity: &mut PropensityWorkspace,
442) -> Result<OverlapReport, ValidationError> {
443 let cols = fit_diagnostic_propensity(problem, glm_options, false, propensity)?;
444 let weights: Vec<f64> = cols
446 .treatment
447 .iter()
448 .zip(cols.scores.iter())
449 .map(|(&t, &p)| {
450 let p = p.clamp(1e-9, 1.0 - 1e-9);
451 if t > 0.5 { 1.0 / p } else { 1.0 / (1.0 - p) }
452 })
453 .collect();
454 Ok(OverlapReport::from_propensities(
455 &cols.scores,
456 Some(&weights),
457 policy,
458 Some(&cols.treatment),
459 Some(IpwTarget::Ate),
460 None,
461 ))
462}
463
464#[must_use]
466pub(crate) fn linear_estimator_no_bootstrap() -> LinearAdjustmentAte {
467 let mut estimator = LinearAdjustmentAte::new();
468 estimator.bootstrap_replicates = 0;
469 estimator
470}
471
472#[must_use]
476pub(crate) fn forced_refit_estimator(
477 caller_estimator: &LinearAdjustmentAte,
478) -> LinearAdjustmentAte {
479 let mut estimator = caller_estimator.clone();
480 estimator.bootstrap_replicates = 0;
481 estimator.overlap = OverlapPolicy::ExplicitOverride;
482 estimator
483}
484
485#[derive(Clone, Copy, Debug, Eq, PartialEq)]
487pub(crate) enum NoiseReplaceTarget {
488 Treatment,
490 Outcome,
492}
493
494#[allow(clippy::too_many_arguments)]
499pub(crate) fn noise_replace_refute(
500 problem: &RefutationProblem<'_>,
501 workspace: &mut EstimationWorkspace,
502 ctx: &ExecutionContext,
503 estimator: &LinearAdjustmentAte,
504 replicates: u32,
505 alpha: f64,
506 target: NoiseReplaceTarget,
507 stream_base: u64,
508 refuter_id: &'static str,
509 failure_label: &'static str,
510) -> Result<RefutationReport, ValidationError> {
511 if replicates < 2 {
512 return Err(ValidationError::NotApplicable {
513 message: "noise-replace refuter requires replicates >= 2",
514 });
515 }
516 let replace_id = match target {
517 NoiseReplaceTarget::Treatment => problem.treatment(),
518 NoiseReplaceTarget::Outcome => problem.outcome(),
519 };
520 let n = problem.data.row_count();
521 let mut ates = Vec::with_capacity(replicates as usize);
522 for r in 0..replicates {
523 let mut noise = vec![0.0; n];
524 fill_gaussian(&mut noise, ctx, stream_base.wrapping_add(u64::from(r)));
525 let data = with_replaced_float(problem.data, replace_id, Arc::from(noise))?;
526 let est = refit_effect(problem, &data, problem.estimand, &[], estimator, workspace, ctx)?;
527 ates.push(est.ate);
528 }
529 let mean_ate = ates.iter().sum::<f64>() / f64::from(replicates);
530 let p_value = replicate_p_value(&ates, 0.0);
531 let passed = p_value >= alpha;
532 Ok(RefutationReport {
533 refuter: Arc::from(refuter_id),
534 original_ate: problem.original.ate,
535 refuted_ate: mean_ate,
536 comparison: p_value,
537 informative: true,
538 passed,
539 failure_condition: (!passed).then(|| {
540 Arc::from(format!(
541 "{failure_label} ATE distribution (mean {mean_ate}) is inconsistent with zero \
542 (p={p_value} < alpha={alpha})"
543 ))
544 }),
545 replicates,
546 })
547}
548
549pub(crate) fn replicate_p_value(samples: &[f64], hypothesized: f64) -> f64 {
557 if samples.len() < 2 {
558 return 1.0;
559 }
560 #[allow(clippy::cast_precision_loss)]
561 let mean = samples.iter().sum::<f64>() / samples.len() as f64;
562 let sd = sample_sd(samples);
563 let scale = mean.abs().max(hypothesized.abs()).max(1.0);
564 if !sd.is_finite() {
565 return 1.0;
566 }
567 if sd <= 1e-12 * scale {
568 return if (hypothesized - mean).abs() <= 1e-9 * scale { 1.0 } else { 0.0 };
569 }
570 let z = (hypothesized - mean) / sd;
571 if !z.is_finite() {
572 return 1.0;
578 }
579 erfc(z.abs() / std::f64::consts::SQRT_2).clamp(0.0, 1.0)
583}
584
585pub(crate) fn float64_full(
587 data: &TabularData,
588 id: VariableId,
589) -> Result<Vec<f64>, ValidationError> {
590 data.float64_values(id).map_err(ValidationError::from)
591}
592
593pub(crate) fn with_row_subset(
596 data: &TabularData,
597 keep_fraction: f64,
598 ctx: &ExecutionContext,
599 stream_id: u64,
600) -> Result<TabularData, ValidationError> {
601 let n = data.row_count();
602 let mut rng = ctx.rng.stream(stream_id);
603 let mut bytes = vec![0u8; n.div_ceil(8)];
604 for i in 0..n {
605 if rng.next_f64() < keep_fraction {
606 bytes[i / 8] |= 1 << (i % 8);
607 }
608 }
609 let mask = ValidityBitmap::from_bytes(bytes, n).map_err(ValidationError::from)?;
610 data.with_analysis_mask(mask).map_err(ValidationError::from)
611}
612
613pub(crate) fn with_resampled_rows(
619 data: &TabularData,
620 resample_ids: &[VariableId],
621 row_idx: &[usize],
622 keep: &[bool],
623) -> Result<TabularData, ValidationError> {
624 let mut replacements: Vec<(VariableId, Arc<[f64]>)> = Vec::with_capacity(resample_ids.len());
629 for &id in resample_ids {
630 let resampled: Vec<f64> = if let Ok(ColumnView::Float64(c)) = data.column(id) {
634 row_idx.iter().map(|&i| c.values[i]).collect()
635 } else {
636 let full = float64_full(data, id)?;
637 row_idx.iter().map(|&i| full[i]).collect()
638 };
639 replacements.push((id, Arc::from(resampled)));
640 }
641 let out = data.with_replaced_floats(&replacements).map_err(ValidationError::from)?;
642 if keep.iter().all(|&k| k) {
643 return Ok(out);
644 }
645 let n = keep.len();
646 let mut bytes = vec![0u8; n.div_ceil(8)];
647 for (i, &k) in keep.iter().enumerate() {
648 if k {
649 bytes[i / 8] |= 1 << (i % 8);
650 }
651 }
652 let mask = ValidityBitmap::from_bytes(bytes, n).map_err(ValidationError::from)?;
653 out.with_analysis_mask(mask).map_err(ValidationError::from)
654}
655
656pub(crate) fn complete_case_rows(
658 data: &TabularData,
659 ids: &[VariableId],
660) -> Result<(Vec<bool>, Vec<usize>), ValidationError> {
661 let mask = data.complete_case_mask(ids).map_err(ValidationError::from)?;
662 let valid: Vec<usize> = mask.iter().enumerate().filter_map(|(i, &k)| k.then_some(i)).collect();
663 Ok((mask, valid))
664}
665
666pub(crate) fn masked_sample_sd(
668 data: &TabularData,
669 id: VariableId,
670 mask: &[bool],
671) -> Result<f64, ValidationError> {
672 let vals = data.float64_masked(id, mask).map_err(ValidationError::from)?;
673 Ok(sample_sd(&vals))
674}
675
676pub(crate) fn sample_sd(values: &[f64]) -> f64 {
678 antecedent_stats::sample_std(values)
679}
680
681pub(crate) fn fill_gaussian(out: &mut [f64], ctx: &ExecutionContext, stream_id: u64) {
686 let mut rng = ctx.rng.stream(stream_id);
687 antecedent_kernels::fill_standard_normal(&mut rng, out);
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
695 fn replicate_p_value_zero_sd_matches_hypothesis_is_valid_probability() {
696 let samples = [1.5, 1.5, 1.5, 1.5];
699 let p = replicate_p_value(&samples, 1.5);
700 assert!((0.0..=1.0).contains(&p), "p_value {p} outside [0, 1]");
701 assert!(!p.is_nan(), "p_value must not be NaN");
702 }
703
704 #[test]
705 fn replicate_p_value_zero_sd_away_from_hypothesis_is_valid_probability() {
706 let samples = [1.5, 1.5, 1.5, 1.5];
708 let p = replicate_p_value(&samples, 0.0);
709 assert!((0.0..=1.0).contains(&p), "p_value {p} outside [0, 1]");
710 assert!(!p.is_nan(), "p_value must not be NaN");
711 }
712
713 #[test]
714 fn replicate_p_value_nan_mean_does_not_propagate_nan() {
715 let samples = [1.0, 2.0, f64::NAN, 3.0];
720 let p = replicate_p_value(&samples, 0.0);
721 assert!((0.0..=1.0).contains(&p), "p_value {p} outside [0, 1]");
722 assert!(!p.is_nan(), "p_value must not be NaN");
723 }
724}