#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CounterfactualResult {
pub observed: f64,
pub counterfactual: f64,
pub impact: f64,
pub relative_impact: f64,
pub ci_lower: f64,
pub ci_upper: f64,
pub significant: bool,
pub n_bootstrap: usize,
}
impl CounterfactualResult {
#[must_use]
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"observed": self.observed,
"counterfactual": self.counterfactual,
"impact": self.impact,
"relative_impact": self.relative_impact,
"ci_lower": self.ci_lower,
"ci_upper": self.ci_upper,
"significant": self.significant,
"n_bootstrap": self.n_bootstrap,
})
}
}
pub struct CounterfactualEstimator {
alpha: f64,
n_bootstrap: usize,
seed: u64,
}
impl Default for CounterfactualEstimator {
fn default() -> Self {
Self {
alpha: 0.3,
n_bootstrap: 1000,
seed: 42,
}
}
}
impl std::fmt::Debug for CounterfactualEstimator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CounterfactualEstimator")
.field("alpha", &self.alpha)
.field("n_bootstrap", &self.n_bootstrap)
.finish_non_exhaustive()
}
}
impl CounterfactualEstimator {
#[must_use]
pub const fn new(alpha: f64, n_bootstrap: usize, seed: u64) -> Self {
Self {
alpha,
n_bootstrap,
seed,
}
}
#[must_use]
pub fn estimate(
&self,
pre_intervention: &[f64],
post_intervention: &[f64],
) -> CounterfactualResult {
let pre: Vec<f64> = pre_intervention
.iter()
.copied()
.filter(|v| v.is_finite())
.collect();
let post: Vec<f64> = post_intervention
.iter()
.copied()
.filter(|v| v.is_finite())
.collect();
if pre.is_empty() || post.is_empty() {
return CounterfactualResult {
observed: 0.0,
counterfactual: 0.0,
impact: 0.0,
relative_impact: 0.0,
ci_lower: 0.0,
ci_upper: 0.0,
significant: false,
n_bootstrap: 0,
};
}
let forecast = self.exponential_smoothing_forecast(&pre, post.len());
let observed = post.iter().sum::<f64>() / post.len() as f64;
let counterfactual = forecast.iter().sum::<f64>() / forecast.len() as f64;
let impact = observed - counterfactual;
let relative_impact = if counterfactual.abs() > 1e-10 {
impact / counterfactual
} else {
0.0
};
let (ci_lower, ci_upper) = self.bootstrap_ci(&pre, post.len(), impact);
CounterfactualResult {
observed,
counterfactual,
impact,
relative_impact,
ci_lower,
ci_upper,
significant: ci_lower > 0.0 || ci_upper < 0.0,
n_bootstrap: self.n_bootstrap,
}
}
fn exponential_smoothing_forecast(&self, pre: &[f64], horizon: usize) -> Vec<f64> {
if pre.is_empty() {
return vec![0.0; horizon];
}
let mut level = pre[0];
for &val in &pre[1..] {
level = self.alpha.mul_add(val, (1.0 - self.alpha) * level);
}
vec![level; horizon]
}
fn bootstrap_ci(&self, pre: &[f64], horizon: usize, observed_impact: f64) -> (f64, f64) {
let mut rng = self.seed;
let mut impacts = Vec::with_capacity(self.n_bootstrap);
for _ in 0..self.n_bootstrap {
let resampled: Vec<f64> = (0..pre.len())
.map(|_| {
let idx = (xorshift(&mut rng) as usize) % pre.len();
pre[idx]
})
.collect();
let forecast = self.exponential_smoothing_forecast(&resampled, horizon);
let cf = forecast.iter().sum::<f64>() / forecast.len().max(1) as f64;
let null_impact = observed_impact + (cf - pre.iter().sum::<f64>() / pre.len() as f64);
impacts.push(null_impact);
}
impacts.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = impacts.len();
if n == 0 {
return (0.0, 0.0);
}
let lower_idx = ((0.025 * n as f64) as usize).min(n - 1);
let upper_idx = ((0.975 * n as f64) as usize).min(n - 1);
(impacts[lower_idx], impacts[upper_idx])
}
}
const fn xorshift(state: &mut u64) -> u64 {
let mut x = *state;
if x == 0 {
x = 0x9E37_79B9_7F4A_7C15;
}
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
*state = x;
x
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn estimate_positive_impact() {
let estimator = CounterfactualEstimator::default();
let pre = vec![9.0, 10.0, 11.0, 10.0, 9.0, 10.0, 11.0, 10.0];
let post = vec![14.0, 15.0, 16.0, 15.0, 14.0, 15.0, 16.0, 15.0];
let result = estimator.estimate(&pre, &post);
assert!(result.observed > result.counterfactual);
assert!(result.impact > 0.0);
}
#[test]
fn estimate_negative_impact() {
let estimator = CounterfactualEstimator::default();
let pre = vec![10.0; 10];
let post = vec![5.0; 5];
let result = estimator.estimate(&pre, &post);
assert!(result.impact < 0.0);
}
#[test]
fn estimate_no_impact() {
let estimator = CounterfactualEstimator::default();
let pre = vec![10.0; 10];
let post = vec![10.0; 5];
let result = estimator.estimate(&pre, &post);
assert!(result.impact.abs() < 1.0);
}
#[test]
fn estimate_empty_data() {
let estimator = CounterfactualEstimator::default();
let result = estimator.estimate(&[], &[]);
assert_eq!(result.n_bootstrap, 0);
assert!((result.impact - 0.0).abs() < 0.001);
}
#[test]
fn result_to_json() {
let result = CounterfactualResult {
observed: 15.0,
counterfactual: 10.0,
impact: 5.0,
relative_impact: 0.5,
ci_lower: 2.0,
ci_upper: 8.0,
significant: true,
n_bootstrap: 1000,
};
let json = result.to_json();
assert_eq!(json["observed"], 15.0);
assert_eq!(json["impact"], 5.0);
assert_eq!(json["significant"], true);
}
#[test]
fn custom_parameters() {
let estimator = CounterfactualEstimator::new(0.5, 500, 123);
let pre = vec![10.0; 10];
let post = vec![15.0; 5];
let result = estimator.estimate(&pre, &post);
assert_eq!(result.n_bootstrap, 500);
}
#[test]
fn relative_impact_computed() {
let estimator = CounterfactualEstimator::default();
let pre = vec![10.0; 10];
let post = vec![20.0; 5];
let result = estimator.estimate(&pre, &post);
assert!(result.relative_impact > 0.5);
}
#[test]
fn significant_flag() {
let estimator = CounterfactualEstimator::default();
let pre = vec![10.0; 20];
let post = vec![100.0; 10];
let result = estimator.estimate(&pre, &post);
assert!(result.impact > 0.0);
}
#[test]
fn estimate_filters_nan_and_infinity() {
let estimator = CounterfactualEstimator::default();
let pre = vec![10.0, f64::NAN, 10.0, f64::INFINITY, 10.0];
let post = vec![15.0, f64::NEG_INFINITY, 15.0];
let result = estimator.estimate(&pre, &post);
assert!(result.observed.is_finite());
assert!(result.counterfactual.is_finite());
assert!(result.impact.is_finite());
}
#[test]
fn estimate_all_nan_returns_zeros() {
let estimator = CounterfactualEstimator::default();
let pre = vec![f64::NAN, f64::INFINITY];
let post = vec![f64::NAN];
let result = estimator.estimate(&pre, &post);
assert_eq!(result.observed, 0.0);
assert_eq!(result.counterfactual, 0.0);
assert!(!result.significant);
}
}