1use std::sync::Arc;
6
7use crate::intervention::TemporalPolicy;
8use crate::{Intervention, TargetPopulation, VariableId};
9
10use super::QueryError;
11
12pub const MAX_TEMPORAL_RESPONSE_HORIZONS: usize = 512;
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21pub struct TemporalResponseLicense {
22 pub max_horizons: usize,
24 pub allowed_policies: &'static [&'static str],
26 pub default_policy: &'static str,
28 pub default_treatment_lag: u32,
30}
31
32#[derive(Clone, Debug, PartialEq)]
38pub struct TemporalResponseSpec {
39 pub horizons: Arc<[u32]>,
42 pub policy: TemporalPolicy,
45 pub max_history_lag: Option<u32>,
47}
48
49impl TemporalResponseSpec {
50 pub const MAX_HORIZONS: usize = MAX_TEMPORAL_RESPONSE_HORIZONS;
52 pub const POLICY_PULSE: &'static str = "pulse";
54 pub const POLICY_SUSTAINED: &'static str = "sustained";
56 pub const ALLOWED_POLICIES: &'static [&'static str] =
58 &[Self::POLICY_PULSE, Self::POLICY_SUSTAINED];
59 pub const DEFAULT_POLICY: &'static str = Self::POLICY_PULSE;
61 pub const DEFAULT_TREATMENT_LAG: u32 = 1;
63
64 #[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 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 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 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 #[must_use]
155 pub fn max_horizon(&self) -> u32 {
156 self.horizons.last().copied().unwrap_or(1)
157 }
158
159 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
175pub const MAX_NONPARAMETRIC_RESPONSE_DIM: usize = 2;
177
178pub const MAX_MATERIALIZED_GRID_POINTS: usize = 1_000_000;
186
187#[derive(Clone, Debug, PartialEq)]
189pub enum GridSpec {
190 Values(Arc<[f64]>),
192 Linspace {
194 start: f64,
196 end: f64,
198 points: usize,
200 },
201}
202
203impl GridSpec {
204 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 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#[derive(Clone, Debug, PartialEq)]
262pub struct ContinuousDomain {
263 pub variable: VariableId,
265 pub grid: GridSpec,
267}
268
269impl ContinuousDomain {
270 #[must_use]
272 pub fn new(variable: VariableId, grid: GridSpec) -> Self {
273 Self { variable, grid }
274 }
275}
276
277#[derive(Clone, Debug, PartialEq)]
279pub enum DerivativeWeighting {
280 Observed,
282 Uniform {
284 lower: f64,
286 upper: f64,
288 },
289 Custom(Arc<[f64]>),
291}
292
293#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
295pub enum DerivativeScale {
296 Identity,
298 LogTreatment,
300 LogOutcome,
302 LogLog,
304}
305
306#[derive(Clone, Debug, Eq, PartialEq, Hash)]
308pub enum ObservationSpec {
309 Complete,
311 RightCensored {
313 latent: VariableId,
315 observed: VariableId,
317 censoring: VariableId,
319 event: VariableId,
321 },
322 LeftCensored {
324 latent: VariableId,
326 observed: VariableId,
328 censoring: VariableId,
330 event: VariableId,
332 },
333 IntervalCensored {
335 latent: VariableId,
337 lower: VariableId,
339 upper: VariableId,
341 },
342 Truncated {
344 latent: VariableId,
346 observed: VariableId,
348 lower: Option<VariableId>,
350 upper: Option<VariableId>,
352 },
353 Selected {
355 latent: VariableId,
357 observed: VariableId,
359 indicator: VariableId,
361 },
362}
363
364#[derive(Clone, Debug, Eq, PartialEq, Hash)]
366pub enum ObservationAssumption {
367 IndependentGiven(Arc<[VariableId]>),
369 OutcomeIndependentGiven(Arc<[VariableId]>),
371 Structural(Arc<str>),
373}
374
375#[derive(Clone, Debug, PartialEq)]
377pub enum ResponseFunctional {
378 MeanCurve {
380 outcome: VariableId,
382 treatment: ContinuousDomain,
384 },
385 AverageDerivative {
387 outcome: VariableId,
389 treatment: VariableId,
391 weighting: DerivativeWeighting,
393 },
394 PointDerivative {
396 outcome: VariableId,
398 treatment: VariableId,
400 at: f64,
402 order: u8,
404 scale: DerivativeScale,
406 },
407 DirectionalDerivative {
409 outcomes: Arc<[VariableId]>,
411 treatments: Arc<[VariableId]>,
413 at: Arc<[f64]>,
415 direction: Arc<[f64]>,
417 },
418 Jacobian {
420 outcomes: Arc<[VariableId]>,
422 treatments: Arc<[VariableId]>,
424 at: Arc<[f64]>,
426 scale: DerivativeScale,
428 },
429 InterventionResponse {
431 outcome: VariableId,
433 interventions: Arc<[Intervention]>,
435 },
436}
437
438impl ResponseFunctional {
439 #[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 #[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 #[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#[derive(Clone, Debug, PartialEq)]
481pub struct ResponseQuery {
482 pub functional: ResponseFunctional,
484 pub target_population: TargetPopulation,
486 pub observation: ObservationSpec,
488 pub observation_assumptions: Arc<[ObservationAssumption]>,
490 pub temporal: Option<TemporalResponseSpec>,
492}
493
494impl ResponseQuery {
495 #[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 #[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 #[must_use]
521 pub fn with_temporal(mut self, temporal: TemporalResponseSpec) -> Self {
522 self.temporal = Some(temporal);
523 self
524 }
525
526 #[must_use]
528 pub const fn is_temporal(&self) -> bool {
529 self.temporal.is_some()
530 }
531
532 #[must_use]
534 pub fn with_target_population(mut self, target: TargetPopulation) -> Self {
535 self.target_population = target;
536 self
537 }
538
539 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 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 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}