Skip to main content

antecedent_expr/
simplify.rs

1//! Algebraic simplification via worklist + memoization.
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::VariableId;
10
11use crate::{CausalExprArena, DerivationMeta, ExprId, ExprNode, VarSetId};
12
13/// Errors surfaced by the crate's simplify entry points when they detect an ill-formed estimand instead of
14/// silently rewriting it.
15///
16/// `eval_sum_out` / `eval_integral_out` (`crate::eval`) evaluate `SumOut` /
17/// `IntegralOut` as a **literal, unnormalized** sum/integral over
18/// `support(variables)`. A well-formed estimand always folds a `P(v|·)` factor into
19/// the body for each bound variable `v`, so the body's free variables always
20/// intersect `variables`. If they don't, the node is malformed: collapsing it to
21/// its body (the old behavior) would silently divide the true value by
22/// `|support(v)|` (`SumOut`) or drop the integration measure entirely
23/// (`IntegralOut`). Rather than guess, `simplify` fails closed and reports it.
24#[derive(Clone, Debug, Eq, PartialEq)]
25#[non_exhaustive]
26pub enum SimplifyError {
27    /// A `SumOut` binds variable(s) that are absent from the free variables of its
28    /// body.
29    DeadSumOut {
30        /// The bound variables, none of which occur free in the summed body.
31        variables: Vec<VariableId>,
32    },
33    /// An `IntegralOut` binds variable(s) that are absent from the free variables of
34    /// its body.
35    DeadIntegralOut {
36        /// The bound variables, none of which occur free in the integrated body.
37        variables: Vec<VariableId>,
38    },
39}
40
41impl fmt::Display for SimplifyError {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::DeadSumOut { variables } => {
45                write!(f, "SumOut binds variable(s) not free in its body: ")?;
46                write_var_list(f, variables)
47            }
48            Self::DeadIntegralOut { variables } => {
49                write!(f, "IntegralOut binds variable(s) not free in its body: ")?;
50                write_var_list(f, variables)
51            }
52        }
53    }
54}
55
56impl std::error::Error for SimplifyError {}
57
58fn write_var_list(f: &mut fmt::Formatter<'_>, variables: &[VariableId]) -> fmt::Result {
59    for (i, v) in variables.iter().enumerate() {
60        if i > 0 {
61            write!(f, ", ")?;
62        }
63        write!(f, "V{}", v.raw())?;
64    }
65    Ok(())
66}
67
68/// Simplify `root` bottom-up with memoization; returns a (possibly new) `ExprId`.
69///
70/// # Errors
71///
72/// [`SimplifyError`] if a `SumOut`/`IntegralOut` binds a variable absent from its
73/// body's free variables (an ill-formed estimand; see [`SimplifyError`] docs).
74pub(crate) fn simplify(arena: &mut CausalExprArena, root: ExprId) -> Result<ExprId, SimplifyError> {
75    let mut memo: HashMap<ExprId, ExprId> = HashMap::new();
76    let mut free_memo: HashMap<ExprId, VarSetId> = HashMap::new();
77    simplify_rec(arena, root, &mut memo, &mut free_memo)
78}
79
80fn simplify_rec(
81    arena: &mut CausalExprArena,
82    id: ExprId,
83    memo: &mut HashMap<ExprId, ExprId>,
84    free_memo: &mut HashMap<ExprId, VarSetId>,
85) -> Result<ExprId, SimplifyError> {
86    if let Some(&cached) = memo.get(&id) {
87        return Ok(cached);
88    }
89    let rebuilt = rebuild_children(arena, id, memo, free_memo)?;
90    let simplified = apply_rules_fixpoint(arena, rebuilt, free_memo)?;
91    memo.insert(id, simplified);
92    Ok(simplified)
93}
94
95fn rebuild_children(
96    arena: &mut CausalExprArena,
97    id: ExprId,
98    memo: &mut HashMap<ExprId, ExprId>,
99    free_memo: &mut HashMap<ExprId, VarSetId>,
100) -> Result<ExprId, SimplifyError> {
101    let node = arena.node(id).clone();
102    let rebuilt = match node {
103        ExprNode::Distribution { .. } => id,
104        ExprNode::Product(list) => {
105            let children_ids: Vec<ExprId> = arena.list(list).to_vec();
106            let mut children: Vec<ExprId> = Vec::with_capacity(children_ids.len());
107            for c in children_ids {
108                children.push(simplify_rec(arena, c, memo, free_memo)?);
109            }
110            let list_id = arena.intern_list(children);
111            arena.intern(ExprNode::Product(list_id))
112        }
113        ExprNode::SumOut { variables, expr } => {
114            let body = simplify_rec(arena, expr, memo, free_memo)?;
115            arena.intern(ExprNode::SumOut { variables, expr: body })
116        }
117        ExprNode::IntegralOut { variables, expr } => {
118            let body = simplify_rec(arena, expr, memo, free_memo)?;
119            arena.intern(ExprNode::IntegralOut { variables, expr: body })
120        }
121        ExprNode::Ratio { numerator, denominator } => {
122            let num = simplify_rec(arena, numerator, memo, free_memo)?;
123            let den = simplify_rec(arena, denominator, memo, free_memo)?;
124            arena.intern(ExprNode::Ratio { numerator: num, denominator: den })
125        }
126        ExprNode::Expectation { function, distribution } => {
127            let dist = simplify_rec(arena, distribution, memo, free_memo)?;
128            arena.intern(ExprNode::Expectation { function, distribution: dist })
129        }
130        ExprNode::Contrast { left, right, op } => {
131            let l = simplify_rec(arena, left, memo, free_memo)?;
132            let r = simplify_rec(arena, right, memo, free_memo)?;
133            arena.intern(ExprNode::Contrast { left: l, right: r, op })
134        }
135    };
136    Ok(rebuilt)
137}
138
139fn apply_rules_fixpoint(
140    arena: &mut CausalExprArena,
141    mut id: ExprId,
142    free_memo: &mut HashMap<ExprId, VarSetId>,
143) -> Result<ExprId, SimplifyError> {
144    // Local rules only; children are already simplified.
145    loop {
146        let next = apply_local_rules(arena, id, free_memo)?;
147        if next == id {
148            return Ok(id);
149        }
150        id = next;
151    }
152}
153
154fn apply_local_rules(
155    arena: &mut CausalExprArena,
156    id: ExprId,
157    free_memo: &mut HashMap<ExprId, VarSetId>,
158) -> Result<ExprId, SimplifyError> {
159    match arena.node(id).clone() {
160        ExprNode::SumOut { variables, expr } => {
161            rewrite_sum_out(arena, id, variables, expr, free_memo)
162        }
163        ExprNode::IntegralOut { variables, expr } => {
164            rewrite_integral_out(arena, id, variables, expr, free_memo)
165        }
166        ExprNode::Product(list) => Ok(rewrite_product(arena, id, list)),
167        ExprNode::Ratio { numerator, denominator } => {
168            Ok(rewrite_ratio(arena, id, numerator, denominator))
169        }
170        _ => Ok(id),
171    }
172}
173
174fn rewrite_sum_out(
175    arena: &mut CausalExprArena,
176    id: ExprId,
177    variables: VarSetId,
178    expr: ExprId,
179    free_memo: &mut HashMap<ExprId, VarSetId>,
180) -> Result<ExprId, SimplifyError> {
181    if arena.var_set(variables).is_empty() {
182        return Ok(tag_if_new(arena, expr, id, "simplify.empty_sum_out"));
183    }
184    if let ExprNode::SumOut { variables: inner_v, expr: inner_e } = arena.node(expr).clone() {
185        let merged: Vec<VariableId> = arena
186            .var_set(variables)
187            .iter()
188            .copied()
189            .chain(arena.var_set(inner_v).iter().copied())
190            .collect();
191        let union = arena.intern_var_set(merged);
192        let node = ExprNode::SumOut { variables: union, expr: inner_e };
193        return Ok(intern_derived(arena, node, "simplify.merge_sum_out"));
194    }
195    let free = free_vars(arena, expr, free_memo);
196    if !intersects(arena, variables, free) {
197        // Ill-formed estimand (see `SimplifyError` docs) — fail closed rather than
198        // silently eliminating the sum (which would drop the `|support(v)|` factor).
199        return Err(SimplifyError::DeadSumOut { variables: arena.var_set(variables).to_vec() });
200    }
201    Ok(id)
202}
203
204fn rewrite_integral_out(
205    arena: &mut CausalExprArena,
206    id: ExprId,
207    variables: VarSetId,
208    expr: ExprId,
209    free_memo: &mut HashMap<ExprId, VarSetId>,
210) -> Result<ExprId, SimplifyError> {
211    if arena.var_set(variables).is_empty() {
212        return Ok(tag_if_new(arena, expr, id, "simplify.empty_integral_out"));
213    }
214    if let ExprNode::IntegralOut { variables: inner_v, expr: inner_e } = arena.node(expr).clone() {
215        let merged: Vec<VariableId> = arena
216            .var_set(variables)
217            .iter()
218            .copied()
219            .chain(arena.var_set(inner_v).iter().copied())
220            .collect();
221        let union = arena.intern_var_set(merged);
222        let node = ExprNode::IntegralOut { variables: union, expr: inner_e };
223        return Ok(intern_derived(arena, node, "simplify.merge_integral_out"));
224    }
225    let free = free_vars(arena, expr, free_memo);
226    if !intersects(arena, variables, free) {
227        // Ill-formed estimand (see `SimplifyError` docs) — fail closed rather than
228        // silently collapsing the integral (which would drop the integration measure).
229        return Err(SimplifyError::DeadIntegralOut {
230            variables: arena.var_set(variables).to_vec(),
231        });
232    }
233    Ok(id)
234}
235
236fn rewrite_product(arena: &mut CausalExprArena, id: ExprId, list: crate::ExprListId) -> ExprId {
237    let children = arena.list(list).to_vec();
238    if children.len() == 1 {
239        return tag_if_new(arena, children[0], id, "simplify.singleton_product");
240    }
241    let mut flat: Vec<ExprId> = Vec::with_capacity(children.len());
242    let mut flattened = false;
243    for c in &children {
244        if let ExprNode::Product(inner) = arena.node(*c) {
245            flat.extend_from_slice(arena.list(*inner));
246            flattened = true;
247        } else {
248            flat.push(*c);
249        }
250    }
251    flat.sort_unstable();
252    let sorted_changed = flat.as_slice() != children.as_slice();
253    if flattened || sorted_changed {
254        if flat.len() == 1 {
255            return tag_if_new(arena, flat[0], id, "simplify.singleton_product");
256        }
257        let list_id = arena.intern_list(flat);
258        let rule =
259            if flattened { "simplify.flatten_product" } else { "simplify.canonical_product" };
260        return intern_derived(arena, ExprNode::Product(list_id), rule);
261    }
262    id
263}
264
265fn rewrite_ratio(
266    arena: &mut CausalExprArena,
267    id: ExprId,
268    numerator: ExprId,
269    denominator: ExprId,
270) -> ExprId {
271    // (a/b)/c → a/(b*c)
272    if let ExprNode::Ratio { numerator: a, denominator: b } = arena.node(numerator).clone() {
273        let bc = {
274            let mut kids = vec![b, denominator];
275            kids.sort_unstable();
276            let list = arena.intern_list(kids);
277            arena.intern(ExprNode::Product(list))
278        };
279        return intern_derived(
280            arena,
281            ExprNode::Ratio { numerator: a, denominator: bc },
282            "simplify.ratio_assoc_left",
283        );
284    }
285    // a/(b/c) → (a*c)/b
286    if let ExprNode::Ratio { numerator: b, denominator: c } = arena.node(denominator).clone() {
287        let ac = {
288            let mut kids = vec![numerator, c];
289            kids.sort_unstable();
290            let list = arena.intern_list(kids);
291            arena.intern(ExprNode::Product(list))
292        };
293        return intern_derived(
294            arena,
295            ExprNode::Ratio { numerator: ac, denominator: b },
296            "simplify.ratio_assoc_right",
297        );
298    }
299    id
300}
301
302fn tag_if_new(arena: &mut CausalExprArena, result: ExprId, _from: ExprId, _rule: &str) -> ExprId {
303    // Identity rewrite to an existing child — no new node; leave child's derivation alone.
304    let _ = arena;
305    result
306}
307
308fn intern_derived(arena: &mut CausalExprArena, node: ExprNode, rule: &str) -> ExprId {
309    let before = arena.len();
310    let id = arena.intern(node);
311    if arena.len() > before {
312        arena.set_derivation_if_absent(id, DerivationMeta { rule: Arc::from(rule), note: None });
313    }
314    id
315}
316
317fn intersects(arena: &CausalExprArena, a: VarSetId, b: VarSetId) -> bool {
318    let av = arena.var_set(a);
319    let bv = arena.var_set(b);
320    let mut i = 0;
321    let mut j = 0;
322    while i < av.len() && j < bv.len() {
323        match av[i].raw().cmp(&bv[j].raw()) {
324            std::cmp::Ordering::Equal => return true,
325            std::cmp::Ordering::Less => i += 1,
326            std::cmp::Ordering::Greater => j += 1,
327        }
328    }
329    false
330}
331
332fn free_vars(
333    arena: &mut CausalExprArena,
334    id: ExprId,
335    memo: &mut HashMap<ExprId, VarSetId>,
336) -> VarSetId {
337    if let Some(&cached) = memo.get(&id) {
338        return cached;
339    }
340    let result = match arena.node(id).clone() {
341        ExprNode::Distribution { variables, conditioned_on, intervention, .. } => {
342            let mut vars: Vec<VariableId> = arena.var_set(variables).to_vec();
343            // `conditioned_on` variables bound by the accompanying `intervention` set
344            // are do(·)-fixed, not free — mirrors `eval::compute_free_vars`'s
345            // `Distribution` arm (`eval.rs`), which this function must agree with:
346            // both feed the "does the body depend on the summed variable" check in
347            // `rewrite_sum_out`/`rewrite_integral_out` above, and a discrepancy there
348            // was previously masking ill-formed estimands (B3). This can only shrink
349            // the free-variable set relative to the old (unconditionally-inclusive)
350            // version, which can only make `intersects()` return true *less* often —
351            // i.e. it can only turn a previously-missed dead-sum/integral into a
352            // now-detected `SimplifyError`, never turn a legitimate dependency into a
353            // spurious elimination. It cannot newly enable an unsound rewrite.
354            let bound: Vec<VariableId> =
355                arena.intervention_assignments(intervention).iter().map(|a| a.variable).collect();
356            for &v in arena.var_set(conditioned_on) {
357                if !bound.iter().any(|b| *b == v) {
358                    vars.push(v);
359                }
360            }
361            arena.intern_var_set(vars)
362        }
363        ExprNode::Product(list) => {
364            let children: Vec<ExprId> = arena.list(list).to_vec();
365            let mut vars = Vec::new();
366            for c in children {
367                let fv = free_vars(arena, c, memo);
368                vars.extend_from_slice(arena.var_set(fv));
369            }
370            arena.intern_var_set(vars)
371        }
372        ExprNode::SumOut { variables, expr } | ExprNode::IntegralOut { variables, expr } => {
373            let body = free_vars(arena, expr, memo);
374            let bound = arena.var_set(variables);
375            let remaining: Vec<VariableId> = arena
376                .var_set(body)
377                .iter()
378                .copied()
379                .filter(|v| !bound.iter().any(|b| b == v))
380                .collect();
381            arena.intern_var_set(remaining)
382        }
383        ExprNode::Ratio { numerator, denominator } => {
384            let n = free_vars(arena, numerator, memo);
385            let d = free_vars(arena, denominator, memo);
386            let mut vars = arena.var_set(n).to_vec();
387            vars.extend_from_slice(arena.var_set(d));
388            arena.intern_var_set(vars)
389        }
390        ExprNode::Expectation { function, distribution } => {
391            let dist = free_vars(arena, distribution, memo);
392            let mut vars = arena.var_set(dist).to_vec();
393            vars.push(function.variable());
394            arena.intern_var_set(vars)
395        }
396        ExprNode::Contrast { left, right, .. } => {
397            let l = free_vars(arena, left, memo);
398            let r = free_vars(arena, right, memo);
399            let mut vars = arena.var_set(l).to_vec();
400            vars.extend_from_slice(arena.var_set(r));
401            arena.intern_var_set(vars)
402        }
403    };
404    memo.insert(id, result);
405    result
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::{ContrastOp, DomainRef, OutcomeExprId};
412    use antecedent_core::Value;
413
414    #[test]
415    fn empty_sum_out_eliminates() {
416        let mut a = CausalExprArena::new();
417        let empty = a.empty_var_set();
418        let empty_i = a.empty_intervention_set();
419        let dist = a.intern(ExprNode::Distribution {
420            variables: empty,
421            conditioned_on: empty,
422            intervention: empty_i,
423            domain: DomainRef::Observational,
424        });
425        let summed = a.intern(ExprNode::SumOut { variables: empty, expr: dist });
426        assert_eq!(simplify(&mut a, summed).unwrap(), dist);
427    }
428
429    #[test]
430    fn merge_nested_sum_out() {
431        let mut a = CausalExprArena::new();
432        let empty = a.empty_var_set();
433        let empty_i = a.empty_intervention_set();
434        let v1 = a.intern_var_set([VariableId::from_raw(1)]);
435        let v2 = a.intern_var_set([VariableId::from_raw(2)]);
436        let vars12 = a.intern_var_set([VariableId::from_raw(1), VariableId::from_raw(2)]);
437        let dist = a.intern(ExprNode::Distribution {
438            variables: vars12,
439            conditioned_on: empty,
440            intervention: empty_i,
441            domain: DomainRef::Observational,
442        });
443        let inner = a.intern(ExprNode::SumOut { variables: v2, expr: dist });
444        let outer = a.intern(ExprNode::SumOut { variables: v1, expr: inner });
445        let s = simplify(&mut a, outer).unwrap();
446        match a.node(s) {
447            ExprNode::SumOut { variables, expr } => {
448                assert_eq!(
449                    a.var_set(*variables),
450                    &[VariableId::from_raw(1), VariableId::from_raw(2)]
451                );
452                assert_eq!(*expr, dist);
453            }
454            other => panic!("expected merged SumOut, got {other:?}"),
455        }
456    }
457
458    #[test]
459    fn dead_sum_out_rejected() {
460        // SumOut{z} over a body whose free variables are disjoint from {z} is an
461        // ill-formed estimand (see `SimplifyError` docs): `eval_sum_out` evaluates it
462        // as a literal `Σ_{z ∈ support(z)} dist`, so silently eliminating the SumOut
463        // (the old, buggy behavior) would drop the `|support(z)|` multiplier and
464        // divide the true value by it. `simplify` must reject it instead.
465        let mut a = CausalExprArena::new();
466        let empty = a.empty_var_set();
467        let empty_i = a.empty_intervention_set();
468        let y = a.intern_var_set([VariableId::from_raw(0)]);
469        let z = a.intern_var_set([VariableId::from_raw(1)]);
470        let dist = a.intern(ExprNode::Distribution {
471            variables: y,
472            conditioned_on: empty,
473            intervention: empty_i,
474            domain: DomainRef::Observational,
475        });
476        let summed = a.intern(ExprNode::SumOut { variables: z, expr: dist });
477        let err = simplify(&mut a, summed).unwrap_err();
478        assert_eq!(err, SimplifyError::DeadSumOut { variables: vec![VariableId::from_raw(1)] });
479    }
480
481    #[test]
482    fn dead_integral_out_rejected() {
483        // IntegralOut analogue of `dead_sum_out_rejected`: collapsing IntegralOut{z}
484        // to its z-independent body would drop the integration measure over z
485        // entirely, which is worse than the SumOut case's scaling error. Must reject.
486        let mut a = CausalExprArena::new();
487        let empty = a.empty_var_set();
488        let empty_i = a.empty_intervention_set();
489        let y = a.intern_var_set([VariableId::from_raw(0)]);
490        let z = a.intern_var_set([VariableId::from_raw(1)]);
491        let dist = a.intern(ExprNode::Distribution {
492            variables: y,
493            conditioned_on: empty,
494            intervention: empty_i,
495            domain: DomainRef::Observational,
496        });
497        let integrated = a.intern(ExprNode::IntegralOut { variables: z, expr: dist });
498        let err = simplify(&mut a, integrated).unwrap_err();
499        assert_eq!(
500            err,
501            SimplifyError::DeadIntegralOut { variables: vec![VariableId::from_raw(1)] }
502        );
503    }
504
505    #[test]
506    fn singleton_and_flatten_product() {
507        let mut a = CausalExprArena::new();
508        let empty = a.empty_var_set();
509        let empty_i = a.empty_intervention_set();
510        let v0 = a.intern_var_set([VariableId::from_raw(0)]);
511        let v1 = a.intern_var_set([VariableId::from_raw(1)]);
512        let d1 = a.intern(ExprNode::Distribution {
513            variables: v0,
514            conditioned_on: empty,
515            intervention: empty_i,
516            domain: DomainRef::Observational,
517        });
518        let d2 = a.intern(ExprNode::Distribution {
519            variables: v1,
520            conditioned_on: empty,
521            intervention: empty_i,
522            domain: DomainRef::Observational,
523        });
524        let inner = {
525            let list = a.intern_list([d1]);
526            a.intern(ExprNode::Product(list))
527        };
528        assert_eq!(simplify(&mut a, inner).unwrap(), d1);
529
530        let nest = {
531            let list_inner = a.intern_list([d1, d2]);
532            let p_inner = a.intern(ExprNode::Product(list_inner));
533            let list_outer = a.intern_list([p_inner, d1]);
534            a.intern(ExprNode::Product(list_outer))
535        };
536        let s = simplify(&mut a, nest).unwrap();
537        match a.node(s) {
538            ExprNode::Product(list) => {
539                let kids = a.list(*list);
540                assert_eq!(kids.len(), 3);
541                let mut sorted = kids.to_vec();
542                sorted.sort_unstable();
543                assert_eq!(kids, sorted.as_slice());
544            }
545            other => panic!("expected product, got {other:?}"),
546        }
547    }
548
549    #[test]
550    fn product_order_independent() {
551        let mut a = CausalExprArena::new();
552        let empty = a.empty_var_set();
553        let empty_i = a.empty_intervention_set();
554        let v0 = a.intern_var_set([VariableId::from_raw(0)]);
555        let v1 = a.intern_var_set([VariableId::from_raw(1)]);
556        let d1 = a.intern(ExprNode::Distribution {
557            variables: v0,
558            conditioned_on: empty,
559            intervention: empty_i,
560            domain: DomainRef::Observational,
561        });
562        let d2 = a.intern(ExprNode::Distribution {
563            variables: v1,
564            conditioned_on: empty,
565            intervention: empty_i,
566            domain: DomainRef::Observational,
567        });
568        let p1 = {
569            let list = a.intern_list([d1, d2]);
570            a.intern(ExprNode::Product(list))
571        };
572        let p2 = {
573            let list = a.intern_list([d2, d1]);
574            a.intern(ExprNode::Product(list))
575        };
576        assert_eq!(simplify(&mut a, p1).unwrap(), simplify(&mut a, p2).unwrap());
577    }
578
579    #[test]
580    fn simplify_idempotent() {
581        let mut a = CausalExprArena::new();
582        let id = a.backdoor_ate(
583            VariableId::from_raw(0),
584            VariableId::from_raw(1),
585            &[VariableId::from_raw(2)],
586            Value::f64(1.0),
587            Value::f64(0.0),
588        );
589        let s1 = simplify(&mut a, id).unwrap();
590        let s2 = simplify(&mut a, s1).unwrap();
591        assert_eq!(s1, s2);
592    }
593
594    #[test]
595    fn ratio_assoc_left() {
596        let mut a = CausalExprArena::new();
597        let empty = a.empty_var_set();
598        let empty_i = a.empty_intervention_set();
599        let v0 = a.intern_var_set([VariableId::from_raw(0)]);
600        let v1 = a.intern_var_set([VariableId::from_raw(1)]);
601        let v2 = a.intern_var_set([VariableId::from_raw(2)]);
602        let da = a.intern(ExprNode::Distribution {
603            variables: v0,
604            conditioned_on: empty,
605            intervention: empty_i,
606            domain: DomainRef::Observational,
607        });
608        let db = a.intern(ExprNode::Distribution {
609            variables: v1,
610            conditioned_on: empty,
611            intervention: empty_i,
612            domain: DomainRef::Observational,
613        });
614        let dc = a.intern(ExprNode::Distribution {
615            variables: v2,
616            conditioned_on: empty,
617            intervention: empty_i,
618            domain: DomainRef::Observational,
619        });
620        let ab = a.intern(ExprNode::Ratio { numerator: da, denominator: db });
621        let nested = a.intern(ExprNode::Ratio { numerator: ab, denominator: dc });
622        let s = simplify(&mut a, nested).unwrap();
623        match a.node(s) {
624            ExprNode::Ratio { numerator, denominator } => {
625                assert_eq!(*numerator, da);
626                match a.node(*denominator) {
627                    ExprNode::Product(list) => {
628                        let kids = a.list(*list);
629                        assert_eq!(kids.len(), 2);
630                        assert!(kids.contains(&db) && kids.contains(&dc));
631                    }
632                    other => panic!("expected product denom, got {other:?}"),
633                }
634            }
635            other => panic!("expected ratio, got {other:?}"),
636        }
637    }
638
639    #[test]
640    fn contrast_rebuilds_children() {
641        let mut a = CausalExprArena::new();
642        let empty = a.empty_var_set();
643        let empty_i = a.empty_intervention_set();
644        let dist = a.intern(ExprNode::Distribution {
645            variables: empty,
646            conditioned_on: empty,
647            intervention: empty_i,
648            domain: DomainRef::Observational,
649        });
650        let summed = a.intern(ExprNode::SumOut { variables: empty, expr: dist });
651        let exp = a.intern(ExprNode::Expectation {
652            function: OutcomeExprId::identity(VariableId::from_raw(0)),
653            distribution: summed,
654        });
655        let contrast =
656            a.intern(ExprNode::Contrast { left: exp, right: exp, op: ContrastOp::Difference });
657        let s = simplify(&mut a, contrast).unwrap();
658        match a.node(s) {
659            ExprNode::Contrast { left, right, .. } => {
660                match a.node(*left) {
661                    ExprNode::Expectation { distribution, .. } => assert_eq!(*distribution, dist),
662                    other => panic!("expected expectation, got {other:?}"),
663                }
664                assert_eq!(left, right);
665            }
666            other => panic!("expected contrast, got {other:?}"),
667        }
668    }
669}