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::TemporalPolicy;
8use crate::{Intervention, TargetPopulation, VariableId};
9
10use super::QueryError;
11
12/// Maximum number of discrete horizons a temporal response may request.
13pub const MAX_TEMPORAL_RESPONSE_HORIZONS: usize = 512;
14
15/// Licensed temporal-response query policy, for language facades.
16///
17/// [`TemporalResponseSpec::validate`] is the semantic authority. Bindings may
18/// duplicate checks for early errors but must read these values rather than
19/// defining them.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct TemporalResponseLicense {
22    /// Maximum number of discrete horizons a query may request.
23    pub max_horizons: usize,
24    /// Wire tags this spec accepts (`pulse`, `sustained`).
25    pub allowed_policies: &'static [&'static str],
26    /// Default wire tag when the caller omits `policy`.
27    pub default_policy: &'static str,
28    /// Default `treatment_lag` (policy origin `-lag`) for Pulse / `ResponseCurve`.
29    pub default_treatment_lag: u32,
30}
31
32/// Temporal attachment for a continuous-response query (ADR 0021).
33///
34/// When present, the query is a temporal cell: dose × horizon surfaces for
35/// [`ResponseFunctional::MeanCurve`], or horizon-indexed intervention responses.
36/// Absence means a static response cell.
37#[derive(Clone, Debug, PartialEq)]
38pub struct TemporalResponseSpec {
39    /// Outcome evaluation horizons in time steps after the policy origin (each ≥ 1).
40    /// Strictly increasing; at least one entry.
41    pub horizons: Arc<[u32]>,
42    /// Temporal intervention policy. Licensed 0.7 cells are Pulse and
43    /// single-step Sustained; Dynamic is refused here (use `TemporalEffectQuery`).
44    pub policy: TemporalPolicy,
45    /// Optional max history lag (steps) when unfolding; `None` = planner default.
46    pub max_history_lag: Option<u32>,
47}
48
49impl TemporalResponseSpec {
50    /// Horizon-count cap ([`MAX_TEMPORAL_RESPONSE_HORIZONS`]).
51    pub const MAX_HORIZONS: usize = MAX_TEMPORAL_RESPONSE_HORIZONS;
52    /// Wire tag for [`crate::intervention::TemporalPolicy::Pulse`].
53    pub const POLICY_PULSE: &'static str = "pulse";
54    /// Wire tag for [`crate::intervention::TemporalPolicy::Sustained`].
55    pub const POLICY_SUSTAINED: &'static str = "sustained";
56    /// Policies [`Self::validate`] accepts on a response cell.
57    pub const ALLOWED_POLICIES: &'static [&'static str] =
58        &[Self::POLICY_PULSE, Self::POLICY_SUSTAINED];
59    /// Default wire tag when a facade omits `policy`.
60    pub const DEFAULT_POLICY: &'static str = Self::POLICY_PULSE;
61    /// Default `treatment_lag` (policy origin `-lag`).
62    pub const DEFAULT_TREATMENT_LAG: u32 = 1;
63
64    /// Machine-readable license for facades. Python must not hard-code these.
65    #[must_use]
66    pub const fn license() -> TemporalResponseLicense {
67        TemporalResponseLicense {
68            max_horizons: Self::MAX_HORIZONS,
69            allowed_policies: Self::ALLOWED_POLICIES,
70            default_policy: Self::DEFAULT_POLICY,
71            default_treatment_lag: Self::DEFAULT_TREATMENT_LAG,
72        }
73    }
74
75    /// Parse a licensed response-policy wire tag at treatment offset `at`.
76    ///
77    /// Sustained is the licensed single-step window `[at, at]`.
78    ///
79    /// # Errors
80    ///
81    /// Unknown tag (including `dynamic`).
82    pub fn parse_policy(tag: &str, at: i32) -> Result<TemporalPolicy, QueryError> {
83        match tag {
84            Self::POLICY_PULSE => Ok(TemporalPolicy::pulse(at)),
85            Self::POLICY_SUSTAINED => Ok(TemporalPolicy::sustained(at, at)),
86            other => Err(QueryError::InvalidResponse(format!(
87                "temporal response policy must be {} or {}; got {other}",
88                Self::POLICY_PULSE,
89                Self::POLICY_SUSTAINED
90            ))),
91        }
92    }
93
94    /// Construct a temporal attachment after validating horizons and policy.
95    ///
96    /// # Errors
97    ///
98    /// Empty/non-increasing/oversized horizons, zero horizon, or invalid policy.
99    pub fn new(
100        horizons: impl Into<Arc<[u32]>>,
101        policy: TemporalPolicy,
102        max_history_lag: Option<u32>,
103    ) -> Result<Self, QueryError> {
104        let horizons = horizons.into();
105        let spec = Self { horizons, policy, max_history_lag };
106        spec.validate()?;
107        Ok(spec)
108    }
109
110    /// Validate horizons and nested policy.
111    ///
112    /// # Errors
113    ///
114    /// [`QueryError::InvalidResponse`] or temporal-policy errors.
115    pub fn validate(&self) -> Result<(), QueryError> {
116        if self.horizons.is_empty() {
117            return Err(QueryError::InvalidResponse(
118                "temporal response requires at least one horizon".into(),
119            ));
120        }
121        if self.horizons.len() > Self::MAX_HORIZONS {
122            return Err(QueryError::InvalidResponse(
123                "temporal response horizon count exceeds the materialization cap".into(),
124            ));
125        }
126        if self.horizons.iter().any(|h| *h == 0) {
127            return Err(QueryError::NonPositiveHorizon);
128        }
129        if self.horizons.windows(2).any(|w| w[0] >= w[1]) {
130            return Err(QueryError::InvalidResponse(
131                "temporal response horizons must be strictly increasing".into(),
132            ));
133        }
134        self.policy.validate().map_err(|e| match e {
135            crate::intervention::InterventionError::InvalidTemporalWindow { from, until } => {
136                QueryError::InvalidTemporalWindow { from, until }
137            }
138            other => QueryError::InvalidIntervention(other.to_string()),
139        })?;
140        match &self.policy {
141            TemporalPolicy::Pulse { .. } | TemporalPolicy::Sustained { .. } => {}
142            TemporalPolicy::Dynamic { .. } => {
143                return Err(QueryError::InvalidResponse(
144                    "temporal response policy must be pulse or sustained; \
145                     Dynamic is a TemporalEffect spelling, not a ResponseCurve cell"
146                        .into(),
147                ));
148            }
149        }
150        Ok(())
151    }
152
153    /// Largest requested horizon (guaranteed ≥ 1 after validation).
154    #[must_use]
155    pub fn max_horizon(&self) -> u32 {
156        self.horizons.last().copied().unwrap_or(1)
157    }
158
159    /// Treatment-time origin under the attached policy.
160    ///
161    /// # Errors
162    ///
163    /// Empty dynamic schedule.
164    pub fn treatment_offset(&self) -> Result<i32, QueryError> {
165        match &self.policy {
166            TemporalPolicy::Pulse { at } => Ok(*at),
167            TemporalPolicy::Sustained { from, .. } => Ok(*from),
168            TemporalPolicy::Dynamic { active_at, .. } => {
169                active_at.first().copied().ok_or(QueryError::DynamicPolicyHasNoTreatmentOffset)
170            }
171        }
172    }
173}
174
175/// Maximum treatment dimension for an explicitly gridded non-parametric surface.
176pub const MAX_NONPARAMETRIC_RESPONSE_DIM: usize = 2;
177
178/// Maximum number of points a [`GridSpec::Linspace`] may materialize.
179///
180/// `GridSpec::values` previously only checked that `points` fit in a `u32`, so e.g.
181/// `Linspace { points: 4_000_000_000 }` passed validation and then tried to allocate a
182/// multi-gigabyte `Vec<f64>` (8 bytes/point). This cap keeps materialization bounded to
183/// something a caller could plausibly intend as an evaluation grid; 1,000,000 points is already
184/// far beyond what any of this crate's response/derivative estimators need per grid.
185pub const MAX_MATERIALIZED_GRID_POINTS: usize = 1_000_000;
186
187/// Points at which a continuous response is evaluated.
188#[derive(Clone, Debug, PartialEq)]
189pub enum GridSpec {
190    /// Explicit, strictly increasing finite values.
191    Values(Arc<[f64]>),
192    /// Inclusive evenly spaced grid.
193    Linspace {
194        /// First point.
195        start: f64,
196        /// Last point.
197        end: f64,
198        /// Number of points, at least two.
199        points: usize,
200    },
201}
202
203impl GridSpec {
204    /// Materialize the grid after validation.
205    ///
206    /// # Errors
207    ///
208    /// [`QueryError::InvalidResponse`] when the grid is invalid or too large to materialize.
209    pub fn values(&self) -> Result<Vec<f64>, QueryError> {
210        self.validate()?;
211        Ok(match self {
212            Self::Values(values) => values.to_vec(),
213            Self::Linspace { start, end, points } => {
214                let points = u32::try_from(*points).map_err(|_| {
215                    QueryError::InvalidResponse("linspace point count exceeds u32 capacity".into())
216                })?;
217                let step = (end - start) / f64::from(points - 1);
218                (0..points).map(|i| start + f64::from(i) * step).collect()
219            }
220        })
221    }
222
223    /// Validate finiteness, size, and ordering.
224    ///
225    /// # Errors
226    ///
227    /// [`QueryError::InvalidResponse`] when values are non-finite, unordered, or undersized.
228    pub fn validate(&self) -> Result<(), QueryError> {
229        match self {
230            Self::Values(values) => {
231                if values.len() < 2 {
232                    return Err(QueryError::InvalidResponse(
233                        "a response grid requires at least two points".into(),
234                    ));
235                }
236                if values.iter().any(|v| !v.is_finite()) || values.windows(2).any(|w| w[0] >= w[1])
237                {
238                    return Err(QueryError::InvalidResponse(
239                        "response-grid values must be finite and strictly increasing".into(),
240                    ));
241                }
242            }
243            Self::Linspace { start, end, points } => {
244                if !start.is_finite() || !end.is_finite() || start >= end || *points < 2 {
245                    return Err(QueryError::InvalidResponse(
246                        "linspace requires finite start < end and at least two points".into(),
247                    ));
248                }
249                if *points > MAX_MATERIALIZED_GRID_POINTS {
250                    return Err(QueryError::InvalidResponse(
251                        "linspace point count is too large to materialize".into(),
252                    ));
253                }
254            }
255        }
256        Ok(())
257    }
258}
259
260/// Domain of a scalar continuous intervention.
261#[derive(Clone, Debug, PartialEq)]
262pub struct ContinuousDomain {
263    /// Intervened variable.
264    pub variable: VariableId,
265    /// Evaluation grid.
266    pub grid: GridSpec,
267}
268
269impl ContinuousDomain {
270    /// Construct a continuous intervention domain.
271    #[must_use]
272    pub fn new(variable: VariableId, grid: GridSpec) -> Self {
273        Self { variable, grid }
274    }
275}
276
277/// Weighting law for an average derivative effect.
278#[derive(Clone, Debug, PartialEq)]
279pub enum DerivativeWeighting {
280    /// Average over the observed treatment/covariate law.
281    Observed,
282    /// Uniform weighting over a supplied finite interval.
283    Uniform {
284        /// Inclusive lower endpoint.
285        lower: f64,
286        /// Inclusive upper endpoint.
287        upper: f64,
288    },
289    /// Caller-supplied row weights, normalized by the estimator.
290    Custom(Arc<[f64]>),
291}
292
293/// Scale on which a derivative is reported.
294#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
295pub enum DerivativeScale {
296    /// `dm/da`.
297    Identity,
298    /// `a * dm/da`.
299    LogTreatment,
300    /// `(1/m) * dm/da`.
301    LogOutcome,
302    /// `(a/m) * dm/da` (elasticity).
303    LogLog,
304}
305
306/// How a scientific outcome entered the observed dataset.
307#[derive(Clone, Debug, Eq, PartialEq, Hash)]
308pub enum ObservationSpec {
309    /// Outcome is completely observed.
310    Complete,
311    /// Right-censored continuous outcome.
312    RightCensored {
313        /// Scientific latent outcome.
314        latent: VariableId,
315        /// Recorded minimum of the latent outcome and censoring value.
316        observed: VariableId,
317        /// Censoring value.
318        censoring: VariableId,
319        /// Event/uncensored indicator.
320        event: VariableId,
321    },
322    /// Left-censored continuous outcome.
323    LeftCensored {
324        /// Scientific latent outcome.
325        latent: VariableId,
326        /// Recorded maximum of the latent outcome and censoring value.
327        observed: VariableId,
328        /// Censoring value.
329        censoring: VariableId,
330        /// Event/uncensored indicator.
331        event: VariableId,
332    },
333    /// Interval-censored continuous outcome.
334    IntervalCensored {
335        /// Scientific latent outcome.
336        latent: VariableId,
337        /// Observed lower endpoint.
338        lower: VariableId,
339        /// Observed upper endpoint.
340        upper: VariableId,
341    },
342    /// Sampling truncation with optional row-specific bounds.
343    Truncated {
344        /// Scientific latent outcome.
345        latent: VariableId,
346        /// Recorded outcome among sampled units.
347        observed: VariableId,
348        /// Optional lower truncation bound.
349        lower: Option<VariableId>,
350        /// Optional upper truncation bound.
351        upper: Option<VariableId>,
352    },
353    /// Outcome observed only when an indicator is one.
354    Selected {
355        /// Scientific latent outcome.
356        latent: VariableId,
357        /// Recorded outcome (valid only on selected rows).
358        observed: VariableId,
359        /// Observation/selection indicator.
360        indicator: VariableId,
361    },
362}
363
364/// Explicit claim about an observation mechanism.
365#[derive(Clone, Debug, Eq, PartialEq, Hash)]
366pub enum ObservationAssumption {
367    /// Observation/censoring is independent after conditioning on these variables.
368    IndependentGiven(Arc<[VariableId]>),
369    /// Observation is independent of the latent outcome after conditioning.
370    OutcomeIndependentGiven(Arc<[VariableId]>),
371    /// Named structural observation model.
372    Structural(Arc<str>),
373}
374
375/// A response functional, distinct from the estimator used to learn it.
376#[derive(Clone, Debug, PartialEq)]
377pub enum ResponseFunctional {
378    /// `a -> E[Y | do(A=a)]`.
379    MeanCurve {
380        /// Outcome.
381        outcome: VariableId,
382        /// Scalar continuous treatment domain.
383        treatment: ContinuousDomain,
384    },
385    /// Scalar weighted average derivative effect.
386    AverageDerivative {
387        /// Outcome.
388        outcome: VariableId,
389        /// Treatment.
390        treatment: VariableId,
391        /// Target weighting law.
392        weighting: DerivativeWeighting,
393    },
394    /// Local derivative of a response representation.
395    PointDerivative {
396        /// Outcome.
397        outcome: VariableId,
398        /// Treatment.
399        treatment: VariableId,
400        /// Evaluation point.
401        at: f64,
402        /// Derivative order (one or two).
403        order: u8,
404        /// Reporting scale.
405        scale: DerivativeScale,
406    },
407    /// Directional derivative for a vector intervention.
408    DirectionalDerivative {
409        /// Outcomes.
410        outcomes: Arc<[VariableId]>,
411        /// Treatments.
412        treatments: Arc<[VariableId]>,
413        /// Evaluation point in treatment order.
414        at: Arc<[f64]>,
415        /// Direction in treatment order.
416        direction: Arc<[f64]>,
417    },
418    /// Low-dimensional response Jacobian.
419    Jacobian {
420        /// Outcomes.
421        outcomes: Arc<[VariableId]>,
422        /// Treatments.
423        treatments: Arc<[VariableId]>,
424        /// Evaluation point in treatment order.
425        at: Arc<[f64]>,
426        /// Reporting scale.
427        scale: DerivativeScale,
428    },
429    /// Mean response under an existing intervention or joint intervention.
430    InterventionResponse {
431        /// Outcome.
432        outcome: VariableId,
433        /// Intervention set.
434        interventions: Arc<[Intervention]>,
435    },
436}
437
438impl ResponseFunctional {
439    /// Treatment variable ids in query order.
440    #[must_use]
441    pub fn treatment_ids(&self) -> Vec<VariableId> {
442        match self {
443            Self::MeanCurve { treatment, .. } => vec![treatment.variable],
444            Self::AverageDerivative { treatment, .. } | Self::PointDerivative { treatment, .. } => {
445                vec![*treatment]
446            }
447            Self::DirectionalDerivative { treatments, .. } | Self::Jacobian { treatments, .. } => {
448                treatments.to_vec()
449            }
450            Self::InterventionResponse { interventions, .. } => {
451                interventions.iter().filter_map(Intervention::primary_variable).collect()
452            }
453        }
454    }
455
456    /// Outcome variable ids in query order.
457    #[must_use]
458    pub fn outcome_ids(&self) -> Vec<VariableId> {
459        match self {
460            Self::MeanCurve { outcome, .. }
461            | Self::AverageDerivative { outcome, .. }
462            | Self::PointDerivative { outcome, .. }
463            | Self::InterventionResponse { outcome, .. } => vec![*outcome],
464            Self::DirectionalDerivative { outcomes, .. } | Self::Jacobian { outcomes, .. } => {
465                outcomes.to_vec()
466            }
467        }
468    }
469
470    /// First treatment/outcome pair, when the functional names at least one of each.
471    #[must_use]
472    pub fn primary_pair(&self) -> Option<(VariableId, VariableId)> {
473        let treatment = self.treatment_ids().into_iter().next()?;
474        let outcome = self.outcome_ids().into_iter().next()?;
475        Some((treatment, outcome))
476    }
477}
478
479/// Complete continuous-response query.
480#[derive(Clone, Debug, PartialEq)]
481pub struct ResponseQuery {
482    /// Requested functional.
483    pub functional: ResponseFunctional,
484    /// Target population.
485    pub target_population: TargetPopulation,
486    /// Observation mechanism (complete by default).
487    pub observation: ObservationSpec,
488    /// Caller-declared observation assumptions. Empty means none.
489    pub observation_assumptions: Arc<[ObservationAssumption]>,
490    /// Optional temporal attachment (ADR 0021). `None` = static response cell.
491    pub temporal: Option<TemporalResponseSpec>,
492}
493
494impl ResponseQuery {
495    /// Construct a completely observed response query.
496    #[must_use]
497    pub fn new(functional: ResponseFunctional) -> Self {
498        Self {
499            functional,
500            target_population: TargetPopulation::AllObserved,
501            observation: ObservationSpec::Complete,
502            observation_assumptions: Arc::from([]),
503            temporal: None,
504        }
505    }
506
507    /// Attach an explicit observation process and its assumptions.
508    #[must_use]
509    pub fn with_observation(
510        mut self,
511        observation: ObservationSpec,
512        assumptions: impl Into<Arc<[ObservationAssumption]>>,
513    ) -> Self {
514        self.observation = observation;
515        self.observation_assumptions = assumptions.into();
516        self
517    }
518
519    /// Attach a temporal dose-over-horizon / policy-path specification.
520    #[must_use]
521    pub fn with_temporal(mut self, temporal: TemporalResponseSpec) -> Self {
522        self.temporal = Some(temporal);
523        self
524    }
525
526    /// Whether this query is a temporal response cell.
527    #[must_use]
528    pub const fn is_temporal(&self) -> bool {
529        self.temporal.is_some()
530    }
531
532    /// Set the target population.
533    #[must_use]
534    pub fn with_target_population(mut self, target: TargetPopulation) -> Self {
535        self.target_population = target;
536        self
537    }
538
539    /// Validate dimensions, finite values, scales, and intervention targets.
540    ///
541    /// # Errors
542    ///
543    /// [`QueryError`] when variables, dimensions, values, or observation semantics are invalid.
544    pub fn validate(&self) -> Result<(), QueryError> {
545        self.validate_temporal_attachment()?;
546        match &self.functional {
547            ResponseFunctional::MeanCurve { outcome, treatment } => {
548                if *outcome == treatment.variable {
549                    return Err(QueryError::TreatmentEqualsOutcome { id: *outcome });
550                }
551                treatment.grid.validate()?;
552            }
553            ResponseFunctional::AverageDerivative { outcome, treatment, weighting } => {
554                if outcome == treatment {
555                    return Err(QueryError::TreatmentEqualsOutcome { id: *outcome });
556                }
557                match weighting {
558                    DerivativeWeighting::Uniform { lower, upper }
559                        if !lower.is_finite() || !upper.is_finite() || lower >= upper =>
560                    {
561                        return Err(QueryError::InvalidResponse(
562                            "uniform derivative weighting requires finite lower < upper".into(),
563                        ));
564                    }
565                    DerivativeWeighting::Custom(weights)
566                        if weights.is_empty()
567                            || weights.iter().any(|w| !w.is_finite() || *w < 0.0)
568                            || weights.iter().all(|w| *w == 0.0) =>
569                    {
570                        return Err(QueryError::InvalidResponse(
571                            "custom derivative weights must be finite, non-negative, and non-zero"
572                                .into(),
573                        ));
574                    }
575                    _ => {}
576                }
577            }
578            ResponseFunctional::PointDerivative { outcome, treatment, at, order, scale } => {
579                if outcome == treatment {
580                    return Err(QueryError::TreatmentEqualsOutcome { id: *outcome });
581                }
582                if !at.is_finite() || !matches!(order, 1 | 2) {
583                    return Err(QueryError::InvalidResponse(
584                        "point derivative requires a finite point and order one or two".into(),
585                    ));
586                }
587                if matches!(scale, DerivativeScale::LogTreatment | DerivativeScale::LogLog)
588                    && *at <= 0.0
589                {
590                    return Err(QueryError::InvalidResponse(
591                        "log-treatment derivative scales require a positive treatment point".into(),
592                    ));
593                }
594            }
595            ResponseFunctional::DirectionalDerivative { outcomes, treatments, at, direction } => {
596                if !response_sets_are_distinct(outcomes, treatments)
597                    || at.len() != treatments.len()
598                    || direction.len() != treatments.len()
599                    || at.iter().chain(direction.iter()).any(|v| !v.is_finite())
600                    || direction.iter().all(|v| *v == 0.0)
601                {
602                    return Err(QueryError::InvalidResponse(
603                        "directional derivative dimensions/values are inconsistent".into(),
604                    ));
605                }
606            }
607            ResponseFunctional::Jacobian { outcomes, treatments, at, scale } => {
608                if !response_sets_are_distinct(outcomes, treatments)
609                    || at.len() != treatments.len()
610                    || at.iter().any(|v| !v.is_finite())
611                {
612                    return Err(QueryError::InvalidResponse(
613                        "Jacobian dimensions/values are inconsistent".into(),
614                    ));
615                }
616                if matches!(scale, DerivativeScale::LogTreatment | DerivativeScale::LogLog)
617                    && at.iter().any(|v| *v <= 0.0)
618                {
619                    return Err(QueryError::InvalidResponse(
620                        "log-treatment Jacobians require positive treatment coordinates".into(),
621                    ));
622                }
623            }
624            ResponseFunctional::InterventionResponse { outcome, interventions } => {
625                if interventions.is_empty() {
626                    return Err(QueryError::InvalidResponse(
627                        "intervention response requires at least one intervention".into(),
628                    ));
629                }
630                for intervention in interventions.iter() {
631                    intervention
632                        .validate()
633                        .map_err(|e| QueryError::InvalidIntervention(e.to_string()))?;
634                    if intervention.primary_variable() == Some(*outcome) {
635                        return Err(QueryError::TreatmentEqualsOutcome { id: *outcome });
636                    }
637                }
638            }
639        }
640        self.target_population.validate()?;
641        Ok(())
642    }
643
644    fn validate_temporal_attachment(&self) -> Result<(), QueryError> {
645        if let Some(temporal) = &self.temporal {
646            temporal.validate()?;
647            match &self.functional {
648                ResponseFunctional::MeanCurve { .. }
649                | ResponseFunctional::InterventionResponse { .. } => {}
650                _ => {
651                    return Err(QueryError::InvalidResponse(
652                        "temporal attachment is licensed only for MeanCurve and InterventionResponse"
653                            .into(),
654                    ));
655                }
656            }
657            if self.observation != ObservationSpec::Complete {
658                return Err(QueryError::InvalidResponse(
659                    "temporal response requires complete observation in 0.7".into(),
660                ));
661            }
662        }
663        Ok(())
664    }
665}
666
667fn response_sets_are_distinct(outcomes: &[VariableId], treatments: &[VariableId]) -> bool {
668    !outcomes.is_empty()
669        && !treatments.is_empty()
670        && !outcomes.iter().any(|outcome| treatments.contains(outcome))
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn temporal_response_license_is_the_facade_contract() {
679        let license = TemporalResponseSpec::license();
680        assert_eq!(license.max_horizons, MAX_TEMPORAL_RESPONSE_HORIZONS);
681        assert_eq!(license.allowed_policies, TemporalResponseSpec::ALLOWED_POLICIES);
682        assert_eq!(license.default_policy, TemporalResponseSpec::POLICY_PULSE);
683        assert!(license.allowed_policies.contains(&license.default_policy));
684        assert_eq!(license.default_treatment_lag, TemporalResponseSpec::DEFAULT_TREATMENT_LAG);
685        let at = -i32::try_from(license.default_treatment_lag).unwrap();
686        assert!(TemporalResponseSpec::parse_policy(license.default_policy, at).is_ok());
687        assert!(TemporalResponseSpec::parse_policy("dynamic", at).is_err());
688        let ok: Vec<u32> = (1..=u32::try_from(license.max_horizons).unwrap()).collect();
689        assert!(TemporalResponseSpec::new(ok, TemporalPolicy::pulse(at), None).is_ok());
690        let too_many: Vec<u32> = (1..=u32::try_from(license.max_horizons + 1).unwrap()).collect();
691        assert!(TemporalResponseSpec::new(too_many, TemporalPolicy::pulse(at), None).is_err());
692    }
693
694    #[test]
695    fn temporal_response_spec_refuses_dynamic_policy() {
696        let err = TemporalResponseSpec::new(
697            vec![1u32],
698            TemporalPolicy::dynamic(crate::DynamicRuleId::from_raw(0), [0]),
699            None,
700        )
701        .unwrap_err();
702        assert!(matches!(err, QueryError::InvalidResponse(_)));
703        assert!(err.to_string().contains("pulse or sustained"));
704    }
705
706    #[test]
707    fn linspace_within_cap_validates_and_materializes() {
708        let grid = GridSpec::Linspace { start: 0.0, end: 1.0, points: 5 };
709        assert!(grid.validate().is_ok());
710        assert_eq!(grid.values().unwrap().len(), 5);
711    }
712
713    #[test]
714    fn linspace_beyond_materialization_cap_is_rejected() {
715        // Before the fix, only `u32::try_from(points)` was checked, so a huge-but-u32-valid
716        // point count (here, well over MAX_MATERIALIZED_GRID_POINTS but still far under
717        // u32::MAX) would sail through validation and then try to allocate an
718        // unreasonably large `Vec<f64>`.
719        let grid =
720            GridSpec::Linspace { start: 0.0, end: 1.0, points: MAX_MATERIALIZED_GRID_POINTS + 1 };
721        let err = grid.validate().unwrap_err();
722        assert!(matches!(err, QueryError::InvalidResponse(_)));
723        assert!(grid.values().is_err());
724    }
725
726    #[test]
727    fn response_functional_primary_pair_matches_treatment_and_outcome_ids() {
728        let treatment = VariableId::from_raw(0);
729        let outcome = VariableId::from_raw(1);
730        let functional = ResponseFunctional::AverageDerivative {
731            outcome,
732            treatment,
733            weighting: DerivativeWeighting::Observed,
734        };
735        assert_eq!(functional.treatment_ids(), vec![treatment]);
736        assert_eq!(functional.outcome_ids(), vec![outcome]);
737        assert_eq!(functional.primary_pair(), Some((treatment, outcome)));
738    }
739
740    #[test]
741    fn linspace_point_count_far_beyond_u32_capacity_is_still_rejected_by_the_cap() {
742        // Guards the original bug report directly: a `points` value so large the old
743        // `u32::try_from` guard alone would have rejected it, but only after already deciding
744        // the input was otherwise well-formed. The size cap must reject it first and for the
745        // documented "too large to materialize" reason.
746        let grid = GridSpec::Linspace { start: 0.0, end: 1.0, points: 4_000_000_000 };
747        let err = grid.validate().unwrap_err();
748        assert!(matches!(err, QueryError::InvalidResponse(_)));
749    }
750}