From 507eaa592f3f8151cead285bbed8958c78f67aa7 Mon Sep 17 00:00:00 2001 From: JingrenWang Date: Thu, 3 Sep 2026 13:09:51 +0800 Subject: [PATCH 1/2] Fix(Processes): Make subterm hoisting deterministic across processes Signed-off-by: JingrenWang --- python/egglog/egraph_state.py | 12 +++-- python/tests/test_deterministic_hoisting.py | 55 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 python/tests/test_deterministic_hoisting.py diff --git a/python/egglog/egraph_state.py b/python/egglog/egraph_state.py index 876466e1..726a681b 100644 --- a/python/egglog/egraph_state.py +++ b/python/egglog/egraph_state.py @@ -858,8 +858,12 @@ def _exprs_multiple_parents(typed_expr: TypedExprDecl) -> list[TypedExprDecl]: """ Returns all expressions that have multiple parents (a list but semantically just an ordered set). """ - to_traverse = {typed_expr} - traversed = set[TypedExprDecl]() + # Traverse with a deterministic LIFO worklist instead of a set of objects: + # popping from a set iterates in id()-hash order (memory addresses), which + # varies per process and made the hoisting order (and hence the $__expr_N + # let numbering and e-class ids) nondeterministic across runs. + to_traverse = [typed_expr] + traversed: set[TypedExprDecl] = set() traversed_twice = list[TypedExprDecl]() while to_traverse: typed_expr = to_traverse.pop() @@ -869,9 +873,9 @@ def _exprs_multiple_parents(typed_expr: TypedExprDecl) -> list[TypedExprDecl]: traversed.add(typed_expr) expr = typed_expr.expr if isinstance(expr, CallDecl): - to_traverse.update(expr.args) + to_traverse.extend(expr.args) elif isinstance(expr, PartialCallDecl): - to_traverse.update(expr.call.args) + to_traverse.extend(expr.call.args) return traversed_twice diff --git a/python/tests/test_deterministic_hoisting.py b/python/tests/test_deterministic_hoisting.py new file mode 100644 index 00000000..3e98d283 --- /dev/null +++ b/python/tests/test_deterministic_hoisting.py @@ -0,0 +1,55 @@ +"""Expression hoisting must be deterministic across processes. + +`_exprs_multiple_parents` used to traverse the expression DAG with a set of +``TypedExprDecl`` objects and ``.pop()`` from it, so the traversal order -- +and with it the order in which shared subterms were hoisted into +``$__expr_N`` let-bindings -- depended on id()-based hashing (memory +addresses). Identical programs were therefore lowered into different +(equivalent) e-graphs in different processes, making ``serialize()`` output +nondeterministic. +""" + +import subprocess +import sys + +SCRIPT = """ +from __future__ import annotations + +import hashlib + +from egglog import EGraph, Expr, StringLike + + +class B(Expr): + @classmethod + def var(cls, name: StringLike) -> B: ... + + def __and__(self, o: B) -> B: ... + + def __or__(self, o: B) -> B: ... + + def __invert__(self) -> B: ... + + +eg = EGraph() +x, y = B.var("x"), B.var("y") +shared = x & y +expr = shared | y +for _ in range(24): + expr = (shared & expr) | (expr & (shared | x)) +eg.let("$e", expr) +print(hashlib.sha256(eg._serialize().to_json().encode()).hexdigest()) +""" + + +def test_serialization_is_deterministic_across_processes() -> None: + hashes = [] + for _ in range(3): + proc = subprocess.run( + [sys.executable, "-c", SCRIPT], + capture_output=True, + text=True, + check=True, + ) + hashes.append(proc.stdout.strip()) + assert len(set(hashes)) == 1, f"nondeterministic serialization: {hashes}" From c33c4f151c9bb0eb093ea123e001894d800c619d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:44:54 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- python/tests/test_deterministic_hoisting.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python/tests/test_deterministic_hoisting.py b/python/tests/test_deterministic_hoisting.py index 3e98d283..42e62aab 100644 --- a/python/tests/test_deterministic_hoisting.py +++ b/python/tests/test_deterministic_hoisting.py @@ -1,4 +1,5 @@ -"""Expression hoisting must be deterministic across processes. +""" +Expression hoisting must be deterministic across processes. `_exprs_multiple_parents` used to traverse the expression DAG with a set of ``TypedExprDecl`` objects and ``.pop()`` from it, so the traversal order --