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}