E-Graph Layout Optimizer
The e-graph optimizer, living in
crates/ftui-layout/src/egraph.rs (~1,700 lines), is a layout-level
optimizer that discovers equivalent-but-cheaper constraint expressions
by equality saturation. The idea is borrowed directly from the
compiler world (e.g., egg in Rust), applied to a tiny arithmetic
language over layout sizes.
It is not on the hot path for every frame. You reach for it when building a layout solver component that wants to pick between many equivalent constraint formulations and choose the smallest cost.
A 60-second primer on e-graphs
An e-graph is a data structure that stores many expressions and the equivalence classes between them. Conceptually:
- E-class: a set of expressions known to be equal (e.g.
{a + 0, a, a * 1}). - E-node: one concrete expression inside an e-class (
a + 0). - Rewrite rule: a pattern-and-replacement that declares two things
equal (
Add(a, Num(0)) ≡ a). - Saturation: keep applying rewrites until nothing new is discovered.
- Extraction: pick the lowest-cost representative from each final e-class.
An e-graph lets you explore an exponential space of equivalent expressions in near-linear time, because equivalences are stored in shared state rather than enumerated.
The Expr language
pub enum Expr {
Num(u16), // concrete cell value
Var(NodeId), // reference to a widget
Add(Id, Id),
Sub(Id, Id),
Max(Id, Id),
Min(Id, Id),
Div(Id, Id),
Mul(Id, Id),
Clamp { min: Id, max: Id, val: Id },
HFlex(Vec<Id>), // horizontal flex container
VFlex(Vec<Id>), // vertical flex container
Fill(Id), // fill remaining space
}Id is an opaque handle into the e-graph; NodeId is an external
widget identifier.
Representative rewrite rules
A small sample of the ~30 rules that ship; each one says “these two expressions are equal”:
| Rule | Name |
|---|---|
Add(a, Num(0)) ≡ a | additive identity |
Max(a, a) ≡ a | idempotent max |
Min(a, a) ≡ a | idempotent min |
Add(a, b) ≡ Add(b, a) | commutativity |
Add(Add(a, b), c) ≡ Add(a, Add(b, c)) | associativity |
HFlex(a, HFlex(b, c)) ≡ HFlex(a, b, c) | flatten nesting |
Clamp { min: x, max: y, val: y } ≡ y when y ≤ x | clamp simplification |
Each rule produces zero or more new merge() calls; the saturator keeps
applying them until a fixed point is reached (or a budget is exhausted).
Architecture
pub struct EGraph {
parents: Vec<u32>, // union-find parent pointers
ranks: Vec<u8>, // union-by-rank
nodes: Vec<Vec<Expr>>, // e-nodes grouped by e-class
memo: HashMap<Expr, Id>, // hashcons: canonical expr → e-class
last_apply_count: usize,
}- Union-find (
parents,ranks) stores the partition of ids into e-classes. nodesstores, per class, the concrete e-nodes that live there.memois the hashcons: the sameExprshape always maps to the same class. When youadd()an expression, canonicalization follows everyIdto its class representative before doing the lookup.
Six methods are load-bearing:
EGraph::new() -> Self
EGraph::add(expr) -> Id // intern + canonicalize
EGraph::merge(a, b) -> Id // union-find
EGraph::find(id) -> Id // path-compressed lookup
EGraph::equiv(a, b) -> bool // same e-class?
EGraph::apply_rules() -> usize // saturate
EGraph::extract(id) -> Expr // lowest-cost from the classThe extraction cost model
After saturation, extract(id) walks the e-class and picks the cheapest
e-node. A simple model weights each variant:
where is a small per-variant constant:
Num/Var— cheapest (leaves).Add/Sub— small constant.Max/Min/Div/Mul— larger.Clamp— larger still (three children).HFlex/VFlex— proportional to arity.Fill— discount because it’s the “already solved” form.
The recursion terminates because every Id strictly reduces when you
take the min over its e-class.
Worked example
use ftui_layout::egraph::{EGraph, Expr, NodeId};
let mut g = EGraph::new();
let a = g.add(Expr::Var(NodeId(0)));
let b = g.add(Expr::Var(NodeId(1)));
let c = g.add(Expr::Var(NodeId(2)));
// HFlex(a, HFlex(b, c))
let inner = g.add(Expr::HFlex(vec![b, c]));
let nested = g.add(Expr::HFlex(vec![a, inner]));
g.apply_rules();
// After the flatten rule fires, HFlex(a, HFlex(b, c))
// is in the same e-class as HFlex(a, b, c).
let flat = g.add(Expr::HFlex(vec![a, b, c]));
assert!(g.equiv(nested, flat));
// Extraction picks the flatter form (lower cost than the nested one).
let best = g.extract(nested);
// best is Expr::HFlex([a, b, c])solve_layout — the production entry point
For real use, you wrap the e-graph in solve_layout which encodes
constraints, saturates, extracts, and reifies sizes:
pub fn solve_layout(
constraints: &[Constraint],
total: u16,
config: &SaturationConfig,
) -> Vec<u16>;
pub fn solve_layout_default(constraints: &[Constraint], total: u16)
-> Vec<u16>;SaturationConfig exposes the saturation budget (max_iterations),
memory watchdog (max_memory_bytes), and other guards. On overflow
SaturationResult::guard_triggered tells you which guard fired.
When to reach for this
You’re building a new solver component
Hand-coded layout math accumulates special cases. An e-graph lets you prove your simplifications are correct by stating them as rules rather than burying them in conditionals.
You have constraint forests that re-occur
If the same constraint tree shape is laid out thousands of times (e.g. a table’s column expressions across many rows), saturation once + memoization pays off.
You want a provable optimum
Saturation explores equivalence classes exhaustively within the budget. The extracted expression is guaranteed to be no worse than any form you could have hand-written from the rules you declared.
Pitfalls
Rule order and rule explosion. Adding a rule that discovers many new equalities per invocation (e.g. distributivity) can make saturation blow past its budget without reaching a fixed point. Keep rule count modest; profile saturation counts in tests.
Extraction cost is a heuristic, not a guarantee. The weights define what “cheapest” means for your use case. If the default model picks a form you don’t like, adjust the weights rather than adding more rules.
Don’t use the e-graph on the per-frame hot path. Build it once per widget-tree shape and cache the extracted expression. Saturation is cheap but nonzero.