Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
619f801
Port tuple-output functions from egglog#938
oflatt Jul 2, 2026
ed920b5
egglog-experimental: adapt to tuple-output Schema/ResolvedCall
oflatt Jul 7, 2026
25724ad
Tidy tuple-output doc comments
oflatt Jul 7, 2026
e688d4c
Load all output columns in the input command for tuple-output functions
oflatt Jul 7, 2026
fcd5cfa
egglog-bridge: general merge-action MergeFn variants (Seq/TableInsert…
oflatt Jul 2, 2026
8cee351
egglog: native congruence :merge builder for constructor views (inert)
oflatt Jul 2, 2026
063a19d
egglog: resolve term-encoding congruence via native :merge (proof mode)
oflatt Jul 2, 2026
c7c2ce8
egglog: make the proof encoding self-contained via :internal-proof-names
oflatt Jul 3, 2026
5ac8196
core-relations: support self-referential merges (self-write pre-seed)
oflatt Jul 7, 2026
a6f6e9a
egglog-bridge: knot-tying for self-referential merges
oflatt Jul 7, 2026
02b4e59
egglog: single self-referential UF for term (non-proof) encoding
oflatt Jul 7, 2026
58abda4
egglog: single self-referential UF for proof-mode encoding
oflatt Jul 7, 2026
ab88652
Clean up encoding docs and comments for consistency
oflatt Jul 7, 2026
07b7e7e
some cleanup
oflatt Jul 10, 2026
2a18824
Reject extracting tuple-output functions; expand tuple-output tests
oflatt Jul 11, 2026
645310a
Add engine-level tests for merge-action variants and self-referential…
oflatt Jul 13, 2026
49e461e
Add design note: :merge as an action block (retire the MergeFn DSL)
oflatt Jul 13, 2026
9118adb
backend: identity/payload value columns for merges
oflatt Jul 13, 2026
bd2c649
backend: make the identity-column guard subsume-safe
oflatt Jul 13, 2026
9f8489f
backend: make the identity-column guard opt-in via Option
oflatt Jul 13, 2026
599c940
encoding: drop IfEq from merges via identity columns + orientation prims
oflatt Jul 13, 2026
de24e1e
:merge is a value-producing action block, typechecked via the action …
oflatt Jul 13, 2026
4e19453
merge: run the action block directly; drop Seq/TableInsert/Construct
oflatt Jul 13, 2026
c70259e
cargo fmt: normalize bridge examples and merge files
oflatt Jul 13, 2026
e632ce4
Add .egg tests for tuple output and merge action blocks
oflatt Jul 13, 2026
aa03f91
merge actions
oflatt Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion egglog-experimental/src/fresh_macro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ fn desugar_fresh_rule(
name: constructor_name.clone(),
schema: Schema {
input: schema,
output: output_sort,
outputs: vec![output_sort],
},
cost: first_opts.cost,
unextractable: first_opts.unextractable,
Expand Down
2 changes: 2 additions & 0 deletions egglog-experimental/src/primitive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ fn required_context(egraph: &mut EGraph, expr: &ResolvedExpr) -> Context {
}
}
ResolvedCall::Primitive(_) => Context::Pure,
// `values` builds/destructures a tuple value; it reads or writes no tables.
ResolvedCall::Values(_) => Context::Pure,
ResolvedCall::Func(func) => match func.subtype {
FunctionSubtype::Constructor => Context::Write,
FunctionSubtype::Custom => Context::Read,
Expand Down
2 changes: 1 addition & 1 deletion egglog-experimental/src/set_cost.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ impl Macro<Vec<Command>> for SetCostDeclarations {
if !*unextractable {
let cost_table_name = get_cost_table_name(name);
let mut cost_table_schema = schema.clone();
cost_table_schema.output = "i64".into();
cost_table_schema.outputs = vec!["i64".into()];
cost_table_commands.push(Command::Function {
span: span.clone(),
name: cost_table_name,
Expand Down
2 changes: 1 addition & 1 deletion egglog-experimental/src/table_rows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub(crate) fn table_layout(egraph: &EGraph, name: &str, span: Span) -> Result<Ta
Ok(TableLayout {
subtype: func.subtype(),
input_sorts: schema.input.clone(),
output_sort: schema.output.clone(),
output_sort: schema.output().clone(),
})
}

Expand Down
28 changes: 28 additions & 0 deletions egglog/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@

## [Unreleased] - ReleaseDate

- **Single self-referential union-find (proof mode).** The proof term encoding now
represents each sort's union-find as one native-tuple function `@UF : (S) -> (S, Proof)`
(parent + a proof `key = parent`) with a self-referential `:merge`, replacing the former
two-table `@UF_S (S S) -> Proof` relation plus `@UF_Sf` index. Constructor-view congruence
and the UF self-merge both keep the smaller e-class and stage an oriented displaced edge,
so the `single_parent`/`uf_function_index` rules and per-term self-loop seeds are gone. This
mirrors the term (non-proof) encoding's single-table `@UF : (S) -> S`.
- **Tuple-output functions.** A function may declare more than one output sort, e.g.
`(function interval (Math) (i64 i64) :merge (values (max old0 new0) (min old1 new1)))`. Such a
function stores its outputs as separate value columns; the functional dependency is
`keys -> (value0, value1, ...)`. Outputs are destructured in queries with
`(= (values lo hi) (interval x))`, written with `(set (interval x) (values 0 100))`, and merged
with a `(values ...)` clause whose `i`-th element merges column `i` using the bound variables
`old0`, `new0`, `old1`, `new1`, .... Tuple outputs are only allowed for plain functions (not
constructors, relations, or view tables) and are not supported by the term/proof encoding.
- **`:merge` action blocks.** A `:merge` may be a value-producing action block
`:merge (<action>* <result-expr>)`: the actions run first (with `old`/`new` bound), then the
trailing expression is the merged value. Actions may be `let` (bind an intermediate value used by
later actions or the result), `set` (write another function), or `union` (unify two eclasses). A
bare `:merge <expr>` (no actions) is unchanged.
- Built-in keywords (most command, action, and schedule heads such as `function`, `set`, `union`,
`rule`, `run`, ..., plus the tuple constructor `values`) are now reserved and may no longer be
used as user identifiers (function/sort/constructor/relation/variant names or variables). Names
starting with `:` may not be used as identifiers (definition names), since that prefix marks
option keywords (`:merge`, `:cost`, ...); it is still accepted in expression position so command
macros can consume their own option markers (e.g. `:until`). The common-word commands `input` and
`output` are only partially reserved: they remain usable as variables, but not as definition names
or as the head of a call expression.
- Add typed `EGraph` extension state that clones with `EGraph` and is restored by `push`/`pop`.
- Report full source file paths in egglog span and error messages.
- Fix seminaive matching after nested containers rebuild in place by propagating dirty container ids through parent containers.
Expand Down
8 changes: 8 additions & 0 deletions egglog/core-relations/src/dependency_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ impl DependencyGraph {
pub(crate) fn strata(&self) -> impl Iterator<Item = &IndexSet<TableId>> {
self.levels.iter().map(|(_, tables)| tables)
}
/// Whether this table's merge reads another table (has a read dependency). Tables with read
/// dependencies sit above level 0; tables with only write dependencies (or none) are at level
/// 0. Used to decide whether the dependency-agnostic `merge_simple` fast path is safe.
pub(crate) fn has_read_deps(&self, table: TableId) -> bool {
self.to_level
.get(table)
.is_some_and(|level| *level != LevelId::new(0))
}
pub(crate) fn write_deps(&self, table: TableId) -> impl Iterator<Item = TableId> + '_ {
self.write_deps
.get(table)
Expand Down
34 changes: 28 additions & 6 deletions egglog/core-relations/src/free_join/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,16 @@ impl Database {
loop {
to_merge.clear();
let to_merge_vec = self.notification_list.reset();
if to_merge_vec.len() < 4 {
// `merge_simple` is faster but ignores read/write-dependency ordering, so it is only
// sound when no dirty table's merge reads another table (e.g. a `Construct`/`Function`
// lookup inside a `:merge`). With a read dependency, an unordered merge could observe a
// stale, not-yet-merged target — and a `Construct` could then mint a duplicate id — so
// fall back to the dependency-ordered strata path however few tables are dirty.
if to_merge_vec.len() < 4
&& !to_merge_vec
.iter()
.any(|table| self.deps.has_read_deps(*table))
{
ever_changed |= self.merge_simple(to_merge_vec);
break;
}
Expand Down Expand Up @@ -607,7 +616,16 @@ impl Database {
while !to_merge.is_empty() {
for table_id in to_merge.iter().copied() {
let mut info = self.tables.unwrap_val(table_id);
let mut es = ExecutionState::new(self.read_only_view(), Default::default());
// Pre-seed the table's OWN buffer so a self-referential merge — one that stages a
// write back into its own table (e.g. the term encoder's `@UF` recursive
// parent-union) — can stage it. The table has been `unwrap_val`'d out of
// `self.tables`, so the lazy `new_buffer()` path (which indexes `self.tables`) would
// fail for the self id. Staged rows land in `pending_state` and are picked up on the
// next fixpoint iteration of this loop. Non-self-referential merges get an empty,
// never-touched buffer. (The strata path in `merge_all` handles this via write-deps.)
let mut bufs = DenseIdMap::default();
bufs.insert(table_id, info.table.new_buffer());
let mut es = ExecutionState::new(self.read_only_view(), bufs);
changed |= info.table.merge(&mut es).added || es.changed;
self.tables.insert(table_id, info);
}
Expand All @@ -625,10 +643,14 @@ impl Database {
pub fn merge_table(&mut self, table: TableId) -> bool {
let mut info = self.tables.unwrap_val(table);
self.total_size_estimate = self.total_size_estimate.wrapping_sub(info.table.len());
let table_changed = info.table.merge(&mut ExecutionState::new(
self.read_only_view(),
Default::default(),
));
// Pre-seed the table's own write buffer so a self-referential merge can stage a write back
// into itself (the table has been `unwrap_val`'d out, so the lazy `new_buffer()` path would
// fail for the self id). This mirrors `merge_simple`; see the comment there.
let mut bufs = DenseIdMap::default();
bufs.insert(table, info.table.new_buffer());
let table_changed = info
.table
.merge(&mut ExecutionState::new(self.read_only_view(), bufs));
self.total_size_estimate = self.total_size_estimate.wrapping_add(info.table.len());
self.tables.insert(table, info);
table_changed.added
Expand Down
14 changes: 14 additions & 0 deletions egglog/egglog-ast/src/generic_ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ pub struct GenericActions<Head: Clone + Display, Leaf: Clone + PartialEq + Eq +
pub Vec<GenericAction<Head, Leaf>>,
);

/// The `:merge` of a function: a *value-producing action block*. On a functional-dependency
/// conflict, the `actions` run (with `old`/`new` — or `old0`/`new0`/... for tuple outputs — bound
/// to the conflicting values), then `result` is evaluated to the merged value(s). A plain
/// `:merge <expr>` has no actions (see [`GenericMerge::result_only`]).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct GenericMerge<Head, Leaf>
where
Head: Clone + Display,
Leaf: Clone + PartialEq + Eq + Display + Hash,
{
pub actions: GenericActions<Head, Leaf>,
pub result: GenericExpr<Head, Leaf>,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum GenericAction<Head, Leaf>
where
Expand Down
61 changes: 61 additions & 0 deletions egglog/egglog-ast/src/generic_ast_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,67 @@ where
}
}

impl<Head, Leaf> GenericMerge<Head, Leaf>
where
Head: Clone + Display,
Leaf: Clone + PartialEq + Eq + Display + Hash,
{
/// A merge with no actions: just a result expression (the `:merge <expr>` form).
pub fn result_only(result: GenericExpr<Head, Leaf>) -> Self {
Self {
actions: GenericActions::default(),
result,
}
}

/// Applies `f` to every expression in the merge's actions and result.
pub fn visit_exprs(
self,
f: &mut impl FnMut(GenericExpr<Head, Leaf>) -> GenericExpr<Head, Leaf>,
) -> Self {
Self {
actions: self.actions.visit_exprs(f),
result: self.result.visit_exprs(f),
}
}

/// Applies the `head` and `leaf` mappings to every symbol in the merge.
pub fn map_symbols<Head2, Leaf2>(
self,
head: &mut impl FnMut(Head) -> Head2,
leaf: &mut impl FnMut(Leaf) -> Leaf2,
) -> GenericMerge<Head2, Leaf2>
where
Head2: Clone + Display,
Leaf2: Clone + PartialEq + Eq + Display + Hash,
{
GenericMerge {
actions: self.actions.map_symbols(head, leaf),
result: self.result.map_symbols(head, leaf),
}
}
}

impl<Head, Leaf> Display for GenericMerge<Head, Leaf>
where
Head: Clone + Display,
Leaf: Clone + PartialEq + Eq + Display + Hash,
{
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
if self.actions.is_empty() {
// Result-only: `:merge <expr>`.
write!(f, "{}", self.result)
} else {
// Action block: `:merge (<action>* <result>)`.
write!(f, "(")?;
for action in self.actions.iter() {
write!(f, "{action} ")?;
}
write!(f, "{})", self.result)
}
}
}

impl<Head, Leaf> GenericAction<Head, Leaf>
where
Head: Clone + Display,
Expand Down
4 changes: 4 additions & 0 deletions egglog/egglog-bridge/examples/ac.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ fn main() {
let mut egraph = EGraph::default();
let int_base = egraph.base_values_mut().register_type::<i64>();
let num_table = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Base(int_base), ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
name: "num".into(),
can_subsume: false,
});
let add_table = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id; 3],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand Down
26 changes: 26 additions & 0 deletions egglog/egglog-bridge/examples/math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ fn main() {
let string_ty = egraph.base_values_mut().register_type::<&'static str>();
// tables
let diff = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
name: "diff".into(),
can_subsume: false,
});
let integral = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -35,13 +39,17 @@ fn main() {
});

let add = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
name: "add".into(),
can_subsume: false,
});
let sub = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -50,6 +58,8 @@ fn main() {
});

let mul = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -58,6 +68,8 @@ fn main() {
});

let div = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -66,6 +78,8 @@ fn main() {
});

let pow = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -74,6 +88,8 @@ fn main() {
});

let ln = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -82,6 +98,8 @@ fn main() {
});

let sqrt = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -90,6 +108,8 @@ fn main() {
});

let sin = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -98,6 +118,8 @@ fn main() {
});

let cos = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Id, ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -106,6 +128,8 @@ fn main() {
});

let rat = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Base(rational_ty), ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand All @@ -114,6 +138,8 @@ fn main() {
});

let var = egraph.add_table(FunctionConfig {
n_vals: 1,
n_identity_vals: None,
schema: vec![ColumnTy::Base(string_ty), ColumnTy::Id],
default: DefaultVal::FreshId,
merge: MergeFn::UnionId,
Expand Down
Loading