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