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}