Skip to main content

antecedent_expr/
provider.rs

1//! Distribution providers for compiled expression evaluation.
2//!
3//! SPDX-License-Identifier: MIT OR Apache-2.0
4
5use std::collections::HashMap;
6use std::fmt;
7use std::sync::Arc;
8
9use antecedent_core::{Value, VariableId};
10
11use crate::{DomainRef, InterventionAssignment};
12
13/// Weighted quadrature nodes: assignment rows paired with integration weights.
14pub type QuadratureNodes = Arc<[(Arc<[Value]>, f64)]>;
15
16/// Evaluation context (optional posterior draw index).
17#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
18pub struct EvalContext {
19    /// Posterior draw index when evaluating against a draw-indexed provider.
20    pub draw: Option<usize>,
21}
22
23/// Variable → value binding for density / outcome lookup.
24#[derive(Clone, Debug, Default)]
25pub struct Assignment {
26    /// Sorted by variable id.
27    entries: Vec<(VariableId, Value)>,
28}
29
30impl Assignment {
31    /// Empty assignment.
32    #[must_use]
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Build from unsorted pairs (sorted + last-wins on duplicate vars).
38    #[must_use]
39    pub fn from_pairs(pairs: impl IntoIterator<Item = (VariableId, Value)>) -> Self {
40        let mut entries: Vec<(VariableId, Value)> = pairs.into_iter().collect();
41        entries.sort_by_key(|(v, _)| v.raw());
42        entries.dedup_by_key(|(v, _)| *v);
43        Self { entries }
44    }
45
46    /// Insert or replace a binding.
47    pub fn set(&mut self, var: VariableId, value: Value) {
48        match self.entries.binary_search_by_key(&var.raw(), |(v, _)| v.raw()) {
49            Ok(i) => self.entries[i].1 = value,
50            Err(i) => self.entries.insert(i, (var, value)),
51        }
52    }
53
54    /// Borrow value for `var`, if present.
55    #[must_use]
56    pub fn get(&self, var: VariableId) -> Option<&Value> {
57        self.entries
58            .binary_search_by_key(&var.raw(), |(v, _)| v.raw())
59            .ok()
60            .map(|i| &self.entries[i].1)
61    }
62
63    /// All bindings, sorted.
64    #[must_use]
65    pub fn entries(&self) -> &[(VariableId, Value)] {
66        &self.entries
67    }
68
69    /// Extend with another assignment (other wins on conflict).
70    pub fn extend_from(&mut self, other: &Assignment) {
71        for (v, val) in &other.entries {
72            self.set(*v, val.clone());
73        }
74    }
75
76    /// Restrict to the given variables (order of `vars` preserved in returned values).
77    pub fn values_for(&self, vars: &[VariableId]) -> Result<Vec<Value>, EvalError> {
78        let mut out = Vec::with_capacity(vars.len());
79        for &v in vars {
80            let Some(val) = self.get(v) else {
81                return Err(EvalError::MissingBinding(v));
82            };
83            out.push(val.clone());
84        }
85        Ok(out)
86    }
87}
88
89/// Resolved distribution factor identity (no string keys).
90#[derive(Clone, Debug)]
91pub struct FactorSpec<'a> {
92    /// Factor variables.
93    pub variables: &'a [VariableId],
94    /// Conditioning variables.
95    pub conditioned_on: &'a [VariableId],
96    /// Hard intervention assignments.
97    pub intervention: &'a [InterventionAssignment],
98    /// Observational vs interventional domain.
99    pub domain: DomainRef,
100}
101
102/// Errors from compiling or evaluating causal expressions.
103#[derive(Clone, Debug, Eq, PartialEq)]
104#[non_exhaustive]
105pub enum EvalError {
106    /// Continuous `IntegralOut` without quadrature nodes and without discrete support.
107    UnsupportedIntegralOut,
108    /// Provider has no entry for the requested factor / assignment.
109    MissingTableEntry,
110    /// Required variable binding absent from the assignment.
111    MissingBinding(VariableId),
112    /// Provider reported empty support for a summed variable.
113    EmptySupport(VariableId),
114    /// Division by zero while evaluating a ratio.
115    DivisionByZero,
116    /// Posterior draw index out of range.
117    DrawOutOfRange {
118        /// Requested draw.
119        draw: usize,
120        /// Number of draws available.
121        n_draws: usize,
122    },
123    /// Support row length does not match requested variable count.
124    SupportShape {
125        /// Expected arity.
126        expected: usize,
127        /// Actual arity.
128        actual: usize,
129    },
130    /// Empirical provider used where posterior draws are required (or vice versa).
131    ProviderKind(&'static str),
132    /// Provider cannot answer a conditional query (non-empty `conditioned_on`) —
133    /// e.g. an independent-factor provider that only models unconditional marginals.
134    UnsupportedConditioning(&'static str),
135}
136
137impl fmt::Display for EvalError {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        match self {
140            Self::UnsupportedIntegralOut => {
141                write!(f, "IntegralOut requires provider quadrature nodes or discrete support")
142            }
143            Self::MissingTableEntry => write!(f, "missing probability table entry"),
144            Self::MissingBinding(v) => write!(f, "missing binding for V{}", v.raw()),
145            Self::EmptySupport(v) => write!(f, "empty support for V{}", v.raw()),
146            Self::DivisionByZero => write!(f, "division by zero in ratio"),
147            Self::DrawOutOfRange { draw, n_draws } => {
148                write!(f, "draw {draw} out of range (n_draws={n_draws})")
149            }
150            Self::SupportShape { expected, actual } => {
151                write!(f, "support row arity {actual} != expected {expected}")
152            }
153            Self::ProviderKind(msg) | Self::UnsupportedConditioning(msg) => write!(f, "{msg}"),
154        }
155    }
156}
157
158impl std::error::Error for EvalError {}
159
160/// Provides densities, discrete supports, and outcome values for evaluation.
161pub trait DistributionProvider {
162    /// Probability / density mass for a factor under an assignment.
163    ///
164    /// # Errors
165    ///
166    /// Missing table entries, bad draw index, or shape errors.
167    fn probability(
168        &self,
169        spec: &FactorSpec<'_>,
170        assignment: &Assignment,
171        ctx: &EvalContext,
172    ) -> Result<f64, EvalError>;
173
174    /// Discrete support for variables (cartesian rows of values aligned to `vars`).
175    ///
176    /// # Errors
177    ///
178    /// Empty domains or unsupported queries.
179    fn support(
180        &self,
181        vars: &[VariableId],
182        ctx: &EvalContext,
183    ) -> Result<Arc<[Arc<[Value]>]>, EvalError>;
184
185    /// Optional continuous quadrature nodes `(assignment_row, Lebesgue weight)` for
186    /// [`crate::ExprNode::IntegralOut`].
187    ///
188    /// Returning `Ok(None)` asks the evaluator to fall back to discrete [`Self::support`].
189    ///
190    /// # Errors
191    ///
192    /// Provider-specific continuous-integration failures.
193    fn quadrature(
194        &self,
195        _vars: &[VariableId],
196        _ctx: &EvalContext,
197    ) -> Result<Option<QuadratureNodes>, EvalError> {
198        Ok(None)
199    }
200
201    /// Outcome function value (identity: the bound value of `var`).
202    ///
203    /// # Errors
204    ///
205    /// Missing binding or non-numeric value.
206    fn outcome(
207        &self,
208        var: VariableId,
209        assignment: &Assignment,
210        ctx: &EvalContext,
211    ) -> Result<f64, EvalError>;
212
213    /// Number of posterior draws, or `None` for a single empirical world.
214    fn n_draws(&self) -> Option<usize>;
215}
216
217/// Canonical key for a factor table row.
218#[derive(Clone, Debug, Eq, PartialEq, Hash)]
219struct FactorKey {
220    variables: Arc<[VariableId]>,
221    conditioned_on: Arc<[VariableId]>,
222    intervention: Arc<[InterventionAssignment]>,
223    domain: DomainRef,
224    /// Concatenation of values for `variables` then `conditioned_on`.
225    values: Arc<[Value]>,
226}
227
228fn factor_key(spec: &FactorSpec<'_>, assignment: &Assignment) -> Result<FactorKey, EvalError> {
229    let mut values = assignment.values_for(spec.variables)?;
230    values.extend(assignment.values_for(spec.conditioned_on)?);
231    Ok(FactorKey {
232        variables: Arc::from(spec.variables),
233        conditioned_on: Arc::from(spec.conditioned_on),
234        intervention: Arc::from(spec.intervention.to_vec()),
235        domain: spec.domain,
236        values: Arc::from(values),
237    })
238}
239
240/// Tabular empirical distribution provider (discrete factors + domains).
241#[derive(Clone, Debug, Default)]
242pub struct EmpiricalTableProvider {
243    domains: HashMap<VariableId, Arc<[Value]>>,
244    tables: HashMap<FactorKey, f64>,
245}
246
247impl EmpiricalTableProvider {
248    /// Empty provider.
249    #[must_use]
250    pub fn new() -> Self {
251        Self::default()
252    }
253
254    /// Declare discrete domain for a variable.
255    pub fn set_domain(&mut self, var: VariableId, values: impl IntoIterator<Item = Value>) {
256        let mut v: Vec<Value> = values.into_iter().collect();
257        // Stable unique by hash equality.
258        let mut seen = std::collections::HashSet::new();
259        v.retain(|x| seen.insert(x.clone()));
260        self.domains.insert(var, Arc::from(v));
261    }
262
263    /// Insert a factor probability for the given spec + assignment.
264    ///
265    /// # Errors
266    ///
267    /// Missing bindings for factor variables / conditions.
268    pub fn insert_probability(
269        &mut self,
270        spec: &FactorSpec<'_>,
271        assignment: &Assignment,
272        probability: f64,
273    ) -> Result<(), EvalError> {
274        let key = factor_key(spec, assignment)?;
275        self.tables.insert(key, probability);
276        Ok(())
277    }
278}
279
280impl DistributionProvider for EmpiricalTableProvider {
281    fn probability(
282        &self,
283        spec: &FactorSpec<'_>,
284        assignment: &Assignment,
285        _ctx: &EvalContext,
286    ) -> Result<f64, EvalError> {
287        let key = factor_key(spec, assignment)?;
288        self.tables.get(&key).copied().ok_or(EvalError::MissingTableEntry)
289    }
290
291    fn support(
292        &self,
293        vars: &[VariableId],
294        _ctx: &EvalContext,
295    ) -> Result<Arc<[Arc<[Value]>]>, EvalError> {
296        if vars.is_empty() {
297            return Ok(Arc::from(vec![Arc::from(Vec::<Value>::new())]));
298        }
299        let mut rows: Vec<Vec<Value>> = vec![Vec::new()];
300        for &v in vars {
301            let domain = self.domains.get(&v).ok_or(EvalError::EmptySupport(v))?;
302            if domain.is_empty() {
303                return Err(EvalError::EmptySupport(v));
304            }
305            let mut next = Vec::with_capacity(rows.len() * domain.len());
306            for prefix in &rows {
307                for val in domain.iter() {
308                    let mut row = prefix.clone();
309                    row.push(val.clone());
310                    next.push(row);
311                }
312            }
313            rows = next;
314        }
315        let out: Vec<Arc<[Value]>> = rows.into_iter().map(Arc::from).collect();
316        Ok(Arc::from(out))
317    }
318
319    fn outcome(
320        &self,
321        var: VariableId,
322        assignment: &Assignment,
323        _ctx: &EvalContext,
324    ) -> Result<f64, EvalError> {
325        let value = assignment.get(var).ok_or(EvalError::MissingBinding(var))?;
326        value.as_f64().ok_or(EvalError::MissingBinding(var))
327    }
328
329    fn n_draws(&self) -> Option<usize> {
330        None
331    }
332}
333
334/// Draw-indexed posterior provider: one [`EmpiricalTableProvider`] per draw.
335#[derive(Clone, Debug, Default)]
336pub struct PosteriorDrawProvider {
337    draws: Vec<EmpiricalTableProvider>,
338}
339
340impl PosteriorDrawProvider {
341    /// Empty posterior provider.
342    #[must_use]
343    pub fn new() -> Self {
344        Self::default()
345    }
346
347    /// Construct from per-draw empirical tables.
348    #[must_use]
349    pub fn from_draws(draws: Vec<EmpiricalTableProvider>) -> Self {
350        Self { draws }
351    }
352
353    /// Number of draws.
354    #[must_use]
355    pub fn len(&self) -> usize {
356        self.draws.len()
357    }
358
359    /// Whether there are no draws.
360    #[must_use]
361    pub fn is_empty(&self) -> bool {
362        self.draws.is_empty()
363    }
364
365    fn table(&self, ctx: &EvalContext) -> Result<&EmpiricalTableProvider, EvalError> {
366        let draw = ctx
367            .draw
368            .ok_or(EvalError::ProviderKind("PosteriorDrawProvider requires EvalContext.draw"))?;
369        self.draws.get(draw).ok_or(EvalError::DrawOutOfRange { draw, n_draws: self.draws.len() })
370    }
371}
372
373impl DistributionProvider for PosteriorDrawProvider {
374    fn probability(
375        &self,
376        spec: &FactorSpec<'_>,
377        assignment: &Assignment,
378        ctx: &EvalContext,
379    ) -> Result<f64, EvalError> {
380        self.table(ctx)?.probability(spec, assignment, ctx)
381    }
382
383    fn support(
384        &self,
385        vars: &[VariableId],
386        ctx: &EvalContext,
387    ) -> Result<Arc<[Arc<[Value]>]>, EvalError> {
388        self.table(ctx)?.support(vars, ctx)
389    }
390
391    fn outcome(
392        &self,
393        var: VariableId,
394        assignment: &Assignment,
395        ctx: &EvalContext,
396    ) -> Result<f64, EvalError> {
397        self.table(ctx)?.outcome(var, assignment, ctx)
398    }
399
400    fn n_draws(&self) -> Option<usize> {
401        Some(self.draws.len())
402    }
403}
404
405/// Independent Gaussian density provider with Gauss–Hermite quadrature.
406///
407/// `probability` returns the product of univariate N(μ, σ²) densities for factor
408/// variables. [`Self::quadrature`] returns product Gauss–Hermite nodes in
409/// Lebesgue measure (suitable for ∫ body(x) dx near each Gaussian mode).
410#[derive(Clone, Debug, Default)]
411pub struct GaussianDensityProvider {
412    /// Per-variable (mean, variance).
413    params: HashMap<VariableId, (f64, f64)>,
414}
415
416impl GaussianDensityProvider {
417    /// Empty provider.
418    #[must_use]
419    pub fn new() -> Self {
420        Self::default()
421    }
422
423    /// Declare an independent Gaussian for `var` with mean `mean` and variance `variance`.
424    ///
425    /// # Panics
426    ///
427    /// Never panics; non-positive variance is rejected by returning early no-op... actually
428    /// we clamp: variance must be > 0 or the insert is skipped. Prefer validating at call sites.
429    pub fn set_gaussian(&mut self, var: VariableId, mean: f64, variance: f64) {
430        if variance > 0.0 && variance.is_finite() && mean.is_finite() {
431            self.params.insert(var, (mean, variance));
432        }
433    }
434}
435
436/// Physicists' Gauss–Hermite nodes/weights for ∫ e^{-t²} g(t) dt (n = 5).
437const GH5_NODES: [f64; 5] = [
438    -2.020_182_870_456_085_6,
439    -0.958_572_464_613_818_5,
440    0.0,
441    0.958_572_464_613_818_5,
442    2.020_182_870_456_085_6,
443];
444const GH5_WEIGHTS: [f64; 5] = [
445    0.019_953_242_059_045_913,
446    0.393_619_323_152_241_35,
447    0.945_308_720_482_941_9,
448    0.393_619_323_152_241_35,
449    0.019_953_242_059_045_913,
450];
451
452impl DistributionProvider for GaussianDensityProvider {
453    fn probability(
454        &self,
455        spec: &FactorSpec<'_>,
456        assignment: &Assignment,
457        _ctx: &EvalContext,
458    ) -> Result<f64, EvalError> {
459        // Independent Gaussians only model unconditional marginals; a non-empty
460        // `conditioned_on` would silently return P(variables) instead of the
461        // requested P(variables | conditioned_on), so reject rather than guess.
462        if !spec.conditioned_on.is_empty() {
463            return Err(EvalError::UnsupportedConditioning(
464                "GaussianDensityProvider models independent Gaussians and cannot answer \
465                 conditional queries; conditioned_on must be empty",
466            ));
467        }
468        let mut dens = 1.0;
469        for &v in spec.variables {
470            let (mean, var) = self.params.get(&v).copied().ok_or(EvalError::EmptySupport(v))?;
471            let x =
472                assignment.get(v).and_then(Value::as_f64).ok_or(EvalError::MissingBinding(v))?;
473            let inv_sqrt = (2.0 * std::f64::consts::PI * var).sqrt().recip();
474            let z = (x - mean) / var.sqrt();
475            dens *= inv_sqrt * (-0.5 * z * z).exp();
476        }
477        Ok(dens)
478    }
479
480    fn support(
481        &self,
482        vars: &[VariableId],
483        _ctx: &EvalContext,
484    ) -> Result<Arc<[Arc<[Value]>]>, EvalError> {
485        if vars.is_empty() {
486            return Ok(Arc::from(vec![Arc::from(Vec::<Value>::new())]));
487        }
488        Err(EvalError::EmptySupport(vars[0]))
489    }
490
491    fn quadrature(
492        &self,
493        vars: &[VariableId],
494        _ctx: &EvalContext,
495    ) -> Result<Option<QuadratureNodes>, EvalError> {
496        if vars.is_empty() {
497            return Ok(Some(Arc::from([(Arc::from(Vec::<Value>::new()), 1.0)])));
498        }
499        // Product GH: start with empty prefix.
500        let mut nodes: Vec<(Vec<Value>, f64)> = vec![(Vec::new(), 1.0)];
501        for &v in vars {
502            let (mean, variance) =
503                self.params.get(&v).copied().ok_or(EvalError::EmptySupport(v))?;
504            let sigma = variance.sqrt();
505            let scale = sigma * std::f64::consts::SQRT_2;
506            let mut next = Vec::with_capacity(nodes.len() * GH5_NODES.len());
507            for (prefix, w0) in &nodes {
508                for (i, &t) in GH5_NODES.iter().enumerate() {
509                    let x = mean + scale * t;
510                    // GH computes ∫ e^{-t²} g(t) dt. For Lebesgue ∫ h(x) dx with
511                    // x = μ + σ√2 t we need g(t) = h(x) σ√2 e^{t²}, so the node
512                    // weight applied to h(x) is w_i · σ√2 · e^{t²}.
513                    let w = w0 * GH5_WEIGHTS[i] * scale * (t * t).exp();
514                    let mut row = prefix.clone();
515                    row.push(Value::f64(x));
516                    next.push((row, w));
517                }
518            }
519            nodes = next;
520        }
521        let out: Vec<(Arc<[Value]>, f64)> =
522            nodes.into_iter().map(|(row, w)| (Arc::from(row), w)).collect();
523        Ok(Some(Arc::from(out)))
524    }
525
526    fn outcome(
527        &self,
528        var: VariableId,
529        assignment: &Assignment,
530        _ctx: &EvalContext,
531    ) -> Result<f64, EvalError> {
532        let value = assignment.get(var).ok_or(EvalError::MissingBinding(var))?;
533        value.as_f64().ok_or(EvalError::MissingBinding(var))
534    }
535
536    fn n_draws(&self) -> Option<usize> {
537        None
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    fn v(id: u32) -> VariableId {
546        VariableId::from_raw(id)
547    }
548
549    fn f(x: f64) -> Value {
550        Value::f64(x)
551    }
552
553    #[test]
554    fn empirical_table_missing_entry_errors() {
555        // Domain declared but no `insert_probability` call for this cell: must
556        // surface `MissingTableEntry` rather than silently yielding 0.0.
557        let mut p = EmpiricalTableProvider::new();
558        let y = v(0);
559        p.set_domain(y, [f(0.0), f(1.0)]);
560        let spec = FactorSpec {
561            variables: &[y],
562            conditioned_on: &[],
563            intervention: &[],
564            domain: DomainRef::Observational,
565        };
566        let assignment = Assignment::from_pairs([(y, f(0.0))]);
567        let err = p.probability(&spec, &assignment, &EvalContext::default()).unwrap_err();
568        assert_eq!(err, EvalError::MissingTableEntry);
569    }
570
571    #[test]
572    fn gaussian_provider_rejects_conditional_query() {
573        // `GaussianDensityProvider` models independent Gaussians; it must error on a
574        // conditional query (non-empty `conditioned_on`) rather than silently
575        // returning the unconditional marginal P(variables).
576        let mut p = GaussianDensityProvider::new();
577        let y = v(0);
578        let z = v(1);
579        p.set_gaussian(y, 0.0, 1.0);
580        p.set_gaussian(z, 0.0, 1.0);
581        let spec = FactorSpec {
582            variables: &[y],
583            conditioned_on: &[z],
584            intervention: &[],
585            domain: DomainRef::Observational,
586        };
587        let assignment = Assignment::from_pairs([(y, f(0.5)), (z, f(0.2))]);
588        let err = p.probability(&spec, &assignment, &EvalContext::default()).unwrap_err();
589        assert!(matches!(err, EvalError::UnsupportedConditioning(_)));
590    }
591}