1#![allow(
13 clippy::cast_possible_truncation,
14 clippy::cast_precision_loss,
15 clippy::many_single_char_names,
16 clippy::similar_names,
17 clippy::float_cmp
18)]
19
20use std::sync::Arc;
21
22use antecedent_core::ExecutionContext;
23use antecedent_data::TableView;
24use antecedent_estimate::{EstimationWorkspace, LinearAdjustmentAte};
25
26use crate::common::{
27 RefutationProblem, RefutationReport, complete_case_rows, fill_gaussian, fit_once, float64_full,
28 linear_estimator_no_bootstrap, masked_sample_sd, refit_effect, sample_sd, with_replaced_float,
29};
30use crate::error::ValidationError;
31
32fn default_grid() -> Vec<f64> {
34 vec![0.01, 0.02, 0.05, 0.1, 0.2, 0.3, 0.5]
35}
36
37fn run_grid(
38 problem: &RefutationProblem<'_>,
39 workspace: &mut EstimationWorkspace,
40 ctx: &ExecutionContext,
41 estimator: &LinearAdjustmentAte,
42 grid: &[f64],
43 noise_stream: u64,
44 nonparametric: bool,
45) -> Result<(f64, f64, bool), ValidationError> {
46 let n = problem.data.row_count();
47 let t0 = float64_full(problem.data, problem.treatment())?;
48 let y0 = float64_full(problem.data, problem.outcome())?;
49 let mut ids = vec![problem.treatment(), problem.outcome()];
50 if problem.temporal.is_none() {
51 ids.extend_from_slice(&problem.estimand.adjustment_set);
52 }
53 let (mask, _valid) = complete_case_rows(problem.data, &ids)?;
54 let sd_t = masked_sample_sd(problem.data, problem.treatment(), &mask)?.max(1e-12);
55 let sd_y = masked_sample_sd(problem.data, problem.outcome(), &mask)?.max(1e-12);
56 let mut u = vec![0.0; n];
57 if nonparametric {
58 fill_bounded(&mut u, ctx, noise_stream);
59 } else {
60 fill_gaussian(&mut u, ctx, noise_stream);
61 }
62
63 let mut sorted_grid = grid.to_vec();
64 sorted_grid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
65
66 let original_sign = problem.original.ate.signum();
67 let dir = if problem.original.ate >= 0.0 { -1.0 } else { 1.0 };
71 let mut last_ate = problem.original.ate;
72 for &r in &sorted_grid {
73 let r = r.clamp(0.0, 0.999);
74 let scale = (r / (1.0 - r)).sqrt();
75 let t: Vec<f64> = t0.iter().zip(&u).map(|(&t, &u)| t + scale * sd_t * u).collect();
76 let y: Vec<f64> = y0.iter().zip(&u).map(|(&y, &u)| y + dir * scale * sd_y * u).collect();
77 let data = with_replaced_float(problem.data, problem.treatment(), Arc::from(t))?;
78 let data = with_replaced_float(&data, problem.outcome(), Arc::from(y))?;
79 let est = if problem.temporal.is_some() {
80 refit_effect(problem, &data, problem.estimand, &[], workspace, ctx)?
81 } else {
82 fit_once(estimator, &data, problem.estimand, problem.query, workspace, ctx)?
83 };
84 last_ate = est.ate;
85 let explained_away = est.ate.abs() < 1e-9 || est.ate.signum() != original_sign;
86 if explained_away {
87 return Ok((r, last_ate, true));
88 }
89 }
90 let robustness_value = sorted_grid.last().copied().unwrap_or(1.0);
91 Ok((robustness_value, last_ate, false))
92}
93
94fn fill_bounded(out: &mut [f64], ctx: &ExecutionContext, stream_id: u64) {
95 let mut rng = ctx.rng.stream(stream_id);
98 let sqrt3 = 3.0_f64.sqrt();
99 for slot in out.iter_mut() {
100 *slot = rng.next_f64().mul_add(2.0, -1.0) * sqrt3;
101 }
102}
103
104#[derive(Clone, Debug)]
106pub struct LinearSensitivity {
107 pub partial_r2_grid: Vec<f64>,
109 pub pass_threshold: f64,
111 pub estimator: LinearAdjustmentAte,
113}
114
115impl Default for LinearSensitivity {
116 fn default() -> Self {
117 Self::new()
118 }
119}
120
121impl LinearSensitivity {
122 #[must_use]
124 pub fn new() -> Self {
125 Self {
126 partial_r2_grid: default_grid(),
127 pass_threshold: 0.1,
128 estimator: linear_estimator_no_bootstrap(),
129 }
130 }
131
132 pub fn refute(
138 &self,
139 problem: &RefutationProblem<'_>,
140 workspace: &mut EstimationWorkspace,
141 ctx: &ExecutionContext,
142 ) -> Result<RefutationReport, ValidationError> {
143 if self.partial_r2_grid.is_empty() {
144 return Err(ValidationError::NotApplicable {
145 message: "linear sensitivity requires a non-empty partial_r2_grid",
146 });
147 }
148 let (robustness_value, refuted_ate, _explained_away) = run_grid(
149 problem,
150 workspace,
151 ctx,
152 &self.estimator,
153 &self.partial_r2_grid,
154 0xA7E0_000A_0000_u64,
155 false,
156 )?;
157 let passed = robustness_value >= self.pass_threshold;
158 Ok(RefutationReport {
159 refuter: Arc::from("sensitivity.linear"),
160 original_ate: problem.original.ate,
161 refuted_ate,
162 comparison: robustness_value,
163 informative: true,
164 passed,
165 failure_condition: if passed {
166 None
167 } else {
168 Some(Arc::from(format!(
169 "effect explained away at partial R²={robustness_value}, below threshold {}",
170 self.pass_threshold
171 )))
172 },
173 replicates: self.partial_r2_grid.len() as u32,
174 })
175 }
176}
177
178#[derive(Clone, Debug)]
181pub struct PartialLinearSensitivity {
182 pub partial_r2_grid: Vec<f64>,
184 pub pass_threshold: f64,
186 pub estimator: LinearAdjustmentAte,
188}
189
190impl Default for PartialLinearSensitivity {
191 fn default() -> Self {
192 Self::new()
193 }
194}
195
196impl PartialLinearSensitivity {
197 #[must_use]
199 pub fn new() -> Self {
200 Self {
201 partial_r2_grid: default_grid(),
202 pass_threshold: 0.1,
203 estimator: linear_estimator_no_bootstrap(),
204 }
205 }
206
207 pub fn refute(
213 &self,
214 problem: &RefutationProblem<'_>,
215 workspace: &mut EstimationWorkspace,
216 ctx: &ExecutionContext,
217 ) -> Result<RefutationReport, ValidationError> {
218 if self.partial_r2_grid.is_empty() {
219 return Err(ValidationError::NotApplicable {
220 message: "partial-linear sensitivity requires a non-empty partial_r2_grid",
221 });
222 }
223 let (robustness_value, refuted_ate, _explained_away) = run_grid(
224 problem,
225 workspace,
226 ctx,
227 &self.estimator,
228 &self.partial_r2_grid,
229 0xA7E0_000B_0000_u64,
230 true,
231 )?;
232 let passed = robustness_value >= self.pass_threshold;
233 Ok(RefutationReport {
234 refuter: Arc::from("sensitivity.partial_linear"),
235 original_ate: problem.original.ate,
236 refuted_ate,
237 comparison: robustness_value,
238 informative: true,
239 passed,
240 failure_condition: if passed {
241 None
242 } else {
243 Some(Arc::from(format!(
244 "effect explained away at partial R²={robustness_value}, below threshold {}",
245 self.pass_threshold
246 )))
247 },
248 replicates: self.partial_r2_grid.len() as u32,
249 })
250 }
251}
252
253fn nw_loo_predict(y: &[f64], cov_rowmajor: &[f64], dim: usize, bandwidth: f64) -> Vec<f64> {
255 let n = y.len();
256 let h2 = (bandwidth.max(1e-6)).powi(2);
257 let mut out = vec![0.0; n];
258 for i in 0..n {
259 let xi = &cov_rowmajor[i * dim..(i + 1) * dim];
260 let mut num = 0.0;
261 let mut den = 0.0;
262 for j in 0..n {
263 if i == j {
264 continue;
265 }
266 let xj = &cov_rowmajor[j * dim..(j + 1) * dim];
267 let mut d2 = 0.0;
268 for d in 0..dim {
269 let t = xi[d] - xj[d];
270 d2 += t * t;
271 }
272 let w = (-0.5 * d2 / h2).exp();
273 num += w * y[j];
274 den += w;
275 }
276 out[i] = if den > 1e-15 { num / den } else { y[i] };
277 }
278 out
279}
280
281fn covariate_matrix(
282 problem: &RefutationProblem<'_>,
283) -> Result<(Vec<f64>, usize, usize), ValidationError> {
284 let ids = problem.estimand.adjustment_set.to_vec();
285 let mut all = ids.clone();
286 all.push(problem.treatment());
287 all.push(problem.outcome());
288 let mask = problem.data.complete_case_mask(&all).map_err(ValidationError::from)?;
289 let n = mask.iter().filter(|&&k| k).count();
290 if ids.is_empty() {
291 return Ok((vec![1.0; n], n, 1));
292 }
293 let dim = ids.len();
294 let mut cov = vec![0.0; n * dim];
295 for (c, &z) in ids.iter().enumerate() {
296 let col = problem.data.float64_masked(z, &mask).map_err(ValidationError::from)?;
297 for (r, &v) in col.iter().enumerate() {
298 cov[r * dim + c] = v;
299 }
300 }
301 Ok((cov, n, dim))
302}
303
304fn silverman_bandwidth(cov_rowmajor: &[f64], n: usize, dim: usize) -> f64 {
305 if n == 0 || dim == 0 {
306 return 1.0;
307 }
308 let mut sum_sd = 0.0;
309 for d in 0..dim {
310 let mut vals = Vec::with_capacity(n);
311 for r in 0..n {
312 vals.push(cov_rowmajor[r * dim + d]);
313 }
314 sum_sd += sample_sd(&vals);
315 }
316 let mean_sd = (sum_sd / dim as f64).max(1e-6);
317 mean_sd * (n as f64).powf(-1.0 / (dim as f64 + 4.0))
318}
319
320#[derive(Clone, Debug)]
322pub struct NonparametricSensitivity {
323 pub partial_r2_grid: Vec<f64>,
325 pub pass_threshold: f64,
327 pub bandwidth: Option<f64>,
329}
330
331impl Default for NonparametricSensitivity {
332 fn default() -> Self {
333 Self::new()
334 }
335}
336
337impl NonparametricSensitivity {
338 #[must_use]
340 pub fn new() -> Self {
341 Self { partial_r2_grid: default_grid(), pass_threshold: 0.1, bandwidth: None }
342 }
343
344 pub fn refute(
350 &self,
351 problem: &RefutationProblem<'_>,
352 _workspace: &mut EstimationWorkspace,
353 ctx: &ExecutionContext,
354 ) -> Result<RefutationReport, ValidationError> {
355 if self.partial_r2_grid.is_empty() {
356 return Err(ValidationError::NotApplicable {
357 message: "nonparametric sensitivity requires a non-empty partial_r2_grid",
358 });
359 }
360 let (cov, n, dim) = covariate_matrix(problem)?;
361 let mut ids = problem.estimand.adjustment_set.to_vec();
362 ids.push(problem.treatment());
363 ids.push(problem.outcome());
364 let mask = problem.data.complete_case_mask(&ids).map_err(ValidationError::from)?;
365 let t = problem
366 .data
367 .float64_masked(problem.treatment(), &mask)
368 .map_err(ValidationError::from)?;
369 let y =
370 problem.data.float64_masked(problem.outcome(), &mask).map_err(ValidationError::from)?;
371 if t.len() != n || y.len() != n {
372 return Err(ValidationError::data_msg("nonparametric sensitivity row mismatch"));
373 }
374 let h = self.bandwidth.unwrap_or_else(|| silverman_bandwidth(&cov, n, dim));
375 let t_hat = nw_loo_predict(&t, &cov, dim, h);
376 let y_hat = nw_loo_predict(&y, &cov, dim, h);
377 let t_res: Vec<f64> = t.iter().zip(&t_hat).map(|(&a, &b)| a - b).collect();
378 let y_res: Vec<f64> = y.iter().zip(&y_hat).map(|(&a, &b)| a - b).collect();
379
380 let residual_ate = residual_ols_ate(&t_res, &y_res);
381 let sd_t = sample_sd(&t_res).max(1e-12);
382 let sd_y = sample_sd(&y_res).max(1e-12);
383 let mut u = vec![0.0; n];
384 fill_gaussian(&mut u, ctx, 0xA7E0_000C_0000_u64);
385
386 let mut sorted_grid = self.partial_r2_grid.clone();
387 sorted_grid.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
388 let original_sign = residual_ate.signum();
389 let dir = if residual_ate >= 0.0 { -1.0 } else { 1.0 };
391 let mut last_ate = residual_ate;
392 let mut robustness_value = sorted_grid.last().copied().unwrap_or(1.0);
393 for &r in &sorted_grid {
394 let r = r.clamp(0.0, 0.999);
395 let scale = (r / (1.0 - r)).sqrt();
396 let t_pert: Vec<f64> =
397 t_res.iter().zip(&u).map(|(&tv, &uu)| tv + scale * sd_t * uu).collect();
398 let y_pert: Vec<f64> =
399 y_res.iter().zip(&u).map(|(&yv, &uu)| yv + dir * scale * sd_y * uu).collect();
400 last_ate = residual_ols_ate(&t_pert, &y_pert);
401 if last_ate.abs() < 1e-9 || last_ate.signum() != original_sign {
402 robustness_value = r;
403 break;
404 }
405 }
406 let passed = robustness_value >= self.pass_threshold;
407 Ok(RefutationReport {
408 refuter: Arc::from("sensitivity.nonparametric"),
409 original_ate: problem.original.ate,
410 refuted_ate: last_ate,
411 comparison: robustness_value,
412 informative: true,
413 passed,
414 failure_condition: if passed {
415 None
416 } else {
417 Some(Arc::from(format!(
418 "nonparametric residual effect explained away at partial R²={robustness_value}, \
419 below threshold {}",
420 self.pass_threshold
421 )))
422 },
423 replicates: self.partial_r2_grid.len() as u32,
424 })
425 }
426}
427
428fn residual_ols_ate(t: &[f64], y: &[f64]) -> f64 {
429 let n = t.len() as f64;
430 if n < 2.0 {
431 return f64::NAN;
432 }
433 let mean_t = t.iter().sum::<f64>() / n;
434 let mean_y = y.iter().sum::<f64>() / n;
435 let mut num = 0.0;
436 let mut den = 0.0;
437 for (&ti, &yi) in t.iter().zip(y) {
438 let dt = ti - mean_t;
439 num += dt * (yi - mean_y);
440 den += dt * dt;
441 }
442 if den < 1e-15 { 0.0 } else { num / den }
443}