Skip to main content

antecedent_core/query/
response.rs

1//! Continuous causal-response queries.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::sync::Arc;
6
7use crate::{Intervention, TargetPopulation, VariableId};
8
9use super::QueryError;
10
11/// Maximum treatment dimension for an explicitly gridded non-parametric surface.
12pub const MAX_NONPARAMETRIC_RESPONSE_DIM: usize = 2;
13
14/// Maximum number of points a [`GridSpec::Linspace`] may materialize.
15///
16/// `GridSpec::values` previously only checked that `points` fit in a `u32`, so e.g.
17/// `Linspace { points: 4_000_000_000 }` passed validation and then tried to allocate a
18/// multi-gigabyte `Vec<f64>` (8 bytes/point). This cap keeps materialization bounded to
19/// something a caller could plausibly intend as an evaluation grid; 1,000,000 points is already
20/// far beyond what any of this crate's response/derivative estimators need per grid.
21pub const MAX_MATERIALIZED_GRID_POINTS: usize = 1_000_000;
22
23/// Points at which a continuous response is evaluated.
24#[derive(Clone, Debug, PartialEq)]
25pub enum GridSpec {
26    /// Explicit, strictly increasing finite values.
27    Values(Arc<[f64]>),
28    /// Inclusive evenly spaced grid.
29    Linspace {
30        /// First point.
31        start: f64,
32        /// Last point.
33        end: f64,
34        /// Number of points, at least two.
35        points: usize,
36    },
37}
38
39impl GridSpec {
40    /// Materialize the grid after validation.
41    ///
42    /// # Errors
43    ///
44    /// [`QueryError::InvalidResponse`] when the grid is invalid or too large to materialize.
45    pub fn values(&self) -> Result<Vec<f64>, QueryError> {
46        self.validate()?;
47        Ok(match self {
48            Self::Values(values) => values.to_vec(),
49            Self::Linspace { start, end, points } => {
50                let points = u32::try_from(*points).map_err(|_| {
51                    QueryError::InvalidResponse("linspace point count exceeds u32 capacity".into())
52                })?;
53                let step = (end - start) / f64::from(points - 1);
54                (0..points).map(|i| start + f64::from(i) * step).collect()
55            }
56        })
57    }
58
59    /// Validate finiteness, size, and ordering.
60    ///
61    /// # Errors
62    ///
63    /// [`QueryError::InvalidResponse`] when values are non-finite, unordered, or undersized.
64    pub fn validate(&self) -> Result<(), QueryError> {
65        match self {
66            Self::Values(values) => {
67                if values.len() < 2 {
68                    return Err(QueryError::InvalidResponse(
69                        "a response grid requires at least two points".into(),
70                    ));
71                }
72                if values.iter().any(|v| !v.is_finite()) || values.windows(2).any(|w| w[0] >= w[1])
73                {
74                    return Err(QueryError::InvalidResponse(
75                        "response-grid values must be finite and strictly increasing".into(),
76                    ));
77                }
78            }
79            Self::Linspace { start, end, points } => {
80                if !start.is_finite() || !end.is_finite() || start >= end || *points < 2 {
81                    return Err(QueryError::InvalidResponse(
82                        "linspace requires finite start < end and at least two points".into(),
83                    ));
84                }
85                if *points > MAX_MATERIALIZED_GRID_POINTS {
86                    return Err(QueryError::InvalidResponse(
87                        "linspace point count is too large to materialize".into(),
88                    ));
89                }
90            }
91        }
92        Ok(())
93    }
94}
95
96/// Domain of a scalar continuous intervention.
97#[derive(Clone, Debug, PartialEq)]
98pub struct ContinuousDomain {
99    /// Intervened variable.
100    pub variable: VariableId,
101    /// Evaluation grid.
102    pub grid: GridSpec,
103}
104
105impl ContinuousDomain {
106    /// Construct a continuous intervention domain.
107    #[must_use]
108    pub fn new(variable: VariableId, grid: GridSpec) -> Self {
109        Self { variable, grid }
110    }
111}
112
113/// Weighting law for an average derivative effect.
114#[derive(Clone, Debug, PartialEq)]
115pub enum DerivativeWeighting {
116    /// Average over the observed treatment/covariate law.
117    Observed,
118    /// Uniform weighting over a supplied finite interval.
119    Uniform {
120        /// Inclusive lower endpoint.
121        lower: f64,
122        /// Inclusive upper endpoint.
123        upper: f64,
124    },
125    /// Caller-supplied row weights, normalized by the estimator.
126    Custom(Arc<[f64]>),
127}
128
129/// Scale on which a derivative is reported.
130#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
131pub enum DerivativeScale {
132    /// `dm/da`.
133    Identity,
134    /// `a * dm/da`.
135    LogTreatment,
136    /// `(1/m) * dm/da`.
137    LogOutcome,
138    /// `(a/m) * dm/da` (elasticity).
139    LogLog,
140}
141
142/// How a scientific outcome entered the observed dataset.
143#[derive(Clone, Debug, Eq, PartialEq, Hash)]
144pub enum ObservationSpec {
145    /// Outcome is completely observed.
146    Complete,
147    /// Right-censored continuous outcome.
148    RightCensored {
149        /// Scientific latent outcome.
150        latent: VariableId,
151        /// Recorded minimum of the latent outcome and censoring value.
152        observed: VariableId,
153        /// Censoring value.
154        censoring: VariableId,
155        /// Event/uncensored indicator.
156        event: VariableId,
157    },
158    /// Left-censored continuous outcome.
159    LeftCensored {
160        /// Scientific latent outcome.
161        latent: VariableId,
162        /// Recorded maximum of the latent outcome and censoring value.
163        observed: VariableId,
164        /// Censoring value.
165        censoring: VariableId,
166        /// Event/uncensored indicator.
167        event: VariableId,
168    },
169    /// Interval-censored continuous outcome.
170    IntervalCensored {
171        /// Scientific latent outcome.
172        latent: VariableId,
173        /// Observed lower endpoint.
174        lower: VariableId,
175        /// Observed upper endpoint.
176        upper: VariableId,
177    },
178    /// Sampling truncation with optional row-specific bounds.
179    Truncated {
180        /// Scientific latent outcome.
181        latent: VariableId,
182        /// Recorded outcome among sampled units.
183        observed: VariableId,
184        /// Optional lower truncation bound.
185        lower: Option<VariableId>,
186        /// Optional upper truncation bound.
187        upper: Option<VariableId>,
188    },
189    /// Outcome observed only when an indicator is one.
190    Selected {
191        /// Scientific latent outcome.
192        latent: VariableId,
193        /// Recorded outcome (valid only on selected rows).
194        observed: VariableId,
195        /// Observation/selection indicator.
196        indicator: VariableId,
197    },
198}
199
200/// Explicit claim about an observation mechanism.
201#[derive(Clone, Debug, Eq, PartialEq, Hash)]
202pub enum ObservationAssumption {
203    /// Observation/censoring is independent after conditioning on these variables.
204    IndependentGiven(Arc<[VariableId]>),
205    /// Observation is independent of the latent outcome after conditioning.
206    OutcomeIndependentGiven(Arc<[VariableId]>),
207    /// Named structural observation model.
208    Structural(Arc<str>),
209}
210
211/// A response functional, distinct from the estimator used to learn it.
212#[derive(Clone, Debug, PartialEq)]
213pub enum ResponseFunctional {
214    /// `a -> E[Y | do(A=a)]`.
215    MeanCurve {
216        /// Outcome.
217        outcome: VariableId,
218        /// Scalar continuous treatment domain.
219        treatment: ContinuousDomain,
220    },
221    /// Scalar weighted average derivative effect.
222    AverageDerivative {
223        /// Outcome.
224        outcome: VariableId,
225        /// Treatment.
226        treatment: VariableId,
227        /// Target weighting law.
228        weighting: DerivativeWeighting,
229    },
230    /// Local derivative of a response representation.
231    PointDerivative {
232        /// Outcome.
233        outcome: VariableId,
234        /// Treatment.
235        treatment: VariableId,
236        /// Evaluation point.
237        at: f64,
238        /// Derivative order (one or two).
239        order: u8,
240        /// Reporting scale.
241        scale: DerivativeScale,
242    },
243    /// Directional derivative for a vector intervention.
244    DirectionalDerivative {
245        /// Outcomes.
246        outcomes: Arc<[VariableId]>,
247        /// Treatments.
248        treatments: Arc<[VariableId]>,
249        /// Evaluation point in treatment order.
250        at: Arc<[f64]>,
251        /// Direction in treatment order.
252        direction: Arc<[f64]>,
253    },
254    /// Low-dimensional response Jacobian.
255    Jacobian {
256        /// Outcomes.
257        outcomes: Arc<[VariableId]>,
258        /// Treatments.
259        treatments: Arc<[VariableId]>,
260        /// Evaluation point in treatment order.
261        at: Arc<[f64]>,
262        /// Reporting scale.
263        scale: DerivativeScale,
264    },
265    /// Mean response under an existing intervention or joint intervention.
266    InterventionResponse {
267        /// Outcome.
268        outcome: VariableId,
269        /// Intervention set.
270        interventions: Arc<[Intervention]>,
271    },
272}
273
274/// Complete continuous-response query.
275#[derive(Clone, Debug, PartialEq)]
276pub struct ResponseQuery {
277    /// Requested functional.
278    pub functional: ResponseFunctional,
279    /// Target population.
280    pub target_population: TargetPopulation,
281    /// Observation mechanism (complete by default).
282    pub observation: ObservationSpec,
283    /// Caller-declared observation assumptions. Empty means none.
284    pub observation_assumptions: Arc<[ObservationAssumption]>,
285}
286
287impl ResponseQuery {
288    /// Construct a completely observed response query.
289    #[must_use]
290    pub fn new(functional: ResponseFunctional) -> Self {
291        Self {
292            functional,
293            target_population: TargetPopulation::AllObserved,
294            observation: ObservationSpec::Complete,
295            observation_assumptions: Arc::from([]),
296        }
297    }
298
299    /// Attach an explicit observation process and its assumptions.
300    #[must_use]
301    pub fn with_observation(
302        mut self,
303        observation: ObservationSpec,
304        assumptions: impl Into<Arc<[ObservationAssumption]>>,
305    ) -> Self {
306        self.observation = observation;
307        self.observation_assumptions = assumptions.into();
308        self
309    }
310
311    /// Set the target population.
312    #[must_use]
313    pub fn with_target_population(mut self, target: TargetPopulation) -> Self {
314        self.target_population = target;
315        self
316    }
317
318    /// Validate dimensions, finite values, scales, and intervention targets.
319    ///
320    /// # Errors
321    ///
322    /// [`QueryError`] when variables, dimensions, values, or observation semantics are invalid.
323    pub fn validate(&self) -> Result<(), QueryError> {
324        match &self.functional {
325            ResponseFunctional::MeanCurve { outcome, treatment } => {
326                if *outcome == treatment.variable {
327                    return Err(QueryError::TreatmentEqualsOutcome { id: *outcome });
328                }
329                treatment.grid.validate()?;
330            }
331            ResponseFunctional::AverageDerivative { outcome, treatment, weighting } => {
332                if outcome == treatment {
333                    return Err(QueryError::TreatmentEqualsOutcome { id: *outcome });
334                }
335                match weighting {
336                    DerivativeWeighting::Uniform { lower, upper }
337                        if !lower.is_finite() || !upper.is_finite() || lower >= upper =>
338                    {
339                        return Err(QueryError::InvalidResponse(
340                            "uniform derivative weighting requires finite lower < upper".into(),
341                        ));
342                    }
343                    DerivativeWeighting::Custom(weights)
344                        if weights.is_empty()
345                            || weights.iter().any(|w| !w.is_finite() || *w < 0.0)
346                            || weights.iter().all(|w| *w == 0.0) =>
347                    {
348                        return Err(QueryError::InvalidResponse(
349                            "custom derivative weights must be finite, non-negative, and non-zero"
350                                .into(),
351                        ));
352                    }
353                    _ => {}
354                }
355            }
356            ResponseFunctional::PointDerivative { outcome, treatment, at, order, scale } => {
357                if outcome == treatment {
358                    return Err(QueryError::TreatmentEqualsOutcome { id: *outcome });
359                }
360                if !at.is_finite() || !matches!(order, 1 | 2) {
361                    return Err(QueryError::InvalidResponse(
362                        "point derivative requires a finite point and order one or two".into(),
363                    ));
364                }
365                if matches!(scale, DerivativeScale::LogTreatment | DerivativeScale::LogLog)
366                    && *at <= 0.0
367                {
368                    return Err(QueryError::InvalidResponse(
369                        "log-treatment derivative scales require a positive treatment point".into(),
370                    ));
371                }
372            }
373            ResponseFunctional::DirectionalDerivative { outcomes, treatments, at, direction } => {
374                if !response_sets_are_distinct(outcomes, treatments)
375                    || at.len() != treatments.len()
376                    || direction.len() != treatments.len()
377                    || at.iter().chain(direction.iter()).any(|v| !v.is_finite())
378                    || direction.iter().all(|v| *v == 0.0)
379                {
380                    return Err(QueryError::InvalidResponse(
381                        "directional derivative dimensions/values are inconsistent".into(),
382                    ));
383                }
384            }
385            ResponseFunctional::Jacobian { outcomes, treatments, at, scale } => {
386                if !response_sets_are_distinct(outcomes, treatments)
387                    || at.len() != treatments.len()
388                    || at.iter().any(|v| !v.is_finite())
389                {
390                    return Err(QueryError::InvalidResponse(
391                        "Jacobian dimensions/values are inconsistent".into(),
392                    ));
393                }
394                if matches!(scale, DerivativeScale::LogTreatment | DerivativeScale::LogLog)
395                    && at.iter().any(|v| *v <= 0.0)
396                {
397                    return Err(QueryError::InvalidResponse(
398                        "log-treatment Jacobians require positive treatment coordinates".into(),
399                    ));
400                }
401            }
402            ResponseFunctional::InterventionResponse { outcome, interventions } => {
403                if interventions.is_empty() {
404                    return Err(QueryError::InvalidResponse(
405                        "intervention response requires at least one intervention".into(),
406                    ));
407                }
408                for intervention in interventions.iter() {
409                    intervention
410                        .validate()
411                        .map_err(|e| QueryError::InvalidIntervention(e.to_string()))?;
412                    if intervention.primary_variable() == Some(*outcome) {
413                        return Err(QueryError::TreatmentEqualsOutcome { id: *outcome });
414                    }
415                }
416            }
417        }
418        self.target_population.validate()?;
419        Ok(())
420    }
421}
422
423fn response_sets_are_distinct(outcomes: &[VariableId], treatments: &[VariableId]) -> bool {
424    !outcomes.is_empty()
425        && !treatments.is_empty()
426        && !outcomes.iter().any(|outcome| treatments.contains(outcome))
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn linspace_within_cap_validates_and_materializes() {
435        let grid = GridSpec::Linspace { start: 0.0, end: 1.0, points: 5 };
436        assert!(grid.validate().is_ok());
437        assert_eq!(grid.values().unwrap().len(), 5);
438    }
439
440    #[test]
441    fn linspace_beyond_materialization_cap_is_rejected() {
442        // Before the fix, only `u32::try_from(points)` was checked, so a huge-but-u32-valid
443        // point count (here, well over MAX_MATERIALIZED_GRID_POINTS but still far under
444        // u32::MAX) would sail through validation and then try to allocate an
445        // unreasonably large `Vec<f64>`.
446        let grid =
447            GridSpec::Linspace { start: 0.0, end: 1.0, points: MAX_MATERIALIZED_GRID_POINTS + 1 };
448        let err = grid.validate().unwrap_err();
449        assert!(matches!(err, QueryError::InvalidResponse(_)));
450        assert!(grid.values().is_err());
451    }
452
453    #[test]
454    fn linspace_point_count_far_beyond_u32_capacity_is_still_rejected_by_the_cap() {
455        // Guards the original bug report directly: a `points` value so large the old
456        // `u32::try_from` guard alone would have rejected it, but only after already deciding
457        // the input was otherwise well-formed. The size cap must reject it first and for the
458        // documented "too large to materialize" reason.
459        let grid = GridSpec::Linspace { start: 0.0, end: 1.0, points: 4_000_000_000 };
460        let err = grid.validate().unwrap_err();
461        assert!(matches!(err, QueryError::InvalidResponse(_)));
462    }
463}