Skip to main content

bitwright/eqsat/
mod.rs

1//! The equality-saturation search service (feature `eqsat`, default off). See `docs/design.md`
2//! ยง10.
3//!
4//! A bounded *alternative-expression search* over immutable graphs, not a phase: the host
5//! decides when (output or checkpoint boundaries, never routine maintenance), which roots, and
6//! which allowance. Admitted equations are proven, unconditional, of one width, and in the
7//! conservative fragment `+ โˆ’ * & | ^ neg ~`: identities are used in both directions, guard-free
8//! rules in their authored direction. An extracted expression is a **candidate** the host's own
9//! profitability and verification gates decide to use.
10//!
11//! ```
12//! use bitwright::eqsat::{SaturateConfig, Saturator, SearchRun};
13//! use bitwright::{Context, ParseOptions, Width};
14//!
15//! let (sat, _report) = Saturator::builtin_groups(&["eqsat.distrib"], SaturateConfig::default());
16//! let mut cx = Context::new();
17//! // Factoring needs distributivity backwards, which a directed simplifier never does. The
18//! // search saturates, so the candidate is published.
19//! let e = cx.parse("x * y + x * z", &ParseOptions::width(Width::W32))?;
20//! let out = sat.search(&mut cx, &[e], SearchRun::default())?;
21//! let want = cx.parse("x * (y + z)", &ParseOptions::width(Width::W32))?;
22//! assert_eq!(out.roots[0].candidate, Some(want));
23//! # Ok::<(), bitwright::Error>(())
24//! ```
25
26mod egraph;
27mod extract;
28mod pattern;
29#[cfg(test)]
30mod tests;
31
32use std::sync::{Arc, OnceLock};
33
34use egraph::{ClassId, EGraph, ENode, EOp, Halt};
35use pattern::Identity;
36
37use crate::engine::budget::Meter;
38use crate::engine::{Admission, Allowance, Budget, BuildError, Cap, Deadline, Exhausted};
39use crate::error::Error;
40use crate::expr::{Context, Expr, FnEnv, OpCode};
41use crate::hash::{IdMap, combine};
42use crate::rules::{Ledger, RuleProgram};
43use crate::{BitVec, SymbolKey, Width};
44
45/// The operators and widths the search accepts.
46#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub struct Fragment {
49    /// The widest root.
50    pub max_width: u16,
51}
52
53impl Fragment {
54    /// Widths 1..=128, one width per root, `+ โˆ’ * & | ^`, unary `โˆ’` and `~`, constants and
55    /// symbols.
56    pub fn conservative() -> Fragment {
57        Fragment { max_width: 128 }
58    }
59}
60
61setters!(Fragment {
62    with_max_width: max_width: u16,
63});
64
65/// What to do with subterms outside the fragment.
66#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
67#[non_exhaustive]
68pub enum UnsupportedPolicy {
69    /// Decline the root (the default).
70    Decline,
71    /// Treat maximal outside subterms as opaque atoms (sound under total semantics; opt-in).
72    Atomize,
73}
74
75/// Search configuration.
76#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
77#[non_exhaustive]
78pub struct SaturateConfig {
79    /// The accepted fragment.
80    pub fragment: Fragment,
81    /// The most iterations per root.
82    pub iterations: u8,
83    /// The most matches applied per identity and direction per iteration.
84    pub matches_per_rule_iter: u32,
85    /// Per-root caps checked before anything is built.
86    pub admission: Admission,
87    /// Outside subterms.
88    pub unsupported: UnsupportedPolicy,
89    /// The most distinct nodes a root may have.
90    pub max_root_nodes: u32,
91}
92
93impl Default for SaturateConfig {
94    /// The conservative defaults: 8 iterations, 16 matches per equation and direction per
95    /// iteration, and nothing published unless every searched root saturated.
96    fn default() -> Self {
97        SaturateConfig {
98            fragment: Fragment::conservative(),
99            iterations: 8,
100            matches_per_rule_iter: 16,
101            admission: Admission::default(),
102            unsupported: UnsupportedPolicy::Decline,
103            max_root_nodes: 4096,
104        }
105    }
106}
107
108setters!(SaturateConfig {
109    with_fragment: fragment: Fragment,
110    with_iterations: iterations: u8,
111    with_matches_per_rule_iter: matches_per_rule_iter: u32,
112    with_admission: admission: Admission,
113    with_unsupported: unsupported: UnsupportedPolicy,
114    with_max_root_nodes: max_root_nodes: u32,
115});
116
117impl SaturateConfig {
118    /// A larger search: 16 iterations of at most 64 new matches per equation and direction.
119    /// Pair it with a larger budget ([`SearchRun::per_call`]). Like every configuration, it
120    /// publishes only searches that saturate: an iteration cap, a budget or a deadline
121    /// withholds the batch. Associativity and distributivity together rarely saturate, so
122    /// choose the equation groups per search ([`Saturator::new`]).
123    pub fn exploratory() -> SaturateConfig {
124        SaturateConfig {
125            iterations: 16,
126            matches_per_rule_iter: 64,
127            ..SaturateConfig::default()
128        }
129    }
130}
131
132/// Which rules were admitted, and why others were not.
133#[derive(Clone, Debug, Default, PartialEq, Eq)]
134#[non_exhaustive]
135pub struct AdmissionReport {
136    /// Admitted identities.
137    pub admitted: Vec<String>,
138    /// Refused rules and the reason.
139    pub refused: Vec<(String, String)>,
140}
141
142/// Options of one search.
143#[derive(Default)]
144#[non_exhaustive]
145pub struct SearchRun<'a> {
146    /// A caller-owned account shared across searches (and with other work).
147    pub allowance: Option<&'a mut Allowance>,
148    /// Caps on this search (default: [`SearchRun::DEFAULT_BUDGET`] when left at the default).
149    pub per_call: Option<Budget>,
150    /// Stop at a host-supplied time.
151    pub deadline: Option<Deadline<'a>>,
152}
153
154setters!(SearchRun<'a> {
155    with_allowance: allowance ? &'a mut Allowance,
156    with_per_call: per_call ? Budget,
157    with_deadline: deadline ? Deadline<'a>,
158});
159
160impl SearchRun<'_> {
161    /// The default caps: 2048 e-nodes and 100 000 work units per search.
162    pub const DEFAULT_BUDGET: Budget = {
163        let mut b = Budget::UNLIMITED;
164        b.eqsat_nodes = 2048;
165        b.eqsat_work = 100_000;
166        b
167    };
168}
169
170impl core::fmt::Debug for SearchRun<'_> {
171    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
172        f.debug_struct("SearchRun")
173            .field("per_call", &self.per_call)
174            .finish_non_exhaustive()
175    }
176}
177
178/// How one root's search ended.
179#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
180#[non_exhaustive]
181pub enum RootEnd {
182    /// No rule application added anything: the graph is saturated.
183    Saturated,
184    /// The iteration cap was reached first.
185    IterationCap,
186    /// A budget or deadline stopped it.
187    Stopped(Exhausted),
188    /// Outside the fragment (with `UnsupportedPolicy::Decline`).
189    DeclinedUnsupported,
190    /// Over an admission cap.
191    DeclinedAdmission(Cap),
192    /// Answered from the context's memo.
193    Memo,
194    /// Not searched: an earlier root already withheld the batch's publication.
195    Skipped,
196}
197
198/// The result for one root.
199#[derive(Copy, Clone, Debug, PartialEq, Eq)]
200#[non_exhaustive]
201pub struct SearchRoot {
202    /// The input.
203    pub input: Expr,
204    /// A strictly cheaper equivalent expression, if the batch was published and one was found.
205    pub candidate: Option<Expr>,
206    /// How its search ended.
207    pub end: RootEnd,
208}
209
210/// Whether a batch's candidates are published.
211#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
212#[non_exhaustive]
213pub enum Publication {
214    /// Every searched root finished: candidates are published.
215    Published,
216    /// Some root stopped: no candidate of the batch is published.
217    Withheld {
218        /// The first stop: [`RootEnd::Stopped`], or [`RootEnd::IterationCap`] when the
219        /// configuration does not publish at the cap.
220        cause: RootEnd,
221    },
222}
223
224/// Counters for one search; unproductive search is counted, not hidden.
225#[derive(Clone, Debug, Default, PartialEq, Eq)]
226#[non_exhaustive]
227pub struct EqsatStats {
228    /// Roots declined as unsupported.
229    pub declined_unsupported: u64,
230    /// Roots declined by an admission cap.
231    pub declined_admission: u64,
232    /// Roots saturated.
233    pub saturated: u64,
234    /// Roots stopped at the iteration cap.
235    pub iteration_capped: u64,
236    /// Roots stopped by a budget or deadline.
237    pub budget_terminated: u64,
238    /// Roots answered from the memo.
239    pub memo_hits: u64,
240    /// Roots with a candidate found (before publication).
241    pub changed: u64,
242    /// E-nodes inserted.
243    pub enodes: u64,
244    /// Work charged.
245    pub work: u64,
246    /// Unions.
247    pub unions: u64,
248    /// Iterations run.
249    pub iterations: u64,
250    /// Batches withheld.
251    pub withheld: u64,
252}
253
254/// The result of a search.
255#[derive(Clone, Debug, PartialEq, Eq)]
256#[non_exhaustive]
257pub struct SearchReport {
258    /// Per input root.
259    pub roots: Vec<SearchRoot>,
260    /// Whether the batch's candidates are published.
261    pub publication: Publication,
262    /// Counters.
263    pub stats: EqsatStats,
264}
265
266struct Inner {
267    rules: Vec<Identity>,
268    cfg: SaturateConfig,
269    epoch: u64,
270}
271
272/// A compiled set of admitted identities and a configuration. Cheap to clone; `Send + Sync`.
273#[derive(Clone)]
274pub struct Saturator {
275    inner: Arc<Inner>,
276}
277
278impl core::fmt::Debug for Saturator {
279    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
280        f.debug_struct("Saturator")
281            .field("identities", &self.inner.rules.len())
282            .field("config", &self.inner.cfg)
283            .finish()
284    }
285}
286
287/// The per-context memo: completed candidates and stable declines, per saturator epoch.
288#[derive(Clone, Debug, Default)]
289pub(crate) struct Memo {
290    epoch: u64,
291    entries: IdMap<u32, (Option<u32>, RootEnd)>,
292}
293
294impl Memo {
295    pub(crate) fn clear(&mut self) {
296        *self = Memo::default();
297    }
298}
299
300fn builtin_program() -> &'static (RuleProgram, Ledger) {
301    static B: OnceLock<(RuleProgram, Ledger)> = OnceLock::new();
302    B.get_or_init(|| {
303        let p = RuleProgram::compile_trusted(crate::rules::corpus::EQSAT)
304            .unwrap_or_else(|e| panic!("the built-in identities do not compile: {e}"));
305        let l = Ledger::parse(crate::rules::corpus::EQSAT_LEDGER)
306            .unwrap_or_else(|e| panic!("the built-in identity ledger does not parse: {e}"));
307        (p, l)
308    })
309}
310
311impl Saturator {
312    /// A saturator over the admitted identities of `programs` (each with the ledger that
313    /// vouches for it), restricted to `groups` (all groups when empty).
314    pub fn new(
315        programs: &[(&RuleProgram, &Ledger)],
316        groups: &[&str],
317        cfg: SaturateConfig,
318    ) -> Result<(Saturator, AdmissionReport), BuildError> {
319        let mut report = AdmissionReport::default();
320        let mut rules = Vec::new();
321        let mut epoch = combine(0x65_7173_6174, u64::from(cfg.iterations));
322        for (p, ledger) in programs {
323            for g in p.groups() {
324                if !groups.is_empty() && !groups.contains(&g.name.as_str()) {
325                    continue;
326                }
327                for &ri in &g.rules {
328                    let rule = &p.rules()[ri];
329                    match pattern::admit(rule, ledger.vouches_for(&rule.name, rule.id)) {
330                        Ok(id) => {
331                            epoch = combine(combine(epoch, rule.id.0[0]), rule.id.0[1]);
332                            report.admitted.push(rule.name.clone());
333                            rules.push(id);
334                        }
335                        Err(why) => report.refused.push((rule.name.clone(), why)),
336                    }
337                }
338            }
339        }
340        for g in groups {
341            if !programs
342                .iter()
343                .any(|(p, _)| p.groups().iter().any(|x| x.name == *g))
344            {
345                return Err(BuildError::UnknownGroup((*g).to_string()));
346            }
347        }
348        {
349            use core::hash::{Hash, Hasher};
350            let mut h = std::collections::hash_map::DefaultHasher::new();
351            cfg.hash(&mut h);
352            epoch = combine(epoch, h.finish());
353        }
354        Ok((
355            Saturator {
356                inner: Arc::new(Inner { rules, cfg, epoch }),
357            },
358            report,
359        ))
360    }
361
362    /// A saturator over every built-in group (`eqsat.*`). Associativity and each
363    /// distributivity group saturate on their own, but not all together on most inputs; prefer
364    /// [`builtin_groups`](Saturator::builtin_groups) with the groups a search needs.
365    pub fn builtin(cfg: SaturateConfig) -> (Saturator, AdmissionReport) {
366        Saturator::builtin_groups(&[], cfg)
367    }
368
369    /// A saturator over the named built-in groups (every group when `groups` is empty):
370    /// `eqsat.assoc`, `eqsat.distrib` (`*` over `+`), `eqsat.distrib_and` (`&` over `|` and
371    /// `^`), `eqsat.distrib_or` (`|` over `&`), `eqsat.negation` and `eqsat.cancel`.
372    pub fn builtin_groups(groups: &[&str], cfg: SaturateConfig) -> (Saturator, AdmissionReport) {
373        let (p, l) = builtin_program();
374        Saturator::new(&[(p, l)], groups, cfg)
375            .unwrap_or_else(|e| panic!("the built-in identities do not link: {e}"))
376    }
377
378    /// Searches each root of `batch` in its own e-graph (sharing only the allowance) and
379    /// extracts strictly cheaper candidates. Publication is transactional per batch.
380    pub fn search(
381        &self,
382        cx: &mut Context,
383        batch: &[Expr],
384        run: SearchRun<'_>,
385    ) -> Result<SearchReport, Error> {
386        let ids = cx.ids(batch)?;
387        let inner = &*self.inner;
388        let SearchRun {
389            allowance,
390            per_call,
391            deadline,
392        } = run;
393        let per_call = per_call.unwrap_or(SearchRun::DEFAULT_BUDGET);
394        let limit = match &allowance {
395            Some(a) => per_call.min(&a.remaining()),
396            None => per_call,
397        };
398        let mut meter = Meter::new(limit, deadline);
399        let mut stats = EqsatStats::default();
400        if cx.eqsat_memo.epoch != inner.epoch {
401            cx.eqsat_memo.clear();
402            cx.eqsat_memo.epoch = inner.epoch;
403        }
404        let mut done: IdMap<u32, (Option<u32>, RootEnd)> = IdMap::default();
405        // A deadline already passed withholds the batch, memo answers included.
406        let mut cause: Option<RootEnd> = meter.deadline_now().err().map(RootEnd::Stopped);
407        let mut failure: Option<Error> = None;
408        for &root in &ids {
409            if done.contains_key(&root) {
410                continue;
411            }
412            if let Some(&(c, _)) = cx.eqsat_memo.entries.get(&root) {
413                stats.memo_hits += 1;
414                done.insert(root, (c, RootEnd::Memo));
415                continue;
416            }
417            match cause {
418                // A budget stop earlier in the batch: nothing more is searched.
419                Some(RootEnd::Stopped(e)) => {
420                    done.insert(root, (None, RootEnd::Stopped(e)));
421                    continue;
422                }
423                // Publication is already withheld: searching on would only spend the budget.
424                Some(_) => {
425                    done.insert(root, (None, RootEnd::Skipped));
426                    continue;
427                }
428                None => {}
429            }
430            let (cand, end) = match self.search_root(cx, root, &mut meter, &mut stats) {
431                Ok(x) => x,
432                Err(e) => {
433                    failure = Some(e);
434                    break;
435                }
436            };
437            match end {
438                RootEnd::Stopped(_) => cause = Some(end),
439                RootEnd::IterationCap => {
440                    cause.get_or_insert(end);
441                }
442                _ => {}
443            }
444            // Stable outcomes are memoized: saturated searches and admission declines. Never an
445            // incomplete search (an iteration cap, a budget or a deadline stop).
446            let stable = matches!(
447                end,
448                RootEnd::Saturated | RootEnd::DeclinedUnsupported | RootEnd::DeclinedAdmission(_)
449            );
450            if stable {
451                cx.eqsat_memo.entries.insert(root, (cand, end));
452            }
453            done.insert(root, (cand, end));
454        }
455        let spent = meter.spent;
456        if let Some(a) = allowance {
457            a.spend(&spent);
458        }
459        if let Some(e) = failure {
460            return Err(e);
461        }
462        stats.enodes = spent.eqsat_nodes;
463        stats.work = spent.eqsat_work;
464        let publication = match cause {
465            None => Publication::Published,
466            Some(c) => {
467                stats.withheld += 1;
468                Publication::Withheld { cause: c }
469            }
470        };
471        let published = publication == Publication::Published;
472        Ok(SearchReport {
473            roots: ids
474                .iter()
475                .map(|&i| {
476                    let (c, end) = done[&i];
477                    SearchRoot {
478                        input: cx.handle(i),
479                        candidate: c.filter(|_| published).map(|c| cx.handle(c)),
480                        end,
481                    }
482                })
483                .collect(),
484            publication,
485            stats,
486        })
487    }
488
489    fn search_root(
490        &self,
491        cx: &mut Context,
492        root: u32,
493        meter: &mut Meter<'_>,
494        stats: &mut EqsatStats,
495    ) -> Result<(Option<u32>, RootEnd), Error> {
496        let cfg = &self.inner.cfg;
497        // Admission from O(1) metadata first.
498        let meta = cx.meta[root as usize];
499        if meta.height > cfg.admission.max_root_height {
500            stats.declined_admission += 1;
501            return Ok((None, RootEnd::DeclinedAdmission(Cap::Height)));
502        }
503        if meta.tree > cfg.admission.max_root_tree_size {
504            stats.declined_admission += 1;
505            return Ok((None, RootEnd::DeclinedAdmission(Cap::TreeSize)));
506        }
507        let w = cx.width_of(root);
508        // The bounded fragment and homogeneity check, then the import.
509        let order = match self.fragment(cx, root, w) {
510            Ok(o) => o,
511            Err(end) => {
512                match end {
513                    RootEnd::DeclinedAdmission(_) => stats.declined_admission += 1,
514                    _ => stats.declined_unsupported += 1,
515                }
516                return Ok((None, end));
517            }
518        };
519        let mut eg = EGraph::new();
520        let rootc = match self.import(cx, &mut eg, &order, meter) {
521            Ok(c) => c,
522            Err(Halt::Budget(e)) => {
523                stats.budget_terminated += 1;
524                return Ok((None, RootEnd::Stopped(e)));
525            }
526            Err(Halt::Contract) => {
527                return Err(Error::Contract(
528                    "constant folding disagreed on import".into(),
529                ));
530            }
531        };
532        let end = match self.saturate(&mut eg, w, meter, stats) {
533            Ok(end) => end,
534            Err(Halt::Budget(e)) => {
535                stats.budget_terminated += 1;
536                stats.unions += eg.unions;
537                return Ok((None, RootEnd::Stopped(e)));
538            }
539            Err(Halt::Contract) => {
540                return Err(Error::Contract(
541                    "an admitted identity united two different constants".into(),
542                ));
543            }
544        };
545        stats.unions += eg.unions;
546        let best = match extract::costs(&mut eg, meter) {
547            Ok(b) => b,
548            Err(e) => {
549                stats.budget_terminated += 1;
550                return Ok((None, RootEnd::Stopped(e)));
551            }
552        };
553        match end {
554            RootEnd::Saturated => stats.saturated += 1,
555            RootEnd::IterationCap => stats.iteration_capped += 1,
556            _ => {}
557        }
558        let Some(e) = cx.as_engine(|cx| extract::build(&mut eg, &best, rootc, cx, w))? else {
559            return Ok((None, end));
560        };
561        if e == root {
562            return Ok((None, end));
563        }
564        // Strictly cheaper as a tree, no larger as a DAG.
565        let (te, tr) = (cx.meta[e as usize].tree, cx.meta[root as usize].tree);
566        let dag = |cx: &mut Context, i: u32| match cx
567            .dag_size(&[cx.handle(i)], cfg.max_root_nodes.saturating_mul(4))
568        {
569            Ok(crate::Bounded::Exact(n)) => Some(n),
570            _ => None,
571        };
572        let (de, dr) = (dag(cx, e), dag(cx, root));
573        let cheaper = te < tr && matches!((de, dr), (Some(a), Some(b)) if a <= b);
574        if !cheaper {
575            return Ok((None, end));
576        }
577        if !agrees(cx, root, e)? {
578            return Err(Error::Contract(
579                "an extracted candidate differs from its input at a sampled point".into(),
580            ));
581        }
582        stats.changed += 1;
583        Ok((Some(e), end))
584    }
585
586    /// The fragment nodes below `root` in post-order (atoms included as leaves), or why the
587    /// root is declined.
588    fn fragment(&self, cx: &Context, root: u32, w: Width) -> Result<Vec<u32>, RootEnd> {
589        let cfg = &self.inner.cfg;
590        if w.bits() > cfg.fragment.max_width {
591            return Err(RootEnd::DeclinedUnsupported);
592        }
593        // `Admission::max_root_dag_size` and `max_root_nodes` both bound the distinct nodes.
594        let max_nodes = cfg
595            .admission
596            .max_root_dag_size
597            .map_or(cfg.max_root_nodes, |d| d.min(cfg.max_root_nodes));
598        let mut order = Vec::new();
599        let mut seen: IdMap<u32, ()> = IdMap::default();
600        let mut stack: Vec<(u32, bool)> = vec![(root, false)];
601        while let Some((i, expanded)) = stack.pop() {
602            if expanded {
603                order.push(i);
604                continue;
605            }
606            if seen.insert(i, ()).is_some() {
607                continue;
608            }
609            if seen.len() as u32 > max_nodes {
610                return Err(RootEnd::DeclinedAdmission(Cap::DagSize));
611            }
612            let n = cx.node(i);
613            if n.width != w.bits() {
614                if cfg.unsupported == UnsupportedPolicy::Atomize && i != root {
615                    order.push(i);
616                    continue;
617                }
618                return Err(RootEnd::DeclinedUnsupported);
619            }
620            let inside = match n.op {
621                OpCode::Const | OpCode::Sym => true,
622                op => {
623                    op.as_bin().is_some_and(pattern::admitted_bin)
624                        || op.as_un().is_some_and(pattern::admitted_un)
625                }
626            };
627            if !inside {
628                if cfg.unsupported == UnsupportedPolicy::Atomize && i != root {
629                    order.push(i);
630                    continue;
631                }
632                return Err(RootEnd::DeclinedUnsupported);
633            }
634            stack.push((i, true));
635            for c in n.children() {
636                stack.push((c, false));
637            }
638        }
639        Ok(order)
640    }
641
642    fn import(
643        &self,
644        cx: &Context,
645        eg: &mut EGraph,
646        order: &[u32],
647        m: &mut Meter<'_>,
648    ) -> Result<ClassId, Halt> {
649        let mut at: IdMap<u32, ClassId> = IdMap::default();
650        for &i in order {
651            let n = cx.node(i);
652            let inside = n.op == OpCode::Const
653                || n.op.as_bin().is_some_and(pattern::admitted_bin)
654                || n.op.as_un().is_some_and(pattern::admitted_un);
655            let e = if let Some(v) = cx.const_val(i) {
656                ENode {
657                    op: EOp::Const(v),
658                    kids: [0, 0],
659                }
660            } else if !inside || n.op == OpCode::Sym || !n.children().all(|c| at.contains_key(&c)) {
661                ENode {
662                    op: EOp::Leaf(i),
663                    kids: [0, 0],
664                }
665            } else if let Some(u) = n.op.as_un() {
666                ENode {
667                    op: EOp::Un(u),
668                    kids: [at[&n.a], 0],
669                }
670            } else {
671                let b = n.op.as_bin().unwrap_or(crate::BinOp::Add);
672                ENode {
673                    op: EOp::Bin(b),
674                    kids: [at[&n.a], at[&n.b]],
675                }
676            };
677            let c = eg.add(e, true, m)?;
678            at.insert(i, c);
679        }
680        order
681            .last()
682            .and_then(|r| at.get(r).copied())
683            .ok_or(Halt::Contract)
684    }
685
686    fn saturate(
687        &self,
688        eg: &mut EGraph,
689        w: Width,
690        m: &mut Meter<'_>,
691        stats: &mut EqsatStats,
692    ) -> Result<RootEnd, Halt> {
693        let cfg = &self.inner.cfg;
694        // Matches already applied (canonicalized when checked): they do not count toward the
695        // per-equation cap, or an early redex could starve every later one.
696        let mut applied: std::collections::HashSet<
697            (usize, usize, ClassId, Vec<ClassId>),
698            crate::hash::IdBuild,
699        > = std::collections::HashSet::default();
700        for _ in 0..cfg.iterations {
701            stats.iterations += 1;
702            let (n0, u0) = (eg.nodes.len(), eg.unions);
703            // Collect every identity's matches in both directions over the canonical classes,
704            // then apply them in collection order.
705            let mut apps: Vec<(usize, usize, ClassId, Vec<ClassId>)> = Vec::new();
706            let classes = eg.canonical_classes();
707            // Saturation can only be claimed when no equation had more matches than it took.
708            let mut capped = false;
709            for (ri, rule) in self.inner.rules.iter().enumerate() {
710                for dir in 0..if rule.bidirectional { 2 } else { 1 } {
711                    let mut left = cfg.matches_per_rule_iter as usize;
712                    for &c in &classes {
713                        if left == 0 && capped {
714                            // Nothing more can be taken, and saturation is already ruled out.
715                            break;
716                        }
717                        let mut found: Vec<Vec<ClassId>> = Vec::new();
718                        // One more than can be taken shows whether matches were left over.
719                        // Matches already applied are skipped before they count.
720                        let window = left + 1;
721                        let mut skip = |eg: &mut EGraph, b: &[ClassId]| {
722                            let key = (
723                                ri,
724                                dir,
725                                eg.find(c),
726                                b.iter().map(|&x| eg.find(x)).collect::<Vec<_>>(),
727                            );
728                            applied.contains(&key)
729                        };
730                        pattern::ematch(
731                            eg,
732                            &rule.sides[dir],
733                            rule.vars,
734                            c,
735                            w,
736                            &mut found,
737                            window,
738                            &mut skip,
739                            m,
740                        )?;
741                        if found.len() >= window {
742                            capped = true;
743                        }
744                        for b in found {
745                            let key = (
746                                ri,
747                                dir,
748                                eg.find(c),
749                                b.iter().map(|&x| eg.find(x)).collect::<Vec<_>>(),
750                            );
751                            if applied.contains(&key) {
752                                continue;
753                            }
754                            if left == 0 {
755                                capped = true;
756                                break;
757                            }
758                            left -= 1;
759                            applied.insert(key);
760                            apps.push((ri, dir, c, b));
761                        }
762                    }
763                }
764            }
765            for (ri, dir, c, b) in apps {
766                let rule = &self.inner.rules[ri];
767                if let Some(r) = pattern::instantiate(eg, &rule.sides[1 - dir], &b, w, m)? {
768                    eg.union(c, r, m)?;
769                }
770            }
771            eg.rebuild(m)?;
772            if eg.nodes.len() == n0 && eg.unions == u0 && !capped {
773                return Ok(RootEnd::Saturated);
774            }
775        }
776        Ok(RootEnd::IterationCap)
777    }
778}
779
780/// Whether `a` and `b` agree at seeded values of their symbols.
781fn agrees(cx: &mut Context, a: u32, b: u32) -> Result<bool, Error> {
782    let (ea, eb) = (cx.handle(a), cx.handle(b));
783    let syms = cx.symbols_in(&[ea, eb])?;
784    // With what the host declared of each (the points agree with it).
785    let keys: Vec<(SymbolKey, Width, Option<crate::KnownBits>)> = syms
786        .iter()
787        .filter_map(|&s| {
788            let key = cx.symbol_key(s)?.clone();
789            let known = cx
790                .find_symbol(&key)
791                .and_then(|e| cx.declared_known(e).ok().flatten());
792            Some((key, cx.symbol_width(s)?, known))
793        })
794        .collect();
795    let mut x = cx.meta[a as usize].shash;
796    for k in 0..32u32 {
797        let vals: Vec<(SymbolKey, BitVec)> = keys
798            .iter()
799            .map(|(key, w, known)| {
800                let v = match k {
801                    0 => BitVec::zero(*w),
802                    1 => BitVec::ones(*w),
803                    _ => {
804                        let limbs: Vec<u64> = (0..8)
805                            .map(|_| {
806                                x = x.wrapping_add(0x9e37_79b9_7f4a_7c15);
807                                let mut z = x;
808                                z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
809                                z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
810                                z ^ (z >> 31)
811                            })
812                            .collect();
813                        BitVec::wrapping_from_limbs(*w, &limbs)
814                    }
815                };
816                (key.clone(), known.map_or(v, |d| d.fit(&v)))
817            })
818            .collect();
819        let env =
820            FnEnv(|key: &SymbolKey, _| vals.iter().find(|(k2, _)| k2 == key).map(|(_, v)| *v));
821        let r = cx.eval(&[ea, eb], &env)?;
822        if r[0] != r[1] {
823            return Ok(false);
824        }
825    }
826    Ok(true)
827}