Skip to main content

dml_core/
sensitivity.rs

1//! Sensitivity analysis for omitted confounders (Chernozhukov, Cinelli, Newey,
2//! Sharma & Syrgkanis, 2022, "Long Story Short"), as in DoubleML.
3//!
4//! If an unobserved confounder `A` was omitted, the estimate is biased by at
5//! most
6//!
7//! ```text
8//! |bias| ≤ |ρ| · √(c_y · c_d / (1 − c_d)) · √(σ² · ν²)
9//! ```
10//!
11//! where `σ²` is the residual variance of the outcome, `ν²` the variance of the
12//! Riesz representer, `c_y` the share of the outcome's residual variation
13//! explained by `A`, `c_d` the relative gain in the Riesz representer's
14//! variation from `A`, and `ρ` the correlation between the two (adversarial at
15//! 1). [`DmlFit::sensitivity_analysis`] turns a scenario `(c_y, c_d, ρ)` into
16//! bounds on θ with confidence bounds, and computes robustness values: how
17//! strong confounding (`c_y = c_d`) must be to move θ (RV) or its confidence
18//! bound (RVa) to the null hypothesis.
19
20use std::fmt;
21
22use ndarray::{Array1, Array2, Array3, ArrayView2};
23
24use crate::error::{DmlError, Result};
25use crate::fit::{aggregate, DmlFit};
26use crate::stats::{median, norm_ppf};
27
28/// How a model's `ν²` is formed from per-observation elements.
29#[derive(Debug, Clone)]
30pub enum Nu2Parts {
31    /// `ν² = mean(e)` with per-observation elements `e = 2·m(W, α) − α²`.
32    Elementwise(Array1<f64>),
33    /// Partially linear models: `ν² = 1 / mean(v²)` for the treatment residual
34    /// `v`, with Riesz representer `α = v · ν²`.
35    InverseMeanSquare(Array1<f64>),
36}
37
38/// The per-observation ingredients of a model's sensitivity elements, produced
39/// while estimating the score.
40///
41/// The outcome residual is `a − θ·b` (`b` only for partially linear models,
42/// whose residual depends on θ).
43#[derive(Debug, Clone)]
44pub struct SensitivityParts {
45    pub outcome_residual: Array1<f64>,
46    pub theta_factor: Option<Array1<f64>>,
47    /// Riesz representer `α`; ignored for [`Nu2Parts::InverseMeanSquare`],
48    /// where it follows from `v`.
49    pub riesz: Array1<f64>,
50    pub nu2: Nu2Parts,
51}
52
53/// Sensitivity elements of one treatment and repetition.
54pub(crate) struct Elements {
55    pub sigma2: f64,
56    pub nu2: f64,
57    pub psi_sigma2: Array1<f64>,
58    pub psi_nu2: Array1<f64>,
59}
60
61impl SensitivityParts {
62    /// `σ²`, `ν²` and their influence functions at the estimate θ. A non-positive
63    /// `ν²` falls back to the (non-orthogonal) `mean(α²)`, as DoubleML does.
64    pub(crate) fn elements(&self, theta: f64) -> Elements {
65        let resid = match &self.theta_factor {
66            Some(b) => &self.outcome_residual - &(theta * b),
67            None => self.outcome_residual.clone(),
68        };
69        let sigma_el = resid.mapv(|e| e * e);
70        let sigma2 = sigma_el.mean().expect("n > 0");
71        let (nu2, psi_nu2, riesz) = match &self.nu2 {
72            Nu2Parts::Elementwise(e) => {
73                let nu2 = e.mean().expect("n > 0");
74                (nu2, e - nu2, self.riesz.clone())
75            }
76            Nu2Parts::InverseMeanSquare(v) => {
77                let v2 = v.mapv(|x| x * x);
78                let nu2 = 1.0 / v2.mean().expect("n > 0");
79                (nu2, v2.mapv(|s| nu2 - s * nu2 * nu2), v * nu2)
80            }
81        };
82        let (nu2, psi_nu2) = if nu2 > 0.0 {
83            (nu2, psi_nu2)
84        } else {
85            let alpha2 = riesz.mapv(|a| a * a);
86            (alpha2.mean().expect("n > 0"), alpha2)
87        };
88        Elements {
89            sigma2,
90            nu2,
91            psi_sigma2: sigma_el - sigma2,
92            psi_nu2,
93        }
94    }
95}
96
97/// Stored sensitivity elements of a fit, per treatment `j` and repetition `r`.
98#[derive(Debug, Clone)]
99pub(crate) struct SensitivityStore {
100    /// Shape `(k, n_rep)`.
101    pub sigma2: Array2<f64>,
102    pub nu2: Array2<f64>,
103    pub max_bias: Array2<f64>,
104    /// Shape `(n, k, n_rep)`.
105    pub psi_max_bias: Array3<f64>,
106    /// Population variance of the outcome (for benchmarking).
107    pub var_y: f64,
108}
109
110impl SensitivityStore {
111    pub(crate) fn new(n: usize, k: usize, n_rep: usize, var_y: f64) -> Self {
112        Self {
113            sigma2: Array2::zeros((k, n_rep)),
114            nu2: Array2::zeros((k, n_rep)),
115            max_bias: Array2::zeros((k, n_rep)),
116            psi_max_bias: Array3::zeros((n, k, n_rep)),
117            var_y,
118        }
119    }
120
121    /// Record one treatment / repetition: `max_bias = √(σ²ν²)` and its
122    /// influence function `(σ²·ψ_ν² + ν²·ψ_σ²) / (2·max_bias)`.
123    pub(crate) fn set(&mut self, j: usize, r: usize, e: &Elements) {
124        let max_bias = (e.sigma2 * e.nu2).sqrt();
125        self.sigma2[[j, r]] = e.sigma2;
126        self.nu2[[j, r]] = e.nu2;
127        self.max_bias[[j, r]] = max_bias;
128        let psi = (e.sigma2 * &e.psi_nu2 + e.nu2 * &e.psi_sigma2) / (2.0 * max_bias);
129        self.psi_max_bias
130            .slice_mut(ndarray::s![.., j, r])
131            .assign(&psi);
132    }
133}
134
135/// A confounding scenario for [`DmlFit::sensitivity_analysis`].
136#[derive(Debug, Clone, PartialEq)]
137pub struct SensitivityParams {
138    pub cf_y: f64,
139    pub cf_d: f64,
140    pub rho: f64,
141    pub level: f64,
142    /// One null hypothesis per treatment, or a single value for all.
143    pub null_hypothesis: Vec<f64>,
144}
145
146impl Default for SensitivityParams {
147    /// DoubleML's defaults: `c_y = c_d = 0.03`, `ρ = 1`, level 0.95, `H0: θ = 0`.
148    fn default() -> Self {
149        Self {
150            cf_y: 0.03,
151            cf_d: 0.03,
152            rho: 1.0,
153            level: 0.95,
154            null_hypothesis: vec![0.0],
155        }
156    }
157}
158
159impl SensitivityParams {
160    pub fn new() -> Self {
161        Self::default()
162    }
163
164    /// Share of the outcome's residual variation explained by the confounder.
165    pub fn cf_y(mut self, cf_y: f64) -> Self {
166        self.cf_y = cf_y;
167        self
168    }
169
170    /// Relative gain in the Riesz representer's variation from the confounder.
171    pub fn cf_d(mut self, cf_d: f64) -> Self {
172        self.cf_d = cf_d;
173        self
174    }
175
176    /// Correlation of the two confounding channels, in `[−1, 1]`.
177    pub fn rho(mut self, rho: f64) -> Self {
178        self.rho = rho;
179        self
180    }
181
182    /// Level of the one-sided confidence bounds.
183    pub fn level(mut self, level: f64) -> Self {
184        self.level = level;
185        self
186    }
187
188    pub fn null_hypothesis(mut self, value: f64) -> Self {
189        self.null_hypothesis = vec![value];
190        self
191    }
192
193    pub fn null_hypotheses(mut self, values: Vec<f64>) -> Self {
194        self.null_hypothesis = values;
195        self
196    }
197
198    fn validate(&self, k: usize) -> Result<()> {
199        let open01 = |v: f64| (0.0..1.0).contains(&v);
200        if !open01(self.cf_y) || !open01(self.cf_d) {
201            return Err(DmlError::InvalidArgument(
202                "cf_y and cf_d must be in [0, 1)".into(),
203            ));
204        }
205        if self.rho.is_nan() || self.rho.abs() > 1.0 {
206            return Err(DmlError::InvalidArgument("rho must be in [-1, 1]".into()));
207        }
208        if !(self.level > 0.0 && self.level < 1.0) {
209            return Err(DmlError::InvalidArgument("level must be in (0, 1)".into()));
210        }
211        if self.null_hypothesis.len() != 1 && self.null_hypothesis.len() != k {
212            return Err(DmlError::DimensionMismatch(format!(
213                "give one null hypothesis or one per treatment ({k})"
214            )));
215        }
216        Ok(())
217    }
218}
219
220/// Bounds under one confounding scenario (per treatment).
221#[derive(Debug, Clone)]
222struct Bounds {
223    theta: (Array1<f64>, Array1<f64>),
224    se: (Array1<f64>, Array1<f64>),
225    ci: (Array1<f64>, Array1<f64>),
226}
227
228/// Result of [`DmlFit::sensitivity_analysis`].
229#[derive(Debug, Clone)]
230pub struct SensitivityResult {
231    pub params: SensitivityParams,
232    treatment_names: Vec<String>,
233    theta: Array1<f64>,
234    pub theta_lower: Array1<f64>,
235    pub theta_upper: Array1<f64>,
236    pub se_lower: Array1<f64>,
237    pub se_upper: Array1<f64>,
238    /// One-sided confidence bounds at `level`.
239    pub ci_lower: Array1<f64>,
240    pub ci_upper: Array1<f64>,
241    /// Robustness value: confounding strength `c_y = c_d` that moves θ to the null.
242    pub rv: Array1<f64>,
243    /// Robustness value for the confidence bound.
244    pub rva: Array1<f64>,
245}
246
247/// Largest confounding strength searched for robustness values (as DoubleML).
248const RV_MAX: f64 = 0.9999;
249
250impl DmlFit {
251    /// Bounds on θ, their confidence bounds and robustness values under the
252    /// confounding scenario `params` (PLR, IRM and DID models).
253    pub fn sensitivity_analysis(&self, params: &SensitivityParams) -> Result<SensitivityResult> {
254        let store = self.sensitivity_store()?;
255        let k = self.n_treat();
256        params.validate(k)?;
257        let b = self.bounds(store, params.cf_y, params.cf_d, params.rho, params.level);
258        let null = |j: usize| {
259            params.null_hypothesis[if params.null_hypothesis.len() == 1 {
260                0
261            } else {
262                j
263            }]
264        };
265        let mut rv = Array1::zeros(k);
266        let mut rva = Array1::zeros(k);
267        for j in 0..k {
268            let h0 = null(j);
269            let upper = h0 > self.coef[j];
270            let theta_at = |v: f64| {
271                let b = self.bounds(store, v, v, params.rho, params.level);
272                if upper {
273                    b.theta.1[j]
274                } else {
275                    b.theta.0[j]
276                }
277            };
278            let ci_at = |v: f64| {
279                let b = self.bounds(store, v, v, params.rho, params.level);
280                if upper {
281                    b.ci.1[j]
282                } else {
283                    b.ci.0[j]
284                }
285            };
286            rv[j] = robustness_value(theta_at, h0, upper);
287            rva[j] = robustness_value(ci_at, h0, upper);
288        }
289        Ok(SensitivityResult {
290            params: SensitivityParams {
291                null_hypothesis: (0..k).map(null).collect(),
292                ..params.clone()
293            },
294            treatment_names: self.treatment_names.clone(),
295            theta: self.coef.clone(),
296            theta_lower: b.theta.0,
297            theta_upper: b.theta.1,
298            se_lower: b.se.0,
299            se_upper: b.se.1,
300            ci_lower: b.ci.0,
301            ci_upper: b.ci.1,
302            rv,
303            rva,
304        })
305    }
306
307    /// Sensitivity elements `(σ², ν²)`, each of shape `(n_treat, n_rep)`:
308    /// the residual variance of the outcome and the variance of the Riesz
309    /// representer. `None` for models without sensitivity analysis.
310    pub fn sensitivity_elements(&self) -> Option<(ArrayView2<'_, f64>, ArrayView2<'_, f64>)> {
311        self.sensitivity
312            .as_ref()
313            .map(|s| (s.sigma2.view(), s.nu2.view()))
314    }
315
316    fn sensitivity_store(&self) -> Result<&SensitivityStore> {
317        self.sensitivity.as_ref().ok_or_else(|| {
318            DmlError::InvalidArgument(
319                "sensitivity analysis is available for PLR, IRM and DID fits".into(),
320            )
321        })
322    }
323
324    /// DoubleML's `_calc_sensitivity_analysis`.
325    fn bounds(
326        &self,
327        store: &SensitivityStore,
328        cf_y: f64,
329        cf_d: f64,
330        rho: f64,
331        level: f64,
332    ) -> Bounds {
333        let strength = rho.abs() * (cf_y * cf_d / (1.0 - cf_d)).sqrt();
334        let (n, k, n_rep) = self.psi.dim();
335        let mut theta_lo = Array2::zeros((k, n_rep));
336        let mut theta_hi = Array2::zeros((k, n_rep));
337        let mut se_lo = Array2::zeros((k, n_rep));
338        let mut se_hi = Array2::zeros((k, n_rep));
339        for j in 0..k {
340            for r in 0..n_rep {
341                let shift = strength * store.max_bias[[j, r]];
342                theta_lo[[j, r]] = self.all_coef[[j, r]] - shift;
343                theta_hi[[j, r]] = self.all_coef[[j, r]] + shift;
344                let jac = self.psi_deriv[[j, r]];
345                let (mut ss_lo, mut ss_hi) = (0.0, 0.0);
346                for i in 0..n {
347                    let scaled = self.psi[[i, j, r]] / jac;
348                    let pmb = strength * store.psi_max_bias[[i, j, r]];
349                    ss_lo += (scaled - pmb).powi(2);
350                    ss_hi += (scaled + pmb).powi(2);
351                }
352                se_lo[[j, r]] = (ss_lo / n as f64 / n as f64).sqrt();
353                se_hi[[j, r]] = (ss_hi / n as f64 / n as f64).sqrt();
354            }
355        }
356        let quant = norm_ppf(level);
357        let ci = |theta: &Array2<f64>, se: &Array2<f64>, sign: f64| -> Array1<f64> {
358            (0..k)
359                .map(|j| {
360                    let v: Vec<f64> = (0..n_rep)
361                        .map(|r| theta[[j, r]] + sign * quant * se[[j, r]])
362                        .collect();
363                    median(&v)
364                })
365                .collect()
366        };
367        let ci_lo = ci(&theta_lo, &se_lo, -1.0);
368        let ci_hi = ci(&theta_hi, &se_hi, 1.0);
369        let (t_lo, s_lo) = aggregate(&theta_lo, &se_lo);
370        let (t_hi, s_hi) = aggregate(&theta_hi, &se_hi);
371        Bounds {
372            theta: (t_lo, t_hi),
373            se: (s_lo, s_hi),
374            ci: (ci_lo, ci_hi),
375        }
376    }
377
378    /// Calibrate a confounding scenario against observed covariates: `short`
379    /// must be the same model refitted **on the same sample splits** without
380    /// the benchmarking covariates (see `sensitivity_benchmark` on the models).
381    pub fn sensitivity_benchmark(&self, short: &DmlFit) -> Result<SensitivityBenchmark> {
382        let long = self.sensitivity_store()?;
383        let short_store = short.sensitivity_store()?;
384        if short.all_coef.dim() != self.all_coef.dim() {
385            return Err(DmlError::DimensionMismatch(
386                "long and short fits need the same treatments and repetitions".into(),
387            ));
388        }
389        let (k, n_rep) = self.all_coef.dim();
390        let (mut cf_y, mut cf_d, mut rho, mut delta) = (
391            Array1::zeros(k),
392            Array1::zeros(k),
393            Array1::zeros(k),
394            Array1::zeros(k),
395        );
396        for j in 0..k {
397            let mut cy = Vec::with_capacity(n_rep);
398            let mut cd = Vec::with_capacity(n_rep);
399            let mut rh = Vec::with_capacity(n_rep);
400            let mut dt = Vec::with_capacity(n_rep);
401            for r in 0..n_rep {
402                let (s_long, s_short) = (long.sigma2[[j, r]], short_store.sigma2[[j, r]]);
403                let (n_long, n_short) = (long.nu2[[j, r]], short_store.nu2[[j, r]]);
404                let r2_long = 1.0 - s_long / long.var_y;
405                let r2_short = 1.0 - s_short / long.var_y;
406                let r2_riesz = n_short / n_long;
407                cy.push(((r2_long - r2_short) / (1.0 - r2_long)).clamp(0.0, 1.0));
408                cd.push(((1.0 - r2_riesz) / r2_riesz).clamp(0.0, 1.0));
409                let d_theta = short.all_coef[[j, r]] - self.all_coef[[j, r]];
410                dt.push(d_theta);
411                let (var_g, var_riesz) = (s_short - s_long, n_long - n_short);
412                let denom = if var_g > 0.0 && var_riesz > 0.0 {
413                    (var_g * var_riesz).sqrt()
414                } else {
415                    0.0
416                };
417                let magnitude = if denom != 0.0 {
418                    (d_theta.abs() / denom).clamp(0.0, 1.0)
419                } else {
420                    1.0
421                };
422                let sign = if d_theta > 0.0 {
423                    1.0
424                } else if d_theta < 0.0 {
425                    -1.0
426                } else {
427                    0.0
428                };
429                rh.push(magnitude * sign);
430            }
431            cf_y[j] = median(&cy);
432            cf_d[j] = median(&cd);
433            rho[j] = median(&rh);
434            delta[j] = median(&dt);
435        }
436        Ok(SensitivityBenchmark {
437            treatment_names: self.treatment_names.clone(),
438            cf_y,
439            cf_d,
440            rho,
441            delta_theta: delta,
442        })
443    }
444}
445
446/// Smallest `v ∈ [0, 0.9999]` at which `bound(v)` reaches `h0` (bisection on a
447/// bound that moves monotonically towards the null as confounding grows).
448/// Returns 0 if the bound already reaches the null without confounding and
449/// 0.9999 if it never does, matching DoubleML's bounded search.
450fn robustness_value(bound: impl Fn(f64) -> f64, h0: f64, upper: bool) -> f64 {
451    // gap > 0 while the bound has not yet crossed the null.
452    let gap = |v: f64| if upper { h0 - bound(v) } else { bound(v) - h0 };
453    if gap(0.0) <= 0.0 {
454        return 0.0;
455    }
456    if gap(RV_MAX) > 0.0 {
457        return RV_MAX;
458    }
459    let (mut lo, mut hi) = (0.0, RV_MAX);
460    for _ in 0..100 {
461        let mid = 0.5 * (lo + hi);
462        if gap(mid) > 0.0 {
463            lo = mid;
464        } else {
465            hi = mid;
466        }
467        if hi - lo < 1e-13 {
468            break;
469        }
470    }
471    0.5 * (lo + hi)
472}
473
474/// Benchmark values for `(c_y, c_d, ρ)` from omitting observed covariates.
475#[derive(Debug, Clone)]
476pub struct SensitivityBenchmark {
477    treatment_names: Vec<String>,
478    pub cf_y: Array1<f64>,
479    pub cf_d: Array1<f64>,
480    pub rho: Array1<f64>,
481    /// Change in the estimate when the covariates are dropped.
482    pub delta_theta: Array1<f64>,
483}
484
485impl SensitivityBenchmark {
486    /// The benchmarked scenario for treatment `j`, ready for
487    /// [`DmlFit::sensitivity_analysis`].
488    pub fn params(&self, j: usize) -> SensitivityParams {
489        SensitivityParams::new()
490            .cf_y(self.cf_y[j])
491            .cf_d(self.cf_d[j])
492            .rho(self.rho[j])
493    }
494}
495
496impl fmt::Display for SensitivityResult {
497    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
498        let p = &self.params;
499        writeln!(
500            f,
501            "================== Sensitivity Analysis =================="
502        )?;
503        writeln!(f)?;
504        writeln!(f, "------------------ Scenario          ------------------")?;
505        writeln!(f, "Significance Level: level={}", p.level)?;
506        writeln!(
507            f,
508            "Sensitivity parameters: cf_y={}; cf_d={}, rho={}",
509            p.cf_y, p.cf_d, p.rho
510        )?;
511        writeln!(f)?;
512        writeln!(f, "------------------ Bounds with CI    ------------------")?;
513        let w = self
514            .treatment_names
515            .iter()
516            .map(String::len)
517            .max()
518            .unwrap_or(1)
519            .max(2);
520        writeln!(
521            f,
522            "{:<w$} {:>10} {:>12} {:>10} {:>12} {:>10}",
523            "", "CI lower", "theta lower", "theta", "theta upper", "CI upper"
524        )?;
525        for (j, name) in self.treatment_names.iter().enumerate() {
526            writeln!(
527                f,
528                "{:<w$} {:>10.6} {:>12.6} {:>10.6} {:>12.6} {:>10.6}",
529                name,
530                self.ci_lower[j],
531                self.theta_lower[j],
532                self.theta[j],
533                self.theta_upper[j],
534                self.ci_upper[j]
535            )?;
536        }
537        writeln!(f)?;
538        writeln!(f, "------------------ Robustness Values ------------------")?;
539        writeln!(
540            f,
541            "{:<w$} {:>8} {:>10} {:>10}",
542            "", "H_0", "RV (%)", "RVa (%)"
543        )?;
544        for (j, name) in self.treatment_names.iter().enumerate() {
545            write!(
546                f,
547                "{:<w$} {:>8.1} {:>10.6} {:>10.6}",
548                name,
549                p.null_hypothesis[j],
550                100.0 * self.rv[j],
551                100.0 * self.rva[j]
552            )?;
553            if j + 1 < self.treatment_names.len() {
554                writeln!(f)?;
555            }
556        }
557        Ok(())
558    }
559}
560
561impl fmt::Display for SensitivityBenchmark {
562    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
563        let w = self
564            .treatment_names
565            .iter()
566            .map(String::len)
567            .max()
568            .unwrap_or(1)
569            .max(2);
570        write!(
571            f,
572            "{:<w$} {:>10} {:>10} {:>10} {:>12}",
573            "", "cf_y", "cf_d", "rho", "delta_theta"
574        )?;
575        for (j, name) in self.treatment_names.iter().enumerate() {
576            write!(
577                f,
578                "\n{:<w$} {:>10.6} {:>10.6} {:>10.6} {:>12.6}",
579                name, self.cf_y[j], self.cf_d[j], self.rho[j], self.delta_theta[j]
580            )?;
581        }
582        Ok(())
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use ndarray::array;
590
591    #[test]
592    fn plr_elements_match_closed_form() {
593        // a = y − ℓ̂, b = d − m̂ = v
594        let parts = SensitivityParts {
595            outcome_residual: array![1.0, -1.0, 2.0, 0.0],
596            theta_factor: Some(array![0.5, -0.5, 1.0, -1.0]),
597            riesz: Array1::zeros(4),
598            nu2: Nu2Parts::InverseMeanSquare(array![0.5, -0.5, 1.0, -1.0]),
599        };
600        let e = parts.elements(2.0);
601        // residuals 1−1, −1+1, 2−2, 0+2 → (0, 0, 0, 2): σ² = 1
602        assert!((e.sigma2 - 1.0).abs() < 1e-15);
603        // mean v² = (0.25+0.25+1+1)/4 = 0.625 → ν² = 1.6
604        assert!((e.nu2 - 1.6).abs() < 1e-15);
605        assert!(e.psi_nu2.sum().abs() < 1e-12 && e.psi_sigma2.sum().abs() < 1e-12);
606    }
607
608    #[test]
609    fn negative_nu2_falls_back_to_riesz_square() {
610        let parts = SensitivityParts {
611            outcome_residual: array![1.0, 2.0],
612            theta_factor: None,
613            riesz: array![1.0, 3.0],
614            nu2: Nu2Parts::Elementwise(array![-1.0, -2.0]),
615        };
616        let e = parts.elements(0.0);
617        assert!((e.nu2 - 5.0).abs() < 1e-15); // mean(1, 9)
618    }
619
620    #[test]
621    fn robustness_value_solves_the_crossing() {
622        // bound(v) = 1 − v, null 0.25 (lower bound) → v = 0.75
623        let rv = robustness_value(|v| 1.0 - v, 0.25, false);
624        assert!((rv - 0.75).abs() < 1e-12);
625        // Upper bound rising with confounding: bound(v) = 1 + v reaches 1.5 at v = 0.5.
626        assert!((robustness_value(|v| 1.0 + v, 1.5, true) - 0.5).abs() < 1e-12);
627        assert_eq!(robustness_value(|v| 1.0 - v, 1.5, false), 0.0); // null already beyond the bound
628        assert_eq!(robustness_value(|_| 1.0, 0.0, false), RV_MAX); // never reached
629    }
630
631    #[test]
632    fn params_validation() {
633        assert!(SensitivityParams::new().cf_y(1.0).validate(1).is_err());
634        assert!(SensitivityParams::new().rho(1.5).validate(1).is_err());
635        assert!(SensitivityParams::new()
636            .null_hypotheses(vec![0.0, 1.0])
637            .validate(3)
638            .is_err());
639        assert!(SensitivityParams::new().validate(2).is_ok());
640    }
641}