Skip to main content

antecedent_validate/
sensitivity.rs

1//! Linear, partial-linear, and nonparametric confounding sensitivity analysis.
2//!
3//! [`LinearSensitivity`] and [`PartialLinearSensitivity`] simulate a confounder `U` with a
4//! configurable *partial R²* on treatment and outcome under a linear (Gaussian) or
5//! partial-linear (bounded) shape. [`NonparametricSensitivity`] first residualizes treatment
6//! and outcome on adjustment covariates with Nadaraya–Watson (Nadaraya 1964; Watson 1964) kernel
7//! regression, then runs the
8//! same partial-R² grid on the residualized series — a production nonparametric path distinct
9//! from the partial-linear shape stand-in.
10//!
11//! SPDX-License-Identifier: MIT OR Apache-2.0
12
13#![allow(
14    clippy::cast_possible_truncation,
15    clippy::cast_precision_loss,
16    clippy::many_single_char_names,
17    clippy::similar_names,
18    clippy::float_cmp
19)]
20
21use std::sync::Arc;
22
23use antecedent_core::{ExecutionContext, VariableId};
24use antecedent_data::TableView;
25use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte, LinearFitKind};
26use antecedent_stats::{
27    DenseLinearAlgebra, FaerBackend, LeastSquaresWorkspace, chol_solve, cholesky_spd, form_xtx,
28};
29
30use crate::common::{
31    RefutationProblem, RefutationReport, complete_case_rows, fill_gaussian, fit_once, float64_full,
32    linear_estimator_no_bootstrap, refit_effect, sample_sd, with_replaced_float,
33};
34use crate::error::ValidationError;
35
36/// Default partial-R² grid, ascending.
37fn default_grid() -> Vec<f64> {
38    vec![0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5]
39}
40
41fn run_grid(
42    problem: &RefutationProblem<'_>,
43    workspace: &mut EstimationWorkspace,
44    ctx: &ExecutionContext,
45    estimator: &LinearAdjustmentAte,
46    grid: &[f64],
47    noise_stream: u64,
48    nonparametric: bool,
49) -> Result<(f64, f64, bool), ValidationError> {
50    let setup = GridSetup::new(problem, ctx, grid, noise_stream, nonparametric)?;
51    if gram_applicable(problem, estimator) {
52        if let Some(result) = try_run_grid_gram(problem, estimator, &setup)? {
53            return Ok(result);
54        }
55    }
56    run_grid_data_pass(problem, workspace, ctx, estimator, &setup)
57}
58
59/// Static OLS, no nested bootstrap: the grid is one Gram of `[1, T, Z, u]` plus
60/// a `p×p` Cholesky per grid point. Temporal / ridge / lasso / Huber keep the
61/// per-point data pass.
62fn gram_applicable(problem: &RefutationProblem<'_>, estimator: &LinearAdjustmentAte) -> bool {
63    problem.temporal.is_none()
64        && estimator.bootstrap_replicates == 0
65        && matches!(estimator.fit_kind, LinearFitKind::Ols)
66}
67
68struct GridSetup {
69    t0: Vec<f64>,
70    y0: Vec<f64>,
71    u: Vec<f64>,
72    sd_t: f64,
73    sd_y: f64,
74    dir: f64,
75    sorted_grid: Vec<f64>,
76    original_sign: f64,
77    original_ate: f64,
78}
79
80impl GridSetup {
81    fn new(
82        problem: &RefutationProblem<'_>,
83        ctx: &ExecutionContext,
84        grid: &[f64],
85        noise_stream: u64,
86        nonparametric: bool,
87    ) -> Result<Self, ValidationError> {
88        let n = problem.data.row_count();
89        let t0 = float64_full(problem.data, problem.treatment())?;
90        let y0 = float64_full(problem.data, problem.outcome())?;
91        let mut ids = vec![problem.treatment(), problem.outcome()];
92        if problem.temporal.is_none() {
93            ids.extend_from_slice(&problem.estimand.adjustment_set);
94        }
95        let (mask, _valid) = complete_case_rows(problem.data, &ids)?;
96        // The grid is a *partial* R² — the share of variance in `T` (and `Y`) left unexplained by
97        // the adjustment set `Z` that the simulated confounder accounts for. Injecting
98        // `scale · SD(T) · u` calibrates against the *marginal* variance instead, so whenever `Z`
99        // has real explanatory power the realized partial R² far exceeds the nominal grid value
100        // (with R²(T,Z) = 0.8, a nominal 0.2 lands at 0.556) and the reported robustness is
101        // misstated. Scale by the residual SD so `scale = √(r/(1−r))` targets the partial R² the
102        // docs and the Cinelli–Hazlett convention promise. `NonparametricSensitivity` already
103        // residualizes; this brings the linear paths in line.
104        let (sd_t, sd_y) =
105            residual_sd_pair_on_adjustment(problem, problem.treatment(), problem.outcome(), &mask)?;
106        let sd_t = sd_t.max(1e-12);
107        let sd_y = sd_y.max(1e-12);
108        let mut u = vec![0.0; n];
109        if nonparametric {
110            fill_bounded(&mut u, ctx, noise_stream);
111        } else {
112            fill_gaussian(&mut u, ctx, noise_stream);
113        }
114
115        let mut sorted_grid = grid.to_vec();
116        sorted_grid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
117
118        let original_sign = problem.original.ate.signum();
119        // Worst-case orientation: load the confounder on Y against the observed effect so the
120        // induced omitted-variable bias works to explain the effect away; a same-sign loading
121        // could never flip a positive estimate and would spuriously kill a negative one.
122        let dir = if problem.original.ate >= 0.0 { -1.0 } else { 1.0 };
123        Ok(Self {
124            t0,
125            y0,
126            u,
127            sd_t,
128            sd_y,
129            dir,
130            sorted_grid,
131            original_sign,
132            original_ate: problem.original.ate,
133        })
134    }
135}
136
137fn run_grid_data_pass(
138    problem: &RefutationProblem<'_>,
139    workspace: &mut EstimationWorkspace,
140    ctx: &ExecutionContext,
141    estimator: &LinearAdjustmentAte,
142    setup: &GridSetup,
143) -> Result<(f64, f64, bool), ValidationError> {
144    let mut last_ate = setup.original_ate;
145    for &r in &setup.sorted_grid {
146        let r = r.clamp(0.0, 0.999);
147        last_ate = data_pass_ate(problem, workspace, ctx, estimator, setup, r)?;
148        let explained_away = last_ate.abs() < 1e-9 || last_ate.signum() != setup.original_sign;
149        if explained_away {
150            return Ok((r, last_ate, true));
151        }
152    }
153    let robustness_value = setup.sorted_grid.last().copied().unwrap_or(1.0);
154    Ok((robustness_value, last_ate, false))
155}
156
157fn data_pass_ate(
158    problem: &RefutationProblem<'_>,
159    workspace: &mut EstimationWorkspace,
160    ctx: &ExecutionContext,
161    estimator: &LinearAdjustmentAte,
162    setup: &GridSetup,
163    r: f64,
164) -> Result<f64, ValidationError> {
165    let r = r.clamp(0.0, 0.999);
166    let scale = (r / (1.0 - r)).sqrt();
167    let t: Vec<f64> =
168        setup.t0.iter().zip(&setup.u).map(|(&t, &u)| t + scale * setup.sd_t * u).collect();
169    let y: Vec<f64> = setup
170        .y0
171        .iter()
172        .zip(&setup.u)
173        .map(|(&y, &u)| y + setup.dir * scale * setup.sd_y * u)
174        .collect();
175    let data = with_replaced_float(problem.data, problem.treatment(), Arc::from(t))?;
176    let data = with_replaced_float(&data, problem.outcome(), Arc::from(y))?;
177    let est = if problem.temporal.is_some() {
178        refit_effect(problem, &data, problem.estimand, &[], estimator, workspace, ctx)?
179    } else {
180        fit_once(estimator, &data, problem.estimand, problem.query, workspace, ctx)?
181    };
182    Ok(est.ate)
183}
184
185/// One Gram of `W = [1, T, Z, u]` over the same complete-case rows `prepare` uses.
186/// Returns `None` when Cholesky refuses (caller falls back to the data pass).
187fn try_run_grid_gram(
188    problem: &RefutationProblem<'_>,
189    estimator: &LinearAdjustmentAte,
190    setup: &GridSetup,
191) -> Result<Option<(f64, f64, bool)>, ValidationError> {
192    let Some(gram) = SensitivityGram::compile(problem, estimator, &setup.u)? else {
193        return Ok(None);
194    };
195    let mut last_ate = setup.original_ate;
196    for &r in &setup.sorted_grid {
197        let r = r.clamp(0.0, 0.999);
198        let scale = (r / (1.0 - r)).sqrt();
199        let a = scale * setup.sd_t;
200        let b = setup.dir * scale * setup.sd_y;
201        let Some(ate) = gram.ate_at(a, b) else {
202            return Ok(None);
203        };
204        last_ate = ate;
205        let explained_away = last_ate.abs() < 1e-9 || last_ate.signum() != setup.original_sign;
206        if explained_away {
207            return Ok(Some((r, last_ate, true)));
208        }
209    }
210    let robustness_value = setup.sorted_grid.last().copied().unwrap_or(1.0);
211    Ok(Some((robustness_value, last_ate, false)))
212}
213
214/// Sufficient statistics `WᵀW` / `WᵀY` for `W = [X | u]`, `X = [1, T, Z]`.
215struct SensitivityGram {
216    g: Vec<f64>,
217    gy: Vec<f64>,
218    p: usize,
219    treatment_delta: f64,
220}
221
222impl SensitivityGram {
223    fn compile(
224        problem: &RefutationProblem<'_>,
225        estimator: &LinearAdjustmentAte,
226        u: &[f64],
227    ) -> Result<Option<Self>, ValidationError> {
228        // Data-pass `with_replaced_float` marks T and Y all-valid, so `prepare`
229        // can keep rows that were missing on the original T/Y. Compile against
230        // that same table or the Gram row set silently disagrees.
231        let t0 = float64_full(problem.data, problem.treatment())?;
232        let y0 = float64_full(problem.data, problem.outcome())?;
233        let data = with_replaced_float(problem.data, problem.treatment(), Arc::from(t0))?;
234        let data = with_replaced_float(&data, problem.outcome(), Arc::from(y0))?;
235        let prep = estimator
236            .prepare(&data, problem.estimand, problem.query)
237            .map_err(ValidationError::from)?;
238        let n = prep.design.nrows;
239        let p = prep.design.ncols;
240        if n == 0 || p < 2 || prep.design.row_selection.len() != n {
241            return Ok(None);
242        }
243        let q = p + 1;
244        let mut w = vec![0.0; n * q];
245        w[..n * p].copy_from_slice(prep.design.matrix.as_ref());
246        for (i, &row) in prep.design.row_selection.iter().enumerate() {
247            if row >= u.len() {
248                return Err(ValidationError::data_msg(
249                    "sensitivity Gram row_selection exceeds confounder length",
250                ));
251            }
252            w[n * p + i] = u[row];
253        }
254        let mut g = vec![0.0; q * q];
255        form_xtx(&w, n, q, &mut g);
256        let mut gy = vec![0.0; q];
257        form_xty(&w, n, q, prep.design.outcome.as_ref(), &mut gy);
258        Ok(Some(Self { g, gy, p, treatment_delta: prep.treatment_delta }))
259    }
260
261    fn ate_at(&self, a: f64, b: f64) -> Option<f64> {
262        let p = self.p;
263        let mut xtx = vec![0.0; p * p];
264        let mut xty = vec![0.0; p];
265        assemble_perturbed_normal_eq(&self.g, &self.gy, p, a, b, &mut xtx, &mut xty);
266        let chol = cholesky_spd(&xtx, p)?;
267        let beta = chol_solve(&chol, p, &xty)?;
268        // Linear main-effects ATE is β_T · Δ for ATE/ATT/ATC/predicate (gcomp equals this).
269        Some(beta[1] * self.treatment_delta)
270    }
271}
272
273fn form_xty(w_colmajor: &[f64], nrows: usize, ncols: usize, y: &[f64], xty: &mut [f64]) {
274    debug_assert!(w_colmajor.len() >= nrows * ncols);
275    debug_assert!(y.len() >= nrows);
276    debug_assert!(xty.len() >= ncols);
277    for c in 0..ncols {
278        let col = &w_colmajor[c * nrows..(c + 1) * nrows];
279        let mut acc = 0.0;
280        for r in 0..nrows {
281            acc += col[r] * y[r];
282        }
283        xty[c] = acc;
284    }
285}
286
287/// Assemble `X'ᵀX'` / `X'ᵀY'` for `X' = [1, T + a u, Z]` and `Y' = Y + b u`
288/// from the Gram of `W = [1, T, Z, u]`.
289fn assemble_perturbed_normal_eq(
290    g: &[f64],
291    gy: &[f64],
292    p: usize,
293    a: f64,
294    b: f64,
295    xtx: &mut [f64],
296    xty: &mut [f64],
297) {
298    let q = p + 1;
299    let u_idx = p;
300    debug_assert!(g.len() >= q * q);
301    debug_assert!(gy.len() >= q);
302    debug_assert!(xtx.len() >= p * p);
303    debug_assert!(xty.len() >= p);
304    for i in 0..p {
305        for j in 0..p {
306            let mut v = g[i * q + j];
307            if j == 1 {
308                v += a * g[i * q + u_idx];
309            }
310            if i == 1 {
311                v += a * g[j * q + u_idx];
312            }
313            if i == 1 && j == 1 {
314                v += a * a * g[u_idx * q + u_idx];
315            }
316            xtx[i * p + j] = v;
317        }
318        let mut rhs = gy[i] + b * g[i * q + u_idx];
319        if i == 1 {
320            rhs += a * gy[u_idx] + a * b * g[u_idx * q + u_idx];
321        }
322        xty[i] = rhs;
323    }
324}
325
326/// Per-grid-point ATEs along the current data-pass path (differential tests).
327#[cfg(test)]
328pub(crate) fn grid_ates_data_pass(
329    problem: &RefutationProblem<'_>,
330    workspace: &mut EstimationWorkspace,
331    ctx: &ExecutionContext,
332    estimator: &LinearAdjustmentAte,
333    grid: &[f64],
334    noise_stream: u64,
335    nonparametric: bool,
336) -> Result<Vec<f64>, ValidationError> {
337    let setup = GridSetup::new(problem, ctx, grid, noise_stream, nonparametric)?;
338    let mut ates = Vec::with_capacity(setup.sorted_grid.len());
339    for &r in &setup.sorted_grid {
340        ates.push(data_pass_ate(problem, workspace, ctx, estimator, &setup, r)?);
341    }
342    Ok(ates)
343}
344
345/// Per-grid-point ATEs along the Gram path (`None` if Cholesky refuses).
346#[cfg(test)]
347pub(crate) fn grid_ates_gram(
348    problem: &RefutationProblem<'_>,
349    estimator: &LinearAdjustmentAte,
350    ctx: &ExecutionContext,
351    grid: &[f64],
352    noise_stream: u64,
353    nonparametric: bool,
354) -> Result<Option<Vec<f64>>, ValidationError> {
355    let setup = GridSetup::new(problem, ctx, grid, noise_stream, nonparametric)?;
356    let Some(gram) = SensitivityGram::compile(problem, estimator, &setup.u)? else {
357        return Ok(None);
358    };
359    let mut ates = Vec::with_capacity(setup.sorted_grid.len());
360    for &r in &setup.sorted_grid {
361        let r = r.clamp(0.0, 0.999);
362        let scale = (r / (1.0 - r)).sqrt();
363        let a = scale * setup.sd_t;
364        let b = setup.dir * scale * setup.sd_y;
365        let Some(ate) = gram.ate_at(a, b) else {
366            return Ok(None);
367        };
368        ates.push(ate);
369    }
370    Ok(Some(ates))
371}
372
373fn fill_bounded(out: &mut [f64], ctx: &ExecutionContext, stream_id: u64) {
374    // Uniform on [-√3, √3): unit variance, so the partial-R² grid calibration derived for
375    // a standardized confounder holds for the bounded shape too.
376    let mut rng = ctx.rng.stream(stream_id);
377    let sqrt3 = 3.0_f64.sqrt();
378    for slot in out.iter_mut() {
379        *slot = rng.next_f64().mul_add(2.0, -1.0) * sqrt3;
380    }
381}
382
383/// Linear confounding sensitivity: simulated Gaussian confounder with configurable partial R².
384#[derive(Clone, Debug)]
385pub struct LinearSensitivity {
386    /// Ascending grid of partial-R² values to test (shared for treatment and outcome).
387    pub partial_r2_grid: Vec<f64>,
388    /// Pass if the robustness value exceeds this threshold (harder to explain away).
389    pub pass_threshold: f64,
390    /// Estimator used for refits (bootstrap disabled).
391    pub estimator: LinearAdjustmentAte,
392}
393
394impl Default for LinearSensitivity {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400impl LinearSensitivity {
401    /// Defaults: grid `[0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5]`, pass threshold 0.1.
402    #[must_use]
403    pub fn new() -> Self {
404        Self {
405            partial_r2_grid: default_grid(),
406            pass_threshold: 0.1,
407            estimator: linear_estimator_no_bootstrap(),
408        }
409    }
410
411    /// Run the linear sensitivity refuter.
412    ///
413    /// # Errors
414    ///
415    /// Data or estimation failures, or an empty `partial_r2_grid`.
416    pub fn refute(
417        &self,
418        problem: &RefutationProblem<'_>,
419        workspace: &mut EstimationWorkspace,
420        ctx: &ExecutionContext,
421    ) -> Result<RefutationReport, ValidationError> {
422        if self.partial_r2_grid.is_empty() {
423            return Err(ValidationError::NotApplicable {
424                message: "linear sensitivity requires a non-empty partial_r2_grid",
425            });
426        }
427        let (robustness_value, refuted_ate, _explained_away) = run_grid(
428            problem,
429            workspace,
430            ctx,
431            &self.estimator,
432            &self.partial_r2_grid,
433            0xA7E0_000A_0000_u64,
434            false,
435        )?;
436        let passed = robustness_value >= self.pass_threshold;
437        Ok(RefutationReport {
438            refuter: Arc::from("sensitivity.linear"),
439            original_ate: problem.original.ate,
440            refuted_ate,
441            comparison: robustness_value,
442            informative: true,
443            passed,
444            failure_condition: if passed {
445                None
446            } else {
447                Some(Arc::from(format!(
448                    "effect explained away at partial R²={robustness_value}, below threshold {}",
449                    self.pass_threshold
450                )))
451            },
452            replicates: self.partial_r2_grid.len() as u32,
453        })
454    }
455}
456
457/// Partial-linear sensitivity: same grid as [`LinearSensitivity`] with a bounded uniform
458/// confounder shape (partial-linear misspecification), not a nonparametric residualization path.
459#[derive(Clone, Debug)]
460pub struct PartialLinearSensitivity {
461    /// Ascending grid of partial-R² values to test (shared for treatment and outcome).
462    pub partial_r2_grid: Vec<f64>,
463    /// Pass if the robustness value exceeds this threshold (harder to explain away).
464    pub pass_threshold: f64,
465    /// Estimator used for refits (bootstrap disabled).
466    pub estimator: LinearAdjustmentAte,
467}
468
469impl Default for PartialLinearSensitivity {
470    fn default() -> Self {
471        Self::new()
472    }
473}
474
475impl PartialLinearSensitivity {
476    /// Defaults: grid `[0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5]`, pass threshold 0.1.
477    #[must_use]
478    pub fn new() -> Self {
479        Self {
480            partial_r2_grid: default_grid(),
481            pass_threshold: 0.1,
482            estimator: linear_estimator_no_bootstrap(),
483        }
484    }
485
486    /// Run the partial-linear sensitivity refuter.
487    ///
488    /// # Errors
489    ///
490    /// Data or estimation failures, or an empty `partial_r2_grid`.
491    pub fn refute(
492        &self,
493        problem: &RefutationProblem<'_>,
494        workspace: &mut EstimationWorkspace,
495        ctx: &ExecutionContext,
496    ) -> Result<RefutationReport, ValidationError> {
497        if self.partial_r2_grid.is_empty() {
498            return Err(ValidationError::NotApplicable {
499                message: "partial-linear sensitivity requires a non-empty partial_r2_grid",
500            });
501        }
502        let (robustness_value, refuted_ate, _explained_away) = run_grid(
503            problem,
504            workspace,
505            ctx,
506            &self.estimator,
507            &self.partial_r2_grid,
508            0xA7E0_000B_0000_u64,
509            true,
510        )?;
511        let passed = robustness_value >= self.pass_threshold;
512        Ok(RefutationReport {
513            refuter: Arc::from("sensitivity.partial_linear"),
514            original_ate: problem.original.ate,
515            refuted_ate,
516            comparison: robustness_value,
517            informative: true,
518            passed,
519            failure_condition: if passed {
520                None
521            } else {
522                Some(Arc::from(format!(
523                    "effect explained away at partial R²={robustness_value}, below threshold {}",
524                    self.pass_threshold
525                )))
526            },
527            replicates: self.partial_r2_grid.len() as u32,
528        })
529    }
530}
531
532/// Nadaraya–Watson leave-one-out prediction of two targets sharing one
533/// covariate matrix (`n × dim`, row-major).
534///
535/// The kernel weight depends only on the covariates, so predicting `y1` and
536/// `y2` in one pass halves the O(n²·dim) distance/`exp` work versus two calls;
537/// per-target accumulation order is unchanged, so each output matches the
538/// single-target form bit for bit.
539fn nw_loo_predict_pair(
540    y1: &[f64],
541    y2: &[f64],
542    cov_rowmajor: &[f64],
543    dim: usize,
544    bandwidth: f64,
545) -> (Vec<f64>, Vec<f64>) {
546    let n = y1.len();
547    let h2 = (bandwidth.max(1e-6)).powi(2);
548    let mut out1 = vec![0.0; n];
549    let mut out2 = vec![0.0; n];
550    for i in 0..n {
551        let xi = &cov_rowmajor[i * dim..(i + 1) * dim];
552        let mut num1 = 0.0;
553        let mut num2 = 0.0;
554        let mut den = 0.0;
555        for j in 0..n {
556            if i == j {
557                continue;
558            }
559            let xj = &cov_rowmajor[j * dim..(j + 1) * dim];
560            let mut d2 = 0.0;
561            for d in 0..dim {
562                let t = xi[d] - xj[d];
563                d2 += t * t;
564            }
565            let w = (-0.5 * d2 / h2).exp();
566            num1 += w * y1[j];
567            num2 += w * y2[j];
568            den += w;
569        }
570        out1[i] = if den > 1e-15 { num1 / den } else { y1[i] };
571        out2[i] = if den > 1e-15 { num2 / den } else { y2[i] };
572    }
573    (out1, out2)
574}
575
576/// SD of each target after linearly regressing it on the adjustment set, i.e.
577/// `(SD(first | Z), SD(second | Z))`.
578///
579/// Falls back to the marginal SD when there is nothing to adjust for (empty `Z`, or a
580/// degenerate design the backend refuses) — with no covariates the partial and marginal
581/// quantities coincide, so that fallback is exact rather than approximate.
582///
583/// Both targets regress on the identical `n × (|Z|+1)` design, so build it (and the
584/// least-squares workspace) once and solve twice — one `least_squares` call per RHS keeps
585/// each target's numeric path, and therefore its result, bit-identical to the historical
586/// one-design-per-target form.
587pub(crate) fn residual_sd_pair_on_adjustment(
588    problem: &RefutationProblem<'_>,
589    first: VariableId,
590    second: VariableId,
591    mask: &[bool],
592) -> Result<(f64, f64), ValidationError> {
593    let z_ids = problem.estimand.adjustment_set.to_vec();
594    let ya = problem.data.float64_masked(first, mask).map_err(ValidationError::from)?;
595    let yb = problem.data.float64_masked(second, mask).map_err(ValidationError::from)?;
596    // Both fallback conditions depend on the mask and `Z` only, never on the target, so the
597    // shared checks reproduce the per-target checks exactly (`ya.len() == yb.len()` by mask).
598    if z_ids.is_empty() || ya.len() < z_ids.len() + 2 {
599        return Ok((sample_sd(&ya), sample_sd(&yb)));
600    }
601    let n = ya.len();
602    let Some(design) = adjustment_design(problem, mask, n, &z_ids)? else {
603        return Ok((sample_sd(&ya), sample_sd(&yb)));
604    };
605    let mut ws = LeastSquaresWorkspace::default();
606    let ncols = z_ids.len() + 1;
607    let sd_a = residual_sd_given_design(&design, n, ncols, &ya, &mut ws);
608    let sd_b = residual_sd_given_design(&design, n, ncols, &yb, &mut ws);
609    Ok((sd_a, sd_b))
610}
611
612/// Column-major `[intercept | Z…]` design over the masked rows, or `None` when a covariate
613/// extraction disagrees with `n` (callers fall back to the marginal SD, as before).
614fn adjustment_design(
615    problem: &RefutationProblem<'_>,
616    mask: &[bool],
617    n: usize,
618    z_ids: &[VariableId],
619) -> Result<Option<Vec<f64>>, ValidationError> {
620    let ncols = z_ids.len() + 1;
621    let mut design = Vec::with_capacity(n * ncols);
622    design.extend(std::iter::repeat_n(1.0, n));
623    for &z in z_ids {
624        let col = problem.data.float64_masked(z, mask).map_err(ValidationError::from)?;
625        if col.len() != n {
626            return Ok(None);
627        }
628        design.extend_from_slice(&col);
629    }
630    Ok(Some(design))
631}
632
633/// Residual SD of `y` on a prebuilt design; falls back to the marginal SD on solver refusal,
634/// non-finite coefficients, or a non-finite residual SD (matching the historical guards).
635fn residual_sd_given_design(
636    design: &[f64],
637    n: usize,
638    ncols: usize,
639    y: &[f64],
640    ws: &mut LeastSquaresWorkspace,
641) -> f64 {
642    let Ok(fit) = FaerBackend.least_squares(design, n, ncols, y, ws) else {
643        return sample_sd(y);
644    };
645    if fit.coefficients.iter().any(|c| !c.is_finite()) {
646        return sample_sd(y);
647    }
648    let residuals: Vec<f64> = (0..n)
649        .map(|r| {
650            let mut pred = fit.coefficients[0];
651            for c in 1..ncols {
652                pred += fit.coefficients[c] * design[c * n + r];
653            }
654            y[r] - pred
655        })
656        .collect();
657    let sd = sample_sd(&residuals);
658    if sd.is_finite() { sd } else { sample_sd(y) }
659}
660
661/// Row-major covariate matrix over the complete-case rows of `mask` (adjustment ∪ {T, Y};
662/// the caller computes that mask once and shares it with the residualization step).
663fn covariate_matrix(
664    problem: &RefutationProblem<'_>,
665    mask: &[bool],
666) -> Result<(Vec<f64>, usize, usize), ValidationError> {
667    let ids = problem.estimand.adjustment_set.to_vec();
668    let n = mask.iter().filter(|&&k| k).count();
669    if ids.is_empty() {
670        return Ok((vec![1.0; n], n, 1));
671    }
672    let dim = ids.len();
673    let mut cov = vec![0.0; n * dim];
674    for (c, &z) in ids.iter().enumerate() {
675        let col = problem.data.float64_masked(z, mask).map_err(ValidationError::from)?;
676        for (r, &v) in col.iter().enumerate() {
677            cov[r * dim + c] = v;
678        }
679    }
680    Ok((cov, n, dim))
681}
682
683fn silverman_bandwidth(cov_rowmajor: &[f64], n: usize, dim: usize) -> f64 {
684    if n == 0 || dim == 0 {
685        return 1.0;
686    }
687    let mut sum_sd = 0.0;
688    for d in 0..dim {
689        let mut vals = Vec::with_capacity(n);
690        for r in 0..n {
691            vals.push(cov_rowmajor[r * dim + d]);
692        }
693        sum_sd += sample_sd(&vals);
694    }
695    let mean_sd = (sum_sd / dim as f64).max(1e-6);
696    mean_sd * (n as f64).powf(-1.0 / (dim as f64 + 4.0))
697}
698
699/// Nonparametric sensitivity: kernel-residualize T and Y on Z, then partial-R² grid on residuals.
700#[derive(Clone, Debug)]
701pub struct NonparametricSensitivity {
702    /// Ascending grid of partial-R² values to test on residualized series.
703    pub partial_r2_grid: Vec<f64>,
704    /// Pass if the robustness value exceeds this threshold.
705    pub pass_threshold: f64,
706    /// Optional bandwidth override; `None` uses Silverman's (1986) rule of thumb.
707    pub bandwidth: Option<f64>,
708}
709
710impl Default for NonparametricSensitivity {
711    fn default() -> Self {
712        Self::new()
713    }
714}
715
716impl NonparametricSensitivity {
717    /// Defaults: same partial-R² grid as linear sensitivity, pass threshold 0.1.
718    #[must_use]
719    pub fn new() -> Self {
720        Self { partial_r2_grid: default_grid(), pass_threshold: 0.1, bandwidth: None }
721    }
722
723    /// Run nonparametric sensitivity.
724    ///
725    /// # Errors
726    ///
727    /// Data failures or empty `partial_r2_grid`.
728    pub fn refute(
729        &self,
730        problem: &RefutationProblem<'_>,
731        _workspace: &mut EstimationWorkspace,
732        ctx: &ExecutionContext,
733    ) -> Result<RefutationReport, ValidationError> {
734        if self.partial_r2_grid.is_empty() {
735            return Err(ValidationError::NotApplicable {
736                message: "nonparametric sensitivity requires a non-empty partial_r2_grid",
737            });
738        }
739        // One complete-case mask (adjustment ∪ {T, Y}) shared by the covariate matrix and
740        // the residualization pulls below — the two call sites used the identical id list.
741        let mut ids = problem.estimand.adjustment_set.to_vec();
742        ids.push(problem.treatment());
743        ids.push(problem.outcome());
744        let mask = problem.data.complete_case_mask(&ids).map_err(ValidationError::from)?;
745        let (cov, n, dim) = covariate_matrix(problem, &mask)?;
746        let t = problem
747            .data
748            .float64_masked(problem.treatment(), &mask)
749            .map_err(ValidationError::from)?;
750        let y =
751            problem.data.float64_masked(problem.outcome(), &mask).map_err(ValidationError::from)?;
752        if t.len() != n || y.len() != n {
753            return Err(ValidationError::data_msg("nonparametric sensitivity row mismatch"));
754        }
755        let h = self.bandwidth.unwrap_or_else(|| silverman_bandwidth(&cov, n, dim));
756        let (t_hat, y_hat) = nw_loo_predict_pair(&t, &y, &cov, dim, h);
757        let t_res: Vec<f64> = t.iter().zip(&t_hat).map(|(&a, &b)| a - b).collect();
758        let y_res: Vec<f64> = y.iter().zip(&y_hat).map(|(&a, &b)| a - b).collect();
759
760        let residual_ate = residual_ols_ate(&t_res, &y_res);
761        let sd_t = sample_sd(&t_res).max(1e-12);
762        let sd_y = sample_sd(&y_res).max(1e-12);
763        let mut u = vec![0.0; n];
764        fill_gaussian(&mut u, ctx, 0xA7E0_000C_0000_u64);
765
766        let mut sorted_grid = self.partial_r2_grid.clone();
767        sorted_grid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
768        let original_sign = residual_ate.signum();
769        // Worst-case orientation, as in `run_grid`: load U on Y against the observed sign.
770        let dir = if residual_ate >= 0.0 { -1.0 } else { 1.0 };
771        let mut last_ate = residual_ate;
772        let mut robustness_value = sorted_grid.last().copied().unwrap_or(1.0);
773        for &r in &sorted_grid {
774            let r = r.clamp(0.0, 0.999);
775            let scale = (r / (1.0 - r)).sqrt();
776            let t_pert: Vec<f64> =
777                t_res.iter().zip(&u).map(|(&tv, &uu)| tv + scale * sd_t * uu).collect();
778            let y_pert: Vec<f64> =
779                y_res.iter().zip(&u).map(|(&yv, &uu)| yv + dir * scale * sd_y * uu).collect();
780            last_ate = residual_ols_ate(&t_pert, &y_pert);
781            if last_ate.abs() < 1e-9 || last_ate.signum() != original_sign {
782                robustness_value = r;
783                break;
784            }
785        }
786        let passed = robustness_value >= self.pass_threshold;
787        Ok(RefutationReport {
788            refuter: Arc::from("sensitivity.nonparametric"),
789            original_ate: problem.original.ate,
790            refuted_ate: last_ate,
791            comparison: robustness_value,
792            informative: true,
793            passed,
794            failure_condition: if passed {
795                None
796            } else {
797                Some(Arc::from(format!(
798                    "nonparametric residual effect explained away at partial R²={robustness_value}, \
799                     below threshold {}",
800                    self.pass_threshold
801                )))
802            },
803            replicates: self.partial_r2_grid.len() as u32,
804        })
805    }
806}
807
808fn residual_ols_ate(t: &[f64], y: &[f64]) -> f64 {
809    let n = t.len() as f64;
810    if n < 2.0 {
811        return f64::NAN;
812    }
813    let mean_t = t.iter().sum::<f64>() / n;
814    let mean_y = y.iter().sum::<f64>() / n;
815    let mut num = 0.0;
816    let mut den = 0.0;
817    for (&ti, &yi) in t.iter().zip(y) {
818        let dt = ti - mean_t;
819        num += dt * (yi - mean_y);
820        den += dt * dt;
821    }
822    if den < 1e-15 { 0.0 } else { num / den }
823}
824
825#[cfg(test)]
826mod gram_algebra {
827    use super::{assemble_perturbed_normal_eq, form_xty};
828    use antecedent_stats::form_xtx;
829
830    #[test]
831    fn assemble_matches_explicit_perturbed_design() {
832        // n=4, p=3: X = [1, T, Z], plus u.
833        let n = 4usize;
834        let p = 3usize;
835        let t = [0.0, 1.0, 0.0, 1.0];
836        let z = [0.2, 0.4, 0.6, 0.8];
837        let y = [1.0, 3.0, 2.0, 4.0];
838        let u = [0.5, -0.5, 1.0, -1.0];
839        let a = 0.3;
840        let b = -0.7;
841        let q = p + 1;
842        let mut w = vec![0.0; n * q];
843        for r in 0..n {
844            w[r] = 1.0;
845            w[n + r] = t[r];
846            w[2 * n + r] = z[r];
847            w[3 * n + r] = u[r];
848        }
849        let mut g = vec![0.0; q * q];
850        form_xtx(&w, n, q, &mut g);
851        let mut gy = vec![0.0; q];
852        form_xty(&w, n, q, &y, &mut gy);
853        let mut xtx = vec![0.0; p * p];
854        let mut xty = vec![0.0; p];
855        assemble_perturbed_normal_eq(&g, &gy, p, a, b, &mut xtx, &mut xty);
856
857        let mut xp = vec![0.0; n * p];
858        let mut yp = vec![0.0; n];
859        for r in 0..n {
860            xp[r] = 1.0;
861            xp[n + r] = t[r] + a * u[r];
862            xp[2 * n + r] = z[r];
863            yp[r] = y[r] + b * u[r];
864        }
865        let mut xtx_ref = vec![0.0; p * p];
866        form_xtx(&xp, n, p, &mut xtx_ref);
867        let mut xty_ref = vec![0.0; p];
868        form_xty(&xp, n, p, &yp, &mut xty_ref);
869        for i in 0..p * p {
870            assert!((xtx[i] - xtx_ref[i]).abs() < 1e-12, "xtx[{i}]");
871        }
872        for i in 0..p {
873            assert!((xty[i] - xty_ref[i]).abs() < 1e-12, "xty[{i}]");
874        }
875    }
876}