Skip to main content

antecedent_validate/
common.rs

1//! Shared refuter types and data transforms.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use 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/// Context for lag-aware temporal refits (series or panel).
30#[derive(Clone, Copy, Debug)]
31pub struct TemporalRefitContext<'a> {
32    /// Unfolded temporal indexer from identification.
33    pub indexer: &'a TemporalIndexer,
34    /// Temporal effect query (pulse / sustained).
35    pub temporal_query: &'a TemporalEffectQuery,
36    /// Optional discovery/estimation split.
37    pub split: Option<&'a DiscoveryEstimationSplit>,
38    /// Kernel policy for lag sample preparation.
39    pub kernel_policy: &'a KernelPolicy,
40    /// Series time index, or `None` when refitting a panel.
41    pub time_index: Option<&'a TimeIndex>,
42    /// Panel units when refitting stacked panel designs.
43    pub panel: Option<&'a PanelData>,
44}
45
46impl TemporalRefitContext<'_> {
47    /// True when this context targets a panel (stacked cluster design).
48    #[must_use]
49    pub fn is_panel(&self) -> bool {
50        self.panel.is_some()
51    }
52}
53
54/// Comparison of original vs refuted estimates.
55#[derive(Clone, Debug)]
56#[non_exhaustive]
57pub struct RefutationReport {
58    /// Refuter id.
59    pub refuter: Arc<str>,
60    /// Original ATE.
61    pub original_ate: f64,
62    /// Refuted / transformed ATE (mean across replicates when applicable).
63    pub refuted_ate: f64,
64    /// Scale-free comparison statistic. Replicate-based refuters store the two-sided
65    /// p-value of the null value under the replicate distribution; sensitivity grids
66    /// store the robustness value; overlap/e-value checks store their own statistic.
67    pub comparison: f64,
68    /// Whether the check is informative for the estimator used.
69    pub informative: bool,
70    /// Whether the check passed the configured threshold.
71    pub passed: bool,
72    /// Failure condition description when `passed` is false.
73    pub failure_condition: Option<Arc<str>>,
74    /// Number of replicate estimates.
75    pub replicates: u32,
76}
77
78impl RefutationReport {
79    /// Construct a refutation report.
80    #[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/// Inputs shared by effect refuters.
106#[derive(Clone, Copy, Debug)]
107pub struct RefutationProblem<'a> {
108    /// Tabular data (series storage wrap, or stacked panel rows for mutation).
109    pub data: &'a TabularData,
110    /// Identified estimand (backdoor adjustment).
111    pub estimand: &'a IdentifiedEstimand,
112    /// Average-effect query (levels / population).
113    pub query: &'a AverageEffectQuery,
114    /// Original point estimate.
115    pub original: &'a EffectEstimate,
116    /// Estimator id used for the original fit (e.g. `linear.adjustment.ate`), when known.
117    pub estimator: Option<&'a str>,
118    /// When set, refits use [`TemporalLinearAdjustment`] on the lag-aligned design.
119    pub temporal: Option<TemporalRefitContext<'a>>,
120    /// Prepare-time per-unit slice plan. Filled by [`crate::PreparedRefutation::compile`];
121    /// callers constructing a problem directly leave this unset and the panel refit
122    /// compiles a plan from [`Self::temporal`] on first use.
123    pub(crate) panel_slices: Option<&'a PanelSliceTemplate<'a>>,
124}
125
126impl<'a> RefutationProblem<'a> {
127    /// Construct a refutation problem. Panel slice reuse is compiled by
128    /// [`crate::PreparedRefutation::compile`].
129    #[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    /// Treatment variable from the query.
149    #[must_use]
150    pub fn treatment(&self) -> VariableId {
151        self.query.treatment
152    }
153
154    /// Outcome variable from the query.
155    #[must_use]
156    pub fn outcome(&self) -> VariableId {
157        self.query.outcome
158    }
159}
160
161/// Rebuild tabular data replacing one float column (preserves mask/weights/other columns).
162pub(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
170/// Append an independent continuous covariate; returns new data and its id.
171pub(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
179/// Fit linear adjustment once (no nested bootstrap pools).
180pub(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
194/// Static or temporal effect refit (bootstrap disabled).
195///
196/// `caller_estimator` carries the refuter's configured [`LinearAdjustmentAte`] (SE kind,
197/// cluster/multiway/panel-time ids, fit family, backend); the refit honors it on both the
198/// static and temporal branches so a caller who set e.g. `se_kind = AnalyticSeKind::Cluster`
199/// gets cluster-robust SEs on every refit replicate, not silently-downgraded homoskedastic
200/// OLS. Two fields are always forced regardless of what the caller configured:
201/// `bootstrap_replicates = 0` (refit replicates must never nest their own bootstrap pools)
202/// and `overlap = OverlapPolicy::ExplicitOverride` (refuters mutate a single column and refit
203/// the same design; re-running propensity/overlap diagnostics on a noised or permuted column
204/// is neither meaningful nor how the original estimate was gated).
205pub(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    // Stack per-unit prepare_with_extras designs (mirrors prepare_panel).
283    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
338/// Stack panel units into one tabular table (row-major concat) for refute mutations.
339pub 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
371/// Scores / treatment / optional outcome from a diagnostic propensity fit.
372pub(crate) struct DiagnosticPropensityColumns {
373    /// Fitted propensity scores.
374    pub scores: Vec<f64>,
375    /// Treatment column (complete cases).
376    pub treatment: Vec<f64>,
377    /// Outcome column when requested.
378    pub outcome: Option<Vec<f64>>,
379}
380
381/// Diagnostic-only logistic propensity on treatment + adjustment covariates.
382///
383/// Used by overlap / Riesz validators when the original estimate has no propensity report.
384pub(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
435/// Build an [`OverlapReport`] from a diagnostic propensity fit, reusing a warmed
436/// propensity workspace (callers without one pass a local `PropensityWorkspace::default()`).
437pub(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    // ATE IPW weights so ESS / extreme-weight fields are defined for the overlap refuter.
445    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/// Linear adjustment with nested bootstrap disabled (refuters / sensitivity grids).
465#[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/// Clone `caller_estimator` and force the two overrides every refit needs regardless of the
473/// caller's config: no nested bootstrap, and no propensity/overlap re-diagnosis. See
474/// [`refit_effect`] for why these two are pinned rather than threaded through.
475#[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/// Which column a noise-replace refuter overwrites.
486#[derive(Clone, Copy, Debug, Eq, PartialEq)]
487pub(crate) enum NoiseReplaceTarget {
488    /// Replace the treatment column.
489    Treatment,
490    /// Replace the outcome column.
491    Outcome,
492}
493
494/// Shared placebo / dummy-outcome loop: replace a column with Gaussian noise and refit.
495///
496/// Passes when the replicate ATE distribution is statistically consistent with zero
497/// (two-sided normal test, `p >= alpha`), so the verdict is invariant to outcome units.
498#[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
549/// Two-sided p-value of observing `hypothesized` as a *typical single replicate*
550/// under a normal fit to `samples` (`z = (hypothesized − mean) / sd`, not `/ (sd/√R)`).
551///
552/// This is not a test of whether the replicate *mean* is `hypothesized`. Adding `√R`
553/// would make any tiny systematic bias refute at large R, which is the wrong operating
554/// characteristic for a placebo / dummy-outcome refuter. Degenerate spread compares
555/// means directly.
556pub(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        // Defense in depth rather than a live path: a non-finite `mean` drags
573        // `sample_sd` non-finite too, so the guard above already catches every
574        // way `z` is currently reachable as NaN/±inf. Kept so a future change to
575        // the spread guards cannot leak a NaN into a reported probability, and
576        // returning 1.0 matches the non-finite convention established above.
577        return 1.0;
578    }
579    // `erfc` is guaranteed in [0, 2] for finite input, so `z.abs() >= 0` keeps this
580    // in [0, 1] mathematically — clamp anyway since this value is documented as a
581    // probability and defensive clamping is cheap.
582    erfc(z.abs() / std::f64::consts::SQRT_2).clamp(0.0, 1.0)
583}
584
585/// Copy a full-length float64 column (unmasked; caller handles missingness).
586pub(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
593/// Restrict analysis to a random `keep_fraction` of rows (Bernoulli per-row draw), intersected
594/// with any existing analysis mask / column validity.
595pub(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
613/// Rebuild tabular data with `ids` columns resampled (with replacement) per `idx`; all other
614/// columns and metadata are preserved. `idx.len()` must equal `data.row_count()` and every
615/// index must point at a complete-case row for `resample_ids` (see [`complete_case_rows`]);
616/// `keep` re-hides rows that were invalid in the source so the replicate keeps the original
617/// effective sample size.
618pub(crate) fn with_resampled_rows(
619    data: &TabularData,
620    resample_ids: &[VariableId],
621    row_idx: &[usize],
622    keep: &[bool],
623) -> Result<TabularData, ValidationError> {
624    // One storage rebuild for all k resampled columns (weights/mask copied once), not one
625    // per column: at 200 bootstrap replicates the per-column rebuild dominated the loop.
626    // `resample_ids` are distinct by construction (treatment, outcome, then covariates), so
627    // gathering every column from the source table matches the old sequential replacement.
628    let mut replacements: Vec<(VariableId, Arc<[f64]>)> = Vec::with_capacity(resample_ids.len());
629    for &id in resample_ids {
630        // Gather straight from the borrowed float64 view when the column is natively float;
631        // the coercing owned-copy fallback keeps non-float ids surfacing the same
632        // `TypeMismatch` from the replace step as before.
633        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
656/// Complete-case mask and the list of valid row indexes for `ids` (analysis mask included).
657pub(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
666/// Sample standard deviation of a column over the complete-case rows of `ids`.
667pub(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
676/// Sample standard deviation (`NaN` for fewer than 2 values).
677pub(crate) fn sample_sd(values: &[f64]) -> f64 {
678    antecedent_stats::sample_std(values)
679}
680
681/// Standard-normal draws via Box–Muller from [`ExecutionContext`] RNG.
682///
683/// Emits both cos/sin components from each uniform pair (same stream use as
684/// historical seeded tests).
685pub(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        // All replicates identical (SD = 0) and equal to the hypothesized value:
697        // handled by the existing degenerate-spread guard, not the erfc path.
698        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        // All replicates identical (SD = 0) but away from the hypothesized value.
707        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        // A blown-up replicate (e.g. a failed refit) can leave NaN in the sample
716        // set; `mean`/`z` then become non-finite even though `sd` itself may
717        // still compare finite. The result must stay a valid probability
718        // (D2: unclamped/non-finite tail probability).
719        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}