Skip to main content

antecedent_core/query/
interference.rs

1//! Randomized interference queries.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::sync::Arc;
6
7use crate::VariableId;
8
9use super::QueryError;
10
11/// Known random assignment design.
12#[derive(Clone, Debug, PartialEq)]
13pub enum AssignmentDesign {
14    /// Independent Bernoulli assignment, scalar or unit-specific probabilities.
15    Bernoulli {
16        /// One probability for all units or one per unit.
17        probabilities: Arc<[f64]>,
18    },
19    /// Exactly `treated` units selected uniformly without replacement.
20    CompleteRandomization {
21        /// Number assigned to treatment.
22        treated: usize,
23    },
24    /// Exactly `treated_clusters` clusters selected uniformly.
25    ClusterRandomization {
26        /// Cluster id per unit.
27        clusters: Arc<[u32]>,
28        /// Number of treated clusters.
29        treated_clusters: usize,
30    },
31}
32
33/// Built-in map from the global assignment vector to unit exposure.
34#[derive(Clone, Debug, PartialEq)]
35pub enum ExposureMapping {
36    /// Unit's own binary treatment only.
37    OwnTreatment,
38    /// Own treatment plus count of treated incoming neighbors.
39    NeighborCount,
40    /// Own treatment plus fraction of treated incoming neighbors.
41    NeighborFraction,
42    /// Own treatment plus weighted mean neighbor treatment.
43    WeightedNeighborExposure,
44    /// Opaque custom mapping id resolved by a caller registry.
45    Custom(Arc<str>),
46}
47
48/// Tolerance for treating two [`ExposureLevel`] values as the same level.
49///
50/// Exposure levels are frequently derived from floating-point neighbor fractions or weighted
51/// means, so exact `==` is too brittle: a `from`/`to` pair that differs only by rounding error
52/// would pass [`InterferenceQuery::validate`] as "distinct", yet
53/// `antecedent_stats::interference` matches units to levels with a tolerance and would then
54/// select the *same* unit set for both, silently producing an exact-zero contrast with no
55/// warning. Validation therefore uses this same tolerance.
56///
57/// This constant lives here, in `antecedent-core`, because `antecedent-stats` depends on
58/// `antecedent-core` (not the reverse), so the core crate cannot import a stats-crate constant.
59/// It is not currently re-exported through `query/mod.rs`/`lib.rs` (out of scope for this
60/// change), so `antecedent-stats` keeps its own same-named, same-valued constant with a doc
61/// comment pointing back here; if the export chain opens up, that duplicate should be replaced
62/// with an import of this one.
63pub const EXPOSURE_LEVEL_TOLERANCE: f64 = 1e-12;
64
65/// One exposure category/level.
66#[derive(Clone, Copy, Debug, PartialEq)]
67pub struct ExposureLevel {
68    /// Unit's own treatment.
69    pub own: f64,
70    /// Neighborhood summary; zero for [`ExposureMapping::OwnTreatment`].
71    pub neighbors: f64,
72}
73
74/// Randomization-based interference estimand.
75#[derive(Clone, Debug, PartialEq)]
76pub enum InterferenceFunctional {
77    /// Mean potential-outcome contrast between two exposure levels.
78    ExposureContrast {
79        /// Outcome variable.
80        outcome: VariableId,
81        /// Baseline exposure.
82        from: ExposureLevel,
83        /// Active exposure.
84        to: ExposureLevel,
85    },
86}
87
88/// Assignment design + exposure mapping + estimand.
89#[derive(Clone, Debug, PartialEq)]
90pub struct InterferenceQuery {
91    /// Known random assignment design.
92    pub assignment: AssignmentDesign,
93    /// Exposure mapping.
94    pub exposure: ExposureMapping,
95    /// Requested contrast.
96    pub functional: InterferenceFunctional,
97    /// Monte Carlo assignments used when exposure probabilities are not analytic.
98    pub probability_draws: u32,
99}
100
101impl InterferenceQuery {
102    /// Construct with 10,000 exposure-probability simulations when required.
103    #[must_use]
104    pub fn new(
105        assignment: AssignmentDesign,
106        exposure: ExposureMapping,
107        functional: InterferenceFunctional,
108    ) -> Self {
109        Self { assignment, exposure, functional, probability_draws: 10_000 }
110    }
111
112    /// Validate design probabilities, exposure levels, and simulation budget.
113    ///
114    /// # Errors
115    ///
116    /// [`QueryError::InvalidInterference`] when any design or exposure value is invalid.
117    pub fn validate(&self) -> Result<(), QueryError> {
118        match &self.assignment {
119            AssignmentDesign::Bernoulli { probabilities }
120                if probabilities.is_empty()
121                    || probabilities.iter().any(|p| !p.is_finite() || *p <= 0.0 || *p >= 1.0) =>
122            {
123                return Err(QueryError::InvalidInterference(
124                    "Bernoulli probabilities must be finite and strictly between zero and one"
125                        .into(),
126                ));
127            }
128            AssignmentDesign::CompleteRandomization { treated } if *treated == 0 => {
129                return Err(QueryError::InvalidInterference(
130                    "complete randomization requires at least one treated unit".into(),
131                ));
132            }
133            AssignmentDesign::ClusterRandomization { clusters, treated_clusters }
134                if clusters.is_empty() || *treated_clusters == 0 =>
135            {
136                return Err(QueryError::InvalidInterference(
137                    "cluster randomization requires clusters and at least one treated cluster"
138                        .into(),
139                ));
140            }
141            _ => {}
142        }
143        let InterferenceFunctional::ExposureContrast { from, to, .. } = &self.functional;
144        if [from.own, from.neighbors, to.own, to.neighbors].iter().any(|v| !v.is_finite())
145            || exposure_levels_match(*from, *to)
146            || self.probability_draws == 0
147        {
148            return Err(QueryError::InvalidInterference(
149                "exposure levels must be finite/distinct and probability_draws must be positive"
150                    .into(),
151            ));
152        }
153        Ok(())
154    }
155}
156
157/// True when `a` and `b` are within [`EXPOSURE_LEVEL_TOLERANCE`] on both components.
158fn exposure_levels_match(a: ExposureLevel, b: ExposureLevel) -> bool {
159    (a.own - b.own).abs() <= EXPOSURE_LEVEL_TOLERANCE
160        && (a.neighbors - b.neighbors).abs() <= EXPOSURE_LEVEL_TOLERANCE
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::VariableId;
167
168    fn query_with_levels(from: ExposureLevel, to: ExposureLevel) -> InterferenceQuery {
169        InterferenceQuery::new(
170            AssignmentDesign::Bernoulli { probabilities: Arc::from([0.5]) },
171            ExposureMapping::OwnTreatment,
172            InterferenceFunctional::ExposureContrast { outcome: VariableId::from_raw(0), from, to },
173        )
174    }
175
176    #[test]
177    fn distinct_exposure_levels_validate() {
178        let query = query_with_levels(
179            ExposureLevel { own: 0.0, neighbors: 0.0 },
180            ExposureLevel { own: 1.0, neighbors: 0.0 },
181        );
182        assert!(query.validate().is_ok());
183    }
184
185    #[test]
186    fn exposure_levels_within_tolerance_are_rejected_at_validation() {
187        // `to` differs from `from` by 1e-15, well inside EXPOSURE_LEVEL_TOLERANCE. Exact `==`
188        // would call this "distinct" and let it through, after which
189        // `antecedent_stats::interference::same_exposure` (tolerance 1e-12) would match both
190        // levels to the same unit set and silently report an exact-zero contrast.
191        let from = ExposureLevel { own: 0.5, neighbors: 0.25 };
192        let to = ExposureLevel { own: 0.5 + 1e-15, neighbors: 0.25 };
193        assert_ne!(from, to, "test fixture must use exact f64 PartialEq inequality");
194        let query = query_with_levels(from, to);
195        let err = query.validate().unwrap_err();
196        assert!(matches!(err, QueryError::InvalidInterference(_)));
197    }
198}