1#![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
36fn 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
59fn 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 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 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
185fn 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
214struct 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 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 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
287fn 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#[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#[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 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#[derive(Clone, Debug)]
385pub struct LinearSensitivity {
386 pub partial_r2_grid: Vec<f64>,
388 pub pass_threshold: f64,
390 pub estimator: LinearAdjustmentAte,
392}
393
394impl Default for LinearSensitivity {
395 fn default() -> Self {
396 Self::new()
397 }
398}
399
400impl LinearSensitivity {
401 #[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 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#[derive(Clone, Debug)]
460pub struct PartialLinearSensitivity {
461 pub partial_r2_grid: Vec<f64>,
463 pub pass_threshold: f64,
465 pub estimator: LinearAdjustmentAte,
467}
468
469impl Default for PartialLinearSensitivity {
470 fn default() -> Self {
471 Self::new()
472 }
473}
474
475impl PartialLinearSensitivity {
476 #[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 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
532fn 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
576pub(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 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
612fn 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
633fn 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
661fn 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#[derive(Clone, Debug)]
701pub struct NonparametricSensitivity {
702 pub partial_r2_grid: Vec<f64>,
704 pub pass_threshold: f64,
706 pub bandwidth: Option<f64>,
708}
709
710impl Default for NonparametricSensitivity {
711 fn default() -> Self {
712 Self::new()
713 }
714}
715
716impl NonparametricSensitivity {
717 #[must_use]
719 pub fn new() -> Self {
720 Self { partial_r2_grid: default_grid(), pass_threshold: 0.1, bandwidth: None }
721 }
722
723 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 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 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 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}