1use 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#[derive(Debug, Clone)]
30pub enum Nu2Parts {
31 Elementwise(Array1<f64>),
33 InverseMeanSquare(Array1<f64>),
36}
37
38#[derive(Debug, Clone)]
44pub struct SensitivityParts {
45 pub outcome_residual: Array1<f64>,
46 pub theta_factor: Option<Array1<f64>>,
47 pub riesz: Array1<f64>,
50 pub nu2: Nu2Parts,
51}
52
53pub(crate) struct Elements {
55 pub sigma2: f64,
56 pub nu2: f64,
57 pub psi_sigma2: Array1<f64>,
58 pub psi_nu2: Array1<f64>,
59 pub riesz: Option<Array1<f64>>,
61 pub nu2_fallback: bool,
63}
64
65impl Elements {
66 pub(crate) fn padded(self, positions: &[usize], n: usize) -> Self {
71 let scale = n as f64 / positions.len() as f64;
72 let pad = |v: &Array1<f64>| {
73 let mut out = Array1::zeros(n);
74 for (&p, &x) in positions.iter().zip(v) {
75 out[p] = scale * x;
76 }
77 out
78 };
79 Self {
80 psi_sigma2: pad(&self.psi_sigma2),
81 psi_nu2: pad(&self.psi_nu2),
82 riesz: self.riesz.as_ref().map(pad),
83 ..self
84 }
85 }
86}
87
88impl SensitivityParts {
89 pub(crate) fn elements(&self, theta: f64) -> Elements {
92 let resid = match &self.theta_factor {
93 Some(b) => &self.outcome_residual - &(theta * b),
94 None => self.outcome_residual.clone(),
95 };
96 let sigma_el = resid.mapv(|e| e * e);
97 let sigma2 = sigma_el.mean().expect("n > 0");
98 let (nu2, psi_nu2, riesz) = match &self.nu2 {
99 Nu2Parts::Elementwise(e) => {
100 let nu2 = e.mean().expect("n > 0");
101 (nu2, e - nu2, self.riesz.clone())
102 }
103 Nu2Parts::InverseMeanSquare(v) => {
104 let v2 = v.mapv(|x| x * x);
105 let nu2 = 1.0 / v2.mean().expect("n > 0");
106 (nu2, v2.mapv(|s| nu2 - s * nu2 * nu2), v * nu2)
107 }
108 };
109 let nu2_fallback = nu2 <= 0.0;
110 let (nu2, psi_nu2) = if nu2_fallback {
111 let alpha2 = riesz.mapv(|a| a * a);
112 (alpha2.mean().expect("n > 0"), alpha2)
113 } else {
114 (nu2, psi_nu2)
115 };
116 Elements {
117 sigma2,
118 nu2,
119 psi_sigma2: sigma_el - sigma2,
120 psi_nu2,
121 riesz: Some(riesz),
122 nu2_fallback,
123 }
124 }
125}
126
127#[derive(Debug, Clone)]
129pub(crate) struct SensitivityStore {
130 pub sigma2: Array2<f64>,
132 pub nu2: Array2<f64>,
133 pub max_bias: Array2<f64>,
134 pub psi_max_bias: Array3<f64>,
136 pub psi_sigma2: Array3<f64>,
139 pub psi_nu2: Array3<f64>,
140 pub riesz: Array3<f64>,
142 pub nu2_fallback: Vec<bool>,
144 pub var_y: f64,
146}
147
148impl SensitivityStore {
149 pub(crate) fn new(n: usize, k: usize, n_rep: usize, var_y: f64) -> Self {
150 Self {
151 sigma2: Array2::zeros((k, n_rep)),
152 nu2: Array2::zeros((k, n_rep)),
153 max_bias: Array2::zeros((k, n_rep)),
154 psi_max_bias: Array3::zeros((n, k, n_rep)),
155 psi_sigma2: Array3::zeros((n, k, n_rep)),
156 psi_nu2: Array3::zeros((n, k, n_rep)),
157 riesz: Array3::zeros((n, k, n_rep)),
158 nu2_fallback: vec![false; k],
159 var_y,
160 }
161 }
162
163 pub(crate) fn elements(&self, j: usize, r: usize) -> Elements {
165 Elements {
166 sigma2: self.sigma2[[j, r]],
167 nu2: self.nu2[[j, r]],
168 psi_sigma2: self.psi_sigma2.slice(ndarray::s![.., j, r]).to_owned(),
169 psi_nu2: self.psi_nu2.slice(ndarray::s![.., j, r]).to_owned(),
170 riesz: Some(self.riesz.slice(ndarray::s![.., j, r]).to_owned()),
171 nu2_fallback: self.nu2_fallback[j],
172 }
173 }
174
175 pub(crate) fn concat(stores: &[&SensitivityStore]) -> Self {
177 let cat2 = |f: fn(&SensitivityStore) -> &Array2<f64>| {
178 let views: Vec<_> = stores.iter().map(|s| f(s).view()).collect();
179 ndarray::concatenate(ndarray::Axis(0), &views).expect("same n_rep")
180 };
181 let cat3 = |f: fn(&SensitivityStore) -> &Array3<f64>| {
182 let views: Vec<_> = stores.iter().map(|s| f(s).view()).collect();
183 ndarray::concatenate(ndarray::Axis(1), &views).expect("same n and n_rep")
184 };
185 Self {
186 sigma2: cat2(|s| &s.sigma2),
187 nu2: cat2(|s| &s.nu2),
188 max_bias: cat2(|s| &s.max_bias),
189 psi_max_bias: cat3(|s| &s.psi_max_bias),
190 psi_sigma2: cat3(|s| &s.psi_sigma2),
191 psi_nu2: cat3(|s| &s.psi_nu2),
192 riesz: cat3(|s| &s.riesz),
193 nu2_fallback: stores
194 .iter()
195 .flat_map(|s| s.nu2_fallback.iter().copied())
196 .collect(),
197 var_y: stores[0].var_y,
198 }
199 }
200
201 pub(crate) fn combine(&self, weights: &Array2<f64>) -> Self {
208 let (n, _, n_rep) = self.psi_max_bias.dim();
209 let mut out = Self::new(n, weights.nrows(), n_rep, self.var_y);
210 for (c, row) in weights.outer_iter().enumerate() {
211 let used: Vec<usize> = (0..row.len()).filter(|&j| row[j] != 0.0).collect();
212 for r in 0..n_rep {
213 for &j in &used {
214 let w = row[j].abs();
215 out.max_bias[[c, r]] += w * self.max_bias[[j, r]];
216 out.psi_max_bias
217 .slice_mut(ndarray::s![.., c, r])
218 .scaled_add(w, &self.psi_max_bias.slice(ndarray::s![.., j, r]));
219 }
220 if let [j] = used[..] {
221 let w = row[j];
222 out.sigma2[[c, r]] = self.sigma2[[j, r]];
223 out.nu2[[c, r]] = w * w * self.nu2[[j, r]];
224 for (dst, src, scale) in [
225 (&mut out.psi_sigma2, &self.psi_sigma2, 1.0),
226 (&mut out.psi_nu2, &self.psi_nu2, w * w),
227 (&mut out.riesz, &self.riesz, w),
228 ] {
229 dst.slice_mut(ndarray::s![.., c, r])
230 .scaled_add(scale, &src.slice(ndarray::s![.., j, r]));
231 }
232 } else {
233 out.sigma2[[c, r]] = f64::NAN;
234 out.nu2[[c, r]] = f64::NAN;
235 }
236 }
237 if let [j] = used[..] {
238 out.nu2_fallback[c] = self.nu2_fallback[j];
239 }
240 }
241 out
242 }
243
244 pub(crate) fn set(&mut self, j: usize, r: usize, e: &Elements) {
247 if let Some(riesz) = &e.riesz {
248 self.riesz.slice_mut(ndarray::s![.., j, r]).assign(riesz);
249 }
250 self.nu2_fallback[j] |= e.nu2_fallback;
251 let max_bias = (e.sigma2 * e.nu2).sqrt();
252 self.sigma2[[j, r]] = e.sigma2;
253 self.nu2[[j, r]] = e.nu2;
254 self.max_bias[[j, r]] = max_bias;
255 let psi = (e.sigma2 * &e.psi_nu2 + e.nu2 * &e.psi_sigma2) / (2.0 * max_bias);
256 self.psi_max_bias
257 .slice_mut(ndarray::s![.., j, r])
258 .assign(&psi);
259 self.psi_sigma2
260 .slice_mut(ndarray::s![.., j, r])
261 .assign(&e.psi_sigma2);
262 self.psi_nu2
263 .slice_mut(ndarray::s![.., j, r])
264 .assign(&e.psi_nu2);
265 }
266
267 pub(crate) fn validate(&mut self) {
272 for j in 0..self.nu2_fallback.len() {
273 if !self.nu2_fallback[j] {
274 continue;
275 }
276 for r in 0..self.nu2.ncols() {
277 let alpha2 = self.riesz.slice(ndarray::s![.., j, r]).mapv(|a| a * a);
278 let e = Elements {
279 sigma2: self.sigma2[[j, r]],
280 nu2: alpha2.mean().expect("n > 0"),
281 psi_sigma2: self.psi_sigma2.slice(ndarray::s![.., j, r]).to_owned(),
282 psi_nu2: alpha2,
283 riesz: None,
284 nu2_fallback: true,
285 };
286 self.set(j, r, &e);
287 }
288 }
289 }
290}
291
292#[derive(Debug, Clone, PartialEq)]
294pub struct SensitivityParams {
295 pub cf_y: f64,
296 pub cf_d: f64,
297 pub rho: f64,
298 pub level: f64,
299 pub null_hypothesis: Vec<f64>,
301}
302
303impl Default for SensitivityParams {
304 fn default() -> Self {
306 Self {
307 cf_y: 0.03,
308 cf_d: 0.03,
309 rho: 1.0,
310 level: 0.95,
311 null_hypothesis: vec![0.0],
312 }
313 }
314}
315
316impl SensitivityParams {
317 pub fn new() -> Self {
318 Self::default()
319 }
320
321 pub fn cf_y(mut self, cf_y: f64) -> Self {
323 self.cf_y = cf_y;
324 self
325 }
326
327 pub fn cf_d(mut self, cf_d: f64) -> Self {
329 self.cf_d = cf_d;
330 self
331 }
332
333 pub fn rho(mut self, rho: f64) -> Self {
335 self.rho = rho;
336 self
337 }
338
339 pub fn level(mut self, level: f64) -> Self {
341 self.level = level;
342 self
343 }
344
345 pub fn null_hypothesis(mut self, value: f64) -> Self {
346 self.null_hypothesis = vec![value];
347 self
348 }
349
350 pub fn null_hypotheses(mut self, values: Vec<f64>) -> Self {
351 self.null_hypothesis = values;
352 self
353 }
354
355 fn validate(&self, k: usize) -> Result<()> {
356 let open01 = |v: f64| (0.0..1.0).contains(&v);
357 if !open01(self.cf_y) || !open01(self.cf_d) {
358 return Err(DmlError::InvalidArgument(
359 "cf_y and cf_d must be in [0, 1)".into(),
360 ));
361 }
362 if self.rho.is_nan() || self.rho.abs() > 1.0 {
363 return Err(DmlError::InvalidArgument("rho must be in [-1, 1]".into()));
364 }
365 if !(self.level > 0.0 && self.level < 1.0) {
366 return Err(DmlError::InvalidArgument("level must be in (0, 1)".into()));
367 }
368 if self.null_hypothesis.len() != 1 && self.null_hypothesis.len() != k {
369 return Err(DmlError::DimensionMismatch(format!(
370 "give one null hypothesis or one per treatment ({k})"
371 )));
372 }
373 Ok(())
374 }
375}
376
377#[derive(Debug, Clone)]
379struct Bounds {
380 theta: (Array1<f64>, Array1<f64>),
381 se: (Array1<f64>, Array1<f64>),
382 ci: (Array1<f64>, Array1<f64>),
383}
384
385#[derive(Debug, Clone)]
387pub struct SensitivityResult {
388 pub params: SensitivityParams,
389 treatment_names: Vec<String>,
390 theta: Array1<f64>,
391 pub theta_lower: Array1<f64>,
392 pub theta_upper: Array1<f64>,
393 pub se_lower: Array1<f64>,
394 pub se_upper: Array1<f64>,
395 pub ci_lower: Array1<f64>,
397 pub ci_upper: Array1<f64>,
398 pub rv: Array1<f64>,
400 pub rva: Array1<f64>,
402}
403
404const RV_MAX: f64 = 0.9999;
406
407impl DmlFit {
408 pub fn sensitivity_analysis(&self, params: &SensitivityParams) -> Result<SensitivityResult> {
411 let store = self.sensitivity_store()?;
412 let k = self.n_treat();
413 params.validate(k)?;
414 let b = self.bounds(store, params.cf_y, params.cf_d, params.rho, params.level);
415 let null = |j: usize| {
416 params.null_hypothesis[if params.null_hypothesis.len() == 1 {
417 0
418 } else {
419 j
420 }]
421 };
422 let mut rv = Array1::zeros(k);
423 let mut rva = Array1::zeros(k);
424 for j in 0..k {
425 let h0 = null(j);
426 let upper = h0 > self.coef[j];
427 let theta_at = |v: f64| {
428 let b = self.bounds(store, v, v, params.rho, params.level);
429 if upper {
430 b.theta.1[j]
431 } else {
432 b.theta.0[j]
433 }
434 };
435 let ci_at = |v: f64| {
436 let b = self.bounds(store, v, v, params.rho, params.level);
437 if upper {
438 b.ci.1[j]
439 } else {
440 b.ci.0[j]
441 }
442 };
443 rv[j] = robustness_value(theta_at, h0, upper);
444 rva[j] = robustness_value(ci_at, h0, upper);
445 }
446 Ok(SensitivityResult {
447 params: SensitivityParams {
448 null_hypothesis: (0..k).map(null).collect(),
449 ..params.clone()
450 },
451 treatment_names: self.treatment_names.clone(),
452 theta: self.coef.clone(),
453 theta_lower: b.theta.0,
454 theta_upper: b.theta.1,
455 se_lower: b.se.0,
456 se_upper: b.se.1,
457 ci_lower: b.ci.0,
458 ci_upper: b.ci.1,
459 rv,
460 rva,
461 })
462 }
463
464 pub fn sensitivity_elements(&self) -> Option<(ArrayView2<'_, f64>, ArrayView2<'_, f64>)> {
468 self.sensitivity
469 .as_ref()
470 .map(|s| (s.sigma2.view(), s.nu2.view()))
471 }
472
473 fn sensitivity_store(&self) -> Result<&SensitivityStore> {
474 self.sensitivity.as_ref().ok_or_else(|| {
475 DmlError::InvalidArgument(
476 "sensitivity analysis is available for PLR, IRM and DID fits".into(),
477 )
478 })
479 }
480
481 fn bounds(
483 &self,
484 store: &SensitivityStore,
485 cf_y: f64,
486 cf_d: f64,
487 rho: f64,
488 level: f64,
489 ) -> Bounds {
490 let strength = rho.abs() * (cf_y * cf_d / (1.0 - cf_d)).sqrt();
491 let (n, k, n_rep) = self.psi.dim();
492 let mut theta_lo = Array2::zeros((k, n_rep));
493 let mut theta_hi = Array2::zeros((k, n_rep));
494 let mut se_lo = Array2::zeros((k, n_rep));
495 let mut se_hi = Array2::zeros((k, n_rep));
496 for j in 0..k {
497 for r in 0..n_rep {
498 let shift = strength * store.max_bias[[j, r]];
499 theta_lo[[j, r]] = self.all_coef[[j, r]] - shift;
500 theta_hi[[j, r]] = self.all_coef[[j, r]] + shift;
501 let jac = self.psi_deriv[[j, r]];
502 let scaled = self.psi.slice(ndarray::s![.., j, r]).mapv(|v| v / jac);
503 let pmb = store
504 .psi_max_bias
505 .slice(ndarray::s![.., j, r])
506 .mapv(|v| strength * v);
507 let (psi_lo, psi_hi) = (&scaled - &pmb, &scaled + &pmb);
508 let variance = |psi: &Array1<f64>| match &self.cluster {
509 Some(ci) => ci.variance(psi.view(), Array1::ones(n).view(), r),
510 None => psi.dot(psi) / n as f64 / n as f64,
511 };
512 se_lo[[j, r]] = variance(&psi_lo).sqrt();
513 se_hi[[j, r]] = variance(&psi_hi).sqrt();
514 }
515 }
516 let quant = norm_ppf(level);
517 let ci = |theta: &Array2<f64>, se: &Array2<f64>, sign: f64| -> Array1<f64> {
518 (0..k)
519 .map(|j| {
520 let v: Vec<f64> = (0..n_rep)
521 .map(|r| theta[[j, r]] + sign * quant * se[[j, r]])
522 .collect();
523 median(&v)
524 })
525 .collect()
526 };
527 let ci_lo = ci(&theta_lo, &se_lo, -1.0);
528 let ci_hi = ci(&theta_hi, &se_hi, 1.0);
529 let (t_lo, s_lo) = aggregate(&theta_lo, &se_lo);
530 let (t_hi, s_hi) = aggregate(&theta_hi, &se_hi);
531 Bounds {
532 theta: (t_lo, t_hi),
533 se: (s_lo, s_hi),
534 ci: (ci_lo, ci_hi),
535 }
536 }
537
538 pub fn sensitivity_benchmark(&self, short: &DmlFit) -> Result<SensitivityBenchmark> {
542 let long = self.sensitivity_store()?;
543 let short_store = short.sensitivity_store()?;
544 if long
545 .sigma2
546 .iter()
547 .chain(&short_store.sigma2)
548 .any(|v| v.is_nan())
549 {
550 return Err(DmlError::InvalidArgument(
551 "benchmarking is not available for combinations of several estimates".into(),
552 ));
553 }
554 if short.all_coef.dim() != self.all_coef.dim() {
555 return Err(DmlError::DimensionMismatch(
556 "long and short fits need the same treatments and repetitions".into(),
557 ));
558 }
559 let (k, n_rep) = self.all_coef.dim();
560 let (mut cf_y, mut cf_d, mut rho, mut delta) = (
561 Array1::zeros(k),
562 Array1::zeros(k),
563 Array1::zeros(k),
564 Array1::zeros(k),
565 );
566 for j in 0..k {
567 let mut cy = Vec::with_capacity(n_rep);
568 let mut cd = Vec::with_capacity(n_rep);
569 let mut rh = Vec::with_capacity(n_rep);
570 let mut dt = Vec::with_capacity(n_rep);
571 for r in 0..n_rep {
572 let (s_long, s_short) = (long.sigma2[[j, r]], short_store.sigma2[[j, r]]);
573 let (n_long, n_short) = (long.nu2[[j, r]], short_store.nu2[[j, r]]);
574 let r2_long = 1.0 - s_long / long.var_y;
575 let r2_short = 1.0 - s_short / long.var_y;
576 let r2_riesz = n_short / n_long;
577 cy.push(((r2_long - r2_short) / (1.0 - r2_long)).clamp(0.0, 1.0));
578 cd.push(((1.0 - r2_riesz) / r2_riesz).clamp(0.0, 1.0));
579 let d_theta = short.all_coef[[j, r]] - self.all_coef[[j, r]];
580 dt.push(d_theta);
581 let (var_g, var_riesz) = (s_short - s_long, n_long - n_short);
582 let denom = if var_g > 0.0 && var_riesz > 0.0 {
583 (var_g * var_riesz).sqrt()
584 } else {
585 0.0
586 };
587 let magnitude = if denom != 0.0 {
588 (d_theta.abs() / denom).clamp(0.0, 1.0)
589 } else {
590 1.0
591 };
592 let sign = if d_theta > 0.0 {
593 1.0
594 } else if d_theta < 0.0 {
595 -1.0
596 } else {
597 0.0
598 };
599 rh.push(magnitude * sign);
600 }
601 cf_y[j] = median(&cy);
602 cf_d[j] = median(&cd);
603 rho[j] = median(&rh);
604 delta[j] = median(&dt);
605 }
606 Ok(SensitivityBenchmark {
607 treatment_names: self.treatment_names.clone(),
608 cf_y,
609 cf_d,
610 rho,
611 delta_theta: delta,
612 })
613 }
614}
615
616fn robustness_value(bound: impl Fn(f64) -> f64, h0: f64, upper: bool) -> f64 {
621 let gap = |v: f64| if upper { h0 - bound(v) } else { bound(v) - h0 };
623 if gap(0.0) <= 0.0 {
624 return 0.0;
625 }
626 if gap(RV_MAX) > 0.0 {
627 return RV_MAX;
628 }
629 let (mut lo, mut hi) = (0.0, RV_MAX);
630 for _ in 0..100 {
631 let mid = 0.5 * (lo + hi);
632 if gap(mid) > 0.0 {
633 lo = mid;
634 } else {
635 hi = mid;
636 }
637 if hi - lo < 1e-13 {
638 break;
639 }
640 }
641 0.5 * (lo + hi)
642}
643
644#[derive(Debug, Clone)]
646pub struct SensitivityBenchmark {
647 treatment_names: Vec<String>,
648 pub cf_y: Array1<f64>,
649 pub cf_d: Array1<f64>,
650 pub rho: Array1<f64>,
651 pub delta_theta: Array1<f64>,
653}
654
655impl SensitivityBenchmark {
656 pub fn params(&self, j: usize) -> SensitivityParams {
659 SensitivityParams::new()
660 .cf_y(self.cf_y[j])
661 .cf_d(self.cf_d[j])
662 .rho(self.rho[j])
663 }
664}
665
666impl fmt::Display for SensitivityResult {
667 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
668 let p = &self.params;
669 writeln!(
670 f,
671 "================== Sensitivity Analysis =================="
672 )?;
673 writeln!(f)?;
674 writeln!(f, "------------------ Scenario ------------------")?;
675 writeln!(f, "Significance Level: level={}", p.level)?;
676 writeln!(
677 f,
678 "Sensitivity parameters: cf_y={}; cf_d={}, rho={}",
679 p.cf_y, p.cf_d, p.rho
680 )?;
681 writeln!(f)?;
682 writeln!(f, "------------------ Bounds with CI ------------------")?;
683 let w = self
684 .treatment_names
685 .iter()
686 .map(String::len)
687 .max()
688 .unwrap_or(1)
689 .max(2);
690 writeln!(
691 f,
692 "{:<w$} {:>10} {:>12} {:>10} {:>12} {:>10}",
693 "", "CI lower", "theta lower", "theta", "theta upper", "CI upper"
694 )?;
695 for (j, name) in self.treatment_names.iter().enumerate() {
696 writeln!(
697 f,
698 "{:<w$} {:>10.6} {:>12.6} {:>10.6} {:>12.6} {:>10.6}",
699 name,
700 self.ci_lower[j],
701 self.theta_lower[j],
702 self.theta[j],
703 self.theta_upper[j],
704 self.ci_upper[j]
705 )?;
706 }
707 writeln!(f)?;
708 writeln!(f, "------------------ Robustness Values ------------------")?;
709 writeln!(
710 f,
711 "{:<w$} {:>8} {:>10} {:>10}",
712 "", "H_0", "RV (%)", "RVa (%)"
713 )?;
714 for (j, name) in self.treatment_names.iter().enumerate() {
715 write!(
716 f,
717 "{:<w$} {:>8.1} {:>10.6} {:>10.6}",
718 name,
719 p.null_hypothesis[j],
720 100.0 * self.rv[j],
721 100.0 * self.rva[j]
722 )?;
723 if j + 1 < self.treatment_names.len() {
724 writeln!(f)?;
725 }
726 }
727 Ok(())
728 }
729}
730
731impl fmt::Display for SensitivityBenchmark {
732 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
733 let w = self
734 .treatment_names
735 .iter()
736 .map(String::len)
737 .max()
738 .unwrap_or(1)
739 .max(2);
740 write!(
741 f,
742 "{:<w$} {:>10} {:>10} {:>10} {:>12}",
743 "", "cf_y", "cf_d", "rho", "delta_theta"
744 )?;
745 for (j, name) in self.treatment_names.iter().enumerate() {
746 write!(
747 f,
748 "\n{:<w$} {:>10.6} {:>10.6} {:>10.6} {:>12.6}",
749 name, self.cf_y[j], self.cf_d[j], self.rho[j], self.delta_theta[j]
750 )?;
751 }
752 Ok(())
753 }
754}
755
756#[cfg(test)]
757mod tests {
758 use super::*;
759 use ndarray::array;
760
761 #[test]
762 fn plr_elements_match_closed_form() {
763 let parts = SensitivityParts {
765 outcome_residual: array![1.0, -1.0, 2.0, 0.0],
766 theta_factor: Some(array![0.5, -0.5, 1.0, -1.0]),
767 riesz: Array1::zeros(4),
768 nu2: Nu2Parts::InverseMeanSquare(array![0.5, -0.5, 1.0, -1.0]),
769 };
770 let e = parts.elements(2.0);
771 assert!((e.sigma2 - 1.0).abs() < 1e-15);
773 assert!((e.nu2 - 1.6).abs() < 1e-15);
775 assert!(e.psi_nu2.sum().abs() < 1e-12 && e.psi_sigma2.sum().abs() < 1e-12);
776 }
777
778 #[test]
779 fn negative_nu2_falls_back_to_riesz_square() {
780 let parts = SensitivityParts {
781 outcome_residual: array![1.0, 2.0],
782 theta_factor: None,
783 riesz: array![1.0, 3.0],
784 nu2: Nu2Parts::Elementwise(array![-1.0, -2.0]),
785 };
786 let e = parts.elements(0.0);
787 assert!((e.nu2 - 5.0).abs() < 1e-15); }
789
790 #[test]
791 fn robustness_value_solves_the_crossing() {
792 let rv = robustness_value(|v| 1.0 - v, 0.25, false);
794 assert!((rv - 0.75).abs() < 1e-12);
795 assert!((robustness_value(|v| 1.0 + v, 1.5, true) - 0.5).abs() < 1e-12);
797 assert_eq!(robustness_value(|v| 1.0 - v, 1.5, false), 0.0); assert_eq!(robustness_value(|_| 1.0, 0.0, false), RV_MAX); }
800
801 #[test]
802 fn params_validation() {
803 assert!(SensitivityParams::new().cf_y(1.0).validate(1).is_err());
804 assert!(SensitivityParams::new().rho(1.5).validate(1).is_err());
805 assert!(SensitivityParams::new()
806 .null_hypotheses(vec![0.0, 1.0])
807 .validate(3)
808 .is_err());
809 assert!(SensitivityParams::new().validate(2).is_ok());
810 }
811}