Skip to main content

ocas_atom/
normalize.rs

1//! Normalization for [`Atom`] expression trees.
2//!
3//! The normalizer puts expressions into a deterministic canonical form:
4//! nested additions and multiplications are flattened, arguments are sorted,
5//! and numeric coefficients are merged.
6
7use crate::{Atom, AtomArena, AtomNode};
8
9/// Normalize an atom into canonical form.
10///
11/// The result is allocated in the same arena as the input via `ctx`.
12///
13/// # Example
14///
15/// ```
16/// use ocas_atom::normalize::normalize;
17/// use ocas_atom::AtomArena;
18/// use ocas_core::arena::Arena;
19///
20/// let arena = Arena::new();
21/// let ctx = AtomArena::new(&arena);
22/// let x = ctx.var("x");
23/// let y = ctx.var("y");
24/// let z = ctx.var("z");
25/// let inner = ctx.add(&[x, y]);
26/// let outer = ctx.add(&[inner, z, ctx.num(2), ctx.num(3)]);
27/// let result = normalize(&ctx, outer);
28/// assert_eq!(result.to_string(), "5 + x + y + z");
29/// ```
30pub fn normalize<'a>(ctx: &AtomArena<'a>, atom: Atom<'a>) -> Atom<'a> {
31    match atom.node() {
32        AtomNode::Num(_) | AtomNode::Var(_) => atom,
33        AtomNode::Fun(name, args) => {
34            let mut normalized: Vec<Atom<'a>> = args.iter().map(|a| normalize(ctx, *a)).collect();
35            // Preserve argument order for calculus forms where order is semantic.
36            if !matches!(name.as_str(), "Derivative" | "Integral") {
37                normalized.sort();
38            }
39            ctx.fun(name.as_str(), &normalized)
40        }
41        AtomNode::Add(args) => {
42            // Normalize children FIRST, then flatten — this ensures any child
43            // that normalizes into an Add node gets flattened, guaranteeing
44            // idempotency (normalize(normalize(x)) == normalize(x)).
45            let normalized_children: Vec<Atom<'a>> =
46                args.iter().map(|a| normalize(ctx, *a)).collect();
47            let mut flat = Vec::new();
48            collect_add(&normalized_children, &mut flat);
49            let mut normalized = flat;
50            // Drop explicit zero terms first (covers the common `x + 0` case),
51            // then sort and merge numeric literals. Merging can itself produce
52            // a new zero (e.g. `93 + -93`), so drop zeros AGAIN after merging.
53            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(0)));
54            normalized.sort();
55            merge_numbers(ctx, &mut normalized, true);
56            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(0)));
57            if normalized.is_empty() {
58                ctx.num(0)
59            } else if normalized.len() == 1 {
60                normalized[0]
61            } else {
62                ctx.add(&normalized)
63            }
64        }
65        AtomNode::Mul(args) => {
66            // Normalize children FIRST, then flatten — same reasoning as Add.
67            let normalized_children: Vec<Atom<'a>> =
68                args.iter().map(|a| normalize(ctx, *a)).collect();
69            let mut flat = Vec::new();
70            collect_mul(&normalized_children, &mut flat);
71            let mut normalized = flat;
72            if normalized
73                .iter()
74                .any(|a| matches!(a.node(), AtomNode::Num(0)))
75            {
76                return ctx.num(0);
77            }
78            // Drop explicit unit terms first, then sort and merge numeric
79            // literals. Merging can produce a new unit (e.g. `-1 * -1 = 1`),
80            // so drop units AGAIN after merging — mirrors the Add branch.
81            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(1)));
82            normalized.sort();
83            merge_numbers(ctx, &mut normalized, false);
84            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(1)));
85            if normalized.is_empty() {
86                ctx.num(1)
87            } else if normalized.len() == 1 {
88                normalized[0]
89            } else {
90                ctx.mul(&normalized)
91            }
92        }
93        AtomNode::Pow(base, exp) => {
94            let base = normalize(ctx, *base);
95            let exp = normalize(ctx, *exp);
96            ctx.pow(base, exp)
97        }
98    }
99}
100
101fn collect_add<'a>(args: &[Atom<'a>], out: &mut Vec<Atom<'a>>) {
102    for &arg in args {
103        match arg.node() {
104            AtomNode::Add(inner) => collect_add(inner, out),
105            _ => out.push(arg),
106        }
107    }
108}
109
110fn collect_mul<'a>(args: &[Atom<'a>], out: &mut Vec<Atom<'a>>) {
111    for &arg in args {
112        match arg.node() {
113            AtomNode::Mul(inner) => collect_mul(inner, out),
114            _ => out.push(arg),
115        }
116    }
117}
118
119fn merge_numbers<'a>(ctx: &AtomArena<'a>, args: &mut Vec<Atom<'a>>, is_add: bool) {
120    let count = args
121        .iter()
122        .take_while(|a| matches!(a.node(), AtomNode::Num(_)))
123        .count();
124
125    if count >= 2 {
126        let nums: Vec<i64> = args[0..count]
127            .iter()
128            .map(|a| match a.node() {
129                AtomNode::Num(n) => *n,
130                _ => unreachable!(),
131            })
132            .collect();
133        // Use wrapping arithmetic to avoid panics on overflow in debug mode.
134        // This matches Rust's release-mode behavior for i64 arithmetic.
135        let merged = if is_add {
136            nums.into_iter().fold(0i64, |acc, n| acc.wrapping_add(n))
137        } else {
138            nums.into_iter().fold(1i64, |acc, n| acc.wrapping_mul(n))
139        };
140        args.drain(0..count);
141        args.insert(0, ctx.num(merged));
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use ocas_core::arena::Arena;
149
150    #[test]
151    fn normalize_leaves_atom_unchanged() {
152        let arena = Arena::new();
153        let ctx = AtomArena::new(&arena);
154        let x = ctx.var("x");
155        assert_eq!(normalize(&ctx, x).to_string(), "x");
156    }
157
158    #[test]
159    fn normalize_flattens_nested_add() {
160        let arena = Arena::new();
161        let ctx = AtomArena::new(&arena);
162        let x = ctx.var("x");
163        let y = ctx.var("y");
164        let z = ctx.var("z");
165        let inner = ctx.add(&[x, y]);
166        let outer = ctx.add(&[inner, z]);
167        assert_eq!(normalize(&ctx, outer).to_string(), "x + y + z");
168    }
169
170    #[test]
171    fn normalize_drops_zero_from_opposite_numerics() {
172        // `93 + (-93) + sin(x)` must collapse to `sin(x)`: the two numerics
173        // merge to 0, which must then be dropped (regression for the
174        // retain-before-merge ordering bug found by proptest).
175        let arena = Arena::new();
176        let ctx = AtomArena::new(&arena);
177        let x = ctx.var("x");
178        let sinx = ctx.fun("sin", &[x]);
179        let a1 = ctx.add(&[ctx.num(93)]);
180        let a2 = ctx.add(&[ctx.num(-93)]);
181        let atom = ctx.add(&[a1, a2, sinx]);
182        assert_eq!(normalize(&ctx, atom).to_string(), "sin(x)");
183    }
184
185    #[test]
186    fn normalize_drops_unit_from_opposite_numerics() {
187        // `(-1) * ((-1) * x)` must collapse to `x`: the two units merge to 1,
188        // which must then be dropped.
189        let arena = Arena::new();
190        let ctx = AtomArena::new(&arena);
191        let x = ctx.var("x");
192        let neg1 = ctx.num(-1);
193        let inner = ctx.mul(&[x, neg1]);
194        let atom = ctx.mul(&[neg1, inner]);
195        assert_eq!(normalize(&ctx, atom).to_string(), "x");
196    }
197
198    #[test]
199    fn normalize_sorts_arguments() {
200        let arena = Arena::new();
201        let ctx = AtomArena::new(&arena);
202        let x = ctx.var("x");
203        let y = ctx.var("y");
204        let z = ctx.var("z");
205        let expr = ctx.add(&[z, x, y]);
206        assert_eq!(normalize(&ctx, expr).to_string(), "x + y + z");
207    }
208
209    #[test]
210    fn normalize_merges_numeric_literals() {
211        let arena = Arena::new();
212        let ctx = AtomArena::new(&arena);
213        let one = ctx.num(1);
214        let two = ctx.num(2);
215        let x = ctx.var("x");
216        let expr = ctx.add(&[one, x, two]);
217        assert_eq!(normalize(&ctx, expr).to_string(), "3 + x");
218    }
219
220    #[test]
221    fn normalize_pow() {
222        let arena = Arena::new();
223        let ctx = AtomArena::new(&arena);
224        let x = ctx.var("x");
225        let two = ctx.num(2);
226        let pow = ctx.pow(x, two);
227        assert_eq!(normalize(&ctx, pow).to_string(), "x^2");
228    }
229
230    #[test]
231    fn normalize_sorts_fun_arguments() {
232        let arena = Arena::new();
233        let ctx = AtomArena::new(&arena);
234        let x = ctx.var("x");
235        let y = ctx.var("y");
236        let f = ctx.fun("f", &[y, x]);
237        assert_eq!(normalize(&ctx, f).to_string(), "f(x, y)");
238    }
239}