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/// Function heads whose argument order is semantic and must survive
10/// normalization. Every other head has its arguments sorted into canonical
11/// order.
12///
13/// - `Derivative` / `Integral` carry the variable of differentiation or
14///   integration, which is positional.
15/// - `EllipticF` / `EllipticE` / `EllipticPi` carry an amplitude and a
16///   parameter `m = k²` (plus a characteristic for `EllipticPi`) that are not
17///   interchangeable; sorting them would make `EllipticF(u, m)` and
18///   `EllipticF(m, u)` the same atom.
19/// - `Ei` is the exponential integral; its two-argument form is Rubi's
20///   `Ei(n, z)` (the incomplete gamma `Eₙ`), whose argument order is semantic.
21pub fn preserves_argument_order(name: &str) -> bool {
22    matches!(
23        name,
24        "Derivative" | "Integral" | "EllipticF" | "EllipticE" | "EllipticPi" | "Ei"
25    )
26}
27
28/// Normalize an atom into canonical form.
29///
30/// The result is allocated in the same arena as the input via `ctx`.
31///
32/// # Example
33///
34/// ```
35/// use ocas_atom::normalize::normalize;
36/// use ocas_atom::AtomArena;
37/// use ocas_core::arena::Arena;
38///
39/// let arena = Arena::new();
40/// let ctx = AtomArena::new(&arena);
41/// let x = ctx.var("x");
42/// let y = ctx.var("y");
43/// let z = ctx.var("z");
44/// let inner = ctx.add(&[x, y]);
45/// let outer = ctx.add(&[inner, z, ctx.num(2), ctx.num(3)]);
46/// let result = normalize(&ctx, outer);
47/// assert_eq!(result.to_string(), "5 + x + y + z");
48/// ```
49pub fn normalize<'a>(ctx: &AtomArena<'a>, atom: Atom<'a>) -> Atom<'a> {
50    match atom.node() {
51        AtomNode::Num(_) | AtomNode::Var(_) => atom,
52        AtomNode::Fun(name, args) => {
53            let mut normalized: Vec<Atom<'a>> = args.iter().map(|a| normalize(ctx, *a)).collect();
54            // Preserve argument order for forms where order is semantic.
55            if !preserves_argument_order(name.as_str()) {
56                normalized.sort();
57            }
58            ctx.fun(name.as_str(), &normalized)
59        }
60        AtomNode::Add(args) => {
61            // Normalize children FIRST, then flatten — this ensures any child
62            // that normalizes into an Add node gets flattened, guaranteeing
63            // idempotency (normalize(normalize(x)) == normalize(x)).
64            let normalized_children: Vec<Atom<'a>> =
65                args.iter().map(|a| normalize(ctx, *a)).collect();
66            let mut flat = Vec::new();
67            collect_add(&normalized_children, &mut flat);
68            let mut normalized = flat;
69            // Drop explicit zero terms first (covers the common `x + 0` case),
70            // then sort and merge numeric literals. Merging can itself produce
71            // a new zero (e.g. `93 + -93`), so drop zeros AGAIN after merging.
72            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(0)));
73            normalized.sort();
74            merge_numbers(ctx, &mut normalized, true);
75            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(0)));
76            if normalized.is_empty() {
77                ctx.num(0)
78            } else if normalized.len() == 1 {
79                normalized[0]
80            } else {
81                ctx.add(&normalized)
82            }
83        }
84        AtomNode::Mul(args) => {
85            // Normalize children FIRST, then flatten — same reasoning as Add.
86            let normalized_children: Vec<Atom<'a>> =
87                args.iter().map(|a| normalize(ctx, *a)).collect();
88            let mut flat = Vec::new();
89            collect_mul(&normalized_children, &mut flat);
90            let mut normalized = flat;
91            if normalized
92                .iter()
93                .any(|a| matches!(a.node(), AtomNode::Num(0)))
94            {
95                return ctx.num(0);
96            }
97            // Drop explicit unit terms first, then sort and merge numeric
98            // literals. Merging can produce a new unit (e.g. `-1 * -1 = 1`),
99            // so drop units AGAIN after merging — mirrors the Add branch.
100            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(1)));
101            normalized.sort();
102            merge_numbers(ctx, &mut normalized, false);
103            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(1)));
104            if normalized.is_empty() {
105                ctx.num(1)
106            } else if normalized.len() == 1 {
107                normalized[0]
108            } else {
109                ctx.mul(&normalized)
110            }
111        }
112        AtomNode::Pow(base, exp) => {
113            let base = normalize(ctx, *base);
114            let exp = normalize(ctx, *exp);
115            // Exact numeric folds: `u^0 → 1`, `u^1 → u`, `0^n → 0` (n > 0),
116            // `1^_ → 1`, `(-1)^-1 → -1`, and exact integer powers `b^e`.
117            if let AtomNode::Num(e) = exp.node() {
118                if *e == 0 {
119                    return ctx.num(1);
120                }
121                if *e == 1 {
122                    return base;
123                }
124                if let AtomNode::Num(b) = base.node() {
125                    if *b == 0 {
126                        if *e > 0 {
127                            return ctx.num(0);
128                        }
129                    } else if *b == 1 {
130                        return ctx.num(1);
131                    } else if *e > 0 {
132                        if let Ok(e32) = u32::try_from(*e)
133                            && let Some(v) = b.checked_pow(e32)
134                        {
135                            return ctx.num(v);
136                        }
137                    } else if *e == -1 && *b == -1 {
138                        return ctx.num(-1);
139                    }
140                }
141            }
142            // Fold (u^r)^n → u^(r·n) when the outer exponent n is an
143            // integer (r rational or atom): sound formal power-of-a-power
144            // folding; non-integer outer exponents stay unfolded
145            // ((x²)^(1/2) ≠ x).
146            if let AtomNode::Num(n) = exp.node()
147                && let AtomNode::Pow(inner_base, inner_exp) = base.node()
148            {
149                let merged = normalize(ctx, ctx.mul(&[*inner_exp, ctx.num(*n)]));
150                return ctx.pow(*inner_base, merged);
151            }
152            ctx.pow(base, exp)
153        }
154    }
155}
156
157fn collect_add<'a>(args: &[Atom<'a>], out: &mut Vec<Atom<'a>>) {
158    for &arg in args {
159        match arg.node() {
160            AtomNode::Add(inner) => collect_add(inner, out),
161            _ => out.push(arg),
162        }
163    }
164}
165
166fn collect_mul<'a>(args: &[Atom<'a>], out: &mut Vec<Atom<'a>>) {
167    for &arg in args {
168        match arg.node() {
169            AtomNode::Mul(inner) => collect_mul(inner, out),
170            _ => out.push(arg),
171        }
172    }
173}
174
175fn merge_numbers<'a>(ctx: &AtomArena<'a>, args: &mut Vec<Atom<'a>>, is_add: bool) {
176    let count = args
177        .iter()
178        .take_while(|a| matches!(a.node(), AtomNode::Num(_)))
179        .count();
180
181    if count >= 2 {
182        let nums: Vec<i64> = args[0..count]
183            .iter()
184            .map(|a| match a.node() {
185                AtomNode::Num(n) => *n,
186                _ => unreachable!(),
187            })
188            .collect();
189        // Use wrapping arithmetic to avoid panics on overflow in debug mode.
190        // This matches Rust's release-mode behavior for i64 arithmetic.
191        let merged = if is_add {
192            nums.into_iter().fold(0i64, |acc, n| acc.wrapping_add(n))
193        } else {
194            nums.into_iter().fold(1i64, |acc, n| acc.wrapping_mul(n))
195        };
196        args.drain(0..count);
197        args.insert(0, ctx.num(merged));
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use ocas_core::arena::Arena;
205
206    #[test]
207    fn normalize_leaves_atom_unchanged() {
208        let arena = Arena::new();
209        let ctx = AtomArena::new(&arena);
210        let x = ctx.var("x");
211        assert_eq!(normalize(&ctx, x).to_string(), "x");
212    }
213
214    #[test]
215    fn normalize_flattens_nested_add() {
216        let arena = Arena::new();
217        let ctx = AtomArena::new(&arena);
218        let x = ctx.var("x");
219        let y = ctx.var("y");
220        let z = ctx.var("z");
221        let inner = ctx.add(&[x, y]);
222        let outer = ctx.add(&[inner, z]);
223        assert_eq!(normalize(&ctx, outer).to_string(), "x + y + z");
224    }
225
226    #[test]
227    fn normalize_drops_zero_from_opposite_numerics() {
228        // `93 + (-93) + sin(x)` must collapse to `sin(x)`: the two numerics
229        // merge to 0, which must then be dropped (regression for the
230        // retain-before-merge ordering bug found by proptest).
231        let arena = Arena::new();
232        let ctx = AtomArena::new(&arena);
233        let x = ctx.var("x");
234        let sinx = ctx.fun("sin", &[x]);
235        let a1 = ctx.add(&[ctx.num(93)]);
236        let a2 = ctx.add(&[ctx.num(-93)]);
237        let atom = ctx.add(&[a1, a2, sinx]);
238        assert_eq!(normalize(&ctx, atom).to_string(), "sin(x)");
239    }
240
241    #[test]
242    fn normalize_drops_unit_from_opposite_numerics() {
243        // `(-1) * ((-1) * x)` must collapse to `x`: the two units merge to 1,
244        // which must then be dropped.
245        let arena = Arena::new();
246        let ctx = AtomArena::new(&arena);
247        let x = ctx.var("x");
248        let neg1 = ctx.num(-1);
249        let inner = ctx.mul(&[x, neg1]);
250        let atom = ctx.mul(&[neg1, inner]);
251        assert_eq!(normalize(&ctx, atom).to_string(), "x");
252    }
253
254    #[test]
255    fn normalize_sorts_arguments() {
256        let arena = Arena::new();
257        let ctx = AtomArena::new(&arena);
258        let x = ctx.var("x");
259        let y = ctx.var("y");
260        let z = ctx.var("z");
261        let expr = ctx.add(&[z, x, y]);
262        assert_eq!(normalize(&ctx, expr).to_string(), "x + y + z");
263    }
264
265    #[test]
266    fn normalize_merges_numeric_literals() {
267        let arena = Arena::new();
268        let ctx = AtomArena::new(&arena);
269        let one = ctx.num(1);
270        let two = ctx.num(2);
271        let x = ctx.var("x");
272        let expr = ctx.add(&[one, x, two]);
273        assert_eq!(normalize(&ctx, expr).to_string(), "3 + x");
274    }
275
276    #[test]
277    fn normalize_pow() {
278        let arena = Arena::new();
279        let ctx = AtomArena::new(&arena);
280        let x = ctx.var("x");
281        let two = ctx.num(2);
282        let pow = ctx.pow(x, two);
283        assert_eq!(normalize(&ctx, pow).to_string(), "x^2");
284    }
285
286    #[test]
287    fn normalize_sorts_fun_arguments() {
288        let arena = Arena::new();
289        let ctx = AtomArena::new(&arena);
290        let x = ctx.var("x");
291        let y = ctx.var("y");
292        let f = ctx.fun("f", &[y, x]);
293        assert_eq!(normalize(&ctx, f).to_string(), "f(x, y)");
294    }
295
296    #[test]
297    fn normalize_preserves_elliptic_argument_order() {
298        let arena = Arena::new();
299        let ctx = AtomArena::new(&arena);
300        let x = ctx.var("x");
301        let y = ctx.var("y");
302        // Amplitude first, parameter second: the order is semantic.
303        let f = ctx.fun("EllipticF", &[x, y]);
304        assert_eq!(normalize(&ctx, f).to_string(), "EllipticF(x, y)");
305        let g = ctx.fun("EllipticF", &[y, x]);
306        assert_eq!(normalize(&ctx, g).to_string(), "EllipticF(y, x)");
307        // Round-trip is idempotent.
308        assert_eq!(
309            normalize(&ctx, normalize(&ctx, g)).to_string(),
310            "EllipticF(y, x)"
311        );
312        // Three-argument form keeps all three positions.
313        let p = ctx.fun("EllipticPi", &[y, x, ctx.num(1)]);
314        assert_eq!(normalize(&ctx, p).to_string(), "EllipticPi(y, x, 1)");
315        // Ei's two-argument (En) form is order-sensitive too.
316        let ei = ctx.fun("Ei", &[ctx.num(1), x]);
317        assert_eq!(normalize(&ctx, ei).to_string(), "Ei(1, x)");
318        // Ordinary heads keep sorting.
319        let h = ctx.fun("sin", &[y]);
320        assert_eq!(normalize(&ctx, h).to_string(), "sin(y)");
321    }
322}