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::sync::Arc;
7
8use antecedent_core::VariableId;
9
10use crate::{CausalExprArena, DerivationMeta, ExprId, ExprNode, VarSetId};
11
12/// Simplify `root` bottom-up with memoization; returns a (possibly new) `ExprId`.
13pub(crate) fn simplify(arena: &mut CausalExprArena, root: ExprId) -> ExprId {
14    let mut memo: HashMap<ExprId, ExprId> = HashMap::new();
15    let mut free_memo: HashMap<ExprId, VarSetId> = HashMap::new();
16    simplify_rec(arena, root, &mut memo, &mut free_memo)
17}
18
19fn simplify_rec(
20    arena: &mut CausalExprArena,
21    id: ExprId,
22    memo: &mut HashMap<ExprId, ExprId>,
23    free_memo: &mut HashMap<ExprId, VarSetId>,
24) -> ExprId {
25    if let Some(&cached) = memo.get(&id) {
26        return cached;
27    }
28    let rebuilt = rebuild_children(arena, id, memo, free_memo);
29    let simplified = apply_rules_fixpoint(arena, rebuilt, free_memo);
30    memo.insert(id, simplified);
31    simplified
32}
33
34fn rebuild_children(
35    arena: &mut CausalExprArena,
36    id: ExprId,
37    memo: &mut HashMap<ExprId, ExprId>,
38    free_memo: &mut HashMap<ExprId, VarSetId>,
39) -> ExprId {
40    let node = arena.node(id).clone();
41    match node {
42        ExprNode::Distribution { .. } => id,
43        ExprNode::Product(list) => {
44            let children_ids: Vec<ExprId> = arena.list(list).to_vec();
45            let children: Vec<ExprId> =
46                children_ids.into_iter().map(|c| simplify_rec(arena, c, memo, free_memo)).collect();
47            let list_id = arena.intern_list(children);
48            arena.intern(ExprNode::Product(list_id))
49        }
50        ExprNode::SumOut { variables, expr } => {
51            let body = simplify_rec(arena, expr, memo, free_memo);
52            arena.intern(ExprNode::SumOut { variables, expr: body })
53        }
54        ExprNode::IntegralOut { variables, expr } => {
55            let body = simplify_rec(arena, expr, memo, free_memo);
56            arena.intern(ExprNode::IntegralOut { variables, expr: body })
57        }
58        ExprNode::Ratio { numerator, denominator } => {
59            let num = simplify_rec(arena, numerator, memo, free_memo);
60            let den = simplify_rec(arena, denominator, memo, free_memo);
61            arena.intern(ExprNode::Ratio { numerator: num, denominator: den })
62        }
63        ExprNode::Expectation { function, distribution } => {
64            let dist = simplify_rec(arena, distribution, memo, free_memo);
65            arena.intern(ExprNode::Expectation { function, distribution: dist })
66        }
67        ExprNode::Contrast { left, right, op } => {
68            let l = simplify_rec(arena, left, memo, free_memo);
69            let r = simplify_rec(arena, right, memo, free_memo);
70            arena.intern(ExprNode::Contrast { left: l, right: r, op })
71        }
72    }
73}
74
75fn apply_rules_fixpoint(
76    arena: &mut CausalExprArena,
77    mut id: ExprId,
78    free_memo: &mut HashMap<ExprId, VarSetId>,
79) -> ExprId {
80    // Local rules only; children are already simplified.
81    loop {
82        let next = apply_local_rules(arena, id, free_memo);
83        if next == id {
84            return id;
85        }
86        id = next;
87    }
88}
89
90fn apply_local_rules(
91    arena: &mut CausalExprArena,
92    id: ExprId,
93    free_memo: &mut HashMap<ExprId, VarSetId>,
94) -> ExprId {
95    match arena.node(id).clone() {
96        ExprNode::SumOut { variables, expr } => {
97            rewrite_sum_out(arena, id, variables, expr, free_memo)
98        }
99        ExprNode::IntegralOut { variables, expr } => {
100            rewrite_integral_out(arena, id, variables, expr, free_memo)
101        }
102        ExprNode::Product(list) => rewrite_product(arena, id, list),
103        ExprNode::Ratio { numerator, denominator } => {
104            rewrite_ratio(arena, id, numerator, denominator)
105        }
106        _ => id,
107    }
108}
109
110fn rewrite_sum_out(
111    arena: &mut CausalExprArena,
112    id: ExprId,
113    variables: VarSetId,
114    expr: ExprId,
115    free_memo: &mut HashMap<ExprId, VarSetId>,
116) -> ExprId {
117    if arena.var_set(variables).is_empty() {
118        return tag_if_new(arena, expr, id, "simplify.empty_sum_out");
119    }
120    if let ExprNode::SumOut { variables: inner_v, expr: inner_e } = arena.node(expr).clone() {
121        let merged: Vec<VariableId> = arena
122            .var_set(variables)
123            .iter()
124            .copied()
125            .chain(arena.var_set(inner_v).iter().copied())
126            .collect();
127        let union = arena.intern_var_set(merged);
128        let node = ExprNode::SumOut { variables: union, expr: inner_e };
129        return intern_derived(arena, node, "simplify.merge_sum_out");
130    }
131    let free = free_vars(arena, expr, free_memo);
132    if !intersects(arena, variables, free) {
133        return tag_if_new(arena, expr, id, "simplify.dead_sum_out");
134    }
135    id
136}
137
138fn rewrite_integral_out(
139    arena: &mut CausalExprArena,
140    id: ExprId,
141    variables: VarSetId,
142    expr: ExprId,
143    free_memo: &mut HashMap<ExprId, VarSetId>,
144) -> ExprId {
145    if arena.var_set(variables).is_empty() {
146        return tag_if_new(arena, expr, id, "simplify.empty_integral_out");
147    }
148    if let ExprNode::IntegralOut { variables: inner_v, expr: inner_e } = arena.node(expr).clone() {
149        let merged: Vec<VariableId> = arena
150            .var_set(variables)
151            .iter()
152            .copied()
153            .chain(arena.var_set(inner_v).iter().copied())
154            .collect();
155        let union = arena.intern_var_set(merged);
156        let node = ExprNode::IntegralOut { variables: union, expr: inner_e };
157        return intern_derived(arena, node, "simplify.merge_integral_out");
158    }
159    let free = free_vars(arena, expr, free_memo);
160    if !intersects(arena, variables, free) {
161        return tag_if_new(arena, expr, id, "simplify.dead_integral_out");
162    }
163    id
164}
165
166fn rewrite_product(arena: &mut CausalExprArena, id: ExprId, list: crate::ExprListId) -> ExprId {
167    let children = arena.list(list).to_vec();
168    if children.len() == 1 {
169        return tag_if_new(arena, children[0], id, "simplify.singleton_product");
170    }
171    let mut flat: Vec<ExprId> = Vec::with_capacity(children.len());
172    let mut flattened = false;
173    for c in &children {
174        if let ExprNode::Product(inner) = arena.node(*c) {
175            flat.extend_from_slice(arena.list(*inner));
176            flattened = true;
177        } else {
178            flat.push(*c);
179        }
180    }
181    flat.sort_unstable();
182    let sorted_changed = flat.as_slice() != children.as_slice();
183    if flattened || sorted_changed {
184        if flat.len() == 1 {
185            return tag_if_new(arena, flat[0], id, "simplify.singleton_product");
186        }
187        let list_id = arena.intern_list(flat);
188        let rule =
189            if flattened { "simplify.flatten_product" } else { "simplify.canonical_product" };
190        return intern_derived(arena, ExprNode::Product(list_id), rule);
191    }
192    id
193}
194
195fn rewrite_ratio(
196    arena: &mut CausalExprArena,
197    id: ExprId,
198    numerator: ExprId,
199    denominator: ExprId,
200) -> ExprId {
201    // (a/b)/c โ†’ a/(b*c)
202    if let ExprNode::Ratio { numerator: a, denominator: b } = arena.node(numerator).clone() {
203        let bc = {
204            let mut kids = vec![b, denominator];
205            kids.sort_unstable();
206            let list = arena.intern_list(kids);
207            arena.intern(ExprNode::Product(list))
208        };
209        return intern_derived(
210            arena,
211            ExprNode::Ratio { numerator: a, denominator: bc },
212            "simplify.ratio_assoc_left",
213        );
214    }
215    // a/(b/c) โ†’ (a*c)/b
216    if let ExprNode::Ratio { numerator: b, denominator: c } = arena.node(denominator).clone() {
217        let ac = {
218            let mut kids = vec![numerator, c];
219            kids.sort_unstable();
220            let list = arena.intern_list(kids);
221            arena.intern(ExprNode::Product(list))
222        };
223        return intern_derived(
224            arena,
225            ExprNode::Ratio { numerator: ac, denominator: b },
226            "simplify.ratio_assoc_right",
227        );
228    }
229    id
230}
231
232fn tag_if_new(arena: &mut CausalExprArena, result: ExprId, _from: ExprId, _rule: &str) -> ExprId {
233    // Identity rewrite to an existing child โ€” no new node; leave child's derivation alone.
234    let _ = arena;
235    result
236}
237
238fn intern_derived(arena: &mut CausalExprArena, node: ExprNode, rule: &str) -> ExprId {
239    let before = arena.len();
240    let id = arena.intern(node);
241    if arena.len() > before {
242        arena.set_derivation_if_absent(id, DerivationMeta { rule: Arc::from(rule), note: None });
243    }
244    id
245}
246
247fn intersects(arena: &CausalExprArena, a: VarSetId, b: VarSetId) -> bool {
248    let av = arena.var_set(a);
249    let bv = arena.var_set(b);
250    let mut i = 0;
251    let mut j = 0;
252    while i < av.len() && j < bv.len() {
253        match av[i].raw().cmp(&bv[j].raw()) {
254            std::cmp::Ordering::Equal => return true,
255            std::cmp::Ordering::Less => i += 1,
256            std::cmp::Ordering::Greater => j += 1,
257        }
258    }
259    false
260}
261
262fn free_vars(
263    arena: &mut CausalExprArena,
264    id: ExprId,
265    memo: &mut HashMap<ExprId, VarSetId>,
266) -> VarSetId {
267    if let Some(&cached) = memo.get(&id) {
268        return cached;
269    }
270    let result = match arena.node(id).clone() {
271        ExprNode::Distribution { variables, conditioned_on, intervention, .. } => {
272            let mut vars: Vec<VariableId> = arena.var_set(variables).to_vec();
273            vars.extend_from_slice(arena.var_set(conditioned_on));
274            // Intervention targets are bound by do(ยท), not free.
275            let _ = intervention;
276            arena.intern_var_set(vars)
277        }
278        ExprNode::Product(list) => {
279            let children: Vec<ExprId> = arena.list(list).to_vec();
280            let mut vars = Vec::new();
281            for c in children {
282                let fv = free_vars(arena, c, memo);
283                vars.extend_from_slice(arena.var_set(fv));
284            }
285            arena.intern_var_set(vars)
286        }
287        ExprNode::SumOut { variables, expr } | ExprNode::IntegralOut { variables, expr } => {
288            let body = free_vars(arena, expr, memo);
289            let bound = arena.var_set(variables);
290            let remaining: Vec<VariableId> = arena
291                .var_set(body)
292                .iter()
293                .copied()
294                .filter(|v| !bound.iter().any(|b| b == v))
295                .collect();
296            arena.intern_var_set(remaining)
297        }
298        ExprNode::Ratio { numerator, denominator } => {
299            let n = free_vars(arena, numerator, memo);
300            let d = free_vars(arena, denominator, memo);
301            let mut vars = arena.var_set(n).to_vec();
302            vars.extend_from_slice(arena.var_set(d));
303            arena.intern_var_set(vars)
304        }
305        ExprNode::Expectation { function, distribution } => {
306            let dist = free_vars(arena, distribution, memo);
307            let mut vars = arena.var_set(dist).to_vec();
308            vars.push(function.variable());
309            arena.intern_var_set(vars)
310        }
311        ExprNode::Contrast { left, right, .. } => {
312            let l = free_vars(arena, left, memo);
313            let r = free_vars(arena, right, memo);
314            let mut vars = arena.var_set(l).to_vec();
315            vars.extend_from_slice(arena.var_set(r));
316            arena.intern_var_set(vars)
317        }
318    };
319    memo.insert(id, result);
320    result
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use crate::{ContrastOp, DomainRef, OutcomeExprId};
327    use antecedent_core::Value;
328
329    #[test]
330    fn empty_sum_out_eliminates() {
331        let mut a = CausalExprArena::new();
332        let empty = a.empty_var_set();
333        let empty_i = a.empty_intervention_set();
334        let dist = a.intern(ExprNode::Distribution {
335            variables: empty,
336            conditioned_on: empty,
337            intervention: empty_i,
338            domain: DomainRef::Observational,
339        });
340        let summed = a.intern(ExprNode::SumOut { variables: empty, expr: dist });
341        assert_eq!(simplify(&mut a, summed), dist);
342    }
343
344    #[test]
345    fn merge_nested_sum_out() {
346        let mut a = CausalExprArena::new();
347        let empty = a.empty_var_set();
348        let empty_i = a.empty_intervention_set();
349        let v1 = a.intern_var_set([VariableId::from_raw(1)]);
350        let v2 = a.intern_var_set([VariableId::from_raw(2)]);
351        let vars12 = a.intern_var_set([VariableId::from_raw(1), VariableId::from_raw(2)]);
352        let dist = a.intern(ExprNode::Distribution {
353            variables: vars12,
354            conditioned_on: empty,
355            intervention: empty_i,
356            domain: DomainRef::Observational,
357        });
358        let inner = a.intern(ExprNode::SumOut { variables: v2, expr: dist });
359        let outer = a.intern(ExprNode::SumOut { variables: v1, expr: inner });
360        let s = simplify(&mut a, outer);
361        match a.node(s) {
362            ExprNode::SumOut { variables, expr } => {
363                assert_eq!(
364                    a.var_set(*variables),
365                    &[VariableId::from_raw(1), VariableId::from_raw(2)]
366                );
367                assert_eq!(*expr, dist);
368            }
369            other => panic!("expected merged SumOut, got {other:?}"),
370        }
371    }
372
373    #[test]
374    fn dead_sum_out_eliminates() {
375        let mut a = CausalExprArena::new();
376        let empty = a.empty_var_set();
377        let empty_i = a.empty_intervention_set();
378        let y = a.intern_var_set([VariableId::from_raw(0)]);
379        let z = a.intern_var_set([VariableId::from_raw(1)]);
380        let dist = a.intern(ExprNode::Distribution {
381            variables: y,
382            conditioned_on: empty,
383            intervention: empty_i,
384            domain: DomainRef::Observational,
385        });
386        let summed = a.intern(ExprNode::SumOut { variables: z, expr: dist });
387        assert_eq!(simplify(&mut a, summed), dist);
388    }
389
390    #[test]
391    fn singleton_and_flatten_product() {
392        let mut a = CausalExprArena::new();
393        let empty = a.empty_var_set();
394        let empty_i = a.empty_intervention_set();
395        let v0 = a.intern_var_set([VariableId::from_raw(0)]);
396        let v1 = a.intern_var_set([VariableId::from_raw(1)]);
397        let d1 = a.intern(ExprNode::Distribution {
398            variables: v0,
399            conditioned_on: empty,
400            intervention: empty_i,
401            domain: DomainRef::Observational,
402        });
403        let d2 = a.intern(ExprNode::Distribution {
404            variables: v1,
405            conditioned_on: empty,
406            intervention: empty_i,
407            domain: DomainRef::Observational,
408        });
409        let inner = {
410            let list = a.intern_list([d1]);
411            a.intern(ExprNode::Product(list))
412        };
413        assert_eq!(simplify(&mut a, inner), d1);
414
415        let nest = {
416            let list_inner = a.intern_list([d1, d2]);
417            let p_inner = a.intern(ExprNode::Product(list_inner));
418            let list_outer = a.intern_list([p_inner, d1]);
419            a.intern(ExprNode::Product(list_outer))
420        };
421        let s = simplify(&mut a, nest);
422        match a.node(s) {
423            ExprNode::Product(list) => {
424                let kids = a.list(*list);
425                assert_eq!(kids.len(), 3);
426                let mut sorted = kids.to_vec();
427                sorted.sort_unstable();
428                assert_eq!(kids, sorted.as_slice());
429            }
430            other => panic!("expected product, got {other:?}"),
431        }
432    }
433
434    #[test]
435    fn product_order_independent() {
436        let mut a = CausalExprArena::new();
437        let empty = a.empty_var_set();
438        let empty_i = a.empty_intervention_set();
439        let v0 = a.intern_var_set([VariableId::from_raw(0)]);
440        let v1 = a.intern_var_set([VariableId::from_raw(1)]);
441        let d1 = a.intern(ExprNode::Distribution {
442            variables: v0,
443            conditioned_on: empty,
444            intervention: empty_i,
445            domain: DomainRef::Observational,
446        });
447        let d2 = a.intern(ExprNode::Distribution {
448            variables: v1,
449            conditioned_on: empty,
450            intervention: empty_i,
451            domain: DomainRef::Observational,
452        });
453        let p1 = {
454            let list = a.intern_list([d1, d2]);
455            a.intern(ExprNode::Product(list))
456        };
457        let p2 = {
458            let list = a.intern_list([d2, d1]);
459            a.intern(ExprNode::Product(list))
460        };
461        assert_eq!(simplify(&mut a, p1), simplify(&mut a, p2));
462    }
463
464    #[test]
465    fn simplify_idempotent() {
466        let mut a = CausalExprArena::new();
467        let id = a.backdoor_ate(
468            VariableId::from_raw(0),
469            VariableId::from_raw(1),
470            &[VariableId::from_raw(2)],
471            Value::f64(1.0),
472            Value::f64(0.0),
473        );
474        let s1 = simplify(&mut a, id);
475        let s2 = simplify(&mut a, s1);
476        assert_eq!(s1, s2);
477    }
478
479    #[test]
480    fn ratio_assoc_left() {
481        let mut a = CausalExprArena::new();
482        let empty = a.empty_var_set();
483        let empty_i = a.empty_intervention_set();
484        let v0 = a.intern_var_set([VariableId::from_raw(0)]);
485        let v1 = a.intern_var_set([VariableId::from_raw(1)]);
486        let v2 = a.intern_var_set([VariableId::from_raw(2)]);
487        let da = a.intern(ExprNode::Distribution {
488            variables: v0,
489            conditioned_on: empty,
490            intervention: empty_i,
491            domain: DomainRef::Observational,
492        });
493        let db = a.intern(ExprNode::Distribution {
494            variables: v1,
495            conditioned_on: empty,
496            intervention: empty_i,
497            domain: DomainRef::Observational,
498        });
499        let dc = a.intern(ExprNode::Distribution {
500            variables: v2,
501            conditioned_on: empty,
502            intervention: empty_i,
503            domain: DomainRef::Observational,
504        });
505        let ab = a.intern(ExprNode::Ratio { numerator: da, denominator: db });
506        let nested = a.intern(ExprNode::Ratio { numerator: ab, denominator: dc });
507        let s = simplify(&mut a, nested);
508        match a.node(s) {
509            ExprNode::Ratio { numerator, denominator } => {
510                assert_eq!(*numerator, da);
511                match a.node(*denominator) {
512                    ExprNode::Product(list) => {
513                        let kids = a.list(*list);
514                        assert_eq!(kids.len(), 2);
515                        assert!(kids.contains(&db) && kids.contains(&dc));
516                    }
517                    other => panic!("expected product denom, got {other:?}"),
518                }
519            }
520            other => panic!("expected ratio, got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn contrast_rebuilds_children() {
526        let mut a = CausalExprArena::new();
527        let empty = a.empty_var_set();
528        let empty_i = a.empty_intervention_set();
529        let dist = a.intern(ExprNode::Distribution {
530            variables: empty,
531            conditioned_on: empty,
532            intervention: empty_i,
533            domain: DomainRef::Observational,
534        });
535        let summed = a.intern(ExprNode::SumOut { variables: empty, expr: dist });
536        let exp = a.intern(ExprNode::Expectation {
537            function: OutcomeExprId::identity(VariableId::from_raw(0)),
538            distribution: summed,
539        });
540        let contrast =
541            a.intern(ExprNode::Contrast { left: exp, right: exp, op: ContrastOp::Difference });
542        let s = simplify(&mut a, contrast);
543        match a.node(s) {
544            ExprNode::Contrast { left, right, .. } => {
545                match a.node(*left) {
546                    ExprNode::Expectation { distribution, .. } => assert_eq!(*distribution, dist),
547                    other => panic!("expected expectation, got {other:?}"),
548                }
549                assert_eq!(left, right);
550            }
551            other => panic!("expected contrast, got {other:?}"),
552        }
553    }
554}