1use std::sync::Arc;
6
7use crate::{Intervention, TargetPopulation, VariableId};
8
9use super::QueryError;
10
11pub const MAX_NONPARAMETRIC_RESPONSE_DIM: usize = 2;
13
14pub const MAX_MATERIALIZED_GRID_POINTS: usize = 1_000_000;
22
23#[derive(Clone, Debug, PartialEq)]
25pub enum GridSpec {
26 Values(Arc<[f64]>),
28 Linspace {
30 start: f64,
32 end: f64,
34 points: usize,
36 },
37}
38
39impl GridSpec {
40 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 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#[derive(Clone, Debug, PartialEq)]
98pub struct ContinuousDomain {
99 pub variable: VariableId,
101 pub grid: GridSpec,
103}
104
105impl ContinuousDomain {
106 #[must_use]
108 pub fn new(variable: VariableId, grid: GridSpec) -> Self {
109 Self { variable, grid }
110 }
111}
112
113#[derive(Clone, Debug, PartialEq)]
115pub enum DerivativeWeighting {
116 Observed,
118 Uniform {
120 lower: f64,
122 upper: f64,
124 },
125 Custom(Arc<[f64]>),
127}
128
129#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
131pub enum DerivativeScale {
132 Identity,
134 LogTreatment,
136 LogOutcome,
138 LogLog,
140}
141
142#[derive(Clone, Debug, Eq, PartialEq, Hash)]
144pub enum ObservationSpec {
145 Complete,
147 RightCensored {
149 latent: VariableId,
151 observed: VariableId,
153 censoring: VariableId,
155 event: VariableId,
157 },
158 LeftCensored {
160 latent: VariableId,
162 observed: VariableId,
164 censoring: VariableId,
166 event: VariableId,
168 },
169 IntervalCensored {
171 latent: VariableId,
173 lower: VariableId,
175 upper: VariableId,
177 },
178 Truncated {
180 latent: VariableId,
182 observed: VariableId,
184 lower: Option<VariableId>,
186 upper: Option<VariableId>,
188 },
189 Selected {
191 latent: VariableId,
193 observed: VariableId,
195 indicator: VariableId,
197 },
198}
199
200#[derive(Clone, Debug, Eq, PartialEq, Hash)]
202pub enum ObservationAssumption {
203 IndependentGiven(Arc<[VariableId]>),
205 OutcomeIndependentGiven(Arc<[VariableId]>),
207 Structural(Arc<str>),
209}
210
211#[derive(Clone, Debug, PartialEq)]
213pub enum ResponseFunctional {
214 MeanCurve {
216 outcome: VariableId,
218 treatment: ContinuousDomain,
220 },
221 AverageDerivative {
223 outcome: VariableId,
225 treatment: VariableId,
227 weighting: DerivativeWeighting,
229 },
230 PointDerivative {
232 outcome: VariableId,
234 treatment: VariableId,
236 at: f64,
238 order: u8,
240 scale: DerivativeScale,
242 },
243 DirectionalDerivative {
245 outcomes: Arc<[VariableId]>,
247 treatments: Arc<[VariableId]>,
249 at: Arc<[f64]>,
251 direction: Arc<[f64]>,
253 },
254 Jacobian {
256 outcomes: Arc<[VariableId]>,
258 treatments: Arc<[VariableId]>,
260 at: Arc<[f64]>,
262 scale: DerivativeScale,
264 },
265 InterventionResponse {
267 outcome: VariableId,
269 interventions: Arc<[Intervention]>,
271 },
272}
273
274#[derive(Clone, Debug, PartialEq)]
276pub struct ResponseQuery {
277 pub functional: ResponseFunctional,
279 pub target_population: TargetPopulation,
281 pub observation: ObservationSpec,
283 pub observation_assumptions: Arc<[ObservationAssumption]>,
285}
286
287impl ResponseQuery {
288 #[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 #[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 #[must_use]
313 pub fn with_target_population(mut self, target: TargetPopulation) -> Self {
314 self.target_population = target;
315 self
316 }
317
318 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 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 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}