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            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(0)));
51            normalized.sort();
52            merge_numbers(ctx, &mut normalized, true);
53            if normalized.is_empty() {
54                ctx.num(0)
55            } else if normalized.len() == 1 {
56                normalized[0]
57            } else {
58                ctx.add(&normalized)
59            }
60        }
61        AtomNode::Mul(args) => {
62            // Normalize children FIRST, then flatten — same reasoning as Add.
63            let normalized_children: Vec<Atom<'a>> =
64                args.iter().map(|a| normalize(ctx, *a)).collect();
65            let mut flat = Vec::new();
66            collect_mul(&normalized_children, &mut flat);
67            let mut normalized = flat;
68            if normalized
69                .iter()
70                .any(|a| matches!(a.node(), AtomNode::Num(0)))
71            {
72                return ctx.num(0);
73            }
74            normalized.retain(|a| !matches!(a.node(), AtomNode::Num(1)));
75            normalized.sort();
76            merge_numbers(ctx, &mut normalized, false);
77            if normalized.is_empty() {
78                ctx.num(1)
79            } else if normalized.len() == 1 {
80                normalized[0]
81            } else {
82                ctx.mul(&normalized)
83            }
84        }
85        AtomNode::Pow(base, exp) => {
86            let base = normalize(ctx, *base);
87            let exp = normalize(ctx, *exp);
88            ctx.pow(base, exp)
89        }
90    }
91}
92
93fn collect_add<'a>(args: &[Atom<'a>], out: &mut Vec<Atom<'a>>) {
94    for &arg in args {
95        match arg.node() {
96            AtomNode::Add(inner) => collect_add(inner, out),
97            _ => out.push(arg),
98        }
99    }
100}
101
102fn collect_mul<'a>(args: &[Atom<'a>], out: &mut Vec<Atom<'a>>) {
103    for &arg in args {
104        match arg.node() {
105            AtomNode::Mul(inner) => collect_mul(inner, out),
106            _ => out.push(arg),
107        }
108    }
109}
110
111fn merge_numbers<'a>(ctx: &AtomArena<'a>, args: &mut Vec<Atom<'a>>, is_add: bool) {
112    let count = args
113        .iter()
114        .take_while(|a| matches!(a.node(), AtomNode::Num(_)))
115        .count();
116
117    if count >= 2 {
118        let nums: Vec<i64> = args[0..count]
119            .iter()
120            .map(|a| match a.node() {
121                AtomNode::Num(n) => *n,
122                _ => unreachable!(),
123            })
124            .collect();
125        // Use wrapping arithmetic to avoid panics on overflow in debug mode.
126        // This matches Rust's release-mode behavior for i64 arithmetic.
127        let merged = if is_add {
128            nums.into_iter().fold(0i64, |acc, n| acc.wrapping_add(n))
129        } else {
130            nums.into_iter().fold(1i64, |acc, n| acc.wrapping_mul(n))
131        };
132        args.drain(0..count);
133        args.insert(0, ctx.num(merged));
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use ocas_core::arena::Arena;
141
142    #[test]
143    fn normalize_leaves_atom_unchanged() {
144        let arena = Arena::new();
145        let ctx = AtomArena::new(&arena);
146        let x = ctx.var("x");
147        assert_eq!(normalize(&ctx, x).to_string(), "x");
148    }
149
150    #[test]
151    fn normalize_flattens_nested_add() {
152        let arena = Arena::new();
153        let ctx = AtomArena::new(&arena);
154        let x = ctx.var("x");
155        let y = ctx.var("y");
156        let z = ctx.var("z");
157        let inner = ctx.add(&[x, y]);
158        let outer = ctx.add(&[inner, z]);
159        assert_eq!(normalize(&ctx, outer).to_string(), "x + y + z");
160    }
161
162    #[test]
163    fn normalize_sorts_arguments() {
164        let arena = Arena::new();
165        let ctx = AtomArena::new(&arena);
166        let x = ctx.var("x");
167        let y = ctx.var("y");
168        let z = ctx.var("z");
169        let expr = ctx.add(&[z, x, y]);
170        assert_eq!(normalize(&ctx, expr).to_string(), "x + y + z");
171    }
172
173    #[test]
174    fn normalize_merges_numeric_literals() {
175        let arena = Arena::new();
176        let ctx = AtomArena::new(&arena);
177        let one = ctx.num(1);
178        let two = ctx.num(2);
179        let x = ctx.var("x");
180        let expr = ctx.add(&[one, x, two]);
181        assert_eq!(normalize(&ctx, expr).to_string(), "3 + x");
182    }
183
184    #[test]
185    fn normalize_pow() {
186        let arena = Arena::new();
187        let ctx = AtomArena::new(&arena);
188        let x = ctx.var("x");
189        let two = ctx.num(2);
190        let pow = ctx.pow(x, two);
191        assert_eq!(normalize(&ctx, pow).to_string(), "x^2");
192    }
193
194    #[test]
195    fn normalize_sorts_fun_arguments() {
196        let arena = Arena::new();
197        let ctx = AtomArena::new(&arena);
198        let x = ctx.var("x");
199        let y = ctx.var("y");
200        let f = ctx.fun("f", &[y, x]);
201        assert_eq!(normalize(&ctx, f).to_string(), "f(x, y)");
202    }
203}