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::borrow::Borrow;
6use std::collections::HashMap;
7use std::fmt;
8use std::hash::{Hash, Hasher};
9use std::sync::{Arc, PoisonError, RwLock};
10
11use antecedent_core::{Value, VariableId};
12
13use crate::{DomainRef, InterventionAssignment};
14
15/// Weighted quadrature nodes: assignment rows paired with integration weights.
16pub type QuadratureNodes = Arc<[(Arc<[Value]>, f64)]>;
17
18/// Shared cartesian support rows, as returned by [`DistributionProvider::support`].
19type SupportRows = Arc<[Arc<[Value]>]>;
20
21/// Evaluation context (optional posterior draw index).
22#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
23pub struct EvalContext {
24    /// Posterior draw index when evaluating against a draw-indexed provider.
25    pub draw: Option<usize>,
26}
27
28/// Variable → value binding for density / outcome lookup.
29#[derive(Clone, Debug, Default)]
30pub struct Assignment {
31    /// Sorted by variable id.
32    entries: Vec<(VariableId, Value)>,
33}
34
35impl Assignment {
36    /// Empty assignment.
37    #[must_use]
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Build from unsorted pairs (sorted + last-wins on duplicate vars).
43    #[must_use]
44    pub fn from_pairs(pairs: impl IntoIterator<Item = (VariableId, Value)>) -> Self {
45        let mut entries: Vec<(VariableId, Value)> = pairs.into_iter().collect();
46        entries.sort_by_key(|(v, _)| v.raw());
47        entries.dedup_by_key(|(v, _)| *v);
48        Self { entries }
49    }
50
51    /// Insert or replace a binding.
52    pub fn set(&mut self, var: VariableId, value: Value) {
53        match self.entries.binary_search_by_key(&var.raw(), |(v, _)| v.raw()) {
54            Ok(i) => self.entries[i].1 = value,
55            Err(i) => self.entries.insert(i, (var, value)),
56        }
57    }
58
59    /// Borrow value for `var`, if present.
60    #[must_use]
61    pub fn get(&self, var: VariableId) -> Option<&Value> {
62        self.entries
63            .binary_search_by_key(&var.raw(), |(v, _)| v.raw())
64            .ok()
65            .map(|i| &self.entries[i].1)
66    }
67
68    /// All bindings, sorted.
69    #[must_use]
70    pub fn entries(&self) -> &[(VariableId, Value)] {
71        &self.entries
72    }
73
74    /// Extend with another assignment (other wins on conflict).
75    pub fn extend_from(&mut self, other: &Assignment) {
76        for (v, val) in &other.entries {
77            self.set(*v, val.clone());
78        }
79    }
80
81    /// Remove the binding for `var`, returning the previous value (if any).
82    ///
83    /// Lets evaluators treat one shared assignment as a binding stack: scoped
84    /// bindings are `set` on entry and removed (or restored) on exit instead
85    /// of cloning the whole assignment per scope.
86    pub fn remove(&mut self, var: VariableId) -> Option<Value> {
87        match self.entries.binary_search_by_key(&var.raw(), |(v, _)| v.raw()) {
88            Ok(i) => Some(self.entries.remove(i).1),
89            Err(_) => None,
90        }
91    }
92
93    /// Restrict to the given variables (order of `vars` preserved in returned values).
94    pub fn values_for(&self, vars: &[VariableId]) -> Result<Vec<Value>, EvalError> {
95        let mut out = Vec::with_capacity(vars.len());
96        for &v in vars {
97            let Some(val) = self.get(v) else {
98                return Err(EvalError::MissingBinding(v));
99            };
100            out.push(val.clone());
101        }
102        Ok(out)
103    }
104}
105
106/// Resolved distribution factor identity (no string keys).
107#[derive(Clone, Debug)]
108pub struct FactorSpec<'a> {
109    /// Factor variables.
110    pub variables: &'a [VariableId],
111    /// Conditioning variables.
112    pub conditioned_on: &'a [VariableId],
113    /// Hard intervention assignments.
114    pub intervention: &'a [InterventionAssignment],
115    /// Observational vs interventional domain.
116    pub domain: DomainRef,
117}
118
119/// Errors from compiling or evaluating causal expressions.
120#[derive(Clone, Debug, Eq, PartialEq)]
121#[non_exhaustive]
122pub enum EvalError {
123    /// Continuous `IntegralOut` without quadrature nodes and without discrete support.
124    UnsupportedIntegralOut,
125    /// Provider has no entry for the requested factor / assignment.
126    MissingTableEntry,
127    /// Required variable binding absent from the assignment.
128    MissingBinding(VariableId),
129    /// Provider reported empty support for a summed variable.
130    EmptySupport(VariableId),
131    /// Division by zero while evaluating a ratio.
132    DivisionByZero,
133    /// Posterior draw index out of range.
134    DrawOutOfRange {
135        /// Requested draw.
136        draw: usize,
137        /// Number of draws available.
138        n_draws: usize,
139    },
140    /// Support row length does not match requested variable count.
141    SupportShape {
142        /// Expected arity.
143        expected: usize,
144        /// Actual arity.
145        actual: usize,
146    },
147    /// Empirical provider used where posterior draws are required (or vice versa).
148    ProviderKind(&'static str),
149    /// Provider cannot answer a conditional query (non-empty `conditioned_on`) —
150    /// e.g. an independent-factor provider that only models unconditional marginals.
151    UnsupportedConditioning(&'static str),
152}
153
154impl fmt::Display for EvalError {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match self {
157            Self::UnsupportedIntegralOut => {
158                write!(f, "IntegralOut requires provider quadrature nodes or discrete support")
159            }
160            Self::MissingTableEntry => write!(f, "missing probability table entry"),
161            Self::MissingBinding(v) => write!(f, "missing binding for V{}", v.raw()),
162            Self::EmptySupport(v) => write!(f, "empty support for V{}", v.raw()),
163            Self::DivisionByZero => write!(f, "division by zero in ratio"),
164            Self::DrawOutOfRange { draw, n_draws } => {
165                write!(f, "draw {draw} out of range (n_draws={n_draws})")
166            }
167            Self::SupportShape { expected, actual } => {
168                write!(f, "support row arity {actual} != expected {expected}")
169            }
170            Self::ProviderKind(msg) | Self::UnsupportedConditioning(msg) => write!(f, "{msg}"),
171        }
172    }
173}
174
175impl std::error::Error for EvalError {}
176
177/// Provides densities, discrete supports, and outcome values for evaluation.
178pub trait DistributionProvider {
179    /// Probability / density mass for a factor under an assignment.
180    ///
181    /// # Errors
182    ///
183    /// Missing table entries, bad draw index, or shape errors.
184    fn probability(
185        &self,
186        spec: &FactorSpec<'_>,
187        assignment: &Assignment,
188        ctx: &EvalContext,
189    ) -> Result<f64, EvalError>;
190
191    /// Discrete support for variables (cartesian rows of values aligned to `vars`).
192    ///
193    /// # Errors
194    ///
195    /// Empty domains or unsupported queries.
196    fn support(
197        &self,
198        vars: &[VariableId],
199        ctx: &EvalContext,
200    ) -> Result<Arc<[Arc<[Value]>]>, EvalError>;
201
202    /// Optional continuous quadrature nodes `(assignment_row, Lebesgue weight)` for
203    /// [`crate::ExprNode::IntegralOut`].
204    ///
205    /// Returning `Ok(None)` asks the evaluator to fall back to discrete [`Self::support`].
206    ///
207    /// # Errors
208    ///
209    /// Provider-specific continuous-integration failures.
210    fn quadrature(
211        &self,
212        _vars: &[VariableId],
213        _ctx: &EvalContext,
214    ) -> Result<Option<QuadratureNodes>, EvalError> {
215        Ok(None)
216    }
217
218    /// Outcome function value (identity: the bound value of `var`).
219    ///
220    /// # Errors
221    ///
222    /// Missing binding or non-numeric value.
223    fn outcome(
224        &self,
225        var: VariableId,
226        assignment: &Assignment,
227        ctx: &EvalContext,
228    ) -> Result<f64, EvalError>;
229
230    /// Number of posterior draws, or `None` for a single empirical world.
231    fn n_draws(&self) -> Option<usize>;
232}
233
234/// Canonical key for a factor table row.
235///
236/// `Hash`/`PartialEq` are defined via [`FactorKeyView`] (not derived) so the
237/// owned key and the borrowed lookup view hash and compare identically — the
238/// `Borrow` contract the allocation-free `probability` lookup relies on.
239#[derive(Clone, Debug, Eq)]
240struct FactorKey {
241    variables: Arc<[VariableId]>,
242    conditioned_on: Arc<[VariableId]>,
243    intervention: Arc<[InterventionAssignment]>,
244    domain: DomainRef,
245    /// Concatenation of values for `variables` then `conditioned_on`.
246    values: Arc<[Value]>,
247}
248
249/// Borrowed view of a [`FactorKey`], usable as a `HashMap` lookup key.
250///
251/// `probability` is the innermost evaluator call; building an owned
252/// `FactorKey` there costs several `Arc` allocations per lookup. The view
253/// borrows the spec slices directly and only needs the caller to assemble the
254/// value row, so a lookup allocates that single row and nothing else.
255#[derive(Clone, Copy)]
256struct FactorKeyView<'a> {
257    variables: &'a [VariableId],
258    conditioned_on: &'a [VariableId],
259    intervention: &'a [InterventionAssignment],
260    domain: DomainRef,
261    /// Concatenation of values for `variables` then `conditioned_on`.
262    values: &'a [Value],
263}
264
265/// Unifies owned and borrowed factor keys behind one hash/equality identity,
266/// so `HashMap<FactorKey, f64>::get` accepts a [`FactorKeyView`] through
267/// `Borrow<dyn FactorKeyLookup>` without constructing a `FactorKey`.
268trait FactorKeyLookup {
269    fn view(&self) -> FactorKeyView<'_>;
270}
271
272impl FactorKeyLookup for FactorKey {
273    fn view(&self) -> FactorKeyView<'_> {
274        FactorKeyView {
275            variables: &self.variables,
276            conditioned_on: &self.conditioned_on,
277            intervention: &self.intervention,
278            domain: self.domain,
279            values: &self.values,
280        }
281    }
282}
283
284impl FactorKeyLookup for FactorKeyView<'_> {
285    fn view(&self) -> FactorKeyView<'_> {
286        *self
287    }
288}
289
290impl<'a> Borrow<dyn FactorKeyLookup + 'a> for FactorKey {
291    fn borrow(&self) -> &(dyn FactorKeyLookup + 'a) {
292        self
293    }
294}
295
296/// Single hashing routine for both key forms; hashing slices (rather than the
297/// `Arc` wrappers) keeps the streams identical for owned and borrowed keys.
298fn hash_factor_view<H: Hasher>(v: &FactorKeyView<'_>, state: &mut H) {
299    v.variables.hash(state);
300    v.conditioned_on.hash(state);
301    v.intervention.hash(state);
302    v.domain.hash(state);
303    v.values.hash(state);
304}
305
306impl Hash for FactorKey {
307    fn hash<H: Hasher>(&self, state: &mut H) {
308        hash_factor_view(&self.view(), state);
309    }
310}
311
312impl PartialEq for FactorKey {
313    fn eq(&self, other: &Self) -> bool {
314        factor_views_eq(&self.view(), &other.view())
315    }
316}
317
318impl Hash for dyn FactorKeyLookup + '_ {
319    fn hash<H: Hasher>(&self, state: &mut H) {
320        hash_factor_view(&self.view(), state);
321    }
322}
323
324impl PartialEq for dyn FactorKeyLookup + '_ {
325    fn eq(&self, other: &Self) -> bool {
326        factor_views_eq(&self.view(), &other.view())
327    }
328}
329
330impl Eq for dyn FactorKeyLookup + '_ {}
331
332fn factor_views_eq(a: &FactorKeyView<'_>, b: &FactorKeyView<'_>) -> bool {
333    a.variables == b.variables
334        && a.conditioned_on == b.conditioned_on
335        && a.intervention == b.intervention
336        && a.domain == b.domain
337        && a.values == b.values
338}
339
340/// Value row for a factor key: `variables` then `conditioned_on`, cloned out
341/// of the assignment. This is the only per-lookup allocation on the hot path.
342fn factor_values(spec: &FactorSpec<'_>, assignment: &Assignment) -> Result<Vec<Value>, EvalError> {
343    let mut values = Vec::with_capacity(spec.variables.len() + spec.conditioned_on.len());
344    for &v in spec.variables.iter().chain(spec.conditioned_on.iter()) {
345        let Some(val) = assignment.get(v) else {
346            return Err(EvalError::MissingBinding(v));
347        };
348        values.push(val.clone());
349    }
350    Ok(values)
351}
352
353/// Owned key for table inserts (cold path; lookups use [`FactorKeyView`]).
354fn factor_key(spec: &FactorSpec<'_>, assignment: &Assignment) -> Result<FactorKey, EvalError> {
355    let values = factor_values(spec, assignment)?;
356    Ok(FactorKey {
357        variables: Arc::from(spec.variables),
358        conditioned_on: Arc::from(spec.conditioned_on),
359        intervention: Arc::from(spec.intervention.to_vec()),
360        domain: spec.domain,
361        values: Arc::from(values),
362    })
363}
364
365/// Tabular empirical distribution provider (discrete factors + domains).
366#[derive(Debug, Default)]
367pub struct EmpiricalTableProvider {
368    domains: HashMap<VariableId, Arc<[Value]>>,
369    tables: HashMap<FactorKey, f64>,
370    /// Memoized cartesian supports keyed by the queried variable list.
371    ///
372    /// `support` takes `&self` (trait signature), so interior mutability is
373    /// required; `RwLock` rather than `RefCell` keeps the provider `Sync` for
374    /// callers that share it across threads. A cache hit is a cheap `Arc`
375    /// clone. Only `set_domain` changes what `support` would return, so it is
376    /// the one invalidation point.
377    support_cache: RwLock<HashMap<Vec<VariableId>, SupportRows>>,
378}
379
380impl Clone for EmpiricalTableProvider {
381    fn clone(&self) -> Self {
382        Self {
383            domains: self.domains.clone(),
384            tables: self.tables.clone(),
385            // Carrying the memoized supports over is safe: they are a pure
386            // function of `domains`, which is cloned alongside.
387            support_cache: RwLock::new(
388                self.support_cache.read().unwrap_or_else(PoisonError::into_inner).clone(),
389            ),
390        }
391    }
392}
393
394impl EmpiricalTableProvider {
395    /// Empty provider.
396    #[must_use]
397    pub fn new() -> Self {
398        Self::default()
399    }
400
401    /// Declare discrete domain for a variable.
402    pub fn set_domain(&mut self, var: VariableId, values: impl IntoIterator<Item = Value>) {
403        let mut v: Vec<Value> = values.into_iter().collect();
404        // Stable unique by hash equality.
405        let mut seen = std::collections::HashSet::new();
406        v.retain(|x| seen.insert(x.clone()));
407        self.domains.insert(var, Arc::from(v));
408        // Memoized supports are cartesian products of the domains; any domain
409        // change invalidates every cached row set.
410        self.support_cache.write().unwrap_or_else(PoisonError::into_inner).clear();
411    }
412
413    /// Insert a factor probability for the given spec + assignment.
414    ///
415    /// # Errors
416    ///
417    /// Missing bindings for factor variables / conditions.
418    pub fn insert_probability(
419        &mut self,
420        spec: &FactorSpec<'_>,
421        assignment: &Assignment,
422        probability: f64,
423    ) -> Result<(), EvalError> {
424        let key = factor_key(spec, assignment)?;
425        self.tables.insert(key, probability);
426        Ok(())
427    }
428}
429
430impl DistributionProvider for EmpiricalTableProvider {
431    fn probability(
432        &self,
433        spec: &FactorSpec<'_>,
434        assignment: &Assignment,
435        _ctx: &EvalContext,
436    ) -> Result<f64, EvalError> {
437        // Borrowed-key lookup: no owned `FactorKey` (and its per-field `Arc`
438        // allocations) on the hot path — only the value row is assembled.
439        let values = factor_values(spec, assignment)?;
440        let key = FactorKeyView {
441            variables: spec.variables,
442            conditioned_on: spec.conditioned_on,
443            intervention: spec.intervention,
444            domain: spec.domain,
445            values: &values,
446        };
447        self.tables.get(&key as &dyn FactorKeyLookup).copied().ok_or(EvalError::MissingTableEntry)
448    }
449
450    fn support(
451        &self,
452        vars: &[VariableId],
453        _ctx: &EvalContext,
454    ) -> Result<Arc<[Arc<[Value]>]>, EvalError> {
455        // Callers (SumOut / IntegralOut / Expectation) request the same
456        // variable sets on every draw, replicate, and nesting level; the
457        // cartesian product is a pure function of `domains`, so memoize it and
458        // hand back a shared `Arc` on hits. Errors (missing / empty domain)
459        // are not cached — that path aborts evaluation anyway.
460        if let Some(hit) =
461            self.support_cache.read().unwrap_or_else(PoisonError::into_inner).get(vars)
462        {
463            return Ok(Arc::clone(hit));
464        }
465        // `vars.is_empty()` needs no special case: the fold below yields the
466        // single empty row, matching the pre-memoization behavior.
467        let mut rows: Vec<Vec<Value>> = vec![Vec::new()];
468        for &v in vars {
469            let domain = self.domains.get(&v).ok_or(EvalError::EmptySupport(v))?;
470            if domain.is_empty() {
471                return Err(EvalError::EmptySupport(v));
472            }
473            let mut next = Vec::with_capacity(rows.len() * domain.len());
474            for prefix in &rows {
475                for val in domain.iter() {
476                    let mut row = prefix.clone();
477                    row.push(val.clone());
478                    next.push(row);
479                }
480            }
481            rows = next;
482        }
483        let out: Arc<[Arc<[Value]>]> = rows.into_iter().map(Arc::from).collect();
484        self.support_cache
485            .write()
486            .unwrap_or_else(PoisonError::into_inner)
487            .insert(vars.to_vec(), Arc::clone(&out));
488        Ok(out)
489    }
490
491    fn outcome(
492        &self,
493        var: VariableId,
494        assignment: &Assignment,
495        _ctx: &EvalContext,
496    ) -> Result<f64, EvalError> {
497        let value = assignment.get(var).ok_or(EvalError::MissingBinding(var))?;
498        value.as_f64().ok_or(EvalError::MissingBinding(var))
499    }
500
501    fn n_draws(&self) -> Option<usize> {
502        None
503    }
504}
505
506/// Draw-indexed posterior provider: one [`EmpiricalTableProvider`] per draw.
507#[derive(Clone, Debug, Default)]
508pub struct PosteriorDrawProvider {
509    draws: Vec<EmpiricalTableProvider>,
510}
511
512impl PosteriorDrawProvider {
513    /// Empty posterior provider.
514    #[must_use]
515    pub fn new() -> Self {
516        Self::default()
517    }
518
519    /// Construct from per-draw empirical tables.
520    #[must_use]
521    pub fn from_draws(draws: Vec<EmpiricalTableProvider>) -> Self {
522        Self { draws }
523    }
524
525    /// Number of draws.
526    #[must_use]
527    pub fn len(&self) -> usize {
528        self.draws.len()
529    }
530
531    /// Whether there are no draws.
532    #[must_use]
533    pub fn is_empty(&self) -> bool {
534        self.draws.is_empty()
535    }
536
537    fn table(&self, ctx: &EvalContext) -> Result<&EmpiricalTableProvider, EvalError> {
538        let draw = ctx
539            .draw
540            .ok_or(EvalError::ProviderKind("PosteriorDrawProvider requires EvalContext.draw"))?;
541        self.draws.get(draw).ok_or(EvalError::DrawOutOfRange { draw, n_draws: self.draws.len() })
542    }
543}
544
545impl DistributionProvider for PosteriorDrawProvider {
546    fn probability(
547        &self,
548        spec: &FactorSpec<'_>,
549        assignment: &Assignment,
550        ctx: &EvalContext,
551    ) -> Result<f64, EvalError> {
552        self.table(ctx)?.probability(spec, assignment, ctx)
553    }
554
555    fn support(
556        &self,
557        vars: &[VariableId],
558        ctx: &EvalContext,
559    ) -> Result<Arc<[Arc<[Value]>]>, EvalError> {
560        self.table(ctx)?.support(vars, ctx)
561    }
562
563    fn outcome(
564        &self,
565        var: VariableId,
566        assignment: &Assignment,
567        ctx: &EvalContext,
568    ) -> Result<f64, EvalError> {
569        self.table(ctx)?.outcome(var, assignment, ctx)
570    }
571
572    fn n_draws(&self) -> Option<usize> {
573        Some(self.draws.len())
574    }
575}
576
577/// Independent Gaussian density provider with Gauss–Hermite quadrature.
578///
579/// `probability` returns the product of univariate N(μ, σ²) densities for factor
580/// variables. [`Self::quadrature`] returns product Gauss–Hermite nodes in
581/// Lebesgue measure (suitable for ∫ body(x) dx near each Gaussian mode).
582#[derive(Clone, Debug, Default)]
583pub struct GaussianDensityProvider {
584    /// Per-variable (mean, variance).
585    params: HashMap<VariableId, (f64, f64)>,
586}
587
588impl GaussianDensityProvider {
589    /// Empty provider.
590    #[must_use]
591    pub fn new() -> Self {
592        Self::default()
593    }
594
595    /// Declare an independent Gaussian for `var` with mean `mean` and variance `variance`.
596    ///
597    /// # Panics
598    ///
599    /// Never panics; non-positive variance is rejected by returning early no-op... actually
600    /// we clamp: variance must be > 0 or the insert is skipped. Prefer validating at call sites.
601    pub fn set_gaussian(&mut self, var: VariableId, mean: f64, variance: f64) {
602        if variance > 0.0 && variance.is_finite() && mean.is_finite() {
603            self.params.insert(var, (mean, variance));
604        }
605    }
606}
607
608/// Physicists' Gauss–Hermite nodes/weights for ∫ e^{-t²} g(t) dt (n = 5).
609const GH5_NODES: [f64; 5] = [
610    -2.020_182_870_456_085_6,
611    -0.958_572_464_613_818_5,
612    0.0,
613    0.958_572_464_613_818_5,
614    2.020_182_870_456_085_6,
615];
616const GH5_WEIGHTS: [f64; 5] = [
617    0.019_953_242_059_045_913,
618    0.393_619_323_152_241_35,
619    0.945_308_720_482_941_9,
620    0.393_619_323_152_241_35,
621    0.019_953_242_059_045_913,
622];
623
624impl DistributionProvider for GaussianDensityProvider {
625    fn probability(
626        &self,
627        spec: &FactorSpec<'_>,
628        assignment: &Assignment,
629        _ctx: &EvalContext,
630    ) -> Result<f64, EvalError> {
631        // Independent Gaussians only model unconditional marginals; a non-empty
632        // `conditioned_on` would silently return P(variables) instead of the
633        // requested P(variables | conditioned_on), so reject rather than guess.
634        if !spec.conditioned_on.is_empty() {
635            return Err(EvalError::UnsupportedConditioning(
636                "GaussianDensityProvider models independent Gaussians and cannot answer \
637                 conditional queries; conditioned_on must be empty",
638            ));
639        }
640        let mut dens = 1.0;
641        for &v in spec.variables {
642            let (mean, var) = self.params.get(&v).copied().ok_or(EvalError::EmptySupport(v))?;
643            let x =
644                assignment.get(v).and_then(Value::as_f64).ok_or(EvalError::MissingBinding(v))?;
645            let inv_sqrt = (2.0 * std::f64::consts::PI * var).sqrt().recip();
646            let z = (x - mean) / var.sqrt();
647            dens *= inv_sqrt * (-0.5 * z * z).exp();
648        }
649        Ok(dens)
650    }
651
652    fn support(
653        &self,
654        vars: &[VariableId],
655        _ctx: &EvalContext,
656    ) -> Result<Arc<[Arc<[Value]>]>, EvalError> {
657        if vars.is_empty() {
658            return Ok(Arc::from(vec![Arc::from(Vec::<Value>::new())]));
659        }
660        Err(EvalError::EmptySupport(vars[0]))
661    }
662
663    fn quadrature(
664        &self,
665        vars: &[VariableId],
666        _ctx: &EvalContext,
667    ) -> Result<Option<QuadratureNodes>, EvalError> {
668        if vars.is_empty() {
669            return Ok(Some(Arc::from([(Arc::from(Vec::<Value>::new()), 1.0)])));
670        }
671        // Product GH: start with empty prefix.
672        let mut nodes: Vec<(Vec<Value>, f64)> = vec![(Vec::new(), 1.0)];
673        for &v in vars {
674            let (mean, variance) =
675                self.params.get(&v).copied().ok_or(EvalError::EmptySupport(v))?;
676            let sigma = variance.sqrt();
677            let scale = sigma * std::f64::consts::SQRT_2;
678            let mut next = Vec::with_capacity(nodes.len() * GH5_NODES.len());
679            for (prefix, w0) in &nodes {
680                for (i, &t) in GH5_NODES.iter().enumerate() {
681                    let x = mean + scale * t;
682                    // GH computes ∫ e^{-t²} g(t) dt. For Lebesgue ∫ h(x) dx with
683                    // x = μ + σ√2 t we need g(t) = h(x) σ√2 e^{t²}, so the node
684                    // weight applied to h(x) is w_i · σ√2 · e^{t²}.
685                    let w = w0 * GH5_WEIGHTS[i] * scale * (t * t).exp();
686                    let mut row = prefix.clone();
687                    row.push(Value::f64(x));
688                    next.push((row, w));
689                }
690            }
691            nodes = next;
692        }
693        let out: Vec<(Arc<[Value]>, f64)> =
694            nodes.into_iter().map(|(row, w)| (Arc::from(row), w)).collect();
695        Ok(Some(Arc::from(out)))
696    }
697
698    fn outcome(
699        &self,
700        var: VariableId,
701        assignment: &Assignment,
702        _ctx: &EvalContext,
703    ) -> Result<f64, EvalError> {
704        let value = assignment.get(var).ok_or(EvalError::MissingBinding(var))?;
705        value.as_f64().ok_or(EvalError::MissingBinding(var))
706    }
707
708    fn n_draws(&self) -> Option<usize> {
709        None
710    }
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    fn v(id: u32) -> VariableId {
718        VariableId::from_raw(id)
719    }
720
721    fn f(x: f64) -> Value {
722        Value::f64(x)
723    }
724
725    #[test]
726    fn empirical_table_missing_entry_errors() {
727        // Domain declared but no `insert_probability` call for this cell: must
728        // surface `MissingTableEntry` rather than silently yielding 0.0.
729        let mut p = EmpiricalTableProvider::new();
730        let y = v(0);
731        p.set_domain(y, [f(0.0), f(1.0)]);
732        let spec = FactorSpec {
733            variables: &[y],
734            conditioned_on: &[],
735            intervention: &[],
736            domain: DomainRef::Observational,
737        };
738        let assignment = Assignment::from_pairs([(y, f(0.0))]);
739        let err = p.probability(&spec, &assignment, &EvalContext::default()).unwrap_err();
740        assert_eq!(err, EvalError::MissingTableEntry);
741    }
742
743    #[test]
744    fn support_memoizes_and_invalidates_on_set_domain() {
745        // A repeated `support` query must be a cheap clone of the same shared
746        // rows (memoization), and `set_domain` — the only mutation that can
747        // change what `support` returns — must invalidate the cache.
748        let mut p = EmpiricalTableProvider::new();
749        let a = v(0);
750        let b = v(1);
751        p.set_domain(a, [f(0.0), f(1.0)]);
752        p.set_domain(b, [f(0.0), f(1.0), f(2.0)]);
753        let ctx = EvalContext::default();
754        let first = p.support(&[a, b], &ctx).unwrap();
755        assert_eq!(first.len(), 6);
756        let second = p.support(&[a, b], &ctx).unwrap();
757        assert!(Arc::ptr_eq(&first, &second), "cache hit must return the shared rows");
758        p.set_domain(b, [f(0.0), f(1.0)]);
759        let third = p.support(&[a, b], &ctx).unwrap();
760        assert!(!Arc::ptr_eq(&first, &third), "set_domain must invalidate the cache");
761        // Cartesian order (outer var major, domain order minor) is unchanged.
762        let rows: Vec<Vec<f64>> =
763            third.iter().map(|r| r.iter().map(|x| x.as_f64().unwrap()).collect()).collect();
764        assert_eq!(rows, vec![vec![0.0, 0.0], vec![0.0, 1.0], vec![1.0, 0.0], vec![1.0, 1.0]]);
765    }
766
767    #[test]
768    fn empty_support_query_yields_single_empty_row() {
769        // The empty query has one row (the empty assignment); the memoized
770        // path must preserve that vacuous-product convention.
771        let p = EmpiricalTableProvider::new();
772        let rows = p.support(&[], &EvalContext::default()).unwrap();
773        assert_eq!(rows.len(), 1);
774        assert!(rows[0].is_empty());
775    }
776
777    #[test]
778    fn borrowed_key_lookup_matches_owned_insert() {
779        // `probability` looks up via the borrowed `FactorKeyView`; it must hit
780        // entries inserted via the owned `FactorKey` (same hash/equality
781        // identity across every component) and still miss when any component
782        // — value row or domain — differs.
783        let mut p = EmpiricalTableProvider::new();
784        let y = v(0);
785        let z = v(1);
786        let t = v(2);
787        let interv = [InterventionAssignment { variable: t, value: f(1.0) }];
788        let spec = FactorSpec {
789            variables: &[y],
790            conditioned_on: &[z],
791            intervention: &interv,
792            domain: DomainRef::Interventional,
793        };
794        let assign = Assignment::from_pairs([(y, f(1.0)), (z, f(0.0))]);
795        p.insert_probability(&spec, &assign, 0.25).unwrap();
796        let ctx = EvalContext::default();
797        assert!((p.probability(&spec, &assign, &ctx).unwrap() - 0.25).abs() < 1e-15);
798        let other = Assignment::from_pairs([(y, f(1.0)), (z, f(1.0))]);
799        assert_eq!(p.probability(&spec, &other, &ctx).unwrap_err(), EvalError::MissingTableEntry);
800        let obs = FactorSpec { domain: DomainRef::Observational, ..spec.clone() };
801        assert_eq!(p.probability(&obs, &assign, &ctx).unwrap_err(), EvalError::MissingTableEntry);
802    }
803
804    #[test]
805    fn gaussian_provider_rejects_conditional_query() {
806        // `GaussianDensityProvider` models independent Gaussians; it must error on a
807        // conditional query (non-empty `conditioned_on`) rather than silently
808        // returning the unconditional marginal P(variables).
809        let mut p = GaussianDensityProvider::new();
810        let y = v(0);
811        let z = v(1);
812        p.set_gaussian(y, 0.0, 1.0);
813        p.set_gaussian(z, 0.0, 1.0);
814        let spec = FactorSpec {
815            variables: &[y],
816            conditioned_on: &[z],
817            intervention: &[],
818            domain: DomainRef::Observational,
819        };
820        let assignment = Assignment::from_pairs([(y, f(0.5)), (z, f(0.2))]);
821        let err = p.probability(&spec, &assignment, &EvalContext::default()).unwrap_err();
822        assert!(matches!(err, EvalError::UnsupportedConditioning(_)));
823    }
824}