Pavel Panchekha

By Jacob Denbeaux, Max Willsey, Philip Zucker, Pavel Panchekha

Share under CC-BY-SA.

E-class is in session

In the hallway outside EGRAPHS 2026, Max, Philip, and I were discussing new e-graph engines. Philip and Max have been writing "micro-egg", a minimal, teaching e-graph implementation to help guide new e-graph contributors, and I lamented the lack of a write-up about the effort.

So we decided to write one then and there. Jacob, who was interested in learning more about e-graphs, sat between Max and Philip with their text editor open, and the two micro-egg authors talked Jacob through a complete e-graph engine over the course of about four hours. Max Bernstein, Thalia Archibald, and Bhargav Kulkarni followed along on their own laptops, and I transcribed. This guide is the result.

Goals

The goal is an equality saturation engine. It's not going to implement every possible optimization, but it's going to implement the optimizations needed to be asymptotically fast.1 [1 Meant in a vague, more intuitive way, not in the sense of a strict proof. This guide is about avoiding mistakes that cause the engine to, for example, find exponentially more matches than necessary, but it sacrifices many 2× and 3× optimizations for simplicity.] Our goal will be running the AC rewrites and on a sum of seven items. This is a big enough problem that an asymptotically fast implementation will complete without additional optimizations but one with critical flaws will not. Plus, this benchmark doesn't need parsing, and we can check correctness with closed-form formulas for the number of e-classes and e-nodes.

The E-node

We start with the e-node, our core data structure. Each one has a string, for the operator name,2 [2 Philip calls it f.] and a list of IDs (for the children). It can be a class, a named tuple, or any other aggregate type. Whatever it is, call it Node:

@dataclass(frozen=True)
class Node:
    f : str
    l : tuple[ID, ...]

We'll use nodes as hash table keys, which in your language might require some extra rigmarole, like a hash method or a frozen annotation. Worst case, in a language like JavaScript, you can serialize nodes to and from strings, space-separating the operator and IDs.

Our IDs will be integers:

ID = int

You can use another representation of IDs, and Philip notes that "fat ID"s with more structure are an important extension point, but the simplest possible e-graph has integer IDs.

Note

This is a "class-centric" e-graph, where e-classes have identity while e-nodes are purely structural containers. There's an older "node-centric" view, where e-nodes have identity and e-classes are a derived structural relationship between e-nodes. Both Max and Philip think the class-centric view, which is newer, is easier to get right and simpler implementation-wise.

Note

Philip points out that one thing you can do is have binary operators only, with currying. Max thinks that's harder and the wrong choice, but Philip points out that it makes e-matching better, and you don't need string interning, and there are other benefits. This author thinks the bottom line is unclear, but it's probably not the right implementation choice for your first e-graph.

Starting the E-graph

Now let's make an e-graph class. Here is its minimal interface:

class EGraph:
    def add(self, e : Node) -> ID: pass
    def union(self, a : ID, b : ID) -> bool: pass

The add method adds terms to the e-graph, while the union method adds equalities between nodes. It's not strictly necessary to return anything from union, but it's convenient to return whether the union changed anything.

We'll implement this interface in three parts:

  1. A union-find over IDs. No children yet, so no congruence.
  2. Then, congruence closure.
  3. Finally, e-matching and rewriting.

You can do congruence closure and e-matching in either order, but both depend on union-find. In any case, we'll use this order.

The Union-find

There are two ways to do the union-find:

  • You can do the union find on the side, as a separate class.
  • Or you can to "bake it in" to the e-graph class itself.

It doesn't matter much. If you have it baked in, you get a more "node-oriented" architecture, and if you have it on the side, you get a more "class-oriented" one. Here we decided to put the union-find on the side.

So let's write our union-find. It is just a union-find on IDs; there are no separate "keys" or anything. There are many options for implementing union-find, and they all work fine, but Philip loves the arena-based union-find on integers most, so we'll do that. Its API:

class UnionFind:
    def makeset(self) -> ID: pass
    def find(self, ID) -> ID: pass
    def union(self, ID, ID) -> bool: pass

Maybe makeset is not the most intuitive name; it basically allocates a fresh ID which isn't equal to any others.

The implementation has one field, called parent, which is an array of IDs and starts empty:

class UnionFind:
    parent : list[ID]

    def __init__(self):
        self.parent = []

Think of each ID as an object with a having a field called parent, pointing to another ID. But instead of making the ID an object with a field, it'll just be an integer, and we'll look up its parent by using the ID as an index into this array. The array starts empty because we haven't allocated any IDs yet. If you think about it, a bunch of objects with a parent pointer is basically a tree.3 [3 But normally you have pointers from a tree node to its children, so this is an "inverted" tree.]

Makeset

We'll implement makeset first. It needs to allocate a new ID, which we do by pushing onto the union-find's parent array. Extending the array "makes" a new valid index, which is the new ID:

class UnionFind:
    def makeset(self):
        new_id = len(self.parent)
        self.parent.append(new_id)
        return new_id

This can be a little confusing! Since we push onto the list, there will be one more valid index after makeset than before. That will be our new ID. We compute that new ID from the pre-pushing length of the list. For example, the first time we call this, the pre-pushing length is 0, and the new ID after we push is 0.

It isn't actually important that the IDs are allocated like this, it's just an implementation convenience. You could imagine using a hash table, or heap-allocated objects, to represent IDs, and allocate IDs randomly or use the object pointer as an ID or something like that.

The value we push should be the new ID's parent; by convention, we say that if an ID's parent is itself, then it has no parent, so it's the root of a tree. You could do something more intuitive, like having an array of Option[ID] elements, but this is a standard convention and it's a bit faster. Multiple IDs are allowed to have no parent; the full union-find is a collection of little trees.

Find

Next, let's implement find. This method just finds the root of the tree that a given ID is in, and the implementation is simple and recursive:

class UnionFind:
    def find(self, id : ID) -> ID:
        if self.parent[id] == id:
            return id
        else:
            return self.find(self.parent[id])

Remember that self.parent[id] is morally id.parent, so this just walks up the tree until the root.

You can and should do path compression. It makes things faster, but it isn't critical; it is a micro-optimization. If you do an at-all-efficient union-find it will never be a bottleneck.

Philip likes the iterative version of find:

class UnionFind:
    def find(self, id : ID) -> ID:
        while id != self.parent[id]:
            id = self.parent[id]
        return id

One way to think about IDs is to say that equal IDs are represented by a canonical ID. That's what find returns: the canonical ID representing a given ID. Any two equal IDs, meaning they're in the same union-find tree, are represented by the same canonical ID, which is the root of that tree.

Union

Now let's make two IDs equal. The first thing union does is it calls find on both IDs:

class UnionFind:
    def union(self, a : ID, b : ID) -> bool:
        a = self.find(a)
        b = self.find(b)
        # ...

We always want to operate on canonical IDs. Next, we check if the two are equal; if they are, union doesn't have to do anything, and we can return False:

class UnionFind:
    def union(self, a : ID, b : ID) -> bool:
        # ...
        if a == b: return False
        # ...

Otherwise, choose one to be the new parent. Philip points out that it's an interesting degree of freedom, which one you pick, and there's a literature here, but it won't matter much for us; we'll just pick a.

So now let's think it through. Currently a and b are both canonical, since we just found them. If a is going to be the canonical representative of both of them, we need b to not be canonical. So make its parent a:

class UnionFind:
    def union(self, a : ID, b : ID) -> bool:
        # ...
        self.parent[b] = a
        return True

We return True because we made a change to the union-find.

Conclusion

We're done with the union-find. Now is a good time to stop and do some tests. For example, you can make a union-find, make three nodes, union two pairs, and check that the third pair is equal:

test_uf = UnionFind()
a, b, c = test_uf.makeset(), test_uf.makeset(), test_uf.makeset()
test_uf.union(a, b)
test_uf.union(b, c)
assert test_uf.find(a) == test_uf.find(c)

Union-find is "solving" a theory, where the theory, basically, is transitivity. Union-find is a way to solve that cheaply. It works great.

You can also write a Graphviz generator for your union-find. That can be useful to get more familiar with the data structure.

You might want to add an are_equal(Id, Id) method, which takes two ids, calls find on both of them, and checks if they're equal. Philip notes that in more complicated e-graphs there's a degree of freedom here. Instead of returning a boolean, it could instead return something richer that tells you how the two IDs are related.

Wrapping up

Anyway, our tests pass. Let's keep going. Add a union find field to the e-graph:

class EGraph:
    union_find : UnionFind
    def __init__(self):
        # ...
        self.union_find = UnionFind()

We're done with the first part of writing an e-graph, and are ready to move on to the second part.

The hash cons

We are ready for the second field of the e-graph. Philip proposes memo, a hash cons. Max is worried about the index map. But maybe it doesn't matter. Let's just go. Let's add the hash cons.

Make a new field, called map, which stores a dictionary. If it's ordered by insertion there are some benefits but it's not critical. In Rust, use IndexMap; in Python, use a plain dictionary. It's a map from Node to Id:

class EGraph:
    memo : dict[Node, ID]
    def __init__(self):
        # ...
        self.memo = {}

Philip points out again that there's two views of e-graphs. One view is "class-centric", where you have structural nodes and opaque IDs. The other view is "node-centric", where "e-classes" are structural, just lists of nodes. We're going to be class-centric. Max points out that in a node-centric implementation it is easier to fall into various exponential rabbit-holes.

Add

Let's start the add method. To start, we're not even going to look at the children. Here's what we'll do: check if it's in the memo. If so, return its value in the memo. If it's not, put it in the memo, mapped to a new ID returned by makeset:

class EGraph:
    def add(self, node : Node) -> ID:
        if node in self.memo:
            return self.memo[node]
        else:
            new_id = self.union_find.makeset()
            self.memo[node] = new_id
            return new_id

Max points out that this is basically the only possible implementation, since makeset is our only "factory" for IDs. The only real tweak you can make is something with the node's children. We're not looking at the children at all, but we'll go back and fix that later.

Note

Philip points out that you can always defensively over-canonicalize, but Max counters that you can always delay canonicalization until congruence closure. We'll come back to do all of the important canonicalization in add, but not yet.

Union

union barely exists. Just call union on the union find:

class EGraph:
    def union(self, a : ID, b : ID) -> bool:
        return self.union_find.union(a, b)

Test that you're actually hash-consing. Make a node type called Leaf which takes a string and makes a Node with a given string and no children. That's useful for testing:

def Leaf(name : str) -> Node:
    return Node(name, ())

Insert two identical nodes and check that you get the same ID. Insert different nodes and make sure they're different:

test_eg = EGraph()
a, a_copy, b = Leaf("a"), Leaf("a"), Leaf("b")
a_id, a_copy_id, b_id = test_eg.add(a), test_eg.add(a_copy), test_eg.add(b)
assert a_id == a_copy_id
assert a_id != b_id

Philip points out that instead of exposing Node directly, you can also do a "smart constructor" approach, where the E-graph is a factory for nodes. Max points out that either way works; you can always munge the input Node.

Congruence closure

Now let's do congruence closure. We need to add a method to the egraph called rebuild. It doesn't do anything yet:

class EGraph:
    def rebuild(self) -> None: pass

Let's start with a test. Insert two leaf nodes a and b, and then insert f(a) and f(b). Then union a and b, then rebuild, and then check if f(a) and f(b) are equal:

f_a = Node("f", (a_id,))
f_b = Node("f", (b_id,))
f_a_id, f_b_id = test_eg.add(f_a), test_eg.add(f_b)
test_eg.union(a_id, b_id)
test_eg.rebuild()
assert test_eg.union_find.find(f_a_id) == test_eg.union_find.find(f_b_id)

It can be good to add an is_equiv method, which just calls the same on the union-find. Max points out that it is nice terminologically to distinguish between equality and equivalence.

This test should fail right now, because rebuild doesn't do anything yet, and that's what's supposed to actually "perform" congruence.

Note

You can imagine doing rebuild every time you call union. Philip points out that that's the traditional way. But Max counters that it isn't the modern way. The modern way is to call rebuild manually, only when you need it.

Canonicalizing nodes

How are we gonna do congruence closure? The stupidest way possible: we're going to reconstruct memo over and over again, until it stops changing. Specifically, we're going to walk over all the nodes in the current memo, and re-insert them into the new dictionary, which will become the new memo. As we do this, we're going to have old keys and old values. When we re-insert a node, it might already be there. It could be there with the current ID, or a different ID. If it's different, you found a congruence. In that case we'll make progress toward congruence closure.

So, for example, that's what's going to happen with the f(a) and f(b) example. When you insert f(b), you're going to find the ID of f(a). So you'll discover that f(a) and f(b) are equal, allowing you to union those two IDs. You just repeat this until fixed-point, until you stop finding new congruences.

To make this work well, we need the lookup in memo to find as many hits as possible. So it's time to look at the children in add. Add a new function, canonicalize_node, and call it in add:

class EGraph:
    def canonicalize_node(self, n : Node) -> Node:
        return Node(n.f, tuple(self.union_find.find(child) for child in n.l))

    def add(self, n : Node) -> ID:
        n = self.canonicalize_node(n)
        # ...

This line takes the input node's children and calls the union-find's find on them. This converts every child into its canonical representative, so if you call add twice with congruent but not equal nodes, you're more likely to get a "hit" in the hash cons. Most of the time, this won't do anything, but there are all sorts of reasons you might have stale IDs floating around, and this procedure helps ensure that the hash cons only stores canonical IDs.

We're trying to fix some kind of out-of-order-ness to our adds and unions. In our test, we add f(a) and f(b), then union a and b. But if we'd done it the other way around, if we'd unioned a and b first, then f(a) and f(b) would be immediately equal, because of the canonicalization in the add method. So what we're doing, basically, is doing a second pass of re-adding f(a) and f(b), but after the union on a and b, to fix that out-of-order-ness.

You can do this in various places. You can do this now or later, more or less often. It's not a bug, strictly speaking, to not call it. But you probably want to do it eventually. We're going to do it once per rewrite iteration.

Note

Philip again points out that you can also have a smart constructor canonicalize e-nodes on creation. But in the implementation that we're writing, nodes are owned by the caller so the e-graph canonicalizes when it needs.

Rebuilding

So now we're ready to do rebuilding. Here's what we're going to do: take the hash-cons out of the e-graph, replacing it with an empty hash-cons; then walk the old e-graph and re-add every node to the new e-graph.

class EGraph:
    def rebuild(self) -> None:
        while True:
            old_memo = self.memo
            self.memo = {}
            # ...

Re-adding a node is very similar to adding a node, but with one difference: if the node isn't in the hash-cons, add calls makeset. But when we re-add a node, we can reuse the old ID:

class EGraph:
    def rebuild(self) -> None:
        while True:
            for old_node, old_id in old_memo.items():
                new_node = self.canonicalize_node(old_node)
                if new_node in self.memo:
                    new_id = self.memo[new_node]
                else:
                    new_id = self.union_find.find(old_id)
                    self.memo[new_node] = new_id
                # ...

Now we have an old ID and a new ID. Union them. This is the point where—if they're different—you've discovered a new congruence equality:

class EGraph:
    def rebuild(self) -> None:
        while True:
            for old_node, old_id in old_memo.items():
                # ...
                self.union(new_id, old_id)

Note that we're not invalidating the union-find (since it stores the unions). The union-find only stores opaque IDs. There is no way to go from an ID to a node, so the union-find isn't storing anything that could be invalid.

Now we just need to do this whole thing in a loop until the hash cons stops changing. Since each union returns a boolean, we just need to or that with a termination signal. If none of the union calls do anything, we can stop.

class EGraph:
    def rebuild(self) -> None:
        while True:
            keep_going = False
            for old_node, old_id in old_memo.items():
                # ...
                if self.union(new_id, old_id):
                    keep_going = True
            if not keep_going:
                break

The test should now pass. In fact, maybe add an assertion: when we're done, every ID in the hash cons, either in a node's children or the hash cons value, must be canonical.

Let me note that this was the buggiest part of the session so far. There were a couple of simultaneous implementations going and basically all of them ran into bugs. The sequence of steps here is very important, and if you get details wrong, it will infinite loop. You gotta break the right loop, for example. It's also tempting to call add, because most of re-adding a node is very similar, but you gotta reuse the old hashcons IDs, otherwise you will keep adding new IDs forever. Max and Philip made that mistake and it derailed us for a half hour. So you really do need to check that, at this point, everything works.

E-matching

Ok. So far, our e-graph can add concrete terms and equalities between them, but that's not how people actually use e-graphs today. Instead, they do equality saturation: initialize the e-graph with some concrete term, but then repeatedly apply rewrite rules to grow the e-graph. Let's implement that next.

We're going to need to implement quite a few moving pieces. We'll first need to define patterns, which describe terms that might be in our e-graph. We'll then implement e-matching, which searches the e-graph for terms matching a pattern. We'll finally implement the full rewrite cycle, which uses a second pattern to add terms and equalities to the e-graph, for every match found by e-matching.

Patterns

To start, we need patterns. A pattern is a syntax tree, with variables at the leaves. How you define this will depend on your language; Python doesn't have algebraic data types, so we'll make a Pattern class with two subclasses, but you can do it some other way if you're using some other language:

class Pattern: pass

@dataclass
class Var(Pattern):
    name : str

@dataclass
class App(Pattern):
    f : str
    l : list[Pattern]

Patterns have a related notion called a substitution, which is a map from variable names to IDs:

Substitution = dict[str, ID]

Let's add an instantiate method to the e-graph. It takes a pattern and a substitution and adds the pattern to the e-graph:

class EGraph:
    def instantiate(self, pat : Pattern, subst : Substitution) -> ID:
        match pat:
            case Var(name):
                return subst[name]
            case App(f, l):
                node = Node(f, tuple(self.instantiate(subpat, subst) for subpat in l))
                return self.add(node)

Again, there's basically only one way to write this function, which is a sign that it's a good function. This will be how the right-hand side of patterns works, the "apply" step. We're doing this first because it's easier, and it's also a nice API for adding nodes. It's not a bad idea here to add a test, where you use instantiate to add a node that you've already manually added, and make sure it's the same.

E-matching

Now let's do e-matching. E-matching is complex, so let's start with the the recursive core, which we'll call ematch_rec. It takes:

  • A pattern, the pattern that we're searching for;
  • An ID, the e-class that we're searching for it in;
  • A partial substitution, the constraints we've accumulated so far.

It returns a list of substitutions. Not just one substitution! A list! That's because a pattern might match in different ways, and we're going to return all of them. You can think of this as a list of completions of the input substitution, extensions of it so that the pattern matches. Here's what the signature looks like:

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        # ...

Nodes from classes

Now, the implementation of this method is complex enough, so let's put some helper methods on the board. First, nodes_in_class. It takes an ID and returns a list of nodes. Not a list of IDs—a list of nodes. This is important. Our implementation will be naive; a real implementation would add an index here to make it efficient, and you can do that later. But our implementation will be simple: it'll iterate over the hashcons and give us all the keys where the value is the input ID. We are reading the hash map backwards, basically:

class EGraph:
    def nodes_in_class(self, root : ID) -> list[Node]:
        out = []
        for node, id in self.memo.items():
            if id == root:
                out.append(node)
        return out

You don't need to do any find calls in here. We will only be doing this after calling rebuild.

There was a sidebar here between Philip and Max. The nodes_in_class is actually an e-analysis. In fact, Philip pointed out, it is the free analysis! A cool fact, though not relevant to our implementation.

Matching variables

Now let's get back to ematch_rec. It's recursive, so there are two cases.

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        match pat:
            case Var(name):
                # ...
            case App(f, subpats):
                # ...

If the pattern is a variable, there are two cases: either it is mentioned in the input substitution, or it is not. If it's not mentioned, then we are unconstrained and can add a new binding to the substitution for it. Do not mutate the substitution, copy it, and return a singleton list with the new substitution. In this case we are always, successfully, extending the input substitution, and there's only one extension:

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        match pat:
            case Var(name):
                if name not in subst:
                    return [ subst | { name: root } ]
                # ...

But what if it is in the substitution? Then we need to make sure that we agree with that constraint. If the substitution maps the variable to the current ID, then the input substitution is still valid, and we return it.

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        match pat:
            case Var(name):
                # ...
                elif subst[name] == root:
                    return [subst]
                # ...

But if it maps the variable to a different ID, then the input substitution cannot match this pattern, so we return an empty list:

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        match pat:
            case Var(name):
                # ...
                else:
                    return []

Note that there are three cases here: not in the substitution; in there and agree; in there and disagree. Each has a different behavior: extending the substitution, validating it, or dropping it.

Matching applications

Now, what if the pattern is not a variable? This is hairy code. We're gonna go line by line.

Write a loop. Get all the nodes in the current class, and loop through them. Filter out all the nodes where the function symbols don't match: check for equality and skip if not equal. Similarly do a length check: the node and the pattern need to have the same number of arguments, otherwise skip.

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        match pat:
            case App(f, subpats):
                for node in self.nodes_in_class(root):
                    if node.f != f: continue
                    if len(node.l) != len(subpats): continue
                    # ...

Now what are we doing? We're looping over all the nodes in the class, and looking at only ones that could match. Next, we clearly we need to recurse. So what we're gonna do is, we're going to zip the pattern children and the node children. We've established that they have the same number of children:

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        match pat:
            case App(f, subpats):
                for node in self.nodes_in_class(root):
                    # ...
                    for sub_id, sub_pat in zip(node.l, subpats):
                        # ...

Now, we have the child pattern, we have the child ID, we have a substitution. We could call ematch_rec. But this isn't quite right. What's the issue? Well, ematch_rec is going to return a list of substitutions. Which one is the right one? Or what if the list is empty? Also, we have a dependency here: when we recurse on the first child, and when we recurse on the second child, the substitutions they make have to agree, and we need to enforce that.

So what we're going to do is to maintain a list of substitutions that we thread through the children. (You could instead do an explicit join at the end. But we're not going to do that.) Create a singleton list with the initial substitution, called todo. It goes inside the nodes in class loop, but outside the zip loop:

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        match pat:
            case App(f, subpats):
                for node in self.nodes_in_class(root):
                    # ...
                    todo = [subst]
                    for sub_id, sub_pat in zip(node.l, subpats):
                        # ...

When we look at each child/subpattern pair, we need to call ematch_rec for each substitution in the todo list. Combine the results into a single list, with flatmap or whatever your language's idiom is here. And then after each child/pattern iteration, you replace the todo list with the new list of matches:

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        match pat:
            case App(f, subpats):
                for node in self.nodes_in_class(root):
                    # ...
                    for sub_id, sub_pat in zip(node.l, subpats):
                        results = []
                        for new_subst in todo:
                            results.extend(self.ematch_rec(sub_pat, sub_id, new_subst))
                        todo = results
                    # ...

This is tricky code! The extend here is doing the flatmap, and note the very common pattern where we are traversing todo to build a new version of it. You gotta get this right.

This is one of many different e-matching algorithms, and this one—top-down e-matching—is far from the best one. But it's a good starting-point for thinking about things; the better algorithms are more complex.

One we have left the zip loop, the todo list contains substitutions that agree with the initial substitution, and also all the subpatterns. So these are all good substitutions. So outside the node in class loop, make an empty list of outputs, and add this final todo list to the outputs, because each node in the class might potentially produce valid substitutions.

class EGraph:
    def ematch_rec(self, pat : Pattern, root : ID, subst : Substitution) -> list[Substitution]:
        match pat:
            case App(f, subpats):
                outputs = []
                for node in self.nodes_in_class(root):
                    # ...
                    outputs.extend(todo)
                return outputs

Oof! This is complicated, and it's another place where it's possible to screw up even with a type discipline. But just work through it line by line. But we are basically done now, at least with the core of pattern matching.

The difference from plain pattern matching is that at every stage there is a multiplicity of matches. This todo list is where that difference lives; it's what stores the set of possible substitutions.

Ok, so we are done with ematch_rec. We're out of the woods here. All we need is ematch, to start the recursion; all it does is call ematch_rec with an empty substitution.

class EGraph:
    def ematch(self, pat : Pattern, root : ID) -> list[Substitution]:
        return self.ematch_rec(pat, root, {})

We'll loop over IDs externally. Let's move on to that, now.

Rewrites

We are now ready to write the rewrite loop.

What is a rewrite? It's just two patterns, a left-hand side and a right-hand side:

@dataclass
class Rewrite:
    lhs : Pattern
    rhs : Pattern

Let's now add method called rewrite. It's going to take in a list of rewrites and apply all of the rewrites "in parallel" to the e-graph:

class EGraph:
    def rewrite(self, rules : list[Rewrite]) -> None: pass

You might want a helper function to get all IDs in the e-graph. It's the set of hashcons values:

class EGraph:
    def all_ids(self):
        return set(self.memo.values())

What we're gonna do is, we're gonna loop over all the rewrites and for each one we're gonna loop over all the IDs in the e-graph. For each one, we're going to e-match the left-hand-side pattern:

class EGraph:
    def rewrite(self, rules : list[Rewrite]) -> None:
        for rule in rules:
            for id in self.all_ids():
                substs = self.ematch(rule.lhs, id)
                # ...

Do not instantiate this substitution. If we start instantiating right now, we'll start populating the e-graph with stale e-nodes, and that will make e-matching exponentially slow. So we don't do that. Instead, we want to do what Philip calls "freezing the state" of the e-matching. We're going to add this substitution to a running list, in a "match object":

@dataclass
class Match:
    rhs : Pattern
    root : ID
    subst : Substitution

class EGraph:
    def rewrite(self, rules : list[Rewrite]) -> None:
        matches = []
        for rule in rules:
            for id in self.all_ids():
                # ...
                for subst in substs:
                    matches.append(Match(rule.rhs, id, subst))
        # ...

So we now build a running list of match objects as we find more matches, but we don't make any changes to the e-graph itself yet. You might recognize this as the kind of thing you do in C++ to avoid iterator invalidation, and that's a good way to think about it: while we're searching the e-graph, we don't want to modify it, so we store the modifications for later. Then, outside the double loop, we iterate over all match objects and call instantiate:

class EGraph:
    def rewrite(self, rules : list[Rewrite]) -> None:
        # ...
        for match_obj in matches:
            new_id = self.instantiate(match_obj.rhs, match_obj.subst)
            # ...

This creates new nodes in the e-graph, as directed by the rewrite rule. Finally, because it's a rewrite rule, we union the new and old nodes, to say that the matched and rewritten nodes are equal. That's why we needed to store the root ID in the match:

class EGraph:
    def rewrite(self, rules : list[Rewrite]) -> None:
        for match_obj in matches:
            # ...
            self.union(match_obj.root, new_id)

We are so close. All that's left is to iterate until saturation. Of course, practically, you typically don't iterate until fixed point. But for our simple test, we're going to do that.

Let's write a function called saturate. It's going to call rewrite in a loop. You rebuild the e-graph, then enter the loop. Record a fingerprint: the size of the union-find and the size of the hash-cons. Then call rewrite and then call rebuild. Record the fingerprint again: if they are the same, stop the loop; otherwise, continue looping:

class EGraph:
    def saturate(self, rules : list[Rewrite]) -> None:
        self.rebuild()
        fingerprint = (len(self.union_find.parent), len(self.memo))
        while True:
            self.rewrite(rules)
            self.rebuild()
            new_fingerprint = (len(self.union_find.parent), len(self.memo))
            if fingerprint == new_fingerprint:
                break
            else:
                fingerprint = new_fingerprint

We're done. Now we just need to do the one test case and pray that it's right.

Testing

Ok! Make a fresh e-graph and insert a sum of seven nodes, that is, the expression ((a + b) + (c + d)) + ((e + f) + g). Any parenthesization would work. The easiest way might be by instantiating a nullary pattern:

test_eg = EGraph()
root = test_eg.instantiate(
    App("+", [
        App("+", [
            App("+", [
                App("a", []),
                App("b", []),
            ]),
            App("+", [
                App("c", []),
                App("d", []),
            ]),
        ]),
        App("+", [
            App("+", [
                App("e", []),
                App("f", []),
            ]),
            App("g", []),
        ]),
    ]),
    {})

Then you need two rewrite rules: commutativity, a + b -> b + a, and associativity (a + b) + c -> a + (b + c):

rules = [
    Rewrite(App("+", [Var("a"), Var("b")]), App("+", [Var("b"), Var("a")])),
    Rewrite(App("+", [App("+", [Var("a"), Var("b")]), Var("c")]), App("+", [Var("a"), App("+", [Var("b"), Var("c")])])),
]

Just call saturate with this set of rewrites, and look at the final size of the e-graph. Any permutation of the seven nodes should be equal to any other. You should have 127 different IDs, or e-classes, one for each non-empty subset of the seven variables. And you should have 1939 e-nodes: 1932 add nodes and 7 leaf nodes:

test_eg.saturate(rules)
assert len(test_eg.all_ids()) == 127
assert len(test_eg.memo) == 1939

If you get a different number, something is wrong. Make sure it works.

Note

There's a formula here. First, e-classes. Every e-class is a subset of {a, b, c, d, e, f, g}, though we don't have an e-class for the empty sum, so that's \(2^n - 1\). Every add e-node is two subsets, but they have to be disjoint, we never have the same variables appear on both sides. There are \(3^n\) of those: each of the seven variables is on the left, the right, or neither. But subtract out cases where one side is empty; there are \(2\cdot2^n - 1\) of those, leaving a total of \(3^n - 2^{n+1} + 1\) add nodes. And then there are \(n\) variable nodes. That gives the 1939 number.

Conclusion

Max's, Jacob's, Max B's, Thalia's, and Bhargav's implementations are available on Github.4 [4 As is one of my older ones.] They all follow the structure in this guide, but with language-specific variations of various kinds. I hope that following this guide has given you your own starting point for working on equality saturation, matching, and analysis. I look forward to hearing about it at PLDI 2027!

Todo

There are some extensions of this we might want. It would be nice to define the concept of terms at the beginning, so that e-nodes and patterns are more natural. Flattening and un-flattening could be discussed there; maybe it would help. Also, I think extraction at the end would be valuable. We could use cancellation as a test case.

Footnotes:

1

Meant in a vague, more intuitive way, not in the sense of a strict proof. This guide is about avoiding mistakes that cause the engine to, for example, find exponentially more matches than necessary, but it sacrifices many 2× and 3× optimizations for simplicity.

2

Philip calls it f.

3

But normally you have pointers from a tree node to its children, so this is an "inverted" tree.

4

As is one of my older ones.