Skip to main content

alkahest_cas/kernel/
pool.rs

1use crate::kernel::{
2    domain::Domain,
3    expr::{BigFloat, BigInt, BigRat, ExprData, ExprId},
4};
5use std::fmt;
6
7/// Canonical ∞ symbol name for [`ExprPool::pos_infinity`] / limits (V2-16).
8pub const POS_INFINITY_SYMBOL: &str = "\u{221e}";
9
10// ---------------------------------------------------------------------------
11// Lock-free arena for ExprPool nodes, and a single-storage index over it.
12//
13// Strategy:
14//   * The `nodes` array (ExprId → Node) is a `boxcar::Vec` — a lock-free,
15//     append-only, reference-stable segmented array.  Reads (`with`, `get`,
16//     `len`) acquire no lock at all; they index directly into the array via a
17//     single atomic load.  This is the *only* place an `ExprData` is stored.
18//   * The `index` (ExprData → ExprId) is a set of 8-byte `Slot`s — an id and
19//     32 bits of the node's hash — in `hashbrown::HashTable`s.  It stores no
20//     `ExprData` of its own: a probe compares the candidate against the node
21//     the slot points at.  (The index used to be a map *keyed by* `ExprData`,
22//     so every node lived twice — once in the array and once, deep-cloned, as
23//     a key — and every miss paid for the clone.)
24//   * Hash-cons uniqueness under concurrency: the index is split into shards,
25//     each behind an `RwLock`, chosen by the top bits of the hash.  A hit takes
26//     the shard's read lock.  A miss takes its write lock, *re-probes under it*,
27//     and only then pushes the node (lock-free) and records its slot, still
28//     under the lock — so two threads interning the same value serialise on the
29//     same shard, the second finds the first's slot, and a node is created at
30//     most once per distinct value.
31//   * Without `parallel` there is one shard; the code path is the same.
32//   * Each `ExprData` is hashed exactly once per `intern`, with a per-pool
33//     random seed (foldhash).  The table grows by re-bucketing the stored
34//     32-bit hashes, so a resize never touches — let alone re-hashes — a node.
35// ---------------------------------------------------------------------------
36
37use hashbrown::HashTable;
38use std::hash::BuildHasher;
39use std::sync::{PoisonError, RwLock};
40
41/// One index entry: the id of an interned node and the low 32 bits of its hash.
42///
43/// The hash bits are a fast reject (most probes that land on the wrong slot
44/// never touch the node) and are what the table re-buckets on growth.  They
45/// never decide equality: a hash collision falls through to comparing the
46/// `ExprData` itself, so two distinct nodes can never be merged.
47#[derive(Clone, Copy)]
48struct Slot {
49    id: u32,
50    hash: u32,
51}
52
53/// The 64-bit table hash for a slot's 32 stored bits.  Duplicating them into
54/// the high half gives hashbrown's control byte (the top 7 bits) and its bucket
55/// index (the low bits) independent-looking inputs.
56#[inline]
57fn slot_hash(h32: u32) -> u64 {
58    let h = u64::from(h32);
59    h | (h << 32)
60}
61
62/// A shard, padded to its own cache lines so concurrent interns on neighbouring
63/// shards do not contend on the lock word.
64#[repr(align(128))]
65struct Shard(RwLock<HashTable<Slot>>);
66
67/// Number of index shards: one without `parallel`; with it, four per available
68/// core rounded up to a power of two (the same rule `DashMap` uses), capped so
69/// an idle pool stays small.  Computed once per process — reading the core
70/// count can mean parsing cgroup files.
71fn shard_count() -> usize {
72    #[cfg(feature = "parallel")]
73    {
74        static SHARDS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
75        *SHARDS.get_or_init(|| {
76            let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
77            (cores * 4).next_power_of_two().clamp(4, 64)
78        })
79    }
80    #[cfg(not(feature = "parallel"))]
81    {
82        1
83    }
84}
85
86struct PoolIndex {
87    shards: Box<[Shard]>,
88    /// `64 - log2(shards.len())`; the shard is `hash >> shard_shift` (the top
89    /// bits), disjoint from the low 32 bits a `Slot` stores.
90    shard_shift: u32,
91    seed: foldhash::fast::RandomState,
92}
93
94impl PoolIndex {
95    fn new() -> Self {
96        let n = shard_count();
97        debug_assert!(n.is_power_of_two());
98        PoolIndex {
99            shards: (0..n)
100                .map(|_| Shard(RwLock::new(HashTable::new())))
101                .collect(),
102            shard_shift: 64 - n.trailing_zeros(),
103            seed: foldhash::fast::RandomState::default(),
104        }
105    }
106
107    /// Return the id for `data`, calling `make` to create the node if absent.
108    fn get_or_insert_with(
109        &self,
110        nodes: &boxcar::Vec<Node>,
111        data: ExprData,
112        make: impl FnOnce(ExprData) -> ExprId,
113    ) -> ExprId {
114        let hash = self.seed.hash_one(&data);
115        self.get_or_insert_hashed(nodes, hash, data, make)
116    }
117
118    /// [`Self::get_or_insert_with`] with the hash supplied, so tests can force
119    /// collisions.
120    ///
121    /// Hit: one shard read-lock.  Miss: the shard write-lock, a re-probe under
122    /// it, and `make` runs while the lock is held — so `make` runs at most once
123    /// per distinct value, however many threads race to intern it.
124    fn get_or_insert_hashed(
125        &self,
126        nodes: &boxcar::Vec<Node>,
127        hash: u64,
128        data: ExprData,
129        make: impl FnOnce(ExprData) -> ExprId,
130    ) -> ExprId {
131        let h32 = hash as u32;
132        let th = slot_hash(h32);
133        // `checked_shr`: with a single shard the shift is 64.
134        let shard = hash.checked_shr(self.shard_shift).unwrap_or(0) as usize;
135        let lock = &self.shards[shard].0;
136        let eq = |s: &Slot| s.hash == h32 && nodes[s.id as usize].data == data;
137
138        // A panic in `make` (say, a child id from another pool) poisons the
139        // lock, but it fires before the slot is recorded, so the table is still
140        // consistent and later interns may carry on.
141        if let Some(s) = lock
142            .read()
143            .unwrap_or_else(PoisonError::into_inner)
144            .find(th, eq)
145        {
146            return ExprId(s.id);
147        }
148        let mut table = lock.write().unwrap_or_else(PoisonError::into_inner);
149        if let Some(s) = table.find(th, eq) {
150            return ExprId(s.id);
151        }
152        let id = make(data);
153        table.insert_unique(
154            th,
155            Slot {
156                id: id.0,
157                hash: h32,
158            },
159            |s| slot_hash(s.hash),
160        );
161        id
162    }
163}
164
165/// Owns all expression nodes. Every [`ExprId`] is valid only within its pool.
166///
167/// `ExprPool` is `Send + Sync`.
168///
169/// Read operations (`with`, `get`, `len`) are fully lock-free — they index
170/// into a `boxcar::Vec` via a single atomic load with no lock acquisition.
171/// Write operations (`intern`) use a per-shard lock (parallel mode) or a
172/// `Mutex` (non-parallel mode) only during new-node insertion.
173/// A node plus the properties that are cheaper to record once than to recompute.
174struct Node {
175    data: ExprData,
176    /// Whether every generator in this subtree commutes under multiplication.
177    ///
178    /// This is a bottom-up property, and hash-consing guarantees a node's
179    /// children are interned before the node itself, so it is computed once
180    /// here from the children's cached flags — O(arity) — instead of by
181    /// walking the whole subtree on every query.
182    mult_commutative: bool,
183    /// Length of the longest root-to-leaf path in this subtree; a leaf is 1.
184    ///
185    /// Computed exactly like `mult_commutative` — once, at intern time, from
186    /// the children's cached values — so [`ExprPool::depth`] is a single array
187    /// read.  Recomputing it on demand is not an option: the pool is a DAG, so
188    /// an unmemoised depth walk is exponential in the sharing, and a memoised
189    /// one allocates a map per query.  Saturating, so a pathological expression
190    /// pins at `u32::MAX` instead of wrapping to a small value.
191    ///
192    /// This is what lets every recursive consumer refuse a too-deep expression
193    /// in O(1) rather than discovering the problem by overflowing the stack.
194    depth: u32,
195    /// Whether some symbol in this subtree has [`Domain::Positive`] or
196    /// [`Domain::NonZero`] — the domains
197    /// `simplify::assumptions::collect_static_domain_facts` turns into
198    /// rewrite facts.  Every `simplify` ends with that collection, and almost
199    /// no expression has such a symbol, so the walk can answer from the root
200    /// in O(1) and prune every subtree without one.  Computed like the flags
201    /// above; the children counted are every child that walk descends into.
202    static_domain_fact: bool,
203    /// Whether some node in this subtree is `∞` or a non-finite `Float`
204    /// (see [`crate::kernel::expr_props::is_non_finite_atom`]).  Computed like
205    /// the flags above, so the simplifier can ask "may this term be infinite
206    /// or NaN?" in O(1) before cancelling it.
207    non_finite: bool,
208}
209
210pub struct ExprPool {
211    /// Lock-free, append-only, reference-stable node array.
212    nodes: boxcar::Vec<Node>,
213    /// Deduplication index: ExprData → ExprId.  Holds ids, not data; see
214    /// [`PoolIndex`].
215    index: PoolIndex,
216}
217
218// `ExprPool` is `Send + Sync` *by inference*, not by assertion.
219//
220// This used to be `unsafe impl Send for ExprPool {}` / `unsafe impl Sync`, and
221// nothing about the type ever needed it: every field is already `Send + Sync`
222// (`boxcar::Vec<Node>` where `Node: Send + Sync`, and `PoolIndex`'s
223// `RwLock`-guarded shards).  An unconditional `unsafe impl` on a type that
224// derives the traits anyway is strictly worse than nothing, because it also
225// *silences the check for the future*: add an `Rc`, a `Cell`, or a raw pointer
226// to `ExprPool`, `Node` or `ExprData` and the compiler would have gone on
227// certifying the pool as shareable across rayon workers and across
228// `Python::allow_threads` — the exact boundary the pool is handed over most
229// often.  The static assertion below re-arms that check: it costs nothing at
230// run time and fails the build the moment a non-thread-safe field appears.
231const _: () = {
232    const fn assert_send_sync<T: Send + Sync>() {}
233    assert_send_sync::<ExprPool>();
234    assert_send_sync::<Node>();
235    assert_send_sync::<ExprData>();
236};
237
238/// Largest number of children a flat `Add`/`Mul` may be spliced up to.
239///
240/// Bounds the self-combination blow-up that flat n-ary form introduces — see
241/// the comment in `flatten_assoc`. Set from measurement: the largest honest
242/// arity across this crate's test suite is 50 001, and the runaway shows up as
243/// powers of two from 32 768 upward, so no threshold separates them by size
244/// alone. This sits 2.6x above the honest maximum and still bounds the runaway,
245/// which converges because a declined splice leaves the node nested.
246pub(crate) const MAX_FLAT_ARITY: usize = 131_072;
247
248impl ExprPool {
249    pub fn new() -> Self {
250        ExprPool {
251            nodes: boxcar::Vec::new(),
252            index: PoolIndex::new(),
253        }
254    }
255
256    /// Intern `data`, returning a shared [`ExprId`]. Identical structures
257    /// always return the same id; structural equality ⟺ id equality.
258    ///
259    /// `data` is first brought to its canonical spelling, so that two
260    /// spellings of one value cannot hold two ids:
261    ///
262    /// * a `Rational` whose denominator is `1` is the `Integer` numerator
263    ///   (`rational(4, 2)` *is* `integer(2)`);
264    /// * an `Add` or `Mul` of one argument is that argument, the empty `Add`
265    ///   is `0` and the empty `Mul` is `1`;
266    /// * a `Float` zero or `NaN` is stored with a positive sign.  `BigFloat`'s
267    ///   `Eq` already identifies `-0.0` with `0.0` (and every `NaN` with every
268    ///   other), so without this the node a pool kept would depend on which
269    ///   spelling happened to be interned first.
270    ///
271    /// The result may therefore be a node of a different kind than `data`.
272    /// Every constructor, and the pool-file loader, goes through here.
273    pub fn intern(&self, data: ExprData) -> ExprId {
274        let data = match self.canonical_form(data) {
275            Ok(d) => d,
276            Err(id) => return id,
277        };
278        // `boxcar::push` is lock-free, so it is safe to call while the index
279        // shard's write lock is held.  `data` is moved into the node, not
280        // cloned: the index keeps only the id.
281        let make = |d: ExprData| {
282            let node = self.make_node(d);
283            ExprId(self.nodes.push(node) as u32)
284        };
285        self.index.get_or_insert_with(&self.nodes, data, make)
286    }
287
288    /// The canonical spelling of `data` (see [`ExprPool::intern`]), or
289    /// `Err(id)` when it is already an interned node.
290    fn canonical_form(&self, data: ExprData) -> Result<ExprData, ExprId> {
291        match data {
292            ExprData::Rational(r) if *r.0.denom() == 1 => {
293                Ok(ExprData::Integer(BigInt(r.0.into_numer_denom().0)))
294            }
295            ExprData::Float(mut f)
296                if (f.inner.is_zero() || f.inner.is_nan()) && f.inner.is_sign_negative() =>
297            {
298                f.inner.abs_mut();
299                Ok(ExprData::Float(f))
300            }
301            ExprData::Add(args) if args.len() <= 1 => match args.first() {
302                Some(&only) => Err(only),
303                None => Err(self.integer(0_i32)),
304            },
305            ExprData::Mul(args) if args.len() <= 1 => match args.first() {
306                Some(&only) => Err(only),
307                None => Err(self.integer(1_i32)),
308            },
309            other => Ok(other),
310        }
311    }
312
313    /// Wrap `data` with its cached properties.  Children are already interned,
314    /// so their flags are just array reads.
315    fn make_node(&self, data: ExprData) -> Node {
316        let mult_commutative = self.compute_mult_commutative(&data);
317        let depth = self.compute_depth(&data);
318        let static_domain_fact = self.compute_static_domain_fact(&data);
319        let non_finite = self.compute_non_finite(&data);
320        Node {
321            data,
322            mult_commutative,
323            depth,
324            static_domain_fact,
325            non_finite,
326        }
327    }
328
329    /// One level of the depth recurrence: `1 + max(child depths)`, reading each
330    /// child's cached depth rather than descending into it.
331    fn compute_depth(&self, data: &ExprData) -> u32 {
332        let child = |c: ExprId| self.depth(c);
333        let deepest = match data {
334            ExprData::Symbol { .. }
335            | ExprData::Integer(_)
336            | ExprData::Rational(_)
337            | ExprData::Float(_) => 0,
338            ExprData::Add(args) | ExprData::Mul(args) => {
339                args.iter().copied().map(child).max().unwrap_or(0)
340            }
341            ExprData::Pow { base, exp } => child(*base).max(child(*exp)),
342            ExprData::Func { args, .. } => args.iter().copied().map(child).max().unwrap_or(0),
343            ExprData::Piecewise { branches, default } => branches
344                .iter()
345                .map(|&(c, v)| child(c).max(child(v)))
346                .max()
347                .unwrap_or(0)
348                .max(child(*default)),
349            ExprData::Predicate { args, .. } => args.iter().copied().map(child).max().unwrap_or(0),
350            ExprData::Forall { var, body } | ExprData::Exists { var, body } => {
351                child(*var).max(child(*body))
352            }
353            ExprData::BigO(inner) => child(*inner),
354            ExprData::RootSum { poly, body, .. } => child(*poly).max(child(*body)),
355        };
356        deepest.saturating_add(1)
357    }
358
359    /// One level of the `mult_tree_is_commutative` recurrence, reading each
360    /// child's cached flag rather than descending into it.
361    fn compute_mult_commutative(&self, data: &ExprData) -> bool {
362        let child = |c: ExprId| self.is_mult_commutative(c);
363        match data {
364            ExprData::Symbol { commutative, .. } => *commutative,
365            ExprData::Integer(_) | ExprData::Rational(_) | ExprData::Float(_) => true,
366            ExprData::Add(args) | ExprData::Mul(args) => args.iter().copied().all(child),
367            ExprData::Pow { base, exp } => child(*base) && child(*exp),
368            ExprData::Func { args, .. } => args.iter().copied().all(child),
369            ExprData::Piecewise { branches, default } => {
370                branches.iter().all(|&(c, v)| child(c) && child(v)) && child(*default)
371            }
372            ExprData::Predicate { args, .. } => args.iter().copied().all(child),
373            ExprData::Forall { var, body } | ExprData::Exists { var, body } => {
374                child(*var) && child(*body)
375            }
376            ExprData::BigO(inner) => child(*inner),
377            ExprData::RootSum { poly, body, .. } => child(*poly) && child(*body),
378        }
379    }
380
381    /// Whether every generator in the subtree rooted at `id` commutes under
382    /// multiplication.  O(1): the flag was computed when `id` was interned.
383    pub fn is_mult_commutative(&self, id: ExprId) -> bool {
384        self.node(id).mult_commutative
385    }
386
387    /// Length of the longest root-to-leaf path in the subtree rooted at `id`.
388    ///
389    /// A leaf (symbol or number) has depth 1.  O(1): the value was computed
390    /// when `id` was interned.  Saturates at [`u32::MAX`].
391    ///
392    /// This is the quantity the expression-depth ceiling is applied to: the
393    /// PyO3 entry points compare it against `MAX_EXPR_DEPTH` to decline a tree
394    /// too deep to recurse over — see
395    /// [`crate::kernel::depth::check_expr_depth`].
396    /// One level of the static-domain-fact recurrence, from the children's
397    /// cached flags.  Covers *every* child, piecewise conditions and binder
398    /// variables included, because the fact walk descends into all of them.
399    fn compute_static_domain_fact(&self, data: &ExprData) -> bool {
400        let child = |c: ExprId| self.node(c).static_domain_fact;
401        match data {
402            ExprData::Symbol { domain, .. } => {
403                matches!(domain, Domain::Positive | Domain::NonZero)
404            }
405            ExprData::Integer(_) | ExprData::Rational(_) | ExprData::Float(_) => false,
406            ExprData::Add(args)
407            | ExprData::Mul(args)
408            | ExprData::Func { args, .. }
409            | ExprData::Predicate { args, .. } => args.iter().copied().any(child),
410            ExprData::Pow { base, exp } => child(*base) || child(*exp),
411            ExprData::Piecewise { branches, default } => {
412                branches.iter().any(|&(c, v)| child(c) || child(v)) || child(*default)
413            }
414            ExprData::Forall { var, body } | ExprData::Exists { var, body } => {
415                child(*var) || child(*body)
416            }
417            ExprData::BigO(inner) => child(*inner),
418            ExprData::RootSum { poly, var, body } => child(*poly) || child(*var) || child(*body),
419        }
420    }
421
422    /// One level of the `non_finite` recurrence (every child, bound variables
423    /// included — a flag that over-reports only makes a rule decline).
424    fn compute_non_finite(&self, data: &ExprData) -> bool {
425        let child = |c: ExprId| self.node(c).non_finite;
426        match data {
427            ExprData::Symbol { name, .. } => name == POS_INFINITY_SYMBOL,
428            ExprData::Float(f) => !f.inner.is_finite(),
429            ExprData::Integer(_) | ExprData::Rational(_) => false,
430            ExprData::Add(args)
431            | ExprData::Mul(args)
432            | ExprData::Func { args, .. }
433            | ExprData::Predicate { args, .. } => args.iter().copied().any(child),
434            ExprData::Pow { base, exp } => child(*base) || child(*exp),
435            ExprData::Piecewise { branches, default } => {
436                branches.iter().any(|&(c, v)| child(c) || child(v)) || child(*default)
437            }
438            ExprData::Forall { var, body } | ExprData::Exists { var, body } => {
439                child(*var) || child(*body)
440            }
441            ExprData::BigO(inner) => child(*inner),
442            ExprData::RootSum { poly, var, body } => child(*poly) || child(*var) || child(*body),
443        }
444    }
445
446    /// Whether some node of the subtree rooted at `id` is `∞` or a
447    /// non-finite `Float`.  O(1): computed when `id` was interned.
448    pub(crate) fn has_non_finite(&self, id: ExprId) -> bool {
449        self.node(id).non_finite
450    }
451
452    /// Whether the subtree rooted at `id` contains a symbol whose domain is
453    /// `Positive` or `NonZero`.  O(1): computed when `id` was interned.  When
454    /// it is `false`, static domain-fact collection has nothing to find there.
455    pub(crate) fn has_static_domain_fact(&self, id: ExprId) -> bool {
456        self.node(id).static_domain_fact
457    }
458
459    pub fn depth(&self, id: ExprId) -> u32 {
460        self.node(id).depth
461    }
462
463    fn node(&self, id: ExprId) -> &Node {
464        self.nodes
465            .get(id.0 as usize)
466            .expect("ExprPool: ExprId out of range")
467    }
468
469    /// Borrow a node by id and apply `f` without cloning.  Lock-free.
470    pub fn with<R, F: FnOnce(&ExprData) -> R>(&self, id: ExprId, f: F) -> R {
471        f(&self.node(id).data)
472    }
473
474    /// Clone and return the `ExprData` for `id`.
475    pub fn get(&self, id: ExprId) -> ExprData {
476        self.with(id, |d| d.clone())
477    }
478
479    /// Number of distinct expressions interned so far.  Lock-free.
480    pub fn len(&self) -> usize {
481        self.nodes.count()
482    }
483
484    pub fn is_empty(&self) -> bool {
485        self.nodes.is_empty()
486    }
487
488    // -----------------------------------------------------------------------
489    // Atom constructors
490    // -----------------------------------------------------------------------
491
492    /// Free symbol; multiplication treats it as commuting with every other factor (default).
493    pub fn symbol(&self, name: impl Into<String>, domain: Domain) -> ExprId {
494        self.symbol_commutative(name, domain, true)
495    }
496
497    /// Canonical name of the kernel-blessed imaginary unit `i = √(−1)`.
498    ///
499    /// Reserved: do not create an unrelated free symbol with this name and
500    /// `Domain::Complex` — the simplifier applies the algebraic power rules
501    /// `i² = −1`, `i³ = −i`, `i⁴ = 1`, … to any symbol matching this name and
502    /// domain (see [`ExprPool::is_imaginary_unit`]).
503    pub const IMAGINARY_UNIT_NAME: &'static str = "I";
504
505    /// The first-class imaginary unit `i = √(−1)`.
506    ///
507    /// Represented as the interned, kernel-blessed commuting symbol
508    /// [`IMAGINARY_UNIT_NAME`](Self::IMAGINARY_UNIT_NAME) with
509    /// [`Domain::Complex`]. This is the *canonical* representation: the
510    /// simplifier knows the algebraic identities `i² = −1`, `i³ = −i`,
511    /// `i⁴ = 1`, and more generally `i^(4k+r) → i^r` for literal integer
512    /// exponents (no branch-cut identities — `√(−1) → i`, `log`/`exp` of
513    /// complex arguments etc. are *not* added).
514    ///
515    /// Differentiation treats it as a constant (`d/dx i = 0`, like `π`/`e`)
516    /// and numeric evaluation declines (it has no `f64` value), matching the
517    /// behaviour of other non-real atoms.
518    pub fn imaginary_unit(&self) -> ExprId {
519        self.symbol(Self::IMAGINARY_UNIT_NAME, Domain::Complex)
520    }
521
522    /// Returns `true` iff `id` is the canonical imaginary unit produced by
523    /// [`ExprPool::imaginary_unit`] (an interned `Domain::Complex` symbol named
524    /// [`IMAGINARY_UNIT_NAME`](Self::IMAGINARY_UNIT_NAME)).
525    pub fn is_imaginary_unit(&self, id: ExprId) -> bool {
526        self.with(id, |d| {
527            matches!(
528                d,
529                ExprData::Symbol { name, domain, .. }
530                    if name == Self::IMAGINARY_UNIT_NAME && *domain == Domain::Complex
531            )
532        })
533    }
534
535    /// Free symbol with explicit commutative flag (V3-2). `commutative: false` is for
536    /// matrix or operator generators where `A*B` and `B*A` must remain distinct.
537    pub fn symbol_commutative(
538        &self,
539        name: impl Into<String>,
540        domain: Domain,
541        commutative: bool,
542    ) -> ExprId {
543        self.intern(ExprData::Symbol {
544            name: name.into(),
545            domain,
546            commutative,
547        })
548    }
549
550    pub fn integer(&self, n: impl Into<rug::Integer>) -> ExprId {
551        self.intern(ExprData::Integer(BigInt(n.into())))
552    }
553
554    pub fn rational(
555        &self,
556        numer: impl Into<rug::Integer>,
557        denom: impl Into<rug::Integer>,
558    ) -> ExprId {
559        let r = rug::Rational::from((numer.into(), denom.into()));
560        self.intern(ExprData::Rational(BigRat(r)))
561    }
562
563    pub fn float(&self, value: f64, prec: u32) -> ExprId {
564        let f = rug::Float::with_val(prec, value);
565        self.intern(ExprData::Float(BigFloat { inner: f, prec }))
566    }
567
568    // -----------------------------------------------------------------------
569    // Compound constructors
570    // -----------------------------------------------------------------------
571
572    /// Splice same-operator children into `args`, in argument order.
573    ///
574    /// `mul` passes `want_mul = true` and splices nested `Mul`s; `add` passes
575    /// `false` and splices nested `Add`s.  This is **associativity and nothing
576    /// else** — no reordering beyond the canonical sort the caller applies
577    /// afterwards, no constant folding, no identity elimination.  Splicing an
578    /// empty `Mul`/`Add` child away is likewise value-preserving, since the
579    /// empty product is 1 and the empty sum is 0.
580    ///
581    /// Every node reachable through [`ExprPool::add`] / [`ExprPool::mul`] is
582    /// already flat, so in practice one level is all there is; the loop is
583    /// nevertheless a full fixpoint because `intern` is public and
584    /// [`crate::kernel::pool_persist`] restores whatever shape a file on disk
585    /// holds, including nested nodes written by an older build.  The worklist
586    /// is an explicit `Vec`, not recursion, so no nesting depth can overflow
587    /// the native stack here.
588    fn flatten_assoc(&self, args: Vec<ExprId>, want_mul: bool) -> Vec<ExprId> {
589        /// The children to splice in for `data`, or `None` to keep it whole.
590        fn splices(data: &ExprData, want_mul: bool) -> Option<&Vec<ExprId>> {
591            match data {
592                ExprData::Mul(children) if want_mul => Some(children),
593                ExprData::Add(children) if !want_mul => Some(children),
594                _ => None,
595            }
596        }
597
598        // Hot path: nothing nested, so hand the caller its own vector back
599        // without allocating a second one.
600        if !args
601            .iter()
602            .any(|&a| splices(&self.node(a).data, want_mul).is_some())
603        {
604            return args;
605        }
606
607        // Arity ceiling.  Flat n-ary form removes the sharing that binary
608        // nesting gave for free: `e = pool.mul([e, e])` in a loop used to build
609        // `n` nodes with both children shared, and now *doubles* the child
610        // count, so `n` rounds cost `2^n` children.  Twenty rounds is ~2M
611        // children, twenty-five exhausts memory, and a real test in this repo
612        // hung the suite at forty.
613        //
614        // Declining to splice — rather than refusing — keeps `mul`/`add` total
615        // and infallible, which matters because they are two of the most-called
616        // constructors in the crate and have no `Result` today.  Above the cap
617        // the caller simply gets a nested node back, which is what it would
618        // have got before flattening existed; `simplify` still flattens, so the
619        // canonical form is unchanged for anything that goes through it.  The
620        // doubling loop then converges: once a splice is declined the node stays
621        // nested, so the next round starts from two children again.
622        //
623        // MAX_FLAT_ARITY is set from measurement, not taste.  Instrumenting
624        // `cargo test -p alkahest-cas --lib` put the largest *honest* arity at
625        // 50 001 (a test building one long sum a term at a time); the blow-up
626        // showed up as the powers of two 32 768 … 2 097 152.  The two ranges
627        // overlap, so no threshold separates them by size alone — this one sits
628        // 2.6x above the honest maximum and still bounds the runaway.
629        // The splice is a *fixpoint* — a spliced-in child may itself be an
630        // `Add`/`Mul` and get expanded in turn — so the ceiling has to bound the
631        // final width, not the first level. Checking one level ahead is not
632        // enough and is actively misleading: after a decline the node is a
633        // 2-child nest, so a one-level count reads as 4 while the worklist goes
634        // on to expand the grandchildren to millions.
635        //
636        // Counting as we expand is exact and costs nothing in the common case,
637        // where the loop finishes long before the ceiling is in sight.
638        let mut out = Vec::with_capacity(args.len() + 4);
639        let mut stack: Vec<ExprId> = args.iter().rev().copied().collect();
640        while let Some(id) = stack.pop() {
641            match splices(&self.node(id).data, want_mul) {
642                Some(children) => {
643                    if out.len() + stack.len() + children.len() > MAX_FLAT_ARITY {
644                        // Abandon the splice and hand back the caller's own
645                        // vector untouched. `mul`/`add` stay total: the caller
646                        // gets a nested node, exactly what it would have got
647                        // before flattening existed, and `simplify` still
648                        // flattens later for anything that goes through it.
649                        return args;
650                    }
651                    stack.extend(children.iter().rev().copied());
652                }
653                None => out.push(id),
654            }
655        }
656        out
657    }
658
659    pub fn add(&self, args: Vec<ExprId>) -> ExprId {
660        // Associativity holds structurally: `(a + b) + c` and `a + (b + c)`
661        // both intern as the flat `Add([a, b, c])`.
662        let mut args = self.flatten_assoc(args, false);
663        // Sort children at construction time so that commutativity holds
664        // structurally: `a + b` and `b + a` intern to the same ExprId.
665        // The sort key is the raw ExprId (opaque u32), which gives a stable,
666        // deterministic canonical order.
667        args.sort_unstable();
668        self.intern(ExprData::Add(args))
669    }
670
671    pub fn mul(&self, args: Vec<ExprId>) -> ExprId {
672        // Associativity holds structurally, exactly as for `add`.  Splicing
673        // preserves argument order, so it is sound for the non-commutative
674        // generators of V3-2 as well as the commutative case.
675        let mut args = self.flatten_assoc(args, true);
676        // Canonical sort only when every subtree is multiplicatively commutative (V3-2).
677        let sort_ok = args
678            .iter()
679            .all(|&a| crate::kernel::expr_props::mult_tree_is_commutative(self, a));
680        if sort_ok {
681            args.sort_unstable();
682        }
683        self.intern(ExprData::Mul(args))
684    }
685
686    pub fn pow(&self, base: ExprId, exp: ExprId) -> ExprId {
687        self.intern(ExprData::Pow { base, exp })
688    }
689
690    /// `name(args…)`, unchecked.
691    ///
692    /// The arity of a built-in name is *not* validated here — this is the
693    /// infallible constructor the library itself uses, always at the right
694    /// arity.  Input from outside (a user, a parser, a file) should go through
695    /// [`ExprPool::try_func`], which refuses `sin()` or `EllipticPi(x)`.
696    pub fn func(&self, name: impl Into<String>, args: Vec<ExprId>) -> ExprId {
697        self.intern(ExprData::Func {
698            name: name.into(),
699            args,
700        })
701    }
702
703    /// `name(args…)`, refusing a built-in function name at the wrong arity
704    /// (see [`crate::kernel::func_arity::known_func_arity`]) with
705    /// [`FuncArityError`](crate::kernel::FuncArityError) (`E-POOL-002`).
706    /// Names outside the built-in table (user functions) take any arity.
707    pub fn try_func(
708        &self,
709        name: impl Into<String>,
710        args: Vec<ExprId>,
711    ) -> Result<ExprId, crate::kernel::FuncArityError> {
712        let name = name.into();
713        crate::kernel::FuncArityError::check(&name, args.len())?;
714        Ok(self.func(name, args))
715    }
716
717    // -----------------------------------------------------------------------
718    // PA-9 — Piecewise / Predicate constructors
719    // -----------------------------------------------------------------------
720
721    /// Build a `Piecewise` expression.
722    ///
723    /// Branches are `(cond, value)` pairs where `cond` must be a
724    /// `Predicate` node.  The `default` value is used when no condition
725    /// matches.
726    pub fn piecewise(&self, branches: Vec<(ExprId, ExprId)>, default: ExprId) -> ExprId {
727        self.intern(ExprData::Piecewise { branches, default })
728    }
729
730    /// Build a `Predicate` node (symbolic boolean condition).
731    pub fn predicate(&self, kind: crate::kernel::expr::PredicateKind, args: Vec<ExprId>) -> ExprId {
732        self.intern(ExprData::Predicate { kind, args })
733    }
734
735    // Convenience constructors for common predicates.
736    pub fn pred_lt(&self, a: ExprId, b: ExprId) -> ExprId {
737        self.predicate(crate::kernel::expr::PredicateKind::Lt, vec![a, b])
738    }
739    pub fn pred_le(&self, a: ExprId, b: ExprId) -> ExprId {
740        self.predicate(crate::kernel::expr::PredicateKind::Le, vec![a, b])
741    }
742    pub fn pred_gt(&self, a: ExprId, b: ExprId) -> ExprId {
743        self.predicate(crate::kernel::expr::PredicateKind::Gt, vec![a, b])
744    }
745    pub fn pred_ge(&self, a: ExprId, b: ExprId) -> ExprId {
746        self.predicate(crate::kernel::expr::PredicateKind::Ge, vec![a, b])
747    }
748    pub fn pred_eq(&self, a: ExprId, b: ExprId) -> ExprId {
749        self.predicate(crate::kernel::expr::PredicateKind::Eq, vec![a, b])
750    }
751    pub fn pred_ne(&self, a: ExprId, b: ExprId) -> ExprId {
752        self.predicate(crate::kernel::expr::PredicateKind::Ne, vec![a, b])
753    }
754    pub fn pred_and(&self, args: Vec<ExprId>) -> ExprId {
755        self.predicate(crate::kernel::expr::PredicateKind::And, args)
756    }
757    pub fn pred_or(&self, args: Vec<ExprId>) -> ExprId {
758        self.predicate(crate::kernel::expr::PredicateKind::Or, args)
759    }
760    pub fn pred_not(&self, a: ExprId) -> ExprId {
761        self.predicate(crate::kernel::expr::PredicateKind::Not, vec![a])
762    }
763    pub fn pred_true(&self) -> ExprId {
764        self.predicate(crate::kernel::expr::PredicateKind::True, vec![])
765    }
766    pub fn pred_false(&self) -> ExprId {
767        self.predicate(crate::kernel::expr::PredicateKind::False, vec![])
768    }
769
770    // V3-3 — first-order quantifiers (first-class `Formula` / FOFormula).
771    /// `∀ var . body`
772    pub fn forall(&self, var: ExprId, body: ExprId) -> ExprId {
773        self.intern(ExprData::Forall { var, body })
774    }
775
776    /// `∃ var . body`
777    pub fn exists(&self, var: ExprId, body: ExprId) -> ExprId {
778        self.intern(ExprData::Exists { var, body })
779    }
780
781    /// `Σ_{c : poly(c)=0} body[var := c]` — a sum over the roots of `poly`.
782    pub fn root_sum(&self, poly: ExprId, var: ExprId, body: ExprId) -> ExprId {
783        self.intern(ExprData::RootSum { poly, var, body })
784    }
785
786    /// `O(arg)` — symbolic big-O bound used in truncated series (V2-15).
787    pub fn big_o(&self, arg: ExprId) -> ExprId {
788        self.intern(ExprData::BigO(arg))
789    }
790
791    /// Canonical `+∞` symbol for limits at infinity (V2-16).
792    pub fn pos_infinity(&self) -> ExprId {
793        self.symbol(POS_INFINITY_SYMBOL, Domain::Positive)
794    }
795
796    // -----------------------------------------------------------------------
797    // Display helper
798    // -----------------------------------------------------------------------
799
800    pub fn display(&self, id: ExprId) -> ExprDisplay<'_> {
801        ExprDisplay { id, pool: self }
802    }
803}
804
805impl Default for ExprPool {
806    fn default() -> Self {
807        Self::new()
808    }
809}
810
811// ---------------------------------------------------------------------------
812// Display — pool-aware recursive formatter
813// ---------------------------------------------------------------------------
814
815/// Wraps an `(ExprId, &ExprPool)` pair so it can implement [`fmt::Display`].
816pub struct ExprDisplay<'a> {
817    pub id: ExprId,
818    pub pool: &'a ExprPool,
819}
820
821impl fmt::Display for ExprDisplay<'_> {
822    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
823        let data = self.pool.get(self.id);
824        fmt_data(&data, self.pool, f)
825    }
826}
827
828impl fmt::Debug for ExprDisplay<'_> {
829    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
830        write!(f, "{}", self)
831    }
832}
833
834/// Format a power base or exponent, parenthesizing compound subexpressions.
835///
836/// `Add`/`Mul` already render with outer parentheses, so wrapping again would
837/// produce `((z + -2))^-1`. Only wrap forms that do not already self-group.
838fn fmt_pow_atom(id: ExprId, pool: &ExprPool) -> String {
839    let s = pool.display(id).to_string();
840    let needs_parens = match pool.get(id) {
841        ExprData::Symbol { .. } | ExprData::Integer(_) | ExprData::Float(_) => false,
842        ExprData::Func { .. } => false,
843        // Already printed as `(…)` by fmt_data.
844        ExprData::Add(_) | ExprData::Mul(_) => false,
845        ExprData::Rational(_)
846        | ExprData::Pow { .. }
847        | ExprData::Piecewise { .. }
848        | ExprData::Predicate { .. }
849        | ExprData::Forall { .. }
850        | ExprData::Exists { .. }
851        | ExprData::BigO(_)
852        | ExprData::RootSum { .. } => true,
853    };
854    if needs_parens {
855        format!("({s})")
856    } else {
857        s
858    }
859}
860
861/// Format a power *base*.
862///
863/// Everything [`fmt_pow_atom`] wraps needs wrapping here too, plus any literal
864/// that renders with a leading `-`: unary minus binds looser than `^` in this
865/// crate's own parser (`BP_UNARY` < `BP_POW` in `parse.rs`), in Python and in
866/// sympy, so `-1^n` re-reads as `-(1^n)`.  Only `(-1)^n` round-trips.
867///
868/// The exponent side deliberately keeps the bare form (`x^-1`): `^` is
869/// right-associative and unary minus binds looser than it, so a `-` there is
870/// already unambiguous.
871fn fmt_pow_base(id: ExprId, pool: &ExprPool) -> String {
872    let s = fmt_pow_atom(id, pool);
873    if s.starts_with('-') {
874        format!("({s})")
875    } else {
876        s
877    }
878}
879
880fn fmt_data(data: &ExprData, pool: &ExprPool, f: &mut fmt::Formatter<'_>) -> fmt::Result {
881    match data {
882        ExprData::Symbol { name, .. } => write!(f, "{}", name),
883        ExprData::Integer(n) => write!(f, "{}", n),
884        ExprData::Rational(r) => write!(f, "{}", r),
885        ExprData::Float(fl) => write!(f, "{}", fl),
886        ExprData::Add(args) => {
887            write!(f, "(")?;
888            for (i, &arg) in args.iter().enumerate() {
889                if i > 0 {
890                    write!(f, " + ")?;
891                }
892                write!(f, "{}", pool.display(arg))?;
893            }
894            write!(f, ")")
895        }
896        ExprData::Mul(args) => {
897            write!(f, "(")?;
898            for (i, &arg) in args.iter().enumerate() {
899                if i > 0 {
900                    write!(f, " * ")?;
901                }
902                write!(f, "{}", pool.display(arg))?;
903            }
904            write!(f, ")")
905        }
906        ExprData::Pow { base, exp } => {
907            // Parenthesize compound bases/exponents so `x^(1/2)^3` cannot be
908            // misread as `x^1 / 2^3`. Prefer `(x^(1/2))^3`.
909            let base_s = fmt_pow_base(*base, pool);
910            let exp_s = fmt_pow_atom(*exp, pool);
911            write!(f, "{base_s}^{exp_s}")
912        }
913        ExprData::Func { name, args } => {
914            write!(f, "{}(", name)?;
915            for (i, &arg) in args.iter().enumerate() {
916                if i > 0 {
917                    write!(f, ", ")?;
918                }
919                write!(f, "{}", pool.display(arg))?;
920            }
921            write!(f, ")")
922        }
923        ExprData::Piecewise { branches, default } => {
924            write!(f, "Piecewise(")?;
925            for (i, (cond, val)) in branches.iter().enumerate() {
926                if i > 0 {
927                    write!(f, ", ")?;
928                }
929                write!(f, "({}, {})", pool.display(*cond), pool.display(*val))?;
930            }
931            write!(f, "; default={})", pool.display(*default))
932        }
933        ExprData::Predicate { kind, args } => match kind {
934            crate::kernel::expr::PredicateKind::True => write!(f, "True"),
935            crate::kernel::expr::PredicateKind::False => write!(f, "False"),
936            crate::kernel::expr::PredicateKind::Not if args.len() == 1 => {
937                write!(f, "¬({})", pool.display(args[0]))
938            }
939            crate::kernel::expr::PredicateKind::And | crate::kernel::expr::PredicateKind::Or => {
940                write!(f, "(")?;
941                for (i, &arg) in args.iter().enumerate() {
942                    if i > 0 {
943                        write!(f, " {} ", kind)?;
944                    }
945                    write!(f, "{}", pool.display(arg))?;
946                }
947                write!(f, ")")
948            }
949            _ if args.len() == 2 => {
950                write!(
951                    f,
952                    "({} {} {})",
953                    pool.display(args[0]),
954                    kind,
955                    pool.display(args[1])
956                )
957            }
958            // A comparison or `Not` with the wrong operand count — only the
959            // raw `predicate` constructor can build one.  Print it rather
960            // than index past the end.
961            _ => {
962                write!(f, "{kind}(")?;
963                for (i, &arg) in args.iter().enumerate() {
964                    if i > 0 {
965                        write!(f, ", ")?;
966                    }
967                    write!(f, "{}", pool.display(arg))?;
968                }
969                write!(f, ")")
970            }
971        },
972        ExprData::Forall { var, body } => {
973            write!(f, "∀ {} . {}", pool.display(*var), pool.display(*body))
974        }
975        ExprData::Exists { var, body } => {
976            write!(f, "∃ {} . {}", pool.display(*var), pool.display(*body))
977        }
978        ExprData::BigO(arg) => {
979            write!(f, "O({})", pool.display(*arg))
980        }
981        ExprData::RootSum { poly, var, body } => {
982            write!(
983                f,
984                "RootSum({}, {} . {})",
985                pool.display(*poly),
986                pool.display(*var),
987                pool.display(*body)
988            )
989        }
990    }
991}
992
993// ---------------------------------------------------------------------------
994// Unit tests
995// ---------------------------------------------------------------------------
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000    use crate::kernel::domain::Domain;
1001
1002    fn pool() -> ExprPool {
1003        ExprPool::new()
1004    }
1005
1006    #[test]
1007    fn noncommutative_mul_orders_distinct() {
1008        let p = pool();
1009        let a = p.symbol_commutative("A", Domain::Real, false);
1010        let b = p.symbol_commutative("B", Domain::Real, false);
1011        assert_ne!(
1012            p.mul(vec![a, b]),
1013            p.mul(vec![b, a]),
1014            "A*B and B*A must not hash-cons together for NC symbols"
1015        );
1016    }
1017
1018    #[test]
1019    fn symbol_commutative_is_structural() {
1020        let p = pool();
1021        let xc = p.symbol_commutative("x", Domain::Real, true);
1022        let xnc = p.symbol_commutative("x", Domain::Real, false);
1023        assert_ne!(xc, xnc);
1024    }
1025
1026    // --- construction and equality ---
1027
1028    #[test]
1029    fn symbol_interning() {
1030        let p = pool();
1031        let x1 = p.symbol("x", Domain::Real);
1032        let x2 = p.symbol("x", Domain::Real);
1033        assert_eq!(x1, x2, "same symbol must return same ExprId");
1034    }
1035
1036    #[test]
1037    fn domain_is_structural() {
1038        let p = pool();
1039        let xr = p.symbol("x", Domain::Real);
1040        let xc = p.symbol("x", Domain::Complex);
1041        assert_ne!(xr, xc, "same name but different domain must be distinct");
1042    }
1043
1044    #[test]
1045    fn integer_interning() {
1046        let p = pool();
1047        let a = p.integer(42_i32);
1048        let b = p.integer(42_i32);
1049        let c = p.integer(99_i32);
1050        assert_eq!(a, b);
1051        assert_ne!(a, c);
1052    }
1053
1054    #[test]
1055    fn rational_canonical() {
1056        let p = pool();
1057        // 2/4 reduces to 1/2
1058        let r1 = p.rational(2_i32, 4_i32);
1059        let r2 = p.rational(1_i32, 2_i32);
1060        assert_eq!(r1, r2, "rationals must be reduced to canonical form");
1061    }
1062
1063    /// One value, one id: a `Rational` with denominator 1 *is* the integer.
1064    #[test]
1065    fn rational_with_unit_denominator_is_the_integer() {
1066        let p = pool();
1067        assert_eq!(p.rational(4_i32, 2_i32), p.integer(2_i32));
1068        assert_eq!(p.rational(-6_i32, 3_i32), p.integer(-2_i32));
1069        assert_eq!(p.rational(0_i32, 5_i32), p.integer(0_i32));
1070        assert!(matches!(
1071            p.get(p.rational(4_i32, 2_i32)),
1072            ExprData::Integer(_)
1073        ));
1074        // Also through the raw `intern`, which the pool-file loader uses.
1075        let raw = p.intern(ExprData::Rational(BigRat(rug::Rational::from(7))));
1076        assert_eq!(raw, p.integer(7_i32));
1077    }
1078
1079    /// `Add`/`Mul` of one argument is that argument; of none, the identity.
1080    #[test]
1081    fn unary_and_empty_sums_and_products_collapse() {
1082        let p = pool();
1083        let x = p.symbol("x", Domain::Real);
1084        assert_eq!(p.add(vec![x]), x);
1085        assert_eq!(p.mul(vec![x]), x);
1086        assert_eq!(p.add(vec![]), p.integer(0_i32));
1087        assert_eq!(p.mul(vec![]), p.integer(1_i32));
1088        assert_eq!(p.add(vec![p.add(vec![x])]), x);
1089        assert_eq!(p.intern(ExprData::Mul(vec![x])), x);
1090        assert_eq!(p.intern(ExprData::Add(vec![])), p.integer(0_i32));
1091        // A non-commutative generator alone is still just itself.
1092        let a = p.symbol_commutative("A", Domain::Real, false);
1093        assert_eq!(p.mul(vec![a]), a);
1094        assert_eq!(p.display(p.add(vec![])).to_string(), "0");
1095        assert_eq!(p.display(p.mul(vec![])).to_string(), "1");
1096    }
1097
1098    /// `-0.0` and `0.0` are one node (as `BigFloat`'s `Eq` already says), and
1099    /// the node kept is `+0.0` whichever spelling is interned first.
1100    #[test]
1101    fn signed_zero_floats_are_one_node_stored_positive() {
1102        let p = pool();
1103        let neg = p.float(-0.0, 53);
1104        let pos = p.float(0.0, 53);
1105        assert_eq!(neg, pos);
1106        match p.get(neg) {
1107            ExprData::Float(f) => assert!(f.inner.is_zero() && !f.inner.is_sign_negative()),
1108            other => panic!("{other:?}"),
1109        }
1110        assert_eq!(p.display(neg).to_string(), "0.0");
1111        let n1 = p.float(f64::NAN, 53);
1112        let n2 = p.float(-f64::NAN, 53);
1113        assert_eq!(n1, n2);
1114    }
1115
1116    /// A `Float` never prints as something that reads back as an `Integer`.
1117    #[test]
1118    fn float_display_is_never_integer_shaped() {
1119        let p = pool();
1120        for (v, prec) in [
1121            (0.0, 53),
1122            (1.0, 53),
1123            (2.0_f64.powi(60), 53),
1124            (3.0, 64),
1125            (0.0, 200),
1126        ] {
1127            let s = p.display(p.float(v, prec)).to_string();
1128            assert!(s.contains(['.', 'e']), "{v} at {prec} printed as {s}");
1129            match p.get(crate::parse::parse(&s, &p, &mut Default::default()).unwrap()) {
1130                ExprData::Float(_) => {}
1131                other => panic!("{s} re-parsed as {other:?}"),
1132            }
1133        }
1134    }
1135
1136    #[test]
1137    fn float_precision_is_structural() {
1138        let p = pool();
1139        let f53 = p.float(1.0, 53);
1140        let f64_ = p.float(1.0, 64);
1141        assert_ne!(
1142            f53, f64_,
1143            "same value but different precision is a different expr"
1144        );
1145    }
1146
1147    // --- compound expressions and subexpression sharing ---
1148
1149    #[test]
1150    fn subexpression_sharing() {
1151        let p = pool();
1152        let x = p.symbol("x", Domain::Real);
1153        let two = p.integer(2_i32);
1154
1155        // Build x^2 twice; both must return the same ExprId.
1156        let xsq1 = p.pow(x, two);
1157        let xsq2 = p.pow(x, two);
1158        assert_eq!(xsq1, xsq2);
1159
1160        // Pool should have exactly 3 nodes: x, 2, x^2.
1161        assert_eq!(p.len(), 3);
1162    }
1163
1164    #[test]
1165    fn add_interning() {
1166        let p = pool();
1167        let x = p.symbol("x", Domain::Real);
1168        let y = p.symbol("y", Domain::Real);
1169        let s1 = p.add(vec![x, y]);
1170        let s2 = p.add(vec![x, y]);
1171        assert_eq!(s1, s2);
1172    }
1173
1174    #[test]
1175    fn arg_order_is_canonical() {
1176        // PA-3: Add/Mul children are sorted at construction time so that
1177        // commutativity holds structurally — a+b and b+a intern to the same ExprId.
1178        let p = pool();
1179        let x = p.symbol("x", Domain::Real);
1180        let y = p.symbol("y", Domain::Real);
1181        let s1 = p.add(vec![x, y]);
1182        let s2 = p.add(vec![y, x]);
1183        assert_eq!(s1, s2, "a+b and b+a must be the same expression after PA-3");
1184        let m1 = p.mul(vec![x, y]);
1185        let m2 = p.mul(vec![y, x]);
1186        assert_eq!(m1, m2, "a*b and b*a must be the same expression after PA-3");
1187    }
1188
1189    #[test]
1190    fn func_interning() {
1191        let p = pool();
1192        let x = p.symbol("x", Domain::Real);
1193        let s1 = p.func("sin", vec![x]);
1194        let s2 = p.func("sin", vec![x]);
1195        let c1 = p.func("cos", vec![x]);
1196        assert_eq!(s1, s2);
1197        assert_ne!(s1, c1);
1198    }
1199
1200    // --- associativity: Add/Mul are flat at construction ---
1201
1202    /// Read a node's `Mul`/`Add` children, or panic if it is neither.
1203    fn nary_args(p: &ExprPool, id: ExprId) -> Vec<ExprId> {
1204        p.with(id, |d| match d {
1205            ExprData::Add(a) | ExprData::Mul(a) => a.clone(),
1206            other => panic!("expected an Add or Mul, got {other:?}"),
1207        })
1208    }
1209
1210    #[test]
1211    fn mul_splices_nested_children() {
1212        let p = pool();
1213        let x = p.symbol("x", Domain::Real);
1214        let y = p.symbol("y", Domain::Real);
1215        let z = p.symbol("z", Domain::Real);
1216
1217        let flat = p.mul(vec![x, y, z]);
1218        let left = p.mul(vec![p.mul(vec![x, y]), z]); // (x·y)·z
1219        let right = p.mul(vec![x, p.mul(vec![y, z])]); // x·(y·z)
1220
1221        assert_eq!(left, flat, "(x*y)*z must intern as the flat x*y*z");
1222        assert_eq!(right, flat, "x*(y*z) must intern as the flat x*y*z");
1223        assert_eq!(nary_args(&p, flat).len(), 3);
1224    }
1225
1226    #[test]
1227    fn add_splices_nested_children() {
1228        let p = pool();
1229        let x = p.symbol("x", Domain::Real);
1230        let y = p.symbol("y", Domain::Real);
1231        let z = p.symbol("z", Domain::Real);
1232
1233        let flat = p.add(vec![x, y, z]);
1234        assert_eq!(p.add(vec![p.add(vec![x, y]), z]), flat);
1235        assert_eq!(p.add(vec![x, p.add(vec![y, z])]), flat);
1236        assert_eq!(nary_args(&p, flat).len(), 3);
1237    }
1238
1239    /// Splicing is per-operator: an `Add` inside a `Mul` (and vice versa) is a
1240    /// different operator and must be left alone, or the value would change.
1241    #[test]
1242    fn splicing_does_not_cross_operators() {
1243        let p = pool();
1244        let x = p.symbol("x", Domain::Real);
1245        let y = p.symbol("y", Domain::Real);
1246        let z = p.symbol("z", Domain::Real);
1247
1248        let sum = p.add(vec![x, y]);
1249        let prod = p.mul(vec![sum, z]); // (x + y)·z  — must stay a 2-factor Mul
1250        assert_eq!(nary_args(&p, prod).len(), 2);
1251        assert_ne!(prod, p.mul(vec![x, y, z]));
1252
1253        let prod2 = p.mul(vec![x, y]);
1254        let sum2 = p.add(vec![prod2, z]); // x·y + z
1255        assert_eq!(nary_args(&p, sum2).len(), 2);
1256        assert_ne!(sum2, p.add(vec![x, y, z]));
1257    }
1258
1259    /// Splicing preserves argument order, so it is sound for the V3-2
1260    /// non-commutative generators, which are never sorted.
1261    #[test]
1262    fn splicing_preserves_order_for_noncommutative_generators() {
1263        let p = pool();
1264        let a = p.symbol_commutative("A", Domain::Real, false);
1265        let b = p.symbol_commutative("B", Domain::Real, false);
1266        let c = p.symbol_commutative("C", Domain::Real, false);
1267
1268        let flat = p.mul(vec![a, b, c]);
1269        assert_eq!(p.mul(vec![p.mul(vec![a, b]), c]), flat);
1270        assert_eq!(p.mul(vec![a, p.mul(vec![b, c])]), flat);
1271        assert_eq!(p.display(flat).to_string(), "(A * B * C)");
1272
1273        // Associativity only: A·B·C and B·A·C are still distinct expressions.
1274        assert_ne!(flat, p.mul(vec![b, a, c]));
1275    }
1276
1277    /// Flattening is a fixpoint, and it runs on an explicit worklist rather
1278    /// than the native stack.  `intern` is public and `pool_persist` restores
1279    /// whatever a file holds, so a genuinely nested chain can still reach the
1280    /// constructors; a 50 000-deep one must splice without overflowing.
1281    #[test]
1282    fn splicing_a_deeply_nested_chain_does_not_recurse() {
1283        const N: i64 = 50_000;
1284        let p = pool();
1285        let x = p.symbol("x", Domain::Real);
1286        let mut acc = x;
1287        for i in 0..N {
1288            let k = p.integer(i);
1289            // Deliberately bypass `add` so the chain really is nested.
1290            acc = p.intern(ExprData::Add(vec![acc, k]));
1291        }
1292        assert_eq!(p.depth(acc), N as u32 + 1);
1293
1294        let flat = p.add(vec![acc]);
1295        assert_eq!(p.depth(flat), 2);
1296        assert_eq!(nary_args(&p, flat).len(), N as usize + 1);
1297    }
1298
1299    /// The empty product is 1 and the empty sum is 0 — and since `intern`
1300    /// canonicalises them to exactly those literals, no empty same-operator
1301    /// child is left to splice: `x · Mul([])` is the ordinary `x · 1`, which
1302    /// `simplify` (not construction) reduces.
1303    #[test]
1304    fn empty_same_operator_children_are_the_identity_literals() {
1305        let p = pool();
1306        let x = p.symbol("x", Domain::Real);
1307        let empty_mul = p.mul(vec![]);
1308        assert_eq!(empty_mul, p.integer(1_i32));
1309        assert_eq!(p.mul(vec![x, empty_mul]), p.mul(vec![x, p.integer(1_i32)]));
1310        let empty_add = p.add(vec![]);
1311        assert_eq!(empty_add, p.integer(0_i32));
1312        assert_eq!(p.add(vec![x, empty_add]), p.add(vec![x, p.integer(0_i32)]));
1313    }
1314
1315    /// Flattening only ever *increases* sharing: the three spellings of a
1316    /// three-factor product are now one node, not three.
1317    #[test]
1318    fn flattening_improves_hash_consing() {
1319        let p = pool();
1320        let x = p.symbol("x", Domain::Real);
1321        let y = p.symbol("y", Domain::Real);
1322        let z = p.symbol("z", Domain::Real);
1323        let before = p.len();
1324        p.mul(vec![p.mul(vec![x, y]), z]);
1325        p.mul(vec![x, p.mul(vec![y, z])]);
1326        p.mul(vec![x, y, z]);
1327        // Two intermediate pairs (x*y, y*z) plus the single shared flat node.
1328        assert_eq!(p.len() - before, 3);
1329    }
1330
1331    // --- display ---
1332
1333    #[test]
1334    fn display_symbol() {
1335        let p = pool();
1336        let x = p.symbol("x", Domain::Real);
1337        assert_eq!(p.display(x).to_string(), "x");
1338    }
1339
1340    #[test]
1341    fn display_integer() {
1342        let p = pool();
1343        let n = p.integer(42_i32);
1344        assert_eq!(p.display(n).to_string(), "42");
1345    }
1346
1347    #[test]
1348    fn display_pow() {
1349        let p = pool();
1350        let x = p.symbol("x", Domain::Real);
1351        let two = p.integer(2_i32);
1352        let xsq = p.pow(x, two);
1353        assert_eq!(p.display(xsq).to_string(), "x^2");
1354    }
1355
1356    #[test]
1357    fn display_add() {
1358        let p = pool();
1359        let x = p.symbol("x", Domain::Real);
1360        let y = p.symbol("y", Domain::Real);
1361        let s = p.add(vec![x, y]);
1362        assert_eq!(p.display(s).to_string(), "(x + y)");
1363    }
1364
1365    #[test]
1366    fn display_func() {
1367        let p = pool();
1368        let x = p.symbol("x", Domain::Real);
1369        let s = p.func("sin", vec![x]);
1370        assert_eq!(p.display(s).to_string(), "sin(x)");
1371    }
1372
1373    #[test]
1374    fn display_nested() {
1375        let p = pool();
1376        let x = p.symbol("x", Domain::Real);
1377        let two = p.integer(2_i32);
1378        let xsq = p.pow(x, two);
1379        let one = p.integer(1_i32);
1380        let expr = p.add(vec![xsq, one]);
1381        assert_eq!(p.display(expr).to_string(), "(x^2 + 1)");
1382    }
1383
1384    /// A negative power base must be parenthesised: `-1^n` re-reads as
1385    /// `-(1^n)` in this crate's own parser, in Python and in sympy.
1386    #[test]
1387    fn display_negative_pow_base_is_parenthesised() {
1388        let p = pool();
1389        let n = p.symbol("n", Domain::Real);
1390        let m1 = p.integer(-1_i32);
1391        assert_eq!(p.display(p.pow(m1, n)).to_string(), "(-1)^n");
1392        let m2 = p.integer(-2_i32);
1393        assert_eq!(p.display(p.pow(m2, n)).to_string(), "(-2)^n");
1394        let half = p.rational(-1, 2);
1395        assert_eq!(p.display(p.pow(half, n)).to_string(), "(-1/2)^n");
1396        // …including under a negative exponent, whose bare `-` is unambiguous.
1397        let m3 = p.integer(-3_i32);
1398        assert_eq!(p.display(p.pow(m2, m3)).to_string(), "(-2)^-3");
1399        // …and inside a product, the `b(n) = -16 * (-2)^n` boundary shape.
1400        // `mul` orders its arguments canonically, hence the factor order here.
1401        let m16 = p.integer(-16_i32);
1402        let prod = p.mul(vec![m16, p.pow(m2, n)]);
1403        assert_eq!(p.display(prod).to_string(), "((-2)^n * -16)");
1404    }
1405
1406    /// A non-negative atom keeps the bare form — no gratuitous parentheses.
1407    #[test]
1408    fn display_positive_pow_base_is_bare() {
1409        let p = pool();
1410        let n = p.symbol("n", Domain::Real);
1411        let two = p.integer(2_i32);
1412        assert_eq!(p.display(p.pow(two, n)).to_string(), "2^n");
1413        let x = p.symbol("x", Domain::Real);
1414        assert_eq!(p.display(p.pow(x, n)).to_string(), "x^n");
1415    }
1416
1417    // --- send + sync: compile-time check ---
1418
1419    fn assert_send_sync<T: Send + Sync>() {}
1420
1421    #[test]
1422    fn pool_is_send_sync() {
1423        assert_send_sync::<ExprPool>();
1424    }
1425}
1426
1427#[cfg(test)]
1428mod flat_arity_cap_tests {
1429    use super::*;
1430    use crate::kernel::Domain;
1431
1432    /// The blow-up this cap exists for: flat n-ary form removes the sharing
1433    /// binary nesting gave for free, so `e = e * e` *doubles* the child count.
1434    /// Forty rounds is 2^40 children — a real test in this repo hung the whole
1435    /// lib suite on exactly this shape. It must terminate, quickly.
1436    #[test]
1437    fn self_combination_terminates_instead_of_doubling_forever() {
1438        let pool = ExprPool::new();
1439        let x = pool.symbol("x", Domain::Real);
1440        // 22 rounds, not 40: doubling from 2 children crosses the cap at round
1441        // 17, so this exercises the decline *and* the reset that follows it.
1442        // Rounds beyond that only re-run the same two phases, and each round
1443        // near the cap sorts and interns ~131k children — in a debug build that
1444        // is minutes of nothing new.
1445        let mut e = pool.mul(vec![x, pool.integer(2_i32)]);
1446        for _ in 0..22 {
1447            e = pool.mul(vec![e, e]);
1448        }
1449        // Reaching here at all is the assertion. Past the cap the splice is
1450        // declined and the node stays nested, so the loop converges rather
1451        // than growing: the result is small, not astronomically wide.
1452        let width = match &pool.get(e) {
1453            ExprData::Mul(children) => children.len(),
1454            _ => 1,
1455        };
1456        assert!(
1457            width <= MAX_FLAT_ARITY,
1458            "a declined splice must leave a bounded node, got {width} children"
1459        );
1460    }
1461
1462    /// The cap must not fire on honest work. The measured maximum across this
1463    /// crate's suite is 50 001 children; a sum well past that still flattens.
1464    #[test]
1465    fn a_large_honest_sum_still_flattens() {
1466        let pool = ExprPool::new();
1467        let x = pool.symbol("x", Domain::Real);
1468        let _ = x;
1469        let terms: Vec<_> = (0..60_000).map(|i| pool.integer(i)).collect();
1470        let sum = pool.add(terms);
1471        let ExprData::Add(children) = &pool.get(sum) else {
1472            panic!("expected a flat Add");
1473        };
1474        assert!(
1475            children.len() >= 59_000,
1476            "a 60k-term sum must stay flat, got {} children",
1477            children.len()
1478        );
1479    }
1480
1481    /// Ordinary nesting is unaffected — the cap is not a behaviour change for
1482    /// anything of a realistic size.
1483    #[test]
1484    fn ordinary_nesting_still_splices() {
1485        let pool = ExprPool::new();
1486        let x = pool.symbol("x", Domain::Real);
1487        let y = pool.symbol("y", Domain::Real);
1488        let z = pool.symbol("z", Domain::Real);
1489        let inner = pool.mul(vec![x, y]);
1490        assert_eq!(pool.mul(vec![inner, z]), pool.mul(vec![x, y, z]));
1491    }
1492}
1493
1494#[cfg(test)]
1495mod intern_index_tests {
1496    use super::*;
1497    use crate::kernel::Domain;
1498    use std::sync::Barrier;
1499
1500    /// Every thread interns the same values, in a different order, all
1501    /// starting at once.  Hash-consing must still give one id per value.
1502    #[test]
1503    fn concurrent_intern_of_the_same_values_agrees() {
1504        const THREADS: usize = 8;
1505        const N: i64 = 2_000;
1506        let pool = ExprPool::new();
1507        let x = pool.symbol("x", Domain::Real);
1508        let before = pool.len();
1509        let barrier = Barrier::new(THREADS);
1510        let per_thread: Vec<Vec<ExprId>> = std::thread::scope(|s| {
1511            let handles: Vec<_> = (0..THREADS)
1512                .map(|t| {
1513                    let (pool, barrier) = (&pool, &barrier);
1514                    s.spawn(move || {
1515                        barrier.wait();
1516                        // Visit 0..N in a thread-specific order (stride coprime
1517                        // to N), but record ids by value so they line up.
1518                        let stride = [1, 3, 7, 9, 11, 13, 17, 19][t];
1519                        let mut ids = vec![ExprId(u32::MAX); 4 * N as usize];
1520                        for j in 0..N {
1521                            let k = (j * stride) % N;
1522                            let i = k as usize;
1523                            ids[4 * i] = pool.integer(k - N / 2);
1524                            ids[4 * i + 1] = pool.rational(k, 7);
1525                            ids[4 * i + 2] = pool.float(k as f64 * 0.25, 53);
1526                            ids[4 * i + 3] = pool.add(vec![x, pool.integer(k + N)]);
1527                        }
1528                        ids
1529                    })
1530                })
1531                .collect();
1532            handles.into_iter().map(|h| h.join().unwrap()).collect()
1533        });
1534        for ids in &per_thread[1..] {
1535            assert_eq!(ids, &per_thread[0], "threads disagree on an id");
1536        }
1537        // Distinct values created: N integers in [-N/2, N/2), N rationals
1538        // k/7 (the multiples of 7 are integers already present, except those
1539        // outside [-N/2, N/2)), N floats, N integers k+N, N sums.
1540        let serial = ExprPool::new();
1541        let sx = serial.symbol("x", Domain::Real);
1542        let serial_before = serial.len();
1543        for k in 0..N {
1544            serial.integer(k - N / 2);
1545            serial.rational(k, 7);
1546            serial.float(k as f64 * 0.25, 53);
1547            serial.add(vec![sx, serial.integer(k + N)]);
1548        }
1549        assert_eq!(
1550            pool.len() - before,
1551            serial.len() - serial_before,
1552            "a racing intern created a duplicate node"
1553        );
1554        // And every id resolves back to the data that produced it.
1555        for k in 0..N {
1556            let i = 4 * k as usize;
1557            assert_eq!(
1558                pool.get(per_thread[0][i]),
1559                ExprData::Integer(crate::kernel::expr::BigInt(rug::Integer::from(k - N / 2)))
1560            );
1561        }
1562    }
1563
1564    /// Intern through the index with a caller-chosen hash, exactly as
1565    /// `ExprPool::intern` does with the real one.
1566    fn intern_with_hash(pool: &ExprPool, hash: u64, data: ExprData) -> ExprId {
1567        pool.index
1568            .get_or_insert_hashed(&pool.nodes, hash, data, |d| {
1569                ExprId(pool.nodes.push(pool.make_node(d)) as u32)
1570            })
1571    }
1572
1573    fn int(n: i64) -> ExprData {
1574        ExprData::Integer(crate::kernel::expr::BigInt(rug::Integer::from(n)))
1575    }
1576
1577    /// A full 64-bit hash collision must not merge distinct nodes: the index
1578    /// stores only ids and hash bits, and decides equality by comparing the
1579    /// candidate against the stored node.
1580    #[test]
1581    fn forced_hash_collision_keeps_nodes_distinct() {
1582        let pool = ExprPool::new();
1583        // Every value gets the same hash, so they share a shard and a probe
1584        // sequence; 200 of them also force the colliding table to grow.
1585        let ids: Vec<ExprId> = (0..200)
1586            .map(|k| intern_with_hash(&pool, 7, int(k)))
1587            .collect();
1588        let distinct: std::collections::HashSet<ExprId> = ids.iter().copied().collect();
1589        assert_eq!(distinct.len(), 200, "a hash collision merged two nodes");
1590        for (k, &id) in ids.iter().enumerate() {
1591            assert_eq!(pool.get(id), int(k as i64));
1592            // A repeat finds the original, not a fresh copy.
1593            assert_eq!(intern_with_hash(&pool, 7, int(k as i64)), id);
1594        }
1595        // Same low 32 bits (the stored `Slot::hash`), different shard bits.
1596        let a = intern_with_hash(&pool, 0x0000_0000_dead_beef, int(-1));
1597        let b = intern_with_hash(&pool, 0xffff_ffff_dead_beef, int(-2));
1598        assert_ne!(a, b);
1599        assert_eq!(pool.len(), 202);
1600    }
1601
1602    /// The pool never holds two nodes with equal data, and every node's data
1603    /// interns back to its own id.
1604    fn assert_hash_consed(pool: &ExprPool) {
1605        let mut seen = std::collections::HashMap::new();
1606        for i in 0..pool.len() {
1607            let id = ExprId(i as u32);
1608            let data = pool.get(id);
1609            assert_eq!(
1610                pool.intern(data.clone()),
1611                id,
1612                "node {i} re-interned elsewhere"
1613            );
1614            if let Some(prev) = seen.insert(data, id) {
1615                panic!("nodes {prev:?} and {id:?} hold equal data");
1616            }
1617        }
1618    }
1619
1620    /// Many threads build the same *compound* structures concurrently — nested
1621    /// sums and products whose children are themselves being interned by the
1622    /// other threads at the same moment — while the index grows under them.
1623    /// Every thread must see the same ids, and no value may be stored twice.
1624    #[test]
1625    fn concurrent_compound_interning_stays_hash_consed() {
1626        const THREADS: usize = 8;
1627        const N: i64 = 1_500;
1628        let pool = ExprPool::new();
1629        let barrier = Barrier::new(THREADS);
1630        let per_thread: Vec<Vec<ExprId>> = std::thread::scope(|s| {
1631            let handles: Vec<_> = (0..THREADS)
1632                .map(|t| {
1633                    let (pool, barrier) = (&pool, &barrier);
1634                    s.spawn(move || {
1635                        let x = pool.symbol("x", Domain::Real);
1636                        let y = pool.symbol("y", Domain::Real);
1637                        barrier.wait();
1638                        let mut out = vec![ExprId(u32::MAX); N as usize];
1639                        // Half the threads walk forwards, half backwards, so
1640                        // the same node is often being created by two threads
1641                        // from opposite ends of the range.
1642                        let order: Vec<i64> = if t % 2 == 0 {
1643                            (0..N).collect()
1644                        } else {
1645                            (0..N).rev().collect()
1646                        };
1647                        for k in order {
1648                            let c = pool.integer(k);
1649                            let p = pool.pow(x, c);
1650                            let m = pool.mul(vec![c, p, y]);
1651                            let f = pool.func("sin", vec![m]);
1652                            out[k as usize] = pool.add(vec![f, p, pool.rational(k, 3)]);
1653                        }
1654                        out
1655                    })
1656                })
1657                .collect();
1658            handles.into_iter().map(|h| h.join().unwrap()).collect()
1659        });
1660        for ids in &per_thread[1..] {
1661            assert_eq!(ids, &per_thread[0], "threads disagree on an id");
1662        }
1663        assert_hash_consed(&pool);
1664    }
1665
1666    /// Readers hitting existing entries while writers grow the same shards:
1667    /// every id handed out before or during the growth stays valid and stable.
1668    #[test]
1669    fn hits_during_concurrent_growth_are_stable() {
1670        let pool = ExprPool::new();
1671        let x = pool.symbol("x", Domain::Real);
1672        let base: Vec<ExprId> = (0..500i64)
1673            .map(|k| pool.add(vec![x, pool.integer(k)]))
1674            .collect();
1675        std::thread::scope(|s| {
1676            for w in 0..4i64 {
1677                let pool = &pool;
1678                s.spawn(move || {
1679                    for k in 0..20_000i64 {
1680                        pool.integer(1_000_000 + w * 20_000 + k);
1681                    }
1682                });
1683            }
1684            for _ in 0..4 {
1685                let (pool, base) = (&pool, &base);
1686                s.spawn(move || {
1687                    for round in 0..40 {
1688                        for (k, &id) in base.iter().enumerate() {
1689                            let again = pool.add(vec![x, pool.integer(k as i64)]);
1690                            assert_eq!(again, id, "round {round}: id moved");
1691                        }
1692                    }
1693                });
1694            }
1695        });
1696        assert_eq!(pool.len(), 1 + 2 * 500 + 80_000);
1697        assert_hash_consed(&pool);
1698    }
1699
1700    /// A panic while creating a node (here: a child id from another pool)
1701    /// must not wedge the index — it fires under the shard's write lock and
1702    /// poisons it, but before the slot is recorded, so later interns into the
1703    /// same shard carry on.
1704    #[test]
1705    fn a_panicking_intern_does_not_wedge_the_pool() {
1706        const H: u64 = 0x1234_5678_9abc_def0;
1707        let pool = ExprPool::new();
1708        let x = pool.symbol("x", Domain::Real);
1709        let bogus = ExprData::Pow {
1710            base: x,
1711            exp: ExprId(1_000_000),
1712        };
1713        let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1714            intern_with_hash(&pool, H, bogus);
1715        }));
1716        assert!(r.is_err());
1717        let before = pool.len();
1718        // Same hash, so the same (now poisoned) shard.
1719        let a = intern_with_hash(&pool, H, int(5));
1720        assert_eq!(intern_with_hash(&pool, H, int(5)), a);
1721        assert_eq!(pool.get(a), int(5));
1722        assert_eq!(pool.len(), before + 1);
1723    }
1724
1725    /// Growing the index past many resizes keeps every id stable.
1726    #[test]
1727    fn ids_survive_index_growth() {
1728        let pool = ExprPool::new();
1729        let ids: Vec<ExprId> = (0..50_000i64).map(|k| pool.integer(k)).collect();
1730        for (k, id) in ids.iter().enumerate() {
1731            assert_eq!(pool.integer(k as i64), *id);
1732        }
1733        assert_eq!(pool.len(), 50_000);
1734    }
1735}
1736
1737#[cfg(test)]
1738mod intern_timing {
1739    //! `#[ignore]`d timing harness for number-atom hashing and interning.
1740    //! Run with
1741    //! `cargo test --release -p alkahest-core --lib intern_timing -- --ignored --nocapture`.
1742    use super::*;
1743    use crate::kernel::expr::{BigFloat, BigInt};
1744    use rug::ops::Pow;
1745    use std::hash::BuildHasher;
1746    use std::time::Instant;
1747
1748    fn per_op(label: &str, iters: u32, mut f: impl FnMut()) {
1749        for _ in 0..(iters / 10).max(1) {
1750            f();
1751        }
1752        let t = Instant::now();
1753        for _ in 0..iters {
1754            f();
1755        }
1756        let ns = t.elapsed().as_nanos() as f64 / f64::from(iters);
1757        println!("{label:<40} {ns:>12.1} ns/op");
1758    }
1759
1760    #[test]
1761    #[ignore]
1762    fn number_atom_hash_and_intern_timings() {
1763        let rs = std::collections::hash_map::RandomState::new();
1764        let small = BigInt(rug::Integer::from(123_456_789));
1765        let big = BigInt(rug::Integer::from(3).pow(2000));
1766        let huge = rug::Integer::from(1) << 100_000u32;
1767        let flt = BigFloat {
1768            inner: rug::Float::with_val(53, 1.1),
1769            prec: 53,
1770        };
1771        per_op("hash BigInt small", 1_000_000, || {
1772            std::hint::black_box(rs.hash_one(std::hint::black_box(&small)));
1773        });
1774        per_op("hash BigInt 3^2000", 100_000, || {
1775            std::hint::black_box(rs.hash_one(std::hint::black_box(&big)));
1776        });
1777        per_op("hash BigFloat 53-bit", 1_000_000, || {
1778            std::hint::black_box(rs.hash_one(std::hint::black_box(&flt)));
1779        });
1780
1781        let p = ExprPool::new();
1782        for k in 0..1000i64 {
1783            p.integer(k);
1784        }
1785        let mut k = 0i64;
1786        per_op("pool.integer(k) hit", 1_000_000, || {
1787            k = (k + 1) % 1000;
1788            std::hint::black_box(p.integer(k));
1789        });
1790        per_op("pool.rational(1,3) hit", 1_000_000, || {
1791            std::hint::black_box(p.rational(1, 3));
1792        });
1793        per_op("pool.float(1.1,53) hit", 1_000_000, || {
1794            std::hint::black_box(p.float(1.1, 53));
1795        });
1796        p.integer(huge.clone());
1797        per_op("pool.integer(2^100000) hit", 10_000, || {
1798            std::hint::black_box(p.integer(huge.clone()));
1799        });
1800
1801        let fresh = ExprPool::new();
1802        let mut n = 0i64;
1803        per_op("pool.integer miss (fresh values)", 1_000_000, || {
1804            n += 1;
1805            std::hint::black_box(fresh.integer(n));
1806        });
1807        let x = fresh.symbol("x", crate::kernel::Domain::Real);
1808        let mut m = 0i64;
1809        per_op("pool.add([x, k]) miss", 500_000, || {
1810            m += 1;
1811            let c = fresh.integer(m);
1812            std::hint::black_box(fresh.add(vec![x, c]));
1813        });
1814    }
1815}