use std::sync::Arc;
use crate::VariableId;
use super::QueryError;
#[derive(Clone, Debug, PartialEq)]
pub enum AssignmentDesign {
Bernoulli {
probabilities: Arc<[f64]>,
},
CompleteRandomization {
treated: usize,
},
ClusterRandomization {
clusters: Arc<[u32]>,
treated_clusters: usize,
},
}
#[derive(Clone, Debug, PartialEq)]
pub enum ExposureMapping {
OwnTreatment,
NeighborCount,
NeighborFraction,
WeightedNeighborExposure,
Custom(Arc<str>),
}
pub const EXPOSURE_LEVEL_TOLERANCE: f64 = 1e-12;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ExposureLevel {
pub own: f64,
pub neighbors: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub enum InterferenceFunctional {
ExposureContrast {
outcome: VariableId,
from: ExposureLevel,
to: ExposureLevel,
},
}
#[derive(Clone, Debug, PartialEq)]
pub struct InterferenceQuery {
pub assignment: AssignmentDesign,
pub exposure: ExposureMapping,
pub functional: InterferenceFunctional,
pub probability_draws: u32,
}
impl InterferenceQuery {
#[must_use]
pub fn new(
assignment: AssignmentDesign,
exposure: ExposureMapping,
functional: InterferenceFunctional,
) -> Self {
Self { assignment, exposure, functional, probability_draws: 10_000 }
}
pub fn validate(&self) -> Result<(), QueryError> {
match &self.assignment {
AssignmentDesign::Bernoulli { probabilities }
if probabilities.is_empty()
|| probabilities.iter().any(|p| !p.is_finite() || *p <= 0.0 || *p >= 1.0) =>
{
return Err(QueryError::InvalidInterference(
"Bernoulli probabilities must be finite and strictly between zero and one"
.into(),
));
}
AssignmentDesign::CompleteRandomization { treated } if *treated == 0 => {
return Err(QueryError::InvalidInterference(
"complete randomization requires at least one treated unit".into(),
));
}
AssignmentDesign::ClusterRandomization { clusters, treated_clusters }
if clusters.is_empty() || *treated_clusters == 0 =>
{
return Err(QueryError::InvalidInterference(
"cluster randomization requires clusters and at least one treated cluster"
.into(),
));
}
_ => {}
}
let InterferenceFunctional::ExposureContrast { from, to, .. } = &self.functional;
if [from.own, from.neighbors, to.own, to.neighbors].iter().any(|v| !v.is_finite())
|| exposure_levels_match(*from, *to)
|| self.probability_draws == 0
{
return Err(QueryError::InvalidInterference(
"exposure levels must be finite/distinct and probability_draws must be positive"
.into(),
));
}
Ok(())
}
}
fn exposure_levels_match(a: ExposureLevel, b: ExposureLevel) -> bool {
(a.own - b.own).abs() <= EXPOSURE_LEVEL_TOLERANCE
&& (a.neighbors - b.neighbors).abs() <= EXPOSURE_LEVEL_TOLERANCE
}
#[cfg(test)]
mod tests {
use super::*;
use crate::VariableId;
fn query_with_levels(from: ExposureLevel, to: ExposureLevel) -> InterferenceQuery {
InterferenceQuery::new(
AssignmentDesign::Bernoulli { probabilities: Arc::from([0.5]) },
ExposureMapping::OwnTreatment,
InterferenceFunctional::ExposureContrast { outcome: VariableId::from_raw(0), from, to },
)
}
#[test]
fn distinct_exposure_levels_validate() {
let query = query_with_levels(
ExposureLevel { own: 0.0, neighbors: 0.0 },
ExposureLevel { own: 1.0, neighbors: 0.0 },
);
assert!(query.validate().is_ok());
}
#[test]
fn exposure_levels_within_tolerance_are_rejected_at_validation() {
let from = ExposureLevel { own: 0.5, neighbors: 0.25 };
let to = ExposureLevel { own: 0.5 + 1e-15, neighbors: 0.25 };
assert_ne!(from, to, "test fixture must use exact f64 PartialEq inequality");
let query = query_with_levels(from, to);
let err = query.validate().unwrap_err();
assert!(matches!(err, QueryError::InvalidInterference(_)));
}
}