Skip to main content

ocas_atom/
lib.rs

1//! Symbolic expression tree and rewriting for oCAS.
2//!
3//! This crate provides the [`Atom`] type: an arena-backed, tagged-union
4//! representation of symbolic expressions. Atoms are immutable, copyable
5//! references into an [`Arena`] and form the core data structure used by the
6//! parser, printer, and rewrite engine.
7
8use std::cell::RefCell;
9use std::collections::HashMap;
10use std::sync::{Mutex, OnceLock};
11
12use ocas_core::arena::Arena;
13
14pub mod normalize;
15pub mod walk;
16
17/// An interned symbolic name (variable, function, or constant).
18///
19/// Symbols are deduplicated globally and live for the remainder of the
20/// process. This keeps [`Atom`] small and comparable by identity.
21///
22/// # Example
23///
24/// ```
25/// use ocas_atom::Symbol;
26///
27/// let x = Symbol::new("x");
28/// let also_x = Symbol::new("x");
29/// assert_eq!(x, also_x);
30/// assert_eq!(x.as_str(), "x");
31/// ```
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub struct Symbol(&'static str);
34
35impl Symbol {
36    /// Create a symbol from a name, interning it globally.
37    ///
38    /// # Example
39    ///
40    /// ```
41    /// use ocas_atom::Symbol;
42    ///
43    /// let sym = Symbol::new("my_var");
44    /// assert_eq!(sym.as_str(), "my_var");
45    /// ```
46    pub fn new(name: &str) -> Self {
47        Self(intern(name))
48    }
49
50    /// Return the symbol's string representation.
51    ///
52    /// # Example
53    ///
54    /// ```
55    /// use ocas_atom::Symbol;
56    ///
57    /// let sym = Symbol::new("y");
58    /// assert_eq!(sym.as_str(), "y");
59    /// ```
60    pub fn as_str(&self) -> &str {
61        self.0
62    }
63}
64
65fn intern(name: &str) -> &'static str {
66    static TABLE: OnceLock<Mutex<HashMap<String, &'static str>>> = OnceLock::new();
67    let table = TABLE.get_or_init(|| Mutex::new(HashMap::new()));
68    let mut table = table.lock().expect("symbol interner lock poisoned");
69    table.entry(name.to_owned()).or_insert_with(|| {
70        let boxed = name.to_owned().into_boxed_str();
71        Box::leak(boxed)
72    })
73}
74
75/// A reference to an expression node allocated in an arena.
76///
77/// `Atom` is a small copyable handle. The actual node data lives in the
78/// [`Arena`] and is freed when the arena is dropped.
79///
80/// # Example
81///
82/// ```
83/// use ocas_atom::AtomArena;
84/// use ocas_core::arena::Arena;
85///
86/// let arena = Arena::new();
87/// let ctx = AtomArena::new(&arena);
88/// let x = ctx.var("x");
89/// let two = ctx.num(2);
90/// let expr = ctx.pow(x, two);
91/// assert_eq!(expr.to_string(), "x^2");
92/// ```
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
94pub struct Atom<'a>(&'a AtomNode<'a>);
95
96impl<'a> Atom<'a> {
97    /// Access the underlying node data.
98    ///
99    /// # Example
100    ///
101    /// ```
102    /// use ocas_atom::{AtomArena, AtomNode};
103    /// use ocas_core::arena::Arena;
104    ///
105    /// let arena = Arena::new();
106    /// let ctx = AtomArena::new(&arena);
107    /// let x = ctx.var("x");
108    /// assert!(matches!(x.node(), AtomNode::Var(_)));
109    /// ```
110    pub fn node(&self) -> &'a AtomNode<'a> {
111        self.0
112    }
113
114    /// Returns the direct children of this atom, in left-to-right order.
115    ///
116    /// `Num`, `Var`, and `Pow` report no children through this API; use
117    /// [`Self::binary_children`] for the two operands of `Pow`.
118    ///
119    /// # Example
120    ///
121    /// ```
122    /// use ocas_atom::AtomArena;
123    /// use ocas_core::arena::Arena;
124    ///
125    /// let arena = Arena::new();
126    /// let ctx = AtomArena::new(&arena);
127    /// let x = ctx.var("x");
128    /// let y = ctx.var("y");
129    /// let sum = ctx.add(&[x, y, ctx.num(1)]);
130    /// assert_eq!(sum.children().len(), 3);
131    /// ```
132    pub fn children(&self) -> &'a [Atom<'a>] {
133        match self.node() {
134            AtomNode::Num(_) | AtomNode::Var(_) => &[],
135            AtomNode::Fun(_, args) | AtomNode::Add(args) | AtomNode::Mul(args) => args,
136            AtomNode::Pow(base, exp) => {
137                // This function cannot return a dynamically-allocated slice,
138                // so callers that need the two-element slice should use
139                // [`Self::binary_children`]. For now, `Pow` reports no children
140                // through this API to keep the return type a plain slice.
141                let _ = (base, exp);
142                &[]
143            }
144        }
145    }
146
147    /// If this atom is a binary operator, return its two operands.
148    ///
149    /// # Example
150    ///
151    /// ```
152    /// use ocas_atom::AtomArena;
153    /// use ocas_core::arena::Arena;
154    ///
155    /// let arena = Arena::new();
156    /// let ctx = AtomArena::new(&arena);
157    /// let x = ctx.var("x");
158    /// let y = ctx.var("y");
159    /// let power = ctx.pow(x, y);
160    /// let (base, exp) = power.binary_children().unwrap();
161    /// assert_eq!(base.to_string(), "x");
162    /// assert_eq!(exp.to_string(), "y");
163    /// ```
164    pub fn binary_children(&self) -> Option<(Atom<'a>, Atom<'a>)> {
165        match self.node() {
166            AtomNode::Pow(base, exp) => Some((*base, *exp)),
167            _ => None,
168        }
169    }
170}
171
172/// The concrete data stored for each expression node.
173///
174/// # Example
175///
176/// ```
177/// use ocas_atom::{AtomArena, AtomNode};
178/// use ocas_core::arena::Arena;
179///
180/// let arena = Arena::new();
181/// let ctx = AtomArena::new(&arena);
182/// let x = ctx.var("x");
183/// match x.node() {
184///     AtomNode::Var(s) => assert_eq!(s.as_str(), "x"),
185///     _ => panic!("expected variable"),
186/// }
187/// ```
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
189pub enum AtomNode<'a> {
190    /// A 64-bit signed integer literal.
191    Num(i64),
192    /// A named variable or constant.
193    Var(Symbol),
194    /// A named function applied to a list of arguments.
195    Fun(Symbol, &'a [Atom<'a>]),
196    /// A sum of sub-expressions.
197    Add(&'a [Atom<'a>]),
198    /// A product of sub-expressions.
199    Mul(&'a [Atom<'a>]),
200    /// A power with base and exponent.
201    Pow(Atom<'a>, Atom<'a>),
202}
203
204/// A context that allocates [`Atom`]s in an [`Arena`].
205///
206/// All construction methods are immutable from the caller's perspective;
207/// mutation happens through the arena's interior mutability. Identical
208/// sub-expressions are hash-consed so that structural equality implies
209/// pointer equality.
210///
211/// # Example
212///
213/// ```
214/// use ocas_atom::AtomArena;
215/// use ocas_core::arena::Arena;
216///
217/// let arena = Arena::new();
218/// let ctx = AtomArena::new(&arena);
219/// let x = ctx.var("x");
220/// let y = ctx.var("y");
221/// let sum = ctx.add(&[x, y]);
222/// assert_eq!(sum.to_string(), "x + y");
223/// ```
224pub struct AtomArena<'a> {
225    arena: &'a Arena,
226    cons_table: RefCell<HashMap<AtomNode<'a>, Atom<'a>>>,
227}
228
229impl<'a> AtomArena<'a> {
230    /// Create an `AtomArena` backed by the given arena.
231    ///
232    /// # Example
233    ///
234    /// ```
235    /// use ocas_atom::AtomArena;
236    /// use ocas_core::arena::Arena;
237    ///
238    /// let arena = Arena::new();
239    /// let ctx = AtomArena::new(&arena);
240    /// let n = ctx.num(42);
241    /// assert_eq!(n.to_string(), "42");
242    /// ```
243    pub fn new(arena: &'a Arena) -> Self {
244        Self {
245            arena,
246            cons_table: RefCell::new(HashMap::new()),
247        }
248    }
249
250    fn intern(&self, candidate: AtomNode<'a>) -> Atom<'a> {
251        let mut table = self.cons_table.borrow_mut();
252        *table
253            .entry(candidate)
254            .or_insert_with(|| Atom(self.arena.allocate_with(|| candidate)))
255    }
256
257    /// Create an integer literal atom.
258    ///
259    /// # Example
260    ///
261    /// ```
262    /// use ocas_atom::AtomArena;
263    /// use ocas_core::arena::Arena;
264    ///
265    /// let arena = Arena::new();
266    /// let ctx = AtomArena::new(&arena);
267    /// let n = ctx.num(7);
268    /// assert_eq!(n.to_string(), "7");
269    /// ```
270    pub fn num(&self, value: i64) -> Atom<'a> {
271        self.intern(AtomNode::Num(value))
272    }
273
274    /// Create a variable atom from a name.
275    ///
276    /// # Example
277    ///
278    /// ```
279    /// use ocas_atom::AtomArena;
280    /// use ocas_core::arena::Arena;
281    ///
282    /// let arena = Arena::new();
283    /// let ctx = AtomArena::new(&arena);
284    /// let x = ctx.var("x");
285    /// assert_eq!(x.to_string(), "x");
286    /// ```
287    pub fn var(&self, name: &str) -> Atom<'a> {
288        self.intern(AtomNode::Var(Symbol::new(name)))
289    }
290
291    /// Create a function application atom.
292    ///
293    /// # Panics
294    ///
295    /// Panics in debug mode if `args` is empty.
296    ///
297    /// # Example
298    ///
299    /// ```
300    /// use ocas_atom::AtomArena;
301    /// use ocas_core::arena::Arena;
302    ///
303    /// let arena = Arena::new();
304    /// let ctx = AtomArena::new(&arena);
305    /// let x = ctx.var("x");
306    /// let f = ctx.fun("sin", &[x]);
307    /// assert_eq!(f.to_string(), "sin(x)");
308    /// ```
309    pub fn fun(&self, name: &str, args: &[Atom<'a>]) -> Atom<'a> {
310        debug_assert!(!args.is_empty(), "Fun node requires at least one argument");
311        let slice = self.arena.allocate_slice(args);
312        self.intern(AtomNode::Fun(Symbol::new(name), slice))
313    }
314
315    /// Create an addition atom.
316    ///
317    /// # Panics
318    ///
319    /// Panics in debug mode if `args` is empty.
320    ///
321    /// # Example
322    ///
323    /// ```
324    /// use ocas_atom::AtomArena;
325    /// use ocas_core::arena::Arena;
326    ///
327    /// let arena = Arena::new();
328    /// let ctx = AtomArena::new(&arena);
329    /// let x = ctx.var("x");
330    /// let y = ctx.var("y");
331    /// let sum = ctx.add(&[x, y]);
332    /// assert_eq!(sum.to_string(), "x + y");
333    /// ```
334    pub fn add(&self, args: &[Atom<'a>]) -> Atom<'a> {
335        debug_assert!(!args.is_empty(), "Add node requires at least one argument");
336        let slice = self.arena.allocate_slice(args);
337        self.intern(AtomNode::Add(slice))
338    }
339
340    /// Create a multiplication atom.
341    ///
342    /// # Panics
343    ///
344    /// Panics in debug mode if `args` is empty.
345    ///
346    /// # Example
347    ///
348    /// ```
349    /// use ocas_atom::AtomArena;
350    /// use ocas_core::arena::Arena;
351    ///
352    /// let arena = Arena::new();
353    /// let ctx = AtomArena::new(&arena);
354    /// let x = ctx.var("x");
355    /// let y = ctx.var("y");
356    /// let product = ctx.mul(&[x, y]);
357    /// assert_eq!(product.to_string(), "x*y");
358    /// ```
359    pub fn mul(&self, args: &[Atom<'a>]) -> Atom<'a> {
360        debug_assert!(!args.is_empty(), "Mul node requires at least one argument");
361        let slice = self.arena.allocate_slice(args);
362        self.intern(AtomNode::Mul(slice))
363    }
364
365    /// Create a power atom.
366    ///
367    /// # Example
368    ///
369    /// ```
370    /// use ocas_atom::AtomArena;
371    /// use ocas_core::arena::Arena;
372    ///
373    /// let arena = Arena::new();
374    /// let ctx = AtomArena::new(&arena);
375    /// let x = ctx.var("x");
376    /// let p = ctx.pow(x, ctx.num(3));
377    /// assert_eq!(p.to_string(), "x^3");
378    /// ```
379    pub fn pow(&self, base: Atom<'a>, exp: Atom<'a>) -> Atom<'a> {
380        self.intern(AtomNode::Pow(base, exp))
381    }
382}
383
384impl std::fmt::Display for Atom<'_> {
385    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386        match self.node() {
387            AtomNode::Num(n) => write!(f, "{n}"),
388            AtomNode::Var(s) => write!(f, "{}", s.as_str()),
389            AtomNode::Fun(name, args) => {
390                write!(f, "{}(", name.as_str())?;
391                for (i, arg) in args.iter().enumerate() {
392                    if i > 0 {
393                        write!(f, ", ")?;
394                    }
395                    write!(f, "{arg}")?;
396                }
397                write!(f, ")")
398            }
399            AtomNode::Add(args) => {
400                for (i, arg) in args.iter().enumerate() {
401                    if i > 0 {
402                        write!(f, " + ")?;
403                    }
404                    write_parenthesized(arg, f)?;
405                }
406                Ok(())
407            }
408            AtomNode::Mul(args) => {
409                for (i, arg) in args.iter().enumerate() {
410                    if i > 0 {
411                        write!(f, "*")?;
412                    }
413                    write_parenthesized(arg, f)?;
414                }
415                Ok(())
416            }
417            AtomNode::Pow(base, exp) => {
418                write_parenthesized(base, f)?;
419                write!(f, "^")?;
420                write_parenthesized(exp, f)
421            }
422        }
423    }
424}
425
426fn write_parenthesized(atom: &Atom<'_>, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
427    match atom.node() {
428        AtomNode::Num(_) | AtomNode::Var(_) => write!(f, "{atom}"),
429        _ => write!(f, "({atom})"),
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    #[test]
438    fn construct_num() {
439        let arena = Arena::new();
440        let ctx = AtomArena::new(&arena);
441        let n = ctx.num(42);
442        assert_eq!(n.to_string(), "42");
443        assert!(matches!(n.node(), AtomNode::Num(42)));
444    }
445
446    #[test]
447    fn construct_var() {
448        let arena = Arena::new();
449        let ctx = AtomArena::new(&arena);
450        let x = ctx.var("x");
451        assert_eq!(x.to_string(), "x");
452        assert!(matches!(x.node(), AtomNode::Var(s) if s.as_str() == "x"));
453    }
454
455    #[test]
456    fn construct_add() {
457        let arena = Arena::new();
458        let ctx = AtomArena::new(&arena);
459        let x = ctx.var("x");
460        let y = ctx.var("y");
461        let sum = ctx.add(&[x, y]);
462        assert_eq!(sum.to_string(), "x + y");
463    }
464
465    #[test]
466    fn construct_mul() {
467        let arena = Arena::new();
468        let ctx = AtomArena::new(&arena);
469        let x = ctx.var("x");
470        let two = ctx.num(2);
471        let prod = ctx.mul(&[x, two]);
472        assert_eq!(prod.to_string(), "x*2");
473    }
474
475    #[test]
476    fn construct_fun() {
477        let arena = Arena::new();
478        let ctx = AtomArena::new(&arena);
479        let x = ctx.var("x");
480        let sin = ctx.fun("sin", &[x]);
481        assert_eq!(sin.to_string(), "sin(x)");
482    }
483
484    #[test]
485    fn fun_with_multiple_args() {
486        let arena = Arena::new();
487        let ctx = AtomArena::new(&arena);
488        let x = ctx.var("x");
489        let y = ctx.var("y");
490        let f = ctx.fun("f", &[x, y]);
491        assert_eq!(f.to_string(), "f(x, y)");
492    }
493
494    #[test]
495    fn children_returns_direct_subexpressions() {
496        let arena = Arena::new();
497        let ctx = AtomArena::new(&arena);
498        let x = ctx.var("x");
499        let y = ctx.var("y");
500        let sum = ctx.add(&[x, y]);
501        assert_eq!(sum.children(), &[x, y]);
502        assert_eq!(x.children(), &[]);
503    }
504
505    #[test]
506    fn nested_expression_prints_with_parentheses() {
507        let arena = Arena::new();
508        let ctx = AtomArena::new(&arena);
509        let x = ctx.var("x");
510        let y = ctx.var("y");
511        let sum = ctx.add(&[x, y]);
512        let two = ctx.num(2);
513        let squared = ctx.pow(sum, two);
514        assert_eq!(squared.to_string(), "(x + y)^2");
515    }
516
517    #[test]
518    fn atom_equality_uses_structure() {
519        let arena = Arena::new();
520        let ctx = AtomArena::new(&arena);
521        let x = ctx.var("x");
522        let y = ctx.var("y");
523        let a = ctx.add(&[x, y]);
524        let b = ctx.add(&[x, y]);
525        let c = ctx.add(&[y, x]);
526        assert_eq!(a, b);
527        assert_ne!(a, c);
528    }
529
530    #[test]
531    fn symbol_identity_is_preserved() {
532        let a = Symbol::new("x");
533        let b = Symbol::new("x");
534        let c = Symbol::new("y");
535        assert_eq!(a, b);
536        assert_ne!(a, c);
537        assert_eq!(a.as_str(), "x");
538    }
539
540    #[test]
541    fn atom_is_copyable() {
542        let arena = Arena::new();
543        let ctx = AtomArena::new(&arena);
544        let x = ctx.var("x");
545        let copied = x;
546        assert_eq!(x, copied);
547    }
548
549    #[test]
550    fn hash_consing_reuses_identical_nodes() {
551        let arena = Arena::new();
552        let ctx = AtomArena::new(&arena);
553        let x = ctx.var("x");
554        let y = ctx.var("y");
555        let a = ctx.add(&[x, y]);
556        let b = ctx.add(&[x, y]);
557        // Hash-consing should return the same arena pointer.
558        assert!(std::ptr::eq(a.node(), b.node()));
559    }
560
561    #[test]
562    fn hash_consing_distinguishes_different_nodes() {
563        let arena = Arena::new();
564        let ctx = AtomArena::new(&arena);
565        let x = ctx.var("x");
566        let y = ctx.var("y");
567        let a = ctx.add(&[x, y]);
568        let b = ctx.add(&[y, x]);
569        assert!(!std::ptr::eq(a.node(), b.node()));
570    }
571}
572
573#[cfg(test)]
574mod proptests {
575    use super::*;
576    use ocas_core::arena::Arena;
577    use proptest::prelude::*;
578
579    /// Owned expression tree used for property-test generation.
580    #[derive(Debug, Clone)]
581    enum PropExpr {
582        Num(i64),
583        Var(&'static str),
584        Fun(&'static str, Vec<PropExpr>),
585        Add(Vec<PropExpr>),
586        Mul(Vec<PropExpr>),
587        Pow(Box<PropExpr>, Box<PropExpr>),
588    }
589
590    fn build_atom<'a>(ctx: &AtomArena<'a>, expr: &PropExpr) -> Atom<'a> {
591        match expr {
592            PropExpr::Num(n) => ctx.num(*n),
593            PropExpr::Var(name) => ctx.var(name),
594            PropExpr::Fun(name, args) => {
595                let atoms: Vec<Atom<'a>> = args.iter().map(|a| build_atom(ctx, a)).collect();
596                ctx.fun(name, &atoms)
597            }
598            PropExpr::Add(args) => {
599                let atoms: Vec<Atom<'a>> = args.iter().map(|a| build_atom(ctx, a)).collect();
600                ctx.add(&atoms)
601            }
602            PropExpr::Mul(args) => {
603                let atoms: Vec<Atom<'a>> = args.iter().map(|a| build_atom(ctx, a)).collect();
604                ctx.mul(&atoms)
605            }
606            PropExpr::Pow(base, exp) => ctx.pow(build_atom(ctx, base), build_atom(ctx, exp)),
607        }
608    }
609
610    fn prop_expr() -> impl Strategy<Value = PropExpr> {
611        let leaf = prop_oneof![
612            (-100..100i64).prop_map(PropExpr::Num),
613            Just(PropExpr::Var("x")),
614            Just(PropExpr::Var("y")),
615            Just(PropExpr::Var("z")),
616        ];
617        leaf.prop_recursive(4, 64, 4, |inner| {
618            prop_oneof![
619                inner.clone().prop_map(|e| PropExpr::Fun("sin", vec![e])),
620                inner.clone().prop_map(|e| PropExpr::Fun("cos", vec![e])),
621                prop::collection::vec(inner.clone(), 1..4).prop_map(PropExpr::Add),
622                prop::collection::vec(inner.clone(), 1..4).prop_map(PropExpr::Mul),
623                (inner.clone(), inner.clone())
624                    .prop_map(|(b, e)| PropExpr::Pow(Box::new(b), Box::new(e))),
625            ]
626        })
627    }
628
629    proptest! {
630        #[test]
631        fn normalize_is_idempotent(expr in prop_expr()) {
632            let arena = Arena::new();
633            let ctx = AtomArena::new(&arena);
634            let atom = build_atom(&ctx, &expr);
635            let once = normalize::normalize(&ctx, atom);
636            let twice = normalize::normalize(&ctx, once);
637            assert_eq!(once.to_string(), twice.to_string());
638        }
639
640        #[test]
641        fn add_identity(expr in prop_expr()) {
642            let arena = Arena::new();
643            let ctx = AtomArena::new(&arena);
644            let atom = build_atom(&ctx, &expr);
645            let zero = ctx.num(0);
646            let with_zero = ctx.add(&[atom, zero]);
647            let normalized = normalize::normalize(&ctx, with_zero);
648            assert_eq!(normalized.to_string(), normalize::normalize(&ctx, atom).to_string());
649        }
650
651        #[test]
652        fn mul_identity(expr in prop_expr()) {
653            let arena = Arena::new();
654            let ctx = AtomArena::new(&arena);
655            let atom = build_atom(&ctx, &expr);
656            let one = ctx.num(1);
657            let with_one = ctx.mul(&[atom, one]);
658            let normalized = normalize::normalize(&ctx, with_one);
659            assert_eq!(normalized.to_string(), normalize::normalize(&ctx, atom).to_string());
660        }
661    }
662}