diff --git a/Makefile b/Makefile index a6e9417b..141ec734 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ .PHONY: \ check nits test python-check python-nits rust-check rust-nits \ - proof-tests benchmark-smoke nightly nightly-uv nightly-rustup \ + proof-tests benchmark-smoke nightly nightly-local nightly-uv nightly-rustup \ update-snapshots format \ python-lock python-format-check python-lint python-typecheck python-test \ - rust-format-check rust-clippy rust-test + rust-format-check rust-clippy rust-doc-links rust-test BENCHMARK_SMOKE_REPORT ?= /tmp/egglog-encoding-bench-smoke.jsonl @@ -47,7 +47,7 @@ python-test: rust-check: rust-nits rust-test -rust-nits: rust-format-check rust-clippy +rust-nits: rust-format-check rust-clippy rust-doc-links rust-format-check: cargo fmt --all -- --check @@ -60,6 +60,12 @@ rust-clippy: cargo clippy --workspace --all-targets -- -D warnings cargo clippy -p egglog-experimental --features dd-backend --all-targets -- -D warnings +# Clippy does not resolve doc links, and plain `cargo doc` skips the private +# items most of this codebase documents, so a rename leaves stale links behind +# unless rustdoc is run over them too. +rust-doc-links: + RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items --workspace + # This is a name-filtered subset of rust-test, useful for proof iteration. proof-tests: cargo test --workspace --test files 'proofs/' @@ -74,12 +80,12 @@ benchmark-smoke: 'from pathlib import Path; import sys; from benchmarking.reports.store import ReportStore; assert ReportStore(Path(sys.argv[1])).row_count > 0' \ "$(BENCHMARK_SMOKE_REPORT)" -# Benchmark every endpoint on this checkout and on main, then copy eval-live's -# interactive report to nightly/output/. The egraphs-good nightly service -# (nightly.cs.washington.edu) runs this target and serves that directory, -# matching `report=` in the nightly configuration. +# Benchmark each endpoint in nightly_bench.py's ENDPOINTS on this checkout and on +# main, then copy eval-live's interactive report to nightly/output/. The +# egraphs-good nightly service (nightly.cs.washington.edu) runs this target and +# serves that directory, matching `report=` in the nightly configuration. nightly: nightly-uv nightly-rustup - $(NIGHTLY_UV) run --locked python scripts/nightly_bench.py + CARGO_HOME="$(CARGO_HOME_DIR)" $(NIGHTLY_UV) run --locked python scripts/nightly_bench.py nightly-uv: @command -v uv >/dev/null || test -x "$(UV_BOOTSTRAP_DIR)/uv" || \ @@ -92,6 +98,11 @@ nightly-rustup: | env CARGO_HOME="$(CARGO_HOME_DIR)" sh -s -- \ -y --no-modify-path --default-toolchain none +# The nightly host's run at one round, for trying it locally. nightly/output/ is +# git-ignored, so this writes it just as the host does. +nightly-local: nightly-uv nightly-rustup + CARGO_HOME="$(CARGO_HOME_DIR)" $(NIGHTLY_UV) run --locked python scripts/nightly_bench.py --rounds 1 + update-snapshots: uv run --locked pytest -q --snapshot-update --snapshot-details diff --git a/README.md b/README.md index bd8cade1..7531cfd4 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ make rust-nits # rustfmt check and Clippy only make proof-tests # proof-focused subset of the workspace tests make benchmark-smoke make nightly # benchmark the nightly endpoints and publish nightly/output/ +make nightly-local # the same run at one round, for trying it out make update-snapshots make format # apply Ruff and rustfmt formatting ``` @@ -380,6 +381,9 @@ failing the run, and the output directory is only overwritten after a successful run. Edit `TARGETS` and `ENDPOINTS` in `scripts/nightly_bench.py` to change what is measured. +`make nightly-local` is the same run at `--rounds 1`, for trying the whole +pipeline out without waiting for a full nightly. + The [egraphs-good nightly service](https://nightly.cs.washington.edu) checks out this repository, runs `make nightly`, and serves `nightly/output/`, matching the `report=` entry in the nightly configuration. Two things that runner does not @@ -390,6 +394,10 @@ the box has none. The runner also leaves rustup's `~/.cargo/bin` off `PATH`, which would leave cargo resolving to Ubuntu's — too old for `rust-toolchain.toml`'s pin — so `nightly_bench.py` puts those shims first. +Both installers run `curl … | sh`, so on a developer box prefer installing uv +and rustup yourself: `make nightly` and `make nightly-local` skip each one that +is already there. Only a bare CI runner needs them. + ### CPU profiling Benchmark reports answer whether and where performance changed. Use the diff --git a/egglog-experimental/dd/src/compile.rs b/egglog-experimental/dd/src/compile.rs index 8f172c8b..594b4185 100644 --- a/egglog-experimental/dd/src/compile.rs +++ b/egglog-experimental/dd/src/compile.rs @@ -144,4 +144,15 @@ impl Slot { pub struct ReadKey { pub func: FunctionId, pub mode: ReadMode, + /// Zero for an ordinary read. Otherwise this stream is the *occurrence view* + /// of `func`: one row per (value, base row) for each value the base row holds + /// in a column whose bit is set here, deduplicated per row. + pub occurrence_cols: u64, +} + +impl ReadKey { + /// The columns the occurrence view reads, empty for an ordinary read. + pub fn occurrence_columns(&self) -> impl Iterator + '_ { + (0..u64::BITS as usize).filter(|c| self.occurrence_cols & (1u64 << c) != 0) + } } diff --git a/egglog-experimental/dd/src/dd_native.rs b/egglog-experimental/dd/src/dd_native.rs index de44048e..ea21b6fb 100644 --- a/egglog-experimental/dd/src/dd_native.rs +++ b/egglog-experimental/dd/src/dd_native.rs @@ -80,7 +80,7 @@ pub const W: usize = 48; /// A fixed-width relation or binding row flowing through the DD dataflow. Input /// rows store relation columns in the low slots. Intermediate binding columns /// are assigned by the current `ProjectionPlan` stage, and captured outputs -/// repack surviving variables into the low slots in [`JoinPlan::var_order`]. +/// repack surviving variables into the low slots in [`JoinPlan`]'s variable order. /// /// A NEWTYPE over `[u32; W]` (rather than the bare array) because timely's /// `ExchangeData` bound required by DD joins is @@ -181,6 +181,50 @@ pub fn plan_join(rule: &RuleSpec) -> Result { for atom in &rule.core.body.atoms { match atom.head { + // An index atom reads the occurrence view of `id`: the leading arg is + // the occurring value, then the base row. The relation's own unit + // output trails the args and is not part of the view. + RuleBodyCall::IndexTable { + id, + ref any_of, + read, + } => { + if let Some(col) = any_of.iter().find(|c| **c >= u64::BITS as usize) { + return Err(format!( + "index column {col} is beyond the {} a read key can track", + u64::BITS + )); + } + let occurrence_cols = any_of.iter().fold(0u64, |m, c| m | (1u64 << c)); + if occurrence_cols == 0 { + return Err("index atom lists no columns".to_string()); + } + let view_args = atom + .args + .split_last() + .map(|(_unit, rest)| rest) + .unwrap_or(&atom.args); + if view_args.len() > W { + return Err(format!("atom arity {} > W {}", view_args.len(), W)); + } + let slots = view_args + .iter() + .map(Slot::from_term) + .collect::, _>>()?; + for s in &slots { + if let Slot::Var(v) = s { + body_vars.insert(*v); + } + } + atoms.push(PlanAtom { + read_key: ReadKey { + func: id, + mode: read, + occurrence_cols, + }, + slots, + }); + } RuleBodyCall::Table { id, read } => { if atom.args.len() > W { return Err(format!("atom arity {} > W {}", atom.args.len(), W)); @@ -199,6 +243,7 @@ pub fn plan_join(rule: &RuleSpec) -> Result { read_key: ReadKey { func: id, mode: read, + occurrence_cols: 0, }, slots, }); @@ -406,7 +451,7 @@ pub struct FusedDdJoin { /// The fused rules in caller-supplied build order. The sorted rule-index list /// identifies the ruleset cache entry but does not reorder these outputs. rules: Vec, - /// Current epoch (monotonic; advanced once per [`step`]). + /// Current epoch (monotonic; advanced once per [`FusedDdJoin::step`]). epoch: u32, } @@ -816,6 +861,7 @@ mod tests { fn live(func: FunctionId) -> ReadKey { ReadKey { + occurrence_cols: 0, func, mode: ReadMode::Live, } diff --git a/egglog-experimental/dd/src/interpret.rs b/egglog-experimental/dd/src/interpret.rs index 00831dda..3d0824a3 100644 --- a/egglog-experimental/dd/src/interpret.rs +++ b/egglog-experimental/dd/src/interpret.rs @@ -44,6 +44,26 @@ use crate::{EGraph, TableDefault, ViewOp}; pub(crate) type Env = HashMap; type DdDeltaRows = HashMap, isize)>>; + +/// The occurrence view of `base`: for each row, one derived row `(value, row…)` +/// per distinct value the row holds in `read`'s columns. A value sitting in two +/// of them yields one derived row, not two — distinct base rows always give +/// distinct derived rows, so the map keys deduplicate exactly. +fn occurrence_view(base: &HashMap, read: ReadKey) -> HashMap { + let mut out = HashMap::new(); + for (row, version) in base { + for col in read.occurrence_columns() { + let Some(&val) = row.get(col) else { + continue; + }; + let mut derived = Vec::with_capacity(row.len() + 1); + derived.push(val); + derived.extend_from_slice(row); + out.insert(derived.into_boxed_slice(), *version); + } + } + out +} type LookupIndex = HashMap>; /// Retractions batched per function: the key length plus the set of keys to @@ -219,7 +239,7 @@ pub fn run_iteration(eg: &mut EGraph, rules: &[(usize, RuleSpec)]) -> Result>` parallel to `rules` (same @@ -321,13 +341,22 @@ fn fused_bindings(eg: &mut EGraph, rules: &[(usize, RuleSpec)]) -> Result eg.live_versions.get(&read.func), ReadMode::Subsumed => eg.subsumed_versions.get(&read.func), ReadMode::All => eg.all_versions.get(&read.func), }; let cur_empty: HashMap = HashMap::new(); - let cur = cur.unwrap_or(&cur_empty); + let base = base.unwrap_or(&cur_empty); + // An occurrence read is a view over the same rows, so deriving it + // here lets the delta diff below stay exactly the same. + let occurrence_rows; + let cur: &HashMap = if read.occurrence_cols == 0 { + base + } else { + occurrence_rows = occurrence_view(base, read); + &occurrence_rows + }; let prev = fed.entry(read).or_default(); if prev == cur { continue; diff --git a/egglog-experimental/dd/src/lib.rs b/egglog-experimental/dd/src/lib.rs index d91c7d07..59fde242 100644 --- a/egglog-experimental/dd/src/lib.rs +++ b/egglog-experimental/dd/src/lib.rs @@ -319,6 +319,28 @@ impl EGraph { for atom in &rule.core.body.atoms { match atom.head { + RuleBodyCall::IndexTable { id, ref any_of, .. } => { + let info = relation(id, "rule body")?; + // The occurring value, the base row, and the index relation's + // own unit output. + let expected = info.arity + 2; + if atom.args.len() != expected { + bail!( + "DD backend cannot add rule {:?}: index atom over `{}` has {} columns, expected {expected}", + rule.name, + info.name, + atom.args.len() + ); + } + if let Some(col) = any_of.iter().find(|c| **c >= info.arity) { + bail!( + "DD backend cannot add rule {:?}: index atom over `{}` reads column {col}, but it has arity {}", + rule.name, + info.name, + info.arity + ); + } + } RuleBodyCall::Table { id, .. } => { if atom.args.len() > dd_native::W { bail!( diff --git a/egglog-experimental/src/fresh_macro.rs b/egglog-experimental/src/fresh_macro.rs index f1a39351..d0e1e847 100644 --- a/egglog-experimental/src/fresh_macro.rs +++ b/egglog-experimental/src/fresh_macro.rs @@ -169,7 +169,7 @@ fn collect_fresh_options(actions: Actions) -> Result, Error> { } /// Parse the arguments to unstable-fresh! -/// Syntax: (unstable-fresh! SortName [:cost N] [:unextractable]) +/// Syntax: `(unstable-fresh! SortName [:cost N] [:unextractable])` fn parse_fresh_args(span: &egglog::ast::Span, args: &[Expr]) -> Result { if args.is_empty() { return Err(Error::ParseError(ParseError( diff --git a/egglog-experimental/tests/snapshots/files__proofs__test_fresh_proof_testing.snap b/egglog-experimental/tests/snapshots/files__proofs__test_fresh_proof_testing.snap index 2f758c7e..0b4c5906 100644 --- a/egglog-experimental/tests/snapshots/files__proofs__test_fresh_proof_testing.snap +++ b/egglog-experimental/tests/snapshots/files__proofs__test_fresh_proof_testing.snap @@ -20,4 +20,4 @@ expression: snapshot (substitution (a (Var "x")) (b (Var "y")))) prf0 (Fiat (= () ()))) - (substitution (?y (Var "x")) (?x t0))) + (substitution (?x t0) (?y (Var "x")))) diff --git a/egglog-experimental/tests/snapshots/files__proofs__with_ruleset_proof_testing.snap b/egglog-experimental/tests/snapshots/files__proofs__with_ruleset_proof_testing.snap index 33f22066..d5e8aa51 100644 --- a/egglog-experimental/tests/snapshots/files__proofs__with_ruleset_proof_testing.snap +++ b/egglog-experimental/tests/snapshots/files__proofs__with_ruleset_proof_testing.snap @@ -15,4 +15,4 @@ expression: snapshot (= t0 (Add (Num 3) (Num 2))) (name "(rewrite (Add a b) (Add b a) :ruleset optimization)") (premises (Fiat (= t0 t0))) - (substitution (a (Num 2)) (b (Num 3)) (@rewrite_var__ t0))) + (substitution (@rewrite_var__ t0) (a (Num 2)) (b (Num 3)))) diff --git a/egglog/CHANGELOG.md b/egglog/CHANGELOG.md index 24dddf61..a7a4f0d0 100644 --- a/egglog/CHANGELOG.md +++ b/egglog/CHANGELOG.md @@ -2,9 +2,12 @@ ## [Unreleased] - ReleaseDate -- Add a `(begin *)` command and a `(let (begin * ))` form that run a block of actions once, immediately, with a shared *local* scope (`let`s bind local variables, not global functions); `let`-begin additionally binds the global `` to the block's trailing value. In the term/proof encoding, each top-level action's minted temporaries — and the shared `let`s introduced by the common-subexpression prepass — now run inside one such block instead of as separate top-level `let`s, so a temporary no longer becomes its own global function/table. This removes the per-proof-node table blow-up that made building a large static graph under the encoding slow (dominant cost on graphs with many top-level terms). A *user-written* `begin` block is reported unsupported under the term/proof encoding for now (proof checking models top-level actions individually, so a block's local bindings have no checkable representation); the encoding's own generated blocks are unaffected. -- Common-subexpression prepass for the term/proof encoding (`ast::cse`, run over the program before encoding): within an action scope, a constructor application occurring more than once is bound to a shared `let` and its occurrences rewritten to that variable, so it is interned once. A top-level action that gains shared `let`s becomes a `begin` block, so they stay local rather than adding a global table per hoisted subterm. -- In the term/proof encoding, when a `union` operand is a freshly-built constructor term, build it directly into the other operand's e-class instead of minting a fresh id and unioning it away: a plain view `set` points the constructor's children at the other operand's (target) e-class, and the view's congruence `:merge` handles the case where the term already exists. This reuses ids and drops the corresponding `@UF` rows (union-heavy workloads run substantially faster and use less memory in both term and proof mode). In proof mode the view row carries the equality proof `target = f(children)`, composed from the union's rule justification and a `Congr` chain over canonicalized children, with the built-in operand's term kept on its own id so proof reconstruction stays unambiguous. +- **Proof mode is substantially faster and uses less memory.** The term/proof encoding no longer writes each proof's `Congr`/`Trans`/`Sym` steps as rows while rules run; it records what justified a fact and rebuilds the steps when a proof is asked for. A flat `(rewrite (Add a b) (Add b a))` firing writes 2 proof rows where it wrote 13. Across the benchmark suite that is 0.73–0.75x wall time, and peak memory on `math-microbenchmark` goes from 2.3 GiB to 1.1 GiB. The proofs themselves are unchanged. +- Fix `set-if-empty` in the term/proof encoding looking its key up in the committed table while staging its insert, so two calls with the same key in one action batch both missed and both inserted, minting two e-classes for one term — leaving the encoding one iteration behind the native backend on programs that do not saturate. It now reads through the batch's predicted rows, as native `lookup_or_insert` already did. +- **Declared indexes.** `(index (any *))` declares a read-only relation over the rows of ``: each value appearing in *any* of the listed columns, followed by the whole row. It answers "which rows mention this value", which no ordinary atom expresses — repeating a variable across those columns instead constrains them to be equal. Purely physical: it changes no results, only the cost of finding those rows. The database maintains it, and the backend builds it on demand, so the declaration binds a name and nothing more. The indexed value must be bound elsewhere in the query, since the atom is probed rather than scanned. A *user-written* declaration is not yet supported under the term/proof encoding, which rewrites a function into a view whose columns differ, so the declaration would name the wrong ones; the encoder's own declarations, written against its views, are unaffected. `(any …)` is an extractor over the row, leaving room for others (e.g. the elements of a container column). +- The term/proof encoding rebuilds a view's eq-sort columns through a declared index: one rule per child eq-sort, driven by a `UF` edge joined against the index, canonicalizing the whole row in its action. This replaces the per-column fan-out and folds the separate e-class rule into it. The Differential Dataflow backend serves index atoms by deriving the occurrence view from the base relation's rows. +- Add a `(begin *)` command and a `(let (begin * ))` form that run a block of actions once, immediately, with a shared *local* scope (`let`s bind local variables, not global functions); `let`-begin additionally binds the global `` to the block's trailing value. In the term/proof encoding, each top-level action's minted temporaries now run inside one such block instead of as separate top-level `let`s, so a temporary no longer becomes its own global function/table. This removes the per-proof-node table blow-up that made building a large static graph under the encoding slow (dominant cost on graphs with many top-level terms). A *user-written* `begin` block is reported unsupported under the term/proof encoding for now (proof checking models top-level actions individually, so a block's local bindings have no checkable representation); the encoding's own generated blocks are unaffected. +- In the term/proof encoding, when a `union` operand is a freshly-built constructor term, build it directly into the other operand's e-class instead of minting a fresh id and unioning it away: a plain view `set` points the constructor's children at the other operand's (target) e-class, and the view's congruence `:merge` handles the case where the term already exists. This reuses ids and drops the corresponding `@UF` rows (union-heavy workloads run substantially faster and use less memory in both term and proof mode). In proof mode the view row carries the equality proof `target = f(children)` as the dropped union's rule justification, with the built-in operand's term kept on its own id so proof reconstruction stays unambiguous. - Fix unsound container rebuild proofs for reordering containers (`Set`, `MultiSet`, `Map`): rebuilds identified changed elements by their position in the container's value-order element list, which need not match the proof term's canonical child order. Rebuild proofs now use a new element-matching `CongrAll` proof constructor, desugared into positional `Congr` steps during proof conversion (the user-facing proof format is unchanged). - Fix `file_supports_proofs` wrongly rejecting programs whose `(push)`-scoped globals are read in actions: the whole-program check ran against the final (popped) scope, so scoped globals looked like unsupported function lookups. Six corpus files (herbie, array, bdd, cyk, math, typeinfer) now run under the term/proof encoding tests. Also accept a reflexive `Fiat` proof over a termified base value: base sorts whose values termify as applications rather than literals (BigInt's `(from-string …)`, BigRat's `(bigrat …)`) declare their canonical value term form via a new `prim_value_constructor` sort hook (the base-sort analogue of `rebuild_container_normalizer`), which the proof checker uses to re-evaluate such terms. `from-string` also gains a primitive validator. - Fix the term/proof encoding dropping rebuilds of a custom function's container-valued *output*: elements unioned after the value was stored kept the stale container. The FD view's value column now canonicalizes through the container rebuild primitive (delete-then-reinsert, so the user merge does not rerun), with the row proof composed by `Congr` at the output position. @@ -13,6 +16,7 @@ - `(fail +)` now accepts multiple commands, running them in order and succeeding if any one fails (previously it wrapped a single command). Desugaring, global removal, and proof encoding keep the whole expansion of a wrapped command inside the `fail`, so `fail` now works over commands that expand to several — including `(fail (set …))` under the term/proof encoding. - In the term/proof encoding, load `(input …)` for custom functions (with or without `:merge`, including `:no-merge` `Unit`-output ones) natively via `EGraph::native_input`, the same path already used for constructors and relations. This removes the per-input bodyless "loader rule" (and its fresh ruleset) that custom-function inputs used to compile to. - Make proof extraction deterministic so proof-mode snapshot tests no longer flake on backends with nondeterministic row order (the differential-dataflow backend). +- Speed up proof extraction, which was quadratic: it rescanned every candidate function's rows once per extracted node. Each function's rows are now read once per extraction run and grouped by output value. The extracted term is unchanged. - Speed up freeing external functions, which was quadratic in the number of short-lived one-shot rules created (e.g. under the term/proof encoding). - Speed up `core-relations`' `merge_all` by resetting only the tables that changed during the call instead of every table. - Desugar global variables as functions instead of constructor + `union` in the term/proof encoding, and skip rebuilding after non-`union` top-level actions (removing a per-definition rebuild cost). diff --git a/egglog/core-relations/src/action/mask.rs b/egglog/core-relations/src/action/mask.rs index 267e6407..3d9c0c36 100644 --- a/egglog/core-relations/src/action/mask.rs +++ b/egglog/core-relations/src/action/mask.rs @@ -105,6 +105,11 @@ impl Mask { pub(crate) fn count_ones(&self) -> usize { self.data.count_ones(..) } + + /// The offsets that are still active, in ascending order. + pub(crate) fn ones(&self) -> impl Iterator + '_ { + self.data.ones() + } } pub(crate) enum IterResult { @@ -286,10 +291,7 @@ where if self.mask.contains(idx) { let mut result = self.pool.get(); result.reserve(self.data.len()); - result.extend(self.data.iter().map(|x| match x { - ValueSource::Const(x) => (*x).clone(), - ValueSource::Slice(x) => x[idx].clone(), - })); + result.extend(self.data.iter().map(|x| x.at(idx))); IterResult::Item(result) } else if idx < self.mask.len() { IterResult::Skip @@ -416,6 +418,31 @@ pub(crate) enum ValueSource<'a, T> { Slice(&'a [T]), } +impl ValueSource<'_, T> { + /// This source's value in lane `idx`; a constant is the same in every lane. + /// + /// # Panics + /// If a slice source is shorter than `idx + 1`. + pub(crate) fn at(&self, idx: usize) -> T { + match self { + ValueSource::Const(value) => value.clone(), + ValueSource::Slice(slice) => slice[idx].clone(), + } + } +} + +/// Where the column `entry` names comes from: a variable's whole binding slice, +/// indexed by lane, or a constant. +pub(crate) fn value_source<'a>( + entry: &crate::QueryEntry, + bindings: &'a crate::action::Bindings, +) -> ValueSource<'a, crate::Value> { + match entry { + crate::QueryEntry::Var(v) => ValueSource::Slice(&bindings[*v]), + crate::QueryEntry::Const(c) => ValueSource::Const(*c), + } +} + /// This is a macro for processing a slice of values pointing into a [`crate::action::Bindings`]. /// /// The out-of-the-box way to do this is to use [`Mask::iter_dynamic`], but that method is both @@ -554,10 +581,9 @@ macro_rules! for_each_binding_with_mask { _ => { let $iter = $mask.iter_dynamic( crate::pool::with_pool_set(crate::pool::PoolSet::get_pool), - $args.iter().map(|v| match v { - crate::QueryEntry::Var(v) => ValueSource::Slice(&$bindings[*v]), - crate::QueryEntry::Const(c) => ValueSource::Const(*c), - }), + $args + .iter() + .map(|v| crate::action::mask::value_source(v, $bindings)), ); $body } diff --git a/egglog/core-relations/src/action/mod.rs b/egglog/core-relations/src/action/mod.rs index 2a1d82e0..0b012da1 100644 --- a/egglog/core-relations/src/action/mod.rs +++ b/egglog/core-relations/src/action/mod.rs @@ -26,7 +26,7 @@ use crate::{ table_spec::{ColumnId, MutationBuffer}, }; -use self::mask::{Mask, MaskIter, ValueSource}; +use self::mask::{Mask, MaskIter, ValueSource, value_source}; #[macro_use] pub(crate) mod mask; @@ -445,6 +445,17 @@ impl<'a> ExecutionState<'a> { self.changed = true; } + /// Stage a batch of mutations against a single table, `mutate` receiving the + /// table's mutation buffer directly, and notify the table as changed once + /// for the whole batch. + fn stage_batch(&mut self, table: TableId, mutate: impl FnOnce(&mut dyn MutationBuffer)) { + self.buffers + .lazy_init(table, || self.db.table_info[table].table.new_buffer()); + mutate(&mut *self.buffers.buffers[table]); + self.buffers.notify_list.notify(table); + self.changed = true; + } + /// Stage a removal of the given row from `table` if it is present. /// /// If you are using `egglog`, consider using `egglog_bridge::TableAction`. @@ -595,6 +606,33 @@ impl<'a> ExecutionState<'a> { } } +/// Scratch space holding one gathered row, reused across the rows of one +/// instruction. +type RowScratch = SmallVec<[Value; 12]>; + +/// Where each column of a row comes from: one entry per element of `args`, in +/// order. +/// +/// A variable's entry borrows its whole binding slice, so [`gather_row`] indexes +/// it by lane. Every such slice must therefore be at least as long as the mask +/// the lanes come from; a shorter one panics rather than silently truncating the +/// batch. +fn row_sources<'a>( + args: &[QueryEntry], + bindings: &'a Bindings, +) -> SmallVec<[ValueSource<'a, Value>; 12]> { + args.iter() + .map(|entry| value_source(entry, bindings)) + .collect() +} + +/// Overwrite `out` with lane `idx` of `sources`. Panics if any source slice is +/// shorter than `idx + 1` (see [`row_sources`]). +fn gather_row(sources: &[ValueSource<'_, Value>], idx: usize, out: &mut RowScratch) { + out.clear(); + out.extend(sources.iter().map(|source| source.at(idx))); +} + impl ExecutionState<'_> { /// Returns the number of matches that make it to the end of the instructions pub(crate) fn run_instrs(&mut self, instrs: &[Instr], bindings: &mut Bindings) -> usize { @@ -772,10 +810,13 @@ impl ExecutionState<'_> { *mask = lookup_result; } Instr::Insert { table, vals } => { - for_each_binding_with_mask!(mask, vals.as_slice(), bindings, |iter| { - iter.for_each(|vals| { - self.stage_insert(*table, vals.as_slice()); - }) + let sources = row_sources(vals, bindings); + let mut row = RowScratch::new(); + self.stage_batch(*table, |buf| { + for idx in mask.ones() { + gather_row(&sources, idx, &mut row); + buf.stage_insert(&row); + } }); } Instr::InsertIfEq { table, l, r, vals } => match (l, r) { @@ -810,10 +851,13 @@ impl ExecutionState<'_> { } }, Instr::Remove { table, args } => { - for_each_binding_with_mask!(mask, args.as_slice(), bindings, |iter| { - iter.for_each(|args| { - self.stage_remove(*table, args.as_slice()); - }) + let sources = row_sources(args, bindings); + let mut row = RowScratch::new(); + self.stage_batch(*table, |buf| { + for idx in mask.ones() { + gather_row(&sources, idx, &mut row); + buf.stage_remove(&row); + } }); } Instr::External { func, args, dst } => { diff --git a/egglog/core-relations/src/free_join/execute.rs b/egglog/core-relations/src/free_join/execute.rs index 35d7976d..37f533f3 100644 --- a/egglog/core-relations/src/free_join/execute.rs +++ b/egglog/core-relations/src/free_join/execute.rs @@ -39,8 +39,8 @@ use crate::{ use super::{ ActionId, AtomId, Database, HashColumnIndex, HashIndex, TableInfo, Variable, - get_column_index_from_tableinfo, - plan::{JoinHeader, JoinStage, Plan}, + get_column_index_from_tableinfo, get_occurrence_index_from_tableinfo, + plan::{JoinHeader, JoinStage, Plan, SingleScanSpec}, with_pool_set, }; @@ -710,6 +710,10 @@ pub(crate) struct TrieNode { /// only ever probed with a single column, so we store one (col, map) pair /// instead of an IdVec across all columns. cached_children: OnceLock>, + /// Cached occurrence index on this subset, with the column set it covers. + /// As with `cached_children`, a node is in practice probed with a single + /// set, so one slot is enough; a probe on any other set builds its own. + cached_occurrence: OnceLock<(SmallVec<[ColumnId; 4]>, Arc)>, } impl std::fmt::Debug for TrieNode { @@ -726,6 +730,7 @@ impl TrieNode { subset, cached_subsets: Default::default(), cached_children: Default::default(), + cached_occurrence: Default::default(), } } @@ -746,6 +751,26 @@ impl TrieNode { .clone() } + /// The occurrence index over this node's subset, built once per node (see + /// [`TrieNode::cached_occurrence`]). + fn get_cached_occurrence_index(&self, cols: &[ColumnId], info: &TableInfo) -> Arc { + let build = || { + Arc::new(ColumnIndex::build_for_subset( + info.table.as_ref(), + self.subset.as_ref(), + cols, + )) + }; + let (cached_cols, index) = self + .cached_occurrence + .get_or_init(|| (SmallVec::from_slice(cols), build())); + if cached_cols.as_slice() == cols { + index.clone() + } else { + build() + } + } + fn get_cached_trie_node( &self, col: ColumnId, @@ -806,6 +831,7 @@ impl BindingInfo { { node.cached_subsets.take(); node.cached_children.take(); + node.cached_occurrence.take(); node.subset = subset; return; } @@ -844,12 +870,15 @@ impl<'a> JoinState<'a> { } } + /// The index a scan of `atom` by `cols` reads. `occurrence` selects the + /// disjunctive reading of `cols` (see [`Atom::occurrence`]). fn get_index( &self, atoms: &Arc>, atom: AtomId, binding_info: &mut BindingInfo, cols: impl Iterator, + occurrence: bool, ) -> Prober { let cols = SmallVec::<[ColumnId; 4]>::from_iter(cols); let trie_node = binding_info.subsets.unwrap_val(atom); @@ -857,6 +886,43 @@ impl<'a> JoinState<'a> { let table_id = atoms[atom].table; let info = &self.db.tables[table_id]; + // An occurrence atom indexes several columns but is probed with a single + // value (the one occurring in any of them), so it takes the column-index + // path regardless of how many columns it covers. + if occurrence { + let all_cacheable = cols.iter().all(|col| { + !info + .spec + .uncacheable_columns + .get(*col) + .copied() + .unwrap_or(false) + }); + let whole_table = info.table.all(); + // Same trade-off as the ordinary paths below. The cached index spans + // the whole table, so it only pays off when this atom is scanning most + // of it; otherwise its result would have to be cut back to a small + // subset, which costs more than indexing that subset directly. A + // seminaive delta is exactly the small case, so getting this wrong + // makes every probe scale with the table rather than the delta. + let ix = if let Subset::Dense(range) = *subset + && all_cacheable + && whole_table.size() / 2 < subset.size() + { + let needs_intersect = + !(whole_table.is_dense() && subset.bounds() == whole_table.bounds()); + DynamicIndex::CachedColumn { + intersect_outer: needs_intersect.then_some(range), + table: get_occurrence_index_from_tableinfo(info, &cols), + } + } else { + DynamicIndex::DynamicColumn(trie_node.get_cached_occurrence_index(&cols, info)) + }; + return Prober { + node: trie_node, + ix, + }; + } let dyn_index = if subset.size() <= SMALL_RESIDUAL && cols.len() == 1 { DynamicIndex::SparseColumn(SparseColumnIndex::new( info.table.as_ref(), @@ -917,13 +983,30 @@ impl<'a> JoinState<'a> { atom: AtomId, col: ColumnId, ) -> Prober { - self.get_index(atoms, atom, binding_info, iter::once(col)) + self.get_index(atoms, atom, binding_info, iter::once(col), false) + } + + /// The index a generic-join scan reads: the occurrence index over the whole + /// column set when the scan binds an occurrence variable, otherwise the + /// ordinary index on its single column. + fn get_scan_index( + &self, + atoms: &Arc>, + binding_info: &mut BindingInfo, + scan: &SingleScanSpec, + ) -> Prober { + match &scan.occurrence_cols { + Some(cols) => { + self.get_index(atoms, scan.atom, binding_info, cols.iter().copied(), true) + } + None => self.get_column_index(atoms, binding_info, scan.atom, scan.column), + } } /// Runs the free join plan, starting with the header. /// /// A bit about the `instr_order` parameter: This defines the order in which the [`JoinStage`] - /// instructions will run. We want to support cached [`SinglePlan`]s that may be based on stale + /// instructions will run. We want to support cached [`SinglePlan`](crate::free_join::plan::SinglePlan)s that may be based on stale /// ordering information. `instr_order` allows us to specify a new ordering of the instructions /// without mutating the plan itself: `run_plan` simply executes /// `plan.stages.instrs[instr_order[i]]` at stage `i`. @@ -1142,14 +1225,14 @@ impl<'a> JoinState<'a> { if binding_info.has_empty_subset(a.atom) { return; } - let prober = self.get_column_index(atoms, binding_info, a.atom, a.column); + let prober = self.get_scan_index(atoms, binding_info, a); let info = &self.db.tables[atoms[a.atom].table]; let table = info.table.as_ref(); let has_stale = table.has_stale_rows(); let mut updates = FrameUpdates::with_capacity(cmp::min(chunk_size, cur_size)); prober.for_each(|val, x| { updates.push_binding(*var, val[0]); - if x.size() <= 16 { + if x.size() <= 16 || a.occurrence_cols.is_some() { let sub = refine_subset(x.to_owned(pool), &a.cs, &table, has_stale); if sub.is_empty() { updates.rollback(); @@ -1178,8 +1261,8 @@ impl<'a> JoinState<'a> { binding_info.move_back(a.atom, prober); } [a, b] => { - let a_prober = self.get_column_index(atoms, binding_info, a.atom, a.column); - let b_prober = self.get_column_index(atoms, binding_info, b.atom, b.column); + let a_prober = self.get_scan_index(atoms, binding_info, a); + let b_prober = self.get_scan_index(atoms, binding_info, b); let ((smaller, smaller_scan), (larger, larger_scan)) = if a_prober.len() < b_prober.len() { @@ -1200,7 +1283,7 @@ impl<'a> JoinState<'a> { smaller.for_each(|val, small_sub| { if let Some(large_sub) = larger.get_subset(val) { updates.push_binding(*var, val[0]); - if small_sub.size() <= 16 { + if small_sub.size() <= 16 || smaller_scan.occurrence_cols.is_some() { let small_sub = refine_subset( small_sub.to_owned(pool), &smaller_scan.cs, @@ -1232,7 +1315,7 @@ impl<'a> JoinState<'a> { } updates.refine_atom(smaller_atom, smaller_node); } - if large_sub.size() <= 16 { + if large_sub.size() <= 16 || larger_scan.occurrence_cols.is_some() { let large_sub = refine_subset( large_sub.to_owned(pool), &larger_scan.cs, @@ -1280,8 +1363,7 @@ impl<'a> JoinState<'a> { let mut smallest_size = usize::MAX; let mut probers = Vec::with_capacity(rest.len()); for (i, scan) in rest.iter().enumerate() { - let prober = - self.get_column_index(atoms, binding_info, scan.atom, scan.column); + let prober = self.get_scan_index(atoms, binding_info, scan); let size = prober.len(); if size < smallest_size { smallest = i; @@ -1318,7 +1400,7 @@ impl<'a> JoinState<'a> { if let Some(sub) = probers[i].get_subset(key) { let table = self.db.tables[atoms[rest[i].atom].table].table.as_ref(); - if sub.size() <= 16 { + if sub.size() <= 16 || scan.occurrence_cols.is_some() { let sub = refine_subset( sub.to_owned(pool), &rest[i].cs, @@ -1356,7 +1438,7 @@ impl<'a> JoinState<'a> { return; } } - if sub.size() <= 16 { + if sub.size() <= 16 || main_spec.occurrence_cols.is_some() { let main_sub = refine_subset( sub.to_owned(pool), &main_spec.cs, @@ -1502,6 +1584,7 @@ impl<'a> JoinState<'a> { spec.to_index.atom, binding_info, spec.to_index.vars.iter().copied(), + spec.occurrence_cols.is_some(), ), ) }) @@ -1686,6 +1769,7 @@ impl<'a> JoinState<'a> { spec.to_index.atom, binding_info, spec.to_index.vars.iter().copied(), + spec.occurrence_cols.is_some(), ) }) .collect::>(); diff --git a/egglog/core-relations/src/free_join/frame_update.rs b/egglog/core-relations/src/free_join/frame_update.rs index 8c60af55..b30d42d1 100644 --- a/egglog/core-relations/src/free_join/frame_update.rs +++ b/egglog/core-relations/src/free_join/frame_update.rs @@ -28,7 +28,7 @@ define_id!(pub SubsetId, u32, "An offset into a buffer of subsets"); pub(super) enum UpdateInstr { PushBinding(Variable, Value), RefineAtom(AtomId, Arc), - /// Refine an atom to a dense offset range, avoiding an Arc allocation. + /// Refine an atom to a dense offset range, avoiding an `Arc` allocation. RefineAtomDense(AtomId, OffsetRange), /// Marks the end of the current frame. Time to make a recursive call. EndFrame, @@ -62,7 +62,7 @@ impl FrameUpdates { } /// Refine `atom` to consider only the given dense offset range, without - /// allocating an Arc eagerly. + /// allocating an `Arc` eagerly. pub(super) fn refine_atom_dense(&mut self, atom: AtomId, range: OffsetRange) { self.updates.push(UpdateInstr::RefineAtomDense(atom, range)); } diff --git a/egglog/core-relations/src/free_join/mod.rs b/egglog/core-relations/src/free_join/mod.rs index ecbd8fc3..9b1ffa17 100644 --- a/egglog/core-relations/src/free_join/mod.rs +++ b/egglog/core-relations/src/free_join/mod.rs @@ -20,7 +20,7 @@ use crate::{ BaseValues, ContainerRebuildSummary, ContainerValues, PoolSet, QueryEntry, TupleIndex, Value, action::{ Bindings, DbView, - mask::{Mask, MaskIter, ValueSource}, + mask::{Mask, MaskIter}, }, dependency_graph::DependencyGraph, hash_index::{ColumnIndex, Index, IndexBase}, @@ -137,6 +137,10 @@ pub struct TableInfo { pub(crate) table: WrappedTable, pub(crate) indexes: IndexCatalog, HashIndex>, pub(crate) column_indexes: IndexCatalog, + /// Occurrence ("any-of") indexes: a `ColumnIndex` over a *set* of columns, + /// mapping each value appearing in any of them to the rows containing it. + /// The disjunctive counterpart to `indexes`, which keys on the whole tuple. + pub(crate) occurrence_indexes: IndexCatalog, HashColumnIndex>, } impl TableInfo { @@ -175,6 +179,7 @@ impl Clone for TableInfo { table: self.table.dyn_clone(), indexes: deep_clone_map(&self.indexes, self.table.as_ref()), column_indexes: deep_clone_map(&self.column_indexes, self.table.as_ref()), + occurrence_indexes: deep_clone_map(&self.occurrence_indexes, self.table.as_ref()), } } } @@ -630,6 +635,9 @@ impl Database { info.column_indexes.update(|_, ti| { Arc::get_mut(ti).unwrap().reset(); }); + info.occurrence_indexes.update(|_, ti| { + Arc::get_mut(ti).unwrap().reset(); + }); info.indexes.update(|_, ti| { Arc::get_mut(ti).unwrap().reset(); }); @@ -741,6 +749,7 @@ impl Database { table, indexes: IndexCatalog::new(), column_indexes: IndexCatalog::new(), + occurrence_indexes: IndexCatalog::new(), }); self.deps.add_table(res, read_deps, write_deps); res @@ -880,6 +889,11 @@ impl Database { arc.reset(); } }); + info.occurrence_indexes.update(|_, ti| { + if let Some(arc) = Arc::get_mut(ti) { + arc.reset(); + } + }); info.indexes.update(|_, ti| { if let Some(arc) = Arc::get_mut(ti) { arc.reset(); @@ -966,6 +980,35 @@ fn get_column_index_from_tableinfo(table_info: &TableInfo, col: ColumnId) -> Has index } +/// Get and refresh an occurrence index over `cols`: a `ColumnIndex` keyed on the +/// whole set, so each value appearing in *any* of `cols` maps to the rows holding +/// it. This is the structure `SortedWritesTable::rebuild_index` uses to find the +/// rows referencing a changed value; [`get_index_from_tableinfo`] over the same +/// columns is its conjunctive counterpart, keyed on the whole tuple. +pub(crate) fn get_occurrence_index_from_tableinfo( + table_info: &TableInfo, + cols: &[ColumnId], +) -> HashColumnIndex { + let index: Arc<_> = table_info + .occurrence_indexes + .get_or_insert(cols.into(), || { + Arc::new(ResettableOnceLock::new(Index::new( + cols.to_vec(), + ColumnIndex::new(), + ))) + }); + index.get_or_update(|index| { + index.refresh(table_info.table.as_ref()); + }); + debug_assert!( + !index + .get() + .unwrap() + .needs_refresh(table_info.table.as_ref()) + ); + index +} + #[derive(Clone)] pub struct ColumnCardEst<'a> { db: &'a Database, diff --git a/egglog/core-relations/src/free_join/plan.rs b/egglog/core-relations/src/free_join/plan.rs index a001f375..431af28e 100644 --- a/egglog/core-relations/src/free_join/plan.rs +++ b/egglog/core-relations/src/free_join/plan.rs @@ -71,12 +71,19 @@ pub(crate) struct ScanSpec { pub to_index: SubAtom, // Only yield rows where the given constraints match. pub constraints: Vec, + /// Set when this scan reaches the atom through its occurrence variable: the + /// columns to read disjunctively (see [`Atom::occurrence`]). + pub occurrence_cols: Option>, } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct SingleScanSpec { pub atom: AtomId, pub column: ColumnId, + /// Set when this scan binds the atom's occurrence variable: the columns to + /// read disjunctively (see [`Atom::occurrence`]). `column` is then only the + /// first of them. + pub occurrence_cols: Option>, pub cs: Vec, } @@ -156,6 +163,19 @@ pub(crate) enum JoinStage { }, } +/// The occurrence columns this subatom reads, if it is the atom's occurrence +/// variable rather than one of its column variables. +fn occurrence_cols_of( + atoms: &DenseIdMap, + subatom: &SubAtom, +) -> Option> { + atoms[subatom.atom] + .occurrence + .as_ref() + .filter(|occ| occ.cols.as_slice() == subatom.vars.as_slice()) + .map(|occ| occ.cols.clone()) +} + /// Merge every `FusedIntersect { to_intersect: [] }` into the first earlier such stage on the /// same cover atom, so each atom contributes at most one single-scan stage. Same-atom no- /// `to_intersect` covers can always be merged: their projections share an atom and any @@ -231,9 +251,7 @@ impl Plan { pub(crate) fn to_report(&self, _symbol_map: &SymbolMap) -> egglog_reports::Plan { match self { Plan::SinglePlan(p) => p.to_report(_symbol_map), - Plan::DecomposedPlan(_) => { - todo!() - } + Plan::DecomposedPlan(p) => p.to_report(_symbol_map), } } @@ -289,11 +307,21 @@ pub(crate) struct DecomposedPlan { pub actions: ActionId, } -impl SinglePlan { - pub(crate) fn to_report(&self, symbol_map: &SymbolMap) -> egglog_reports::Plan { +/// Render one run of join instructions as report stages, numbering each stage's +/// successor from `offset` so several runs can be concatenated into one plan. +fn stages_to_report( + stages: &JoinStages, + atoms: &DenseIdMap, + symbol_map: &SymbolMap, + offset: usize, +) -> Vec<( + egglog_reports::Stage, + Option, + Vec, +)> { + { use egglog_reports::{ - Plan as ReportPlan, Scan as ReportScan, SingleScan as ReportSingleScan, - Stage as ReportStage, + Scan as ReportScan, SingleScan as ReportSingleScan, Stage as ReportStage, }; const INTERNAL_PREFIX: &str = "@"; let get_var = |var: Variable| { @@ -310,8 +338,8 @@ impl SinglePlan { .map(|s| s.to_string()) .unwrap_or_else(|| format!("{INTERNAL_PREFIX}R{atom:?}")) }; - let mut stages = Vec::new(); - for (i, stage) in self.stages.instrs.iter().enumerate() { + let mut out = Vec::new(); + for (i, stage) in stages.instrs.iter().enumerate() { let report_stage = match stage { JoinStage::Intersect { var, scans } => { let var_name = get_var(*var); @@ -340,8 +368,10 @@ impl SinglePlan { .vars .iter() .map(|col| { - let var_name = - get_var(self.atoms[cover.to_index.atom].get_var(*col).unwrap()); + let var_name = atoms[cover.to_index.atom] + .get_var(*col) + .map(get_var) + .unwrap_or_else(|| format!("{INTERNAL_PREFIX}c{col:?}")); (var_name, col.index() as i64) }) .collect(); @@ -353,9 +383,12 @@ impl SinglePlan { let cols: Vec<(String, i64)> = key_spec .iter() .map(|col| { - let var_name = get_var( - self.atoms[scan.to_index.atom].get_var(*col).unwrap(), - ); + // `key_spec` indexes the cover, so it need not + // name a column of the probed atom. + let var_name = atoms[scan.to_index.atom] + .get_var(*col) + .map(get_var) + .unwrap_or_else(|| format!("{INTERNAL_PREFIX}c{col:?}")); (var_name, col.index() as i64) }) .collect(); @@ -367,23 +400,85 @@ impl SinglePlan { to_intersect: report_to_intersect, } } + // The cover is an intermediate materialization rather than a + // table, so it is named for the materialization it reads and its + // columns are the ones it binds. JoinStage::FusedIntersectMat { - cover: _, - mode: _, - bind: _, - to_intersect: _, + cover, + mode, + bind, + to_intersect, } => { - todo!("materialization") + let cover_cols: Vec<(String, i64)> = bind + .iter() + .map(|(col, var)| (get_var(*var), col.index() as i64)) + .collect(); + let report_cover = ReportScan( + format!("{INTERNAL_PREFIX}mat{cover:?}[{mode:?}]"), + cover_cols, + ); + let report_to_intersect = to_intersect + .iter() + .map(|(scan, key_spec)| { + let atom_name = get_atom(scan.to_index.atom); + let cols: Vec<(String, i64)> = key_spec + .iter() + .map(|col| { + // `key_spec` indexes the cover, so it need not + // name a column of the probed atom. + let var_name = atoms[scan.to_index.atom] + .get_var(*col) + .map(get_var) + .unwrap_or_else(|| format!("{INTERNAL_PREFIX}c{col:?}")); + (var_name, col.index() as i64) + }) + .collect(); + ReportScan(atom_name, cols) + }) + .collect(); + ReportStage::FusedIntersect { + cover: report_cover, + to_intersect: report_to_intersect, + } } }; - let next = if i == self.stages.instrs.len() - 1 { + let next = if i == stages.instrs.len() - 1 { vec![] } else { - vec![i + 1] + vec![offset + i + 1] }; - stages.push((report_stage, None, next)); + out.push((report_stage, None, next)); } - ReportPlan { stages } + out + } +} + +impl SinglePlan { + pub(crate) fn to_report(&self, symbol_map: &SymbolMap) -> egglog_reports::Plan { + egglog_reports::Plan { + stages: stages_to_report(&self.stages, &self.atoms, symbol_map, 0), + } + } +} + +impl DecomposedPlan { + /// Every bag's instructions in order, then the block that joins their + /// materialized results. Stages are numbered across blocks, so a block's last + /// stage has no successor: the bags are independent by construction. + pub(crate) fn to_report(&self, symbol_map: &SymbolMap) -> egglog_reports::Plan { + let mut stages = Vec::new(); + for (block, _mat_spec) in &self.stages.blocks { + let offset = stages.len(); + stages.extend(stages_to_report(block, &self.atoms, symbol_map, offset)); + } + let offset = stages.len(); + stages.extend(stages_to_report( + &self.result_block, + &self.atoms, + symbol_map, + offset, + )); + egglog_reports::Plan { stages } } } @@ -428,8 +523,9 @@ fn next_var_to_eliminate( .map(|(var, vinfo)| { let subquery_vars = atoms .iter() - // every atom that contains this variable - .filter(|(_, atom)| atom.get_col(var).is_some()) + // every atom that contains this variable, as a column or as the + // value an occurrence index is read by + .filter(|(_, atom)| atom.binds(var)) // every variable of those atoms .flat_map(|(_, atom)| atom.vars()); @@ -445,16 +541,19 @@ fn next_var_to_eliminate( let size_estimation = vinfo .occurrences .iter() - .filter_map(|occ| { + .flat_map(|occ| { let atom = &atoms[occ.atom]; let table = atom.table; if table.is_dummy() { - return None; + return SmallVec::<[ColUniqueness; 4]>::new(); } - let col = atom.get_col(var).unwrap(); + // An occurrence variable is not a column of its atom, so it is + // estimated from the columns it can occur in. // TODO: plan header before query decomposition so we know the exact // subset we are handling - Some(col_est.col_uniqueness(table, col)) + atom.columns_bound_by(var) + .map(|col| col_est.col_uniqueness(table, col)) + .collect() }) .fold(ColUniqueness::default(), |a, b| a.join(&b)); ((occ, size_estimation), var, subquery_vars) @@ -527,6 +626,7 @@ fn update_hypergraph( var_columns, constraints: ProcessedConstraints::dummy(), table: TableId::dummy(), + occurrence: None, }); // Update variable occurrences to include the covering atom @@ -927,6 +1027,11 @@ fn plan_single_bag( vars: smallvec![], }, constraints: vec![], + // This path merges subatoms across an + // atom's variables, which an occurrence + // read cannot share; such a query is + // planned without decomposition. + occurrence_cols: None, }, smallvec![], )); @@ -1062,11 +1167,13 @@ fn fuse_last_stage( /// /// For example, in the following, looking up of `r` can be lifted up before `z` /// +/// ```text /// for x in R isec S: /// R = R[x]; S = S[x] /// for z in R: /// if r in Mat[x]: /// yield +/// ``` fn loop_lifting(stages: JoinStages) -> JoinStages { let mut instrs = Arc::unwrap_or_clone(stages.instrs); for i in 1..instrs.len() { @@ -1610,9 +1717,14 @@ fn compile_stage( .chain(filters.iter().map(|(x, _)| x)) .map(|subatom| { let atom = subatom.atom; + // A subatom whose columns are the atom's occurrence set binds + // the occurrence variable, so the scan reads them + // disjunctively instead of collapsing to `vars[0]`. + let occurrence_cols = occurrence_cols_of(&ctx.atoms, subatom); SingleScanSpec { atom, column: subatom.vars[0], + occurrence_cols, cs: take_atom_constraints_if_new(ctx, state, atom), } }), @@ -1628,19 +1740,29 @@ fn compile_stage( let atom = cover.atom; let cover_spec = ScanSpec { + occurrence_cols: occurrence_cols_of(&ctx.atoms, &cover), to_index: cover, constraints: take_atom_constraints_if_new(ctx, state, atom), }; let mut bind = SmallVec::new(); for var in vars { - bind.push((ctx.atoms[atom].get_col(var).unwrap(), var)); + // Scanning a cover binds each variable from the column it occupies; an + // occurrence variable has none, so a plan that covers it is unsupported. + let col = ctx.atoms[atom].get_col(var).unwrap_or_else(|| { + panic!( + "unsupported plan: {var:?} is bound by scanning an atom where it \ + occupies no column" + ) + }); + bind.push((col, var)); } let mut to_intersect = Vec::with_capacity(filters.len()); for (subatom, key_spec) in filters { let atom = subatom.atom; let scan = ScanSpec { + occurrence_cols: occurrence_cols_of(&ctx.atoms, &subatom), to_index: subatom, constraints: take_atom_constraints_if_new(ctx, state, atom), }; diff --git a/egglog/core-relations/src/hash_index/mod.rs b/egglog/core-relations/src/hash_index/mod.rs index 788e671d..82834c27 100644 --- a/egglog/core-relations/src/hash_index/mod.rs +++ b/egglog/core-relations/src/hash_index/mod.rs @@ -232,7 +232,13 @@ impl IndexBase for ColumnIndex { } fn add_row(&mut self, vals: &[Value], row: RowId) { // SAFETY: everything in `table` comes from `subsets`. - for key in vals { + for (i, key) in vals.iter().enumerate() { + // An index over several columns posts a row under each value it + // holds; a value sitting in more than one of those columns must + // still post the row once. + if vals[..i].contains(key) { + continue; + } let shard = self.shard_data.get_shard_mut(key, &mut self.shards); unsafe { shard @@ -276,7 +282,12 @@ impl IndexBase for ColumnIndex { let mut split = IdVec::::default(); split.resize_with(shard_data.n_shards(), || TaggedRowBuffer::new(1)); for (row_id, keys) in buf.iter() { - for key in keys { + for (i, key) in keys.iter().enumerate() { + // As in `add_row`: a value in more than one of the indexed + // columns still posts its row once. + if keys[..i].contains(key) { + continue; + } shard_data .get_shard_mut(*key, &mut split) .add_row(row_id, &[*key]); @@ -622,21 +633,21 @@ impl ColumnIndex { } } - /// Build a single-column index for `subset` of `table`. Picks between a - /// sort-based bulk path and a per-row scan based on subset size: large - /// subsets amortize the sort overhead, small ones avoid the buffer copy. + /// Build an index over just `subset`, mapping each value appearing in any of + /// `cols` to the rows of `subset` holding it. With one column this is the + /// usual per-column index; with several it is an occurrence index. pub(crate) fn build_for_subset( table: WrappedTableRef, subset: SubsetRef, - col: ColumnId, + cols: &[ColumnId], ) -> ColumnIndex { const SORT_BULK_THRESHOLD: usize = 512; let mut res = ColumnIndex::new(); - if subset.size() >= SORT_BULK_THRESHOLD { - res.rebuild_full(&[col], table, subset); + if subset.size() >= SORT_BULK_THRESHOLD || cols.len() != 1 { + res.rebuild_full(cols, table, subset); } else { res.reserve_for_n_rows(subset.size()); - table.for_each_col(subset, col, &mut |row_id, val| { + table.for_each_col(subset, cols[0], &mut |row_id, val| { res.add_row(&[val], row_id); }); } diff --git a/egglog/core-relations/src/hash_index/tests.rs b/egglog/core-relations/src/hash_index/tests.rs index 07006c71..5d550e66 100644 --- a/egglog/core-relations/src/hash_index/tests.rs +++ b/egglog/core-relations/src/hash_index/tests.rs @@ -131,3 +131,76 @@ fn multi_column_column_index_rebuild_orders_each_value_by_row() { }); assert_eq!(row_ids, expected); } + +/// A `ColumnIndex` over several columns posts each row under every value it +/// holds in them. A value sitting in more than one of those columns must still +/// post its row once: consumers that treat the index as a relation would +/// otherwise see the row twice. +#[test] +fn column_index_posts_a_row_once_per_distinct_value() { + let table = WrappedTable::new(fill_table( + vec![ + vec![v(0), v(10), v(11)], + vec![v(1), v(11), v(12)], + // Same value in both indexed columns. + vec![v(2), v(13), v(13)], + ], + 1, + None, + |old, new| { + assert_eq!(old, new, "no conflicts in this test"); + None + }, + )); + let mut index = Index::new(vec![ColumnId::new(1), ColumnId::new(2)], ColumnIndex::new()); + index.refresh(table.as_ref()); + + let rows_for = |val| { + index + .get_subset(&v(val)) + .map(|subset| { + let mut rows = Vec::new(); + subset.offsets(|row_id| rows.push(row_id.index())); + rows + }) + .unwrap_or_default() + }; + + assert_eq!(rows_for(10), vec![0]); + // Reached through either indexed column. + assert_eq!(rows_for(11), vec![0, 1]); + assert_eq!(rows_for(12), vec![1]); + assert_eq!(rows_for(13), vec![2], "one entry, not one per column"); + assert!(rows_for(99).is_empty()); + // Column 0 was not indexed. + assert!(rows_for(0).is_empty()); +} + +/// The parallel index build must deduplicate a row's repeated value the same way +/// the serial one does. A table large enough to cross +/// `parallelize_index_construction` takes a different code path, so it needs its +/// own coverage. +#[test] +fn parallel_column_index_posts_a_row_once_per_distinct_value() { + const N: usize = 25_000; + let mut rows: Vec> = (0..N) + .map(|i| vec![v(i), v(i + 1_000_000), v(i + 2_000_000)]) + .collect(); + // One row holding the same value in both indexed columns. + let dup = v(999_999); + rows[5] = vec![v(5), dup, dup]; + let table = WrappedTable::new(fill_table(rows, 1, None, |old, new| { + assert_eq!(old, new, "no conflicts in this test"); + None + })); + + let mut index = Index::new(vec![ColumnId::new(1), ColumnId::new(2)], ColumnIndex::new()); + index.refresh(table.as_ref()); + + let mut got = Vec::new(); + index + .get_subset(&dup) + .expect("the duplicated value is indexed") + .offsets(|row_id| got.push(row_id.index())); + assert_eq!(got, vec![5], "one entry, not one per column"); +} diff --git a/egglog/core-relations/src/query.rs b/egglog/core-relations/src/query.rs index 09026396..440d1e2c 100644 --- a/egglog/core-relations/src/query.rs +++ b/egglog/core-relations/src/query.rs @@ -409,6 +409,7 @@ impl<'outer, 'a> QueryBuilder<'outer, 'a> { table: table_id, var_columns: Default::default(), constraints: processed, + occurrence: None, }; let next_atom = AtomId::from_usize(self.query.atoms.n_ids()); let mut subatoms = HashMap::::default(); @@ -461,6 +462,70 @@ impl<'outer, 'a> QueryBuilder<'outer, 'a> { Ok(self.query.atoms.push(atom)) } + + /// Add an atom that additionally binds `occurrence_var` to a value occurring + /// in *some* one of `occurrence_cols`. `vars` covers the table's columns as in + /// [`Self::add_atom`]; `occurrence_var` is not one of them, and the columns + /// are read disjunctively — unlike a variable repeated across them, which + /// constrains them to be equal. + /// + /// The atom can only be probed, so `occurrence_var` must be bound elsewhere + /// in the query: scanning this atom would have to yield one binding per + /// distinct occurring value, which the join does not express. Whether that + /// holds is checked when the query is built. + pub fn add_occurrence_atom<'b>( + &mut self, + table_id: TableId, + vars: &[QueryEntry], + occurrence_var: Variable, + occurrence_cols: &[ColumnId], + cs: impl IntoIterator, + ) -> Result { + let arity = self.rsb.db.tables[table_id].spec.arity(); + if occurrence_cols.is_empty() { + return Err(QueryError::EmptyOccurrenceIndex { table: table_id }); + } + if let Some(col) = occurrence_cols.iter().find(|c| c.index() >= arity) { + return Err(QueryError::BadArity { + table: table_id, + expected: arity, + got: col.index() + 1, + }); + } + let atom_id = self.add_atom(table_id, vars, cs)?; + let cols: SmallVec<[ColumnId; 4]> = SmallVec::from_slice(occurrence_cols); + // The variable may also sit at a column of this atom. If that column is + // one of `cols` the value provably occurs, so the constraint is implied + // and the atom is an ordinary one. + if let Some(col) = self.query.atoms[atom_id] + .var_columns + .get_col(occurrence_var) + { + if cols.contains(&col) { + return Ok(atom_id); + } + return Err(QueryError::OccurrenceVarAtUnindexedColumn { + table: table_id, + column: col.index(), + }); + } + self.query.atoms[atom_id].occurrence = Some(Occurrence { + var: occurrence_var, + cols: cols.clone(), + }); + // Register the occurrence columns as this variable's foothold in the + // atom, so the planner probes the atom once the variable is bound. + self.query + .var_info + .get_mut(occurrence_var) + .expect("all variables must be bound in current query") + .occurrences + .push(SubAtom { + atom: atom_id, + vars: cols.iter().copied().collect(), + }); + Ok(atom_id) + } } #[derive(Debug, Error)] @@ -490,6 +555,14 @@ pub enum QueryError { #[error("attempt to compare two groups of values, one of length {l}, another of length {r}")] MultiComparisonMismatch { l: usize, r: usize }, + #[error("occurrence atom on table {table:?} lists no columns to index")] + EmptyOccurrenceIndex { table: TableId }, + + #[error( + "occurrence atom on table {table:?} also binds its indexed value at column {column}, which is not one of the indexed columns" + )] + OccurrenceVarAtUnindexedColumn { table: TableId, column: usize }, + #[error("table {table:?} expected {expected:?} columns but got {got:?}")] BadArity { table: TableId, @@ -528,6 +601,47 @@ impl RuleBuilder<'_, '_> { self.build_with_description("") } + /// An occurrence atom can only be probed, so every occurrence variable must + /// be a column of some *other* atom that binds it first (see + /// [`Atom::occurrence`]). + fn assert_occurrence_vars_bound(&self) { + let atoms = &self.qb.query.atoms; + for (id, atom) in atoms.iter() { + let Some(occ) = &atom.occurrence else { + continue; + }; + let bound_elsewhere = atoms + .iter() + .any(|(other, a)| other != id && a.var_columns.get_col(occ.var).is_some()); + assert!( + bound_elsewhere, + "occurrence variable {:?} of the atom on table {:?} is not bound by \ + another atom; an occurrence atom can only be probed", + occ.var, atom.table + ); + } + } + + /// Plan a query containing an occurrence atom whole, with generic join. + /// + /// Tree decomposition and the free-join planners reason about an atom's + /// variables through its columns, and an occurrence variable has none, so + /// they cannot see the edge between such an atom and the one binding its + /// value. Applied at build, so [`QueryBuilder::set_plan_strategy`] cannot + /// override it. + fn force_whole_query_generic_join(&mut self) { + if self + .qb + .query + .atoms + .iter() + .any(|(_, atom)| atom.occurrence.is_some()) + { + self.qb.query.no_decomp = true; + self.qb.query.plan_strategy = PlanStrategy::Gj; + } + } + fn build_symbol_map(&self) -> SymbolMap { let var_info = &self.qb.query.var_info; SymbolMap { @@ -549,6 +663,8 @@ impl RuleBuilder<'_, '_> { } pub fn build_with_description(mut self, desc: impl Into) -> RuleId { + self.assert_occurrence_vars_bound(); + self.force_whole_query_generic_join(); let var_info = &self.qb.query.var_info; let symbol_map = self.build_symbol_map(); // Generate an id for our actions and slot them in. @@ -883,6 +999,23 @@ pub(crate) struct Atom { /// Fast constraints get re-computed when queries are executed. In particular, this makes it /// possible to cache plans and add new fast constraints to them without re-planning. pub(crate) constraints: ProcessedConstraints, + /// A variable bound to a value occurring in *some* one of these columns, + /// serviced by an occurrence index (see + /// [`crate::free_join::get_occurrence_index_from_tableinfo`]). + /// + /// The variable is not a column of `table`, so it is absent from + /// `var_columns`; the columns are read disjunctively, unlike a variable + /// repeated across columns, which constrains them to be equal. It can only be + /// *probed*: some other atom must bind the variable first, since scanning + /// this atom would have to yield one binding per distinct occurring value. + pub(crate) occurrence: Option, +} + +/// See [`Atom::occurrence`]. +#[derive(Debug, Clone)] +pub(crate) struct Occurrence { + pub(crate) var: Variable, + pub(crate) cols: SmallVec<[ColumnId; 4]>, } impl Atom { @@ -890,6 +1023,25 @@ impl Atom { self.var_columns.vars() } + /// Whether this atom constrains `var`, either at a column or as the value its + /// occurrence index is read by (see [`Atom::occurrence`]). + pub(crate) fn binds(&self, var: Variable) -> bool { + self.var_columns.get_col(var).is_some() || self.occurrence_of(var).is_some() + } + + /// The columns `var` can take a value from: the one it occupies, or every + /// column of the occurrence set when it is the occurrence variable. + pub(crate) fn columns_bound_by(&self, var: Variable) -> impl Iterator + '_ { + let col = self.var_columns.get_col(var); + let occ = self.occurrence_of(var); + col.into_iter() + .chain(occ.into_iter().flat_map(|o| o.cols.iter().copied())) + } + + fn occurrence_of(&self, var: Variable) -> Option<&Occurrence> { + self.occurrence.as_ref().filter(|occ| occ.var == var) + } + pub(crate) fn get_var(&self, col: ColumnId) -> Option { self.var_columns.get_var(col) } diff --git a/egglog/core-relations/src/table/mod.rs b/egglog/core-relations/src/table/mod.rs index 0fbb0c14..fe26d2ca 100644 --- a/egglog/core-relations/src/table/mod.rs +++ b/egglog/core-relations/src/table/mod.rs @@ -235,13 +235,13 @@ struct Buffer { impl MutationBuffer for Buffer { fn stage_insert(&mut self, row: &[Value]) { - let (shard, _) = hash_code(self.shard_data, row, self.n_keys as _); + let shard = shard_of_key(self.shard_data, row, self.n_keys as _); self.pending_rows .get_or_insert(shard, || RowBuffer::new(self.n_cols as _)) .add_row(row); } fn stage_remove(&mut self, key: &[Value]) { - let (shard, _) = hash_code(self.shard_data, key, self.n_keys as _); + let shard = shard_of_key(self.shard_data, key, self.n_keys as _); self.pending_removals .get_or_insert(shard, || ArbitraryRowBuffer::new(self.n_keys as _)) .add_row(key); @@ -1274,6 +1274,14 @@ fn get_entry_mut<'a>( .map(|ent| &mut ent.row) } +/// The shard a row's key belongs to. Always shard 0 for an unsharded table. +fn shard_of_key(shard_data: ShardData, row: &[Value], n_keys: usize) -> ShardId { + if shard_data.n_shards() == 1 { + return ShardId::new(0); + } + hash_code(shard_data, row, n_keys).0 +} + fn hash_code(shard_data: ShardData, row: &[Value], n_keys: usize) -> (ShardId, u64) { let mut hasher = FxHasher::default(); for val in &row[0..n_keys] { diff --git a/egglog/core-relations/src/table/rebuild.rs b/egglog/core-relations/src/table/rebuild.rs index e274c0ba..d1419807 100644 --- a/egglog/core-relations/src/table/rebuild.rs +++ b/egglog/core-relations/src/table/rebuild.rs @@ -60,6 +60,22 @@ impl SortedWritesTable { // Incremental rebuilds are possible if we can scan the subset of the columns that are // relevant. let to_scan = self.subset_tracker.recent_updates(table_id, table); + if std::env::var_os("EGGLOG_REBUILD_TRACE").is_some() { + log::info!( + "REBUILD_TRACE branch={} diff={} table_size={}", + if incremental_rebuild( + to_scan.size(), + self.data.next_row().index(), + parallelize_rebuild(to_scan.size()) + ) { + "incremental" + } else { + "fullscan" + }, + to_scan.size(), + self.data.next_row().index() + ); + } if incremental_rebuild( to_scan.size(), self.data.next_row().index(), diff --git a/egglog/core-relations/src/table_spec.rs b/egglog/core-relations/src/table_spec.rs index a4117b5e..4487d80f 100644 --- a/egglog/core-relations/src/table_spec.rs +++ b/egglog/core-relations/src/table_spec.rs @@ -17,7 +17,7 @@ use crate::{ QueryEntry, TableId, Variable, action::{ Bindings, ExecutionState, - mask::{Mask, MaskIter, ValueSource}, + mask::{Mask, MaskIter}, }, common::Value, hash_index::{ColumnIndex, IndexBase, TupleIndex}, @@ -286,7 +286,7 @@ pub trait Table: Any + Send + Sync { } /// Returns true if the table contains any stale rows (rows whose first column - /// has been set to [`Value::stale()`]). The default implementation returns `true` + /// has been set to `Value::stale()`). The default implementation returns `true` /// (conservative). Tables that track stale-row counts should override this. fn has_stale_rows(&self) -> bool { true @@ -431,7 +431,7 @@ impl TableWrapper for WrapperImpl { inner: table, wrapper: self, }; - ColumnIndex::build_for_subset(wrapped, subset, col) + ColumnIndex::build_for_subset(wrapped, subset, &[col]) } fn group_by_key(&self, table: &dyn Table, subset: SubsetRef, cols: &[ColumnId]) -> TupleIndex { let table = table.as_any().downcast_ref::().unwrap(); diff --git a/egglog/core-relations/src/tests.rs b/egglog/core-relations/src/tests.rs index 70cc9ced..2aaafc3c 100644 --- a/egglog/core-relations/src/tests.rs +++ b/egglog/core-relations/src/tests.rs @@ -1408,3 +1408,151 @@ fn early_stop_inner() { "External function called {final_count} times, should be much less than 10k" ); } + +/// An occurrence atom binds a variable to a value appearing in *any* of the +/// listed columns, and is probed through the occurrence index. Here `dirty` +/// supplies the value and the atom finds every `edge` row mentioning it at +/// either endpoint — the access pattern a rebuild needs. +#[test] +fn occurrence_atom_finds_rows_mentioning_a_bound_value() { + use crate::table_spec::ColumnId; + + let mut db = Database::default(); + let relation = |n_keys: usize, n_cols: usize| { + SortedWritesTable::new( + n_keys, + n_cols, + None, + vec![], + Box::new(|_, left, right, _| { + assert_eq!(left, right, "merge not supported"); + false + }), + ) + }; + // edge(id, from, to) — `from`/`to` are the columns to index together. + let edge = db.add_table(relation(1, 3), iter::empty(), iter::empty()); + let dirty = db.add_table(relation(1, 1), iter::empty(), iter::empty()); + let touched = db.add_table(relation(1, 1), iter::empty(), iter::empty()); + let v = Value::new; + { + let mut b = db.new_buffer(edge); + b.stage_insert(&[v(0), v(10), v(11)]); + b.stage_insert(&[v(1), v(11), v(12)]); + b.stage_insert(&[v(2), v(12), v(13)]); + b.stage_insert(&[v(3), v(20), v(20)]); // same value at both endpoints + } + { + let mut b = db.new_buffer(dirty); + b.stage_insert(&[v(11)]); + b.stage_insert(&[v(20)]); + } + db.merge_all(); + + let mut rules = RuleSetBuilder::new(&mut db); + let mut query = rules.new_rule(); + let val = query.new_var_named("val"); + let id = query.new_var_named("id"); + let from = query.new_var_named("from"); + let to = query.new_var_named("to"); + query.add_atom(dirty, &[val.into()], &[]).unwrap(); + query + .add_occurrence_atom( + edge, + &[id.into(), from.into(), to.into()], + val, + &[ColumnId::new(1), ColumnId::new(2)], + &[], + ) + .unwrap(); + let mut action = query.build(); + action.insert(touched, &[id.into()]).unwrap(); + action.build_with_description("touched"); + let rule_set = rules.build(); + db.run_rule_set(&rule_set, ReportLevel::TimeOnly); + db.merge_all(); + + let mut got = Vec::new(); + let table = db.get_table(touched); + table + .scan(table.all().as_ref()) + .iter() + .for_each(|(_, row)| got.push(row[0].rep())); + got.sort_unstable(); + // 11 sits at `to` of edge 0 and `from` of edge 1; 20 sits at both endpoints + // of edge 3, which must still be reported once. Edge 2 mentions neither. + assert_eq!(got, vec![0, 1, 3]); +} + +/// A query containing an occurrence atom is planned with generic join whatever +/// strategy was asked for: the other planners map a subatom's columns back to +/// its variables, and an occurrence variable occupies none of them. +#[test] +fn occurrence_atom_overrides_the_requested_plan_strategy() { + use crate::free_join::plan::PlanStrategy; + use crate::table_spec::ColumnId; + + let mut db = Database::default(); + let relation = |n_keys: usize, n_cols: usize| { + SortedWritesTable::new( + n_keys, + n_cols, + None, + vec![], + Box::new(|_, left, right, _| { + assert_eq!(left, right, "merge not supported"); + false + }), + ) + }; + let edge = db.add_table(relation(1, 3), iter::empty(), iter::empty()); + let dirty = db.add_table(relation(1, 1), iter::empty(), iter::empty()); + let touched = db.add_table(relation(1, 1), iter::empty(), iter::empty()); + let v = Value::new; + { + let mut b = db.new_buffer(edge); + b.stage_insert(&[v(0), v(10), v(11)]); + b.stage_insert(&[v(1), v(12), v(13)]); + } + { + let mut b = db.new_buffer(dirty); + b.stage_insert(&[v(11)]); + } + db.merge_all(); + + for strat in [PlanStrategy::MinCover, PlanStrategy::PureSize] { + let mut rules = RuleSetBuilder::new(&mut db); + let mut query = rules.new_rule(); + query.set_plan_strategy(strat); + let val = query.new_var_named("val"); + let id = query.new_var_named("id"); + let from = query.new_var_named("from"); + let to = query.new_var_named("to"); + query.add_atom(dirty, &[val.into()], &[]).unwrap(); + query + .add_occurrence_atom( + edge, + &[id.into(), from.into(), to.into()], + val, + &[ColumnId::new(1), ColumnId::new(2)], + &[], + ) + .unwrap(); + let mut action = query.build(); + action.insert(touched, &[id.into()]).unwrap(); + action.build_with_description("touched"); + let rule_set = rules.build(); + db.run_rule_set(&rule_set, ReportLevel::TimeOnly); + db.merge_all(); + } + + let mut got = Vec::new(); + let table = db.get_table(touched); + table + .scan(table.all().as_ref()) + .iter() + .for_each(|(_, row)| got.push(row[0].rep())); + got.sort_unstable(); + got.dedup(); + assert_eq!(got, vec![0], "only edge 0 holds 11"); +} diff --git a/egglog/egglog-ast/src/generic_ast.rs b/egglog/egglog-ast/src/generic_ast.rs index 3ddd518f..377297e5 100644 --- a/egglog/egglog-ast/src/generic_ast.rs +++ b/egglog/egglog-ast/src/generic_ast.rs @@ -21,7 +21,7 @@ pub enum GenericExpr { Lit(Span, Literal), } -/// Facts are the left-hand side of a [`Command::Rule`]. +/// Facts are the left-hand side of a `Command::Rule`. /// They represent a part of a database query. /// Facts can be expressions or equality constraints between expressions. /// @@ -63,8 +63,8 @@ where Leaf: Clone + PartialEq + Eq + Display + Hash, { /// Bind a variable to a particular datatype or primitive. - /// At the top level (in a [`Command::Action`]), this defines a global variable. - /// In a [`Command::Rule`], this defines a local variable in the actions. + /// At the top level (in a `Command::Action`), this defines a global variable. + /// In a `Command::Rule`, this defines a local variable in the actions. Let(Span, Leaf, GenericExpr), /// `set` a function to a particular result. /// `set` should not be used on datatypes- diff --git a/egglog/egglog-backend-trait/src/backend_impl.rs b/egglog/egglog-backend-trait/src/backend_impl.rs index 39dc5216..ae796b8c 100644 --- a/egglog/egglog-backend-trait/src/backend_impl.rs +++ b/egglog/egglog-backend-trait/src/backend_impl.rs @@ -66,6 +66,25 @@ fn build_rule(egraph: &mut EGraph, rule: RuleSpec) -> Result { RuleBodyCall::Table { id, read } => { builder.query_table(*id, &entries, read.is_subsumed())?; } + RuleBodyCall::IndexTable { id, any_of, read } => { + // An index is declared as the relation `(value, row…) -> Unit`, so + // its atom carries the occurring value, the indexed function's row, + // and the relation's own unit output. Only the row belongs to the + // underlying function. + let (indexed, rest) = entries + .split_first() + .expect("an index atom binds a value and a row"); + let (_unit, row) = rest + .split_last() + .expect("an index atom carries the relation's unit output"); + builder.query_table_by_occurrence( + *id, + row, + indexed.clone(), + any_of, + read.is_subsumed(), + )?; + } RuleBodyCall::Primitive { id, output, .. } => { builder.query_prim(*id, &entries, *output)?; } diff --git a/egglog/egglog-backend-trait/src/lib.rs b/egglog/egglog-backend-trait/src/lib.rs index cd51f2e0..bcf1a1f0 100644 --- a/egglog/egglog-backend-trait/src/lib.rs +++ b/egglog/egglog-backend-trait/src/lib.rs @@ -145,6 +145,17 @@ pub enum RuleBodyCall { id: FunctionId, read: ReadMode, }, + /// An atom over a declared index: the rows of `id` reached through the value + /// occurring in *some* one of `any_of`. Its arguments are that value followed + /// by the whole row of `id`. + /// + /// The atom can only be probed, so the leading value must be bound elsewhere + /// in the query. + IndexTable { + id: FunctionId, + any_of: Vec, + read: ReadMode, + }, Primitive { id: ExternalFunctionId, name: Box, diff --git a/egglog/egglog-bridge/src/lib.rs b/egglog/egglog-bridge/src/lib.rs index 5a3d9310..9c59ca4e 100644 --- a/egglog/egglog-bridge/src/lib.rs +++ b/egglog/egglog-bridge/src/lib.rs @@ -143,7 +143,7 @@ pub struct EGraph { /// Live registry of name-indexed action handles. Shared (via /// `Arc>`) with state wrappers and primitive callbacks /// in the egglog crate so name-indexed action methods on - /// [`WriteState`] / [`FullState`] can resolve table actions at + /// `WriteState` / `FullState` can resolve table actions at /// invoke time. Mutated in place from [`add_table`](EGraph::add_table). action_registry: Arc>, } @@ -330,29 +330,35 @@ impl EGraph { /// Register the term encoder's `set-if-empty` canonicalize op for the FD /// view table named `view_name` (`n_keys` key columns), returning the - /// [`ExternalFunctionId`] its mint sites resolve to. At invoke: look up - /// `(view keys)`; if a row exists return its first output (the eclass); - /// otherwise insert `(keys, trailing-default-columns)` and return the first - /// default column. Serviced over this backend's db view table. + /// [`ExternalFunctionId`] its mint sites resolve to. + /// + /// Invoked as `(keys…, vals…)`, it returns the view's first output column + /// (the e-class) for `keys`: the existing row's when the key is present, and + /// otherwise the first of `vals` after staging `(keys, vals)`. A second + /// invocation with the same key inside one action batch sees the first one's + /// staged row, so the two agree — see + /// [`TableAction::lookup_or_insert_vals`]. pub fn register_set_if_empty( &mut self, view_name: String, n_keys: usize, - _out_arity: usize, + out_arity: usize, ) -> ExternalFunctionId { let registry = self.action_registry.clone(); self.register_external_func(Box::new(make_external_func( move |state: &mut ExecutionState, args: &[Value]| { let registry = registry.read().unwrap(); let action = registry.lookup_table(&view_name)?.clone(); + // Too few vals and the row is staged short of its value columns; + // too many and the surplus lands on the timestamp. + debug_assert_eq!( + args.len(), + n_keys + out_arity, + "set-if-empty on view `{view_name}` takes {n_keys} keys and \ + {out_arity} values" + ); let keys = &args[..n_keys]; - if let Some(vals) = action.lookup_values(state, keys) { - // Already canonicalized: reuse the committed eclass and skip - // the insert, so the fresh id never enters the table. - return Some(vals[0]); - } - action.insert(state, args.iter().copied()); - Some(args[n_keys]) + Some(action.lookup_or_insert_vals(state, keys, &args[n_keys..])) }, ))) } @@ -373,10 +379,11 @@ impl EGraph { let registry = registry.read().unwrap(); let action = registry.lookup_table(&view_name)?.clone(); let fallback = args[n_keys]; - Some(match action.lookup_values(state, &args[..n_keys]) { - Some(vals) => vals[col_idx], - None => fallback, - }) + Some( + action + .lookup_value_col(state, &args[..n_keys], col_idx) + .unwrap_or(fallback), + ) }, ))) } @@ -1858,6 +1865,28 @@ impl TableAction { self.table_math.n_vals() } + /// Look up value column `col_idx` for `key`, or `None` if the key is absent. + /// This is a pure read and never inserts a default row. + pub fn lookup_value_col( + &self, + state: &ExecutionState, + key: &[Value], + col_idx: usize, + ) -> Option { + // The timestamp column sits right after the value columns, so an + // out-of-range `col_idx` would read one of the table's own bookkeeping + // columns back as a value. + debug_assert!( + col_idx < self.table_math.n_vals(), + "value column {col_idx} is past this table's {} value columns", + self.table_math.n_vals() + ); + state.get_table(self.table).get_row_column( + key, + ColumnId::from_usize(self.table_math.num_keys() + col_idx), + ) + } + /// Look up all output columns for `key`. This is a pure read and never /// inserts a default row. pub fn lookup_values(&self, state: &ExecutionState, key: &[Value]) -> Option> { @@ -2004,6 +2033,39 @@ impl TableAction { } } + /// Get-or-insert with caller-supplied value columns, reading this batch's + /// own pending inserts. + /// + /// Returns the first value column of the row for `key`: the committed row's + /// if the key is present, the pending row's if an earlier call in this same + /// action batch already inserted it, and otherwise `vals`' first entry after + /// staging `(key, vals)`. + pub fn lookup_or_insert_vals( + &self, + state: &mut ExecutionState, + key: &[Value], + vals: &[Value], + ) -> Value { + debug_assert_eq!( + vals.len(), + self.table_math.n_vals(), + "lookup_or_insert_vals: vals must fill every value column" + ); + let timestamp = MergeVal::Constant(Value::from_usize(state.read_counter(self.timestamp))); + let mut merge_vals = SmallVec::<[MergeVal; 4]>::new(); + merge_vals.extend(vals.iter().map(|v| MergeVal::Constant(*v))); + merge_vals.push(timestamp); + if self.table_math.subsume { + merge_vals.push(MergeVal::Constant(NOT_SUBSUMED)); + } + state.predict_col( + self.table, + key, + merge_vals.iter().copied(), + ColumnId::from_usize(self.table_math.ret_val_col()), + ) + } + /// Insert a row into this table. pub fn insert(&self, state: &mut ExecutionState, row: impl Iterator) { let ts = Value::from_usize(state.read_counter(self.timestamp)); diff --git a/egglog/egglog-bridge/src/rule.rs b/egglog/egglog-bridge/src/rule.rs index 551cfe20..7e567287 100644 --- a/egglog/egglog-bridge/src/rule.rs +++ b/egglog/egglog-bridge/src/rule.rs @@ -103,7 +103,7 @@ pub(crate) struct Query { id_counter: CounterId, ts_counter: CounterId, vars: DenseIdMap, - atoms: Vec<(TableId, Vec, SchemaMath)>, + atoms: Vec, /// The builders for queries in this module essentially wrap the lower-level /// builders from the `core_relations` crate. A single egglog rule can turn /// into N core-relations rules. The code is structured by constructing a @@ -409,7 +409,12 @@ impl RuleBuilder<'_> { }, ); let res = AtomId::from_usize(self.query.atoms.len()); - self.query.atoms.push((table, atom, schema_math)); + self.query.atoms.push(QueryAtom { + table, + entries: atom, + schema: schema_math, + occurrence: None, + }); res } @@ -472,6 +477,34 @@ impl RuleBuilder<'_> { )) } + /// Add a table atom that additionally binds `indexed` to a value occurring in + /// *some* one of `cols`, serviced by an occurrence index over those columns. + /// + /// This is the "which rows mention this value" access pattern. It differs + /// from repeating a variable across `cols`, which instead constrains those + /// columns to be equal. `cols` are the function's own column indices, and + /// `indexed` is not one of `entries`. + /// + /// The atom can only be probed, so `indexed` must be bound elsewhere in the + /// query. `is_subsumed` constrains the subsumption column exactly as it does + /// for [`Self::query_table`], so an index reads the same rows the function + /// itself would. + pub fn query_table_by_occurrence( + &mut self, + func: FunctionId, + entries: &[QueryEntry], + indexed: QueryEntry, + cols: &[usize], + is_subsumed: Option, + ) -> Result { + let atom = self.query_table(func, entries, is_subsumed)?; + self.query.atoms[atom.index()].occurrence = Some(( + indexed, + cols.iter().copied().map(ColumnId::from_usize).collect(), + )); + Ok(atom) + } + /// Add the given primitive atom to query. As elsewhere in the crate, the last /// argument is the "return value" of the function. pub fn query_prim( @@ -787,8 +820,15 @@ impl Query { let mut rsb = RuleSetBuilder::new(db); let (mut qb, mut inner) = self.query_state(&mut rsb); let mut atom_mapping = Vec::with_capacity(self.atoms.len()); - for (table, entries, _schema_info) in &self.atoms { - atom_mapping.push(add_atom(&mut qb, *table, entries, &[], &mut inner)?); + for atom in &self.atoms { + atom_mapping.push(add_atom( + &mut qb, + atom.table, + &atom.entries, + atom.occurrence.as_ref(), + &[], + &mut inner, + )?); } let rule_id = self.run_rules_and_build(qb, inner, desc)?; let rs = rsb.build(); @@ -816,7 +856,7 @@ impl Query { } if let Some(focus_atom) = self.sole_focus { // There is a single "focus" atom that we will constrain to look at new values. - let (_, _, schema_info) = &self.atoms[focus_atom]; + let schema_info = &self.atoms[focus_atom].schema; let ts_col = ColumnId::from_usize(schema_info.ts_col()); let _ = rsb.add_rule_from_cached_plan( &cached_plan.plan, @@ -844,7 +884,7 @@ impl Query { // constraints in order, and the focus atom may have an empty delta, which // will let it bail early. { - let (_, _, schema_info) = &self.atoms[focus_atom]; + let schema_info = &self.atoms[focus_atom].schema; let ts_col = ColumnId::from_usize(schema_info.ts_col()); constraints.push(( cached_plan.atom_mapping[focus_atom], @@ -854,7 +894,11 @@ impl Query { }, )) } - for (i, (_, _, schema_info)) in self.atoms[0..focus_atom].iter().enumerate() { + for (i, schema_info) in self.atoms[0..focus_atom] + .iter() + .map(|a| &a.schema) + .enumerate() + { if mid_ts == Timestamp::new(0) { continue 'outer; } @@ -897,10 +941,24 @@ impl Bindings { } } +/// One atom of a rule's query, as staged before the core-relations query is built. +#[derive(Clone)] +struct QueryAtom { + table: TableId, + /// The function's columns followed by the table's timestamp / subsume columns. + entries: Vec, + schema: SchemaMath, + /// Set for an occurrence atom: the variable bound to a value appearing in + /// *some* one of `cols` (function-column indices), rather than at a fixed + /// column. See `core_relations::Atom::occurrence`. + occurrence: Option<(QueryEntry, SmallVec<[ColumnId; 4]>)>, +} + fn add_atom( qb: &mut QueryBuilder, table: TableId, entries: &[QueryEntry], + occurrence: Option<&(QueryEntry, SmallVec<[ColumnId; 4]>)>, constraints: &[Constraint], inner: &mut Bindings, ) -> Result { @@ -910,5 +968,13 @@ fn add_atom( } } let vars = inner.convert_all(entries); - Ok(qb.add_atom(table, &vars, constraints)?) + let Some((occ_entry, cols)) = occurrence else { + return Ok(qb.add_atom(table, &vars, constraints)?); + }; + let DstVar::Var(occ_var) = inner.convert(occ_entry) else { + return Err(anyhow::anyhow!( + "an occurrence atom's indexed value must be a variable, not a constant" + )); + }; + Ok(qb.add_occurrence_atom(table, &vars, occ_var, cols, constraints)?) } diff --git a/egglog/src/ast/check_shadowing.rs b/egglog/src/ast/check_shadowing.rs index d98ae6a9..14504df4 100644 --- a/egglog/src/ast/check_shadowing.rs +++ b/egglog/src/ast/check_shadowing.rs @@ -53,6 +53,7 @@ impl Names { } Ok(()) } + ResolvedNCommand::Index { span, name, .. } => self.check(name.clone(), span.clone()), ResolvedNCommand::AddRuleset(span, name) => self.check(name.clone(), span.clone()), ResolvedNCommand::UnstableCombinedRuleset(span, name, _args) => { self.check(name.clone(), span.clone()) diff --git a/egglog/src/ast/cse.rs b/egglog/src/ast/cse.rs deleted file mode 100644 index 4cb729cd..00000000 --- a/egglog/src/ast/cse.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Common-subexpression elimination over action scopes. -//! -//! Within one scope, a constructor application occurring more than once is bound -//! to a shared `let` and its occurrences rewritten to that variable: -//! -//! ```text -//! (Foo (Bar x) (Bar x)) ⟶ (let c (Bar x)) (Foo c c) -//! ``` - -use crate::{ - ast::{ - FunctionSubtype, GenericActions, GenericNCommand, ResolvedAction, ResolvedExpr, - ResolvedExprExt, ResolvedNCommand, ResolvedVar, - }, - core::ResolvedCall, - util::{FreshGen, HashMap, SymbolGen}, -}; -use egglog_ast::generic_ast::GenericExpr; - -/// Apply [`cse_actions`] to every action scope in `program`. -/// -/// A rule head keeps its shape; a top-level action that gains shared `let`s -/// becomes a [`GenericNCommand::CoreActions`] block, so they stay local. -pub(crate) fn cse_program( - program: Vec, - fresh: &mut SymbolGen, -) -> Vec { - program - .into_iter() - .map(|command| match command { - GenericNCommand::NormRule { mut rule } => { - rule.head = GenericActions(cse_actions(&rule.head.0, fresh)); - GenericNCommand::NormRule { rule } - } - GenericNCommand::CoreAction(action) => { - let deduped = cse_actions(std::slice::from_ref(&action), fresh); - // Nothing shared: keep the plain action. - if deduped.len() == 1 { - GenericNCommand::CoreAction(deduped.into_iter().next().unwrap()) - } else { - GenericNCommand::CoreActions(GenericActions(deduped)) - } - } - GenericNCommand::CoreActions(actions) => { - GenericNCommand::CoreActions(GenericActions(cse_actions(&actions.0, fresh))) - } - other => other, - }) - .collect() -} - -/// Bind each constructor application occurring more than once in this scope to a -/// shared `let`, returning the scope's actions with those `let`s prepended. -pub(crate) fn cse_actions( - actions: &[ResolvedAction], - fresh: &mut SymbolGen, -) -> Vec { - let mut counts: HashMap = HashMap::default(); - for action in actions { - count_action_ctors(action, &mut counts); - } - if counts.values().all(|&c| c < 2) { - return actions.to_vec(); - } - let mut cache: HashMap = HashMap::default(); - let mut out = vec![]; - for action in actions { - let rewritten = cse_action(action, &counts, &mut cache, &mut out, fresh); - out.push(rewritten); - } - out -} - -/// Count each constructor sub-application (by its s-expr form) in `expr`. -fn count_ctor_subexprs(expr: &ResolvedExpr, counts: &mut HashMap) { - if let GenericExpr::Call(_, call, args) = expr { - for arg in args { - count_ctor_subexprs(arg, counts); - } - if matches!(call, ResolvedCall::Func(ft) if ft.subtype == FunctionSubtype::Constructor) { - *counts.entry(expr.to_string()).or_default() += 1; - } - } -} - -/// Count constructor sub-applications across one action's expressions. -fn count_action_ctors(action: &ResolvedAction, counts: &mut HashMap) { - match action { - ResolvedAction::Let(_, _, e) | ResolvedAction::Expr(_, e) => count_ctor_subexprs(e, counts), - ResolvedAction::Set(_, _, args, val) => { - args.iter().for_each(|a| count_ctor_subexprs(a, counts)); - count_ctor_subexprs(val, counts); - } - ResolvedAction::Union(_, a, b) => { - count_ctor_subexprs(a, counts); - count_ctor_subexprs(b, counts); - } - ResolvedAction::Change(_, _, _, args) => { - args.iter().for_each(|a| count_ctor_subexprs(a, counts)); - } - ResolvedAction::Panic(..) => {} - } -} - -/// Rewrite one action, hoisting its repeated constructor sub-applications (per -/// `counts`) into shared `let`s pushed onto `out` before it. -fn cse_action( - action: &ResolvedAction, - counts: &HashMap, - cache: &mut HashMap, - out: &mut Vec, - fresh: &mut SymbolGen, -) -> ResolvedAction { - let mut go = |e: &ResolvedExpr| cse_expr(e, counts, cache, out, fresh); - match action { - ResolvedAction::Let(span, v, e) => ResolvedAction::Let(span.clone(), v.clone(), go(e)), - ResolvedAction::Expr(span, e) => ResolvedAction::Expr(span.clone(), go(e)), - ResolvedAction::Set(span, call, args, val) => { - let args = args.iter().map(&mut go).collect(); - let val = go(val); - ResolvedAction::Set(span.clone(), call.clone(), args, val) - } - ResolvedAction::Union(span, a, b) => ResolvedAction::Union(span.clone(), go(a), go(b)), - ResolvedAction::Change(span, change, call, args) => { - let args = args.iter().map(&mut go).collect(); - ResolvedAction::Change(span.clone(), *change, call.clone(), args) - } - ResolvedAction::Panic(..) => action.clone(), - } -} - -/// Rewrite one expression bottom-up, replacing each repeated constructor -/// application with a shared `let`-bound variable. Keyed by the original s-expr, -/// matching `counts`. -fn cse_expr( - expr: &ResolvedExpr, - counts: &HashMap, - cache: &mut HashMap, - out: &mut Vec, - fresh: &mut SymbolGen, -) -> ResolvedExpr { - let GenericExpr::Call(span, call, args) = expr else { - return expr.clone(); - }; - let new_args = args - .iter() - .map(|a| cse_expr(a, counts, cache, out, fresh)) - .collect(); - let rewritten = GenericExpr::Call(span.clone(), call.clone(), new_args); - let is_ctor = - matches!(call, ResolvedCall::Func(ft) if ft.subtype == FunctionSubtype::Constructor); - if !is_ctor || counts.get(&expr.to_string()).copied().unwrap_or(0) < 2 { - return rewritten; - } - let key = expr.to_string(); - if let Some(v) = cache.get(&key) { - return GenericExpr::Var(span.clone(), v.clone()); - } - let var = ResolvedVar { - name: fresh.fresh("cse"), - sort: expr.output_type(), - is_global_ref: false, - }; - out.push(ResolvedAction::Let(span.clone(), var.clone(), rewritten)); - cache.insert(key, var.clone()); - GenericExpr::Var(span.clone(), var) -} diff --git a/egglog/src/ast/desugar.rs b/egglog/src/ast/desugar.rs index abdbb73a..d8266225 100644 --- a/egglog/src/ast/desugar.rs +++ b/egglog/src/ast/desugar.rs @@ -167,6 +167,17 @@ pub(crate) fn desugar_command( proof_constructors, unionable, }], + Command::Index { + span, + name, + function, + any_of, + } => vec![NCommand::Index { + span, + name, + function, + any_of, + }], Command::AddRuleset(span, name) => vec![NCommand::AddRuleset(span, name)], Command::UnstableCombinedRuleset(span, name, subrulesets) => { vec![NCommand::UnstableCombinedRuleset(span, name, subrulesets)] diff --git a/egglog/src/ast/expr.rs b/egglog/src/ast/expr.rs index b269af49..05439653 100644 --- a/egglog/src/ast/expr.rs +++ b/egglog/src/ast/expr.rs @@ -49,7 +49,8 @@ pub type ResolvedExpr = GenericExpr; /// A [`MappedExpr`] arises naturally when you want a mapping between an expression /// and its flattened form. It records this mapping by annotating each `Head` /// with a `Leaf`, which it maps to in the flattened form. -/// A useful operation on `MappedExpr`s is [`MappedExprExt::get_corresponding_var_or_lit``]. +/// A useful operation on `MappedExpr`s is +/// [`MappedExprExt::get_corresponding_var_or_lit`](crate::ast::MappedExprExt::get_corresponding_var_or_lit). pub(crate) type MappedExpr = GenericExpr, Leaf>; pub(crate) trait ResolvedExprExt { diff --git a/egglog/src/ast/mod.rs b/egglog/src/ast/mod.rs index 8aceaa49..44fd58ff 100644 --- a/egglog/src/ast/mod.rs +++ b/egglog/src/ast/mod.rs @@ -1,5 +1,4 @@ pub mod check_shadowing; -pub mod cse; pub mod desugar; mod expr; mod parse; @@ -68,7 +67,8 @@ pub(crate) enum Ruleset { pub type NCommand = GenericNCommand; /// [`ResolvedNCommand`] is another specialization of [`GenericNCommand`], which /// adds the type information to heads and leaves of commands. -/// [`TypeInfo::typecheck_command`] turns an [`NCommand`] into a [`ResolvedNCommand`]. +/// [`EGraph::typecheck_command`](crate::EGraph::typecheck_command) turns an +/// [`NCommand`] into a [`ResolvedNCommand`]. pub(crate) type ResolvedNCommand = GenericNCommand; /// A [`NCommand`] is a desugared [`Command`], where syntactic sugars @@ -112,6 +112,12 @@ where unionable: bool, }, Function(GenericFunctionDecl), + Index { + span: Span, + name: String, + function: String, + any_of: Vec, + }, AddRuleset(Span, String), UnstableCombinedRuleset(Span, String, Vec), NormRule { @@ -209,6 +215,17 @@ where term_node: f.internal_term_node, }, }, + GenericNCommand::Index { + span, + name, + function, + any_of, + } => GenericCommand::Index { + span: span.clone(), + name: name.clone(), + function: function.clone(), + any_of: any_of.clone(), + }, GenericNCommand::AddRuleset(span, name) => { GenericCommand::AddRuleset(span.clone(), name.clone()) } @@ -284,6 +301,7 @@ where ), GenericNCommand::Sort { .. } | GenericNCommand::Function(..) + | GenericNCommand::Index { .. } | GenericNCommand::AddRuleset(..) | GenericNCommand::UnstableCombinedRuleset(..) | GenericNCommand::CoreAction(..) @@ -328,6 +346,17 @@ where unionable, }, GenericNCommand::Function(func) => GenericNCommand::Function(func.visit_exprs(f)), + GenericNCommand::Index { + span, + name, + function, + any_of, + } => GenericNCommand::Index { + span, + name, + function, + any_of, + }, GenericNCommand::AddRuleset(span, name) => GenericNCommand::AddRuleset(span, name), GenericNCommand::UnstableCombinedRuleset(span, name, rulesets) => { GenericNCommand::UnstableCombinedRuleset(span, name, rulesets) @@ -831,6 +860,28 @@ where /// :ruleset myrules) /// (run myrules 2) /// ``` + /// Declare a named index over an existing function's columns. + /// + /// ```text + /// (index AddOcc AddView (any 0 1 2)) + /// ``` + /// + /// `AddOcc` is a read-only relation holding, for every row of `AddView` and + /// every listed column, that column's value followed by the whole row. Its + /// rows are exactly the live rows of `AddView`, so it changes no results — + /// only how fast a rule that looks up rows by a contained value runs. The + /// database maintains it; `set` and `delete` on it are errors. + /// + /// `any` reads the columns disjunctively: one entry per distinct value in + /// them, so a value in two of a row's columns still yields one entry. This + /// is the "which rows mention this value" lookup, and differs from repeating + /// a variable across those columns, which constrains them to be equal. + Index { + span: Span, + name: String, + function: String, + any_of: Vec, + }, AddRuleset(Span, String), /// Using the `combined-ruleset` command, construct another ruleset /// which runs all the rules in the given rulesets. @@ -1200,6 +1251,18 @@ where } => { write!(f, "(relation {name} ({}))", ListDisplay(inputs, " ")) } + GenericCommand::Index { + name, + function, + any_of, + .. + } => { + write!( + f, + "(index {name} {function} (any {}))", + ListDisplay(any_of, " ") + ) + } GenericCommand::AddRuleset(_span, name) => { write!(f, "(ruleset {name})") } @@ -2020,6 +2083,17 @@ where cost, term_node, }, + GenericCommand::Index { + span, + name, + function, + any_of, + } => GenericCommand::Index { + span, + name: fun(name), + function: fun(function), + any_of, + }, GenericCommand::AddRuleset(span, name) => GenericCommand::AddRuleset(span, fun(name)), GenericCommand::UnstableCombinedRuleset(span, name, others) => { GenericCommand::UnstableCombinedRuleset( @@ -2280,6 +2354,17 @@ where cost, term_node, }, + GenericCommand::Index { + span, + name, + function, + any_of, + } => GenericCommand::Index { + span, + name, + function, + any_of, + }, GenericCommand::AddRuleset(span, name) => GenericCommand::AddRuleset(span, name), GenericCommand::UnstableCombinedRuleset(span, name, others) => { GenericCommand::UnstableCombinedRuleset(span, name, others) diff --git a/egglog/src/ast/parse.rs b/egglog/src/ast/parse.rs index 703f9455..35892023 100644 --- a/egglog/src/ast/parse.rs +++ b/egglog/src/ast/parse.rs @@ -663,6 +663,26 @@ impl Parser { }], _ => return error!(span, "usage: (relation (*))"), }, + "index" => match tail { + [name, function, key] => { + let key = key.expect_list("index key")?; + let [extractor, cols @ ..] = key else { + return error!(span, "usage: (index (any *))"); + }; + if extractor.expect_atom("index extractor")? != "any" { + return error!(span, "the only index extractor is `any`"); + } + vec![Command::Index { + span, + name: name.expect_atom("index name")?, + function: function.expect_atom("indexed function name")?, + any_of: map_fallible(cols, self, |_, sexp| { + sexp.expect_uint("index column") + })?, + }] + } + _ => return error!(span, "usage: (index (any *))"), + }, "ruleset" => match tail { [name] => vec![Command::AddRuleset(span, name.expect_atom("ruleset name")?)], _ => return error!(span, "usage: (ruleset )"), diff --git a/egglog/src/lib.rs b/egglog/src/lib.rs index 48b9a319..64aeb65f 100644 --- a/egglog/src/lib.rs +++ b/egglog/src/lib.rs @@ -954,7 +954,7 @@ impl EGraph { } } - /// Lower a resolved `:merge` (a value-producing action block) to a backend [`MergeFn`], keeping + /// Lower a resolved `:merge` (a value-producing action block) to a backend [`egglog_bridge::MergeFn`], keeping /// the existing merge interpreter. The `result` produces the merged value(s); any `actions` run /// first as effects. /// `self_ref` names the function this merge belongs to and its (peeked) backend id, @@ -999,7 +999,7 @@ impl EGraph { }) } - /// Lower a single resolved merge action to a backend [`MergeAction`]. Supports `set`, `let`, and + /// Lower a single resolved merge action to a backend [`egglog_bridge::MergeAction`]. Supports `set`, `let`, and /// `union`; other actions (`delete`/`panic`/`extract`/...) are not meaningful during a merge. fn translate_merge_action( &self, @@ -2033,6 +2033,12 @@ impl EGraph { self.declare_function(&fdecl)?; log::info!("Declared {} {}.", fdecl.subtype, fdecl.name) } + ResolvedNCommand::Index { name, function, .. } => { + // Nothing to build: the backend creates the occurrence index the + // first time a rule probes it. Typechecking already registered + // the relation the atoms resolve against. + log::info!("Declared index {name} over {function}."); + } ResolvedNCommand::AddRuleset(_span, name) => { self.add_ruleset(name.clone()); log::info!("Declared ruleset {name}."); @@ -2635,11 +2641,8 @@ impl EGraph { self.names.check_shadowing(command)?; } - // Share repeated constructor applications (see `ast::cse`). - let deduped = - ast::cse::cse_program(typechecked_no_globals, &mut self.parser.symbol_gen); - - let term_encoding_added = ProofInstrumentor::add_term_encoding(self, deduped)?; + let term_encoding_added = + ProofInstrumentor::add_term_encoding(self, typechecked_no_globals)?; let mut new_typechecked = vec![]; for new_cmd in term_encoding_added { let desugared = @@ -3001,7 +3004,7 @@ impl EGraph { } /// Run a pattern query: bind the variables in `vars` against - /// `facts` and return one [`HashMap`] per match, keyed by variable + /// `facts` and return one `HashMap` per match, keyed by variable /// name. Values stay raw — convert via [`EGraph::value_to_base`]. /// /// With zero vars, returns at most one empty map (so `.len()` is 1 @@ -3377,21 +3380,79 @@ impl<'a> BackendRule<'a> { args.into_iter().map(|term| self.entry(term)).collect() } + /// An index atom is probed, never scanned, so the value it is looked up by + /// must be bound elsewhere in the query. + fn check_index_value_is_bound( + &self, + index: &crate::typechecking::FuncType, + atom: &core::GenericAtom, + query: &core::Query, + ) -> Result<(), Error> { + let Some(core::GenericAtomTerm::Var(span, value)) = atom.args.first() else { + return Ok(()); + }; + // The value may sit at a column of this very atom, which both binds it and + // makes the occurrence redundant — the atom is then an ordinary one. + if atom + .args + .iter() + .skip(1) + .any(|arg| matches!(arg, core::GenericAtomTerm::Var(_, v) if v.name == value.name)) + { + return Ok(()); + } + let bound_elsewhere = query.atoms.iter().any(|other| { + !std::ptr::eq(other, atom) + && !matches!(&other.head, + ResolvedCall::Func(f) if self.type_info.indexes.contains_key(&f.name)) + && other.args.iter().any( + |arg| matches!(arg, core::GenericAtomTerm::Var(_, v) if v.name == value.name), + ) + }); + if bound_elsewhere { + return Ok(()); + } + Err( + TypeError::IndexValueUnbound(index.name.clone(), value.name.clone(), span.clone()) + .into(), + ) + } + fn query( &mut self, query: &core::Query, include_subsumed: bool, ) -> Result<(), Error> { for atom in &query.atoms { + let read = if include_subsumed { + ReadMode::All + } else { + ReadMode::Live + }; let (head, args) = match &atom.head { + // An atom on a declared index reads the rows of the indexed + // function, reached through the value its first argument binds. + ResolvedCall::Func(f) if self.type_info.indexes.contains_key(&f.name) => { + self.check_index_value_is_bound(f, atom, query)?; + let index = self.type_info.indexes[&f.name].clone(); + let indexed = self + .type_info + .get_func_type(&index.function) + .expect("index target checked at declaration") + .clone(); + ( + RuleBodyCall::IndexTable { + id: self.func(&indexed), + any_of: index.any_of, + read, + }, + self.args(&atom.args)?, + ) + } ResolvedCall::Func(f) => ( RuleBodyCall::Table { id: self.func(f), - read: if include_subsumed { - ReadMode::All - } else { - ReadMode::Live - }, + read, }, self.args(&atom.args)?, ), @@ -3420,8 +3481,27 @@ impl<'a> BackendRule<'a> { Ok(()) } + /// A declared index is a view the database maintains, so an action writing + /// to one is rejected. + fn reject_index_write(&self, call: &ResolvedCall, span: &Span) -> Result<(), Error> { + if let ResolvedCall::Func(f) = call + && self.type_info.indexes.contains_key(&f.name) + { + return Err(TypeError::IndexIsReadOnly(f.name.clone(), span.clone()).into()); + } + Ok(()) + } + fn actions(&mut self, actions: &core::ResolvedCoreActions) -> Result<(), Error> { for action in &actions.0 { + match action { + core::GenericCoreAction::Let(span, _, f, _) + | core::GenericCoreAction::Set(span, f, _, _) + | core::GenericCoreAction::Change(span, _, f, _) => { + self.reject_index_write(f, span)?; + } + _ => {} + } match action { core::GenericCoreAction::Let(span, v, f, args) => { let (call, args) = match f { diff --git a/egglog/src/proofs/mod.rs b/egglog/src/proofs/mod.rs index 82f0978a..13451c4e 100644 --- a/egglog/src/proofs/mod.rs +++ b/egglog/src/proofs/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod proof_extraction; pub(crate) mod proof_extractor; pub(crate) mod proof_format; pub(crate) mod proof_fresh; +pub(crate) mod proof_head; pub(crate) mod proof_normal_form; pub(crate) mod proof_simplification; pub(crate) mod proof_tests; diff --git a/egglog/src/proofs/proof_checker.rs b/egglog/src/proofs/proof_checker.rs index 9c6b1d9d..74c46925 100644 --- a/egglog/src/proofs/proof_checker.rs +++ b/egglog/src/proofs/proof_checker.rs @@ -16,7 +16,7 @@ use crate::{ core::ResolvedCall, proofs::proof_format::{Justification, ProofId, ProofStore, Proposition}, typechecking::FuncType, - util::{HashMap, HashSet, SymbolGen}, + util::{HashMap, HashSet, IndexMap, SymbolGen}, }; use thiserror::Error; @@ -565,9 +565,12 @@ fn format_term(term_dag: &TermDag, term_id: TermId) -> String { } /// Helper function to format a substitution as a string -fn format_substitution(term_dag: &TermDag, substitution: &HashMap) -> String { +fn format_substitution<'a>( + term_dag: &TermDag, + substitution: impl IntoIterator, +) -> String { substitution - .iter() + .into_iter() .map(|(k, v)| format!("{} -> {}", k, format_term(term_dag, *v))) .collect::>() .join(", ") @@ -1269,7 +1272,7 @@ impl ProofStore { fn check_rule_produces_equality( &mut self, rule: &crate::ast::GenericRule, - substitution: &HashMap, + substitution: &IndexMap, subst_with_globals: &HashMap, claimed: &Proposition, rule_name: &str, diff --git a/egglog/src/proofs/proof_container_rebuild.rs b/egglog/src/proofs/proof_container_rebuild.rs index 263c48fd..a16ac2c8 100644 --- a/egglog/src/proofs/proof_container_rebuild.rs +++ b/egglog/src/proofs/proof_container_rebuild.rs @@ -31,6 +31,100 @@ fn mint_proof_row( out } +/// Name of an eq-sort's `uf_canon` primitive, derived from its `@UF_` table +/// name. The encoder and the typechecker compute it the same way, so the +/// desugared program needs no extra annotation to find it. +pub(crate) fn uf_canon_prim_name(uf_name: &str) -> String { + format!("{uf_name}_canon") +} + +/// Name of an eq-sort's `uf_canon_proof` primitive. See [`uf_canon_prim_name`]. +pub(crate) fn uf_canon_proof_prim_name(uf_name: &str) -> String { + format!("{uf_name}_canon_proof") +} + +/// Register an eq-sort's single-term canonicalization primitives, so a rebuild +/// rule can canonicalize a term in its action: +/// +/// * `uf_canon : (S S) -> S` — `(term fallback)`, the term's `@UF_` leader, or +/// `fallback` when it has no row. Callers pass the term itself, making it +/// leader-or-self. +/// * `uf_canon_proof : (S Proof) -> Proof` (proof mode) — the `@UF_` row's +/// proof `term = leader`, or `fallback`. Callers pass the reflexive +/// `Proof(term)`. +/// +/// Both are the generic view-column read over the two-output `@UF_` table, so +/// every backend services them against its own storage. They read `@UF_`, so +/// they are sound only in the action of a rule whose body joins the driving +/// `@UF` delta. Called from the sort's Sort command, so they exist both during +/// encoding and on re-parse. +pub(crate) fn register_uf_canon( + eg: &mut EGraph, + sort_name: &str, + uf_name: &str, + proofs_enabled: bool, +) { + let Some(sort) = eg.get_sort_by_name(sort_name).cloned() else { + return; + }; + let table = uf_name.to_string(); + eg.add_backend_op_primitive( + UfCanonCol { + name: uf_canon_prim_name(uf_name), + key_sort: sort.clone(), + out_sort: sort.clone(), + }, + WriteState::valid_contexts(), + move |backend, _| backend.register_view_column_read(table.clone(), 1, 0), + ); + + // The proof column is `Unit` in term mode, and no rule reads it there. + if proofs_enabled { + let proof_sort: ArcSort = std::sync::Arc::new(EqSort { + name: eg.proof_state.proof_names.proof_datatype.clone(), + }); + let table = uf_name.to_string(); + eg.add_backend_op_primitive( + UfCanonCol { + name: uf_canon_proof_prim_name(uf_name), + key_sort: sort, + out_sort: proof_sort, + }, + WriteState::valid_contexts(), + move |backend, _| backend.register_view_column_read(table.clone(), 1, 1), + ); + } +} + +/// One column of an eq-sort's `@UF_` row, read by term with a fallback (see +/// [`register_uf_canon`]). +#[derive(Clone)] +struct UfCanonCol { + name: String, + key_sort: ArcSort, + out_sort: ArcSort, +} + +impl Primitive for UfCanonCol { + fn name(&self) -> &str { + &self.name + } + + fn get_type_constraints(&self, span: &Span) -> Box { + // (term fallback) -> column + SimpleTypeConstraint::new( + &self.name, + vec![ + self.key_sort.clone(), + self.out_sort.clone(), + self.out_sort.clone(), + ], + span.clone(), + ) + .into_box() + } +} + /// Register a container sort's rebuild primitives from its /// [`ContainerRebuildSpec`]. Called when a container Sort command carrying an /// `:internal-container-rebuild` annotation is typechecked, so the primitives diff --git a/egglog/src/proofs/proof_encoding.md b/egglog/src/proofs/proof_encoding.md index 1303db34..2bead2e5 100644 --- a/egglog/src/proofs/proof_encoding.md +++ b/egglog/src/proofs/proof_encoding.md @@ -3,532 +3,797 @@ Rewrites an egglog program to use an encoding for equality tracking, optionally # Overview The job of the term encoding is to *remove all calls to union* in the egglog -program. This makes proof production easier, since all equality reasoning is -explicit and can be instrumented with proof tracking. The encoding replaces -egglog's built-in congruence and rebuilding with an explicit, per-sort -union-find and view tables maintained by ordinary rules. +program. Egglog's built-in congruence and rebuilding are replaced by an explicit +per-sort union-find and per-constructor view tables, maintained by ordinary +rules. Every equality then has a rule firing behind it, which is what makes +proof tracking possible at all. The transformation is triggered when an `EGraph` is created with [`EGraph::new_with_term_encoding`](crate::EGraph::new_with_term_encoding) (no proofs), [`EGraph::new_with_proofs`](crate::EGraph::new_with_proofs), or by converting an existing one via [`EGraph::with_term_encoding_enabled`](crate::EGraph::with_term_encoding_enabled). -The same shapes are used with and without proofs: union-find and view rows carry -a proof column that is `()` (of sort `Unit`) when proofs are off, and a real -`Proof` when they are on. +The same table shapes are used either way: union-find and view rows carry a +proof column that is `()` (of sort `Unit`) with proofs off and a real `@Proof` +with them on. -The rest of this document is organized as: +This document has two parts. +[**The equality encoding**](#the-equality-encoding) is the tables, actions, +queries, and maintenance rules; its snippets are all shown with proofs off. +[**Proofs**](#proofs) is what fills the proof column, told in two layers: an +encoding that builds each proof as the rule runs, and the *skeleton* the encoder +actually emits, which records just enough for proof conversion to rebuild that +same proof afterwards. -- **[Data structures](#data-structures)** — the per-sort union-find and the - per-constructor term relation and view that every command reads and writes. -- **[Actions](#actions)** — how term construction, `union`, and `delete` lower, - including how a nested term is built over canonical ids with proofs. -- **[Queries](#queries)** — how rule bodies and `check`/`prove` read the views. -- **[Rebuilding](#rebuilding)** — the maintenance rules that keep views and the - union-find canonical, and the schedule that runs them. -- **[Globals](#globals)** and **[Containers](#containers)**. - -We use a running example throughout: +The running example throughout is: ```text -(sort Math) -(constructor Add (i64 i64) Math) -(Add 1 2) -(rule ((Add a b)) - ((union (Add a b) (Add b a))) - :name "commutativity") +(datatype Math (Add Math Math) (Num i64)) +(Add (Num 1) (Num 2)) +(rewrite (Add a b) (Add b a)) (run 1) -(check (= (Add 1 2) (Add 2 1))) -(delete (Add 1 2)) +(check (= (Add (Num 1) (Num 2)) (Add (Num 2) (Num 1)))) +(delete (Add (Num 1) (Num 2))) ``` -Internal names generated by the encoding are shown here without their `__` -prefix (e.g. `AddView`, not `__AddView`), and fresh ids are given readable names. +Generated names keep the `@` prefix the encoding gives them (`@AddView`), but +the fresh variables it numbers `@pv0`, `@pv1`, … are renamed to something +readable. -# Data structures +# The equality encoding ## Union-find ```text -(sort Math :internal-uf UF_Math) -(function UF_Math (Math) (Math Unit) - :merge ((set (UF_Math (ordering-max old0 new0)) (values (ordering-min old0 new0) ())) +(sort Math :internal-uf @UF_Math) +(function @UF_Math (Math) (Math Unit) + :merge ((set (@UF_Math (ordering-max old0 new0)) (values (ordering-min old0 new0) ())) (values (ordering-min old0 new0) ())) :unextractable :internal-hidden :internal-identity-vals 1) ``` -The union-find for each sort is the function `UF_`, mapping each term to -its parent (plus the proof column, `()` here). A term with no row is its own -representative, so `UF_` is an identity-on-miss lookup. To union `a` and -`b`, the encoding runs -`(set (UF_ (ordering-max a b)) (values (ordering-min a b) ()))`. If the key -already had a different parent, the `:merge` block keeps the smaller of the two -parents and `set`s the larger parent's edge to the smaller one (both are equal to -the key). `ordering-max`/`ordering-min` impose an arbitrary but deterministic -order (by insertion) so the parent choice is stable. +`@UF_` maps each term to its parent, plus the proof column. A term with no +row is its own representative, so the lookup is identity-on-miss. To union `a` +and `b` the encoding runs +`(set (@UF_ (ordering-max a b)) (values (ordering-min a b) ()))`. If the +key already had a different parent, the `:merge` block keeps the smaller of the +two parents and `set`s the larger parent's edge to the smaller one (both are +equal to the key). `ordering-max`/`ordering-min` impose an arbitrary but +deterministic order (by insertion), so the parent choice is stable. `:internal-identity-vals 1` marks the first value column (the parent) as the one -that decides whether a re-`set` is a change: an incoming `set` carrying the same -parent leaves the row untouched and does not run the `:merge` block, so re-setting -an existing edge does not re-stage the same union forever. This is about making -re-writes idempotent — it is *not* a key; the value column is not a unique index, -and many keys can point at the same parent. +that decides whether a re-`set` is a change, so re-setting an existing edge leaves +the row untouched, skips the `:merge` block, and does not re-stage the same union +forever. It makes re-writes idempotent; it is *not* a key, and many rows can point +at one parent. ## Term relation and view -Each constructor expands to a **term relation** (`Add`), a **view** (`AddView`), +Each constructor expands to a **term relation**, a **view**, a rebuild index, and deferred-deletion helpers: ```text -(function Add (i64 i64 Math) Unit :no-merge :unextractable :internal-hidden :internal-term-node) -(function AddView (i64 i64) (Math Unit) - :merge ((set (UF_Math (ordering-max old0 new0)) (values (ordering-min old0 new0) ())) +(function Add (Math Math Math) Unit :no-merge :unextractable :internal-hidden :internal-term-node) +(function @AddView (Math Math) (Math Unit) + :merge ((set (@UF_Math (ordering-max old0 new0)) (values (ordering-min old0 new0) ())) (values (ordering-min old0 new0) ())) :internal-term-constructor Add :internal-identity-vals 1) -(function to_delete_Add (i64 i64) Unit :no-merge :internal-hidden) -(function to_subsume_Add (i64 i64) Unit :no-merge :internal-hidden) +(index @AddOcc_Math @AddView (any 0 1 2)) +(function @to_delete_Add (Math Math) Unit :no-merge :unextractable :internal-hidden) +(function @to_subsume_Add (Math Math) Unit :no-merge :unextractable :internal-hidden) ``` -The term relation carries `:internal-term-node`: its rows are term nodes (the -minted id is the last input), which proof extraction reconstructs. The -deferred-deletion helpers are plain `Unit` relations keyed on the children (a -`(delete (Add 1 2))` inserts `to_delete_Add(1, 2)`, which the delete ruleset -consumes); they carry no minted id and no `:internal-term-node`, so extraction -never reads them as terms. - -The term relation `Add(child0, child1, eclass)` stores every application as a -row whose last column is the term's own id (minted with `get-fresh!`); nothing is -ever removed from it, which lets proofs refer to terms even after they leave the -e-graph. The **view** is a functional dependency `children -> (eclass, proof)` -mapping a term's *canonicalized* children to its e-class representative. Two view -rows that collide on the same children are congruent, so the view's `:merge` -resolves congruence directly — it keeps the smaller e-class and unions the two in -`UF_`, so no separate congruence rule is needed. All queries read the view; -the term relation is write-only after creation. - -## Proof tables (proof mode) +The term relation `Add(child0, child1, eclass)` stores every application as a row +whose last column is the term's own id, minted with `get-fresh!`. Nothing is ever +removed from it, which lets proofs refer to terms after they leave the e-graph. +`:internal-term-node` marks its rows as term nodes for proof extraction. -With proofs enabled, the encoding first emits a header defining the proof format -(corresponding to [`RawProof`](crate::proofs::RawProof); see -`proof_encoding_helpers.rs`): the `Proof`, `Ast`, and `ProofList` sorts and the -proof-node relations `Rule`, `Fiat`, `Trans`, `Sym`, `Congr`, `CongrAll`, -`Merge…`, `ContainerNormalize`, `Eval` (each a `(function … Unit :no-merge)`, not -a constructor), plus `AstMath` (one `Ast` per sort) and: +The **view** is the functional dependency `children -> (eclass, proof)` over a +term's *canonicalized* children. Two view rows that collide on the same children +are congruent, so the view's `:merge` resolves congruence directly — it keeps the +smaller e-class and unions the two in `@UF_`, and no separate congruence +rule is needed. All queries read the view; the term relation is write-only after +creation. The two deferred-deletion helpers are plain `Unit` relations keyed on +the children, with no minted id and no `:internal-term-node`, so extraction never +reads them as terms. -```text -(function MathProof (Math) Proof :merge old :unextractable :internal-hidden) -``` +## Building a term -`MathProof` records, for each term `t`, a proof of the proposition `t = t` -(oldest kept). The union-find and view keep the same shape, but their proof -column now carries a real `Proof`: +Evaluating a constructor application mints an id, writes the term-relation row, +and interns the application into its view. Top level `(Add (Num 1) (Num 2))` +lowers to: ```text -(function UF_Math (Math) (Math Proof) - :merge ((let hi_pf_ (proof-of-max old0 old1 new0 new1)) - (let lo_pf_ (proof-of-min old0 old1 new0 new1)) - (set (UF_Math (ordering-max old0 new0)) - (values (ordering-min old0 new0) (Trans (Sym hi_pf_) lo_pf_))) - (values (ordering-min old0 new0) lo_pf_)) - :unextractable :internal-hidden :internal-identity-vals 1) +(let n1 (get-fresh! "Math")) +(set (Num 1 n1) ()) +(let n1_can (set-if-empty-@NumView! 1 n1 ())) +… ;; the same for (Num 2) +(let ab (get-fresh! "Math")) +(set (Add n1_can n2_can ab) ()) +(let ab_can (set-if-empty-@AddView! n1_can n2_can ab ())) ``` -If term `k` has parent `p`, `(UF_Math k)` returns `(values p proof)` where `proof` -proves `k = p` (the key on the left). `proof-of-min`/`proof-of-max` pair the -proof with the smaller/larger parent; the displaced edge stores -`Trans (Sym hi_pf_) lo_pf_` (proving `larger = smaller`). The view's proof column -instead proves `eclass = f(children)` (the eclass on the left), so its `:merge` -composes `Trans hi_pf_ (Sym lo_pf_)` — flipped relative to the union-find's. +`set-if-empty-!` interns the application and returns the view's *existing* +e-class if the term was already there, so `ab_can` is always canonical. A parent +is built over its children's canonical ids, which is what keeps views canonical. +A freshly minted term needs no `@UF_` row — identity-on-miss makes it its +own representative. -# Actions +## Union in a rule -## Building a term +A `union` of two e-classes writes one `@UF_` edge from the larger endpoint +to the smaller, using the `ordering-max`/`ordering-min` convention above. Each +operand that is itself a term is built first, to obtain its e-class. -Evaluating a constructor application adds to both the term relation and the view. -Top-level `(Add 1 2)` lowers to (globals `x`, `y` name the two ids; see -[Globals](#globals)): +Building an operand and then unioning it away wastes an e-class id and its +`@UF_` edge. When a `union` operand is a freshly built constructor term, +the encoder instead builds it *directly into* the other operand's e-class. Two +passes over the head implement this (both in [`crate::proofs::proof_head`]): + +1. **Normalize** — lift every constructor-application `union` operand into a + `let`, so inline `(union (Add a b) (Add b a))` and let-bound + `(let l (Add a b)) (let r (Add b a)) (union l r)` become the same shape. +2. **Construct-into** — for `(union l r)` where an operand is a + constructor-`let`, pick the other operand as the *target* (built normally) + and build the constructor-`let` operand (the *guest*) into the target's + e-class, dropping the explicit union. + +The running example's `rewrite` matches `(Add a b)` into `rewrite_var` and builds +`(Add b a)` as a guest into it: ```text -(set (x) (get-fresh! "Math")) ;; mint a fresh id for the new term -(set (Add 1 2 (x)) ()) ;; the term-relation row -(set (y) (set-if-empty-AddView! 1 2 (x) ())) ;; intern into the view; (y) is canonical +(rule ((= (values e p) (@AddView a b)) + (= rewrite_var e)) + ((set (Add b a rewrite_var) ()) + (set (@AddView b a) (values rewrite_var ()))) + :name "(rewrite (Add a b) (Add b a))") ``` -`get-fresh!` mints a new id; `set-if-empty-!` interns the application into -its view and returns the view's **existing** e-class if the term was already -there, so `(y)` is always the canonical id. The new term needs no `UF_` -row — identity-on-miss makes it its own representative. +No fresh e-class and no `@UF_Math` edge: the view is `set` to point at the +target, so `(Add b a)` *is* `rewrite_var`. If `(Add b a)` already exists under a +different e-class, the plain `set` collides on the children key and the view's +congruence `:merge` unions the two — exactly the edge the explicit union would +have produced. The guest's variable is bound to the target's e-class, so a later +use in the same head shares it. -## Union in a rule - -A `union` of two e-classes writes one `UF_` edge from the larger endpoint -to the smaller (the exact row and its `ordering-max`/`ordering-min` convention -are in [Union-find](#union-find)). Each operand that is itself a term is built -first (as in [Building a term](#building-a-term)) to obtain its e-class. In proof -mode this is the whole story for `union` — both operands are built and -canonicalized so an equality proof can be threaded through each step (see -[Building nested terms with proofs](#building-nested-terms-with-proofs)). +Only one operand needs to be a constructor application; a `union` of two matched +variables keeps the plain `@UF_` edge. -## Optimization: building a union operand into an e-class +## Delete and subsume -Building an operand and then unioning it away wastes an e-class id (and its -`UF_` edge). When a `union` operand is a freshly-built constructor term, -the encoder instead builds it *directly into* the other operand's e-class. Two -passes over the rule's actions implement this: +Deletions and subsumptions are deferred. `(delete (Add (Num 1) (Num 2)))` builds +its argument's children and then records a marker row: -1. **Normalize** — lift every constructor-application `union` operand into a - `let`, so both operand shapes become one: inline `(union (Add a b) (Add b a))` - and let-bound `(let l (Add a b)) (let r (Add b a)) (union l r)` normalize to - the same let-bound form. -2. **Construct-into** — for `(union l r)` where an operand is a constructor-`let`, - pick the other operand as the *target* (built normally) and build the - constructor-`let` operand (the *guest*) into the target's e-class, dropping - the explicit union. +```text +(set (@to_delete_Add n1_can n2_can) ()) +``` -The commutativity rule builds `(Add b a)` into `(Add a b)`'s e-class: +which `@delete_subsume_ruleset` consumes during maintenance: ```text -(rule ((= (values e p) (AddView a b))) - ((let ab (get-fresh! "Math")) - (set (Add a b ab) ()) - (let ab_canon (set-if-empty-AddView! a b ab ())) - (set (Add b a ab_canon) ()) - (set (AddView b a) (values ab_canon ()))) - :name "commutativity") +(rule ((@to_delete_Add c0_ c1_) + (= (values e pf) (@AddView c0_ c1_))) + ((delete (@AddView c0_ c1_)) + (delete (@to_delete_Add c0_ c1_))) + :ruleset @delete_subsume_ruleset :name "@delete_rule") ``` -- **Target.** `(Add a b)` is built normally (mint + `set-if-empty`), yielding - its canonical e-class `ab_canon`. -- **Guest → target.** The view is `set` to point at `ab_canon` - (`(set (AddView b a) (values ab_canon ()))`), so `(Add b a)` *is* `ab_canon`. - No fresh e-class and no `UF_Math` edge. The guest's variable is bound to - `ab_canon`, so a later reuse (e.g. a subsequent `(Add r a)`) shares it. -- **Congruence handles collisions.** If `(Add b a)` already exists under a - different e-class, the plain view `set` collides on children key `b a` and the - view's congruence `:merge` unions the two e-classes in `UF_Math` — exactly the - edge the explicit union would have produced. +with a `:subsume` sibling for `@to_subsume_Add`. Only the view is deleted or +subsumed; the term relation is never queried, so keeping its rows lets proofs +still refer to deleted terms. -Only one operand needs to be a constructor application; a `union` of two matched -variables keeps the plain `UF_` edge above. +# Queries -**In proof mode** the view row carries a proof `ab_canon = (Add b a)`, composed -from the union's rule proof (`ab_canon = (Add b a)` is the rule's RHS) extended -by a `Congr` chain over any canonicalized children (see -[Building nested terms with proofs](#building-nested-terms-with-proofs)). -Crucially, the guest's term keeps **its own** minted id — the view *value* is -`ab_canon`, but the term-relation row stays on the guest's natural node. Proof -reconstruction reads term rows (not views), so writing the guest's shape under -`ab_canon` would give that e-class two shapes and make its `@Ast` ambiguous; -keeping the term on its own id avoids that. +All queries — rule bodies, `check`, and `prove` — read the **view**, never the +term relation. A view read binds both the e-class and the proof column: -## Building nested terms with proofs +```text +(= (values e p) (@AddView a b)) +``` -With proofs on, building a *nested* term additionally threads a proof from the -term as the rule built it to its canonical form, so the final `union` can record -a correct equality proof. Trace this rule: +A nested term flattens into one view read per subterm, joined on shared e-class +variables. The running example's `check` expands to: ```text -(rule ((Seed a b c rewrite_var)) - ((union rewrite_var (Neg (Add a (Add b c)))))) +(check (= (values e1 p1) (@NumView 1)) + (= (values e2 p2) (@NumView 2)) + (= (values e3 p3) (@AddView e1 e2)) + (= (values e4 p4) (@NumView 2)) + (= (values e5 p5) (@NumView 1)) + (= (values e6 p6) (@AddView e4 e5)) + (= e3 e6)) ``` -The head first **flattens** to one constructor application per step: +that is, that the representatives of `(Add (Num 1) (Num 2))` and +`(Add (Num 2) (Num 1))` are the same e-class. A plain `check` discards the proof +columns; a rule body or `prove` composes them (see +[Body premises](#body-premises)). + +# Rebuilding + +Between the original program's commands, the encoding runs maintenance rules that +restore the invariants egglog would otherwise maintain during rebuilding: ```text -(let d (Add b c)) -(let e (Add a d)) -(let f (Neg e)) -(union rewrite_var f) +(ruleset @parent) ;; path compression on the union-find +(ruleset @rebuilding) ;; re-canonicalize view rows; resolve congruence +(ruleset @rebuilding_cleanup) ;; drop rows merged away +(ruleset @delete_subsume_ruleset) + +(run-schedule + (seq (saturate (seq (run @rebuilding_cleanup) + (saturate (seq (run @parent))) + (run @rebuilding))) + (run @delete_subsume_ruleset))) ``` -The body match binds `a b c rewrite_var` and yields the rule's premise proof, -collected into a one-element list `prems = (PCons body_proof (PNil))`; every proof -minted below is justified by `(Rule rule_name prems lhs rhs)`. Term, AST, and -proof nodes are all relations, so a new node is a fresh id plus a row `set` -(written inline below — e.g. `(Rule …)` means "mint a proof id and `set` its -`Rule` row"). +A command that only builds and interns terms — a `let`, a `set`, a top-level +expression over non-container sorts — merges no e-classes and defers no work, so +no maintenance runs after it. Everything else is followed by the schedule above. -For each subterm we track up to three ids and the proofs between them: +## Path compression -- **`e`** — the *natural* term, built with its children's as-built ids. -- **`e'`** — the same term over **canonical children** (each child replaced by its - representative). It differs from `e` only when a child moved, and the proof - `e_to_e'` is a `Congr` rewriting those children. -- **`e''`** — the view's **representative** for `e'`, returned by `set-if-empty`; - the view supplies the proof `e'_to_e''`. +The only union-find rule flattens `a -> b -> c` chains to `a -> c`: -The connector handed up to the parent is `e_to_e''` (their composition). The -natural node `e` is deliberately never interned, so its `Rule` proof keeps -pointing at the shape the rule head wrote. +```text +(rule ((= (values b pb) (@UF_Math a)) + (= (values c pc) (@UF_Math b)) + (!= b c)) + ((set (@UF_Math a) (values c ()))) + :ruleset @parent :name "@uf_path_compress") +``` -### Line 1 — `(let d (Add b c))` +## Keeping the view canonical -`b` and `c` are already canonical (they came from the body match), so there is no -child to rewrite: `d' = d`, and the connector is just the view proof. +Each view gets one *rebuild index* per distinct eq-sort among its columns, +covering that sort's children **and** the e-class: ```text -;; natural d = (Add b c), with its `d = d` rule proof -(let d (get-fresh! "Math")) -(set (Add b c d) ()) -(let d_prf (Rule rule_name prems (AstMath d) (AstMath d))) -(set (MathProof d) d_prf) +(index @AddOcc_Math @AddView (any 0 1 2)) +``` -;; intern (Add b c); d' is the representative the view returns -(let d' (set-if-empty-AddView! b c d d_prf)) -(let d'_prf (view-proof-AddView b c …)) ;; proves `d' = (Add b c)` -(let d_to_d' (Trans d_prf (Sym d'_prf))) ;; `d = d'` +`@AddOcc_Math` is an ordinary declared index: for every view row and every listed +column, that column's value followed by the whole row. It answers "which rows +mention this term", which is what a rebuild needs and what a native rebuild finds +through its own such index. + +One rule per sort then drives the rebuild from a `@UF_` edge rather than by +matching the view: + +```text +(rule ((= (values leader plf) (@UF_Math follower)) + (!= follower leader) + (@AddOcc_Math follower c0_ c1_ e2_ pf)) + ((let c0_canon_ (@UF_Math_canon c0_ c0_)) + (let c1_canon_ (@UF_Math_canon c1_ c1_)) + (let e2_canon_ (@UF_Math_canon e2_ e2_)) + (delete (@AddView c0_ c1_)) + (set (@AddView c0_canon_ c1_canon_) (values e2_canon_ ()))) + :ruleset @rebuilding :unsafe-seminaive :name "@rebuild_rule" :internal-include-subsumed) ``` -### Line 2 — `(let e (Add a d))` +The index atom binds the whole row, so nothing else need be read, and the action +re-canonicalizes *every* eq-sort column at once — including the e-class, which +therefore needs no rule of its own. One firing yields the fully canonical row, so +two children moving in the same iteration fire twice with the same result rather +than each leaving a differently half-rewritten row behind. `@UF__canon` is +the row's leader column read by term, with the term itself as the fallback, so a +column already at its leader canonicalizes to itself. + +The row is deleted before being re-inserted, because when only the e-class moved +the canonical key equals the old one. Reading `@UF_` in the action is what +makes the rule `:unsafe-seminaive`; the driving `@UF_` delta in the body is +what makes that read sound. + +Container columns are not indexed — they carry no `@UF_` row to drive a +lookup — and keep the `:naive` rule in [Containers](#containers). Subsumption +markers (`@to_subsume_`) are re-keyed to their leaders by their own +per-column rules, so a subsumed row stays subsumed after its children move. + +This rule is always the incremental shape. Native instead picks per table, per +round, between that and scanning the whole table, and on the benchmark suite it +almost always scans. What that costs, and why emptying `@UF` each iteration does +not recover it, is measured in `rebuild_cost.md`. + +# Globals -`d` was canonicalized to `d'`, so the natural `e = (Add a d)` is rewritten to -`e' = (Add a d')` with a `Congr` at child index 1, then interned. +*Before the term encoding*, [`crate::ast::remove_globals`] desugars every global +variable to a nullary function, so the backend need not treat them specially: ```text -;; natural e = (Add a d), over d's as-built id -(let e (get-fresh! "Math")) -(set (Add a d e) ()) -(let e_prf (Rule rule_name prems (AstMath e) (AstMath e))) -(set (MathProof e) e_prf) +(let g1 (Add (Num 1) (Num 2))) +``` -;; e' = (Add a d'): rewrite child 1 (d -> d') -(let e_to_e' (Congr e_prf 1 d_to_d')) ;; `e = e'` +becomes -;; intern (Add a d'); e'' is the representative -(let e'' (set-if-empty-AddView! a d' e' e_to_e')) -(let e'_to_e'' (view-proof-AddView a d' …)) ;; proves `e'' = (Add a d')` -(let e_to_e'' (Trans e_to_e' e'_to_e'')) ;; connector `e = e''` +```text +(function g1 () Math :internal-let) +(set (g1) (Add (Num 1) (Num 2))) ``` -### Line 3 — `(let f (Neg e))` +and references to `g1` become the lookup `(g1)`. The encoding then treats +`:internal-let` like a nullary constructor: it gets a term relation, an FD view +`@g1View : () -> (Math, proof)` with the congruence `:merge`, a rebuild index, and +rebuild rules like any other function. Because the definition is a `set` and not +a `union`, a global adds no e-class merge of its own. -Same shape with one child: rewrite `e -> e''` at index 0. +The pass also appends a lookup fact to the body of every rule whose *head* +mentions a global, so the head can read the global's current value. Those facts +are premises like any other. + +# Containers + +Container sorts (`Vec`, `Set`, `Map`, `MultiSet`, `Pair`) are never unioned +directly, so they get **no** union-find tables. A container is instead +recanonicalized structurally when its elements' e-classes change. For ```text -(let f (get-fresh! "Math")) -(set (Neg e f) ()) -(let f_prf (Rule rule_name prems (AstMath f) (AstMath f))) -(set (MathProof f) f_prf) +(datatype Math (Num i64)) +(sort MathVec (Vec Math)) +(constructor Wrap (MathVec) Math) +``` -(let f_to_f' (Congr f_prf 0 e_to_e'')) ;; f' = (Neg e''), `f = f'` +the `MathVec` argument of `Wrap` is a container column, so it is canonicalized by +the per-container *rebuild primitive* the encoding registers on the sort +(`@container_rebuild`, with `@container_rebuild_proof` beside it in proof mode): -(let f'' (set-if-empty-NegView! e'' f' f_to_f')) -(let f'_to_f'' (view-proof-NegView e'' …)) ;; proves `f'' = (Neg e'')` -(let f_to_f'' (Trans f_to_f' f'_to_f'')) ;; connector `f = f''` +```text +(rule ((= (values e pf) (@WrapView c0_)) + (= c0_canon_ (@container_rebuild c0_)) + (!= c0_ c0_canon_)) + ((set (@WrapView c0_canon_) (values e ())) + (delete (@WrapView c0_))) + :ruleset @rebuilding :naive :name "@rebuild_rule" :internal-include-subsumed) ``` -### The union — `(union rewrite_var f)` +The primitive clones the container, remaps each element to its union-find leader, +and re-interns it. Because it reads the elements' `@UF_` tables rather than +joining a tracked table, the rule is `:naive`: an element becoming equal to +another produces no delta on the container's own view row, so the rule must +rescan the view each round. Nested containers (e.g. `(Vec (Vec Math))`) rebuild +by recursing through container-typed elements. The e-class column of `@WrapView` +is an ordinary eq-sort column and gets the indexed rule from +[Keeping the view canonical](#keeping-the-view-canonical). -The rule justifies `rewrite_var = f` directly (over the natural `f`); composing -with the `f = f''` connector gives `rewrite_var = f''`. The edge is oriented to -the union-find's `larger -> smaller` convention with `proof-of-max`/`proof-of-min`. +See [`crate::proofs::proof_container_rebuild`] for the rebuild primitives, and +[Container proofs](#container-proofs) for what they put in the proof column. + +# Proofs + +With proofs enabled, the encoding first emits a header defining the proof format +(see [`crate::proofs::proof_format`] and `proof_encoding_helpers.rs`): the +`@Proof` and `@Ast` sorts, one `@Ast` per sort, and the proof-node +relations `@Fiat`, `@RuleLink`, `@MergeIdx`, `@MergeRow`, `@Trans`, `@Sym`, +`@Congr`, `@CongrAll`, `@ContainerNormalize`, `@Eval` — each a +`(function … Unit :no-merge)`, not a constructor, so a proof node is a fresh id +plus a row. Two further families have their column count fixed by the site rather +than by the format, so each shape is declared just before the commands needing +it: `@Rule_`, a rule proof carrying its `k` body premises inline, and +`@Packed_`, one row standing for a whole composition over `k` proofs (see +[Packed rows](#packed-rows)). + +Each sort also gets `(function @Proof () @Proof :merge old)`, +recording for each term `t` a proof of `t = t` (oldest kept), and the union-find +and view proof columns become real: ```text -(let rw_to_f (Rule rule_name prems (AstMath rewrite_var) (AstMath f))) -(let rw_to_f'' (Trans rw_to_f f_to_f'')) ;; `rewrite_var = f''` -(set (UF_Math (ordering-max rewrite_var f'')) - (values (ordering-min rewrite_var f'') )) +(function @UF_Math (Math) (Math @Proof) :merge (… @Packed_2 "trans_sym_p0_p1" …) …) +(function @AddView (Math Math) (Math @Proof) :merge (… @Packed_2 "trans_p0_sym_p1" …) …) ``` -The discipline is the same at every level: build the natural term, `Congr` each -child that moved to its representative, intern with `set-if-empty` to get the -representative id, and hand a `natural = representative` connector up to the -parent. Only representative ids reach the views; the union-find sees a natural -only as the key of a `natural = representative` edge, written when a container -captures the natural (see [Containers](#containers)). - -## Delete and subsume +If term `k` has parent `p`, `(@UF_Math k)` returns `(values p proof)` where +`proof` proves `k = p` — the key on the left. A view row's proof runs the other +way, `eclass = f(children)`. + +The rest of this part is the two layers. +[**Layer 1**](#layer-1-building-the-proof-as-the-rule-runs) is a rule that builds +each proof as it goes; it is the specification. +[**Layer 2**](#layer-2-proof-skeletons) is what the encoder emits for a rule +head: a skeleton naming the firing, from which proof conversion recovers layer +1's proof. Everything after those two — body premises, rebuild rows, merge +collisions, containers — is stated against layer 1. + +## Layer 1: building the proof as the rule runs + +Layer 1 keeps the proof and the row together: alongside every row a rule head +writes, it writes the proof of the equality that row asserts, composing +`@Congr`, `@Trans`, and `@Sym` into rows as it goes. **The encoder does not do +this for a rule head** — the snippets in this section are illustrative, not +dumps. It is still the design layer 2 is an optimization of, and the encoder +runs exactly this composition wherever there is no rule head to replay (see +[Where layer 1 is still emitted](#where-layer-1-is-still-emitted)). + +Three things force its shape. + +**A built subterm needs two nodes.** The proof of the shape a head wrote has to +be stated over the term as written, over its children's *as-built* ids; call that +the **natural** node `t`. But a parent must be built over its children's +representatives to keep views canonical, so the same term is minted again over +**canonical children** as `t'`, even when no child moved. `t` is deliberately +never interned, so the view's congruence can never move it. This is why proof +mode mints a node for a construct-into guest where the term encoding alone writes +the guest's row straight onto the target's e-class. + +**The step from `t` to `t'` is congruence.** Each child that was built and then +interned carries a *connector* proof `child_natural = child_eclass`; one `@Congr` +per such child turns the head's own conclusion `t = t` into `t = t'`. + +**Interning `t'` is not determined by the head.** `set-if-empty` returns the +view's representative `t''`, which may be some other term entirely if that shape +was already present. The view row's proof — `t'' = t'` — is the one fact about +the firing that the head's syntax does not fix. Call it the level's **bridge**. +Composing it gives the level's connector, `t = t''`, which the level above uses +as its `@Congr` step. + +For the running example's `rewrite`, whose two children come straight from the +body and whose result is a construct-into guest, layer 1 would emit five proofs +(illustrative: each proof node is one `let` rather than a `get-fresh!` plus a +row, and `rule-proof` stands for whatever names a proof the firing concludes): ```text -(rule ((to_delete_Add c0 c1) - (= (values e pf) (AddView c0 c1))) - ((delete (AddView c0 c1)) - (delete (to_delete_Add c0 c1))) - :ruleset delete_subsume_ruleset :name "delete_rule") -(rule ((to_subsume_Add c0 c1) - (= (values e pf) (AddView c0 c1))) - ((subsume (AddView c0 c1))) - :ruleset delete_subsume_ruleset :name "delete_rule_subsume") - -(to_delete_Add 1 2) ;; lowering of (delete (Add 1 2)) +(let ba (get-fresh! "Math")) ;; the natural node +(set (Add b a ba) ()) +(let own (rule-proof rule_name prems)) ;; ba = ba, the head's own conclusion +(let edge (rule-proof rule_name prems)) ;; rewrite_var = ba, the dropped union +(let view (@Trans edge own)) ;; rewrite_var = (Add b a), the view row +(let back (@Sym view)) +(let conn (@Trans own back)) ;; ba = rewrite_var, the connector +(set (@MathProof ba) own) +(set (@AddView b a) (values rewrite_var view)) ``` -Deletions and subsumptions are deferred: `(delete (Add 1 2))` records -`(to_delete_Add 1 2)`, and the `delete_subsume_ruleset` (run during maintenance) -removes the view row. Only the view is deleted/subsumed — the term relation is -never queried, so keeping its rows lets proofs still refer to deleted terms. +The compositions are not written per site. They are four operations over +proofs — `canonicalize`, `reflexive`, `connect`, and `guest_view` in +[`crate::proofs::proof_head`] — that say, respectively: apply one `@Congr` per +built child, turning `t = t` into `t = t'`; turn `t = t'` into `t' = t'`; join a +term to the e-class it interned into; and state a guest's view row from the +dropped union's edge. Walking a head bottom-up and applying those four is the +whole of layer 1. -# Queries +### A nested term, level by level -All queries — rule bodies, `check`, and `prove` — read the **view**, never the -term relation. A view read binds both the e-class and the proof column: +The example above has one level. What the three forces cost becomes visible when +a head builds a term over a term. Take ```text -(= (values e p) (AddView a b)) +(let f (Neg (Add a (Add b c)))) +(union rewrite_var f) ``` -A nested term flattens into one view read per subterm, joined on shared e-class -variables. The `check` in the running example expands to: +with `a`, `b`, `c` matched by the body. Flatten it first, so every constructor +application is its own action and every operand is a variable: ```text -(check (= (values e1 p1) (AddView 1 2)) - (= (values e2 p2) (AddView 2 1)) - (= e1 e2)) +(let d (Add b c)) +(let e (Add a d)) +(let f (Neg e)) +(union rewrite_var f) ``` -This checks that the representatives of `(Add 1 2)` and `(Add 2 1)` are the same -e-class. In proof mode the read also binds the view's proof (`p1`, `p2`); a plain -`check` discards it, while a rule body or `prove` uses it: the fact's proof is the -view's proof composed with a `Congr` for each child that carries its own subproof, -mirroring the construction side. +Now walk it bottom-up, carrying at each level a proof from the id the head built +to the canonical id the view interned. That proof is what lets the *next* level +up use canonical children while still concluding something about the term as +written. `set-if-empty` below is the primitive that returns a view's existing +representative, or installs the given id and proof when the shape is new. -# Rebuilding +**`(let d (Add b c))`.** Both children are body variables, already canonical, so +there is no congruence step — the natural node is the only one built: -Between the original program's commands, the encoding runs maintenance rules that -restore the invariants egglog normally maintains during rebuilding: +```text +(let d (get-fresh! "Math")) +(set (Add b c d) ()) +(let d-prf (rule-proof rule_name prems)) ;; d = d +(let (values d' d-to-d'-prf) (set-if-empty (@AddView b c) d d-prf)) +``` + +**`(let e (Add a d))`.** The child `d` moved, so this level needs both nodes. The +natural node is over `d`; the canonical one over `d'`; `@Congr` at the child's +position carries the first to the second: ```text -(ruleset parent) ;; path compression on the union-find -(ruleset rebuilding) ;; re-canonicalize view rows; resolve congruence -(ruleset rebuilding_cleanup) ;; drop rows merged away -(ruleset delete_subsume_ruleset) +(let e (get-fresh! "Math")) +(set (Add a d e) ()) +(let e-prf (rule-proof rule_name prems)) ;; e = e -(run-schedule - (seq - (saturate - (run rebuilding_cleanup) - (saturate (run parent)) - (run rebuilding)) - (run delete_subsume_ruleset))) -``` +(let e' (get-fresh! "Math")) ;; the same term over canonical children +(set (Add a d' e') ()) +(let e-to-e'-prf (@Congr e-prf 1 d-to-d'-prf)) ;; e = e' +(let e'-prf (@Trans (@Sym e-to-e'-prf) e-to-e'-prf)) ;; e' = e' -## Path compression +(let (values e'' e'-to-e''-prf) (set-if-empty (@AddView a d') e' e'-prf)) +(let e-to-e''-prf (@Trans e-to-e'-prf e'-to-e''-prf)) ;; e = e'', for the level above +``` -The only union-find rule flattens `a -> b -> c` chains to `a -> c` (composing the -two edge proofs with `Trans` in proof mode): +**`(let f (Neg e))`.** Identical shape, one level up, with `e-to-e''-prf` as the +congruence step: ```text -(rule ((= (values b pb) (UF_Math a)) - (= (values c pc) (UF_Math b)) - (!= b c)) - ((set (UF_Math a) (values c ()))) - :ruleset parent :name "uf_path_compress") +(let f (get-fresh! "Math")) +(set (Neg e f) ()) +(let f-prf (rule-proof rule_name prems)) ;; f = f + +(let f' (get-fresh! "Math")) +(set (Neg e'' f') ()) +(let f-to-f'-prf (@Congr f-prf 0 e-to-e''-prf)) ;; f = f' +(let f'-prf (@Trans (@Sym f-to-f'-prf) f-to-f'-prf)) ;; f' = f' + +(let (values f'' f'-to-f''-prf) (set-if-empty (@NegView e'') f' f'-prf)) +(let f-to-f''-prf (@Trans f-to-f'-prf f'-to-f''-prf)) ;; f = f'' ``` -## Keeping the view canonical +**`(union rewrite_var f)`.** The union is stated over the term as written, then +carried to the representative the view actually holds: -For each constructor, the encoding fans out one rebuild rule per rebuildable -column. `Add`'s `i64` children are not eq-sorts, so its only rule re-canonicalizes -the **e-class** column: when the e-class has a `UF_` parent, re-`set` the row -with the leader (the view's `:merge` keeps the smaller). +```text +(let rewrite_var-to-f (rule-proof rule_name prems)) ;; rewrite_var = f +(let rewrite_var-to-f'' (@Trans rewrite_var-to-f f-to-f''-prf)) +(set (@UF_Math rewrite_var) (values f'' rewrite_var-to-f'')) +``` + +Three levels, and only four rows outlive the firing: the three view rows and the +`@UF_Math` edge. Everything else is proof — two nodes per level where a child +moved, plus a `@Congr`, a `@Sym` and two `@Trans` to thread them. That ratio is +what [layer 2](#layer-2-proof-skeletons) removes: the walk above is a function of +the head and the substitution, so it need not be written into the database at +all. + +The walk is the specification of the proof at every layer-1 site, but not of the +rows written there: where the encoder runs this walk itself, each `@Congr`, +`@Sym` and `@Trans` above is a node of one [packed row](#packed-rows) rather than +a row of its own. + +## Layer 2: proof skeletons + +Layer 1 composes a proof for every step of the walk. Almost none of them are ever +read: a proof is only wanted if someone later asks to explain a specific fact. +Layer 2 keeps the same walk but writes a row only where the *e-graph itself must +store a proof* — a view row's proof column, a `@UF` edge's proof column, a +`@Proof` entry. Each such row is a **skeleton**: it names the rule that +fired, the premise proofs it fired on, and *which* proof of that head it is. + +The original rule head is then the **format** of the proof. Given the skeleton, +proof conversion replays the head under the firing's substitution, applies the +same four operations, and arrives at exactly the proof layer 1 would have +written. Layer 2 is correct because of that: same proof, computed later. + +### Columns + +One walk of a head produces a flat array of proofs, in a fixed order — an +action's operands before the action, a term's children before the term. A proof +is named by nothing but its position in that array, its **column**. Each position +claims a fixed run: + +| position | columns | +| --- | --- | +| a term the head builds | own conclusion, that conclusion over canonical children, the connector | +| a construct-into guest | own conclusion, the dropped `union`'s edge, the view row it writes, the connector | +| any other call | own conclusion | +| a `union` | its equality, then the union-find edge in each direction | +| a `set` | its row | + +A position whose head produces no proof still holds its column, so the numbering +follows the walk rather than what either side emits. The table above is +[`crate::proofs::proof_head`]'s `HeadPosition`, and one walk of the head turns it +into a `HeadLayout`: the encoder claims a position's run as it lowers, and +`Firing` fills the same run as it rebuilds the array, so a row's column indexes +straight into the result. + +A rule proof stores no terms at all. The column, plus the premises, is the whole +conclusion. + +### Bridges + +The bridge — which e-class a subterm interned into — is the one thing conversion +cannot recompute, so the skeleton carries it. A row written before the head +interns anything carries the body premises inline as `@Rule_`. Every row after +that is a `@RuleLink`, naming the row written just before the newest interning — +which carries the premises and every earlier bridge — plus that interning's +bridge. Chaining keeps a row's width constant no matter how deep the head is. + +So a row carries exactly the bridges the head had recorded when it wrote the row, +and the replay takes them one at a time: it reaches the column the row names, and +then asks for one bridge more than the row has. Running out is what tells the +replay it has gone as far as this row can say anything about, and it is the only +thing that stops it. + +### The running example + +The `rewrite`'s head builds one guest over two matched variables. It writes two +proof rows: ```text -(rule ((= (values e pe) (AddView c0 c1)) - (= (values leader ple) (UF_Math e)) - (!= e leader)) - ((set (AddView c0 c1) (values leader ()))) - :ruleset rebuilding :name "rebuild_rule" :internal-include-subsumed) +(rule ((= (values e p) (@AddView a b)) + (= rewrite_var e)) + ((let rule_name "(rewrite (Add a b) (Add b a))") + (let ba (get-fresh! "Math")) + (set (Add b a ba) ()) + (let own (get-fresh! "@Proof")) + (set (@Rule_1 rule_name p 0 own) ()) + (set (@MathProof ba) own) + (let view (get-fresh! "@Proof")) + (set (@Rule_1 rule_name p 2 view) ()) + (set (@AddView b a) (values rewrite_var view))) + :name "(rewrite (Add a b) (Add b a))" :unsafe-seminaive) ``` -A rule for an **eq-sort child** instead re-keys the row: it `set`s the view at the -canonicalized children and deletes the stale row (proof mode composes a `Congr` at -that child index). Because `UF_` has no row for a canonical term -(identity-on-miss), a column already at its leader simply does not match, so no -self-loops or default lookups are needed. Subsumption markers -(`to_subsume_`) are likewise re-keyed to their leaders so a subsumed -row stays subsumed after its children move. +Column 0 is the natural node's own conclusion, stored in `@MathProof`; column 2 +is the guest's view row. Columns 1 and 3 — the dropped union's edge and the +connector — are in the array, but nothing stores them, so no row is written. +Those two plus the intermediate `@Sym` are the three rows layer 1 wrote above and +layer 2 does not. -# Globals +A nested head chains. For -*Before the term encoding*, egglog desugars all global variables to nullary -functions with the `remove_globals.rs` pass, so the backend need not treat them -specially. A program like: +```text +(rule ((Seed r)) ((union r (Add (Num 1) (Num 2))))) +``` + +the walk numbers `(Num 1)` at columns 0–2, `(Num 2)` at 3–5, and the guest +`(Add …)` at 6–9, and the head writes six rows (term rows and the `get-fresh!` +binding each proof variable elided): ```text -(sort Math) -(constructor Add (i64 i64) Math) -(let g1 (Add 1 2)) -(rule ((= g1 (Add 2 3))) - ((Add 3 4))) +(set (@Rule_1 rule_name prems 0 num1_pf0) ()) ;; (Num 1) as written +(set (@Rule_1 rule_name prems 1 num1_pf1) ()) ;; …over canonical children +(let num1_e (set-if-empty-@NumView! 1 …)) +(let num1_bridge (view-proof-@NumView 1 …)) +(set (@RuleLink num1_pf1 num1_bridge 3 num2_pf0) ()) +(set (@RuleLink num1_pf1 num1_bridge 4 num2_pf1) ()) +(let num2_e (set-if-empty-@NumView! 2 …)) +(let num2_bridge (view-proof-@NumView 2 …)) +(set (@RuleLink num2_pf1 num2_bridge 6 add_pf0) ()) ;; (Add …) as written +(set (@RuleLink num2_pf1 num2_bridge 8 add_view) ()) +(set (@AddView num1_e num2_e) (values r add_view)) ``` -desugars to: +The last row carries both bridges, reached through the chain. Column 3 is an own +conclusion, which composes nothing, so it does not use the bridge it carries — but +carry it it must, or the replay would stop one interning short of it. Layer 1's +walk of the same head names seventeen proofs: four conclusions and thirteen +composition steps. Six rows against seventeen is the whole point of the layer. + +Row counts per firing, proof rows only (not the view or `@UF` write beside them): +a flat rewrite **2**, the nested head above **6**, a view rebuild **1**, a merge +collision **1**. + +A `union` of two matched variables builds nothing, so it needs one row — but +which endpoint the `@UF` edge is stated from is only known once the ids are +compared. Its column is therefore an expression, not a literal: ```text -(sort Math) -(constructor Add (i64 i64) Math) -(function g1 () Math :internal-let) -(set (g1) (Add 1 2)) -(rule ((= (g1) (Add 2 3))) - ((Add 3 4))) +(set (@Rule_1 rule_name prems (proof-of-max x 1 y 2) edge) ()) +(set (@UF_Math (ordering-max x y)) (values (ordering-min x y) edge)) ``` -The global is a nullary `:internal-let` function `set` to its value (no `union` -at the top level), so it gets a view and rebuild rules like any other function; -references to `g1` become the lookup `(g1)`. The `(Add 1 2)` and `(Add 3 4)` -constructions above are the same [term construction](#building-a-term) shown -earlier — the term-building sites in the encoding wrap their minted ids in such -`:internal-let` functions. +`proof-of-max` picks between the two orientations' columns by the same value +ordering as `ordering-max`. -# Containers +## Where layer 1 is still emitted -Container sorts (`Vec`, `Set`, `Map`, `MultiSet`, `Pair`) are never unioned -directly, so they get **no** union-find tables. Instead a container is -recanonicalized structurally when its elements' e-classes change. Take: +Layer 2 needs a rule head to use as the format. Where there is none, the encoder +applies the same four operations itself and writes the composition out. The +operations are written once, over the `ProofAlgebra` trait in +[`crate::proofs::proof_head`], and implemented twice: for the encoder, where a +"proof" is the name of an emitted variable, and for proof conversion, where it is +a node in the proof store. Those are one algebra run at two times — while +lowering, or while replaying a skeleton — which is exactly the difference between +the layers. + +**Top-level actions.** A top-level action is justified by `@Fiat` and has no +column to name, so the encoder composes. For the running example's +`(Add (Num 1) (Num 2))` at top level the whole composition is one +[packed row](#packed-rows) — `add_own` is the `@Fiat` conclusion and +`num*_bridge` the two children's view-row proofs: ```text -(datatype Math (Num i64)) -(sort MathVec (Vec Math)) -(constructor Wrap (MathVec) Math) +(set (@Packed_3 "trans_sym_congr_congr_p0_0_sym_p1_1_sym_p2_congr_congr_p0_0_sym_p1_1_sym_p2" + add_own num1_bridge num2_bridge add_canon) ()) ``` -The `MathVec` argument of `Wrap` is a container column, so its rebuild rule -canonicalizes it with a per-container *rebuild primitive* the encoding registers -(here `MathVec_rebuild`); the e-class column gets the usual `UF_Math` rule: +Four `@Proof` rows (plus six `@Ast` rows) for that one expression: the three +`@Fiat` conclusions and that one row. A `@Fiat` is composed from nothing, so it +cannot be a hole of a skeleton and stays a row of its own; the `@Ast` rows are +its endpoints. The row count is also already reduced by dropping steps the +encoder knows are reflexive — `(Num 1)`'s own conclusion is its canonical one, so +neither `Num` level composes anything. + +**Merge bodies and maintenance rules.** A custom function's `:merge`, the +path-compression rule, and the container rebuild rule are all code the encoder +wrote rather than a user head, so they compose too: path compression emits +`@Trans` of the two edge proofs, and the container rebuild emits a `@Congr` onto +the view row. + +### Body premises + +A rule body's premise proofs are also composed, not recorded, since a body has no +column either. A nested pattern reads one view per subterm, and the fact's proof +is the outermost view's proof with a `@Congr` for each child that carries its own +subproof. That chain is emitted lazily, as one packed row, at the point the +premise is first read — which is inside the rule's *action* list. They are the +body's proofs, not proofs of anything the head concludes. + +## Packed rows + +Wherever [layer 1 is emitted](#where-layer-1-is-still-emitted), the composition's +shape is fixed by the site rather than discovered at run time, so one row stands +for the whole of it. + +A site with no rule head to replay says what its row stands for by writing a +**skeleton** — a proof term over the row's other columns, spelled into the first +one in prefix order: `sym`, `trans`, `congr`, `p` for the proof in column n, +and a bare number for a congruence's child position. Unpacking reads the skeleton +back off that column and substitutes the rest into it, so there is one statement +of the composition rather than one at each end. A column may be named twice — +`trans_sym_p0_p0` is `reflexive`, `t' = t'` from the one proof of `t = t'` — and +is then carried once. Every other column is a proof, so the constructor is a +function of their count alone: `@Packed_`. + +The encoder composes over proof *names*, so it holds each `@Sym`, `@Trans` and +`@Congr` back as a tree and writes the row where a statement reads the name. Two +things bound what one row spells. A composition nothing reads is never written at +all — the connector of a top-level term nobody builds on, for instance. And the +connector a built term hands its parent gets a row of its own, rather than being +spelled into every row above it, whenever the term's own children moved: a +level's row then spells its own children's steps and no deeper term's, so the row +is a function of the term's arity rather than of its size. + +**A view rebuild** writes one row whose columns are the row proof, then each +canonicalized column's step proof, then the e-class's own step when the view's +output is an e-class. In proof mode the rebuild rule of +[Keeping the view canonical](#keeping-the-view-canonical) reads a step proof per +column and packs them: ```text -(rule ((= (values e pf) (WrapView c0)) - (= c0_rebuilt (MathVec_rebuild c0)) - (!= c0 c0_rebuilt)) - ((set (WrapView c0_rebuilt) (values e ())) - (delete (WrapView c0))) - :ruleset rebuilding :naive :name "rebuild_rule" :internal-include-subsumed) +(let c0_canon_ (@UF_Math_canon c0_ c0_)) +(let c0_term (@MathProof c0_)) +(let c0_step (@UF_Math_canon_proof c0_ c0_term)) +… same for c1_ and e2_ … +(set (@Packed_4 "trans_sym_p3_congr_congr_p0_0_p1_1_p2" + pf c0_step c1_step e2_step out) ()) ``` -The primitive clones the container, remaps each element to its union-find leader, -and re-interns it. Because it reads the elements' `UF_` tables rather than -joining a tracked table, the rule is marked `:naive`: an element becoming equal -to another produces no delta on the container's own view row, so the rule must -rescan the view each round. Nested containers (e.g. `(Vec (Vec Math))`) rebuild -by recursing through container-typed elements. - -**Proofs.** A container's term form is the s-expr of its constructor — -`(vec-of e0 e1 …)`, `(pair a b)`, `(map-of k0 v0 …)`. Every container sort gets -a reflexive `Proof` table (a `container = container` proof, set at -creation); a chain of congruence steps over the changed elements, anchored -there, proves `old = new` and folds into the view's congruence step like an -eq-sort child's UF proof. The chain uses `CongrAll` (element-matching -congruence: replace every child equal to `a` by `b`) rather than positional -`Congr`: the rebuild primitive sees elements in *value* order, while the term -form orders children canonically (e.g. AST order for sets). `CongrAll` exists -only in the raw e-graph proof; proof conversion desugars it into positional -`Congr` steps computed against the actual term. - -For reordering/merging containers (`Set`, `Map`, `MultiSet`) the term after the -congruence steps can be out of order or hold duplicates, so a -`ContainerNormalize` step (see [`crate::proofs::proof_format`]) canonicalizes -it — sort + dedup for sets, sort for multisets, sort + last-write-wins for -maps. It is emitted on every rebuild and dropped by the proof simplifier -wherever it is the identity (always for `Vec` / `Pair`). Maps use a flat -`(map-of k0 v0 …)` form so this works like the other containers. - -**Natural element ids.** In proof mode a container is built over its elements' -*natural* ids (see [nested terms](#building-nested-terms-with-proofs)), not -their deduped e-classes: the deduped id can extract to a different syntactic -shape (e.g. a birewrite partner interned first), which would break the -rule-head check for the container's term-proof. Each element's -`natural -> (deduped, connector)` edge is written to the element's ordinary -`UF_` at build time, so the standard rebuild (plus path compression) -canonicalizes the natural like any stale term. - -See [`crate::proofs::proof_container_rebuild`] for the rebuild primitives. +`@UF__canon_proof` supplies each step's proof, reflexive for a column that +did not move. The e-class's step composes on the left rather than at a child +position, since an e-class can equal one of its own children's terms. + +**A merge collision** — two rows colliding on one key in a `@UF` or view +`:merge` — writes one packed row for the edge it displaces. +`proof-of-max`/`proof-of-min` pair each carried proof with the larger and smaller +side. The two share an endpoint, and which endpoint decides which of them the +composition reverses; either way it proves `larger = smaller`. The union-find's +carried proofs share their left-hand side, spelling `trans_sym_p0_p1`, and the +view's their right, spelling `trans_p0_sym_p1`. + +**A custom function's view merge** writes one `@MergeRow` naming the function and +the two colliding rows' proofs; the conclusion is recovered by running the merge +body on the premise outputs. Constructor subterms inside the merge body get +`@MergeIdx` rows, indexed pre-order over the body so conversion can evaluate the +matching subexpression. + +## Container proofs + +A container's term form is the s-expr of its constructor — `(vec-of e0 e1 …)`, +`(pair a b)`, `(map-of k0 v0 …)`. Every container sort gets a `@Proof` +table holding a reflexive `container = container` proof, set at creation. A chain +of congruence steps over the changed elements, anchored there, proves `old = new` +and folds into the view's congruence step like an eq-sort child's `@UF` proof. + +The chain uses `@CongrAll` — replace every child equal to `a` by `b` — rather +than positional `@Congr`, because the rebuild primitive sees elements in *value* +order while the term form orders children canonically. `@CongrAll` exists only in +the raw e-graph proof; conversion desugars it into positional `@Congr` steps +computed against the actual term. + +For reordering or merging containers (`Set`, `Map`, `MultiSet`) the term after +those steps can be out of order or hold duplicates, so a `@ContainerNormalize` +step canonicalizes it — sort plus dedup for sets, sort for multisets, sort plus +last-write-wins for maps. It is emitted on every rebuild, and the proof simplifier +drops it wherever it is the identity (always, for `Vec` and `Pair`). + +A container is built over its elements' **natural** ids, not their deduped +e-classes: a deduped id can extract to a different syntactic shape, which would +break the rule-head check for the container's term proof. Each element's +`natural -> (deduped, connector)` edge goes into the element's ordinary +`@UF_`, so the standard rebuild and path compression canonicalize the natural +like any other stale term. That edge's proof column is the element's connector, +which is therefore one of the few connectors a rule head does write a row for. diff --git a/egglog/src/proofs/proof_encoding.rs b/egglog/src/proofs/proof_encoding.rs index 012650d4..07bbcf00 100644 --- a/egglog/src/proofs/proof_encoding.rs +++ b/egglog/src/proofs/proof_encoding.rs @@ -1,37 +1,125 @@ #[doc = include_str!("proof_encoding.md")] -use crate::proofs::proof_encoding_helpers::{EncodingNames, Justification}; +use crate::proofs::proof_encoding_helpers::{ + Composition, EncodingNames, HeadColumn, Justification, Skeleton, uf_clear_enabled, +}; +use crate::proofs::proof_head::{ + HeadPlan, HeadPosition, HeadProof, HeadRun, ProofAlgebra, ProofSite, constructor_operand, +}; use crate::typechecking::FuncType; -use crate::util::HashSet; use crate::*; -use egglog_ast::generic_ast::GenericExpr; -/// Term-construction side channel (proof mode): maps a built term's canonical -/// e-class var to `(natural e-class var, connector proof var)`, where the -/// connector proves `natural = canonical`. A parent term reads its children's -/// entries to build the natural term and its `Congr` connector; the root `union` -/// and a global's `global_value_proof` read it to anchor on the natural form. +/// A value an instrumented action names: the id later statements read it by +/// and, for a term the encoding built here, the term as written plus the proof +/// connecting the two. /// -/// Scoped to a single generated program — a rule, a top-level action, or a -/// custom function's merge body — so each such scope threads a fresh, local map -/// through the action/term builders. -pub(crate) type NatConn = HashMap; - -/// A [`NatConn`] entry: a built term's natural (as-built) id and the connector -/// proof `natural = canonical` (`None` when the term needed no -/// canonicalization). +/// A proof about the term's shape has to be stated over the natural form, since +/// the id is an interned e-class whose AST may float. #[derive(Clone)] -pub(crate) struct NatEntry { - pub natural: String, - pub connector: Option, +pub(crate) struct Operand { + /// The id later statements read this operand by. + value: String, + /// The term as written; the same id unless the encoding built it here. + natural: String, + /// `natural = value`, `None` when the two are the same id. + connector: Option, } -/// Which way a pair-valued table's carried proofs point, selecting the -/// displaced-edge composition in [`ProofInstrumentor::ordered_union_merge`]. -enum CarriedProofs { - /// `@UF` rows: the carried proof proves `key = parent`. - KeyToParent, - /// Congruence views: the carried proof proves `eclass = f(children)`. - EclassToTerm, +impl Operand { + /// An operand the encoding did not build here: a literal, a body match, a + /// value read out of a view. + fn plain(value: String) -> Self { + Operand { + natural: value.clone(), + value, + connector: None, + } + } + + /// A term the encoding built as `natural` and interned into `value`. + fn built(value: String, natural: String, connector: Connector) -> Self { + Operand { + value, + natural, + connector: Some(connector), + } + } +} + +/// The ids emitted code reads a run of operands by. +fn ids(operands: &[Operand]) -> Vec { + operands.iter().map(|o| o.value.clone()).collect() +} + +/// The column of `run` holding `proof`. Unnumbered where the site composes +/// rather than claiming a run. +fn head_column(run: Option, proof: HeadProof) -> HeadColumn { + run.map_or(HeadColumn::Unnumbered, |run| { + HeadColumn::Numbered(run.column(proof).to_string()) + }) +} + +/// The variables an action block has bound to a term the encoding built. Every +/// other name reads back as itself, so only these are recorded. +#[derive(Default)] +struct Scope(HashMap); + +impl Scope { + /// What `name` stands for. + fn read(&self, name: &str) -> Operand { + self.0 + .get(name) + .cloned() + .unwrap_or_else(|| Operand::plain(name.to_string())) + } + + /// Record that `(let name )` was emitted, so a later reference to + /// `name` reads the same term. + fn bind(&mut self, name: &str, operand: &Operand) { + if operand.connector.is_some() { + self.0.insert( + name.to_string(), + Operand { + value: name.to_string(), + ..operand.clone() + }, + ); + } + } +} + +/// How a built term's connector proof `natural = canonical` is named. +#[derive(Clone)] +pub(crate) enum Connector { + /// A proof node the encoding already minted. + Node(String), + /// A rule head's, named by the column of the head's proof (see + /// [`crate::proofs::proof_head`]), so a row is minted only where the encoding + /// stores the proof. + Column(usize), +} + +/// A constructor's *natural* node — children at their as-built ids — and the +/// proofs about it. Shared by [`ProofInstrumentor::add_constructor_with_proof`] +/// and [`ProofInstrumentor::instrument_construct_into`]. +struct Natural { + /// The children at their deduped ids, the view's key. + dedup_args: Vec, + /// The natural node's id. + fv_nat: String, + /// `fv_nat = fv_nat`, the head's own conclusion here. + nat_prf: String, + /// `fv_nat = f(deduped children)`: one `Congr` per canonicalized child. + /// `None` in a rule head, where proof conversion folds it instead. + to_dedup: Option, +} + +/// A declared index on a function's view, covering the view columns of one +/// eq-sort — its children and its e-class. An `@UF` edge on a term reaches every +/// row mentioning it at any of them. +#[derive(Clone)] +pub(crate) struct ViewIndex { + pub name: String, + pub sort_name: String, } // TODO refactor so that encoding state is optional on the e-graph, ProofNames not optional on EncodingState. Then we don't have to clone proof names everywhere. @@ -47,6 +135,9 @@ pub(crate) struct EncodingState { /// Maps container sort name -> the name of its registered proof-producing /// container-rebuild primitive (`ContainerRebuildProof`). Proof mode only. pub container_rebuild_proof_name: HashMap, + /// Function name -> the rebuild indexes declared on its view, one per + /// distinct eq-sort among the view's columns (see [`ViewIndex`]). + pub view_index: HashMap>, pub term_header_added: bool, // TODO this is very ugly- we should separate out a typechecking struct // since we didn't need an entire e-graph @@ -70,6 +161,7 @@ impl EncodingState { proof_func_parent: HashMap::default(), container_rebuild_name: HashMap::default(), container_rebuild_proof_name: HashMap::default(), + view_index: HashMap::default(), term_header_added: false, original_typechecking: None, proofs_enabled: false, @@ -81,14 +173,69 @@ impl EncodingState { } } +/// What a rule head's next rule proof row chains onto. +#[derive(Default)] +struct HeadChain { + /// The last rule proof row this head minted. + last: Option, + /// The row minted just before the head's newest interning, carrying every + /// bridge before that one, and that interning's bridge — the interned + /// subterm's view-row proof. `None` until the head interns something. + link: Option<(String, String)>, +} + /// Thin wrapper around an [`EGraph`] for the term encoding pub(crate) struct ProofInstrumentor<'a> { pub(crate) egraph: &'a mut EGraph, + /// Set while instrumenting a rule head; `None` everywhere else, where a proof + /// records its own conclusion and needs no bridges. + head_chain: Option, + /// Proof variables the encoder knows prove `t = t`, keyed by the emitted + /// variable name. Names are globally fresh, so entries never collide across + /// the generated programs. + reflexive: HashSet, + /// What each proof still held back binds, keyed by its variable. A proof is + /// written where it is first read; one nothing reads is never written at all. + deferred: HashMap, + /// Compositions that get a row of their own rather than being written into + /// the one composing over them (see [`Self::level_connector`]). + sealed: HashSet, + /// Declarations of the packed constructors the compositions written so far + /// need, to be emitted ahead of the command using them. + packed_decls: Vec, + /// Which layer the statements being emitted belong to: a rule head's + /// skeleton, or a composition written out here. + site: ProofSite, +} + +/// A held-back proof: finished statements, emitted as they stand (see +/// [`ProofInstrumentor::defer_lookup`]), or a composition the encoder can still +/// rewrite into one row (see [`ProofInstrumentor::mint_sym`]). +enum Deferred { + Stmts(Vec), + Composed(Composition), +} + +/// The variables a joined argument string names: its whitespace-separated +/// tokens, with any wrapping parentheses stripped so a primitive call reads as +/// the variables inside it. +fn read_vars(args_joined: &str) -> impl Iterator { + args_joined + .split_whitespace() + .map(|arg| arg.trim_matches(|c| c == '(' || c == ')')) } impl<'a> ProofInstrumentor<'a> { pub(crate) fn new(egraph: &'a mut EGraph) -> Self { - Self { egraph } + Self { + egraph, + head_chain: None, + reflexive: HashSet::default(), + deferred: HashMap::default(), + sealed: HashSet::default(), + packed_decls: vec![], + site: ProofSite::Composed, + } } /// Make a term state and use it to instrument the code. @@ -118,9 +265,10 @@ impl<'a> ProofInstrumentor<'a> { Ok(lowered) } - /// Mint a `Rule` or `Fiat` proof of the equality `a = b` over the two - /// endpoints' ASTs, appending the mints to `stmts`. Panics on merge - /// justifications (merge bodies contain no `union` actions). + /// Mint a `Rule` or `Fiat` proof of the equality `a = b`, appending the mints + /// to `stmts`. Only `Fiat` names the two endpoints' ASTs; a rule proof's + /// proposition comes from its column. Panics on merge justifications (merge + /// bodies contain no `union` actions). fn edge_proof( &mut self, stmts: &mut Vec, @@ -129,21 +277,13 @@ impl<'a> ProofInstrumentor<'a> { b: &str, justification: &Justification, ) -> String { - let ast_sort = self.proof_names().ast_sort.clone(); - let proof_sort = self.proof_sort(); - let a1 = self.mint(stmts, to_ast, a, &ast_sort); - let a2 = self.mint(stmts, to_ast, b, &ast_sort); match justification { - Justification::Rule(rule_name, proof_list) => { - let rule = self.proof_names().rule_constructor.clone(); - self.mint( - stmts, - &rule, - &format!("{rule_name} {proof_list} {a1} {a2}"), - &proof_sort, - ) - } + Justification::Rule(..) => self.rule_row(stmts, justification), Justification::Fiat => { + let ast_sort = self.proof_names().ast_sort.clone(); + let proof_sort = self.proof_sort(); + let a1 = self.mint(stmts, to_ast, a, &ast_sort); + let a2 = self.mint(stmts, to_ast, b, &ast_sort); let fiat = self.proof_names().fiat_constructor.clone(); self.mint(stmts, &fiat, &format!("{a1} {a2}"), &proof_sort) } @@ -153,60 +293,192 @@ impl<'a> ProofInstrumentor<'a> { } } - /// The natural (as-built) id of `key`: `key`'s [`NatEntry::natural`] if it was - /// a canonicalized constructor term, otherwise `key` itself (leaves and body - /// matches have no entry). Used to pin an edge proof to the enode the rule - /// built rather than the deduped e-class, whose AST may float. - fn natural_of(nat_conn: &NatConn, key: &str) -> String { - nat_conn - .get(key) - .map(|e| e.natural.clone()) - .unwrap_or_else(|| key.to_string()) + /// Mint the rule proof row `justification` names. Proof conversion derives the + /// proposition from the column alone, so the row stores no terms. + /// + /// A head's first row carries the body premises inline; every row after the + /// head has interned a subterm chains onto the row before that interning, + /// adding its bridge. So a row names exactly the bridges the head had + /// recorded when it minted the row. + fn rule_row(&mut self, stmts: &mut Vec, justification: &Justification) -> String { + let link = self + .head_chain + .as_ref() + .and_then(|chain| chain.link.clone()); + let proof = match link { + None => self.inline_rule_row(stmts, justification), + Some((prev, bridge)) => self.link_rule_row(stmts, justification, &prev, &bridge), + }; + if let Some(chain) = &mut self.head_chain { + chain.last = Some(proof.clone()); + } + proof + } + + /// A rule proof row carrying the head's body premises as columns. + fn inline_rule_row( + &mut self, + stmts: &mut Vec, + justification: &Justification, + ) -> String { + let Justification::Rule(rule_name, premises, _) = justification else { + panic!("only a rule justification mints a rule proof row"); + }; + let (rule_name, premises) = (rule_name.clone(), premises.clone()); + let column = justification.column_expr(); + let rule = self.proof_names().fused_rule(premises.len()); + let proof_sort = self.proof_sort(); + let premises = premises.iter().map(|p| format!("{p} ")).collect::(); + self.mint( + stmts, + &rule, + &format!("{rule_name} {premises}{column}"), + &proof_sort, + ) + } + + /// A rule proof row naming `prev` — a row of the same head, carrying the body + /// premises and every earlier bridge — plus `bridge`. + fn link_rule_row( + &mut self, + stmts: &mut Vec, + justification: &Justification, + prev: &str, + bridge: &str, + ) -> String { + assert!( + matches!(justification, Justification::Rule(..)), + "only a rule justification mints a rule proof row" + ); + let column = justification.column_expr(); + let link = self.proof_names().rule_link_constructor.clone(); + let proof_sort = self.proof_sort(); + self.mint( + stmts, + &link, + &format!("{prev} {bridge} {column}"), + &proof_sort, + ) + } + + /// Record a subterm's view-row proof as a bridge premise of the rule proofs + /// minted from here on. A composing site records nothing: it has already used + /// the proof, and a subterm the head concludes nothing about — a nested + /// `change` argument — is not in the array conversion rebuilds. + fn record_bridge(&mut self, view_proof: &str) { + if self.site.composes() { + return; + } + if let Some(chain) = &mut self.head_chain { + let prev = chain + .last + .clone() + .expect("a head states the term it is interning before interning it"); + chain.link = Some((prev, view_proof.to_string())); + } + } + + /// A built term's connector proof as a proof node, minting the rule proof row + /// for a [`Connector::Column`]. + fn connector_node( + &mut self, + stmts: &mut Vec, + justification: &Justification, + connector: &Connector, + ) -> String { + match connector { + Connector::Node(node) => node.clone(), + Connector::Column(column) => { + let connector = justification.at(HeadColumn::Numbered(column.to_string())); + self.rule_row(stmts, &connector) + } + } } /// Mark two things as equal, adding proof if proofs are enabled. /// Emits any proof-relation mints onto `stmts` and returns the `(set @UF ...)` /// action, which the caller must emit after `stmts`. + /// + /// `run` is the columns the walk reserved for this `union` (see + /// [`HeadPosition::Union`]). pub(crate) fn union( &mut self, stmts: &mut Vec, type_name: &str, - lhs: &str, - rhs: &str, + lhs: &Operand, + rhs: &Operand, justification: &Justification, - nat_conn: &NatConn, + run: Option, ) -> String { let uf_name = self.uf_name(type_name); - let smaller = format!("(ordering-min {lhs} {rhs})"); - let larger = format!("(ordering-max {lhs} {rhs})"); + let smaller = format!("(ordering-min {} {})", lhs.value, rhs.value); + let larger = format!("(ordering-max {} {})", lhs.value, rhs.value); // `@UF : (S) -> (S, {Unit|Proof})` is keyed by the larger endpoint; its // `:merge` resolves conflicting parents. The second column carries a proof // `larger = smaller` (`()` in term mode). - if !self.egraph.proof_state.proofs_enabled { - return format!("(set ({uf_name} {larger}) (values {smaller} ()))"); - } + let proof = if !self.egraph.proof_state.proofs_enabled { + "()".to_string() + } else if self.site.composes() { + self.composed_union_edge(stmts, type_name, lhs, rhs, &larger, &smaller) + } else { + self.skeleton_union_edge(stmts, lhs, rhs, justification, run) + }; + format!("(set ({uf_name} {larger}) (values {smaller} {proof}))") + } + + /// The `larger = smaller` proof a `union` in a rule head stores in `@UF`. The + /// column and the operands' bridge premises determine the whole composition, + /// so one row records it. + fn skeleton_union_edge( + &mut self, + stmts: &mut Vec, + lhs: &Operand, + rhs: &Operand, + justification: &Justification, + run: Option, + ) -> String { + let run = run.expect("a rule head's unions are numbered"); + // `proof-of-max` picks the direction the `larger = smaller` edge needs, + // by the same value ordering as `ordering-max`, over the two columns' + // `i64` literals. + let oriented = format!( + "(proof-of-max {} {} {} {})", + lhs.value, + run.column(HeadProof::EdgeFromLhs), + rhs.value, + run.column(HeadProof::EdgeFromRhs) + ); + self.rule_row(stmts, &justification.at(HeadColumn::Numbered(oriented))) + } + /// The `larger = smaller` proof a `union` outside a rule head stores in `@UF`, + /// composed here rather than recorded: nothing downstream rebuilds it. + fn composed_union_edge( + &mut self, + stmts: &mut Vec, + type_name: &str, + lhs: &Operand, + rhs: &Operand, + larger: &str, + smaller: &str, + ) -> String { let to_ast_constructor = self .proof_names() .sort_to_ast_constructor .get(type_name) .unwrap() .clone(); - let proof_sort = self.proof_sort(); - - // Natural id + connector (`natural = deduped`) for each operand, if it was - // a canonicalized constructor term. Leaves / body matches have neither. - let lhs_info = nat_conn.get(lhs).cloned(); - let rhs_info = nat_conn.get(rhs).cloned(); - let lhs_conn = lhs_info.as_ref().and_then(|e| e.connector.clone()); - let rhs_conn = rhs_info.as_ref().and_then(|e| e.connector.clone()); // Neither operand was a canonicalized constructor term (no connector), so // both e-classes' ASTs are stable: build the edge proof directly over them. - if lhs_conn.is_none() && rhs_conn.is_none() { - let proof = - self.edge_proof(stmts, &to_ast_constructor, &larger, &smaller, justification); - return format!("(set ({uf_name} {larger}) (values {smaller} {proof}))"); + if lhs.connector.is_none() && rhs.connector.is_none() { + return self.edge_proof( + stmts, + &to_ast_constructor, + larger, + smaller, + &Justification::Fiat, + ); } // A canonicalized operand's deduped e-class may already be unioned with a @@ -214,42 +486,34 @@ impl<'a> ProofInstrumentor<'a> { // the *natural* forms (ASTs pinned to the enode the rule built), then route // each deduped e-class to a shared natural form and orient the edge to // `larger = smaller` with proof-of-max/min. - let lhs_nat = Self::natural_of(nat_conn, lhs); - let rhs_nat = Self::natural_of(nat_conn, rhs); - + // + // Built over the operands in source order, so it states the conclusion + // forwards. let base_proof = self.edge_proof( stmts, &to_ast_constructor, - &lhs_nat, - &rhs_nat, - justification, + &lhs.natural, + &rhs.natural, + &Justification::Fiat, ); - let sym = self.proof_names().eq_sym_constructor.clone(); - let trans = self.proof_names().eq_trans_constructor.clone(); // The shared natural form is the canonicalized side's natural (pinned // AST), so the Trans goes through it rather than through the deduped // e-class. - let (lhs_to_shared, rhs_to_shared) = if let Some(rc) = &rhs_conn { - let lhs_to = if let Some(lc) = &lhs_conn { - let sym_lc = self.mint(stmts, &sym, lc, &proof_sort); - self.mint( - stmts, - &trans, - &format!("{sym_lc} {base_proof}"), - &proof_sort, - ) - } else { - base_proof.clone() - }; - let rhs_to = self.mint(stmts, &sym, rc, &proof_sort); - (lhs_to, rhs_to) - } else { - let lc = lhs_conn.as_ref().unwrap(); - let lhs_to = self.mint(stmts, &sym, lc, &proof_sort); - let rhs_to = self.mint(stmts, &sym, &base_proof, &proof_sort); - (lhs_to, rhs_to) - }; + let lhs_conn = lhs + .connector + .as_ref() + .map(|c| self.connector_node(stmts, &Justification::Fiat, c)); + let rhs_conn = rhs + .connector + .as_ref() + .map(|c| self.connector_node(stmts, &Justification::Fiat, c)); + let (lhs_to_shared, rhs_to_shared) = self.union_to_shared(base_proof, lhs_conn, rhs_conn); + // `proof-of-max`/`min` read the two sides directly rather than through a + // mint, so bind them here. + self.emit_pending_group(stmts, &lhs_to_shared); + self.emit_pending_group(stmts, &rhs_to_shared); + let (lhs, rhs) = (&lhs.value, &rhs.value); let max_pf = self.fresh_var(); stmts.push(format!( "(let {max_pf} (proof-of-max {lhs} {lhs_to_shared} {rhs} {rhs_to_shared}))" @@ -258,164 +522,48 @@ impl<'a> ProofInstrumentor<'a> { stmts.push(format!( "(let {min_pf} (proof-of-min {lhs} {lhs_to_shared} {rhs} {rhs_to_shared}))" )); - let sym_min = self.mint(stmts, &sym, &min_pf, &proof_sort); - let edge = self.mint(stmts, &trans, &format!("{max_pf} {sym_min}"), &proof_sort); - format!("(set ({uf_name} {larger}) (values {smaller} {edge}))") - } - - /// The `(FuncType, args)` of a constructor-application expression, else `None`. - fn constructor_operand(expr: &ResolvedExpr) -> Option<(&FuncType, &[ResolvedExpr])> { - match expr { - ResolvedExpr::Call(_, ResolvedCall::Func(func_type), args) - if func_type.subtype == FunctionSubtype::Constructor => - { - Some((func_type, args.as_slice())) - } - _ => None, - } - } - - /// Lift each constructor-application `union` operand into a preceding `let`, - /// so every union operand is a variable and the inline and let-bound shapes - /// coincide before [`Self::plan_construct_into`] runs. - fn normalize_union_operands(&mut self, actions: &[ResolvedAction]) -> Vec { - let mut out = vec![]; - for action in actions { - match action { - ResolvedAction::Union(span, lhs, rhs) => { - let lhs = self.lift_union_operand(lhs.clone(), &mut out); - let rhs = self.lift_union_operand(rhs.clone(), &mut out); - out.push(ResolvedAction::Union(span.clone(), lhs, rhs)); - } - other => out.push(other.clone()), - } - } - out - } - - /// If `operand` is a constructor application, bind it to a fresh `let` - /// (pushed onto `out`) and return a variable referencing it; otherwise - /// return `operand` unchanged. - fn lift_union_operand( - &mut self, - operand: ResolvedExpr, - out: &mut Vec, - ) -> ResolvedExpr { - if Self::constructor_operand(&operand).is_none() { - return operand; - } - let span = operand.span(); - let var = ResolvedVar { - name: self.egraph.parser.symbol_gen.fresh("union_operand"), - sort: operand.output_type(), - is_global_ref: false, - }; - out.push(ResolvedAction::Let(span.clone(), var.clone(), operand)); - GenericExpr::Var(span, var) - } - - /// Plan the construct-into optimization over normalized actions (union - /// operands are variables). Returns a map `guest -> target` — the guest's - /// constructor is built into the target's e-class instead of a fresh one — - /// and the set of union action indices it makes redundant. - /// - /// Conservative: only a `union` of two distinct, not-yet-touched variables - /// where at least one is a constructor-`let` is optimized. The guest is the - /// later-defined constructor operand (so the target's e-class is already - /// bound where the guest is built); a matched (un-`let`) variable is always - /// an eligible target. - fn plan_construct_into( - actions: &[ResolvedAction], - ) -> (HashMap, HashSet) { - let mut all_def: HashMap = HashMap::default(); - let mut ctor_def: HashMap = HashMap::default(); - for (i, action) in actions.iter().enumerate() { - if let ResolvedAction::Let(_, v, expr) = action { - all_def.insert(v.name.clone(), i); - if Self::constructor_operand(expr).is_some() { - ctor_def.insert(v.name.clone(), i); - } - } - } - - let mut construct_into: HashMap = HashMap::default(); - let mut dropped: HashSet = HashSet::default(); - let mut used: HashSet = HashSet::default(); - for (i, action) in actions.iter().enumerate() { - let ResolvedAction::Union(_, lhs, rhs) = action else { - continue; - }; - let (GenericExpr::Var(_, va), GenericExpr::Var(_, vb)) = (lhs, rhs) else { - continue; - }; - let (a, b) = (va.name.clone(), vb.name.clone()); - if a == b { - // Union of a variable with itself is a no-op. - dropped.insert(i); - continue; - } - if used.contains(&a) || used.contains(&b) { - // Keep chains of optimized unions out of scope for now. - continue; - } - let (guest, target) = match (ctor_def.get(&a), ctor_def.get(&b)) { - (Some(&ia), Some(&ib)) => { - if ia >= ib { - (a, b) - } else { - (b, a) - } - } - (Some(_), None) => (a, b), - (None, Some(_)) => (b, a), - (None, None) => continue, - }; - // The target's e-class must be bound where the guest is built: a - // matched variable always is; a `let` must precede the guest's. - let guest_idx = ctor_def[&guest]; - if let Some(&target_idx) = all_def.get(&target) - && target_idx >= guest_idx - { - continue; - } - used.insert(guest.clone()); - used.insert(target.clone()); - construct_into.insert(guest, target); - dropped.insert(i); - } - (construct_into, dropped) + let sym_min = self.mint_sym(&min_pf); + let edge = self.mint_trans(&max_pf, &sym_min); + // The `@UF` row below is the caller's, not a mint of ours. + self.emit_pending_group(stmts, &edge); + edge } /// Lower a construct-into guest `(let guest (F args))`: point its view value /// at `target`'s e-class with a plain `set` (a collision with an existing - /// `F(args)` unions the two via the view's `:merge`), and bind `guest` to - /// `target` so later uses share the representative. In proof mode the view - /// row also carries the proof `target = F(args)`. + /// `F(args)` unions the two via the view's `:merge`), and bind `guest` to it + /// so later uses share the representative. In proof mode the view row also + /// carries the proof `target = F(args)`, the dropped union's edge. + /// + /// Returns the guest's term, which the caller binds to `guest`. fn instrument_construct_into( &mut self, res: &mut Vec, expr: &ResolvedExpr, - target: &str, + target: &Operand, guest: &str, justification: &Justification, - nat_conn: &mut NatConn, - ) { - let (func_type, args) = Self::constructor_operand(expr) + scope: &Scope, + ) -> Operand { + let (func_type, args) = constructor_operand(expr) .expect("construct-into guest must be a constructor application"); let ctor_name = func_type.name.clone(); - let child_vals: Vec = args + let child_vals: Vec = args .iter() - .map(|arg| self.instrument_action_expr(arg, res, justification, nat_conn)) + .map(|arg| self.instrument_action_expr(arg, res, justification, scope)) .collect(); + let run = self.site.claim(HeadPosition::Guest); + let target_id = &target.value; if !self.proofs_enabled() { + let child_ids = ids(&child_vals); res.push(format!( - "(set ({ctor_name} {} {target}) ())", - ListDisplay(&child_vals, " ") + "(set ({ctor_name} {} {target_id}) ())", + ListDisplay(&child_ids, " ") )); - res.push(self.update_fd_view(&ctor_name, &child_vals, target, "()")); - res.push(format!("(let {guest} {target})")); - return; + res.push(self.update_fd_view(&ctor_name, &child_ids, target_id, "()")); + res.push(format!("(let {guest} {target_id})")); + return Operand::plain(target_id.clone()); } let sort_name = func_type.output().name().to_string(); @@ -433,49 +581,57 @@ impl<'a> ProofInstrumentor<'a> { .get(&sort_name) .expect("sort AST") .clone(); - let proof_sort = self.proof_sort(); - let trans = self.proof_names().eq_trans_constructor.clone(); - let sym = self.proof_names().eq_sym_constructor.clone(); let view = self.view_name(&ctor_name); - let (dedup_args, fv_nat, nat_prf, nat_to_dedup) = self.build_natural_with_congr( + let Natural { + dedup_args, + fv_nat, + nat_prf, + to_dedup: nat_to_dedup, + } = self.build_natural_with_congr( res, &ctor_name, &view_sort, &child_vals, - justification, - nat_conn, + &justification.at(head_column(run, HeadProof::Own)), ); let term_proof_ctor = self.term_proof_name(&sort_name); res.push(format!("(set ({term_proof_ctor} {fv_nat}) {nat_prf})")); - let target_nat = Self::natural_of(nat_conn, target); - let target_conn = nat_conn.get(target).and_then(|e| e.connector.clone()); - let edge = self.edge_proof(res, &sort_ast, &target_nat, &fv_nat, justification); - let to_dedup = self.mint(res, &trans, &format!("{edge} {nat_to_dedup}"), &proof_sort); - let view_proof = match target_conn { - Some(conn) => { - let sc = self.mint(res, &sym, &conn, &proof_sort); - self.mint(res, &trans, &format!("{sc} {to_dedup}"), &proof_sort) + let view_proof = match &nat_to_dedup { + Some(chain) => { + let edge = self.edge_proof(res, &sort_ast, &target.natural, &fv_nat, justification); + let target_conn = target + .connector + .as_ref() + .map(|conn| self.connector_node(res, justification, conn)); + self.guest_view(edge, chain.clone(), target_conn) + } + // The guest's columns plus its bridge premises determine the whole + // composition, so one row records it and the edge proof it is built + // from needs no row of its own. + None => { + let view = justification.at(head_column(run, HeadProof::GuestView)); + self.rule_row(res, &view) } - None => to_dedup, }; // The guest's term keeps its own id (`fv_nat`); only the view VALUE uses // the target. Emitting `(F dedup_args target)` would add the guest's - // shape to `target`'s term relation, making `target`'s `@Ast` ambiguous - // during proof reconstruction (which reads term rows, not views). + // shape to `target`'s term relation, making the term proof reconstruction + // picks for `target` ambiguous (it reads term rows, not views). let dedup_disp = ListDisplay(&dedup_args, " ").to_string(); + // The view row below carries the proof directly, not through a mint. + self.emit_pending_group(res, &view_proof); res.push(format!( - "(set ({view} {dedup_disp}) (values {target} {view_proof}))" + "(set ({view} {dedup_disp}) (values {target_id} {view_proof}))" )); - res.push(format!("(let {guest} {target})")); - let sv = self.mint(res, &sym, &view_proof, &proof_sort); - let guest_conn = self.mint(res, &trans, &format!("{nat_to_dedup} {sv}"), &proof_sort); - nat_conn.insert( - guest.to_string(), - NatEntry { - natural: fv_nat, - connector: Some(guest_conn), - }, - ); + res.push(format!("(let {guest} {target_id})")); + let guest_conn = match &nat_to_dedup { + Some(chain) => Connector::Node(self.level_connector(chain, &view_proof)), + None => Connector::Column( + run.expect("a rule head's guest is numbered") + .column(HeadProof::Connector), + ), + }; + Operand::built(target_id.clone(), fv_nat, guest_conn) } /// The parent table is the database representation of a union-find datastructure. @@ -503,40 +659,38 @@ impl<'a> ProofInstrumentor<'a> { /// The shared `:merge` block for a collision that unions two members of one /// e-class: keep `(ordering-min old0 new0)` with the smaller side's carried /// proof, and `set` the displaced larger side's `@UF` edge to the smaller - /// with a composed proof of `larger = smaller`. The composition depends on - /// which way the two carried proofs point (see [`CarriedProofs`]): - /// `key = parent` proofs compose as `Trans (Sym hi_pf_) lo_pf_`, - /// `eclass = f(children)` proofs as `Trans hi_pf_ (Sym lo_pf_)`. - fn ordered_union_merge(&mut self, uf_name: &str, carried: CarriedProofs) -> String { + /// with a proof of `larger = smaller`. That proof is one packed row naming + /// both carried proofs, standing for `composition` over them — the larger + /// side's is column 0 and the smaller side's column 1. + /// + /// Returns the packed constructor's declaration, which the caller must emit + /// ahead of the block, and the block itself. + fn ordered_union_merge(&mut self, uf_name: &str, composition: Skeleton) -> (String, String) { if !self.proofs_enabled() { - return format!( - "((set ({uf_name} (ordering-max old0 new0)) (values (ordering-min old0 new0) ())) + return ( + String::new(), + format!( + "((set ({uf_name} (ordering-max old0 new0)) (values (ordering-min old0 new0) ())) (values (ordering-min old0 new0) ()))" + ), ); } - let trans = self.proof_names().eq_trans_constructor.clone(); - let sym = self.proof_names().eq_sym_constructor.clone(); + let (displaced, decl) = self.packed_proof_constructor(composition.width()); + let spelling = composition.spelling(); let proof_sort = self.proof_sort(); let mut mints = vec![]; - let displaced_pf = match carried { - CarriedProofs::KeyToParent => { - let sym_pf = self.mint(&mut mints, &sym, "hi_pf_", &proof_sort); - self.mint(&mut mints, &trans, &format!("{sym_pf} lo_pf_"), &proof_sort) - } - CarriedProofs::EclassToTerm => { - let sym_pf = self.mint(&mut mints, &sym, "lo_pf_", &proof_sort); - self.mint(&mut mints, &trans, &format!("hi_pf_ {sym_pf}"), &proof_sort) - } - }; + let row = format!("\"{spelling}\" hi_pf_ lo_pf_"); + let displaced_pf = self.mint(&mut mints, &displaced, &row, &proof_sort); let mints_str = mints.join("\n "); - format!( + let merge = format!( "((let hi_pf_ (proof-of-max old0 old1 new0 new1)) (let lo_pf_ (proof-of-min old0 old1 new0 new1)) {mints_str} (set ({uf_name} (ordering-max old0 new0)) (values (ordering-min old0 new0) {displaced_pf})) (values (ordering-min old0 new0) lo_pf_))" - ) + ); + (decl, merge) } /// Declare a sort's union-find `@UF : (S) -> (S, {Unit|Proof})`, mapping each @@ -567,7 +721,10 @@ impl<'a> ProofInstrumentor<'a> { } else { String::new() }; - let uf_merge = self.ordered_union_merge(&uf_name, CarriedProofs::KeyToParent); + // An `@UF` row's carried proof proves `key = parent`, so both share their + // lhs and it is the larger side's that the composition reverses. + let (packed_decl, uf_merge) = + self.ordered_union_merge(&uf_name, Skeleton::Leaf(0).sym().trans(Skeleton::Leaf(1))); // path compression: a->b (pb: a=b), b->c (pc: b=c) => a->c (Trans pb pc: a=c) let (compressed_proof_lets, compressed_proof) = if proofs { let trans = self.proof_names().eq_trans_constructor.clone(); @@ -579,8 +736,24 @@ impl<'a> ProofInstrumentor<'a> { (String::new(), "()".to_string()) }; + // Under the clear knob, every edge of this table is dropped once maintenance + // has finished consuming it. `:naive` because the ruleset's own delta is + // empty on the run that must see the whole table. + let clear_rule = if uf_clear_enabled() { + let clear_name = self.egraph.parser.symbol_gen.fresh("uf_clear_rule"); + let clear_ruleset = self.proof_names().uf_clear_ruleset_name.clone(); + format!( + "(rule ((= (values {b} {pb}) ({uf_name} {a}))) + ((delete ({uf_name} {a}))) + :ruleset {clear_ruleset} :naive :name \"{clear_name}\") + " + ) + } else { + String::new() + }; + let code = format!( - "{proof_tables} + "{packed_decl}{proof_tables} (function {uf_name} ({sort_name}) ({sort_name} {proof_type}) :merge {uf_merge} :unextractable :internal-hidden :internal-identity-vals 1) (rule ((= (values {b} {pb}) ({uf_name} {a})) (= (values {c} {pc}) ({uf_name} {b})) @@ -589,6 +762,7 @@ impl<'a> ProofInstrumentor<'a> { (set ({uf_name} {a}) (values {c} {compressed_proof}))) :ruleset {path_compress_ruleset_name} :name \"{fresh_name}\") + {clear_rule} " ); @@ -620,9 +794,6 @@ impl<'a> ProofInstrumentor<'a> { /// Running the merge inside the view's `:merge` computes the body exactly /// once; computing it twice mints extra, over-merged term rows. fn custom_view_merge(&mut self, fdecl: &ResolvedFunctionDecl) -> String { - // `nat_conn` is scoped to this merge body; the body mints subterms via - // `add_term_and_view`, which threads it. - let mut nat_conn = NatConn::default(); let name = fdecl.name.clone(); let merge = fdecl .merge @@ -631,13 +802,12 @@ impl<'a> ProofInstrumentor<'a> { let mut body_code = vec![]; let mut idx = 0usize; - let merged = self.instrument_merge_body( - &merge.result, - &mut body_code, - &name, - &mut idx, - &mut nat_conn, - ); + let merged = self + .instrument_merge_body(&merge.result, &mut body_code, &name, &mut idx) + .value; + // The merge body's outermost term records a connector nothing composes + // with; whatever is still deferred reached no statement. + self.drop_pending_lookups(); let row_proof = if self.egraph.proof_state.proofs_enabled { let fresh = self.term_proof_for_justification( &mut body_code, @@ -675,6 +845,7 @@ impl<'a> ProofInstrumentor<'a> { let view_name = self.view_name(&fdecl.name); let in_sorts = ListDisplay(schema.input.clone(), " "); let fresh_sort = self.egraph.parser.symbol_gen.fresh("view"); + let index_decls = self.declare_view_indexes(fdecl); let delete_rule = self.delete_and_subsume(fdecl); let to_delete_name = self.delete_name(&fdecl.name); let subsumed_name = self.subsumed_name(&fdecl.name); @@ -725,11 +896,17 @@ impl<'a> ProofInstrumentor<'a> { // Every encoded function uses the FD pair-valued view `(children) -> // (output, {Unit|Proof})` keyed on children only; the branches below // differ in how the `:merge` resolves a children-key collision. + let mut packed_decl = String::new(); let view_decl = if output_is_eclass { // Two rows conflicting on the same children are congruent: keep the // smaller eclass and union the two eclasses in the sort's `@UF`. let uf_name = self.uf_name(schema.output()); - let congruence_merge = self.ordered_union_merge(&uf_name, CarriedProofs::EclassToTerm); + // A view's carried proof proves `eclass = f(children)`, so both share + // their rhs and it is the smaller side's that the composition + // reverses. + let (decl, congruence_merge) = self + .ordered_union_merge(&uf_name, Skeleton::Leaf(0).trans(Skeleton::Leaf(1).sym())); + packed_decl = decl; format!( "(function {view_name} ({in_sorts}) ({out_type} {proof_type}) :merge {congruence_merge} :internal-term-constructor {name}{view_flags} :internal-identity-vals 1)" ) @@ -773,7 +950,8 @@ impl<'a> ProofInstrumentor<'a> { {fresh_sort_decl} {to_ast_view_sort} (function {name} ({term_sorts} {view_sort}) Unit :no-merge :internal-hidden :internal-term-node) - {view_decl} + {packed_decl}{view_decl} + {index_decls} (function {to_delete_name} ({in_sorts}) Unit :no-merge :internal-hidden) (function {subsumed_name} ({in_sorts}) Unit :no-merge :internal-hidden) {delete_rule}", @@ -782,29 +960,24 @@ impl<'a> ProofInstrumentor<'a> { // Actions need to be instrumented to add to the view // as well as to the terms tables. + // + // Every proof minted here is named by the column the walk is at, so an + // action's operands are instrumented before the columns the action itself + // claims (see [`crate::proofs::proof_head`]). fn instrument_action( &mut self, action: &ResolvedAction, justification: &Justification, - nat_conn: &mut NatConn, + scope: &mut Scope, ) -> Vec { let mut res = vec![]; match action { ResolvedAction::Let(_span, v, generic_expr) => { - let v2 = - self.instrument_action_expr(generic_expr, &mut res, justification, nat_conn); - // Carry the canonicalization info onto the let-bound name. `v2` is - // the built term's deduped e-class var, keyed in `nat_conn` by that - // fresh var; without this, a later reference to `v.name` (e.g. the - // `new-e` in `(let new-e (Bop …)) (union e new-e)`) misses in - // `nat_conn` and `union` falls into the no-connector branch, whose - // bare `@Rule` endpoint extracts the deduped (canonicalized) shape - // instead of the natural one the rule head produced. - if let Some(info) = nat_conn.get(&v2).cloned() { - nat_conn.insert(v.name.clone(), info); - } - res.push(format!("(let {} {})", v.name, v2)); + let bound = + self.instrument_action_expr(generic_expr, &mut res, justification, scope); + res.push(format!("(let {} {})", v.name, bound.value)); + scope.bind(&v.name, &bound); } ResolvedAction::Set(_span, h, generic_exprs, generic_expr) => { let ResolvedCall::Func(func_type) = h else { @@ -813,42 +986,45 @@ impl<'a> ProofInstrumentor<'a> { ); }; + let mut exprs = vec![]; + for e in generic_exprs.iter().chain(std::iter::once(generic_expr)) { + exprs.push(self.instrument_action_expr(e, &mut res, justification, scope)); + } + // The row `(f args… value)` is the `set`'s own conclusion, and its + // only column. Building a constructor claims two more, so a `set` + // on one would misnumber every later column rather than fail. + assert_ne!( + func_type.subtype, + FunctionSubtype::Constructor, + "`set` on a constructor should have been rejected by typechecking" + ); + let run = self.site.claim(HeadPosition::Set); + // Global definition `(set (x) e)`: x is a nullary `:internal-let` // function aliasing e. Store e's value+proof directly in x's FD view // (x's e-class *is* e's) — no term mint, which would use the wrong // arity for x's term relation (its output is the eclass, so it has // no separate output column). if generic_exprs.is_empty() && self.egraph.type_info.is_global(&func_type.name) { - let e_value = self.instrument_action_expr( - generic_expr, - &mut res, - justification, - nat_conn, - ); + let e_value = exprs.pop().expect("a set has a value"); let proof = if self.proofs_enabled() { - self.global_value_proof( - &mut res, - func_type, - &e_value, - justification, - nat_conn, - ) + self.global_value_proof(&mut res, func_type, &e_value, justification) } else { "()".to_string() }; // Term row (`x`'s e-class is e's) + the FD view `() -> (val, proof)`. + let e_value = e_value.value; res.push(format!("(set ({} {e_value}) ())", func_type.name)); res.push(self.update_fd_view(&func_type.name, &[], &e_value, &proof)); return res; } - let mut exprs = vec![]; - for e in generic_exprs.iter().chain(std::iter::once(generic_expr)) { - exprs.push(self.instrument_action_expr(e, &mut res, justification, nat_conn)); - } - - let (add_code, _fv) = - self.add_term_and_view(func_type, &exprs, justification, nat_conn); + let (add_code, _fv) = self.add_term_and_view( + func_type, + &exprs, + &justification.at(head_column(run, HeadProof::Own)), + run, + ); res.extend(add_code); } ResolvedAction::Change(_span, change, h, generic_exprs) => { @@ -857,16 +1033,21 @@ impl<'a> ProofInstrumentor<'a> { Change::Delete => self.delete_name(&func_type.name), Change::Subsume => self.subsumed_name(&func_type.name), }; + // `change` concludes nothing, so its arguments hold no column + // for conversion to read back: they compose like a top-level + // action, and the head's numbering resumes after them. + let head = std::mem::replace(&mut self.site, ProofSite::Composed); let children = generic_exprs .iter() - .map(|e| self.instrument_action_expr(e, &mut res, justification, nat_conn)) + .map(|e| self.instrument_action_expr(e, &mut res, justification, scope)) .collect::>(); + self.site = head; // The marker is a `Unit` relation, so insert a row keyed on the // children with `set` (rather than a constructor application). res.push(format!( "(set ({symbol} {}) ())", - ListDisplay(children, " ") + ListDisplay(ids(&children), " ") )); } else { panic!( @@ -878,30 +1059,25 @@ impl<'a> ProofInstrumentor<'a> { // A union whose operand is a freshly-built constructor term is // optimized upstream in `instrument_actions`; this arm handles // the remaining general unions. - let v1 = - self.instrument_action_expr(generic_expr, &mut res, justification, nat_conn); - let v2 = - self.instrument_action_expr(generic_expr1, &mut res, justification, nat_conn); + let v1 = self.instrument_action_expr(generic_expr, &mut res, justification, scope); + let v2 = self.instrument_action_expr(generic_expr1, &mut res, justification, scope); let ot = generic_expr.output_type(); let type_name = ot.name(); - let unioned = self.union(&mut res, type_name, &v1, &v2, justification, nat_conn); + let run = self.site.claim(HeadPosition::Union); + let unioned = self.union(&mut res, type_name, &v1, &v2, justification, run); res.push(unioned); } ResolvedAction::Panic(..) => { res.push(format!("{action}")); } ResolvedAction::Expr(_span, generic_expr) => { - self.instrument_action_expr(generic_expr, &mut res, justification, nat_conn); + self.instrument_action_expr(generic_expr, &mut res, justification, scope); } } res } - /// Build the proof term that justifies a freshly-created term `fv` - /// (wrapped by the AST constructor `to_ast`) proving `fv = fv`, from the - /// surrounding [`Justification`], emitting the proof-relation mints onto - /// `stmts` and returning the proof var. /// Anchor a container's term-proof: mint a proof of `fv = fv` under /// `justification` and record it in the container sort's `Proof` /// table (the base the container rebuild composes from). @@ -923,6 +1099,12 @@ impl<'a> ProofInstrumentor<'a> { stmts.push(format!("(set ({cproof} {fv}) {proof_var})")); } + /// A proof of `fv = fv` under `justification`, appending its mints to `stmts`. + /// + /// The caller must be at a position whose own conclusion is reflexive: a rule + /// justification's proof states whatever its column says and is marked + /// reflexive regardless, so calling this at an equality — a `union`'s — would + /// have the compositions built on it silently drop a real proof. pub(super) fn term_proof_for_justification( &mut self, stmts: &mut Vec, @@ -930,25 +1112,24 @@ impl<'a> ProofInstrumentor<'a> { to_ast: &str, justification: &Justification, ) -> String { - let ast_sort = self.proof_names().ast_sort.clone(); let proof_sort = self.proof_sort(); match justification { - Justification::Rule(rule_name, rule_proof) => { - let a1 = self.mint(stmts, to_ast, fv, &ast_sort); - let a2 = self.mint(stmts, to_ast, fv, &ast_sort); - let rule = self.proof_names().rule_constructor.clone(); - self.mint( - stmts, - &rule, - &format!("{rule_name} {rule_proof} {a1} {a2}"), - &proof_sort, - ) + // The head's own conclusion here is `fv = fv` (`fv`/`to_ast` unused: + // the proposition comes from the column). + Justification::Rule(..) => { + let proof = self.rule_row(stmts, justification); + self.mark_reflexive(&proof); + proof } + // Both AST endpoints wrap the same `fv`, so this proves `fv = fv`. Justification::Fiat => { + let ast_sort = self.proof_names().ast_sort.clone(); let a1 = self.mint(stmts, to_ast, fv, &ast_sort); let a2 = self.mint(stmts, to_ast, fv, &ast_sort); let fiat = self.proof_names().fiat_constructor.clone(); - self.mint(stmts, &fiat, &format!("{a1} {a2}"), &proof_sort) + let proof = self.mint(stmts, &fiat, &format!("{a1} {a2}"), &proof_sort); + self.mark_reflexive(&proof); + proof } // Term-free: no AST minted (`fv`/`to_ast` unused). The checker // reconstructs the conclusion from the merge body + premise outputs. @@ -975,37 +1156,44 @@ impl<'a> ProofInstrumentor<'a> { /// Proof stored in a global's FD view for the value `e` it aliases. /// - /// When `e` is a built term (e.g. `(Plus …)`), `add_term_and_view` has already - /// proved its *natural* form — the literal term the checker reconstructs from - /// the global's `(let x e)` — and recorded a `connector : natural = e_value` in - /// `nat_conn`. Anchor the global's proof on that natural form (a reflexive - /// `e_value = e_value` routed through it) instead of fiat-ing the canonical - /// `e_value` directly: `e_value`'s shape may be a rewritten (canonicalized) - /// child the checker cannot establish, whereas the natural form is exactly the - /// global definition it can. An atomic value (a literal, or a bare reference to - /// another global) has no connector and is fiat-ed directly — a literal is - /// self-justifying and a global alias is already established. + /// When `e` is a built term (e.g. `(Plus …)`), the encoding has already proved + /// its *natural* form — the literal term the checker reconstructs from the + /// global's `(let x e)` — and holds a `connector : natural = e`. Anchor the + /// global's proof on that natural form (a reflexive `e = e` routed through it) + /// instead of fiat-ing the canonical value directly: the value's shape may be a + /// rewritten (canonicalized) child the checker cannot establish, whereas the + /// natural form is exactly the global definition it can. An atomic value (a + /// literal, or a bare reference to another global) has no connector and is + /// fiat-ed directly — a literal is self-justifying and a global alias is + /// already established. + /// + /// A global is only ever set by a top-level action — a rule head that names + /// one reads it as a query variable — so the connector here is always a proof + /// node the encoder minted, never a rule head's column. fn global_value_proof( &mut self, res: &mut Vec, func_type: &FuncType, - e_value: &str, + e_value: &Operand, justification: &Justification, - nat_conn: &NatConn, ) -> String { - if let Some(NatEntry { - connector: Some(connector), - .. - }) = nat_conn.get(e_value).cloned() - { - let proof_sort = self.proof_sort(); - let sym = self.proof_names().eq_sym_constructor.clone(); - let trans = self.proof_names().eq_trans_constructor.clone(); - let sym_conn = self.mint(res, &sym, &connector, &proof_sort); - self.mint(res, &trans, &format!("{sym_conn} {connector}"), &proof_sort) - } else { - let to_ast = self.fname_to_ast_name(&func_type.name).to_string(); - self.term_proof_for_justification(res, e_value, &to_ast, justification) + let value = &e_value.value; + match &e_value.connector { + Some(Connector::Node(connector)) => { + let connector = connector.clone(); + let proof_sort = self.proof_sort(); + let sym = self.proof_names().eq_sym_constructor.clone(); + let trans = self.proof_names().eq_trans_constructor.clone(); + let sym_conn = self.mint(res, &sym, &connector, &proof_sort); + self.mint(res, &trans, &format!("{sym_conn} {connector}"), &proof_sort) + } + Some(Connector::Column(column)) => { + panic!("a global's value cannot be named by rule head column {column}") + } + None => { + let to_ast = self.fname_to_ast_name(&func_type.name).to_string(); + self.term_proof_for_justification(res, value, &to_ast, justification) + } } } @@ -1026,6 +1214,192 @@ impl<'a> ProofInstrumentor<'a> { ) } + /// Record that `proof` proves `t = t`, which the `mint_*` constructors use + /// to drop the steps composed onto it. + pub(crate) fn mark_reflexive(&mut self, proof: &str) { + self.reflexive.insert(proof.to_string()); + } + + /// Whether `proof` is known to prove `t = t`. + fn is_reflexive(&self, proof: &str) -> bool { + self.reflexive.contains(proof) + } + + /// `Sym(proof)`, or `proof` itself when it is reflexive. + /// + /// Held back, like the other two composites: the row is written where + /// something reads the name, so a composition nothing reads is never written + /// and one that is becomes a single row (see [`Self::emit_composition`]). + pub(crate) fn mint_sym(&mut self, proof: &str) -> String { + if self.is_reflexive(proof) { + return proof.to_string(); + } + let inner = self.composition(proof); + self.compose(inner.sym()) + } + + /// `Trans(lhs, rhs)`, dropping whichever side is reflexive. Held back, see + /// [`Self::mint_sym`]. + pub(crate) fn mint_trans(&mut self, lhs: &str, rhs: &str) -> String { + if self.is_reflexive(lhs) { + return rhs.to_string(); + } + if self.is_reflexive(rhs) { + return lhs.to_string(); + } + let (lhs, rhs) = (self.composition(lhs), self.composition(rhs)); + self.compose(lhs.trans(rhs)) + } + + /// `Congr(acc, idx, step)`, or `acc` when `step` is reflexive. Held back, see + /// [`Self::mint_sym`]. + pub(crate) fn mint_congr(&mut self, acc: &str, idx: usize, step: &str) -> String { + if self.is_reflexive(step) { + return acc.to_string(); + } + let (acc, step) = (self.composition(acc), self.composition(step)); + self.compose(acc.congr(idx, step)) + } + + /// What `proof` stands for: the composition it names while that is still + /// unwritten and unsealed, else the variable itself. + fn composition(&self, proof: &str) -> Composition { + match self.deferred.get(proof) { + Some(Deferred::Composed(composition)) if !self.sealed.contains(proof) => { + composition.clone() + } + _ => Composition::Leaf(proof.to_string()), + } + } + + /// Name `composition`, holding its row back until something reads the name. + fn compose(&mut self, composition: Composition) -> String { + let proof = self.fresh_var(); + self.deferred + .insert(proof.clone(), Deferred::Composed(composition)); + proof + } + + /// The connector a built term hands its parent: from the term as written, + /// through `chain` to the term over canonical children, and back along the + /// view row `dedup`. + /// + /// A term whose own `chain` is still an unwritten composition seals the + /// connector, so the row above it holds a column here rather than spelling + /// this term out too. A level's packed row is then a function of its arity + /// and not of the whole term's size. + fn level_connector(&mut self, chain: &str, dedup: &str) -> String { + let composed = matches!(self.deferred.get(chain), Some(Deferred::Composed(_))); + let connector = self.connect(chain.to_string(), dedup.to_string()); + if composed { + self.sealed.insert(connector.clone()); + } + connector + } + + /// The `@Sym`/`@Trans`/`@Congr` row `composition` is, as a name and its + /// arguments — `None` unless it is one step over proofs already in scope. + fn single_step(&self, composition: &Composition) -> Option<(String, String)> { + let names = self.proof_names(); + Some(match composition { + Composition::Sym(inner) => { + (names.eq_sym_constructor.clone(), inner.leaf()?.to_string()) + } + Composition::Trans(left, right) => ( + names.eq_trans_constructor.clone(), + format!("{} {}", left.leaf()?, right.leaf()?), + ), + Composition::Congr(base, index, child) => ( + names.congr_constructor.clone(), + format!("{} {index} {}", base.leaf()?, child.leaf()?), + ), + Composition::Leaf(_) => return None, + }) + } + + /// Write the row `proof` names: one `@Sym`/`@Trans`/`@Congr` when the + /// composition is a single step, else one packed row standing for the whole + /// of it. + fn emit_composition(&mut self, stmts: &mut Vec, proof: &str, composition: Composition) { + let (skeleton, columns) = composition.pack(); + for leaf in &columns { + self.emit_pending_group(stmts, leaf); + } + let (name, args) = self.single_step(&composition).unwrap_or_else(|| { + let (name, decl) = self.packed_proof_constructor(columns.len()); + if !decl.is_empty() { + self.packed_decls.push(decl); + } + let spelling = skeleton.spelling(); + (name, format!("\"{spelling}\" {}", columns.join(" "))) + }); + let proof_sort = self.proof_sort(); + let get_fresh = crate::proofs::proof_fresh::GET_FRESH_PRIM_NAME; + stmts.push(format!("(let {proof} ({get_fresh} \"{proof_sort}\"))")); + stmts.push(format!("(set ({name} {args} {proof}) ())")); + } + + /// The declarations the compositions written since the last call need, as + /// commands to run ahead of the ones using them. + fn take_packed_decls(&mut self) -> Vec { + if self.packed_decls.is_empty() { + return vec![]; + } + let decls = std::mem::take(&mut self.packed_decls).join(""); + self.parse_program(&decls) + } + + /// Hold back `group`, the statements binding `proof`, until something reads + /// `proof`. A proof that nothing ends up reading is never bound, and + /// [`Self::drop_pending_lookups`] discards it. + /// + /// `group` is emitted verbatim wherever the flush lands, so everything it + /// reads must be either bound within it or in scope there — a query + /// variable, or a statement already emitted. + pub(crate) fn defer_lookup(&mut self, proof: &str, group: Vec) { + self.deferred + .insert(proof.to_string(), Deferred::Stmts(group)); + } + + /// Discard everything still held back, whose proofs nothing read. + pub(crate) fn drop_pending_lookups(&mut self) { + self.deferred.clear(); + self.sealed.clear(); + } + + /// Emit the deferred groups `args_joined` reads, and transitively the groups + /// those read, keeping each binding ahead of the statement reading it. A + /// group is emitted at most once, wherever it is first read. + fn emit_pending_lookups(&mut self, stmts: &mut Vec, args_joined: &str) { + if self.deferred.is_empty() { + return; + } + for var in read_vars(args_joined) { + self.emit_pending_group(stmts, var); + } + } + + /// [`Self::emit_pending_lookups`] for what one variable holds back. Called + /// directly by a reader that does not go through [`Self::mint`] — a statement + /// built by `format!` rather than as a row of its own. + fn emit_pending_group(&mut self, stmts: &mut Vec, var: &str) { + match self.deferred.remove(var) { + Some(Deferred::Composed(composition)) => self.emit_composition(stmts, var, composition), + Some(Deferred::Stmts(group)) => stmts.extend(group), + None => {} + } + } + + /// Bind a fresh id of `sort`, asserting nothing about it. + fn fresh_id(&mut self, stmts: &mut Vec, sort: &str) -> String { + let v = self.fresh_var(); + // The generic `get-fresh!` takes the target sort as a string literal so it + // types its output without per-sort primitives (its runtime ignores the arg). + let get_fresh = crate::proofs::proof_fresh::GET_FRESH_PRIM_NAME; + stmts.push(format!("(let {v} ({get_fresh} \"{sort}\"))")); + v + } + /// Mint a fresh id of `out_sort` and assert the relation row /// `({name} {args_joined} )`, appending the `let`/`set` onto `stmts` /// and returning the fresh variable. Terms and proofs are relations rather @@ -1038,11 +1412,8 @@ impl<'a> ProofInstrumentor<'a> { args_joined: &str, out_sort: &str, ) -> String { - let v = self.fresh_var(); - // The generic `get-fresh!` takes the target sort as a string literal so it - // types its output without per-sort primitives (its runtime ignores the arg). - let get_fresh = crate::proofs::proof_fresh::GET_FRESH_PRIM_NAME; - stmts.push(format!("(let {v} ({get_fresh} \"{out_sort}\"))")); + self.emit_pending_lookups(stmts, args_joined); + let v = self.fresh_id(stmts, out_sort); stmts.push(format!("(set ({name} {args_joined} {v}) ())")); v } @@ -1052,25 +1423,26 @@ impl<'a> ProofInstrumentor<'a> { /// stored e-class (a global is `set` before it is used, so the fresh fallback is /// dead code that only fires on a malformed program). The value read is already /// the view's canonical e-class, so no natural/deduped connector is recorded. + /// + /// The signature requires the fallback pair, so both are bare fresh ids: no + /// row says anything about either, since nothing ever reads them. fn lookup_global(&mut self, name: &str, res: &mut Vec) -> String { let view = self.view_name(name); let set_if_empty = crate::proofs::proof_fresh::set_if_empty_prim_name(&view); - let get_fresh = crate::proofs::proof_fresh::GET_FRESH_PRIM_NAME; let view_sort = self .proof_names() .fn_to_term_sort .get(name) .expect("term sort recorded in term_and_view") .clone(); - let fresh_e = self.fresh_var(); - res.push(format!("(let {fresh_e} ({get_fresh} \"{view_sort}\"))")); - let vx = self.fresh_var(); + let fresh_e = self.fresh_id(res, &view_sort); let fallback_proof = if self.proofs_enabled() { - let to_ast = self.fname_to_ast_name(name).to_string(); - self.term_proof_for_justification(res, &fresh_e, &to_ast, &Justification::Fiat) + let proof_sort = self.proof_sort(); + self.fresh_id(res, &proof_sort) } else { "()".to_string() }; + let vx = self.fresh_var(); res.push(format!( "(let {vx} ({set_if_empty} {fresh_e} {fallback_proof}))" )); @@ -1086,13 +1458,16 @@ impl<'a> ProofInstrumentor<'a> { /// the created term. For constructors, `args` excludes the eclass of the /// resulting term (it may not exist yet); for custom functions, `args` /// includes all arguments, output included. + /// + /// `run` is the columns the caller claimed for the position it is at; only a + /// constructor reads past the own conclusion the caller already named. fn add_term_and_view( &mut self, func_type: &FuncType, - args: &[String], + args: &[Operand], justification: &Justification, - nat_conn: &mut NatConn, - ) -> (Vec, String) { + run: Option, + ) -> (Vec, Operand) { let mut res = vec![]; let view_sort = self .egraph @@ -1104,17 +1479,20 @@ impl<'a> ProofInstrumentor<'a> { .clone(); let var = if func_type.subtype != FunctionSubtype::Constructor { - self.add_custom_row(&mut res, func_type, args, justification, &view_sort) + let fv = + self.add_custom_row(&mut res, func_type, &ids(args), justification, &view_sort); + Operand::plain(fv) } else if !self.egraph.proof_state.proofs_enabled { - self.add_constructor_term_only(&mut res, func_type, args, &view_sort) + let canon = self.add_constructor_term_only(&mut res, func_type, &ids(args), &view_sort); + Operand::plain(canon) } else { self.add_constructor_with_proof( &mut res, func_type, args, justification, - nat_conn, &view_sort, + run, ) }; (res, var) @@ -1179,34 +1557,18 @@ impl<'a> ProofInstrumentor<'a> { canon } - /// Mint a constructor's *natural* node (children at their as-built ids) and - /// build a `Congr` chain proving `fv_nat = f(deduped children)`. Returns - /// `(deduped children, fv_nat, nat_prf, nat_to_dedup)`, where `nat_prf` is - /// `fv_nat`'s term proof (the caller anchors it) and `nat_to_dedup` is the - /// chain. Shared by [`Self::add_constructor_with_proof`] and - /// [`Self::instrument_construct_into`]. + /// Mint a constructor's natural node and the proofs about it. fn build_natural_with_congr( &mut self, res: &mut Vec, fname: &str, view_sort: &str, - args: &[String], + args: &[Operand], justification: &Justification, - nat_conn: &NatConn, - ) -> (Vec, String, String, String) { + ) -> Natural { let to_ast = self.fname_to_ast_name(fname).to_string(); - let congr = self.proof_names().congr_constructor.clone(); - let proof_sort = self.proof_sort(); - // Each arg is a child's deduped id; look up its natural id + connector. - let children: Vec<(String, String, Option)> = args - .iter() - .map(|a| match nat_conn.get(a) { - Some(e) => (a.clone(), e.natural.clone(), e.connector.clone()), - None => (a.clone(), a.clone(), None), - }) - .collect(); - let nat_args: Vec = children.iter().map(|(_, n, _)| n.clone()).collect(); - let dedup_args: Vec = children.iter().map(|(d, _, _)| d.clone()).collect(); + let nat_args: Vec = args.iter().map(|a| a.natural.clone()).collect(); + let dedup_args = ids(args); let fv_nat = self.mint( res, fname, @@ -1214,69 +1576,71 @@ impl<'a> ProofInstrumentor<'a> { view_sort, ); let nat_prf = self.term_proof_for_justification(res, &fv_nat, &to_ast, justification); - let mut nat_to_dedup = nat_prf.clone(); - for (i, (_, _, conn)) in children.iter().enumerate() { - if let Some(conn) = conn { - nat_to_dedup = self.mint( - res, - &congr, - &format!("{nat_to_dedup} {i} {conn}"), - &proof_sort, - ); + let to_dedup = self.site.composes().then(|| { + let mut steps = vec![]; + for (i, arg) in args.iter().enumerate() { + if let Some(conn) = &arg.connector { + steps.push((i, self.connector_node(res, justification, conn))); + } } + self.canonicalize(nat_prf.clone(), steps) + }); + Natural { + dedup_args, + fv_nat, + nat_prf, + to_dedup, } - (dedup_args, fv_nat, nat_prf, nat_to_dedup) } /// Proof-mode constructors: build the *natural* term (children at their /// as-built ids) and the *canonical* term (children at their view-deduped /// ids), connect them with a `Congr` chain over the changed children, then /// `set-if-empty` to the view's deduped e-class and stitch on the view-dedup - /// edge. Return the deduped e-class (so parents and views stay canonical) - /// and record `(natural, connector : natural = deduped)` in `nat_conn` for - /// the parent's `Congr` and the root `union`. + /// edge. The operand returned reads as the deduped e-class (so parents and + /// views stay canonical), carrying the natural term and the connector + /// `natural = deduped` for the parent's `Congr` and the root `union`. + /// + /// `run` is the [`HeadPosition::Built`] columns the caller claimed. fn add_constructor_with_proof( &mut self, res: &mut Vec, func_type: &FuncType, - args: &[String], + args: &[Operand], justification: &Justification, - nat_conn: &mut NatConn, view_sort: &str, - ) -> String { + run: Option, + ) -> Operand { let view = self.view_name(&func_type.name); let set_if_empty = crate::proofs::proof_fresh::set_if_empty_prim_name(&view); - let proof_sort = self.proof_sort(); - let trans = self.proof_names().eq_trans_constructor.clone(); - let sym = self.proof_names().eq_sym_constructor.clone(); let term_proof_constructor = self.term_proof_name(func_type.output().name()); - // `fv_nat` stays *unseeded* (only `fv_can` is written to the view) so it is - // never pulled into the view's congruence `:merge`; its `@Rule` endpoint - // therefore keeps the as-built shape the rule head produced. `fv_can` is - // always a separate node (even when no child changed) with the reflexive - // proof `fv_can = fv_can`, exempt from the rule-head check. - let (dedup_args, fv_nat, nat_prf, nat_to_dedup_term) = self.build_natural_with_congr( - res, - &func_type.name, - view_sort, - args, - justification, - nat_conn, - ); + // `fv_nat` stays *unseeded* — only `fv_can` is written to the view — so the + // view's congruence `:merge` can never move it, and the proof of the shape the + // head wrote stays stated over the ids the head built. `fv_can` is a separate + // node even when no child changed. + let natural = + self.build_natural_with_congr(res, &func_type.name, view_sort, args, justification); + let Natural { + dedup_args, + fv_nat, + nat_prf, + to_dedup, + } = natural; let fv_can = self.mint( res, &func_type.name, &ListDisplay(&dedup_args, " ").to_string(), view_sort, ); - let sym_ntd = self.mint(res, &sym, &nat_to_dedup_term, &proof_sort); - let can_prf = self.mint( - res, - &trans, - &format!("{sym_ntd} {nat_to_dedup_term}"), - &proof_sort, - ); + let can_prf = match &to_dedup { + Some(chain) => self.reflexive(chain.clone()), + // One row records the composition proof conversion rebuilds. + None => { + let canonical = justification.at(head_column(run, HeadProof::Canonical)); + self.rule_row(res, &canonical) + } + }; // Anchor both term proofs, dedup `fv_can` to the view e-class, and read the // view's stored proof (`dedup = f(children)`). @@ -1284,6 +1648,8 @@ impl<'a> ProofInstrumentor<'a> { let vprf = self.fresh_var(); let view_proof = crate::proofs::proof_fresh::view_proof_prim_name(&view); let dedup_args = ListDisplay(&dedup_args, " "); + // The three statements below read `can_prf` directly, not through a mint. + self.emit_pending_group(res, &can_prf); res.push(format!( "(set ({term_proof_constructor} {fv_nat}) {nat_prf})" )); @@ -1296,25 +1662,73 @@ impl<'a> ProofInstrumentor<'a> { res.push(format!( "(let {vprf} ({view_proof} {dedup_args} {can_prf}))" )); + // The read misses on a row this action just seeded, returning the fallback: + // a proof about the term as written rather than about the canonical one, + // which is how conversion tells "no bridge" from a real one. + self.record_bridge(&vprf); + + let connector = match &to_dedup { + Some(chain) => Connector::Node(self.level_connector(chain, &vprf)), + None => Connector::Column( + run.expect("a rule head's terms are numbered") + .column(HeadProof::Connector), + ), + }; + Operand::built(dedup, fv_nat, connector) + } - // connector `fv_nat = dedup` = Trans(nat_to_dedup, Sym(dedup = f(children))). - // `sym_vprf` reads the `vprf` let, so it must follow the statements above. - let sym_vprf = self.mint(res, &sym, &vprf, &proof_sort); - let connector = self.mint( - res, - &trans, - &format!("{nat_to_dedup_term} {sym_vprf}"), - &proof_sort, - ); - - nat_conn.insert( - dedup.clone(), - NatEntry { - natural: fv_nat, - connector: Some(connector), - }, - ); - dedup + /// Declare one index per distinct eq-sort among a view's columns, so an `@UF` + /// edge on a term reaches the rows mentioning it by lookup instead of by + /// matching the view once per column. The e-class column is indexed too, so a + /// stale e-class is found the same way. Containers are excluded: they carry + /// no `@UF` row and are canonicalized structurally. + fn declare_view_indexes(&mut self, fdecl: &ResolvedFunctionDecl) -> String { + let types = fdecl.resolved_schema.view_types(); + // Children, plus the value column when it is an e-class. When only the + // e-class moves the canonical key equals the old one, so the rebuild rule + // deletes the old row before re-inserting rather than after (see + // `indexed_rebuild_rule`). A custom function's value column is an ordinary + // output, rebuilt by its own rule, so indexing it would only invite the + // whole-row rule to rewrite it with an e-class's proof shape. + let indexable = if self.output_is_eclass(fdecl) { + types.len() + } else { + types.len() - 1 + }; + let mut by_sort: Vec<(String, Vec)> = Vec::new(); + for (i, ty) in types[..indexable].iter().enumerate() { + if ty.is_eq_container_sort() || !ty.is_eq_sort() { + continue; + } + let sort = ty.name().to_string(); + match by_sort.iter_mut().find(|(s, _)| *s == sort) { + Some((_, positions)) => positions.push(i), + None => by_sort.push((sort, vec![i])), + } + } + let view_name = self.view_name(&fdecl.name); + let mut decls = String::new(); + let mut entries = Vec::new(); + for (sort_name, positions) in by_sort { + let index_name = self + .egraph + .parser + .symbol_gen + .fresh(&format!("{}Occ_{sort_name}", fdecl.name)); + decls.push_str(&format!( + "(index {index_name} {view_name} (any {}))\n", + ListDisplay(&positions, " ") + )); + entries.push(ViewIndex { + name: index_name, + sort_name, + }); + } + self.egraph + .proof_state + .view_index + .insert(fdecl.name.clone(), entries); + decls } /// Query a functional-dependency view by its `children` key, binding fresh @@ -1351,21 +1765,22 @@ impl<'a> ProofInstrumentor<'a> { res: &mut Vec, fname: &str, idx: &mut usize, - nat_conn: &mut NatConn, - ) -> String { + ) -> Operand { let my_idx = *idx; *idx += 1; match expr { - ResolvedExpr::Lit(_, lit) => format!("{lit}"), - ResolvedExpr::Var(_, resolved_var) => match resolved_var.name.as_str() { - "old" => "old0".to_string(), - "new" => "new0".to_string(), - other => other.to_string(), - }, + ResolvedExpr::Lit(_, lit) => Operand::plain(format!("{lit}")), + ResolvedExpr::Var(_, resolved_var) => { + Operand::plain(match resolved_var.name.as_str() { + "old" => "old0".to_string(), + "new" => "new0".to_string(), + other => other.to_string(), + }) + } ResolvedExpr::Call(_, ResolvedCall::Func(func_type), args) => { let arg_vars = args .iter() - .map(|a| self.instrument_merge_body(a, res, fname, idx, nat_conn)) + .map(|a| self.instrument_merge_body(a, res, fname, idx)) .collect::>(); let just = Justification::MergeIdx( fname.to_string(), @@ -1373,7 +1788,7 @@ impl<'a> ProofInstrumentor<'a> { "new1".to_string(), my_idx, ); - let (code, fv) = self.add_term_and_view(func_type, &arg_vars, &just, nat_conn); + let (code, fv) = self.add_term_and_view(func_type, &arg_vars, &just, None); res.extend(code); fv } @@ -1384,14 +1799,14 @@ impl<'a> ProofInstrumentor<'a> { ResolvedExpr::Call(_, ResolvedCall::Primitive(sp), args) => { let arg_vars = args .iter() - .map(|a| self.instrument_merge_body(a, res, fname, idx, nat_conn)) + .map(|a| self.instrument_merge_body(a, res, fname, idx)) .collect::>(); let prim_name = sp.name().to_string(); let out = sp.output(); let fv = self.fresh_var(); res.push(format!( "(let {fv} ({prim_name} {}))", - ListDisplay(&arg_vars, " ") + ListDisplay(ids(&arg_vars), " ") )); if self.egraph.proof_state.proofs_enabled && out.is_eq_container_sort() { let csort = out.name().to_string(); @@ -1403,7 +1818,7 @@ impl<'a> ProofInstrumentor<'a> { ); self.anchor_container_term_proof(res, &fv, &csort, &just); } - fv + Operand::plain(fv) } ResolvedExpr::Call(_, _, _) => { panic!("proof-mode merge body for `{fname}` contains an unsupported call form") @@ -1412,21 +1827,31 @@ impl<'a> ProofInstrumentor<'a> { } // Add to view and term tables, returning a variable for the created term. + // + // A call claims its columns after its arguments have claimed theirs, so the + // walk numbers a term's children before the term (see + // [`crate::proofs::proof_head`]). fn instrument_action_expr( &mut self, expr: &ResolvedExpr, res: &mut Vec, proof: &Justification, - nat_conn: &mut NatConn, - ) -> String { + scope: &Scope, + ) -> Operand { match expr { - ResolvedExpr::Lit(_, lit) => format!("{lit}"), - ResolvedExpr::Var(_, resolved_var) => resolved_var.name.clone(), + ResolvedExpr::Lit(_, lit) => Operand::plain(format!("{lit}")), + ResolvedExpr::Var(_, resolved_var) => scope.read(&resolved_var.name), ResolvedExpr::Call(_, resolved_call, args) => { let args = args .iter() - .map(|arg| self.instrument_action_expr(arg, res, proof, nat_conn)) + .map(|arg| self.instrument_action_expr(arg, res, proof, scope)) .collect::>(); + // The whole run this call claims, its own conclusion first. + let run = self.site.claim(match constructor_operand(expr) { + Some(_) => HeadPosition::Built, + None => HeadPosition::Call, + }); + let proof = &proof.at(head_column(run, HeadProof::Own)); match resolved_call { ResolvedCall::Func(func_type) => { if func_type.subtype == FunctionSubtype::Custom { @@ -1436,7 +1861,7 @@ impl<'a> ProofInstrumentor<'a> { // FD view (see `lookup_global`). This is the only custom // lookup allowed here. if self.egraph.type_info.is_global(&func_type.name) { - self.lookup_global(&func_type.name, res) + Operand::plain(self.lookup_global(&func_type.name, res)) } else { panic!( "Found a function lookup in actions, should have been prevented by typechecking" @@ -1444,7 +1869,7 @@ impl<'a> ProofInstrumentor<'a> { } } else { let (add_code, fv) = - self.add_term_and_view(func_type, &args, proof, nat_conn); + self.add_term_and_view(func_type, &args, proof, run); res.extend(add_code); fv } @@ -1463,17 +1888,21 @@ impl<'a> ProofInstrumentor<'a> { // rebuild canonicalizes the element (see "Containers" in // proof_encoding.md). let mut build_args = Vec::with_capacity(args.len()); - for (a, asort) in args.iter().zip(specialized_primitive.input()) { - match nat_conn.get(a).cloned() { - Some(NatEntry { - natural, - connector: Some(conn), - }) if container_proof && asort.is_eq_sort() => { + for (arg, asort) in args.iter().zip(specialized_primitive.input()) { + match &arg.connector { + Some(connector) if container_proof && asort.is_eq_sort() => { + let (value, natural) = (&arg.value, &arg.natural); let uf = self.uf_name(asort.name()); - res.push(format!("(set ({uf} {natural}) (values {a} {conn}))")); - build_args.push(natural); + let conn = self.connector_node(res, proof, connector); + // The `@UF` row reads the connector directly, + // not through a mint. + self.emit_pending_group(res, &conn); + res.push(format!( + "(set ({uf} {natural}) (values {value} {conn}))" + )); + build_args.push(natural.clone()); } - _ => build_args.push(a.clone()), + _ => build_args.push(arg.value.clone()), } } let fv = self.fresh_var(); @@ -1486,7 +1915,7 @@ impl<'a> ProofInstrumentor<'a> { if container_proof { self.anchor_container_term_proof(res, &fv, &csort, proof); } - fv + Operand::plain(fv) } ResolvedCall::Values(_) => { panic!("tuple-output (`values`) functions are not supported in proofs") @@ -1501,34 +1930,42 @@ impl<'a> ProofInstrumentor<'a> { &mut self, actions: &[ResolvedAction], justification: &Justification, - nat_conn: &mut NatConn, ) -> Vec { - // Repeated constructor applications are already shared `let`s (see - // `ast::cse`). Normalize union operands to variables, then build each + // Normalize union operands to variables, then build each // freshly-constructed union operand directly into the other operand's // e-class (see proof_encoding.md, "Union in a rule"). - let normalized = self.normalize_union_operands(actions); - let (construct_into, dropped) = Self::plan_construct_into(&normalized); + let symbol_gen = &mut self.egraph.parser.symbol_gen; + let mut fresh = || symbol_gen.fresh("union_operand"); + let plan = HeadPlan::new(actions, &mut fresh); + // A rule head is a format proof conversion can replay, so its proofs are + // named by column; everywhere else the encoder composes them itself. + self.site = match justification { + Justification::Rule(..) => ProofSite::skeleton(plan.layout.clone()), + _ => ProofSite::Composed, + }; + let mut scope = Scope::default(); let mut res = vec![]; - for (i, action) in normalized.iter().enumerate() { - if dropped.contains(&i) { + for (i, action) in plan.actions.iter().enumerate() { + if plan.dropped.contains(&i) { continue; } match action { - ResolvedAction::Let(_, v, expr) if construct_into.contains_key(&v.name) => { - let target = construct_into[&v.name].clone(); - self.instrument_construct_into( + ResolvedAction::Let(_, v, expr) if plan.construct_into.contains_key(&v.name) => { + let target = scope.read(&plan.construct_into[&v.name]); + let guest = self.instrument_construct_into( &mut res, expr, &target, &v.name, justification, - nat_conn, + &scope, ); + scope.bind(&v.name, &guest); } - _ => res.extend(self.instrument_action(action, justification, nat_conn)), + _ => res.extend(self.instrument_action(action, justification, &mut scope)), } } + self.site = ProofSite::Composed; res } @@ -1537,36 +1974,46 @@ impl<'a> ProofInstrumentor<'a> { /// When proofs are enabled we query proof tables, then build a proof for the rule in the actions. /// Finally, each view update also updates the proof tables. fn instrument_rule(&mut self, rule: &ResolvedRule) -> Vec { - // Fresh per generated program (see `NatConn`): a shared map would leak - // stale entries keyed by repeated user let names (e.g. `new-e`) from - // earlier rules/merges, referencing out-of-scope vars. - let mut nat_conn = NatConn::default(); - // term_proofs are fetched as action-side lookups (see instrument_facts), - // so a rule with any needs a Read/Full action context (`eval_opt` below). - let (facts, action_lookups, proof_str) = self.instrument_facts(&rule.body); - let proof_var = self.fresh_var(); + // The reflexive-proof names are globally fresh, so keeping earlier rules' + // would be harmless but unbounded. + self.reflexive.clear(); + let (facts, action_lookups, premises) = self.instrument_facts(&rule.body); let rule_name_var = if self.egraph.proof_state.proofs_enabled { self.egraph.parser.symbol_gen.fresh("rule_name") } else { "()".to_string() }; - let proof = Justification::Rule(rule_name_var.clone(), proof_var.clone()); - let reads_in_rhs = !action_lookups.is_empty(); - // The looked-up proofs feed `proof_str`, so bind them before the proof list. + // Every mint site replaces the placeholder with the column the walk is at. + let proof = Justification::Rule(rule_name_var.clone(), premises, HeadColumn::Unnumbered); + // A proof-mode head reads the database: it looks up the body variables' + // term proofs and interns each subterm it builds, so it needs a Read/Full + // action context (`eval_opt` below). + let reads_in_rhs = self.egraph.proof_state.proofs_enabled; let action_lookups_str = ListDisplay(&action_lookups, "\n "); - let proof_var_binding = if self.egraph.proof_state.proofs_enabled { + let proof_prelude = if self.egraph.proof_state.proofs_enabled { format!( "(let {rule_name_var} \"{}\") - {action_lookups_str} - (let {proof_var} - {proof_str})", + {action_lookups_str}", rule.name ) } else { "".to_string() }; - let actions = self.instrument_actions(&rule.head.0, &proof, &mut nat_conn); + // Every subterm the head interns records its view-row proof as a bridge + // premise (see `record_bridge`), so the bridges the rule proofs name + // accumulate as the actions run. + self.head_chain = self + .egraph + .proof_state + .proofs_enabled + .then(HeadChain::default); + let actions = self.instrument_actions(&rule.head.0, &proof); + self.head_chain = None; + // A premise proof and the lookups under it are emitted by the first + // statement naming them, which is a head row; whatever is still deferred + // reached none, so the rule never needs to compute it. + self.drop_pending_lookups(); let name = &rule.name; let ruleset_opt = if rule.ruleset.is_empty() { "".to_string() @@ -1579,17 +2026,13 @@ impl<'a> ProofInstrumentor<'a> { let eval_opt = if rule.eval_mode.is_naive() { ":naive" } else if reads_in_rhs { - if self.egraph.proof_state.force_proof_naive { - ":naive" - } else { - ":unsafe-seminaive" - } + self.rhs_read_eval_opt() } else { "" }; let instrumented = format!( "(rule ({}) - ({proof_var_binding} + ({proof_prelude} {}) {ruleset_opt} {eval_opt} :name \"{name}\")", @@ -1607,13 +2050,22 @@ impl<'a> ProofInstrumentor<'a> { let delete_ruleset = self.proof_names().delete_subsume_ruleset_name.clone(); // The `@UF` `:merge` resolves conflicting parents itself, so only // `path_compress` (flattening chains) remains as UF maintenance. + // + // The clear runs last, once the saturated rebuild has canonicalized every + // row that any later query, `delete`, or `subsume` can reach — so the edges + // it drops are ones nothing reads again. + let clear_step = if uf_clear_enabled() { + format!(" {}", self.proof_names().uf_clear_ruleset_name) + } else { + String::new() + }; self.parse_schedule(format!( "(seq (saturate {rebuilding_cleanup_ruleset} (saturate {path_compress_ruleset}) {rebuilding_ruleset}) - {delete_ruleset})" + {delete_ruleset}{clear_step})" )) } @@ -1622,7 +2074,8 @@ impl<'a> ProofInstrumentor<'a> { ResolvedSchedule::Run(span, config) => { let new_run = match config.until { Some(ref facts) => { - let (instrumented, _lookups, _proof) = self.instrument_facts(facts); + let (instrumented, _lookups, _premises) = self.instrument_facts(facts); + self.drop_pending_lookups(); let instrumented_facts = self.parse_facts(&instrumented); Schedule::Run( span.clone(), @@ -1783,26 +2236,29 @@ impl<'a> ProofInstrumentor<'a> { ResolvedNCommand::NormRule { rule } => { res.extend(self.instrument_rule(rule)); } - // A top-level action, or a block of them from `ast::cse`. The - // instrumented result runs as one local-scope block so the minted - // temporaries stay local (see `parse_program_as_local_actions`). + // A top-level action, or a block of them. The instrumented result + // runs as one local-scope block so the minted temporaries stay + // local (see `parse_program_as_local_actions`). ResolvedNCommand::CoreAction(_) | ResolvedNCommand::CoreActions(_) => { let actions: &[ResolvedAction] = match command { ResolvedNCommand::CoreAction(action) => std::slice::from_ref(action), ResolvedNCommand::CoreActions(actions) => &actions.0, _ => unreachable!("guarded by the match arm"), }; - let mut nat_conn = NatConn::default(); let instrumented = self - .instrument_actions(actions, &Justification::Fiat, &mut nat_conn) + .instrument_actions(actions, &Justification::Fiat) .join("\n"); + // A term built here records a connector nothing may go on to + // compose with; whatever is still deferred reached no statement. + self.drop_pending_lookups(); res.extend(self.parse_program_as_local_actions(&instrumented)); } ResolvedNCommand::LetBegin(..) => { unreachable!("LetBegin is removed by remove_globals") } ResolvedNCommand::Check(span, facts) => { - let (instrumented, _lookups, _proof) = self.instrument_facts(facts); + let (instrumented, _lookups, _premises) = self.instrument_facts(facts); + self.drop_pending_lookups(); res.push(Command::Check( span.clone(), self.parse_facts(&instrumented), @@ -1829,19 +2285,20 @@ impl<'a> ProofInstrumentor<'a> { ResolvedNCommand::Extract(span, expr, variants) => { // Instrument the expressions to use view tables (like actions, not facts) let mut action_stmts = vec![]; - let mut nat_conn = NatConn::default(); - let instrumented_expr = self.instrument_action_expr( - expr, - &mut action_stmts, - &Justification::Fiat, - &mut nat_conn, - ); - let instrumented_variants = self.instrument_action_expr( - variants, - &mut action_stmts, - &Justification::Fiat, - &mut nat_conn, - ); + // An extract expression binds nothing, so no name it reads can + // stand for a term built here. + let scope = Scope::default(); + let instrumented_expr = self + .instrument_action_expr(expr, &mut action_stmts, &Justification::Fiat, &scope) + .value; + let instrumented_variants = self + .instrument_action_expr( + variants, + &mut action_stmts, + &Justification::Fiat, + &scope, + ) + .value; // Add any action statements needed to set up the expressions for stmt in action_stmts { @@ -1873,6 +2330,7 @@ impl<'a> ProofInstrumentor<'a> { } ResolvedNCommand::Pop(..) | ResolvedNCommand::Push(..) + | ResolvedNCommand::Index { .. } | ResolvedNCommand::AddRuleset(..) | ResolvedNCommand::Output { .. } | ResolvedNCommand::UnstableCombinedRuleset(..) @@ -1902,9 +2360,18 @@ impl<'a> ProofInstrumentor<'a> { } self.egraph.proof_state.term_header_added = true; } + if self.egraph.proof_state.proofs_enabled { + let arities = self.rule_arity_header(&program); + res.extend(arities); + } for command in program { + let at = res.len(); self.term_encode_command(&command, &mut res)?; + // A packed constructor is a property of the composition written, so + // it is declared with the first command writing one — ahead of that + // command, and outside any `fail` wrapping it. + res.splice(at..at, self.take_packed_decls()); if !command_skips_rebuild(&command) { res.push(Command::RunSchedule(self.rebuild())); diff --git a/egglog/src/proofs/proof_encoding_facts.rs b/egglog/src/proofs/proof_encoding_facts.rs index 1d9be787..37d1f132 100644 --- a/egglog/src/proofs/proof_encoding_facts.rs +++ b/egglog/src/proofs/proof_encoding_facts.rs @@ -61,24 +61,22 @@ impl ProofInstrumentor<'_> { // The custom function's FD view is keyed by children: bind the // output `v` (pair-first) and the row's existence proof - // `proof_var` (pair-second). - let proof_var = self.fresh_var(); + // `proof_var` (pair-second). With proofs off the column is `Unit` + // and unread, so match the literal (see the constructor case). + let proof_var = if self.egraph.proof_state.proofs_enabled { + self.fresh_var() + } else { + "()".to_string() + }; let children_str = ListDisplay(&new_args, " "); res.push(format!( "(= (values {v} {proof_var}) ({view_name} {children_str}))" )); if self.egraph.proof_state.proofs_enabled { - let congr = self.proof_names().congr_constructor.clone(); - let proof_sort = self.proof_sort(); let mut proof = proof_var; for (i, arg_proof) in arg_proofs.into_iter().enumerate() { - proof = self.mint( - action_lookups, - &congr, - &format!("{proof} {i} {arg_proof}"), - &proof_sort, - ); + proof = self.mint_congr(&proof, i, &arg_proof); } proof } else { @@ -90,16 +88,8 @@ impl ProofInstrumentor<'_> { let (v2, p2) = self.instrument_fact_expr(right_expr, res, action_lookups); res.push(format!("(= {v1} {v2})")); if self.egraph.proof_state.proofs_enabled { - let sym = self.proof_names().eq_sym_constructor.clone(); - let trans = self.proof_names().eq_trans_constructor.clone(); - let proof_sort = self.proof_sort(); - let sym_pf = self.mint(action_lookups, &sym, &p1, &proof_sort); - self.mint( - action_lookups, - &trans, - &format!("{sym_pf} {p2}"), - &proof_sort, - ) + let sym_pf = self.mint_sym(&p1); + self.mint_trans(&sym_pf, &p2) } else { "()".to_string() } @@ -138,7 +128,7 @@ impl ProofInstrumentor<'_> { let proof_code = if self.egraph.proof_state.proofs_enabled { let lit_sort = literal_sort(lit); let lit_str = format!("{lit}"); - self.reflexive_fiat_proof(action_lookups, lit_sort.name(), &lit_str) + self.reflexive_fiat_proof(lit_sort.name(), &lit_str) } else { "()".to_string() }; @@ -161,13 +151,19 @@ impl ProofInstrumentor<'_> { // present when the rule fires. Fetch it directly in the // action (the rule is then `:unsafe-seminaive`, see // instrument_rule) instead of as a body join — one fewer - // join per eq-sort body variable. Callers that don't - // build a proof (run :until, check) discard these. - action_lookups - .push(format!("(let {fresh_proof} ({term_proof_name} {var}))")); + // join per eq-sort body variable. Deferred because the + // proof is reflexive, so the composition asking for it + // often drops it; callers that don't build a proof + // (run :until, check) discard these. + self.defer_lookup( + &fresh_proof, + vec![format!("(let {fresh_proof} ({term_proof_name} {var}))")], + ); + // A term proof is the term's reflexive anchor. + self.mark_reflexive(&fresh_proof); fresh_proof } else { - self.reflexive_fiat_proof(action_lookups, resolved_var.sort.name(), var) + self.reflexive_fiat_proof(resolved_var.sort.name(), var) }, ) } @@ -201,25 +197,26 @@ impl ProofInstrumentor<'_> { let args_str = ListDisplay(new_args, " "); let proof = { - let view_proof_var = self.fresh_var(); // A constructor view is the FD tuple // `(children) -> (eclass, {Unit|Proof})`: bind the eclass (`fv`) - // and proof from the tuple. + // and proof from the tuple. With proofs off the column is + // `Unit` and nothing reads it, so match the literal rather + // than bind a variable no atom shares — one per view read + // would otherwise enter the join's variable graph and skew + // the planner's decomposition. + let view_proof_var = if self.proofs_enabled() { + self.fresh_var() + } else { + "()".to_string() + }; res.push(format!( "(= (values {fv} {view_proof_var}) ({view_name} {args_str}))" )); if self.proofs_enabled() { - let congr = self.proof_names().congr_constructor.clone(); - let proof_sort = self.proof_sort(); let mut proof = view_proof_var; for (i, arg_proof) in arg_proofs.into_iter().enumerate() { if let Some(arg_proof) = arg_proof { - proof = self.mint( - action_lookups, - &congr, - &format!("{proof} {i} {arg_proof}"), - &proof_sort, - ); + proof = self.mint_congr(&proof, i, &arg_proof); } } proof @@ -259,11 +256,7 @@ impl ProofInstrumentor<'_> { } else { // Base primitives produce a literal result; a // reflexive `Fiat` over a literal is checker-valid. - self.reflexive_fiat_proof( - action_lookups, - specialized_primitive.output().name(), - &fv, - ) + self.reflexive_fiat_proof(specialized_primitive.output().name(), &fv) }; (fv.clone(), proof) @@ -276,51 +269,49 @@ impl ProofInstrumentor<'_> { } } - /// Return the instrumented query and a proof that it matched. - /// Returns `(body_facts, action_lookups, proof)`. Eq-sort variables' - /// `term_proof` fetches are emitted into `action_lookups` as - /// `(let p (term_proof v))` lines for the caller to splice into the - /// rule's actions (the rule is then `:unsafe-seminaive`). Callers - /// that don't build a proof (`run :until`, `check`) discard the - /// lookups and the proof. + /// Return the instrumented query, the mints its premise proofs need, and one + /// premise proof per fact. + /// + /// Only the mints nothing can defer come back in the second component: the + /// `Eval` marker of a container side condition and an eq-sort primitive + /// result's `term_proof` fetch. Everything else a premise proof is built + /// from is deferred, landing wherever the premise is first read — for a + /// rule, the head's own actions. Either component makes the rule + /// `:unsafe-seminaive`. A head that names no premise (`(panic …)`) reads + /// none of it, so none of it is emitted; callers that build no proof at all + /// (`run :until`, `check`) discard the lookups and the premises, and must + /// [`ProofInstrumentor::drop_pending_lookups`]. pub(super) fn instrument_facts( &mut self, facts: &[ResolvedFact], - ) -> (Vec, Vec, String) { + ) -> (Vec, Vec, Vec) { let mut res = vec![]; let mut action_lookups = vec![]; - let mut proof = vec![]; + let mut premises = vec![]; for fact in facts.iter() { let f_proof = self.instrument_fact(fact, &mut res, &mut action_lookups); - proof.push(f_proof); + premises.push(f_proof); } - - // The prooflist mints are actions (emitted into `action_lookups` before - // the proof binding). Only proof mode consumes the prooflist; in term - // mode it is discarded, so skip the mints to keep `action_lookups` empty. - let proof_list = if self.proofs_enabled() { - self.format_prooflist(&mut action_lookups, &proof) - } else { - String::new() - }; - (res, action_lookups, proof_list) + (res, action_lookups, premises) } - /// Mint a reflexive `Fiat` proof `value = value` for a term of `sort_name` - /// (two identical ASTs under a `Fiat`), appending the mints to `stmts`. - fn reflexive_fiat_proof( - &mut self, - stmts: &mut Vec, - sort_name: &str, - value: &str, - ) -> String { + /// A reflexive `Fiat` proof `value = value` for a term of `sort_name` (two + /// identical ASTs under a `Fiat`). + /// + /// Deferred (see [`ProofInstrumentor::defer_lookup`]): the returned variable + /// is bound wherever something first reads it, and nowhere if nothing does. + fn reflexive_fiat_proof(&mut self, sort_name: &str, value: &str) -> String { let to_ast = self .proof_names() .sort_to_ast_constructor .get(sort_name) .unwrap() .clone(); - self.term_proof_for_justification(stmts, value, &to_ast, &Justification::Fiat) + let mut group = vec![]; + let proof = + self.term_proof_for_justification(&mut group, value, &to_ast, &Justification::Fiat); + self.defer_lookup(&proof, group); + proof } } diff --git a/egglog/src/proofs/proof_encoding_helpers.rs b/egglog/src/proofs/proof_encoding_helpers.rs index ee39b854..06f4525c 100644 --- a/egglog/src/proofs/proof_encoding_helpers.rs +++ b/egglog/src/proofs/proof_encoding_helpers.rs @@ -7,7 +7,7 @@ use crate::{ EGraph, TypeInfo, Value, ast::{ Command, Expr, Fact, GenericCommand, ResolvedAction, ResolvedCommand, ResolvedExpr, - ResolvedExprExt, ResolvedFact, Schedule, Span, + ResolvedExprExt, ResolvedFact, ResolvedNCommand, Schedule, Span, }, core::ResolvedCall, proofs::proof_encoding::ProofInstrumentor, @@ -19,11 +19,27 @@ use crate::{ /// All of these names should be generated with the single global [`SymbolGen`]. #[derive(Clone)] pub(crate) struct EncodingNames { - pub(crate) proof_list_sort: String, pub(crate) ast_sort: String, pub(crate) proof_datatype: String, pub(crate) fiat_constructor: String, - pub(crate) rule_constructor: String, + /// Prefix of the rule proofs carrying their body premises inline: premise + /// count `k`'s constructor is [`Self::fused_rule`]. One prefix rather than a + /// name per arity, so that re-parsing a desugared program recovers the same + /// names without having encoded its rules. + pub(crate) rule_fused_prefix: String, + /// The premise counts [`ProofInstrumentor::rule_arity_header`] has declared. + pub(crate) rule_fused_declared: HashSet, + /// A later proof of the same head: the previous column's rule proof plus one + /// canonicalization bridge. + pub(crate) rule_link_constructor: String, + /// Prefix of the packed proof constructors carrying a [`Skeleton`] and the + /// columns it composes over: column count `k`'s constructor is + /// [`Self::packed_proof`]. Derived from one name for the same reason as + /// [`Self::rule_fused_prefix`]. + pub(crate) packed_prefix: String, + /// The column counts [`ProofInstrumentor::packed_proof_constructor`] has + /// declared. + pub(crate) packed_declared: HashSet, pub(crate) merge_fn_idx_constructor: String, pub(crate) merge_fn_row_constructor: String, pub(crate) eq_trans_constructor: String, @@ -35,13 +51,12 @@ pub(crate) struct EncodingNames { /// For a given function symbol, the name of the function that converts to the AST type. pub(crate) sort_to_ast_constructor: HashMap, pub(crate) fn_to_term_sort: HashMap, - pub(crate) pcons: String, - pub(crate) pnil: String, // Ruleset names pub(crate) path_compress_ruleset_name: String, pub(crate) rebuilding_ruleset_name: String, pub(crate) rebuilding_cleanup_ruleset_name: String, pub(crate) delete_subsume_ruleset_name: String, + pub(crate) uf_clear_ruleset_name: String, // Per-function fresh names pub(crate) view_name: HashMap, pub(crate) to_delete_name: HashMap, @@ -49,12 +64,222 @@ pub(crate) struct EncodingNames { pub(crate) term_proof_name: HashMap, } -/// Packages proof information for instrumenting actions. -/// We may not know yet what terms we are instrumenting, so the justification leaves -/// that information to be filled in later. -/// This is only used internally in this file, it's not part of the proof format. +/// A proof composed out of the equality axioms over leaves of type `L`. Two +/// leaves are in use — a [`Composition`]'s and a [`Skeleton`]'s — and +/// [`Composition::pack`] is the map between them. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub(crate) enum ProofTree { + Leaf(L), + Sym(Box>), + Trans(Box>, Box>), + /// The child position the step rewrites. + Congr(Box>, usize, Box>), +} + +/// The composition one packed proof row stands for, written over the row's own +/// proof columns: a leaf is the proof in a column, so the skeleton is also the +/// row's layout. Every column is named, so a column a composition reaches twice +/// is named twice and carried once. +/// +/// A packed row carries [`Skeleton::spelling`] in its first column, so the site +/// writing the row and the unpacking that reads it work from one statement of +/// the composition. +pub(crate) type Skeleton = ProofTree; + +/// A composition the encoder has built but not written a row for. A leaf names +/// a proof variable already in scope; the whole tree becomes one row where +/// something reads it. +pub(crate) type Composition = ProofTree; + +impl ProofTree { + pub(crate) fn sym(self) -> Self { + ProofTree::Sym(Box::new(self)) + } + + pub(crate) fn trans(self, rhs: Self) -> Self { + ProofTree::Trans(Box::new(self), Box::new(rhs)) + } + + /// This composition with one more congruence step, rewriting the child at + /// position `child` by the proof `step` reaches. + pub(crate) fn congr(self, child: usize, step: Self) -> Self { + ProofTree::Congr(Box::new(self), child, Box::new(step)) + } + + /// The leaf this is, when it names one rather than composing. + pub(crate) fn leaf(&self) -> Option<&L> { + match self { + ProofTree::Leaf(leaf) => Some(leaf), + _ => None, + } + } +} + +impl Skeleton { + /// How many columns the row has. + pub(crate) fn width(&self) -> usize { + match self { + ProofTree::Leaf(column) => column + 1, + ProofTree::Sym(inner) => inner.width(), + ProofTree::Trans(left, right) | ProofTree::Congr(left, _, right) => { + left.width().max(right.width()) + } + } + } + + fn collect_columns(&self, columns: &mut Vec) { + match self { + ProofTree::Leaf(column) => columns.push(*column), + ProofTree::Sym(inner) => inner.collect_columns(columns), + ProofTree::Trans(left, right) | ProofTree::Congr(left, _, right) => { + left.collect_columns(columns); + right.collect_columns(columns); + } + } + } + + /// This skeleton as the string a packed row carries: its nodes in prefix + /// order, one `_`-separated token each — `sym`, `trans`, `congr`, + /// `p` for a column, and a bare number for a congruence's child + /// position. Panics unless [`Self::from_spelling`] reads it back, since that + /// is all unpacking has to go on. + pub(crate) fn spelling(&self) -> String { + let mut tokens = vec![]; + self.spell(&mut tokens); + let spelling = tokens.join("_"); + assert_eq!( + Skeleton::from_spelling(&spelling).as_ref(), + Some(self), + "{spelling} does not spell {self:?}" + ); + spelling + } + + fn spell(&self, tokens: &mut Vec) { + match self { + ProofTree::Leaf(column) => tokens.push(format!("p{column}")), + ProofTree::Sym(inner) => { + tokens.push("sym".to_string()); + inner.spell(tokens); + } + ProofTree::Trans(left, right) => { + tokens.push("trans".to_string()); + left.spell(tokens); + right.spell(tokens); + } + ProofTree::Congr(base, child, step) => { + tokens.push("congr".to_string()); + base.spell(tokens); + tokens.push(child.to_string()); + step.spell(tokens); + } + } + } + + /// The skeleton [`Self::spelling`] writes as `spelling`, or `None` when that + /// is not a spelling of one whose columns are `0..n`. A column may be named + /// more than once: a composition reaching the same step twice carries it + /// once. + pub(crate) fn from_spelling(spelling: &str) -> Option { + let mut tokens = spelling.split('_'); + let skeleton = Skeleton::read(&mut tokens)?; + if tokens.next().is_some() { + return None; + } + let mut columns = vec![]; + skeleton.collect_columns(&mut columns); + columns.sort_unstable(); + columns.dedup(); + (columns == (0..columns.len()).collect::>()).then_some(skeleton) + } + + fn read<'a>(tokens: &mut impl Iterator) -> Option { + match tokens.next()? { + "sym" => Some(Skeleton::read(tokens)?.sym()), + "trans" => { + let left = Skeleton::read(tokens)?; + Some(left.trans(Skeleton::read(tokens)?)) + } + "congr" => { + let base = Skeleton::read(tokens)?; + let child = tokens.next()?.parse().ok()?; + Some(base.congr(child, Skeleton::read(tokens)?)) + } + token => Some(ProofTree::Leaf(token.strip_prefix('p')?.parse().ok()?)), + } + } +} + +impl Composition { + /// This composition as a packed row: the [`Skeleton`] it states and the proof + /// variable in each of the row's columns, in first use order. Equal subtrees + /// share their columns, so a step the composition reaches twice is carried + /// once. + pub(crate) fn pack(&self) -> (Skeleton, Vec) { + let mut columns = vec![]; + let skeleton = self.lay_out(&mut HashMap::default(), &mut columns); + (skeleton, columns) + } + + fn lay_out( + &self, + laid_out: &mut HashMap, + columns: &mut Vec, + ) -> Skeleton { + if let Some(skeleton) = laid_out.get(self) { + return skeleton.clone(); + } + let skeleton = match self { + ProofTree::Leaf(proof) => { + columns.push(proof.clone()); + ProofTree::Leaf(columns.len() - 1) + } + ProofTree::Sym(inner) => inner.lay_out(laid_out, columns).sym(), + ProofTree::Trans(left, right) => { + let left = left.lay_out(laid_out, columns); + left.trans(right.lay_out(laid_out, columns)) + } + ProofTree::Congr(base, child, step) => { + let base = base.lay_out(laid_out, columns); + base.congr(*child, step.lay_out(laid_out, columns)) + } + }; + laid_out.insert(self.clone(), skeleton.clone()); + skeleton + } +} + +/// Which proof of a rule head a row states: the column naming its position in +/// the head's flat array of proofs (see [`crate::proofs::proof_head`]), as an +/// `i64`-valued egglog expression. +#[derive(Clone)] +pub(crate) enum HeadColumn { + Numbered(String), + /// A rule row the walk gave no column: the placeholder before the encoder + /// fills one in, and a position inside a head that concludes nothing. It + /// renders as `-1`, which reading a proof back panics on. + Unnumbered, +} + +impl HeadColumn { + /// The column value as an egglog expression. + fn expr(&self) -> String { + match self { + HeadColumn::Numbered(expr) => expr.clone(), + HeadColumn::Unnumbered => "-1".to_string(), + } + } +} + +/// What justifies the proofs the encoder mints for an action. Which proof of the +/// head a mint states is left as a [`HeadColumn`], filled in once the encoder's +/// walk knows the column it is at. Internal to the encoder, not part of the proof +/// format. +#[derive(Clone)] pub(crate) enum Justification { - Rule(String, String), // rule-name expression and proof-list expression + /// Rule-name expression, one premise-proof expression per body fact, and + /// which of the head's proofs is being stated. + Rule(String, Vec, HeadColumn), Fiat, /// Term-free merge justification for a merge-body subexpression: function /// name, the two premise (view) proof expressions, and the pre-order index of @@ -69,14 +294,67 @@ pub(crate) enum Justification { MergeRow(String, String, String), } +impl Justification { + /// The same justification about `column`. Only a rule justification names a + /// column; anything else is returned unchanged. + pub(crate) fn at(&self, column: HeadColumn) -> Justification { + match self { + Justification::Rule(name, proofs, _) => { + Justification::Rule(name.clone(), proofs.clone(), column) + } + other => other.clone(), + } + } + + /// The egglog expression for the `Rule` row's column. + pub(crate) fn column_expr(&self) -> String { + match self { + Justification::Rule(_, _, column) => column.expr(), + _ => HeadColumn::Unnumbered.expr(), + } + } +} + impl EncodingNames { + /// The rule proof constructor carrying `arity` premise proofs inline. + pub(crate) fn fused_rule(&self, arity: usize) -> String { + format!("{}_{arity}", self.rule_fused_prefix) + } + + /// The premise count `head` carries inline, when it is one of + /// [`Self::fused_rule`]'s constructors. + pub(crate) fn fused_rule_arity(&self, head: &str) -> Option { + head.strip_prefix(&self.rule_fused_prefix)? + .strip_prefix('_')? + .parse() + .ok() + } + + /// The packed proof constructor carrying a [`Skeleton`] over `columns` proof + /// columns. + pub(crate) fn packed_proof(&self, columns: usize) -> String { + format!("{}_{columns}", self.packed_prefix) + } + + /// The proof-column count `head` carries, when it is one of + /// [`Self::packed_proof`]'s constructors. + pub(crate) fn packed_proof_columns(&self, head: &str) -> Option { + head.strip_prefix(&self.packed_prefix)? + .strip_prefix('_')? + .parse() + .ok() + } + pub(crate) fn new(symbol_gen: &mut SymbolGen) -> Self { Self { - proof_list_sort: symbol_gen.fresh("ProofList"), ast_sort: symbol_gen.fresh("Ast"), proof_datatype: symbol_gen.fresh("Proof"), fiat_constructor: symbol_gen.fresh("Fiat"), - rule_constructor: symbol_gen.fresh("Rule"), + rule_fused_prefix: symbol_gen.fresh("Rule"), + rule_fused_declared: HashSet::default(), + rule_link_constructor: symbol_gen.fresh("RuleLink"), + packed_prefix: symbol_gen.fresh("Packed"), + packed_declared: HashSet::default(), merge_fn_idx_constructor: symbol_gen.fresh("MergeIdx"), merge_fn_row_constructor: symbol_gen.fresh("MergeRow"), eq_trans_constructor: symbol_gen.fresh("Trans"), @@ -87,12 +365,11 @@ impl EncodingNames { eval_constructor: symbol_gen.fresh("Eval"), sort_to_ast_constructor: HashMap::default(), fn_to_term_sort: HashMap::default(), - pcons: symbol_gen.fresh("PCons"), - pnil: symbol_gen.fresh("PNil"), path_compress_ruleset_name: symbol_gen.fresh("parent"), rebuilding_ruleset_name: symbol_gen.fresh("rebuilding"), rebuilding_cleanup_ruleset_name: symbol_gen.fresh("rebuilding_cleanup"), delete_subsume_ruleset_name: symbol_gen.fresh("delete_subsume_ruleset"), + uf_clear_ruleset_name: symbol_gen.fresh("uf_clear"), view_name: HashMap::default(), to_delete_name: HashMap::default(), subsumed_name: HashMap::default(), @@ -101,6 +378,19 @@ impl EncodingNames { } } +/// Whether maintenance empties every `@UF_` table at the end of each run, +/// keeping only the edges a later iteration's rebuild will read. Set +/// `EGGLOG_UF_CLEAR=1` to enable. +/// +/// Off by default because it does not pay: the rebuild rule already reads only +/// the `@UF` delta, so the rows this drops are ones its join never visits. It +/// also loses the ability to canonicalize a value from an earlier iteration, +/// which [`crate::extract::find_canonical`] needs, and lets a rule that +/// re-derives one union per iteration keep `saturate` from terminating. +pub(crate) fn uf_clear_enabled() -> bool { + matches!(std::env::var("EGGLOG_UF_CLEAR").as_deref(), Ok("1")) +} + impl ProofInstrumentor<'_> { pub(crate) fn uf_name(&mut self, sort: &str) -> String { if let Some(name) = self.egraph.proof_state.uf_parent.get(sort) { @@ -150,41 +440,96 @@ impl ProofInstrumentor<'_> { out } - /// Build a proof list (`pnil`, then `pcons` folds) by minting a fresh id - /// per node and asserting the row, emitting the mints onto `stmts` and - /// returning the final list's var. - pub(crate) fn format_prooflist( - &mut self, - stmts: &mut Vec, - proofs: &[String], - ) -> String { - let pcons = self.proof_names().pcons.clone(); - let pnil = self.proof_names().pnil.clone(); - let proof_list_sort = self.proof_names().proof_list_sort.clone(); - - let mut prooflist = self.mint(stmts, &pnil, "", &proof_list_sort); - for proof in proofs.iter().rev() { - prooflist = self.mint( - stmts, - &pcons, - &format!("{proof} {prooflist}"), - &proof_list_sort, - ); + /// Declarations for the fused rule constructors `program`'s rules need but + /// that no earlier program declared. A rule's premise count is its body-fact + /// count, so the arities a program uses are known before it is encoded; these + /// are emitted ahead of the program's own commands. + pub(crate) fn rule_arity_header(&mut self, program: &[ResolvedNCommand]) -> Vec { + fn collect(commands: &[ResolvedNCommand], out: &mut Vec) { + for command in commands { + match command { + ResolvedNCommand::NormRule { rule } => out.push(rule.body.len()), + ResolvedNCommand::Fail(_, nested) => collect(nested, out), + _ => {} + } + } } - prooflist + let mut arities = vec![]; + collect(program, &mut arities); + arities.sort_unstable(); + arities.dedup(); + + let mut decls = vec![]; + for arity in arities { + if !self + .egraph + .proof_state + .proof_names + .rule_fused_declared + .insert(arity) + { + continue; + } + let names = self.proof_names(); + let name = names.fused_rule(arity); + let proof = names.proof_datatype.clone(); + let premises = vec![proof.as_str(); arity].join(" "); + let sep = if arity == 0 { "" } else { " " }; + decls.push(format!( + "(function {name} (String{sep}{premises} i64 {proof}) Unit :no-merge :internal-hidden :internal-term-node)" + )); + } + if decls.is_empty() { + return vec![]; + } + let decls = decls.join("\n"); + self.parse_program(&decls) + } + + /// The packed proof constructor for a row of `columns` proof columns, + /// together with its declaration — empty once some program has declared it. + /// + /// A row's column count is a property of the site packing it, not of the + /// proof format, so the declaration is emitted with the first commands using + /// it. + pub(crate) fn packed_proof_constructor(&mut self, columns: usize) -> (String, String) { + let name = self.proof_names().packed_proof(columns); + if !self + .egraph + .proof_state + .proof_names + .packed_declared + .insert(columns) + { + return (name, String::new()); + } + let proof = self.proof_names().proof_datatype.clone(); + let columns: String = std::iter::repeat_n(format!("{proof} "), columns).collect(); + let decl = format!( + "(function {name} (String {columns}{proof}) Unit :no-merge :internal-hidden :internal-term-node)\n" + ); + (name, decl) } /// Header commands for term encoding, setting up rulesets. pub(crate) fn term_header(&mut self) -> Vec { + let uf_clear = if uf_clear_enabled() { + format!( + "\n (ruleset {})", + self.proof_names().uf_clear_ruleset_name + ) + } else { + String::new() + }; let str = format!( "(ruleset {}) (ruleset {}) (ruleset {}) - (ruleset {})", + (ruleset {}){uf_clear}", self.proof_names().path_compress_ruleset_name, self.proof_names().rebuilding_ruleset_name, self.proof_names().rebuilding_cleanup_ruleset_name, - self.proof_names().delete_subsume_ruleset_name + self.proof_names().delete_subsume_ruleset_name, ); self.parse_program(&str) } @@ -275,6 +620,17 @@ impl ProofInstrumentor<'_> { self.egraph.proof_state.proofs_enabled } + /// The evaluation-mode option for a generated rule that reads the database + /// in its action: `:unsafe-seminaive`, or `:naive` (the safe whole-database + /// baseline) under the `force_proof_naive` test knob. + pub(crate) fn rhs_read_eval_opt(&self) -> &'static str { + if self.egraph.proof_state.force_proof_naive { + ":naive" + } else { + ":unsafe-seminaive" + } + } + /// Returns the proof output type: `Proof` when proofs are enabled, `Unit` otherwise. pub(crate) fn proof_type_str(&self) -> &str { if self.proofs_enabled() { @@ -351,12 +707,19 @@ impl ProofInstrumentor<'_> { } } + /// A fresh name for an encoder temporary. pub(crate) fn fresh_var(&mut self) -> String { - self.egraph.parser.symbol_gen.fresh("v") + // Keep this hint distinct from the `"v"` that `proofs::proof_normal_form` + // gives a rule's body variables. `SymbolGen` counts per hint, so sharing + // one would number body variables by how many temporaries the encoder + // happened to mint — and those names are printed in a proof's + // `substitution`, so minting one more proof node would rewrite unrelated + // proof output. + self.egraph.parser.symbol_gen.fresh("pv") } /// Header string for proof encoding, defining sorts and constructors. - /// Correspondings to [`RawProof`] in the Rust code. + /// Correspondings to `RawProof` in [`crate::proofs::proof_format`]. pub(crate) fn proof_header(&mut self) -> String { let mut to_ast_constructors = Vec::new(); // need to build a Ast{lit} for each lit sort in self @@ -385,11 +748,10 @@ impl ProofInstrumentor<'_> { let to_ast_str = to_ast_constructors.join("\n"); let EncodingNames { - ref proof_list_sort, ref ast_sort, ref proof_datatype, ref fiat_constructor, - ref rule_constructor, + ref rule_link_constructor, ref merge_fn_idx_constructor, ref merge_fn_row_constructor, ref eq_trans_constructor, @@ -398,32 +760,49 @@ impl ProofInstrumentor<'_> { ref congr_all_constructor, ref container_normalize_constructor, ref eval_constructor, - ref pcons, - ref pnil, .. } = *self.proof_names(); format!( " -(sort {proof_list_sort}) (sort {ast_sort}) ;; wrap sorts in this for proofs ;; The proof datatype records the global proof constructor names so container ;; rebuild can recover them on re-parse (see ContainerRebuildSpec). (sort {proof_datatype} :internal-proof-names {congr_constructor} {congr_all_constructor} {eq_trans_constructor} {eq_sym_constructor} {container_normalize_constructor} {fiat_constructor}) -;; Proof/AST/ProofList terms are relations, not constructors: the encoding mints -;; a fresh id (`get-fresh!`) and asserts the row, so congruent duplicates are -;; kept (never merged away) rather than relying on native congruence. The final -;; column of each relation is the minted output id. -(function {pcons} ({proof_datatype} {proof_list_sort} {proof_list_sort}) Unit :no-merge :internal-hidden :internal-term-node) -(function {pnil} ({proof_list_sort}) Unit :no-merge :internal-hidden :internal-term-node) +;; Proof/AST terms are relations, not constructors: the encoding mints a fresh id +;; (`get-fresh!`) and asserts the row, so congruent duplicates are kept (never +;; merged away) rather than relying on native congruence. The final column of each +;; relation is the minted output id. {to_ast_str} ;; Fiat justification for globals and primitives, gives two terms t1 = t2 for the proposition being justified (function {fiat_constructor} ({ast_sort} {ast_sort} {proof_datatype}) Unit :no-merge :internal-hidden :internal-term-node) -;; name of rule, one proof per fact in the query, proposition being proven t1 = t2 -(function {rule_constructor} (String {proof_list_sort} {ast_sort} {ast_sort} {proof_datatype}) Unit :no-merge :internal-hidden :internal-term-node) +;; A rule proof written before its head interns anything carries its premises +;; inline, in a `Rule_` declared per premise count (see `rule_arity_header`): +;; (Rule_ ) +;; Every rule proof after that names an earlier column's proof — which carries +;; the shared premises and the bridges recorded before it — plus the one *bridge* +;; premise recorded since: the view-row proof of the subterm the head interned, +;; saying which e-class it landed in. `` says which proof of the head's +;; lowering this is (see `proof_head`); proof conversion derives the proposition +;; from it, so no term is stored. The rule name is not repeated either: it is +;; read off the `Rule_` row ending the chain. +;; (RuleLink ) +(function {rule_link_constructor} ({proof_datatype} {proof_datatype} i64 {proof_datatype}) Unit :no-merge :internal-hidden :internal-term-node) + +;; A site with a fixed composition rather than a rule head — a top-level action, +;; a merge body, a view rebuild, a merge collision — writes one packed row +;; standing for the whole composition, in a `Packed_` declared per proof-column +;; count (see `packed_proof_constructor`). The first column spells the composition +;; over the rest, in prefix order: `sym`, `trans`, `congr`, `p` for the proof in +;; column n, and a bare number for a congruence's child position. So +;; (Packed_2 \"trans_sym_p0_p1\" ) +;; is the `@UF` edge a merge collision displaces, where both carried proofs share +;; their left-hand side, and +;; (Packed_2 \"congr_p0_3_p1\" ) +;; is a view rebuild that canonicalized child column 3. ;; term-free merge justification for an FD custom-function view subexpression: ;; name of function, two premise proofs, and the pre-order index of the merge-body @@ -495,6 +874,10 @@ pub fn file_supports_proofs_with_egraph(path: &Path, mut egraph: EGraph) -> bool pub enum ProofEncodingUnsupportedReason { #[error("primitive operation lacks a validator function")] PrimitiveWithoutValidator, + #[error( + "a declared index names a user function, which the term/proof encoding replaces with a view whose columns differ; the encoding does not yet rewrite index declarations" + )] + IndexDeclaration, #[error( "action contains a function lookup. Finding the output of a function is only supported in queries." )] @@ -678,6 +1061,12 @@ fn command_supports_proof_encoding_impl( { return Err(ProofEncodingUnsupportedReason::NaiveEqSortPrimitiveFact); } + // A declared index refers to a function's columns by position. The encoding + // rewrites that function into a view with a different shape, so the + // declaration would silently point at the wrong columns. + if matches!(command, crate::ast::GenericCommand::Index { .. }) { + return Err(ProofEncodingUnsupportedReason::IndexDeclaration); + } // Tuple-output functions store multiple value columns, which the term/proof encoding (built // around single-output constructor views) does not model. if let crate::ast::GenericCommand::Function { schema, .. } = command diff --git a/egglog/src/proofs/proof_encoding_rebuild.rs b/egglog/src/proofs/proof_encoding_rebuild.rs index de75eb7f..1c1fdedc 100644 --- a/egglog/src/proofs/proof_encoding_rebuild.rs +++ b/egglog/src/proofs/proof_encoding_rebuild.rs @@ -3,20 +3,24 @@ //! that execute requested deletes/subsumptions. (`@UF` path compression stays //! in [`super::proof_encoding`].) -use super::proof_encoding::ProofInstrumentor; +use super::proof_encoding::{ProofInstrumentor, ViewIndex}; +use super::proof_encoding_helpers::Skeleton; use crate::typechecking::FuncType; use crate::*; -/// Which FD-view value column [`ProofInstrumentor::fd_value_rebuild_rule`] rebuilds. -enum ValueRebuild { - /// The value is the term's e-class (constructors and globals). - Eclass, - /// A custom function's eq-sort output at child index `out_idx`. - CustomOutput { out_idx: usize }, - /// A custom function's eq-container output at child index `out_idx`, - /// canonicalized by the container rebuild primitive (containers have no - /// `@UF` to chase). - ContainerOutput { out_idx: usize }, +/// The composition a view rebuild's packed row states, over the columns +/// [`ProofInstrumentor::indexed_rebuild_rule`] writes: the row proof in column +/// 0, then one step proof per canonicalized child in `children`, in the order +/// the composition applies them, then the e-class's own step when `eclass`. +pub(super) fn rebuild_skeleton(children: &[usize], eclass: bool) -> Skeleton { + let mut skeleton = Skeleton::Leaf(0); + for (step, &child) in children.iter().enumerate() { + skeleton = skeleton.congr(child, Skeleton::Leaf(1 + step)); + } + if eclass { + skeleton = Skeleton::Leaf(1 + children.len()).sym().trans(skeleton); + } + skeleton } impl ProofInstrumentor<'_> { @@ -80,9 +84,8 @@ impl ProofInstrumentor<'_> { /// /// A child update re-keys the row (`set` at the canonicalized children, then /// `delete`); a collision on the new key runs the view's `:merge`. The value - /// column is canonicalized by [`Self::fd_value_rebuild_rule`]. In proof mode - /// each rule composes the updated view proof, and a container update records the - /// rebuilt container's `Proof`. + /// column is canonicalized by [`Self::fd_custom_value_rebuild_rule`]. In proof mode + /// each rule composes the updated view proof. pub(super) fn rebuilding_rules(&mut self, fdecl: &ResolvedFunctionDecl) -> Vec { let proofs = self.proofs_enabled(); // A global's output *is* its e-class (like a constructor's), so it takes the @@ -102,7 +105,9 @@ impl ProofInstrumentor<'_> { // One rule per rebuildable key column (re-keys the row via set + delete). for (i, ty) in types[..n_keys].iter().enumerate() { let is_container = ty.is_eq_container_sort(); - if !is_container && !ty.is_eq_sort() { + // Eq-sort children are handled by the index-driven rule below, which + // fixes the whole row in one firing. + if !is_container { continue; } let ci = child(i); @@ -111,17 +116,14 @@ impl ProofInstrumentor<'_> { // Canonicalize the column with the container rebuild primitive or a `@UF` // lookup, and build the proof pieces. Container-reading rules are `:naive` // (the primitive reads `@UF` tables the rule doesn't join on). - let (canon_fact, proof_lets, pf_arg, cproof_set) = if is_container { + let (canon_fact, proof_lets, pf_arg) = if is_container { let value_prim = self.container_rebuild_prim(ty); let canon_fact = format!("(= {canon} ({value_prim} {ci}))"); if proofs { let congr = self.proof_names().congr_constructor.clone(); - let trans = self.proof_names().eq_trans_constructor.clone(); - let sym = self.proof_names().eq_sym_constructor.clone(); let proof_sort = self.proof_sort(); let proof_prim = self.container_rebuild_proof_prim(ty); let rebuild_pf = self.fresh_var(); - let cproof = self.term_proof_name(ty.name()); // proof_lets: bind the container rebuild proof, then mint the congr proof. let mut lets = vec![format!("(let {rebuild_pf} ({proof_prim} {ci}))")]; let new_pf = self.mint( @@ -130,162 +132,245 @@ impl ProofInstrumentor<'_> { &format!("{view_prf} {i} {rebuild_pf}"), &proof_sort, ); - // cproof_set: mint (Sym rebuild_pf), (Trans .. rebuild_pf), then record it. - let mut cproof_stmts = vec![]; - let sym_pf = self.mint(&mut cproof_stmts, &sym, &rebuild_pf, &proof_sort); - let trans_pf = self.mint( - &mut cproof_stmts, - &trans, - &format!("{sym_pf} {rebuild_pf}"), - &proof_sort, - ); - cproof_stmts.push(format!("(set ({cproof} {canon}) {trans_pf})")); ( canon_fact, lets.join("\n "), new_pf, - cproof_stmts.join("\n "), ) } else { - (canon_fact, String::new(), "()".to_string(), String::new()) + (canon_fact, String::new(), "()".to_string()) } } else { - let uf_name = self.uf_name(ty.name()); - let uf_prf = self.fresh_var(); - let canon_fact = format!("(= (values {canon} {uf_prf}) ({uf_name} {ci}))"); - if proofs { - let congr = self.proof_names().congr_constructor.clone(); - let proof_sort = self.proof_sort(); - let mut lets = vec![]; - let new_pf = self.mint( - &mut lets, - &congr, - &format!("{view_prf} {i} {uf_prf}"), - &proof_sort, - ); - ( - canon_fact, - lets.join("\n "), - new_pf, - String::new(), - ) - } else { - (canon_fact, String::new(), "()".to_string(), String::new()) - } + unreachable!("non-container children take the index-driven rule") }; let mut updated = key_vars.clone(); updated[i] = canon.clone(); let updated_view = self.update_fd_view(&fdecl.name, &updated, &value_var, &pf_arg); let facts = format!("{query_view}\n{canon_fact}\n(!= {ci} {canon})"); - let actions = format!( - "{proof_lets}\n{updated_view}\n{cproof_set}\n(delete ({view_name} {keys_str}))" - ); + let actions = + format!("{proof_lets}\n{updated_view}\n(delete ({view_name} {keys_str}))"); rules.push_str(&self.rebuild_rule(&facts, &actions, is_container)); } - // FD view value column (see [`Self::fd_value_rebuild_rule`]). A - // constructor/global's value *is* its e-class; a custom function's - // eq-sort or eq-container output takes the delete-then-reinsert path. - // A base-sort custom output never goes stale, so nothing is emitted. + // FD view value column. A constructor/global's value *is* its e-class; a + // custom function's eq-sort or eq-container output takes the + // delete-then-reinsert path. A base-sort custom output never goes stale, + // so nothing is emitted. + for vi in self + .egraph + .proof_state + .view_index + .get(&fdecl.name) + .cloned() + .unwrap_or_default() + { + rules.push_str(&self.indexed_rebuild_rule(fdecl, &key_vars, &types, &vi)); + } if output_is_eclass { - rules.push_str(&self.fd_value_rebuild_rule(fdecl, &key_vars, ValueRebuild::Eclass)); + // Covered by the index rule above, which indexes the e-class column too. } else if fdecl.subtype == FunctionSubtype::Custom && !self.is_encoded_global(fdecl) { if types[n - 1].is_eq_sort() { - rules.push_str(&self.fd_value_rebuild_rule( - fdecl, - &key_vars, - ValueRebuild::CustomOutput { out_idx: n - 1 }, - )); + rules.push_str(&self.fd_custom_value_rebuild_rule(fdecl, &key_vars, n - 1)); } else if types[n - 1].is_eq_container_sort() { - rules.push_str(&self.fd_value_rebuild_rule( - fdecl, - &key_vars, - ValueRebuild::ContainerOutput { out_idx: n - 1 }, - )); + rules.push_str(&self.fd_container_value_rebuild_rule(fdecl, &key_vars, n - 1)); } } self.parse_program(&rules) } - /// One rule that canonicalizes an FD view's stale value column. + /// The rebuild rule for one child eq-sort, driven by an `@UF_` edge joined + /// against that sort's declared index. + /// + /// The index reaches every row mentioning the moved term — at any child + /// position or at the e-class — by lookup rather than by matching the view, + /// and its atom binds the whole row, so nothing else need be read. The action + /// then re-canonicalizes *every* eq-sort column with `uf_canon`, so one firing + /// yields the fully canonical row. Two children moving in the same iteration + /// therefore fire twice with the same result, rather than each producing a + /// differently half-rewritten row for a later pass to merge. /// - /// * [`ValueRebuild::Eclass`] (constructors/globals): the value *is* the - /// e-class, so re-`set` the same key and let the congruence `:merge` keep the - /// min. The row proof `canon = f(children)` is `Trans(Sym(key = leader), key = - /// f(children))`. - /// * [`ValueRebuild::CustomOutput`] (a custom function's eq-sort output): - /// `delete` the stale row first, so the re-`set` inserts without re-running - /// the user merge. The row proof rewrites the output child by `Congr` at its - /// position. - /// * [`ValueRebuild::ContainerOutput`] (a custom function's eq-container - /// output): like `CustomOutput`, but the value canonicalizes via the - /// container rebuild primitive (`:naive` — it reads `@UF` tables the rule - /// doesn't join on), and the rebuilt container gets a reflexive - /// `Proof` anchor for later rebuilds. - fn fd_value_rebuild_rule( + /// `uf_canon` reads `@UF_` in the action, which is what makes the rule + /// `:unsafe-seminaive` (or `:naive` under the test knob); the driving `@UF` + /// delta in the body is what makes that read sound. + /// + /// In proof mode a firing writes one + /// [`packed_proof`](super::proof_encoding_helpers::EncodingNames::packed_proof) + /// row, or none at all when nothing was canonicalized and the view's output + /// is not an e-class. + fn indexed_rebuild_rule( &mut self, fdecl: &ResolvedFunctionDecl, key_vars: &[String], - kind: ValueRebuild, + types: &[ArcSort], + vi: &ViewIndex, ) -> String { - if let ValueRebuild::ContainerOutput { out_idx } = kind { - return self.fd_container_value_rebuild_rule(fdecl, key_vars, out_idx); + use crate::proofs::proof_container_rebuild::{ + uf_canon_prim_name, uf_canon_proof_prim_name, + }; + let proofs = self.proofs_enabled(); + let view_name = self.view_name(&fdecl.name); + let keys_str = format!("{}", ListDisplay(key_vars, " ")); + let n_keys = key_vars.len(); + + let follower = self.fresh_var(); + let leader = self.fresh_var(); + let leader_pf = self.fresh_var(); + let eclass = format!("e{}_", n_keys); + let row_pf = self.fresh_var(); + let uf_name = self.uf_name(&vi.sort_name); + let uf_atom = format!("(= (values {leader} {leader_pf}) ({uf_name} {follower}))"); + // The index relation is `(value, children…, eclass, proof)`. + let index_atom = format!("({} {follower} {keys_str} {eclass} {row_pf})", vi.name); + + // Canonicalize every eq-sort column. A column that did not move + // canonicalizes to itself and its step is reflexive, which the proof + // simplifier drops. + let mut lets: Vec = Vec::new(); + let mut updated = key_vars.to_vec(); + // The child position and step proof of each canonicalized column, in + // ascending position — the order the composition applies them in. + let mut steps: Vec<(usize, String)> = Vec::new(); + for j in 0..n_keys { + if types[j].is_eq_container_sort() || !types[j].is_eq_sort() { + continue; + } + let cj = &key_vars[j]; + let uf_j = self.uf_name(types[j].name()); + let canon = format!("c{j}_canon_"); + lets.push(format!( + "(let {canon} ({} {cj} {cj}))", + uf_canon_prim_name(&uf_j) + )); + if proofs { + let term_proof = self.term_proof_name(types[j].name()); + let refl = self.fresh_var(); + let step = self.fresh_var(); + lets.push(format!("(let {refl} ({term_proof} {cj}))")); + lets.push(format!( + "(let {step} ({} {cj} {refl}))", + uf_canon_proof_prim_name(&uf_j) + )); + steps.push((j, step)); + } + updated[j] = canon; } + // Only an e-class is canonicalized here, and it moves the other way round + // from a child: the row proof reads `eclass = f(children)`, so a new leader + // composes as `Trans(Sym(eclass = leader), …)`. A custom function's value + // column is an ordinary output, not an e-class — that composition would be + // wrong for it, so it keeps [`Self::fd_custom_value_rebuild_rule`], which + // rewrites it by `Congr` at its position. + let out_ty = &types[n_keys]; + let mut eclass_step = None; + let value_var = if self.output_is_eclass(fdecl) + && out_ty.is_eq_sort() + && !out_ty.is_eq_container_sort() + { + let uf_out = self.uf_name(out_ty.name()); + let canon = format!("e{n_keys}_canon_"); + lets.push(format!( + "(let {canon} ({} {eclass} {eclass}))", + uf_canon_prim_name(&uf_out) + )); + if proofs { + let term_proof = self.term_proof_name(out_ty.name()); + let refl = self.fresh_var(); + let step = self.fresh_var(); + lets.push(format!("(let {refl} ({term_proof} {eclass}))")); + lets.push(format!( + "(let {step} ({} {eclass} {refl}))", + uf_canon_proof_prim_name(&uf_out) + )); + // The packed row takes the step as it stands: its expansion is + // what applies the `Sym`. + eclass_step = Some(step); + } + canon + } else { + eclass.clone() + }; + + // Lay the row out and state its composition together: the row proof, + // then each canonicalized column's step proof, then the e-class's own + // step. + let mut decls = String::new(); + let mut proof_acc = row_pf.clone(); + let children: Vec = steps.iter().map(|&(child, _)| child).collect(); + let skeleton = rebuild_skeleton(&children, eclass_step.is_some()); + let mut args = vec![row_pf.clone()]; + args.extend(steps.into_iter().map(|(_, step)| step)); + args.extend(eclass_step); + if args.len() > 1 { + let (packed, decl) = self.packed_proof_constructor(args.len()); + decls = decl; + let proof_sort = self.proof_sort(); + let row = format!("\"{}\" {}", skeleton.spelling(), args.join(" ")); + proof_acc = self.mint(&mut lets, &packed, &row, &proof_sort); + } + + let pf_arg = if proofs { proof_acc } else { "()".to_string() }; + let updated_view = self.update_fd_view(&fdecl.name, &updated, &value_var, &pf_arg); + let facts = format!("{uf_atom}\n(!= {follower} {leader})\n{index_atom}"); + // Delete before re-inserting. When only the e-class moved the canonical + // key equals the old one, so deleting afterwards would drop the row it + // just wrote; deleting first lets the insert win, as the custom-output + // value rebuild already does. + let actions = format!( + "{}\n(delete ({view_name} {keys_str}))\n{updated_view}", + lets.join("\n ") + ); + let ruleset = self.proof_names().rebuilding_ruleset_name.clone(); + let fresh_name = self.egraph.parser.symbol_gen.fresh("rebuild_rule"); + let eval_opt = self.rhs_read_eval_opt(); + format!( + "{decls}(rule ({facts})\n ({actions})\n :ruleset {ruleset} {eval_opt} :name \"{fresh_name}\" :internal-include-subsumed)\n" + ) + } + + /// One rule that canonicalizes a custom function's stale eq-sort output, at + /// child index `out_idx`: chase the output's `@UF` edge, `delete` the stale + /// row first so the re-`set` inserts without re-running the user merge, and in + /// proof mode rewrite the row proof's output child by `Congr` at that position. + /// + /// A view whose value *is* an e-class needs no rule of its own — the whole-row + /// rebuild canonicalizes that column too (see [`Self::indexed_rebuild_rule`]). + fn fd_custom_value_rebuild_rule( + &mut self, + fdecl: &ResolvedFunctionDecl, + key_vars: &[String], + out_idx: usize, + ) -> String { let value_uf_name = self.uf_name(fdecl.resolved_schema.output().name()); let (query_view, value_var, view_prf) = self.query_fd_view(&fdecl.name, key_vars); let canon = self.fresh_var(); let uf_prf = self.fresh_var(); let (proof_lets, pf_arg) = if self.proofs_enabled() { let proof_sort = self.proof_sort(); + let congr = self.proof_names().congr_constructor.clone(); let mut lets = vec![]; - let pf = match kind { - ValueRebuild::Eclass => { - let sym = self.proof_names().eq_sym_constructor.clone(); - let trans = self.proof_names().eq_trans_constructor.clone(); - let sym_pf = self.mint(&mut lets, &sym, &uf_prf, &proof_sort); - self.mint( - &mut lets, - &trans, - &format!("{sym_pf} {view_prf}"), - &proof_sort, - ) - } - ValueRebuild::CustomOutput { out_idx } => { - let congr = self.proof_names().congr_constructor.clone(); - self.mint( - &mut lets, - &congr, - &format!("{view_prf} {out_idx} {uf_prf}"), - &proof_sort, - ) - } - ValueRebuild::ContainerOutput { .. } => unreachable!("handled above"), - }; + let pf = self.mint( + &mut lets, + &congr, + &format!("{view_prf} {out_idx} {uf_prf}"), + &proof_sort, + ); (lets.join("\n "), pf) } else { (String::new(), "()".to_string()) }; let set_canon = self.update_fd_view(&fdecl.name, key_vars, &canon, &pf_arg); - let actions = match kind { - ValueRebuild::Eclass => format!("{proof_lets}\n{set_canon}"), - ValueRebuild::CustomOutput { .. } => { - let view_name = self.view_name(&fdecl.name); - let keys_str = ListDisplay(key_vars, " ").to_string(); - format!("{proof_lets}\n(delete ({view_name} {keys_str}))\n{set_canon}") - } - ValueRebuild::ContainerOutput { .. } => unreachable!("handled above"), - }; + let view_name = self.view_name(&fdecl.name); + let keys_str = ListDisplay(key_vars, " ").to_string(); + let actions = format!("{proof_lets}\n(delete ({view_name} {keys_str}))\n{set_canon}"); let facts = format!( "{query_view}\n(= (values {canon} {uf_prf}) ({value_uf_name} {value_var}))\n(!= {value_var} {canon})" ); self.rebuild_rule(&facts, &actions, false) } - /// The [`ValueRebuild::ContainerOutput`] arm of - /// [`Self::fd_value_rebuild_rule`]: canonicalize a custom function's - /// container-valued output with the container rebuild primitive, - /// delete-then-reinsert the row (dodging the user merge), and in proof mode - /// compose the row proof with a `Congr` at the output position and anchor - /// the rebuilt container's reflexive `Proof`. + /// [`Self::fd_custom_value_rebuild_rule`] for an eq-container output: + /// containers have no `@UF` to chase, so the value canonicalizes via the + /// container rebuild primitive (`:naive` — it reads `@UF` tables the rule + /// doesn't join on). fn fd_container_value_rebuild_rule( &mut self, fdecl: &ResolvedFunctionDecl, @@ -297,14 +382,11 @@ impl ProofInstrumentor<'_> { let (query_view, value_var, view_prf) = self.query_fd_view(&fdecl.name, key_vars); let canon = self.fresh_var(); let canon_fact = format!("(= {canon} ({value_prim} {value_var}))"); - let (proof_lets, pf_arg, cproof_set) = if self.proofs_enabled() { + let (proof_lets, pf_arg) = if self.proofs_enabled() { let congr = self.proof_names().congr_constructor.clone(); - let trans = self.proof_names().eq_trans_constructor.clone(); - let sym = self.proof_names().eq_sym_constructor.clone(); let proof_sort = self.proof_sort(); let proof_prim = self.container_rebuild_proof_prim(&out_ty); let rebuild_pf = self.fresh_var(); - let cproof = self.term_proof_name(out_ty.name()); let mut lets = vec![format!("(let {rebuild_pf} ({proof_prim} {value_var}))")]; let new_pf = self.mint( &mut lets, @@ -312,29 +394,15 @@ impl ProofInstrumentor<'_> { &format!("{view_prf} {out_idx} {rebuild_pf}"), &proof_sort, ); - let mut cproof_stmts = vec![]; - let sym_pf = self.mint(&mut cproof_stmts, &sym, &rebuild_pf, &proof_sort); - let trans_pf = self.mint( - &mut cproof_stmts, - &trans, - &format!("{sym_pf} {rebuild_pf}"), - &proof_sort, - ); - cproof_stmts.push(format!("(set ({cproof} {canon}) {trans_pf})")); - ( - lets.join("\n "), - new_pf, - cproof_stmts.join("\n "), - ) + (lets.join("\n "), new_pf) } else { - (String::new(), "()".to_string(), String::new()) + (String::new(), "()".to_string()) }; let set_canon = self.update_fd_view(&fdecl.name, key_vars, &canon, &pf_arg); let view_name = self.view_name(&fdecl.name); let keys_str = ListDisplay(key_vars, " ").to_string(); let facts = format!("{query_view}\n{canon_fact}\n(!= {value_var} {canon})"); - let actions = - format!("{proof_lets}\n(delete ({view_name} {keys_str}))\n{set_canon}\n{cproof_set}"); + let actions = format!("{proof_lets}\n(delete ({view_name} {keys_str}))\n{set_canon}"); self.rebuild_rule(&facts, &actions, true) } diff --git a/egglog/src/proofs/proof_extractor.rs b/egglog/src/proofs/proof_extractor.rs index 219d905e..4263a5cb 100644 --- a/egglog/src/proofs/proof_extractor.rs +++ b/egglog/src/proofs/proof_extractor.rs @@ -2,100 +2,187 @@ use crate::ast::FunctionSubtype; use crate::extract::find_canonical; use crate::termdag::{TermDag, TermId}; use crate::util::{HashMap, HashSet}; -use crate::{ArcSort, EGraph, Value}; +use crate::{ArcSort, EGraph, Function, Value}; use egglog_backend_trait::BackendExt; +use std::ops::Range; + +/// A node of the search: a value together with the sort it is reconstructed at. +type Key = (Value, String); /// Root-directed extraction for proof terms. /// /// Unlike the public extractor, this does not compute globally optimal costs /// for the whole e-graph. It searches for any reconstructable term for the /// requested root, ignoring `:unextractable` and hidden constructor flags, and -/// skips view tables so proof terms use their original constructor names. +/// skips view tables so proof terms use their original constructor names. A term +/// of any depth extracts without overflowing the stack. struct RootExtractor { - cache: HashMap<(Value, String), Option>, - active: HashSet<(Value, String)>, + cache: HashMap>, + active: HashSet, + /// Every function the search has read so far, keyed by its index in + /// `EGraph::functions`. Scoped to one extraction run. + scanned: HashMap, } -impl RootExtractor { - fn new() -> Self { - Self { - cache: Default::default(), - active: Default::default(), - } - } +/// What the frame on top of the stack needs from the driver. +enum Step { + /// This child's term, before the frame can continue. + Need(Value, ArcSort), + /// Nothing more; the frame's answer is `None` when its node has no term. + Done(Option), +} - fn extract( - &mut self, - egraph: &EGraph, - termdag: &mut TermDag, - value: Value, - sort: &ArcSort, - ) -> Option { - let key = (value, sort.name().to_owned()); - if let Some(term) = self.cache.get(&key) { - return *term; - } - if !self.active.insert(key.clone()) { - return None; - } +/// How to start resolving a node. +enum Begin { + /// Nothing is known yet; push this frame. + Push(Frame), + /// The cache, or a cycle back to a frame already on the stack, settles it. + Settled(Option), +} + +/// One suspended node of the search. +struct Frame { + key: Key, + value: Value, + sort: ArcSort, + stage: Stage, +} + +/// How far a frame has got through the ways to reconstruct its node: the exact +/// reconstruction first, then the canonical representative's term. +enum Stage { + /// Nothing tried yet. + Start, + /// Reconstructing a container from its elements, in order. + Container { + elements: Vec<(ArcSort, Value)>, + children: Vec, + }, + /// Reconstructing an e-class from a table row. + Eq(EqStage), + /// Waiting on the canonical representative's term, which is the answer. + Canonical, +} - let mut term = self.extract_exact(egraph, termdag, value, sort); - if term.is_none() { - let canonical = find_canonical(egraph, value, sort); - if canonical != value { - term = self.extract(egraph, termdag, canonical, sort); +/// The eq-sort search: functions in declaration order, and within a function its +/// matching rows in lexicographic order. +struct EqStage { + /// Index into `egraph.functions` of the first function not yet scanned. + unscanned: usize, + /// The function `rows` and `row` come from. + func: usize, + /// Positions in that function's [`FunctionRows`] ordering, holding the rows + /// that match the node's value and have not been tried yet. + rows: Range, + /// The row being reconstructed, with the child terms resolved so far. + row: Option<(Vec, Vec)>, +} + +/// The non-subsumed rows of one function, grouped by the value in its +/// extraction output column. +/// +/// A group's rows are in lexicographic order, so which row a search picks does +/// not depend on the backend's row iteration order — see `prove_exists`. +struct FunctionRows { + /// Row width; zero when the function has no rows. + arity: usize, + /// The rows, concatenated in the order the backend yielded them. + vals: Vec, + /// Row numbers into `vals`, ordered by output value and then by row + /// contents, so each group is a contiguous run. + order: Vec, + /// Where each output value's group sits in `order`. + groups: HashMap>, +} + +impl FunctionRows { + fn build(egraph: &EGraph, func: &Function) -> Self { + let output_idx = func.extraction_output_index(); + let mut arity = 0; + let mut vals = Vec::new(); + egraph + .backend + .for_each(func.backend_id, |row: egglog_bridge::ScanEntry| { + if !row.subsumed { + arity = row.vals.len(); + vals.extend_from_slice(row.vals); + } + }); + vals.shrink_to_fit(); + + let count = if arity == 0 { 0 } else { vals.len() / arity }; + let mut order: Vec = (0..count as u32).collect(); + order.sort_unstable_by(|&a, &b| { + let a = &vals[a as usize * arity..][..arity]; + let b = &vals[b as usize * arity..][..arity]; + // Group by output value; order a group lexicographically by whole row. + a[output_idx].cmp(&b[output_idx]).then_with(|| a.cmp(b)) + }); + + let mut groups: HashMap> = HashMap::default(); + let mut start = 0; + while start < order.len() { + let value = vals[order[start] as usize * arity + output_idx]; + let mut end = start + 1; + while end < order.len() && vals[order[end] as usize * arity + output_idx] == value { + end += 1; } + groups.insert(value, start..end); + start = end; } - self.active.remove(&key); - self.cache.insert(key, term); - term + Self { + arity, + vals, + order, + groups, + } } - fn extract_exact( - &mut self, - egraph: &EGraph, - termdag: &mut TermDag, - value: Value, - sort: &ArcSort, - ) -> Option { - if sort.is_container_sort() { - self.extract_container(egraph, termdag, value, sort) - } else if sort.is_eq_sort() { - self.extract_eq(egraph, termdag, value, sort) - } else { - Some(sort.reconstruct_termdag_base(egraph.backend.base_values(), value, termdag)) - } + /// The row at `position` in the ordering. + fn row(&self, position: usize) -> &[Value] { + let start = self.order[position] as usize * self.arity; + &self.vals[start..start + self.arity] } - fn extract_container( - &mut self, - egraph: &EGraph, - termdag: &mut TermDag, - value: Value, - sort: &ArcSort, - ) -> Option { - let elements = sort.inner_values(egraph.backend.container_values(), value); - let mut ch_terms = Vec::with_capacity(elements.len()); - for (sort, value) in elements { - ch_terms.push(self.extract(egraph, termdag, value, &sort)?); + /// Where the rows holding `value` in the output column sit in the ordering. + fn group(&self, value: Value) -> Range { + self.groups.get(&value).cloned().unwrap_or(0..0) + } +} + +impl EqStage { + fn new() -> Self { + Self { + unscanned: 0, + func: 0, + rows: 0..0, + row: None, } - Some(sort.reconstruct_termdag_container( - egraph.backend.container_values(), - value, - termdag, - ch_terms, - )) } - fn extract_eq( + /// The next row to try for `value` at `sort`, moving on to further functions + /// once the current one's matching rows run out. + /// + /// Rows come back lexicographically smallest first (see [`FunctionRows`]). + fn next_row( &mut self, egraph: &EGraph, - termdag: &mut TermDag, + scanned: &mut HashMap, value: Value, sort: &ArcSort, - ) -> Option { - for func in egraph.functions.values() { + ) -> Option> { + loop { + if let Some(position) = self.rows.next() { + let rows = scanned + .get(&self.func) + .expect("the function whose rows are being tried was scanned"); + return Some(rows.row(position).to_vec()); + } + + let (_, func) = egraph.functions.get_index(self.unscanned)?; + self.func = self.unscanned; + self.unscanned += 1; // Term/proof relations (function-to-Unit, id in the last input) and // ordinary constructors both reconstruct here; views and the // delete/subsume markers (`is_relation_term` is false for markers) are @@ -107,41 +194,184 @@ impl RootExtractor { continue; } - let output_idx = func.extraction_output_index(); - let mut matching_rows = Vec::new(); - egraph - .backend - .for_each(func.backend_id, |row: egglog_bridge::ScanEntry| { - if !row.subsumed && row.vals[output_idx] == value { - matching_rows.push(row.vals.to_vec()); + self.rows = scanned + .entry(self.func) + .or_insert_with(|| FunctionRows::build(egraph, func)) + .group(value); + } + } +} + +impl Frame { + /// Take this node as far as it can go, given the term its last requested + /// child produced (`None` on the frame's first turn). + fn advance( + &mut self, + egraph: &EGraph, + scanned: &mut HashMap, + termdag: &mut TermDag, + resumed: Option>, + ) -> Step { + let mut child = resumed; + loop { + match &mut self.stage { + Stage::Start => { + if self.sort.is_container_sort() { + self.stage = Stage::Container { + elements: self + .sort + .inner_values(egraph.backend.container_values(), self.value), + children: Vec::new(), + }; + } else if self.sort.is_eq_sort() { + self.stage = Stage::Eq(EqStage::new()); + } else { + return Step::Done(Some(self.sort.reconstruct_termdag_base( + egraph.backend.base_values(), + self.value, + termdag, + ))); + } + } + // An element with no term sinks the whole container. + Stage::Container { .. } if matches!(child, Some(None)) => { + return self.fall_back(egraph); + } + Stage::Container { elements, children } => { + if let Some(Some(term)) = child.take() { + children.push(term); + } + return match elements.get(children.len()) { + Some((sort, value)) => Step::Need(*value, sort.clone()), + None => Step::Done(Some(self.sort.reconstruct_termdag_container( + egraph.backend.container_values(), + self.value, + termdag, + std::mem::take(children), + ))), + }; + } + Stage::Eq(eq) => { + match child.take() { + // A child with no term rules the row out; try the next. + Some(None) => eq.row = None, + Some(Some(term)) => { + eq.row + .as_mut() + .expect("a resolved child belongs to the pending row") + .1 + .push(term); + } + None => {} } - }); - // Reconstruct from the lexicographically-smallest matching row so the - // chosen term does not depend on the backend's (possibly nondeterministic) - // row iteration order — see `prove_exists`. - matching_rows.sort(); - - for row in matching_rows { - let num_children = func.extraction_num_children(); - let mut ch_terms = Vec::with_capacity(num_children); - let mut valid = true; - for (value, sort) in row.iter().take(num_children).zip(func.schema.input.iter()) { - match self.extract(egraph, termdag, *value, sort) { - Some(term) => ch_terms.push(term), - None => { - valid = false; - break; + match &mut eq.row { + Some((row, children)) => { + let (_, func) = egraph + .functions + .get_index(eq.func) + .expect("the pending row's function"); + return if children.len() == func.extraction_num_children() { + Step::Done(Some(termdag.app( + func.extraction_term_name().to_string(), + std::mem::take(children), + ))) + } else { + let index = children.len(); + Step::Need(row[index], func.schema.input[index].clone()) + }; } + None => match eq.next_row(egraph, scanned, self.value, &self.sort) { + Some(row) => eq.row = Some((row, Vec::new())), + None => return self.fall_back(egraph), + }, } } - - if valid { - return Some(termdag.app(func.extraction_term_name().to_string(), ch_terms)); + Stage::Canonical => { + return Step::Done( + child + .take() + .expect("the canonical representative was resolved"), + ); } } } + } - None + /// Give up on reconstructing this node exactly and wait on the canonical + /// representative's term instead, or report no term when the node already is + /// the representative. + fn fall_back(&mut self, egraph: &EGraph) -> Step { + let canonical = find_canonical(egraph, self.value, &self.sort); + if canonical == self.value { + return Step::Done(None); + } + self.stage = Stage::Canonical; + Step::Need(canonical, self.sort.clone()) + } +} + +impl RootExtractor { + fn new() -> Self { + Self { + cache: Default::default(), + active: Default::default(), + scanned: Default::default(), + } + } + + /// Open a node, marking it in progress so a cycle back to it resolves to + /// `None` instead of spinning. + fn begin(&mut self, value: Value, sort: ArcSort) -> Begin { + let key = (value, sort.name().to_owned()); + if let Some(term) = self.cache.get(&key) { + return Begin::Settled(*term); + } + if !self.active.insert(key.clone()) { + return Begin::Settled(None); + } + Begin::Push(Frame { + key, + value, + sort, + stage: Stage::Start, + }) + } + + fn extract( + &mut self, + egraph: &EGraph, + termdag: &mut TermDag, + value: Value, + sort: &ArcSort, + ) -> Option { + let mut stack = match self.begin(value, sort.clone()) { + Begin::Push(frame) => vec![frame], + Begin::Settled(term) => return term, + }; + // The term the frame on top of the stack asked for, once it is known. + let mut resumed = None; + + loop { + let step = stack + .last_mut() + .expect("the loop returns as soon as the stack empties") + .advance(egraph, &mut self.scanned, termdag, resumed.take()); + match step { + Step::Need(value, sort) => match self.begin(value, sort) { + Begin::Push(frame) => stack.push(frame), + Begin::Settled(term) => resumed = Some(term), + }, + Step::Done(term) => { + let frame = stack.pop().expect("the frame that just advanced"); + self.active.remove(&frame.key); + self.cache.insert(frame.key, term); + if stack.is_empty() { + return term; + } + resumed = Some(term); + } + } + } } } diff --git a/egglog/src/proofs/proof_format.rs b/egglog/src/proofs/proof_format.rs index 0e780dbd..4dec0da5 100644 --- a/egglog/src/proofs/proof_format.rs +++ b/egglog/src/proofs/proof_format.rs @@ -1,18 +1,22 @@ use crate::{ ResolvedCall, Term, TermDag, TermId, - ast::{FunctionSubtype, GenericNCommand, ResolvedExpr, ResolvedFact, ResolvedNCommand}, + ast::{ + FunctionSubtype, GenericNCommand, ResolvedExpr, ResolvedFact, ResolvedNCommand, + ResolvedRule, + }, proofs::{ proof_checker::{ ProofCheckError, ProofCheckErrorKind, eval_expr_with_subst, gather_globals, run_merge, }, - proof_encoding_helpers::EncodingNames, + proof_encoding_helpers::{EncodingNames, Skeleton}, + proof_head::{Firing, HeadPlan, HeadWalk, ProofAlgebra}, }, typechecking::{FuncType, PrimitiveValidator}, - util::{HEntry, HashMap, HashSet, IndexSet, SymbolGen}, + util::{HashMap, HashSet, IEntry, IndexMap, IndexSet, SymbolGen}, }; use egglog_ast::generic_ast::Literal; use egglog_numeric_id::{DenseIdMap, NumericId, define_id}; -use std::fmt; +use std::{fmt, rc::Rc}; define_id!( RawProofId, @@ -94,16 +98,32 @@ fn run_merge_subexpr( .into()) } +/// A rule proof's columns, gathered across the chain of head proofs it is the +/// last of. +struct RuleColumns { + name: String, + /// One per premise the encoder recorded: the rule's written body facts, in + /// order, then a lookup per global the head mentions. + premises: Vec, + /// One per subterm the head interned before this row's proof, in construction + /// order. + bridges: Vec, + /// Which of the head's proofs the row asked about states, as an `i64` term + /// (see [`crate::proofs::proof_head`]); the rows further down the chain state + /// proofs from earlier in the head's walk. + column: TermId, +} + /// A proof straight from the e-graph, not exposed to users. struct RawProofStore { term_dag: TermDag, /// The proof constructor names, used to recognize each extracted proof /// term's head by exact match (rather than substring guessing). names: EncodingNames, - /// Bidirectional map between proof terms and their ids. + /// The proofs parsed so far, hash-consed: a [`RawProofId`] is an index into + /// it, and equal ids mean equal trees. store: IndexSet, term_to_proof: HashMap, - proof_to_term: HashMap, } pub(crate) fn proof_store_from_term( @@ -134,9 +154,16 @@ pub(crate) fn proof_store_from_term( enum RawProof { /// Equalities added at the top level are justified by fiat. Fiat(TermId, TermId), - /// Given a rule name and proofs for each premise, produces a proof of a grounded equality t1 = t2 from the body of the rule. - /// The subsitution is implicit- in [`ProofTerm`] they are explicit. - Rule(String, Vec, TermId, TermId), + /// Given a rule name and proofs for each premise, produces a proof of a + /// grounded equality from the head of the rule. The substitution is implicit — + /// in [`Justification::Rule`] it is explicit. + /// + /// The second list holds one *bridge* premise per subterm the head interned — + /// the view-row proof that says which e-class it landed in — in construction + /// order. The column names which proof of the head's lowering this is (see + /// [`crate::proofs::proof_head`]); conversion derives the equality from that + /// and the bridges, so the row stores no terms. + Rule(String, Vec, Vec, i64), /// A term-free merge proof: given proofs `f(…, old) = f(…, old)` and /// `f(…, new) = f(…, new)`, the index `idx` identifies which subexpression of the /// merge body this justifies (a pre-order index over the body tree). The @@ -191,6 +218,27 @@ pub struct ProofStore { /// these heads over literals is a self-evident value, so the checker accepts a /// reflexive `Fiat` over it ([`ProofStore::reflexive_value_term`]). pub(super) prim_value_constructors: HashSet, + /// Rule name -> where the rule sits in the program being checked. + rule_at: HashMap, + /// Rule name -> how its head lowers. + head_plans: HashMap>, + /// (Rule name, body premises) -> how far that firing's head has been walked. + /// Every rule proof of one firing reads a column out of the array that one + /// walk fills. + head_walks: HashMap<(String, Vec), HeadWalk>, + /// Structural sharing for the proofs conversion synthesizes. + synthesized: HashMap, +} + +/// What a synthesized proof is, for sharing one node per distinct value. +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) enum SynthKey { + /// A rule head's own conclusion: the rule, which of its proofs, and the + /// premises that fix the substitution. + Rule(String, i64, Vec), + Sym(ProofId), + Trans(ProofId, ProofId), + Congr(ProofId, usize, ProofId), } impl fmt::Debug for ProofStore { @@ -247,9 +295,10 @@ pub struct Proof { /// Some justifications are axioms of egglog, like Sym, Trans, and Congr. /// Other justifications are based on user input, like Fiat, Rule, and MergeFn. /// -/// Compared to [`RawProof`], a [`Justification`] is always paired with the [`Proposition`] being proven (in a [`Proof`]). +/// Compared to the crate-internal `RawProof`, a [`Justification`] is always paired with the +/// [`Proposition`] being proven (in a [`Proof`]). /// Additionally, [`Justification::Rule`] includes the explicit substitution mapping variable names to terms, -/// while [`RawProof::Rule`] leaves this implicit. +/// while `RawProof::Rule` leaves this implicit. #[derive(Clone, Debug)] pub enum Justification { /// Equalities added at the top level are justified by fiat. @@ -258,8 +307,8 @@ pub enum Justification { Fiat, /// Proves a grounded equality `t1 = t2` which appears /// in the body of a rule given a substitution given proofs - /// for each premise ([`Fact`]) of the rule. - /// If the [`Propostion`] proven is a term like `t = t`, + /// for each premise ([`Fact`](crate::ast::Fact)) of the rule. + /// If the [`Proposition`] proven is a term like `t = t`, /// t may be a subexpression of the body of the rule under the substitution. /// /// A proof for a premise is an equality t1 = t2 that matches the premise under some substitution. @@ -268,7 +317,8 @@ pub enum Justification { Rule { name: String, premise_proofs: Vec, - substitution: HashMap, + /// Ordered by where each variable first occurs in the rule body. + substitution: IndexMap, }, /// Given two proofs f(c1, c2, ..., old) = f(c1, c2, ..., old) and f(c1, c2, ..., new) = f(c1, c2, ..., new), /// proves either: @@ -312,6 +362,20 @@ pub enum Justification { Eval, } +/// The [`RawProof`] a constructor states, over its arguments and its +/// already-parsed nested proofs. +type BuildProof = fn(&RawProofStore, &[TermId], &[RawProofId]) -> RawProof; + +/// How one proof constructor is read: how many arguments it takes, which of +/// them are nested proofs — in the order they are parsed — and what proof it +/// states over them. The rule and packed-row constructors are variadic and are +/// not described here; both readers special-case them. +struct ProofShape { + arity: usize, + children: &'static [usize], + build: BuildProof, +} + impl RawProofStore { /// After extracting a proof from the e-graph, convert it to a [`RawProof`]. pub(crate) fn from_extracted( @@ -324,12 +388,150 @@ impl RawProofStore { names: encoding_names.clone(), store: IndexSet::default(), term_to_proof: HashMap::default(), - proof_to_term: HashMap::default(), }; + store.parse_nested_first(term); let parsed = store.parse_proof(term); (store, parsed) } + /// Parse the proofs nested in `term_id` deepest first, on an explicit stack, + /// visiting them in the order [`Self::parse_proof`] would. It then finds each + /// one already parsed, so a deep proof does not need a deep call stack. + fn parse_nested_first(&mut self, term_id: TermId) { + // `false` means the term's nested proofs still have to be pushed. + let mut stack = vec![(term_id, false)]; + let mut seen: HashSet = HashSet::default(); + while let Some((id, nested_pushed)) = stack.pop() { + if nested_pushed { + self.parse_proof(id); + continue; + } + if !seen.insert(id) { + continue; + } + stack.push((id, true)); + // Reversed, so popping visits them in `parse_proof_inner`'s order. + stack.extend( + self.nested_proofs(id) + .into_iter() + .rev() + .map(|nested| (nested, false)), + ); + } + } + + /// The proof terms [`Self::parse_proof_inner`] recurses into, in order. A + /// malformed term reports none, leaving the diagnostic to the parse itself. + fn nested_proofs(&self, term_id: TermId) -> Vec { + let Term::App(head, args) = self.term_dag.get(term_id) else { + return vec![]; + }; + let names = &self.names; + if names.fused_rule_arity(head).is_some() || *head == names.rule_link_constructor { + let RuleColumns { + premises, bridges, .. + } = self.rule_columns(term_id); + return premises.into_iter().chain(bridges).collect(); + } + if let Some(columns) = names.packed_proof_columns(head) + && args.len() == columns + 1 + { + return args[1..].to_vec(); + } + self.shape(head) + .filter(|shape| shape.arity == args.len()) + .map_or(vec![], |shape| { + shape.children.iter().map(|&at| args[at]).collect() + }) + } + + /// How the proof constructor `head` names is read, or `None` when `head` + /// names none. + fn shape(&self, head: &str) -> Option { + let names = &self.names; + let shape = |arity, children: &'static [usize], build: BuildProof| { + Some(ProofShape { + arity, + children, + build, + }) + }; + if head == names.fiat_constructor { + shape(2, &[], |_, args, _| RawProof::Fiat(args[0], args[1])) + } else if head == names.merge_fn_idx_constructor { + shape(4, &[1, 2], |store, args, kids| { + let function = store.parse_string(args[0]); + RawProof::MergeFnIdx(function, kids[0], kids[1], store.parse_index(args[3])) + }) + } else if head == names.merge_fn_row_constructor { + shape(3, &[1, 2], |store, args, kids| { + RawProof::MergeFnRow(store.parse_string(args[0]), kids[0], kids[1]) + }) + } else if head == names.eq_trans_constructor { + shape(2, &[0, 1], |_, _, kids| RawProof::Trans(kids[0], kids[1])) + } else if head == names.eq_sym_constructor { + shape(1, &[0], |_, _, kids| RawProof::Sym(kids[0])) + } else if head == names.container_normalize_constructor { + shape(1, &[0], |_, _, kids| RawProof::ContainerNormalize(kids[0])) + } else if head == names.congr_constructor { + shape(3, &[0, 2], |store, args, kids| { + RawProof::Congr(kids[0], store.parse_index(args[1]), kids[1]) + }) + } else if head == names.congr_all_constructor { + shape(2, &[0, 1], |_, _, kids| { + RawProof::CongrAll(kids[0], kids[1]) + }) + } else if head == names.eval_constructor { + shape(0, &[], |_, _, _| RawProof::Eval) + } else { + None + } + } + + /// Read a rule proof's columns, walking the chain of links a head adds one of + /// per proof it composes. + /// + /// The premises and the bridges are told apart structurally rather than by + /// counting: the row ending the chain carries every premise inline, and each + /// link adds exactly one bridge. The column returned is the outermost row's, + /// read at the index that row's own asserted arity fixes. + fn rule_columns(&self, term_id: TermId) -> RuleColumns { + let mut bridges = vec![]; + let mut column = None; + let mut cell = term_id; + loop { + let Term::App(head, args) = self.term_dag.get(cell) else { + panic!("expected a rule proof term. Proof parsing assumes valid proofs."); + }; + if *head == self.names.rule_link_constructor { + assert!(args.len() == 3, "{head} should have 3 args"); + column.get_or_insert(args[2]); + bridges.push(args[1]); + cell = args[0]; + continue; + } + let Some(arity) = self.names.fused_rule_arity(head) else { + panic!( + "expected a rule proof constructor, got {head}. Proof parsing assumes valid proofs." + ); + }; + assert!( + args.len() == arity + 2, + "{head} should have {} args", + arity + 2 + ); + // Recorded newest first, since a subterm's view-row proof is only + // readable once the subterm is interned. + bridges.reverse(); + return RuleColumns { + name: self.parse_string(args[0]), + premises: args[1..arity + 1].to_vec(), + bridges, + column: *column.get_or_insert(args[arity + 1]), + }; + } + } + fn parse_proof(&mut self, term_id: TermId) -> RawProofId { if let Some(&proof_id) = self.term_to_proof.get(&term_id) { return proof_id; @@ -337,10 +539,38 @@ impl RawProofStore { let proof_id = self.parse_proof_inner(term_id); self.term_to_proof.insert(term_id, proof_id); - self.proof_to_term.insert(proof_id, term_id); proof_id } + /// [`Self::parse_proof`] for one of the term's own nested proofs, which + /// [`Self::parse_nested_first`] has already parsed. A child that pre-pass + /// misses recurses from here instead, at the call depth it exists to avoid. + fn child_proof(&mut self, term_id: TermId) -> RawProofId { + debug_assert!( + self.term_to_proof.contains_key(&term_id), + "proof child {term_id:?} was not visited before its parent" + ); + self.parse_proof(term_id) + } + + /// The composition `skeleton` states, over the proof columns `columns` of a + /// packed row. + fn instantiate(&mut self, skeleton: &Skeleton, columns: &[TermId]) -> RawProofId { + let proof = match skeleton { + Skeleton::Leaf(column) => return self.child_proof(columns[*column]), + Skeleton::Sym(inner) => RawProof::Sym(self.instantiate(inner, columns)), + Skeleton::Trans(left, right) => { + let left = self.instantiate(left, columns); + RawProof::Trans(left, self.instantiate(right, columns)) + } + Skeleton::Congr(base, child, step) => { + let base = self.instantiate(base, columns); + RawProof::Congr(base, *child, self.instantiate(step, columns)) + } + }; + self.add_proof(proof) + } + fn parse_proof_inner(&mut self, term_id: TermId) -> RawProofId { let term = self.term_dag.get(term_id).clone(); let Term::App(head, args) = term else { @@ -349,89 +579,51 @@ impl RawProofStore { ); }; - let proof = if head == self.names.fiat_constructor { - assert!(args.len() == 2, "fiat constructor should have 2 args"); - RawProof::Fiat(args[0], args[1]) - } else if head == self.names.rule_constructor { - assert!(args.len() == 4, "rule constructor should have 4 args"); - let name = self.parse_string(args[0]); - let premises = self.parse_proof_list(args[1]); - RawProof::Rule(name, premises, args[2], args[3]) - } else if head == self.names.merge_fn_idx_constructor { - assert!(args.len() == 4, "merge-idx constructor should have 4 args"); - let function = self.parse_string(args[0]); - let old_proof = self.parse_proof(args[1]); - let new_proof = self.parse_proof(args[2]); - let idx = self.parse_index(args[3]); - RawProof::MergeFnIdx(function, old_proof, new_proof, idx) - } else if head == self.names.merge_fn_row_constructor { - assert!(args.len() == 3, "merge-row constructor should have 3 args"); - let function = self.parse_string(args[0]); - let old_proof = self.parse_proof(args[1]); - let new_proof = self.parse_proof(args[2]); - RawProof::MergeFnRow(function, old_proof, new_proof) - } else if head == self.names.eq_trans_constructor { - assert!(args.len() == 2, "trans constructor should have 2 args"); - let left = self.parse_proof(args[0]); - let right = self.parse_proof(args[1]); - RawProof::Trans(left, right) - } else if head == self.names.eq_sym_constructor { - assert!(args.len() == 1, "sym constructor should have 1 arg"); - let inner = self.parse_proof(args[0]); - RawProof::Sym(inner) - } else if head == self.names.container_normalize_constructor { + if let Some(columns) = self.names.packed_proof_columns(&head) { assert!( - args.len() == 1, - "container-normalize constructor should have 1 arg" + args.len() == columns + 1, + "{head} should have {} args", + columns + 1 ); - let inner = self.parse_proof(args[0]); - RawProof::ContainerNormalize(inner) - } else if head == self.names.congr_constructor { - assert!(args.len() == 3, "congr constructor should have 3 args"); - let proof = self.parse_proof(args[0]); - let child_index = self.parse_index(args[1]); - let child_proof = self.parse_proof(args[2]); - RawProof::Congr(proof, child_index, child_proof) - } else if head == self.names.congr_all_constructor { - assert!(args.len() == 2, "congr-all constructor should have 2 args"); - let proof = self.parse_proof(args[0]); - let child_proof = self.parse_proof(args[1]); - RawProof::CongrAll(proof, child_proof) - } else if head == self.names.eval_constructor { - assert!(args.is_empty(), "eval constructor should have no args"); - RawProof::Eval - } else { - panic!("Unrecognized proof term head: {head}. Proof parsing assumes valid proofs."); - }; - - self.add_proof(proof) - } + let spelling = self.parse_string(args[0]); + let skeleton = Skeleton::from_spelling(&spelling) + .filter(|skeleton| skeleton.width() == columns) + .unwrap_or_else(|| { + panic!("{spelling} is not a composition over {head}'s {columns} columns") + }); + return self.instantiate(&skeleton, &args[1..]); + } - fn parse_proof_list(&mut self, list_term: TermId) -> Vec { - let term = self.term_dag.get(list_term).clone(); - match term { - Term::App(head, args) => { - if head == self.names.pnil { - assert!(args.is_empty(), "pnil should not have arguments"); - Vec::new() - } else if head == self.names.pcons { - assert!(args.len() == 2, "pcons should have 2 arguments"); - let head_proof = self.parse_proof(args[0]); - let rest = self.parse_proof_list(args[1]); - let mut list = Vec::with_capacity(rest.len() + 1); - list.push(head_proof); - list.extend(rest); - list - } else { - panic!( - "expected proof list constructor, got {head}. Proof parsing assumes valid proofs." - ); - } - } - other => { - panic!("expected proof list, got {other:?}. Proof parsing assumes valid proofs.") - } + if self.names.fused_rule_arity(&head).is_some() || head == self.names.rule_link_constructor + { + let RuleColumns { + name, + premises, + bridges, + column, + } = self.rule_columns(term_id); + let premises = premises.iter().map(|arg| self.child_proof(*arg)).collect(); + let bridges = bridges.iter().map(|arg| self.child_proof(*arg)).collect(); + let column = self.parse_int(column); + return self.add_proof(RawProof::Rule(name, premises, bridges, column)); } + + let shape = self.shape(&head).unwrap_or_else(|| { + panic!("Unrecognized proof term head: {head}. Proof parsing assumes valid proofs.") + }); + assert!( + args.len() == shape.arity, + "{head} should have {} args", + shape.arity + ); + let children: Vec = shape + .children + .iter() + .map(|&at| self.child_proof(args[at])) + .collect(); + let proof = (shape.build)(self, &args, &children); + + self.add_proof(proof) } fn parse_string(&self, term_id: TermId) -> String { @@ -452,6 +644,13 @@ impl RawProofStore { } } + fn parse_int(&self, term_id: TermId) -> i64 { + match self.term_dag.get(term_id) { + Term::Lit(Literal::Int(i)) => *i, + other => panic!("expected integer literal in proof term, got {other:?}"), + } + } + fn add_proof(&mut self, proof: RawProof) -> RawProofId { if let Some(id) = self.store.get_index_of(&proof) { return RawProofId::from_usize(id); @@ -474,13 +673,13 @@ impl RawProofStore { } } -/// True iff `fact` is a custom-function application fact `(= (f args) v)` (either -/// argument order), for which the checker's proof normal form expects a *reflexive* -/// premise proof. Constructor and plain equality facts are excluded. +/// True iff `fact` is a custom-function application fact `(= (f args) v)`, for +/// which the checker's proof normal form expects a *reflexive* premise proof. +/// Constructor and plain equality facts are excluded. Proof normal form always +/// writes the call on the left (see [`crate::proofs::proof_normal_form`]). fn is_custom_func_fact(fact: &ResolvedFact) -> bool { let call = match fact { - ResolvedFact::Eq(_, ResolvedExpr::Call(_, c, _), ResolvedExpr::Var(..)) - | ResolvedFact::Eq(_, ResolvedExpr::Var(..), ResolvedExpr::Call(_, c, _)) => c, + ResolvedFact::Eq(_, ResolvedExpr::Call(_, c, _), ResolvedExpr::Var(..)) => c, _ => return false, }; matches!(call, ResolvedCall::Func(ft) if ft.subtype == FunctionSubtype::Custom) @@ -511,6 +710,19 @@ impl ProofStore { &self.id_to_proof[proof_id] } + /// Add a proof, sharing one node per distinct `key`. The e-graph + /// hash-conses its own proof rows, so the proofs conversion rebuilds in their + /// place must be shared too — otherwise a subproof reached along several paths + /// becomes a fresh copy per path, and the proof unfolds into a tree. + pub(super) fn push_shared_proof(&mut self, key: SynthKey, proof: Proof) -> ProofId { + if let Some(&id) = self.synthesized.get(&key) { + return id; + } + let id = self.id_to_proof.push(proof); + self.synthesized.insert(key, id); + id + } + /// Get a string representation of the proof with the given id. /// The string representation is a pretty-printed s-expression block with /// let bindings for sub-proofs and sub-terms. @@ -523,6 +735,25 @@ impl ProofStore { buffer } + /// An empty store over `term_dag`. + pub(super) fn new( + term_dag: TermDag, + container_normalizers: HashMap, + prim_value_constructors: HashSet, + ) -> ProofStore { + ProofStore { + term_dag, + proof_id: HashMap::default(), + id_to_proof: DenseIdMap::new(), + container_normalizers, + prim_value_constructors, + rule_at: HashMap::default(), + head_plans: HashMap::default(), + head_walks: HashMap::default(), + synthesized: HashMap::default(), + } + } + fn from_raw( prog: &Vec, raw_store: RawProofStore, @@ -530,13 +761,16 @@ impl ProofStore { container_normalizers: HashMap, prim_value_constructors: HashSet, ) -> (ProofStore, ProofId) { - let mut store = ProofStore { - term_dag: raw_store.term_dag.clone(), - proof_id: HashMap::default(), - id_to_proof: DenseIdMap::new(), + let mut store = ProofStore::new( + raw_store.term_dag.clone(), container_normalizers, prim_value_constructors, - }; + ); + for (at, command) in prog.iter().enumerate() { + if let ResolvedNCommand::NormRule { rule } = command { + store.rule_at.entry(rule.name.clone()).or_insert(at); + } + } let globals = gather_globals(prog, &mut store.term_dag) .unwrap_or_else(|_| panic!("failed to gather globals from program")); @@ -554,20 +788,14 @@ impl ProofStore { /// reflexivizing to its RHS lands both premises on the same canonical view row /// so the checker's input-match succeeds. fn reflexivize_premise(&mut self, premise_id: ProofId) -> ProofId { - let prop = self.id_to_proof[premise_id].proposition.clone(); + let prop = &self.id_to_proof[premise_id].proposition; if prop.lhs == prop.rhs { return premise_id; } - // Sym(p) : rhs = lhs - let sym_id = self.id_to_proof.push(Proof { - proposition: Proposition::new(prop.rhs, prop.lhs), - justification: Justification::Sym(premise_id), - }); - // Trans(Sym(p), p) : rhs = rhs - self.id_to_proof.push(Proof { - proposition: Proposition::new(prop.rhs, prop.rhs), - justification: Justification::Trans(sym_id, premise_id), - }) + // Shared, so the two rows of one firing reflexivize a premise to the + // same id: the premise vector is both the walk memo's key and the + // rule proofs' sharing key. + self.reflexive(premise_id) } /// The two `MergeFn*` premise proofs end (rhs) at the colliding view terms @@ -641,11 +869,13 @@ impl ProofStore { ), justification: Justification::Fiat, }, - RawProof::Rule(name, premise_proofs, lhs, rhs) => { + RawProof::Rule(name, premise_proofs, bridge_proofs, raw_column) => { let converted_premises: Vec = premise_proofs .iter() .map(|pid| self.convert_raw_proof(prog, globals, raw_store, *pid)) .collect(); + let rule = self.rule_named(prog, name); + let planned = self.head_plan(rule); // Rebuild/canonicalization can rewrite a matched custom-function-fact // premise `(= (f args) v)` into a non-reflexive natural->canonical @@ -654,18 +884,22 @@ impl ProofStore { // rewrites). The checker's function-fact normal form expects a // reflexive premise at the matched (canonical) shape, so reflexivize // those. Equality-fact premises `(= a b)` must stay non-reflexive. - let reflex_mask: Vec = { - let rule = prog - .iter() - .find_map(|cmd| match cmd { - ResolvedNCommand::NormRule { rule } if rule.name == *name => Some(rule), - _ => None, - }) - .unwrap_or_else(|| panic!("could not find rule with name {name}")); - rule.body.iter().map(is_custom_func_fact).collect() - }; - // Premises are in body-fact order, one per fact (the checker rejects - // any count mismatch), so zip pairs each with its fact's decision. + let reflex_mask: Vec = rule.body.iter().map(is_custom_func_fact).collect(); + // Premises are in body-fact order, so each pairs with its own fact's + // decision. There may be more premises than facts: `remove_globals` + // appends a lookup fact per global the rule's *head* mentions, and the + // encoder records a premise for each, while `prog` is the program from + // before that pass. Those extras are exactly the trailing ones, so + // dropping them is what pairs the rest correctly — but a *shorter* + // premise list would misalign the mask silently, so require the + // encoder's count to cover the body (see + // `rule_premises_cover_the_written_body_facts`). + assert!( + converted_premises.len() >= reflex_mask.len(), + "rule {name} recorded {} premises for a body of {} facts", + converted_premises.len(), + reflex_mask.len() + ); let converted_premises: Vec = converted_premises .into_iter() .zip(reflex_mask) @@ -678,22 +912,57 @@ impl ProofStore { }) .collect(); - let mut substitution = - self.compute_rule_substitution(prog, name, &converted_premises); - // remove globals from the substitution, since they are not necessary - substitution.retain(|var, _term_id| globals.get(var).is_none()); - - Proof { - proposition: Proposition::new( - raw_store.unwrap_ast(*lhs), - raw_store.unwrap_ast(*rhs), - ), - justification: Justification::Rule { - name: name.clone(), - premise_proofs: converted_premises, - substitution, - }, + // This row carries on the walk the last row of the same firing + // left off at. A row reached while this one walks starts its own, + // so the further of the two is the one kept. + let firing_key = (name.clone(), converted_premises.clone()); + let carried = self.head_walks.remove(&firing_key); + let substitution = self.compute_rule_substitution(rule, &converted_premises); + // A carried walk brings the bindings the earlier row seeded it + // with, so only a walk starting from scratch needs them. + let bindings = match &carried { + Some(_) => HashMap::default(), + None => { + let mut bindings = globals.clone(); + bindings + .extend(substitution.iter().map(|(var, term)| (var.clone(), *term))); + bindings + } + }; + // A global's value is in every substitution, so recording it in the + // proof would only repeat the program. + let mut recorded = substitution; + recorded.retain(|var, _term| globals.get(var).is_none()); + // The bridges are in the order the head builds, which is the + // order the walk takes them in, so the supply picks up where the + // carried walk stopped. + let mut next = carried.as_ref().map_or(0, HeadWalk::bridges_taken); + let mut firing = Firing::new( + name, + &planned, + bindings, + converted_premises, + recorded, + Box::new(move |store: &mut ProofStore, _to_canonical| { + let raw = *bridge_proofs.get(next)?; + next += 1; + Some(store.convert_raw_proof(prog, globals, raw_store, raw)) + }), + ); + if let Some(walk) = carried { + firing.carry_on(walk); } + let proof_id = firing.column(self, *raw_column); + let walked = firing.into_walk(); + let further = self + .head_walks + .get(&firing_key) + .is_none_or(|kept| kept.reaches() < walked.reaches()); + if further { + self.head_walks.insert(firing_key, walked); + } + self.proof_id.insert(raw_proof.clone(), proof_id); + return proof_id; } RawProof::MergeFnIdx(function, old_raw, new_raw, idx) => { let old_proof_id = self.convert_raw_proof(prog, globals, raw_store, *old_raw); @@ -760,6 +1029,7 @@ impl ProofStore { let base_lhs = self.id_to_proof[base_id].lhs(); let base_rhs = self.id_to_proof[base_id].rhs(); let child_rhs = self.id_to_proof[child_id].rhs(); + self.assert_congr_starts_at_child(base_rhs, *child_index, child_id); let rhs = self.replace_term_child(base_rhs, *child_index, child_rhs); Proof { @@ -805,34 +1075,45 @@ impl ProofStore { proof_id } + /// The rule the proof names, which the encoder guarantees is in the program. + fn rule_named<'a>(&self, prog: &'a [ResolvedNCommand], rule_name: &str) -> &'a ResolvedRule { + let at = *self + .rule_at + .get(rule_name) + .unwrap_or_else(|| panic!("could not find rule with name {rule_name}")); + match &prog[at] { + ResolvedNCommand::NormRule { rule } => rule, + _ => unreachable!("only a rule is recorded"), + } + } + + /// How `rule`'s head lowers. A property of the rule text, so it is computed + /// once per rule. + fn head_plan(&mut self, rule: &ResolvedRule) -> Rc { + if let Some(plan) = self.head_plans.get(&rule.name) { + return plan.clone(); + } + let mut minted = 0usize; + let mut fresh = || { + minted += 1; + format!("@union-operand-{minted}") + }; + let plan = Rc::new(HeadPlan::new(&rule.head.0, &mut fresh)); + self.head_plans.insert(rule.name.clone(), plan.clone()); + plan + } + /// For a given rule and premise proofs, compute the substitution used in the rule application. /// The proof has enough information to compute the substitution, we do it here /// for convenience. + /// + /// Entries come out in the order the variables first occur in the rule body. fn compute_rule_substitution( &self, - prog: &[ResolvedNCommand], - rule_name: &str, + rule: &ResolvedRule, premise_proofs: &[ProofId], - ) -> HashMap { - let substitution = HashMap::default(); - - let Some(rule) = prog.iter().find_map(|cmd| match cmd { - ResolvedNCommand::NormRule { rule } if rule.name == rule_name => Some(rule), - _ => None, - }) else { - panic!("could not find rule with name {rule_name}"); - }; - - if rule.body.len() != premise_proofs.len() { - panic!( - "rule {} has {} premises, but got {} premise proofs", - rule_name, - rule.body.len(), - premise_proofs.len() - ); - } - - let mut current_subst = substitution; + ) -> IndexMap { + let mut current_subst = IndexMap::default(); for (fact, proof_id) in rule.body.iter().zip(premise_proofs.iter()) { // Container side conditions carry only an `Eval` marker (no value); // their bindings are generated by `check_side_condition` at check @@ -846,11 +1127,14 @@ impl ProofStore { current_subst } + /// Bind the fact's variables from the term its premise proof proves. A + /// primitive call contributes no bindings of its own — the value it computes + /// is read off the proof instead. fn unify_fact( &self, fact: &ResolvedFact, proof_id: ProofId, - subst: &mut HashMap, + subst: &mut IndexMap, ) { let proof = &self.id_to_proof[proof_id]; match fact { @@ -882,13 +1166,13 @@ impl ProofStore { ); } - // bind last child to v - let var_child_term = children.last().unwrap(); - self.add_to_subst(subst, &v.name, *var_child_term); - // unify other args + // unify the arguments before binding v to the last child, so the + // substitution records the variables in the order the fact writes them for (arg_expr, child_term) in args.iter().zip(children.iter()) { self.unify_expr(arg_expr, *child_term, subst); } + let var_child_term = children.last().unwrap(); + self.add_to_subst(subst, &v.name, *var_child_term); } ResolvedFact::Eq(_, lhs_expr, rhs_expr) => { self.unify_expr(lhs_expr, proof.lhs(), subst); @@ -900,12 +1184,12 @@ impl ProofStore { } } - fn add_to_subst(&self, subst: &mut HashMap, var: &str, term_id: TermId) { + fn add_to_subst(&self, subst: &mut IndexMap, var: &str, term_id: TermId) { match subst.entry(var.to_string()) { - HEntry::Vacant(entry) => { + IEntry::Vacant(entry) => { entry.insert(term_id); } - HEntry::Occupied(entry) => { + IEntry::Occupied(entry) => { if *entry.get() != term_id { panic!( "conflicting substitutions for variable {}: {:?} vs {:?}", @@ -922,7 +1206,7 @@ impl ProofStore { &self, expr: &ResolvedExpr, term_id: TermId, - substitution: &mut HashMap, + substitution: &mut IndexMap, ) { match expr { ResolvedExpr::Lit(_, _lit) => (), @@ -1003,6 +1287,22 @@ impl ProofStore { current } + /// A congruence step's middle-term check, the counterpart of the one + /// [`crate::proofs::proof_head::trans`] makes: the child the step rewrites + /// has to be the child it starts at. Panics otherwise. + fn assert_congr_starts_at_child(&self, base: TermId, child_index: usize, child: ProofId) { + let child_lhs = self.id_to_proof[child].lhs(); + let base_child = match self.term_dag.get(base) { + Term::App(_, children) => children.get(child_index).copied(), + other => panic!("congruence requires an application term, got {other:?}"), + }; + assert_eq!( + base_child, + Some(child_lhs), + "congruence step {child_index} does not start at that child" + ); + } + pub(super) fn replace_term_child( &mut self, term_id: TermId, @@ -1182,3 +1482,447 @@ impl Proof { &self.justification } } + +/// A packed row unpacks to exactly the composition its skeleton states. The +/// compositions below are written out by hand rather than generated, so they are +/// an oracle rather than a second copy of the instantiation. +/// +/// [`RawProofStore::add_proof`] hash-conses, so the unpacked row and the +/// hand-written chain land on the same [`RawProofId`] exactly when they are the +/// same tree, which is where `assert_agree` compares them. +#[cfg(test)] +mod tests { + use super::*; + use crate::proofs::proof_encoding_rebuild::rebuild_skeleton; + use crate::util::SymbolGen; + + /// One firing of a rebuild rule over a four-child view row, as the proofs + /// the rule packs: the row proof `e_old = f(old0 old1 old2 old3)`, a step + /// per child column (column 3's is reflexive — that column did not move), + /// and the e-class's own move `e_old = e_new`. + struct RebuildFiring { + raw: RawProofStore, + row: TermId, + /// `old_j = new_j`, per column. + steps: Vec, + /// `e_old = e_new`. + eclass: TermId, + old: Vec, + /// Each column's canonical form; column 3's is its old one. + new: Vec, + e_old: TermId, + e_new: TermId, + } + + impl RebuildFiring { + fn new() -> RebuildFiring { + let mut raw = empty_store(); + let leaf = |raw: &mut RawProofStore, name: String| raw.term_dag.app(name, vec![]); + let old: Vec = (0..4).map(|j| leaf(&mut raw, format!("old{j}"))).collect(); + let new: Vec = (0..4) + .map(|j| match j { + 3 => old[3], + _ => leaf(&mut raw, format!("new{j}")), + }) + .collect(); + let e_old = leaf(&mut raw, "e_old".to_string()); + let e_new = leaf(&mut raw, "e_new".to_string()); + + let row_term = raw.term_dag.app("f".to_string(), old.clone()); + let row = fiat_term(&mut raw, e_old, row_term); + let steps = (0..4) + .map(|j| fiat_term(&mut raw, old[j], new[j])) + .collect(); + let eclass = fiat_term(&mut raw, e_old, e_new); + RebuildFiring { + raw, + row, + steps, + eclass, + old, + new, + e_old, + e_new, + } + } + + /// The proposition `lhs = f(children)`. + fn concludes(&mut self, lhs: TermId, children: Vec) -> Proposition { + let rhs = self.raw.term_dag.app("f".to_string(), children); + Proposition::new(lhs, rhs) + } + + /// The proof of one of this firing's columns. + fn proof(&mut self, column: TermId) -> RawProofId { + self.raw.parse_proof(column) + } + + /// [`assert_agree`] over this firing's store. + fn assert_agree(&self, packed: RawProofId, chain: RawProofId, expected: &Proposition) { + assert_agree(&self.raw, packed, chain, expected); + } + } + + /// Require that the unpacked row `packed` is the same tree as the chain it + /// packs, and that the chain proves `expected`. + fn assert_agree( + raw: &RawProofStore, + packed: RawProofId, + chain: RawProofId, + expected: &Proposition, + ) { + let mut store = + ProofStore::new(raw.term_dag.clone(), HashMap::default(), HashSet::default()); + let prog = vec![]; + let globals = HashMap::default(); + let mut convert = |id| { + let converted = store.convert_raw_proof(&prog, &globals, raw, id); + store.simplify(converted) + }; + let (packed_proof, chain_proof) = (convert(packed), convert(chain)); + assert_eq!(store.get(chain_proof).proposition(), expected); + assert_eq!( + packed, + chain, + "the unpacked row\n{}\nis not the composition it packs\n{}", + store.proof_to_string(packed_proof), + store.proof_to_string(chain_proof), + ); + } + + /// A leaf proof of `lhs = rhs`, spelled the way an extracted `Fiat` row is + /// (each endpoint wrapped in an `Ast` constructor). + fn fiat_term(raw: &mut RawProofStore, lhs: TermId, rhs: TermId) -> TermId { + let lhs = raw.term_dag.app("Ast".to_string(), vec![lhs]); + let rhs = raw.term_dag.app("Ast".to_string(), vec![rhs]); + let head = raw.names.fiat_constructor.clone(); + raw.term_dag.app(head, vec![lhs, rhs]) + } + + /// A reflexive `Fiat` row over a nullary term, as an extracted proof term. + fn leaf_proof_term(raw: &mut RawProofStore, name: &str) -> TermId { + let value = raw.term_dag.app(name.to_string(), vec![]); + fiat_term(raw, value, value) + } + + /// An empty store whose names are the ones a packed row is spelled with. + fn empty_store() -> RawProofStore { + RawProofStore { + term_dag: TermDag::default(), + names: EncodingNames::new(&mut SymbolGen::new("test".to_string())), + store: IndexSet::default(), + term_to_proof: HashMap::default(), + } + } + + /// A packed row over `columns`, as the extracted term of the constructor + /// carrying `skeleton`. + fn packed_term(raw: &mut RawProofStore, skeleton: &Skeleton, columns: Vec) -> TermId { + assert_eq!(columns.len(), skeleton.width(), "one term per column"); + let head = raw.names.packed_proof(columns.len()); + let spelling = raw.term_dag.lit(Literal::String(skeleton.spelling())); + let args = std::iter::once(spelling).chain(columns).collect(); + raw.term_dag.app(head, args) + } + + /// [`RawProofStore::from_extracted`]'s parse of one packed row. + fn parse(raw: &mut RawProofStore, term: TermId) -> RawProofId { + raw.parse_nested_first(term); + raw.parse_proof(term) + } + + /// A rebuild row's columns: the row proof, then each canonicalized column's + /// step proof, then the e-class's own step when it has one. + fn rebuild_columns( + row: TermId, + steps: &[(usize, TermId)], + eclass: Option, + ) -> Vec { + let mut columns = vec![row]; + columns.extend(steps.iter().map(|&(_, step)| step)); + columns.extend(eclass); + columns + } + + /// One rebuild row, packed and parsed. + fn rebuild( + raw: &mut RawProofStore, + row: TermId, + steps: &[(usize, TermId)], + eclass: Option, + ) -> RawProofId { + let children: Vec = steps.iter().map(|&(child, _)| child).collect(); + let skeleton = rebuild_skeleton(&children, eclass.is_some()); + let columns = rebuild_columns(row, steps, eclass); + let term = packed_term(raw, &skeleton, columns); + parse(raw, term) + } + + /// Columns 0 and 2 move, and so does the e-class; column 3's step is + /// reflexive. + #[test] + fn rebuild_unpacks_to_the_chain_it_packs() { + let mut firing = RebuildFiring::new(); + let (row, eclass) = (firing.row, firing.eclass); + let steps = firing.steps.clone(); + let packed = rebuild( + &mut firing.raw, + row, + &[(0, steps[0]), (2, steps[2]), (3, steps[3])], + Some(eclass), + ); + + let row = firing.proof(row); + let (step0, step2, step3) = ( + firing.proof(steps[0]), + firing.proof(steps[2]), + firing.proof(steps[3]), + ); + let eclass = firing.proof(eclass); + let at_0 = firing.raw.add_proof(RawProof::Congr(row, 0, step0)); + let at_2 = firing.raw.add_proof(RawProof::Congr(at_0, 2, step2)); + let at_3 = firing.raw.add_proof(RawProof::Congr(at_2, 3, step3)); + let back = firing.raw.add_proof(RawProof::Sym(eclass)); + let chain = firing.raw.add_proof(RawProof::Trans(back, at_3)); + + let children = vec![firing.new[0], firing.old[1], firing.new[2], firing.old[3]]; + let expected = firing.concludes(firing.e_new, children); + firing.assert_agree(packed, chain, &expected); + } + + /// A view whose output is not an e-class: only child columns move. + #[test] + fn rebuild_without_an_eclass_step_unpacks_to_the_chain() { + let mut firing = RebuildFiring::new(); + let row = firing.row; + let steps = firing.steps.clone(); + let packed = rebuild(&mut firing.raw, row, &[(1, steps[1]), (3, steps[3])], None); + + let row = firing.proof(row); + let (step1, step3) = (firing.proof(steps[1]), firing.proof(steps[3])); + let at_1 = firing.raw.add_proof(RawProof::Congr(row, 1, step1)); + let chain = firing.raw.add_proof(RawProof::Congr(at_1, 3, step3)); + + let children = vec![firing.old[0], firing.new[1], firing.old[2], firing.old[3]]; + let expected = firing.concludes(firing.e_old, children); + firing.assert_agree(packed, chain, &expected); + } + + /// Only the e-class moved, so the fold contributes nothing. + #[test] + fn rebuild_with_no_child_steps_unpacks_to_the_chain() { + let mut firing = RebuildFiring::new(); + let (row, eclass) = (firing.row, firing.eclass); + let packed = rebuild(&mut firing.raw, row, &[], Some(eclass)); + + let row = firing.proof(row); + let eclass = firing.proof(eclass); + let back = firing.raw.add_proof(RawProof::Sym(eclass)); + let chain = firing.raw.add_proof(RawProof::Trans(back, row)); + + let children = firing.old.clone(); + let expected = firing.concludes(firing.e_new, children); + firing.assert_agree(packed, chain, &expected); + } + + /// A step that names a child it does not start at is rejected rather than + /// minting a proof of an equality nothing established. + #[test] + #[should_panic(expected = "congruence step 1 does not start at that child")] + fn a_rebuild_step_at_the_wrong_child_is_rejected() { + let mut firing = RebuildFiring::new(); + let (row, steps) = (firing.row, firing.steps.clone()); + let packed = rebuild(&mut firing.raw, row, &[(1, steps[0])], None); + let mut store = ProofStore::new( + firing.raw.term_dag.clone(), + HashMap::default(), + HashSet::default(), + ); + store.convert_raw_proof(&vec![], &HashMap::default(), &firing.raw, packed); + } + + /// Every column of the row is a proof, including the e-class's past the last + /// step. A proof `nested_proofs` misses is still parsed, but by + /// `parse_proof`'s recursion rather than by `parse_nested_first`'s heap + /// stack, so a deep chain through it overflows. + #[test] + fn a_rebuild_rows_nested_proofs_are_its_row_and_its_steps() { + let mut raw = empty_store(); + let row = leaf_proof_term(&mut raw, "row"); + let first = leaf_proof_term(&mut raw, "first"); + let second = leaf_proof_term(&mut raw, "second"); + let eclass = leaf_proof_term(&mut raw, "eclass"); + let steps = [(0, first), (2, second)]; + for (with_eclass, expected) in [ + (None, vec![row, first, second]), + (Some(eclass), vec![row, first, second, eclass]), + ] { + let skeleton = rebuild_skeleton(&[0, 2], with_eclass.is_some()); + let columns = rebuild_columns(row, &steps, with_eclass); + let term = packed_term(&mut raw, &skeleton, columns); + assert_eq!( + raw.nested_proofs(term), + expected, + "a rebuild row nests its row proof, its step proofs and its e-class proof" + ); + } + } + + /// A chain of rebuilds parses without recursing per link, on a stack far + /// too small to hold one frame per link — through either proof field a link + /// can chain on. + #[test] + fn a_deep_rebuild_chain_parses_without_a_deep_stack() { + for through_eclass in [false, true] { + std::thread::Builder::new() + .stack_size(512 * 1024) + .spawn(move || { + let mut raw = empty_store(); + let step = leaf_proof_term(&mut raw, "step"); + let leaf = leaf_proof_term(&mut raw, "row"); + let skeleton = rebuild_skeleton(&[0], through_eclass); + let mut chain = leaf; + for _ in 0..50_000 { + let (row, eclass) = if through_eclass { + (leaf, Some(chain)) + } else { + (chain, None) + }; + let columns = rebuild_columns(row, &[(0, step)], eclass); + chain = packed_term(&mut raw, &skeleton, columns); + } + RawProofStore::from_extracted(&raw.names, raw.term_dag.clone(), chain); + }) + .expect("spawn") + .join() + .expect("a deep rebuild chain should parse"); + } + } + + /// The two compositions a merge collision packs, by which endpoint its two + /// carried proofs share: the larger side's is column 0 and the smaller + /// side's column 1, and whichever points the wrong way is reversed. + fn displaced_skeletons() -> [Skeleton; 2] { + [ + Skeleton::Leaf(0).sym().trans(Skeleton::Leaf(1)), + Skeleton::Leaf(0).trans(Skeleton::Leaf(1).sym()), + ] + } + + /// One merge collision under `skeleton`: the larger side's carried proof, + /// the smaller side's, and the `hi = lo` the displaced edge has to state. + fn collision(raw: &mut RawProofStore, skeleton: &Skeleton) -> (TermId, TermId, Proposition) { + let leaf = |raw: &mut RawProofStore, name: &str| raw.term_dag.app(name.into(), vec![]); + let hi_term = leaf(raw, "hi"); + let lo_term = leaf(raw, "lo"); + let shared_term = leaf(raw, "shared"); + // The skeleton reverses whichever side does not already run `hi -> lo`. + let shared_on_the_left = + matches!(skeleton, Skeleton::Trans(left, _) if matches!(**left, Skeleton::Sym(_))); + let (hi, lo) = if shared_on_the_left { + ( + fiat_term(raw, shared_term, hi_term), + fiat_term(raw, shared_term, lo_term), + ) + } else { + ( + fiat_term(raw, hi_term, shared_term), + fiat_term(raw, lo_term, shared_term), + ) + }; + (hi, lo, Proposition::new(hi_term, lo_term)) + } + + /// Either way the carried proofs point, the row unpacks to the `Sym` + `Trans` + /// pair it packs, proving that the displaced side equals the kept one. + #[test] + fn displaced_unpacks_to_the_pair_it_packs() { + for skeleton in displaced_skeletons() { + let mut raw = empty_store(); + let (hi, lo, expected) = collision(&mut raw, &skeleton); + let term = packed_term(&mut raw, &skeleton, vec![hi, lo]); + let displaced = parse(&mut raw, term); + let (hi, lo) = (raw.parse_proof(hi), raw.parse_proof(lo)); + let pair = match &skeleton { + Skeleton::Trans(left, _) if matches!(**left, Skeleton::Sym(_)) => { + let back = raw.add_proof(RawProof::Sym(hi)); + raw.add_proof(RawProof::Trans(back, lo)) + } + _ => { + let back = raw.add_proof(RawProof::Sym(lo)); + raw.add_proof(RawProof::Trans(hi, back)) + } + }; + assert_agree(&raw, displaced, pair, &expected); + } + } + + /// Both carried proofs nest, so neither is left to `parse_proof`'s recursion + /// (see [`a_rebuild_rows_nested_proofs_are_its_row_and_its_steps`]). + #[test] + fn a_displaced_rows_nested_proofs_are_its_two_carried_proofs() { + let mut raw = empty_store(); + let hi = leaf_proof_term(&mut raw, "hi"); + let lo = leaf_proof_term(&mut raw, "lo"); + for skeleton in displaced_skeletons() { + let term = packed_term(&mut raw, &skeleton, vec![hi, lo]); + assert_eq!( + raw.nested_proofs(term), + vec![hi, lo], + "a displaced row nests both carried proofs" + ); + } + } + + /// A chain of displaced edges — one collision's proof carried into the next — + /// parses without recursing per link, on a stack far too small to hold one + /// frame per link, through either carried proof. + #[test] + fn a_deep_displaced_chain_parses_without_a_deep_stack() { + for skeleton in displaced_skeletons() { + for through_lo in [false, true] { + let skeleton = skeleton.clone(); + std::thread::Builder::new() + .stack_size(512 * 1024) + .spawn(move || { + let mut raw = empty_store(); + let leaf = leaf_proof_term(&mut raw, "carried"); + let mut chain = leaf; + for _ in 0..50_000 { + let columns = if through_lo { + vec![leaf, chain] + } else { + vec![chain, leaf] + }; + chain = packed_term(&mut raw, &skeleton, columns); + } + RawProofStore::from_extracted(&raw.names, raw.term_dag.clone(), chain); + }) + .expect("spawn") + .join() + .expect("a deep displaced chain should parse"); + } + } + } + + /// Every composition a packed row can stand for is recovered from the string + /// the row carries, so the encoder and unpacking cannot drift apart. + #[test] + fn a_packed_rows_string_spells_its_skeleton() { + let mut skeletons = displaced_skeletons().to_vec(); + for steps in 0..4 { + for eclass in [false, true] { + let children: Vec = (0..steps).map(|step| 2 * step).collect(); + skeletons.push(rebuild_skeleton(&children, eclass)); + } + } + for skeleton in skeletons { + let spelling = skeleton.spelling(); + assert_eq!( + Skeleton::from_spelling(&spelling), + Some(skeleton.clone()), + "{spelling} should spell {skeleton:?}" + ); + } + } +} diff --git a/egglog/src/proofs/proof_head.rs b/egglog/src/proofs/proof_head.rs new file mode 100644 index 00000000..695b62a8 --- /dev/null +++ b/egglog/src/proofs/proof_head.rs @@ -0,0 +1,1055 @@ +//! How a rule head lowers, and the flat array of proofs that lowering produces. +//! +//! A head that builds a term needs more proofs than it concludes: the term as +//! the head wrote it, the same term over its children's representatives, and the +//! edges between them. One walk of the head produces them all, in a fixed order, +//! so a proof is named by nothing but its position in that array — its *column*. +//! [`HeadLayout`] is where those columns go. The encoder walks a head to lower +//! it, naming each row it emits by the column the layout gives it; [`Firing`] +//! walks the same head to rebuild the array from the substitution, the body +//! premises, and the rule proof's trailing *bridge* premises — the view-row proof +//! of each subterm the head interned. A row's column indexes straight into what +//! comes back. +//! +//! [`HeadPlan`] is the head as the encoder lowers it, read by both walks. The +//! compositions themselves are written once, over [`ProofAlgebra`]: this walk +//! applies them to proof nodes, and the encoder to the proof variables it emits +//! wherever it composes rather than records — a top-level action or a merge +//! body. [`ProofSite`] says which of those two the encoder is doing. + +use crate::{ + TermId, + ast::{ + FunctionSubtype, GenericAction, GenericExpr, ResolvedAction, ResolvedExpr, ResolvedExprExt, + ResolvedVar, Span, + }, + core::ResolvedCall, + proofs::{ + proof_checker::eval_expr_with_subst, + proof_encoding::ProofInstrumentor, + proof_format::{Justification, Proof, ProofId, ProofStore, Proposition, SynthKey}, + }, + typechecking::FuncType, + util::{HashMap, HashSet, IndexMap}, +}; + +/// One proof a head's walk produces, naming what a column holds. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum HeadProof { + /// The head's own conclusion about the term written here. + Own, + /// That conclusion restated over the children's representatives. + Canonical, + /// The term as written equals the e-class the head interned it into. + Connector, + /// A construct-into guest's dropped `union`, stated `target = guest`. + DroppedEdge, + /// A construct-into guest's view row: the target's e-class equals the guest's + /// term over its children's representatives. + GuestView, + /// A `union`'s union-find edge — the `@UF` row's arrow from a term to its + /// parent, proving `term = parent` — with the left operand as the term. + /// Which operand that is comes out of the value ordering at run time, so the + /// walk produces the edge both ways round. + EdgeFromLhs, + /// The same edge with the `union`'s right operand as the term. + EdgeFromRhs, +} + +/// A position a head's walk reaches, which claims one column per proof it +/// produces there. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum HeadPosition { + /// A term the head builds. + Built, + /// A term built into another operand's e-class rather than a fresh one. + Guest, + /// Any other call: a primitive, a relation, a global lookup. + Call, + /// A `union`. + Union, + /// A `set`. + Set, +} + +impl HeadPosition { + /// The proofs this position produces, in the order the walk produces them. + pub(crate) fn proofs(self) -> &'static [HeadProof] { + use HeadProof::*; + match self { + HeadPosition::Built => &[Own, Canonical, Connector], + HeadPosition::Guest => &[Own, DroppedEdge, GuestView, Connector], + HeadPosition::Call => &[Own], + HeadPosition::Union => &[Own, EdgeFromLhs, EdgeFromRhs], + HeadPosition::Set => &[Own], + } + } +} + +/// The consecutive columns one position claims, one per proof in +/// [`HeadPosition::proofs`]. +#[derive(Clone, Copy)] +pub(crate) struct HeadRun { + position: HeadPosition, + /// The run's first column. + first: usize, +} + +impl HeadRun { + /// The column holding `proof`. + pub(crate) fn column(self, proof: HeadProof) -> usize { + let offset = self + .position + .proofs() + .iter() + .position(|held| *held == proof) + .unwrap_or_else(|| panic!("a {:?} position holds no {proof:?}", self.position)); + self.first + offset + } +} + +/// Where every column of a rule head goes. +/// +/// One walk of the [`HeadPlan`] claims a run per position — an action's operands +/// before the action, a term's children before the term — and that walk is the +/// authority: the encoder claims runs from it as it lowers, and [`Firing`] fills +/// them as it walks, each panicking if the position it is at is not the one the +/// layout has there. +#[derive(Clone)] +pub(crate) struct HeadLayout { + runs: Vec, +} + +impl HeadLayout { + /// Lay the columns of a planned head out by walking it once. + fn new( + actions: &[ResolvedAction], + construct_into: &HashMap, + dropped: &HashSet, + ) -> HeadLayout { + let mut layout = HeadLayout { runs: vec![] }; + for (at, action) in actions.iter().enumerate() { + if dropped.contains(&at) { + continue; + } + match action { + GenericAction::Let(_, var, expr) if construct_into.contains_key(&var.name) => { + let (_, args) = constructor_operand(expr) + .expect("a construct-into guest is a constructor application"); + layout.args(args); + layout.claim(HeadPosition::Guest); + } + GenericAction::Let(_, _, expr) | GenericAction::Expr(_, expr) => layout.expr(expr), + GenericAction::Union(_, lhs, rhs) => { + layout.expr(lhs); + layout.expr(rhs); + layout.claim(HeadPosition::Union); + } + GenericAction::Set(_, _, args, value) => { + layout.args(args); + layout.expr(value); + layout.claim(HeadPosition::Set); + } + // A `change` concludes nothing, and a `panic` is passed through + // uninstrumented, so neither they nor their arguments hold a + // column. + GenericAction::Change(..) | GenericAction::Panic(..) => {} + } + } + layout + } + + /// The run of columns the walk's `position`th position claims. + fn run(&self, position: usize) -> HeadRun { + *self + .runs + .get(position) + .unwrap_or_else(|| panic!("a head's walk has no position {position}")) + } + + /// What the column at `column` holds, or `None` past the head's last column. + #[cfg(test)] + pub(crate) fn proof_at(&self, column: usize) -> Option { + let run = self.runs.iter().rev().find(|run| run.first <= column)?; + run.position.proofs().get(column - run.first).copied() + } + + fn expr(&mut self, expr: &ResolvedExpr) { + let ResolvedExpr::Call(_, _, args) = expr else { + // A variable or a literal builds nothing, so it is not a position. + return; + }; + self.args(args); + self.claim(match constructor_operand(expr) { + Some(_) => HeadPosition::Built, + None => HeadPosition::Call, + }); + } + + fn args(&mut self, args: &[ResolvedExpr]) { + for arg in args { + self.expr(arg); + } + } + + fn claim(&mut self, position: HeadPosition) { + let first = self + .runs + .last() + .map_or(0, |run| run.first + run.position.proofs().len()); + self.runs.push(HeadRun { position, first }); + } +} + +/// Which of the encoding's two layers the encoder is lowering under (see the +/// *Proofs* part of `proof_encoding.md`). +pub(crate) enum ProofSite { + /// No rule head to replay: a top-level action, a merge body, or a position + /// inside a head that concludes nothing — a `change` argument. + Composed, + /// A rule head, whose columns `layout` lays out, at its `next_position`th + /// position. + Skeleton { + layout: HeadLayout, + next_position: usize, + }, +} + +impl ProofSite { + /// A rule head laid out by `layout`, before its first position. + pub(crate) fn skeleton(layout: HeadLayout) -> ProofSite { + ProofSite::Skeleton { + layout, + next_position: 0, + } + } + + /// Whether the proofs here are composed on the spot rather than numbered. + pub(crate) fn composes(&self) -> bool { + matches!(self, ProofSite::Composed) + } + + /// Claim the columns of the next position, which must be a `position`, or + /// `None` where the site composes instead of numbering. + pub(crate) fn claim(&mut self, position: HeadPosition) -> Option { + let ProofSite::Skeleton { + layout, + next_position, + } = self + else { + return None; + }; + let run = layout.run(*next_position); + assert_eq!( + run.position, position, + "the encoder is lowering a {position:?} at position {next_position}, \ + where the head's layout has a {:?}", + run.position + ); + *next_position += 1; + Some(run) + } +} + +/// A rule head as the encoder lowers it. +pub(crate) struct HeadPlan { + /// The head with every constructor-application `union` operand lifted into a + /// preceding `let`. + pub actions: Vec, + /// Guest variable -> the variable holding the e-class its constructor is + /// built into, instead of a fresh one. + pub construct_into: HashMap, + /// Indices into [`Self::actions`] of the `union`s the plan makes redundant. + pub dropped: HashSet, + /// Where this head's columns go. + pub layout: HeadLayout, +} + +impl HeadPlan { + /// Plan `actions`. `fresh` names the lifted `let`s; only their uniqueness + /// matters, so a consumer that wants the shape rather than the code can + /// supply a local counter. + pub(crate) fn new(actions: &[ResolvedAction], fresh: &mut dyn FnMut() -> String) -> Self { + let actions = normalize_union_operands(actions, fresh); + let (construct_into, dropped) = plan_construct_into(&actions); + let layout = HeadLayout::new(&actions, &construct_into, &dropped); + HeadPlan { + actions, + construct_into, + dropped, + layout, + } + } +} + +/// The `(FuncType, args)` of a constructor-application expression, else `None`. +pub(crate) fn constructor_operand(expr: &ResolvedExpr) -> Option<(&FuncType, &[ResolvedExpr])> { + match expr { + ResolvedExpr::Call(_, ResolvedCall::Func(func_type), args) + if func_type.subtype == FunctionSubtype::Constructor => + { + Some((func_type, args.as_slice())) + } + _ => None, + } +} + +/// The row a `set` writes, as a call the head can evaluate: a custom function +/// stores its output as the last argument. +fn set_row_expr( + span: &Span, + func: &ResolvedCall, + args: &[ResolvedExpr], + value: &ResolvedExpr, +) -> ResolvedExpr { + let mut row = args.to_vec(); + row.push(value.clone()); + ResolvedExpr::Call(span.clone(), func.clone(), row) +} + +/// Lift each constructor-application `union` operand into a preceding `let`, so +/// that every `union` operand [`plan_construct_into`] sees is a variable. +fn normalize_union_operands( + actions: &[ResolvedAction], + fresh: &mut dyn FnMut() -> String, +) -> Vec { + let mut out = vec![]; + for action in actions { + match action { + ResolvedAction::Union(span, lhs, rhs) => { + let lhs = lift_union_operand(lhs.clone(), &mut out, fresh); + let rhs = lift_union_operand(rhs.clone(), &mut out, fresh); + out.push(ResolvedAction::Union(span.clone(), lhs, rhs)); + } + other => out.push(other.clone()), + } + } + out +} + +/// If `operand` is a constructor application, bind it to a fresh `let` (pushed +/// onto `out`) and return a variable referencing it; otherwise return `operand` +/// unchanged. +fn lift_union_operand( + operand: ResolvedExpr, + out: &mut Vec, + fresh: &mut dyn FnMut() -> String, +) -> ResolvedExpr { + if constructor_operand(&operand).is_none() { + return operand; + } + let span = operand.span(); + let var = ResolvedVar { + name: fresh(), + sort: operand.output_type(), + is_global_ref: false, + }; + out.push(ResolvedAction::Let(span.clone(), var.clone(), operand)); + GenericExpr::Var(span, var) +} + +/// Plan the construct-into optimization over normalized actions (union operands +/// are variables). Returns a map from each guest variable — whose constructor is +/// built into the target's e-class instead of a fresh one — to the target +/// variable, and the set of union action indices it makes redundant. +/// +/// Conservative: only a `union` of two distinct, not-yet-touched variables where +/// at least one is a constructor-`let` is optimized. The guest is the +/// later-defined constructor operand (so the target's e-class is already bound +/// where the guest is built); a matched (un-`let`) variable is always an eligible +/// target. +fn plan_construct_into(actions: &[ResolvedAction]) -> (HashMap, HashSet) { + let mut all_def: HashMap = HashMap::default(); + let mut ctor_def: HashMap = HashMap::default(); + for (i, action) in actions.iter().enumerate() { + if let ResolvedAction::Let(_, v, expr) = action { + all_def.insert(v.name.clone(), i); + if constructor_operand(expr).is_some() { + ctor_def.insert(v.name.clone(), i); + } + } + } + + let mut construct_into: HashMap = HashMap::default(); + let mut dropped: HashSet = HashSet::default(); + let mut used: HashSet = HashSet::default(); + for (i, action) in actions.iter().enumerate() { + let ResolvedAction::Union(_, lhs, rhs) = action else { + continue; + }; + let (GenericExpr::Var(_, va), GenericExpr::Var(_, vb)) = (lhs, rhs) else { + continue; + }; + let (a, b) = (va.name.clone(), vb.name.clone()); + if a == b { + // Union of a variable with itself is a no-op. + dropped.insert(i); + continue; + } + if used.contains(&a) || used.contains(&b) { + // Keep chains of optimized unions out of scope for now. + continue; + } + let (guest, target) = match (ctor_def.get(&a), ctor_def.get(&b)) { + (Some(&ia), Some(&ib)) => { + if ia >= ib { + (a.clone(), b) + } else { + (b, a.clone()) + } + } + (Some(_), None) => (a.clone(), b), + (None, Some(_)) => (b, a.clone()), + (None, None) => continue, + }; + // The target's e-class must be bound where the guest is built: a matched + // variable always is; a `let` must precede the guest's. + let guest_idx = ctor_def[&guest]; + if let Some(&target_idx) = all_def.get(&target) + && target_idx >= guest_idx + { + continue; + } + used.insert(guest.clone()); + used.insert(target.clone()); + construct_into.insert(guest, target); + dropped.insert(i); + } + (construct_into, dropped) +} + +/// How far a rule head's walk has got, and everything it produced on the way. +/// +/// Each rule proof of a firing walks the head only as far as its own bridges +/// reach, and hands what it produced to the next one, which carries on from +/// there. What it holds is what the whole walk would have produced this far. +#[derive(Clone, Default)] +pub(crate) struct HeadWalk { + /// The action to carry on at. + next_action: usize, + /// How many of the layout's positions the walk has reached. + next_position: usize, + /// How many bridges the walk has taken from the row's supply. + bridges_taken: usize, + /// The columns filled so far. `None` at a column the walk numbers but the + /// head produces no proof for. + proofs: Vec>, + /// What the head's variables stand for, as the walk binds them. Empty until + /// the first walk, which seeds it with the globals and the substitution. + bindings: HashMap, + /// A variable holding a term the head built -> that term's connector. + connectors: HashMap, +} + +impl HeadWalk { + /// How far into the head this walk got. + pub(crate) fn reaches(&self) -> usize { + self.next_action + } + + /// How many bridges the walk took, which is where a row carrying on from it + /// starts taking from its own supply. + pub(crate) fn bridges_taken(&self) -> usize { + self.bridges_taken + } + + /// Where the walk stands, to come back to with [`Self::rewind`]. + fn mark(&self) -> ActionStart { + ActionStart { + action: self.next_action, + position: self.next_position, + bridges: self.bridges_taken, + columns: self.proofs.len(), + } + } + + /// Undo everything the walk did after `mark`. The bindings and connectors it + /// wrote are left alone: an action rewrites its own before anything reads + /// them, and the actions after it are unreached either way. + fn rewind(&mut self, mark: ActionStart) { + self.next_action = mark.action; + self.next_position = mark.position; + self.bridges_taken = mark.bridges; + self.proofs.truncate(mark.columns); + } +} + +/// The action boundary a walk can be put back to. +#[derive(Clone, Copy, Default)] +struct ActionStart { + action: usize, + position: usize, + bridges: usize, + columns: usize, +} + +/// One firing of a rule head, and the flat array of proofs its lowering produces. +/// +/// Each position of the walk fills the run of columns [`HeadLayout`] gives it, +/// in the order [`HeadPosition::proofs`] lists them. The array lives in a +/// [`HeadWalk`], which one rule proof of the firing hands on to the next. +pub(crate) struct Firing<'a> { + rule_name: &'a str, + plan: &'a HeadPlan, + /// The position being filled, and how many of its columns are filled. + open: Option<(HeadRun, usize)>, + /// The premises the rule body matched, one per body fact. + body_premises: Vec, + substitution: IndexMap, + /// The bridges the requesting rule proof row carries, in the order the head + /// interned them, each asked for with the proof of the term being interned. + /// The supply runs dry inside the action the row was minted in. + bridges: Box Option + 'a>, + walk: HeadWalk, + /// Where the action being walked began. + action_start: ActionStart, + /// Whether the walk has asked for a bridge past the ones this row carries. + dry: bool, +} + +impl<'a> Firing<'a> { + /// `bindings` must resolve every variable the head reads — the globals plus + /// the body's substitution — unless a walk is handed to [`Self::carry_on`], + /// which brings its own. `bridges` hands them over one at a time, starting + /// where that walk stopped taking. + pub(crate) fn new( + rule_name: &'a str, + plan: &'a HeadPlan, + bindings: HashMap, + body_premises: Vec, + substitution: IndexMap, + bridges: Box Option + 'a>, + ) -> Self { + Firing { + rule_name, + plan, + open: None, + body_premises, + substitution, + bridges, + walk: HeadWalk { + bindings, + ..HeadWalk::default() + }, + action_start: ActionStart::default(), + dry: false, + } + } + + /// Carry on from `walk`, which an earlier row of this firing left behind. + pub(crate) fn carry_on(&mut self, walk: HeadWalk) { + self.walk = walk; + } + + /// The walk to hand the next row of this firing. The action the supply ran + /// dry in is put back, so the next row walks it with the bridge it wanted. + pub(crate) fn into_walk(mut self) -> HeadWalk { + if self.dry { + self.walk.rewind(self.action_start); + } + self.walk + } + + /// Every proof the head's lowering produces, by column. `None` at a column + /// the walk numbers but the head produces no proof for. + #[cfg(test)] + pub(crate) fn proofs(&mut self, store: &mut ProofStore) -> &[Option] { + self.fill(store); + &self.walk.proofs + } + + /// The proof the e-graph stored in the column `raw` names. + pub(crate) fn column(&mut self, store: &mut ProofStore, raw: i64) -> ProofId { + let rule_name = self.rule_name; + let column = usize::try_from(raw).unwrap_or_else(|_| { + panic!("rule {rule_name} proof was emitted without a column ({raw})") + }); + self.fill(store); + self.walk + .proofs + .get(column) + .copied() + .flatten() + .unwrap_or_else(|| { + panic!("rule {rule_name}'s head produces no proof at column {column}") + }) + } + + /// Walk the head as far as this row's bridges reach: to the end of the action + /// that asks for one past them, which is the action the row was minted in. + fn fill(&mut self, store: &mut ProofStore) { + let plan = self.plan; + while !self.dry && self.walk.next_action < plan.actions.len() { + self.action_start = self.walk.mark(); + let at = self.walk.next_action; + self.walk.next_action += 1; + if plan.dropped.contains(&at) { + continue; + } + match &plan.actions[at] { + GenericAction::Let(_, var, expr) => { + let connector = match self.plan.construct_into.get(&var.name) { + Some(target) => self.guest(store, expr, target), + None => self.expr(store, expr), + }; + let term = self.eval(store, expr); + self.walk.bindings.insert(var.name.clone(), term); + if let Some(connector) = connector { + self.walk.connectors.insert(var.name.clone(), connector); + } + } + GenericAction::Expr(_, expr) => { + self.expr(store, expr); + } + GenericAction::Union(_, lhs, rhs) => { + let lhs_connector = self.expr(store, lhs); + let rhs_connector = self.expr(store, rhs); + let lhs_term = self.eval(store, lhs); + let rhs_term = self.eval(store, rhs); + self.claim(HeadPosition::Union, |this| { + let own = + this.own(store, HeadProof::Own, Proposition::new(lhs_term, rhs_term)); + match (lhs_connector, rhs_connector) { + // Nothing was built, so both endpoints' terms are the + // ones the head concluded over and the edge is that + // conclusion, in whichever direction the union-find + // asks for. + (None, None) => { + this.own( + store, + HeadProof::EdgeFromLhs, + Proposition::new(lhs_term, rhs_term), + ); + this.own( + store, + HeadProof::EdgeFromRhs, + Proposition::new(rhs_term, lhs_term), + ); + } + operands => this.union_edge(store, own, operands), + } + }); + } + GenericAction::Set(span, func, args, value) => { + for arg in args { + self.expr(store, arg); + } + self.expr(store, value); + let row = set_row_expr(span, func, args, value); + let row_term = self.eval(store, &row); + self.claim(HeadPosition::Set, |this| { + this.own(store, HeadProof::Own, Proposition::new(row_term, row_term)); + }); + } + GenericAction::Change(..) | GenericAction::Panic(..) => {} + } + } + } + + /// Walk `expr`, and answer with its connector when it holds a term the head + /// built. + fn expr(&mut self, store: &mut ProofStore, expr: &ResolvedExpr) -> Option { + let ResolvedExpr::Call(_, _, args) = expr else { + // A variable's value comes from wherever it was bound; a literal + // holds no built term. + return match expr { + ResolvedExpr::Var(_, var) => self.walk.connectors.get(&var.name).copied(), + _ => None, + }; + }; + let steps = self.args(store, args); + let term = self.eval(store, expr); + // A primitive or a global lookup builds nothing itself, though a + // constructor argument of it is still built. + let builds = constructor_operand(expr).is_some(); + let position = if builds { + HeadPosition::Built + } else { + HeadPosition::Call + }; + self.claim(position, |this| { + let own = this.own(store, HeadProof::Own, Proposition::new(term, term)); + if !builds { + return None; + } + let to_canonical = store.canonicalize(own, steps); + let canonical_reflexive = store.reflexive(to_canonical); + this.push(HeadProof::Canonical, Some(canonical_reflexive)); + let connector = match this.bridge(store, to_canonical) { + Some(bridge) => store.connect(to_canonical, bridge), + None => to_canonical, + }; + this.push(HeadProof::Connector, Some(connector)); + Some(connector) + }) + } + + /// Walk a construct-into guest, whose constructor is built into `target`'s + /// e-class: the dropped `union`'s edge stands in for the interning row, and + /// the guest's view row states that e-class equals the guest's term. + fn guest( + &mut self, + store: &mut ProofStore, + expr: &ResolvedExpr, + target: &str, + ) -> Option { + let (_, args) = + constructor_operand(expr).expect("a construct-into guest is a constructor application"); + let steps = self.args(store, args); + let term = self.eval(store, expr); + self.claim(HeadPosition::Guest, |this| { + let own = this.own(store, HeadProof::Own, Proposition::new(term, term)); + let to_canonical = store.canonicalize(own, steps); + let target_term = *this.walk.bindings.get(target).unwrap_or_else(|| { + panic!( + "rule {}'s construct-into target {target} is unbound", + this.rule_name + ) + }); + let edge = this.own( + store, + HeadProof::DroppedEdge, + Proposition::new(target_term, term), + ); + let target_connector = this.walk.connectors.get(target).copied(); + let view = store.guest_view(edge, to_canonical, target_connector); + this.push(HeadProof::GuestView, Some(view)); + let connector = store.connect(to_canonical, view); + this.push(HeadProof::Connector, Some(connector)); + Some(connector) + }) + } + + /// Walk a call's arguments, keeping the connector of each one holding a term + /// the head built, at the position it sits in the call. + fn args(&mut self, store: &mut ProofStore, args: &[ResolvedExpr]) -> Vec<(usize, ProofId)> { + let mut steps = vec![]; + for (index, arg) in args.iter().enumerate() { + if let Some(connector) = self.expr(store, arg) { + steps.push((index, connector)); + } + } + steps + } + + /// A `union`'s union-find edge, routed through the operands' written forms so + /// both endpoints' proofs end at one shared term. + fn union_edge( + &mut self, + store: &mut ProofStore, + own: ProofId, + operands: (Option, Option), + ) { + let (lhs, rhs) = operands; + let (lhs_to, rhs_to) = store.union_to_shared(own, lhs, rhs); + for (from, max_pf, min_pf) in [ + (HeadProof::EdgeFromLhs, lhs_to, rhs_to), + (HeadProof::EdgeFromRhs, rhs_to, lhs_to), + ] { + let back = sym(store, min_pf); + let edge = trans(store, max_pf, back); + self.push(from, Some(edge)); + } + } + + /// Fill the run of columns the layout gives this walk's next position, which + /// must be a `position`. + fn claim(&mut self, position: HeadPosition, fill: impl FnOnce(&mut Self) -> R) -> R { + let run = self.plan.layout.run(self.walk.next_position); + assert_eq!( + run.position, position, + "rule {}'s walk is at a {position:?} where its layout has a {:?}", + self.rule_name, run.position + ); + assert_eq!( + self.walk.proofs.len(), + run.first, + "rule {}'s walk reached the {:?} the layout puts at column {}, having filled {}", + self.rule_name, + position, + run.first, + self.walk.proofs.len() + ); + self.walk.next_position += 1; + let outer = self.open.replace((run, 0)); + assert!(outer.is_none(), "a head's positions do not nest"); + let out = fill(self); + let (_, produced) = self.open.take().expect("the position stayed open"); + assert_eq!( + produced, + position.proofs().len(), + "rule {}'s walk produced {produced} of a {position:?}'s {} proofs", + self.rule_name, + position.proofs().len() + ); + out + } + + /// Fill the open position's next column, which the layout says holds `proof`. + fn push(&mut self, proof: HeadProof, id: Option) { + let (run, produced) = self + .open + .as_mut() + .expect("every proof the walk produces belongs to a position"); + assert_eq!( + run.position.proofs().get(*produced), + Some(&proof), + "rule {}'s walk produced a {proof:?} where a {:?} holds {:?}", + self.rule_name, + run.position, + run.position.proofs().get(*produced) + ); + *produced += 1; + self.walk.proofs.push(id); + } + + /// The head's own conclusion, at the column holding `proof`. + fn own( + &mut self, + store: &mut ProofStore, + proof: HeadProof, + proposition: Proposition, + ) -> ProofId { + let column = self.walk.proofs.len() as i64; + let id = store.push_shared_proof( + SynthKey::Rule( + self.rule_name.to_string(), + column, + self.body_premises.clone(), + ), + Proof { + proposition, + justification: Justification::Rule { + name: self.rule_name.to_string(), + premise_proofs: self.body_premises.clone(), + substitution: self.substitution.clone(), + }, + }, + ); + self.push(proof, Some(id)); + id + } + + /// The term `expr` evaluates to under the bindings in effect. + fn eval(&mut self, store: &mut ProofStore, expr: &ResolvedExpr) -> TermId { + eval_expr_with_subst( + self.rule_name, + expr, + &mut store.term_dag, + &self.walk.bindings, + ) + .unwrap_or_else(|err| panic!("rule {}'s head did not replay: {err}", self.rule_name)) + .0 + } + + /// The next bridge off the supply: the view-row proof the head recorded for + /// the term the walk just built, saying which e-class it interned into. + /// + /// `None` once the supply has run dry, which ends the walk. + fn bridge(&mut self, store: &mut ProofStore, to_canonical: ProofId) -> Option { + let taken = self.walk.bridges_taken; + self.walk.bridges_taken += 1; + let Some(bridge) = (self.bridges)(store, to_canonical) else { + self.dry = true; + return None; + }; + // Every proof this read can return — an existing row's, a rebuilt row's, a + // construct-into guest view, or the encoder's `can_prf` fallback — ends at + // the canonical term. A bridge that does not is one aligned to the wrong + // term, which would otherwise compose into a proof of the wrong equality. + assert_eq!( + store.get(bridge).rhs(), + store.get(to_canonical).rhs(), + "rule {}'s bridge {taken} does not end at the canonical term", + self.rule_name + ); + Some(bridge) + } +} + +/// Layer 1: the equality axioms, plus the four compositions a head's lowering +/// builds out of them. +/// +/// Walking a head bottom-up and applying [`Self::canonicalize`], +/// [`Self::reflexive`], [`Self::connect`] and [`Self::guest_view`] — with +/// [`Self::union_to_shared`] for a `union`'s two orientations — is the whole of +/// layer 1. +pub(super) trait ProofAlgebra { + type Proof: Clone; + + /// `p : a = b` reversed to `b = a`. + fn sym(&mut self, proof: Self::Proof) -> Self::Proof; + /// `a = b` and `b = c` joined into `a = c`. + fn trans(&mut self, left: Self::Proof, right: Self::Proof) -> Self::Proof; + /// `base`'s right-hand side with the child at `child` rewritten by `step`. + fn congr(&mut self, base: Self::Proof, child: usize, step: Self::Proof) -> Self::Proof; + + /// The proof that a term the head wrote equals the same term over its + /// children's representatives: one `congr` per child the head built. + fn canonicalize( + &mut self, + own: Self::Proof, + children: impl IntoIterator, + ) -> Self::Proof { + let mut to_canonical = own; + for (child, step) in children { + to_canonical = self.congr(to_canonical, child, step); + } + to_canonical + } + + /// `t = t` for the term `to_canonical` reaches. + fn reflexive(&mut self, to_canonical: Self::Proof) -> Self::Proof { + let back = self.sym(to_canonical.clone()); + self.trans(back, to_canonical) + } + + /// A built term's connector, from the term as written to the e-class the head + /// interned it into: `dedup` states that e-class equals the canonical term, so + /// the connector runs through the canonical term and back. + fn connect(&mut self, to_canonical: Self::Proof, dedup: Self::Proof) -> Self::Proof { + let back = self.sym(dedup); + self.trans(to_canonical, back) + } + + /// Both operands of a `union` routed to one shared term, so the union-find + /// edge can be composed in either orientation. `own` states the union's own + /// conclusion `lhs = rhs`, and an operand's connector is present when the head + /// built that operand; at least one of them must have been. + fn union_to_shared( + &mut self, + own: Self::Proof, + lhs: Option, + rhs: Option, + ) -> (Self::Proof, Self::Proof) { + match rhs { + Some(rhs) => { + let lhs_to = match lhs { + Some(lhs) => { + let back = self.sym(lhs); + self.trans(back, own) + } + None => own, + }; + let rhs_to = self.sym(rhs); + (lhs_to, rhs_to) + } + None => { + let lhs = lhs.expect("one operand of the union was built"); + let lhs_to = self.sym(lhs); + let rhs_to = self.sym(own); + (lhs_to, rhs_to) + } + } + } + + /// A construct-into guest's view-row proof: the target's e-class equals the + /// guest's term over its children's representatives. `edge` is the dropped + /// `union`'s equality stated `target = guest`, and `target` the target's own + /// connector when the head built it too. + fn guest_view( + &mut self, + edge: Self::Proof, + to_canonical: Self::Proof, + target: Option, + ) -> Self::Proof { + let to_dedup = self.trans(edge, to_canonical); + match target { + Some(target) => { + let back = self.sym(target); + self.trans(back, to_dedup) + } + None => to_dedup, + } + } +} + +/// Applying the algebra builds the proof. +impl ProofAlgebra for ProofStore { + type Proof = ProofId; + + fn sym(&mut self, proof: ProofId) -> ProofId { + sym(self, proof) + } + + fn trans(&mut self, left: ProofId, right: ProofId) -> ProofId { + trans(self, left, right) + } + + fn congr(&mut self, base: ProofId, child: usize, step: ProofId) -> ProofId { + congr(self, base, child, step) + } +} + +/// Applying the algebra emits the proof: each step is a row, named by the +/// variable it binds. +impl ProofAlgebra for ProofInstrumentor<'_> { + type Proof = String; + + fn sym(&mut self, proof: String) -> String { + self.mint_sym(&proof) + } + + fn trans(&mut self, left: String, right: String) -> String { + self.mint_trans(&left, &right) + } + + fn congr(&mut self, base: String, child: usize, step: String) -> String { + self.mint_congr(&base, child, &step) + } +} + +/// `Sym(p)`: `p : a = b` reversed to `b = a`. +pub(super) fn sym(store: &mut ProofStore, proof: ProofId) -> ProofId { + let prop = store.get(proof).proposition().clone(); + store.push_shared_proof( + SynthKey::Sym(proof), + Proof { + proposition: Proposition::new(prop.rhs, prop.lhs), + justification: Justification::Sym(proof), + }, + ) +} + +/// `Trans(left, right)`. Panics unless the two meet at the same middle term. +pub(super) fn trans(store: &mut ProofStore, left: ProofId, right: ProofId) -> ProofId { + let lhs = store.get(left).lhs(); + let rhs = store.get(right).rhs(); + assert_eq!( + store.get(left).rhs(), + store.get(right).lhs(), + "transitivity requires matching middle terms" + ); + store.push_shared_proof( + SynthKey::Trans(left, right), + Proof { + proposition: Proposition::new(lhs, rhs), + justification: Justification::Trans(left, right), + }, + ) +} + +/// `Congr(base, child_index, child_proof)`: `base`'s right-hand side with the +/// child at `child_index` rewritten by `child_proof`. +pub(super) fn congr( + store: &mut ProofStore, + base: ProofId, + child_index: usize, + child_proof: ProofId, +) -> ProofId { + let lhs = store.get(base).lhs(); + let base_rhs = store.get(base).rhs(); + let child_rhs = store.get(child_proof).rhs(); + let rhs = store.replace_term_child(base_rhs, child_index, child_rhs); + store.push_shared_proof( + SynthKey::Congr(base, child_index, child_proof), + Proof { + proposition: Proposition::new(lhs, rhs), + justification: Justification::Congr { + proof: base, + child_index, + child_proof, + }, + }, + ) +} diff --git a/egglog/src/proofs/proof_tests.rs b/egglog/src/proofs/proof_tests.rs index aac0eadb..8b0b78e6 100644 --- a/egglog/src/proofs/proof_tests.rs +++ b/egglog/src/proofs/proof_tests.rs @@ -1,11 +1,15 @@ #[cfg(test)] mod tests { use crate::ast::{ - Literal, ResolvedAction, ResolvedCommand, ResolvedExpr, RuleEvalMode, - sanitize_internal_names, + GenericAction, GenericNCommand, Literal, ResolvedAction, ResolvedCommand, ResolvedExpr, + ResolvedFact, RuleEvalMode, remove_globals::remove_globals, sanitize_internal_names, }; use crate::core::ResolvedCall; + use crate::proofs::proof_checker::eval_expr_with_subst; use crate::proofs::proof_extraction::ProveExistsError; + use crate::proofs::proof_format::{ProofId, ProofStore, Proposition}; + use crate::proofs::proof_head::{Firing, HeadPlan, HeadProof, ProofAlgebra}; + use crate::util::{HashMap, HashSet, IndexMap, SymbolGen}; use crate::{ CommandOutput, EGraph, Error, ProofEncodingUnsupportedReason, TermDag, TermId, add_primitive_with_validator, @@ -16,10 +20,473 @@ mod tests { egraph.resolve_program(None, source).unwrap() } - /// The proof encoder reads body variables' `term_proof`s from the RHS via - /// `:unsafe-seminaive` lookups. Assert this produces the same database as - /// the safe baseline (the same rules annotated `:naive`), for a hardcoded - /// handful of files (running it across all tests would be too slow). + /// A bridge supply for a firing where every term the head builds is new, so + /// each one interns into itself. It never runs dry, so a walk drawing on it + /// reaches the end of the head. + fn interns_into_itself(store: &mut ProofStore, to_canonical: ProofId) -> Option { + Some(store.reflexive(to_canonical)) + } + + /// Stand a distinct constant in for every variable a rule head reads but + /// does not itself bind, so the head can be processed without a match. + fn head_input_bindings( + actions: &[ResolvedAction], + term_dag: &mut TermDag, + ) -> HashMap { + fn read(expr: &ResolvedExpr, bound: &HashSet, inputs: &mut Vec) { + expr.visit_vars(&mut |_, var| { + if !bound.contains(&var.name) && !inputs.contains(&var.name) { + inputs.push(var.name.clone()); + } + }); + } + let mut bound: HashSet = HashSet::default(); + let mut inputs = vec![]; + for action in actions { + match action { + GenericAction::Let(_, var, expr) => { + read(expr, &bound, &mut inputs); + bound.insert(var.name.clone()); + } + GenericAction::Expr(_, expr) => read(expr, &bound, &mut inputs), + GenericAction::Union(_, lhs, rhs) => { + read(lhs, &bound, &mut inputs); + read(rhs, &bound, &mut inputs); + } + GenericAction::Set(_, _, args, value) => { + for arg in args { + read(arg, &bound, &mut inputs); + } + read(value, &bound, &mut inputs); + } + GenericAction::Panic(..) | GenericAction::Change(..) => {} + } + } + inputs + .into_iter() + .map(|name| { + let term = term_dag.app(name.clone(), vec![]); + (name, term) + }) + .collect() + } + + /// A rule head's proofs are the flat array the encoder names positions of and + /// proof conversion rebuilds, so the array has to reach everything the head + /// concludes — nothing may be left with no column to be named by. Pin it: + /// walking each head states every proposition processing that head derives. + #[test] + fn walking_a_rule_head_states_every_proposition_it_concludes() { + let source = r#" + (datatype Math (Num i64) (Add Math Math) (Neg Math)) + (relation Seen (Math)) + (function Cost (Math) i64 :no-merge) + + (Add (Num 1) (Num 2)) + + (rule ((= e (Add a b))) + ((union e (Add b a))) + :name "commute") + + (rule ((= e (Add a b))) + ((let inner (Neg a)) + (let outer (Add inner b)) + (union e outer) + (Seen outer)) + :name "nest") + + (rule ((= e (Num n))) + ((set (Cost e) 1)) + :name "cost") + + ;; A `union` neither of whose operands is a matched variable, so the + ;; orientation check has to read both operands' own conclusions. + (rule ((Seen s)) + ((union (Neg s) (Add s s))) + :name "both_built") + + (run 2) + (prove (= (Add (Num 1) (Num 2)) (Add (Num 2) (Num 1)))) + "#; + + let mut egraph = EGraph::new_with_proofs(); + egraph.parse_and_run_program(None, source).unwrap(); + + let rules: Vec<_> = egraph + .proof_check_program + .iter() + .filter_map(|cmd| match cmd { + GenericNCommand::NormRule { rule } => Some(rule), + _ => None, + }) + .collect(); + let names: Vec<_> = rules.iter().map(|rule| rule.name.as_str()).collect(); + for expected in ["commute", "nest", "cost", "both_built"] { + assert!( + names.contains(&expected), + "rule '{expected}' not in {names:?}" + ); + } + + for rule in rules { + let actions = &rule.head.0; + let mut term_dag = TermDag::default(); + let inputs = head_input_bindings(actions, &mut term_dag); + + let mut minted = 0usize; + let mut fresh = || { + minted += 1; + format!("@union-operand-{minted}") + }; + let plan = HeadPlan::new(actions, &mut fresh); + let mut store = ProofStore::new(term_dag, HashMap::default(), HashSet::default()); + let expected = head_conclusions(&rule.name, actions, inputs.clone(), &mut store); + // No premises, and every term the head builds interns into itself, so + // each column states the head's conclusion there rather than one about + // an e-class it interned into — and the walk reaches the whole head. + let mut firing = Firing::new( + &rule.name, + &plan, + inputs, + vec![], + IndexMap::default(), + Box::new(interns_into_itself), + ); + let columns: Vec<_> = firing + .proofs(&mut store) + .iter() + .flatten() + .copied() + .collect(); + let stated: HashSet<_> = columns + .into_iter() + .map(|proof| store.get(proof).proposition().clone()) + .collect(); + for (what, prop) in expected { + assert!( + stated.contains(&prop), + "rule '{}' concludes {what}, which no column of its head states", + rule.name + ); + } + } + } + + /// The encoder names each rule proof row by the column its walk of the head + /// is at, and proof conversion reads that column out of the array [`Firing`] + /// builds by walking the same head. Both claim their runs from + /// [`HeadLayout`], which checks the position each is at as it goes, so the + /// two cannot disagree about a run's length. Pin what is left: every column + /// an encoded head writes names a proof the walk produces, and one the head + /// writes a row for — so a numbering that slipped within a run fails here. + #[test] + fn every_column_an_encoded_head_writes_is_one_the_walk_produces() { + let source = r#" + (datatype Math (Num i64) (Add Math Math) (Neg Math)) + (relation Seen (Math)) + (function Cost (Math) i64 :no-merge) + (let g (Num 7)) + + (Add (Num 1) (Num 2)) + + (rule ((= e (Add a b))) + ((union e (Add b a))) + :name "commute") + + (rule ((= e (Add a b))) + ((let inner (Neg a)) + (let outer (Add inner b)) + (union e outer) + (Seen outer)) + :name "nest") + + (rule ((= e (Num n))) + ((set (Cost e) 1)) + :name "cost") + + ;; A head reading a global, so `remove_globals` appends a body fact. + (rule ((Seen s)) + ((Seen (Add s g))) + :name "with_global") + + ;; A `union` of two matched variables: neither operand is built, so + ;; the orientation reads both endpoints' own conclusions. + (rule ((= e (Add a b)) (= f (Add b a))) + ((union e f)) + :name "matched_union") + + ;; A second `union` on an already-optimized variable stays a `union`, + ;; with a connector on each operand, so the orientation reads two + ;; composed columns. + (rule ((Seen s)) + ((let p (Neg s)) + (union p s) + (union p (Add s s))) + :name "chained_union") + "#; + + // Nothing above runs, so the columns below are read by this test alone + // rather than by proof conversion firing the rules. + // + // The rules as the checker replays them: the heads [`Firing`] walks. + let mut checker = EGraph::new_with_proofs(); + checker.parse_and_run_program(None, source).unwrap(); + let written: HashMap<&str, &crate::ast::ResolvedRule> = checker + .proof_check_program + .iter() + .filter_map(|cmd| match cmd { + GenericNCommand::NormRule { rule } => Some((rule.name.as_str(), rule)), + _ => None, + }) + .collect(); + + let mut encoder = EGraph::new_with_proofs(); + let commands = encoder.resolve_program(None, source).unwrap(); + let names = encoder.proof_state.proof_names.clone(); + + let mut covered: Vec<(&str, usize)> = vec![]; + for command in &commands { + let ResolvedCommand::Rule { rule } = command else { + continue; + }; + let Some(walked) = written.get(rule.name.as_str()) else { + continue; + }; + // Every rule proof row the head writes: `(set (Rule_k … col fresh) ())` + // or `(set (RuleLink prev bridge col fresh) ())`. The column sits just + // ahead of the minted id, as a literal or as the `proof-of-max` over + // the two columns a `union` orients between. + let columns: Vec = rule + .head + .0 + .iter() + .filter_map(|action| match action { + ResolvedAction::Set(_, ResolvedCall::Func(func), args, _) + if names.fused_rule_arity(&func.name).is_some() + || func.name == names.rule_link_constructor => + { + args.get(args.len().checked_sub(2)?) + } + _ => None, + }) + .flat_map(|column| { + let mut out = vec![]; + int_literals(column, &mut out); + out + }) + .collect(); + if columns.is_empty() { + continue; + } + + let actions = &walked.head.0; + let mut term_dag = TermDag::default(); + let inputs = head_input_bindings(actions, &mut term_dag); + let mut minted = 0usize; + let mut fresh = || { + minted += 1; + format!("@union-operand-{minted}") + }; + let plan = HeadPlan::new(actions, &mut fresh); + let layout = &plan.layout; + let mut store = ProofStore::new(term_dag, HashMap::default(), HashSet::default()); + let mut firing = Firing::new( + &walked.name, + &plan, + inputs, + vec![], + IndexMap::default(), + Box::new(interns_into_itself), + ); + let filled: Vec = firing + .proofs(&mut store) + .iter() + .map(Option::is_some) + .collect(); + + let mut named = 0usize; + for column in &columns { + // `-1` is the encoder's placeholder for a position the head + // concludes nothing about, whose proof nothing reads back. + if *column < 0 { + continue; + } + let column = *column as usize; + assert!( + filled.get(column).copied().unwrap_or(false), + "rule '{}' writes column {column}, which its head walk does not \ + produce (columns filled: {filled:?})", + rule.name + ); + // A head writes no row for a guest's dropped `union` — that is + // folded into the view row beside it — so a column landing on one + // is a column the walk numbers differently. + let held = layout.proof_at(column); + assert!( + !matches!(held, Some(HeadProof::DroppedEdge)), + "rule '{}' writes column {column}, which its head walk fills \ + with {held:?} — a proof no head writes a row for", + rule.name + ); + named += 1; + } + covered.push((rule.name.as_str(), named)); + } + + // Guard against a vacuous pass: every rule above must write a numbered + // column, so a head that stops writing them fails here rather than + // silently dropping out of the check. + for expected in [ + "commute", + "nest", + "cost", + "with_global", + "matched_union", + "chained_union", + ] { + let named = covered + .iter() + .find(|(name, _)| *name == expected) + .map(|(_, named)| *named) + .unwrap_or_else(|| { + panic!("rule '{expected}' wrote no rule proof row: {covered:?}") + }); + assert!( + named > 0, + "rule '{expected}' wrote only unnumbered columns: {covered:?}" + ); + } + } + + /// Every integer literal in `expr`, in order. + fn int_literals(expr: &ResolvedExpr, out: &mut Vec) { + match expr { + ResolvedExpr::Lit(_, Literal::Int(n)) => out.push(*n), + ResolvedExpr::Call(_, _, args) => { + for arg in args { + int_literals(arg, out); + } + } + ResolvedExpr::Lit(..) | ResolvedExpr::Var(..) => {} + } + } + + /// What a rule head concludes, derived independently of the walk that + /// produces its proofs: every call it evaluates exists, and every `union` + /// holds in both directions. + fn head_conclusions( + rule_name: &str, + actions: &[ResolvedAction], + mut bindings: HashMap, + store: &mut ProofStore, + ) -> Vec<(String, Proposition)> { + fn eval( + rule_name: &str, + expr: &ResolvedExpr, + bindings: &HashMap, + store: &mut ProofStore, + ) -> TermId { + eval_expr_with_subst(rule_name, expr, &mut store.term_dag, bindings) + .unwrap_or_else(|e| panic!("rule '{rule_name}' head did not evaluate: {e}")) + .0 + } + fn exists( + rule_name: &str, + expr: &ResolvedExpr, + bindings: &HashMap, + store: &mut ProofStore, + out: &mut Vec<(String, Proposition)>, + ) { + let ResolvedExpr::Call(_, _, args) = expr else { + return; + }; + for arg in args { + exists(rule_name, arg, bindings, store, out); + } + let term = eval(rule_name, expr, bindings, store); + out.push((format!("that {expr} exists"), Proposition::new(term, term))); + } + + let mut out = vec![]; + for action in actions { + match action { + GenericAction::Let(_, var, expr) => { + exists(rule_name, expr, &bindings, store, &mut out); + let term = eval(rule_name, expr, &bindings, store); + bindings.insert(var.name.clone(), term); + } + GenericAction::Expr(_, expr) => exists(rule_name, expr, &bindings, store, &mut out), + GenericAction::Union(_, lhs, rhs) => { + exists(rule_name, lhs, &bindings, store, &mut out); + exists(rule_name, rhs, &bindings, store, &mut out); + let lhs_term = eval(rule_name, lhs, &bindings, store); + let rhs_term = eval(rule_name, rhs, &bindings, store); + out.push(( + format!("{lhs} = {rhs}"), + Proposition::new(lhs_term, rhs_term), + )); + out.push(( + format!("{rhs} = {lhs}"), + Proposition::new(rhs_term, lhs_term), + )); + } + GenericAction::Set(span, func, args, value) => { + let mut row = args.to_vec(); + row.push(value.clone()); + let row = ResolvedExpr::Call(span.clone(), func.clone(), row); + exists(rule_name, &row, &bindings, store, &mut out); + } + GenericAction::Panic(..) | GenericAction::Change(..) => {} + } + } + out + } + + /// A rule proof records no terms, so a body variable bound to a value the + /// body computed — a primitive's result, a container's — reaches the + /// checker only by replaying the rule. Each case below binds one that way + /// and proves a conclusion that needs it. + #[test] + fn rule_proofs_check_with_computed_body_values() { + let cases = [ + // a computed String + r#"(relation Strings (String String)) + (Strings "hello" "world") + (rule ((Strings a b) (= res (+ a " " b))) + ((Strings "found" "hello world")) :name "concat") + (run 1) + (prove (Strings "found" "hello world"))"#, + // a non-eq container matched in the body and read + r#"(sort IVec (Vec i64)) + (relation HasVec (IVec)) + (relation VLen (i64)) + (HasVec (vec-of 1 2 3)) + (rule ((HasVec v) (= n (vec-length v))) ((VLen n)) :name "vec-len") + (run 1) + (prove (VLen 3))"#, + // a non-eq container whose read computes a base value + r#"(sort SMap (Map String i64)) + (relation HasMap (SMap)) + (relation MapVal (i64)) + (HasMap (map-insert (map-empty) "a" 7)) + (rule ((HasMap m) (= v (map-get m "a"))) ((MapVal v)) :name "map-get") + (run 1) + (prove (MapVal 7))"#, + ]; + + for source in cases { + let mut egraph = EGraph::new_with_proofs(); + egraph + .parse_and_run_program(None, source) + .unwrap_or_else(|e| panic!("{source}\nfailed: {e}")); + } + } + + /// The encoding reads `@UF` and `term_proof` from rule actions under + /// `:unsafe-seminaive` — in user rule heads and in the indexed rebuild + /// rule. Assert this produces the same database as the safe baseline (the + /// same rules annotated `:naive`), for a hardcoded handful of files + /// (running it across all tests would be too slow). #[test] fn unsafe_seminaive_matches_naive() { let files = [ @@ -33,7 +500,6 @@ mod tests { let source = std::fs::read_to_string(file) .unwrap_or_else(|e| panic!("couldn't read {file}: {e}")); - // Guard against a vacuous comparison: the two encodings must differ. let encode = |naive: bool| -> String { let mut egraph = crate::EGraph::new_with_proofs(); egraph.proof_state.force_proof_naive = naive; @@ -45,9 +511,28 @@ mod tests { .collect::>() .join("\n") }; + + // Guard against a vacuous comparison: both `:unsafe-seminaive` + // sites must be present, and the knob must flip every one of them, + // since a rule left `:unsafe-seminaive` runs identically on both + // sides. Only the rebuild rules carry `:internal-include-subsumed`. + let unsafe_encoding = encode(false); + let (rebuild, rule_head): (Vec<&str>, Vec<&str>) = unsafe_encoding + .lines() + .filter(|line| line.contains(":unsafe-seminaive")) + .partition(|line| line.contains(":internal-include-subsumed")); + assert!( + !rule_head.is_empty(), + "expected {file} to encode a rule head `:unsafe-seminaive`" + ); + assert!( + !rebuild.is_empty(), + "expected {file} to encode the rebuild rule `:unsafe-seminaive`" + ); assert!( - encode(false).contains(":unsafe-seminaive") && encode(false) != encode(true), - "expected {file} to exercise the `:unsafe-seminaive` encoding path" + !encode(true).contains(":unsafe-seminaive"), + "`force_proof_naive` left `:unsafe-seminaive` in {file}, so the \ + comparison does not cover those rules" ); // `print-size` summarizes the whole database (per-function row @@ -140,8 +625,15 @@ mod tests { "#; let mut egraph = EGraph::new_with_proofs(); - let rule_constructor = egraph.proof_state.proof_names.rule_constructor.clone(); let commands = egraph.resolve_program(None, source).unwrap(); + // Only the rows carrying premises inline name the rule; a later column's + // link reads the name off the row it chains onto. + let names = &egraph.proof_state.proof_names; + let rule_constructors: HashSet = names + .rule_fused_declared + .iter() + .map(|arity| names.fused_rule(*arity)) + .collect(); let rule = commands .iter() .find_map(|command| match command { @@ -180,17 +672,17 @@ mod tests { ); let rule_name_var = rule_name_vars[0]; - // Proof constructors are relations, so each `Rule` proof is emitted as a - // `(set (@Rule ) ())` action, not a - // call expression. Count those set actions and check they reuse the hoisted - // rule-name variable as their first argument. + // Proof constructors are relations, so each rule proof is emitted as a + // `(set (@Rule_1 ) ())` action — the rule + // has one body fact — not a call expression. Count those set actions and + // check they reuse the hoisted rule-name variable as their first argument. let rule_uses = rule .head .0 .iter() .filter(|action| match action { ResolvedAction::Set(_, ResolvedCall::Func(func), args, _) - if func.name == rule_constructor => + if rule_constructors.contains(&func.name) => { assert!( matches!( @@ -214,6 +706,170 @@ mod tests { .expect("hoisted rule-name proof should pass the checker"); } + /// A rule proof carries its premises inline, in a constructor declared per + /// premise count ahead of the program that needs it. A program run in pieces + /// must therefore declare the arities each piece introduces, not just the + /// first. + #[test] + fn rule_premise_arities_are_declared_per_program() { + let mut egraph = EGraph::new_with_proofs(); + egraph + .parse_and_run_program( + None, + "(datatype Math (Add Math Math) (Num i64)) + (rewrite (Add a b) (Add b a))", + ) + .unwrap(); + assert!( + !egraph + .proof_state + .proof_names + .rule_fused_declared + .contains(&2), + "the first program has no two-premise rule, so it must not declare that arity" + ); + // Two body facts, so a premise count the first program never declared. + egraph + .parse_and_run_program( + None, + "(relation Seed (Math)) + (rule ((Seed x) (= x (Add a b))) ((union x (Add b (Add a b))))) + (Seed (Add (Num 1) (Num 2))) + (run 2) + (prove (= (Add (Num 1) (Num 2)) + (Add (Num 2) (Add (Num 1) (Num 2)))))", + ) + .unwrap(); + assert!( + egraph + .proof_state + .proof_names + .rule_fused_declared + .contains(&2), + "the second program's two-premise rule should have declared that arity" + ); + } + + /// The encoder records one premise per body fact of the rule *after* + /// `remove_globals` appends a lookup fact per global the head mentions, while + /// the proof checker replays the rule as written, without those facts. Proof + /// conversion pairs premises with written facts by position, so the premise + /// count must cover the written body — the extras are exactly the trailing + /// ones. + #[test] + fn rule_premises_cover_the_written_body_facts() { + let source = r#" + (datatype Math (Add Math Math) (Num i64)) + (relation Seen (Math)) + (let g (Num 7)) + ;; One written body fact, and a head that reads a global. + (rule ((Seen x)) ((Seen (Add x g))) :name "with_global") + ;; Two written body facts and no global. + (rule ((Seen x) (= x (Add a b))) ((Seen a)) :name "without_global") + (Seen (Num 1)) + (run 2) + (prove (Seen (Add (Num 1) (Num 7)))) + "#; + + // The rules as the checker replays them: before `remove_globals`. + let mut checker = EGraph::new_with_proofs(); + checker.parse_and_run_program(None, source).unwrap(); + let written: HashMap> = checker + .proof_check_program + .iter() + .filter_map(|cmd| match cmd { + GenericNCommand::NormRule { rule } => Some((rule.name.clone(), rule.body.clone())), + _ => None, + }) + .collect(); + + // The rules as the encoder emits them: the premise count is the arity of + // the `Rule_` constructor each head writes. + let mut encoder = EGraph::new_with_proofs(); + let commands = encoder.resolve_program(None, source).unwrap(); + let names = encoder.proof_state.proof_names.clone(); + let mut recorded: HashMap = HashMap::default(); + for command in &commands { + let ResolvedCommand::Rule { rule } = command else { + continue; + }; + if !written.contains_key(&rule.name) { + continue; + } + let premises = rule + .head + .0 + .iter() + .filter_map(|action| match action { + ResolvedAction::Set(_, ResolvedCall::Func(func), _, _) => { + names.fused_rule_arity(&func.name) + } + _ => None, + }) + .max() + .unwrap_or_else(|| panic!("rule '{}' wrote no inline rule proof", rule.name)); + recorded.insert(rule.name.clone(), premises); + } + + // Holds for every rule the checker replays, including the one `prove` + // generates. + for (name, premises) in &recorded { + let facts = written[name].len(); + assert!( + *premises >= facts, + "rule '{name}' recorded {premises} premises for a body of {facts} written facts" + ); + } + // Pin both sides of the inequality, so neither half can drift unnoticed: + // the global reference adds exactly one trailing lookup fact, and a rule + // without one records exactly its written facts. + assert_eq!( + recorded.get("with_global").copied(), + Some(written["with_global"].len() + 1), + "a head reading a global should record one extra premise" + ); + assert_eq!( + recorded.get("without_global").copied(), + Some(written["without_global"].len()), + "a rule mentioning no global should record one premise per written fact" + ); + + // The extras being *trailing* is what makes pairing by position correct, + // and it rests entirely on `remove_globals` mapping the written facts in + // place and appending the lookups after them. No written body here + // mentions a global, so the mapping is the identity and the prefix + // compares exactly. + let removed = remove_globals( + checker.proof_check_program.clone(), + &mut SymbolGen::new("premise_order".to_string()), + ); + let mut compared = 0; + for command in &removed { + let GenericNCommand::NormRule { rule } = command else { + continue; + }; + let before = &written[&rule.name]; + assert!( + rule.body.len() >= before.len(), + "`remove_globals` dropped a body fact of rule '{}'", + rule.name + ); + for (at, (after, before)) in rule.body.iter().zip(before).enumerate() { + assert_eq!( + after, before, + "rule '{}' fact {at} is not the written one after `remove_globals`", + rule.name + ); + } + compared += 1; + } + assert_eq!( + compared, + written.len(), + "every rule the checker replays should have been compared" + ); + } + #[test] fn proof_mode_allows_eq_sort_primitive_results_in_facts() { let mut egraph = EGraph::default(); diff --git a/egglog/src/proofs/rebuild_cost.md b/egglog/src/proofs/rebuild_cost.md new file mode 100644 index 00000000..7557e43b --- /dev/null +++ b/egglog/src/proofs/rebuild_cost.md @@ -0,0 +1,204 @@ +# Where the encoding's rebuild time goes + +Measurements against `5cc4dc1` (PR #39's head), serial (`--threads 1`), release, +`bench.py` with 3–6 rounds per endpoint. Two questions: is clearing `@UF` every +iteration worth it, and what makes the encoded rebuild slower than native's. + +## Clearing `@UF` every iteration does not pay + +The union-find's only readers are the maintenance rules, so once the rebuild loop +saturates every row a query, `delete`, or `subsume` can reach is canonical and the +edges that got it there are dead. Deleting them is *sound* — with a +delete-everything ruleset running last in the maintenance schedule, all 801 +`--test files` cases pass and every proof snapshot is byte-identical. + +It is not *faster*, at 6 rounds: + +| | baseline | clear | ratio | +| --- | ---: | ---: | ---: | +| `math-microbenchmark` wall | 4483 ms | 4511 ms | 1.006 | +| `eggcc-2mm-pass1` wall | 9709 ms | 9971 ms | 1.027 | +| `eggcc-2mm-pass1` peak RSS | 525 MiB | 568 MiB | 1.082 | + +Per ruleset, the clear costs `+134 ms` while `@parent` saves `-27 ms`, and +`@rebuilding` does not move outside noise. The reason is that the rebuild rule is +`:unsafe-seminaive` and its driving `@UF` atom is therefore already restricted to +the delta: the rows a clear removes are ones the join never visits. `@parent` +joins `@UF` against itself, so its full side does shrink — which is the whole of +the measured win, and it is 0.6% of one benchmark. + +The ceiling is low regardless. Time in `@parent` on the baseline is 3.0% of wall +on `math-microbenchmark`, 1.2% on `herbie`, and 0.2% on `eggcc-2mm-pass1`, so a +clear that cost nothing could not win more than that. + +Two things also break, which is why the knob is off by default: + +- **Canonicalizing a value from an earlier iteration stops working.** + `find_canonical` and proof extraction's fall-back read `@UF` outside rebuilding + to resolve a value that predates this iteration. Surface syntax never needs it + (queries read views, which are canonical, and `(extract e)` interns `e` first), + but a Rust-API caller holding a `Value` across a union does, and native + resolves those. +- **`saturate` can stop terminating.** `(rule ((Same x y)) ((union x y)) :naive)` + under `(saturate ...)` terminates normally and hangs with the clear on: once + `x` and `y` are canonically equal the re-firing rule writes the self-edge + `v -> v`, which `:internal-identity-vals 1` normally makes an idempotent + no-op. After a clear the row is absent, so the re-`set` is a fresh insert and + the loop always reports a change. Adding `(!= x y)` fixes that program, and + suppressing self-edges at the source would fix the class — a `@UF` row whose + key is its own parent carries no information, since a term with no row is + already its own representative. + +A whole-table `clear_table` fast path (already present end to end, from +`Database::clear_table` through `EGraph::clear_function`) would remove the +`+134 ms` but not the reason there is nothing to win, and would not help the +termination case at all, where the change is reported by the re-*insert*. + +## The encoded rebuild is ~4.5x native's, and always takes the incremental branch + +Encoded proofs against native (`--treatment proofs` vs `off`), fastest of 3: + +| file | native | proofs | ratio | native rebuild | `@rebuilding`+`@parent` | +| --- | ---: | ---: | ---: | ---: | ---: | +| `math-microbenchmark` | 1035 ms | 4502 ms | 4.35x | 386 ms | 1679 ms | +| `eggcc-2mm-pass1` | 2717 ms | 9783 ms | 3.60x | 581 ms | 2682 ms | +| `herbie` | 130 ms | 564 ms | 4.34x | 5 ms | 47 ms | +| `hardboiled_conv1d_32` | 323 ms | 1127 ms | 3.49x | 0 ms | 33 ms | +| `luminal-llama` | 1120 ms | 8238 ms | 7.35x | 6 ms | 13 ms | + +Rebuild excess is 30–37% of the encoding's total overhead on the two files where +rebuilding matters, and search is the larger half of it: rebuild search on +`math-microbenchmark` is 890 ms, itself 2.3x native's entire rebuild. As a share +of all search time, maintenance is 79% on `math-microbenchmark`, 55% on `herbie`, +21% on `eggcc-2mm-pass1` — and 0.5% on `luminal-llama`, whose 7.35x is the worst +of the set and has nothing to do with rebuilding. + +Rebuild is not the whole of the search gap, and on most files it is not even the +larger part. Search time only, native against encoded: + +| file | native | encoded | of which user rules | of which maintenance | +| --- | ---: | ---: | ---: | ---: | +| `luminal-llama` | 28 ms | 1683 ms | **1674 ms** | 9 ms | +| `eggcc-2mm-pass1` | 1659 ms | 2933 ms | **2323 ms** | 609 ms | +| `hardboiled_conv1d_32` | ~0 ms | 317 ms | **300 ms** | 16 ms | +| `math-microbenchmark` | 221 ms | 1129 ms | 240 ms | **890 ms** | +| `herbie` | 16 ms | 42 ms | 19 ms | 23 ms | + +Maintenance dominates search on `math-microbenchmark` and `herbie` only. Elsewhere +the cost is in the rewritten *user* queries — most sharply on `luminal-llama`, +where encoded user-rule search is 60x native's entire search while its rebuild is +9 ms. Whatever the encoded rebuild is made to cost, that file does not improve. +The two are separate problems and the rest of this note is about the smaller one. + +The user-rule cost is **not** join width. Comparing the two desugared programs +suggests it is — `matmul_backend`'s bodies go from a median of 13 top-level forms +to 38 — but that comparison is invalid. `GenericExprExt::to_query` in `core.rs` +flattens every nested `Call` into one atom per subterm, so native's + +```text +(= ?cast (Op (Cast ?size (F32)) (ICons ?matmul (INil)))) +``` + +compiles to the same four atoms the encoding writes out longhand. Native's +desugared *text* keeps the nesting; the encoding's does not. On the rule +`"cublaslt bf16 matmul cast f32 output"` both come to eleven real atoms — +`Op`, `cublaslt`, four `Bf16`, `Op`, `Cast`, `F32`, `ICons`, `INil` — and the +encoding adds two `(= ?matmul )` aliases a compiler substitutes away. +Body shape and atom count therefore match, and the 60x is elsewhere. + +Rebuild churn is not the explanation either: `num_matches_per_rule` totals 12550 +under both, so every rule fires the same number of times and no extra deltas +exist. The e-graph is identical too (129 functions, 15127 rows, no table +differing). + +It is the **query planner's tree decomposition**. With `--no-decomp` the plans +become the same 14/15 stages in the same order, and the encoded time reaches +parity: over the 254 rules present in both, search+apply is 523 ms native vs +1225 ms encoded with decomposition, and **166 ms vs 158 ms without**. With +decomposition on, the encoded plan covers the 1301-row `@IConsView` in its outer +loop where native's covers the 12-row `FusionEnd`. + +Turning decomposition off is a large win on its own, for native as much as for the +encoding — `luminal-llama` goes 1019 ms to 487 ms native and 3845 ms to 2404 ms in +term mode — but it is not uniform: `math-microbenchmark` is slightly worse without +it. The heuristic is also unstable under small changes to a query, which is what +makes the next section's result so hard to read. + +## A real bug in `remove_dup_vars`, and why it is not landed + +`remove_dup_vars` implements egglog's FD-based duplicate elimination: two atoms +over one function with equal inputs must agree on the output, so one is dropped +and an equality recorded. It splits the **last** argument as the output and keys +on the rest, which assumes a single value column. A tuple-output function has two, +so the key keeps the e-class — and two reads of one row bind that to different +variables, so no group ever forms. Every one of the encoding's FD views is +tuple-output, so repeated subterms are never shared: native's plan reaches one +`@INil1236` from both `ICons` probes where the encoding has two variables. + +Keying on the inputs and unifying every output column fixes it, and does what it +should: the encoded plan for `grow-FE-B-lhs-Mul` becomes structurally identical to +native's, sharing one variable across both probes. It is **not** landed, because +the planner's sensitivity turns it into a loss where it matters: + +| geomean vs `5cc4dc1`, 3 rounds | ratio | +| --- | ---: | +| native (`off`) | 1.010 | +| term encoding | 0.943 | +| proofs | ~1.09 | + +Native is flat on five of six files but `herbie` is reproducibly **1.087** at 8 +rounds (127 ms to 138 ms). Proofs regresses on `luminal-llama` (8073 ms to +11462 ms) — the same file and the same change that term mode runs 23% *faster*. +Isolating it on that file shows the fix is a real 8% win and decomposition is what +punishes the new query shape: + +| `luminal-llama`, proofs | decomposition on | `--no-decomp` | +| --- | ---: | ---: | +| baseline | 8126 ms | 6950 ms | +| with the fix | 11397 ms | 6410 ms | + +Pairing the fix with `--no-decomp` is not uniform either: `hardboiled_conv1d_32` +runs 0.829 in term mode and 1.422 in proofs. Per-file swings of ±40% in both +directions from one change are the signature of a plan heuristic being chosen by +luck, so the decomposition and costing work has to come first. The fix is one +commit, reverted in place, and worth re-applying against a planner that costs +these queries stably. + +Native picks per table, per round, between scanning only the recently-updated +subset and scanning the whole table, at `diff > table_size / 8` +(`core-relations/src/table/rebuild.rs`). Tracing that choice: + +| workload | decisions | fullscan | median `diff/table_size` | +| --- | ---: | ---: | ---: | +| `math-microbenchmark` | 429 | **93.2%** | 1.000 | +| `eggcc-2mm-pass1` | 336340 | **99.8%** | 0.800 | + +So native almost always scans, and the encoding has no way to: its rebuild rule +is driven by the `@UF` delta joined against a declared occurrence index, and an +index atom is probed rather than scanned by construction. On these workloads the +encoding therefore pays twice — maintaining an index native would not consult, +then probing it per delta row instead of making one sequential pass. + +Scanning the index is not the fix: `(any 0 1 2)` holds one entry per row *per +listed column*, so scanning it visits each row three times. The scan has to be +over the view, which makes it a different rule body rather than a different plan +for the same rule — no cost-based planner change can reach it. + +The shape that scan wants already exists in the encoding, as the container +rebuild rule: `:naive`, canonicalize in the body, guard on the column having +moved. Two ways to choose between the shapes, neither needing the backend to +recognize a rule: + +- A body guard that is cheap and fails fast — a primitive comparing the `@UF` + delta against the view's size, ordered first so a losing round never reaches + the scan. +- A per-view scan rule per column (which is what the index-driven rule replaced), + costing one scan per column rather than one per view. + +The higher-ceiling option is a bulk view-rebuild primitive that does the scan and +the canonicalization in Rust and mints the proof rows itself, which is what +`proof_container_rebuild.rs` already does for containers. That is the only option +that also removes the interpreted-join overhead, rather than trading one join +shape for another. + +`EGGLOG_REBUILD_TRACE=1` prints native's per-table branch and its two inputs. diff --git a/egglog/src/proofs/snapshots/egglog__proofs__proof_tests__tests__doc_example_add_function1.snap b/egglog/src/proofs/snapshots/egglog__proofs__proof_tests__tests__doc_example_add_function1.snap index 39c3c425..e7395abc 100644 --- a/egglog/src/proofs/snapshots/egglog__proofs__proof_tests__tests__doc_example_add_function1.snap +++ b/egglog/src/proofs/snapshots/egglog__proofs__proof_tests__tests__doc_example_add_function1.snap @@ -1,5 +1,6 @@ --- source: egglog/src/proofs/proof_tests.rs +assertion_line: 1225 expression: snapshot --- (ruleset __parent) @@ -15,37 +16,40 @@ expression: snapshot :ruleset __parent :name "__uf_path_compress") (function Add (i64 i64 Math) Unit :no-merge :unextractable :internal-hidden :internal-term-node) (function __AddView (i64 i64) (Math Unit) :merge ((set (__UF_Math (ordering-max old0 new0)) (values (ordering-min old0 new0) ())) (values (ordering-min old0 new0) ())) :internal-term-constructor Add :internal-identity-vals 1) +(index __AddOcc_Math __AddView (any 2)) (function __to_delete_Add (i64 i64) Unit :no-merge :unextractable :internal-hidden) (function __to_subsume_Add (i64 i64) Unit :no-merge :unextractable :internal-hidden) (rule ((__to_delete_Add c0_ c1_) - (= (values __v __v1) (__AddView c0_ c1_))) + (= (values __pv __pv1) (__AddView c0_ c1_))) ((delete (__AddView c0_ c1_)) (delete (__to_delete_Add c0_ c1_))) :ruleset __delete_subsume_ruleset :name "__delete_rule") (rule ((__to_subsume_Add c0_ c1_) - (= (values __v2 __v3) (__AddView c0_ c1_))) + (= (values __pv2 __pv3) (__AddView c0_ c1_))) ((subsume (__AddView c0_ c1_))) :ruleset __delete_subsume_ruleset :name "__delete_rule_subsume") -(rule ((= (values __v4 __v5) (__AddView c0_ c1_)) - (= (values __v6 __v7) (__UF_Math __v4)) - (!= __v4 __v6)) - ((set (__AddView c0_ c1_) (values __v6 ()))) - :ruleset __rebuilding :name "__rebuild_rule" :internal-include-subsumed) +(rule ((= (values __pv5 __pv6) (__UF_Math __pv4)) + (!= __pv4 __pv5) + (__AddOcc_Math __pv4 c0_ c1_ e2_ __pv7)) + ((let e2_canon_ (__UF_Math_canon e2_ e2_)) + (delete (__AddView c0_ c1_)) + (set (__AddView c0_ c1_) (values e2_canon_ ()))) + :ruleset __rebuilding :name "__rebuild_rule" :unsafe-seminaive :internal-include-subsumed) (begin - (let __v8 (get-fresh! "Math")) - (set (Add 1 2 __v8) ()) - (let __v9 (set-if-empty-__AddView! 1 2 __v8 ())) + (let __pv8 (get-fresh! "Math")) + (set (Add 1 2 __pv8) ()) + (let __pv9 (set-if-empty-__AddView! 1 2 __pv8 ())) ) -(rule ((= (values __v10 __v11) (__AddView a b))) - ((let __v13 (get-fresh! "Math")) - (set (Add a b __v13) ()) - (let __v14 (set-if-empty-__AddView! a b __v13 ())) - (let __union_operand __v14) +(rule ((= (values __pv10 ()) (__AddView a b))) + ((let __pv11 (get-fresh! "Math")) + (set (Add a b __pv11) ()) + (let __pv12 (set-if-empty-__AddView! a b __pv11 ())) + (let __union_operand __pv12) (set (Add b a __union_operand) ()) (set (__AddView b a) (values __union_operand ())) (let __union_operand1 __union_operand)) :name "commutativity") -(check (= (values __v15 __v16) (__AddView 1 2)) -(= (values __v17 __v18) (__AddView 2 1)) -(= __v15 __v17)) +(check (= (values __pv13 ()) (__AddView 1 2)) +(= (values __pv14 ()) (__AddView 2 1)) +(= __pv13 __pv14)) (run-schedule (seq (saturate (seq (run __rebuilding_cleanup) (saturate (run __parent)) (run __rebuilding))) (run __delete_subsume_ruleset))) diff --git a/egglog/src/proofs/snapshots/egglog__proofs__proof_tests__tests__doc_example_add_function2.snap b/egglog/src/proofs/snapshots/egglog__proofs__proof_tests__tests__doc_example_add_function2.snap index f65fc5f6..87f630c9 100644 --- a/egglog/src/proofs/snapshots/egglog__proofs__proof_tests__tests__doc_example_add_function2.snap +++ b/egglog/src/proofs/snapshots/egglog__proofs__proof_tests__tests__doc_example_add_function2.snap @@ -1,5 +1,6 @@ --- source: egglog/src/proofs/proof_tests.rs +assertion_line: 1202 expression: snapshot --- (ruleset __parent) @@ -12,14 +13,14 @@ expression: snapshot (function __to_delete_add (i64 i64) Unit :no-merge :unextractable :internal-hidden) (function __to_subsume_add (i64 i64) Unit :no-merge :unextractable :internal-hidden) (rule ((__to_delete_add c0_ c1_) - (= (values __v __v1) (__addView c0_ c1_))) + (= (values __pv __pv1) (__addView c0_ c1_))) ((delete (__addView c0_ c1_)) (delete (__to_delete_add c0_ c1_))) :ruleset __delete_subsume_ruleset :name "__delete_rule") (rule ((__to_subsume_add c0_ c1_) - (= (values __v2 __v3) (__addView c0_ c1_))) + (= (values __pv2 __pv3) (__addView c0_ c1_))) ((subsume (__addView c0_ c1_))) :ruleset __delete_subsume_ruleset :name "__delete_rule_subsume") -(check (= (values __n __v4) (__addView 0 0)) +(check (= (values __n ()) (__addView 0 0)) (= __n 0)) (run-schedule (seq (saturate (seq (run __rebuilding_cleanup) (saturate (run __parent)) (run __rebuilding))) (run __delete_subsume_ruleset))) diff --git a/egglog/src/typechecking.rs b/egglog/src/typechecking.rs index 45951e76..5dd6fef2 100644 --- a/egglog/src/typechecking.rs +++ b/egglog/src/typechecking.rs @@ -241,6 +241,18 @@ pub struct TypeInfo { pub(crate) global_sorts: HashMap, /// Sorts that do not allow union (e.g., from `:no-union` sorts or relations). pub(crate) non_unionable_sorts: HashSet, + /// Declared indexes, by the name their atoms are written with. + pub(crate) indexes: HashMap, +} + +/// A declared index: a read-only relation over the rows of `function`, holding +/// each value appearing in `any_of` followed by the whole row. +#[derive(Clone, Debug)] +pub struct IndexInfo { + pub function: String, + /// Column indices of `function`'s row (its inputs then its outputs), read + /// disjunctively. + pub any_of: Vec, } // These methods need to be on the `EGraph` in order to @@ -468,6 +480,70 @@ impl EGraph { Ok(result) } + /// Validate an index declaration and register it as a read-only relation + /// `(value, )`, so its atoms resolve like any other. + fn typecheck_index( + &mut self, + span: &Span, + name: &str, + function: &str, + any_of: &[usize], + ) -> Result<(), TypeError> { + if self.type_info.func_types.contains_key(name) { + return Err(TypeError::FunctionAlreadyBound( + name.to_owned(), + span.clone(), + )); + } + let ft = self + .type_info + .get_func_type(function) + .ok_or_else(|| TypeError::UnboundFunction(function.to_owned(), span.clone()))?; + // The indexable row is the function's inputs followed by its outputs. + let row: Vec = ft.input.iter().chain(ft.outputs.iter()).cloned().collect(); + if any_of.is_empty() { + return Err(TypeError::EmptyIndex(name.to_owned(), span.clone())); + } + let mut value_sort: Option = None; + for &col in any_of { + let sort = row.get(col).ok_or_else(|| { + TypeError::IndexColumnOutOfRange(name.to_owned(), col, row.len(), span.clone()) + })?; + match &value_sort { + None => value_sort = Some(sort.clone()), + Some(prev) if prev.name() == sort.name() => {} + Some(prev) => { + return Err(TypeError::IndexColumnSortMismatch( + name.to_owned(), + prev.name().to_owned(), + sort.name().to_owned(), + span.clone(), + )); + } + } + } + let mut input = vec![value_sort.expect("any_of is non-empty")]; + input.extend(row); + let unit = self.type_info.sorts.get("Unit").expect("Unit sort").clone(); + self.type_info.func_types.insert( + name.to_owned(), + FuncType { + name: name.to_owned(), + subtype: FunctionSubtype::Custom, + input, + outputs: vec![unit], + }, + ); + self.type_info.indexes.insert( + name.to_owned(), + IndexInfo { + function: function.to_owned(), + any_of: any_of.to_vec(), + }, + ); + Ok(()) + } + fn typecheck_command(&mut self, command: &NCommand) -> Result { let symbol_gen = &mut self.parser.symbol_gen; @@ -527,6 +603,14 @@ impl EGraph { self.proof_state .uf_parent .insert(name.clone(), uf_ctor.clone()); + // The rebuild rules canonicalize a term in their action + // through these, derived from the sort's `@UF_` table. + crate::proofs::proof_container_rebuild::register_uf_canon( + self, + name, + uf_ctor, + proof_func.is_some(), + ); } if let Some(pf) = proof_func { self.proof_state @@ -671,6 +755,20 @@ impl EGraph { ), NCommand::Pop(span, n) => ResolvedNCommand::Pop(span.clone(), *n), NCommand::Push(n) => ResolvedNCommand::Push(*n), + NCommand::Index { + span, + name, + function, + any_of, + } => { + self.typecheck_index(span, name, function, any_of)?; + ResolvedNCommand::Index { + span: span.clone(), + name: name.clone(), + function: function.clone(), + any_of: any_of.clone(), + } + } NCommand::AddRuleset(span, ruleset) => { ResolvedNCommand::AddRuleset(span.clone(), ruleset.clone()) } @@ -1445,6 +1543,18 @@ pub enum TypeError { expected: ArcSort, actual: ArcSort, }, + #[error("{1}\nIndex {0} lists no columns to index")] + EmptyIndex(String, Span), + #[error("{3}\nIndex {0} refers to column {1}, but the indexed row has {2} columns")] + IndexColumnOutOfRange(String, usize, usize, Span), + #[error("{3}\nIndex {0} mixes columns of sort {1} and {2}; an index reads one sort")] + IndexColumnSortMismatch(String, String, String, Span), + #[error( + "{2}\nIndex {0} is looked up by {1}, which no other atom binds. An index atom is probed, so its value must be bound elsewhere in the query." + )] + IndexValueUnbound(String, String, Span), + #[error("{1}\nIndex {0} is maintained by the database and cannot be written to")] + IndexIsReadOnly(String, Span), #[error("{1}\nUnbound symbol {0}")] Unbound(String, Span), #[error( diff --git a/egglog/src/util.rs b/egglog/src/util.rs index 92106435..6816af33 100644 --- a/egglog/src/util.rs +++ b/egglog/src/util.rs @@ -5,6 +5,7 @@ pub(crate) type HashMap = hashbrown::HashMap; pub(crate) type HashSet = hashbrown::HashSet; pub(crate) type HEntry<'a, A, B> = hashbrown::hash_map::Entry<'a, A, B, BuildHasher>; pub type IndexMap = indexmap::IndexMap; +pub(crate) type IEntry<'a, A, B> = indexmap::map::Entry<'a, A, B>; pub type IndexSet = indexmap::IndexSet; pub use egglog_ast::generic_ast_helpers::INTERNAL_SYMBOL_PREFIX; diff --git a/egglog/tests/custom-eqsort-output-rebuild.egg b/egglog/tests/custom-eqsort-output-rebuild.egg new file mode 100644 index 00000000..07e611fb --- /dev/null +++ b/egglog/tests/custom-eqsort-output-rebuild.egg @@ -0,0 +1,20 @@ +;; A custom function whose output is an eq-sort: when that output's e-class +;; moves, the row is rebuilt by rewriting the output child, so its row proof +;; composes by `Congr` at the output position. A constructor's value column is +;; the term's own e-class and composes the other way round, and applying that +;; shape here produces a proof the checker rejects. + +(datatype Math (Num i64) (W Math)) +(function f (Math) Math :merge old) +(ruleset r) +(rule ((= o (f k))) ((union (W k) o)) :ruleset r) + +(let $a (Num 1)) +(let $b (Num 2)) +(let $c (Num 3)) +(set (f $a) $c) +(union $b $c) + +(run-schedule (saturate (run))) +(run-schedule (saturate (run r))) +(check (= (W $a) $b)) diff --git a/egglog/tests/index_any.egg b/egglog/tests/index_any.egg new file mode 100644 index 00000000..a33215b6 --- /dev/null +++ b/egglog/tests/index_any.egg @@ -0,0 +1,111 @@ +;; A declared index is a read-only relation over the rows of a function: each +;; value appearing in any of the listed columns, followed by the whole row. It +;; is the "which rows mention this value" lookup, which no ordinary atom +;; expresses -- repeating a variable across columns would instead constrain +;; those columns to be equal. + +(datatype Math (Num i64) (Add Math Math)) +(function edge (Math Math) Math :merge old) + +;; Columns 0 and 1 are the inputs, column 2 the output: all three are indexed, +;; so a value is found wherever it sits in the row. +(index EdgeOcc edge (any 0 1 2)) + +(relation dirty (Math)) +(relation touched (Math Math Math)) + +(let $a (Num 1)) +(let $b (Num 2)) +(let $c (Num 3)) +(set (edge $a $b) $c) +(set (edge $b $c) $a) +;; Both endpoints are the same value, and the row must still be reported once. +(set (edge $c $c) $c) + +(rule ((dirty x) (EdgeOcc x p q r)) + ((touched p q r))) + +;; (Num 2) sits at an input of the first row and at an input of the second. +(dirty $b) +(run 1) +(check (touched $a $b $c)) +(check (touched $b $c $a)) +(fail (check (touched $c $c $c))) + +;; (Num 1) sits at an input of the first row and at the *output* of the second. +(dirty $a) +(run 1) +(check (touched $a $b $c)) +(check (touched $b $c $a)) + +;; (Num 3) appears in every row, twice over in the third. +(dirty $c) +(run 1) +(check (touched $c $c $c)) +(print-size touched) + +;; --- Rules that earlier planner work got wrong --------------------------- + +(relation seed (Math Math)) +(relation triple (Math Math Math)) +(relation selfcol (Math Math Math)) + +;; Three body atoms: the query planner must see the edge between the atom +;; binding the value and the index atom, rather than treating them as +;; disconnected (or failing to plan the rule at all). +(rule ((dirty x) (EdgeOcc x p q r) (seed p r)) + ((triple p q r))) + +;; The cover binds two variables of the index atom at once — the occurring +;; value and another column — so a trie node reused across frames must not +;; answer from the index it built for the previous frame's subset. Several +;; bindings that cross-combine `p` and `x` are what expose a stale one: it +;; reports a row that does not exist and loses the ones that do. +(constructor link (Math Math) Math) +(index LinkOcc link (any 0 1 2)) +(relation crossed (Math Math)) +(relation linked (Math Math Math)) +(let $l1 (Num 11)) (let $l2 (Num 12)) (let $l3 (Num 13)) +(let $r1 (Num 21)) (let $r2 (Num 22)) (let $r3 (Num 23)) +(union (link $l1 $r1) (Num 31)) +(union (link $l2 $r2) (Num 32)) +(union (link $l3 $r3) (Num 33)) +(crossed $l1 $r1) (crossed $l2 $r1) (crossed $l2 $r2) +(crossed $l3 $r2) (crossed $l3 $r3) (crossed $l1 $r3) +(rule ((crossed p x) (LinkOcc x p q r)) + ((linked p q r))) + +;; The occurring value also sits at one of the indexed columns. The occurrence +;; is then implied, and the atom means the same as an ordinary row match. +(rule ((dirty x) (EdgeOcc x x q r)) + ((selfcol x q r))) + +(seed $a $c) +(run 1) +;; edge($a,$b)=$c is the only row; $a occurs at column 0 and $c at column 2. +(check (triple $a $b $c)) +;; Exactly the three real rows, and nothing recombined across them. +(check (linked $l1 $r1 (Num 31))) +(check (linked $l2 $r2 (Num 32))) +(check (linked $l3 $r3 (Num 33))) +(fail (check (linked $l2 $r1 (Num 31)))) +(check (selfcol $a $b $c)) +(fail (check (selfcol $b $b $c))) + +;; --- An index reads the same rows the function itself would --------------- + +;; Subsumption is one of those rows' properties, so an ordinary rule must not +;; reach a subsumed row through an index when a plain atom would not. +(constructor arc (Math Math) Math) +(index ArcOcc arc (any 0 1 2)) +(relation viaindex (Math Math Math)) +(relation viaplain (Math Math)) +(let $s1 (Num 41)) (let $s2 (Num 42)) +(union (arc $s1 $s2) (Num 43)) +(subsume (arc $s1 $s2)) +(dirty $s2) +(rule ((dirty x) (ArcOcc x p q r)) ((viaindex p q r))) +(rule ((dirty x) (= r (arc p x))) ((viaplain p r))) +(run 1) +(fail (check (viaindex $s1 $s2 (Num 43)))) +(fail (check (viaplain $s1 (Num 43)))) diff --git a/egglog/tests/snapshots/files__proof_unsupported_files.snap b/egglog/tests/snapshots/files__proof_unsupported_files.snap index 9756dc15..44180d78 100644 --- a/egglog/tests/snapshots/files__proof_unsupported_files.snap +++ b/egglog/tests/snapshots/files__proof_unsupported_files.snap @@ -15,6 +15,7 @@ factoring-multisets.egg fusion.egg hardboiled_conv1d_128.egg herbie-tutorial.egg +index_any.egg interval.egg lambda.egg levenshtein-distance.egg diff --git a/egglog/tests/snapshots/files__proofs__antiunify_proof_testing.snap b/egglog/tests/snapshots/files__proofs__antiunify_proof_testing.snap index d66c335c..3c065715 100644 --- a/egglog/tests/snapshots/files__proofs__antiunify_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__antiunify_proof_testing.snap @@ -27,7 +27,7 @@ expression: proof_snapshot (= t1 t6) (name "(rewrite (Add x y) (Add y x))") (premises (Fiat (= t1 t1))) - (substitution (y t0) (x (Var "x")) (@rewrite_var__ t1))) + (substitution (@rewrite_var__ t1) (x (Var "x")) (y t0))) 0) (Congr (= t2 t7) (Fiat (= t2 t2)) (Sym (= (Num 3) t0) prf0) 0) 1))) @@ -35,9 +35,9 @@ expression: proof_snapshot (substitution (@rewrite_var__3 t3) (a t0) - (d (Var "y")) (b (Var "x")) - (c t0))) + (c t0) + (d (Var "y")))) (Congr (= t3 (Add (Num 3) t4)) (Congr diff --git a/egglog/tests/snapshots/files__proofs__array_proof_testing.snap b/egglog/tests/snapshots/files__proofs__array_proof_testing.snap index 01ffa3fe..05736edf 100644 --- a/egglog/tests/snapshots/files__proofs__array_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__array_proof_testing.snap @@ -1,6 +1,5 @@ --- source: egglog/tests/files.rs -assertion_line: 279 expression: proof_snapshot --- (let t0 (select (store (AVar "mem1") (Var "r1") (Num 42)) (Var "r1"))) @@ -9,10 +8,10 @@ expression: proof_snapshot (name "(rewrite (select (store mem i e) i) e)") (premises (Fiat (= t0 t0))) (substitution + (@rewrite_var__ t0) (mem (AVar "mem1")) - (e (Num 42)) (i (Var "r1")) - (@rewrite_var__ t0))) + (e (Num 42)))) (let t0 (neq (Var "r1") (Var "r2"))) (Fiat (= t0 t0)) (let t0 (add (Var "r1") (Num 17))) @@ -33,7 +32,7 @@ expression: proof_snapshot ((neq e x)) )") (premises (Fiat (= t0 t0)) (Fiat (= () ()))) - (substitution (e t0) (x (Var "r1")) (i 17)))) + (substitution (x (Var "r1")) (i 17) (e t0)))) (substitution (x t0) (y (Var "r1")))) (let t0 (add (Var "r1") (Num 17))) (let t1 (select (store (AVar "mem1") (Var "r1") (Num 42)) t0)) @@ -63,14 +62,14 @@ expression: proof_snapshot ((neq e x)) )") (premises (Fiat (= t0 t0)) (Fiat (= () ()))) - (substitution (e t0) (x (Var "r1")) (i 17)))) + (substitution (x (Var "r1")) (i 17) (e t0)))) (substitution (x t0) (y (Var "r1"))))) (substitution (mem (AVar "mem1")) - (e1 t1) - (e (Num 42)) (i1 (Var "r1")) - (i2 t0))) + (e (Num 42)) + (i2 t0) + (e1 t1))) (let t0 (add (Var "r1") (Var "r2"))) (let t1 (store (AVar "mem1") t0 (Num 1))) (let t2 (add (Var "r2") (Var "r1"))) @@ -112,16 +111,16 @@ expression: proof_snapshot (premises (Congr (= t3 (store t1 t0 (Num 2))) (Fiat (= t3 t3)) prf0 1)) (substitution + (@rewrite_var__1 t3) (mem (AVar "mem1")) - (e1 (Num 1)) - (e2 (Num 2)) (i t0) - (@rewrite_var__1 t3))) + (e1 (Num 1)) + (e2 (Num 2)))) 0)) (Congr (= t9 (neq t0 t4)) (Congr - (= t9 (neq t2 t4)) + (= t9 (neq t0 t8)) (Rule (= t9 t9) (name @@ -130,18 +129,18 @@ expression: proof_snapshot ((neq (add x z) (add y z))) )") (premises (Fiat (= t10 t10)) (Fiat (= t2 t2))) - (substitution (z (Var "r1")) (e t2) (y (Var "r3")) (x (Var "r2")))) - (Sym - (= t8 t4) - (Rule - (= t4 t8) - (name "(rewrite (add x y) (add y x))") - (premises (Fiat (= t4 t4))) - (substitution (@rewrite_var__2 t4) (x (Var "r1")) (y (Var "r3"))))) - 1) - prf0 - 0)) - (substitution (mem (AVar "mem1")) (e1 t5) (e (Num 2)) (i1 t0) (i2 t4))) + (substitution (x (Var "r2")) (y (Var "r3")) (z (Var "r1")) (e t2))) + prf0 + 0) + (Sym + (= t8 t4) + (Rule + (= t4 t8) + (name "(rewrite (add x y) (add y x))") + (premises (Fiat (= t4 t4))) + (substitution (@rewrite_var__2 t4) (x (Var "r1")) (y (Var "r3"))))) + 1)) + (substitution (mem (AVar "mem1")) (i1 t0) (e (Num 2)) (i2 t4) (e1 t5))) (let t0 (add (Num 1) (Var "r1"))) (let t1 (add (Num 1) t0)) (let t2 (add t1 (Num -3))) @@ -158,7 +157,7 @@ expression: proof_snapshot (premises (Fiat (= t3 t3))) (substitution (@rewrite_var__2 t3) (x (Num 1)) (y t2))))) (let t9 (@rewrite_var__3 t3)) -(let t10 (substitution (z (Num 1)) (y (Num -3)) t9 (x t1))) +(let t10 (substitution t9 (x t1) (y (Num -3)) (z (Num 1)))) (let t11 (add (Var "r1") (Num 1))) (let t12 (add t0 (Num 1))) (let prf0 @@ -183,10 +182,10 @@ expression: proof_snapshot 0))) (let t14 (substitution - (z (Num 1)) - (y (Num 1)) (@rewrite_var__3 t1) - (x (Var "r1")))) + (x (Var "r1")) + (y (Num 1)) + (z (Num 1)))) (let t15 (premises (Congr @@ -202,28 +201,28 @@ expression: proof_snapshot t13 t14) 0))) -(let t16 (substitution (z t5) (y t4) t9 (x (Var "r1")))) +(let t16 (substitution t9 (x (Var "r1")) (y t4) (z t5))) (let t17 (add (Num 1) (Num -3))) (let prf1 (Rule - (= t17 (Num -2)) - (name "(rewrite (add (Num x) (Num y)) (Num (+ x y)))") - (premises - (Rule - (= t17 t17) - (name "(rewrite (add (add x y) z) (add x (add y z)))") - (premises - (Congr - (= t2 (add t12 (Num -3))) - (Fiat (= t2 t2)) - prf0 - 0)) - (substitution - (z (Num -3)) - (y (Num 1)) - (@rewrite_var__3 t2) - (x t0)))) - (substitution (@rewrite_var__4 t17) (x 1) (y -3)))) + (= t17 (Num -2)) + (name "(rewrite (add (Num x) (Num y)) (Num (+ x y)))") + (premises + (Rule + (= t17 t17) + (name "(rewrite (add (add x y) z) (add x (add y z)))") + (premises + (Congr + (= t2 (add t12 (Num -3))) + (Fiat (= t2 t2)) + prf0 + 0)) + (substitution + (@rewrite_var__3 t2) + (x t0) + (y (Num 1)) + (z (Num -3))))) + (substitution (@rewrite_var__4 t17) (x 1) (y -3)))) (Rule (= t3 (Var "r1")) (name "(rewrite (add x (Num 0)) x)") @@ -244,37 +243,37 @@ expression: proof_snapshot (Congr (= t6 (add (Num 2) t17)) (Congr - (= t6 (add t4 t17)) + (= t6 (add (Num 2) t5)) (Rule (= t6 t6) (name "(rewrite (add (add x y) z) (add x (add y z)))") t15 t16) - (Trans - (= t5 t17) - (Rule - (= t5 (Num -2)) - (name "(rewrite (add (Num x) (Num y)) (Num (+ x y)))") - (premises - (Rule - (= t5 t5) - (name "(rewrite (add (add x y) z) (add x (add y z)))") - t8 - t10)) - (substitution (@rewrite_var__4 t5) (x -3) (y 1))) - (Sym (= (Num -2) t17) prf1)) - 1) - (Rule - (= t4 (Num 2)) - (name "(rewrite (add (Num x) (Num y)) (Num (+ x y)))") - (premises - (Rule - (= t4 t4) - (name "(rewrite (add (add x y) z) (add x (add y z)))") - t13 - t14)) - (substitution (@rewrite_var__4 t4) (x 1) (y 1))) - 0) + (Rule + (= t4 (Num 2)) + (name "(rewrite (add (Num x) (Num y)) (Num (+ x y)))") + (premises + (Rule + (= t4 t4) + (name "(rewrite (add (add x y) z) (add x (add y z)))") + t13 + t14)) + (substitution (@rewrite_var__4 t4) (x 1) (y 1))) + 0) + (Trans + (= t5 t17) + (Rule + (= t5 (Num -2)) + (name "(rewrite (add (Num x) (Num y)) (Num (+ x y)))") + (premises + (Rule + (= t5 t5) + (name "(rewrite (add (add x y) z) (add x (add y z)))") + t8 + t10)) + (substitution (@rewrite_var__4 t5) (x -3) (y 1))) + (Sym (= (Num -2) t17) prf1)) + 1) prf1 1)) (substitution (@rewrite_var__4 t6) (x 2) (y -2))) diff --git a/egglog/tests/snapshots/files__proofs__bind_prim_result_proof_testing.snap b/egglog/tests/snapshots/files__proofs__bind_prim_result_proof_testing.snap index 155a8dc0..1d60fc2a 100644 --- a/egglog/tests/snapshots/files__proofs__bind_prim_result_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__bind_prim_result_proof_testing.snap @@ -8,4 +8,4 @@ expression: proof_snapshot (premises (Fiat (= (Strings "hello" "world") (Strings "hello" "world"))) (Fiat (= "hello world" "hello world"))) - (substitution (a "hello") (res "hello world") (b "world"))) + (substitution (a "hello") (b "world") (res "hello world"))) diff --git a/egglog/tests/snapshots/files__proofs__birewrite_proof_testing.snap b/egglog/tests/snapshots/files__proofs__birewrite_proof_testing.snap index d720ff71..6494e913 100644 --- a/egglog/tests/snapshots/files__proofs__birewrite_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__birewrite_proof_testing.snap @@ -7,10 +7,10 @@ expression: proof_snapshot (= t0 (Add (Lit 1) (Add (Lit 2) (Lit 3)))) (name "(birewrite (Add (Add x y) z) (Add x (Add y z)))=>") (premises (Fiat (= t0 t0))) - (substitution (z (Lit 3)) (y (Lit 2)) (x (Lit 1)) (@rewrite_var__ t0))) + (substitution (@rewrite_var__ t0) (x (Lit 1)) (y (Lit 2)) (z (Lit 3)))) (let t0 (Add (Lit 4) (Add (Lit 5) (Lit 6)))) (Rule (= t0 (Add (Add (Lit 4) (Lit 5)) (Lit 6))) (name "(birewrite (Add (Add x y) z) (Add x (Add y z)))<=") (premises (Fiat (= t0 t0))) - (substitution (z (Lit 6)) (y (Lit 5)) (@rewrite_var__1 t0) (x (Lit 4)))) + (substitution (@rewrite_var__1 t0) (x (Lit 4)) (y (Lit 5)) (z (Lit 6)))) diff --git a/egglog/tests/snapshots/files__proofs__calc_proof_testing.snap b/egglog/tests/snapshots/files__proofs__calc_proof_testing.snap index 3fd74f44..911ada73 100644 --- a/egglog/tests/snapshots/files__proofs__calc_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__calc_proof_testing.snap @@ -12,10 +12,10 @@ expression: proof_snapshot (name "(birewrite (g* (g* a b) c) (g* a (g* b c)))=>") (premises (Fiat (= t1 t1))) (substitution - (c t0) + (@rewrite_var__ t1) (a (g* (AConst) (AConst))) (b (g* (AConst) (AConst))) - (@rewrite_var__ t1))) + (c t0))) (let t0 (g* (inv (aConst)) (aConst))) (let t1 (g* (bConst) t0)) (let t2 (g* t1 (inv (bConst)))) @@ -35,11 +35,11 @@ expression: proof_snapshot (premises (Fiat (= t0 t0))) (substitution (@rewrite_var__4 t0) (a (aConst)))) 1)) - (substitution (a (bConst)) (@rewrite_var__3 t1))) + (substitution (@rewrite_var__3 t1) (a (bConst)))) 0) (let t0 (g* (bConst) (inv (bConst)))) (Rule (= t0 (IConst)) (name "(rewrite (g* a (inv a)) $I)") (premises (Fiat (= t0 t0))) - (substitution (a (bConst)) (@rewrite_var__5 t0))) + (substitution (@rewrite_var__5 t0) (a (bConst)))) diff --git a/egglog/tests/snapshots/files__proofs__combinators_proof_testing.snap b/egglog/tests/snapshots/files__proofs__combinators_proof_testing.snap index eaa5de35..6499c8ac 100644 --- a/egglog/tests/snapshots/files__proofs__combinators_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__combinators_proof_testing.snap @@ -1,6 +1,5 @@ --- source: egglog/tests/files.rs -assertion_line: 279 expression: proof_snapshot --- (let t0 (If (Var "x") (N 0) (N 1))) @@ -36,7 +35,7 @@ expression: proof_snapshot ((union (Comb x) (CAbs v (Comb body)))) )") (premises (Fiat (= t2 t2))) - (substitution (v "x") (body t1) (x t2))) + (substitution (x t2) (v "x") (body t1))) (Rule (= t21 t20) (name @@ -44,14 +43,14 @@ expression: proof_snapshot ((union (Comb x) (CApp (CApp $CAdd (Comb l)) (Comb r)))) )") (premises (Fiat (= t1 t1))) - (substitution (r (N 2)) (x t1) (l t0))) + (substitution (x t1) (l t0) (r (N 2)))) 1))) (let t23 (substitution + (@rewrite_var__9 t18) (v "x") - (y (Comb (N 2))) (x t12) - (@rewrite_var__9 t18))) + (y (Comb (N 2))))) (let t24 (premises (Congr @@ -65,7 +64,7 @@ expression: proof_snapshot ((union (Comb x) (CApp (Comb f) (Comb a)))) )") (premises (Fiat (= t3 t3))) - (substitution (a (FConst)) (f t2) t19)) + (substitution t19 (f t2) (a (FConst)))) (Sym (= (Comb (FConst)) (CFConst)) (Rule @@ -86,10 +85,10 @@ expression: proof_snapshot 0))) (let t25 (substitution - (cz (CFConst)) - (cx t13) (@rewrite_var__16 t9) - (cy t15))) + (cx t13) + (cy t15) + (cz (CFConst)))) (let t26 (CApp (KConst) (Comb (N 2)))) (let t27 (CApp (KConst) (CN 2))) (let prf0 @@ -131,10 +130,10 @@ expression: proof_snapshot t23))) (let t34 (substitution + (@rewrite_var__9 t13) (v "x") - (y t4) (x (CAddConst)) - (@rewrite_var__9 t13))) + (y t4))) (let t35 (premises (Congr @@ -154,10 +153,10 @@ expression: proof_snapshot 0))) (let t36 (substitution - (cz (CFConst)) - (cx (CAbs "x" (CAddConst))) (@rewrite_var__16 t14) - (cy t5))) + (cx (CAbs "x" (CAddConst))) + (cy t5) + (cz (CFConst)))) (let t37 (premises (Congr @@ -238,8 +237,8 @@ expression: proof_snapshot t33 t34)) (substitution - (v "x") - (@rewrite_var__8 (CAbs "x" (CAddConst))))) + (@rewrite_var__8 (CAbs "x" (CAddConst))) + (v "x"))) 0)) (substitution (@rewrite_var__15 t31) @@ -247,7 +246,7 @@ expression: proof_snapshot (cy (CFConst)))) 0) 0))) -(let t38 (substitution (cl t6) (cr (Comb (N 2))) (cx t9))) +(let t38 (substitution (cx t9) (cl t6) (cr (Comb (N 2))))) (let t39 (Uncomb (Comb (N 2)))) (let t40 (Add t7 t39)) (let t41 (If (FConst) (N 0) (N 1))) @@ -281,17 +280,17 @@ expression: proof_snapshot )") (premises (Fiat (= t0 t0))) (substitution - (t (N 0)) - (f (N 1)) (x t0) - (c (Var "x")))) + (c (Var "x")) + (t (N 0)) + (f (N 1)))) 1))) (let t55 (substitution + (@rewrite_var__9 t5) (v "x") - (y (Comb (N 1))) (x t47) - (@rewrite_var__9 t5))) + (y (Comb (N 1))))) (let t56 (premises (Congr @@ -311,10 +310,10 @@ expression: proof_snapshot 0))) (let t57 (substitution - (cz (CFConst)) - (cx t48) (@rewrite_var__16 t6) - (cy t50))) + (cx t48) + (cy t50) + (cz (CFConst)))) (let t58 (CApp (KConst) (Comb (N 1)))) (let t59 (CApp (KConst) (CN 1))) (let prf1 @@ -355,10 +354,10 @@ expression: proof_snapshot t55))) (let t69 (substitution + (@rewrite_var__9 t48) (v "x") - (y (Comb (N 0))) (x t46) - (@rewrite_var__9 t48))) + (y (Comb (N 0))))) (let t70 (premises (Congr @@ -378,10 +377,10 @@ expression: proof_snapshot 0))) (let t71 (substitution - (cz (CFConst)) - (cx t63) (@rewrite_var__16 t49) - (cy t65))) + (cx t63) + (cy t65) + (cz (CFConst)))) (let t72 (CApp (KConst) (Comb (N 0)))) (let t73 (CApp (KConst) (CN 0))) (let prf2 @@ -411,13 +410,13 @@ expression: proof_snapshot (v "x") (n 0))) (let t76 (cx (Comb (N 0)))) -(let t77 (CApp (CAbs "x" (CIfConst)) (CFConst))) -(let t78 (CAbs "x" (Comb (Var "x")))) -(let t79 (CApp t78 (CFConst))) +(let t77 (CAbs "x" (Comb (Var "x")))) +(let t78 (CApp t77 (CFConst))) +(let t79 (CApp (CAbs "x" (CIfConst)) (CFConst))) (let t80 (CApp (CApp (SConst) (CAbs "x" (CIfConst))) - t78)) + t77)) (let t81 (premises (Rule @@ -428,10 +427,10 @@ expression: proof_snapshot t69))) (let t82 (substitution + (@rewrite_var__9 t63) (v "x") - (y (Comb (Var "x"))) (x (CIfConst)) - (@rewrite_var__9 t63))) + (y (Comb (Var "x"))))) (let t83 (premises (Congr @@ -451,10 +450,10 @@ expression: proof_snapshot 0))) (let t84 (substitution - (cz (CFConst)) - (cx (CAbs "x" (CIfConst))) (@rewrite_var__16 t64) - (cy t78))) + (cx (CAbs "x" (CIfConst))) + (cy t77) + (cz (CFConst)))) (let t85 (premises (Congr @@ -546,19 +545,21 @@ expression: proof_snapshot (Congr (= t64 (CApp (CIfConst) (CFConst))) (Congr - (= t64 (CApp t77 (CFConst))) + (= t64 (CApp (CIfConst) t78)) (Rule - (= t64 (CApp t77 t79)) + (= t64 (CApp t79 t78)) (name "(rewrite (CApp (CApp (CApp $S cx) cy) cz) (CApp (CApp cx cz) (CApp cy cz)))") t83 t84) (Rule - (= t79 (CFConst)) - (name "(rewrite (CApp $I cx) cx)") + (= t79 (CIfConst)) + (name "(rewrite (CApp (CApp $K cx) cy) cx)") (premises (Congr - (= t79 (CApp (IConst) (CFConst))) + (= + t79 + (CApp (CApp (KConst) (CIfConst)) (CFConst))) (Rule (= t79 t79) (name @@ -566,72 +567,72 @@ expression: proof_snapshot t83 t84) (Rule - (= t78 (IConst)) - (name "(rewrite (CAbs v (CVar v)) $I)") + (= + (CAbs "x" (CIfConst)) + (CApp (KConst) (CIfConst))) + (name "(rewrite (CAbs v $CIf) (CApp $K $CIf))") (premises - (Congr - (= t78 (CAbs "x" (CVar "x"))) - (Rule - (= t78 t78) - (name - "(rewrite (CAbs v (CApp x y)) (CApp (CApp $S (CAbs v x)) (CAbs v y)))") - t81 - t82) - (Rule - (= (Comb (Var "x")) (CVar "x")) - (name - "(rule ((= x (Var v))) - ((union (Comb x) (CVar v))) - )") - (premises (Fiat (= (Var "x") (Var "x")))) - (substitution (v "x") (x (Var "x")))) - 1)) - (substitution (@rewrite_var__2 t78) (v "x"))) + (Rule + (= + (CAbs "x" (CIfConst)) + (CAbs "x" (CIfConst))) + (name + "(rewrite (CAbs v (CApp x y)) (CApp (CApp $S (CAbs v x)) (CAbs v y)))") + t81 + t82)) + (substitution + (@rewrite_var__7 (CAbs "x" (CIfConst))) + (v "x"))) 0)) - (substitution (@rewrite_var__14 t79) (cx (CFConst)))) - 1) + (substitution + (@rewrite_var__15 t79) + (cx (CIfConst)) + (cy (CFConst)))) + 0) (Rule - (= t77 (CIfConst)) - (name "(rewrite (CApp (CApp $K cx) cy) cx)") + (= t78 (CFConst)) + (name "(rewrite (CApp $I cx) cx)") (premises (Congr - (= t77 (CApp (CApp (KConst) (CIfConst)) (CFConst))) + (= t78 (CApp (IConst) (CFConst))) (Rule - (= t77 t77) + (= t78 t78) (name "(rewrite (CApp (CApp (CApp $S cx) cy) cz) (CApp (CApp cx cz) (CApp cy cz)))") t83 t84) (Rule - (= - (CAbs "x" (CIfConst)) - (CApp (KConst) (CIfConst))) - (name "(rewrite (CAbs v $CIf) (CApp $K $CIf))") + (= t77 (IConst)) + (name "(rewrite (CAbs v (CVar v)) $I)") (premises - (Rule - (= - (CAbs "x" (CIfConst)) - (CAbs "x" (CIfConst))) - (name - "(rewrite (CAbs v (CApp x y)) (CApp (CApp $S (CAbs v x)) (CAbs v y)))") - t81 - t82)) - (substitution - (@rewrite_var__7 (CAbs "x" (CIfConst))) - (v "x"))) + (Congr + (= t77 (CAbs "x" (CVar "x"))) + (Rule + (= t77 t77) + (name + "(rewrite (CAbs v (CApp x y)) (CApp (CApp $S (CAbs v x)) (CAbs v y)))") + t81 + t82) + (Rule + (= (Comb (Var "x")) (CVar "x")) + (name + "(rule ((= x (Var v))) + ((union (Comb x) (CVar v))) + )") + (premises (Fiat (= (Var "x") (Var "x")))) + (substitution (x (Var "x")) (v "x"))) + 1)) + (substitution (@rewrite_var__2 t77) (v "x"))) 0)) - (substitution - (@rewrite_var__15 t77) - (cx (CIfConst)) - (cy (CFConst)))) - 0) + (substitution (@rewrite_var__14 t78) (cx (CFConst)))) + 1) 0) 0))) (let t86 (substitution + (cx t6) (cc (CFConst)) (ct (Comb (N 0))) - (cx t6) (cf (Comb (N 1))))) (Rule (= t3 (N 3)) @@ -744,4 +745,4 @@ expression: proof_snapshot 2))) (substitution (@rewrite_var__12 t7) (t (N 0)) (f (N 1)))) 0)) - (substitution (@rewrite_var__13 t3) (m 2) (n 1))) + (substitution (@rewrite_var__13 t3) (n 1) (m 2))) diff --git a/egglog/tests/snapshots/files__proofs__container_proofs_proof_testing.snap b/egglog/tests/snapshots/files__proofs__container_proofs_proof_testing.snap index 3b6a13a6..66a35b7e 100644 --- a/egglog/tests/snapshots/files__proofs__container_proofs_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__container_proofs_proof_testing.snap @@ -9,7 +9,7 @@ expression: proof_snapshot (= (@ExistsConstructor) (@ExistsConstructor)) (name "@prove_exists_rule") (premises prf0 (Fiat (= (Z) (Z))) prf0 (Eval) (Fiat (= t1 t1))) - (substitution (@v92 (Z)) (@v91 (S (Z))) (@v94 t0) (@v93 (S (Z))))) + (substitution (@v (S (Z))) (@v1 (Z)) (@v2 (S (Z))) (@v3 t0))) (let t0 (pair (Z) (S (Z)))) (let t1 (HasPair t0)) (Rule @@ -20,7 +20,7 @@ expression: proof_snapshot (Fiat (= (S (Z)) (S (Z)))) (Eval) (Fiat (= t1 t1))) - (substitution (@v211 (Z)) (@v212 (S (Z))) (@v213 t0))) + (substitution (@v4 (Z)) (@v5 (S (Z))) (@v6 t0))) (let t0 (vec-of (Z) (S (Z)))) (let t1 (HasVec t0)) (Rule @@ -31,7 +31,7 @@ expression: proof_snapshot (Fiat (= (S (Z)) (S (Z)))) (Eval) (Fiat (= t1 t1))) - (substitution (@v322 (S (Z))) (@v323 t0) (@v321 (Z)))) + (substitution (@v7 (Z)) (@v8 (S (Z))) (@v9 t0))) (let t0 (map-of (Z) (S (Z)))) (let t1 (HasMap t0)) (Rule @@ -42,7 +42,7 @@ expression: proof_snapshot (Fiat (= (S (Z)) (S (Z)))) (Eval) (Fiat (= t1 t1))) - (substitution (@v435 (Z)) (@v436 (S (Z))) (@v437 t0))) + (substitution (@v10 (Z)) (@v11 (S (Z))) (@v12 t0))) (let prf0 (Fiat (= (Z) (Z)))) (let t0 (multiset-of (S (Z)) (Z) (Z))) (let t1 (HasMS t0)) @@ -50,7 +50,7 @@ expression: proof_snapshot (= (@ExistsConstructor4) (@ExistsConstructor4)) (name "@prove_exists_rule4") (premises prf0 (Fiat (= (S (Z)) (S (Z)))) prf0 (Eval) (Fiat (= t1 t1))) - (substitution (@v546 (S (Z))) (@v545 (Z)) (@v547 (Z)) (@v548 t0))) + (substitution (@v13 (Z)) (@v14 (S (Z))) (@v15 (Z)) (@v16 t0))) (let t0 (vec-of (Z) (S (Z)))) (let t1 (HasVec t0)) (Rule @@ -94,7 +94,7 @@ expression: proof_snapshot ((MapGet w)) )") (premises (Fiat (= t1 t1)) (Fiat (= (Z) (Z))) (Fiat (= (S (Z)) (S (Z))))) - (substitution (m t0) (w (S (Z))) (@v815 (Z)))) + (substitution (m t0) (@v17 (Z)) (w (S (Z))))) (let t0 (multiset-of (S (Z)) (Z) (Z))) (let t1 (HasMS t0)) (Rule @@ -116,7 +116,7 @@ expression: proof_snapshot ((PFirst a)) )") (premises (Fiat (= t1 t1)) (Fiat (= (Z) (Z)))) - (substitution (a (Z)) (p t0))) + (substitution (p t0) (a (Z)))) (Rule (= (QVecLen 2) (QVecLen 2)) (name @@ -195,5 +195,5 @@ expression: proof_snapshot ((OutWrap w)) )") (premises (Fiat (= (HasElem (Z)) (HasElem (Z)))) (Eval) (Fiat (= t0 t0))) - (substitution (a (Z)) (@v1563 (vec-of (Z) (Z))) (w t0)))) - (substitution (@v1588 (Z)) (@v1589 (Z)) (@v1590 (vec-of (Z) (Z))))) + (substitution (a (Z)) (w t0) (@v18 (vec-of (Z) (Z)))))) + (substitution (@v19 (Z)) (@v20 (Z)) (@v21 (vec-of (Z) (Z))))) diff --git a/egglog/tests/snapshots/files__proofs__container_reorder_proofs_proof_testing.snap b/egglog/tests/snapshots/files__proofs__container_reorder_proofs_proof_testing.snap index 0eb98f5e..c6c0f66b 100644 --- a/egglog/tests/snapshots/files__proofs__container_reorder_proofs_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__container_reorder_proofs_proof_testing.snap @@ -83,7 +83,7 @@ expression: proof_snapshot )") t0 (substitution))) - (substitution (x (N 1)) (@rewrite_var__ (Id (N 1))))) + (substitution (@rewrite_var__ (Id (N 1))) (x (N 1)))) 0) (Rule (= (Id (N 2)) (N 2)) @@ -97,10 +97,10 @@ expression: proof_snapshot )") t0 (substitution))) - (substitution (x (N 2)) (@rewrite_var__ (Id (N 2))))) + (substitution (@rewrite_var__ (Id (N 2))) (x (N 2)))) 1)) 0)) - (substitution (@v810 (N 1)) (@v811 (N 2)) (@v812 t3))) + (substitution (@v (N 1)) (@v1 (N 2)) (@v2 t3))) (let t0 (Add (Num 51) (Num 52))) (let t1 (Add (Num 52) (Num 51))) (let t2 (premises (Fiat (= (Go) (Go))))) @@ -147,7 +147,7 @@ expression: proof_snapshot prf0 0) 0)) - (substitution (@v859 t5) (@v858 t1))) + (substitution (@v3 t1) (@v4 t5))) (let t0 (premises (Fiat (= (Go) (Go))))) (let prf0 (Rule @@ -232,7 +232,7 @@ expression: proof_snapshot )") t0 (substitution))) - (substitution (x (N 11)) (@rewrite_var__ (Id (N 11))))) + (substitution (@rewrite_var__ (Id (N 11))) (x (N 11)))) 0) (Rule (= (Id (N 12)) (N 12)) @@ -246,10 +246,10 @@ expression: proof_snapshot )") t0 (substitution))) - (substitution (x (N 12)) (@rewrite_var__ (Id (N 12))))) + (substitution (@rewrite_var__ (Id (N 12))) (x (N 12)))) 1)) 0)) - (substitution (@v908 t3) (@v906 (N 11)) (@v907 (N 12)) (@v905 (N 11)))) + (substitution (@v5 (N 11)) (@v6 (N 11)) (@v7 (N 12)) (@v8 t3))) (let t0 (premises (Fiat (= (Go) (Go))))) (let prf0 (Rule @@ -340,15 +340,15 @@ expression: proof_snapshot )") t0 (substitution))) - (substitution (x (N 22)) (@rewrite_var__ (Id (N 22))))) + (substitution (@rewrite_var__ (Id (N 22))) (x (N 22)))) 0)) 0)) (substitution - (@v964 t3) - (@v960 (N 21)) - (@v961 (N 23)) - (@v962 (N 22)) - (@v963 (N 23)))) + (@v9 (N 21)) + (@v10 (N 23)) + (@v11 (N 22)) + (@v12 (N 23)) + (@v13 t3))) (let t0 (premises (Fiat (= (Go) (Go))))) (let prf0 (Rule @@ -375,7 +375,7 @@ expression: proof_snapshot )") t0 (substitution))) - (substitution (x (N 31)) (@rewrite_var__ (Id (N 31)))))) + (substitution (@rewrite_var__ (Id (N 31))) (x (N 31))))) (Rule (= (@ExistsConstructor4) (@ExistsConstructor4)) (name "@prove_exists_rule4") @@ -418,7 +418,7 @@ expression: proof_snapshot prf1 1) 0)) - (substitution (@v1024 (N 32)) (@v1022 (N 31)) (@v1023 (N 31)) (@v1025 t3))) + (substitution (@v14 (N 31)) (@v15 (N 31)) (@v16 (N 32)) (@v17 t3))) (let t0 (premises (Fiat (= (Go) (Go))))) (let prf0 (Rule @@ -522,8 +522,8 @@ expression: proof_snapshot )") t0 (substitution))) - (substitution (x (N 41)) (@rewrite_var__ (Id (N 41))))) + (substitution (@rewrite_var__ (Id (N 41))) (x (N 41)))) 0) 1) 0)) - (substitution (@v1077 (N 41)) (@v1079 (N 41)) (@v1078 (N 42)) (@v1080 t5))) + (substitution (@v18 (N 41)) (@v19 (N 42)) (@v20 (N 41)) (@v21 t5))) diff --git a/egglog/tests/snapshots/files__proofs__container_set_collapse_proof_testing.snap b/egglog/tests/snapshots/files__proofs__container_set_collapse_proof_testing.snap index 847b4e25..3262f591 100644 --- a/egglog/tests/snapshots/files__proofs__container_set_collapse_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__container_set_collapse_proof_testing.snap @@ -1,6 +1,5 @@ --- source: egglog/tests/files.rs -assertion_line: 279 expression: proof_snapshot --- (let prf0 (Sym (= (B) (A)) (Fiat (= (A) (B))))) @@ -31,8 +30,8 @@ expression: proof_snapshot (Sym (= (Holds (set-of (A))) t0) prf1) prf1)) (substitution - (@v132 (A)) - (@v134 (A)) - (@v131 (A)) - (@v135 (set-of (A))) - (@v133 (set-of (A))))) + (@v (A)) + (@v1 (A)) + (@v3 (A)) + (@v2 (set-of (A))) + (@v4 (set-of (A))))) diff --git a/egglog/tests/snapshots/files__proofs__custom_container_output_rebuild_proof_testing.snap b/egglog/tests/snapshots/files__proofs__custom_container_output_rebuild_proof_testing.snap index 50a23185..0daebfab 100644 --- a/egglog/tests/snapshots/files__proofs__custom_container_output_rebuild_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__custom_container_output_rebuild_proof_testing.snap @@ -1,6 +1,5 @@ --- source: egglog/tests/files.rs -assertion_line: 279 expression: proof_snapshot --- (let t0 (freer (N 0) (set-of (N 1)))) @@ -22,7 +21,7 @@ expression: proof_snapshot (Trans (= t0 t0) (Sym (= t0 t1) prf0) prf0) (Fiat (= (N 1) (N 1))) (Eval)) - (substitution (@v98 (N 1)) (@n (set-of (N 1))))) + (substitution (@n (set-of (N 1))) (@v (N 1)))) (let t0 (set-of (N 1) (N 3))) (let t1 (freer (N 0) t0)) (let t2 (freer (N 0) (set-of (N 1)))) @@ -50,4 +49,4 @@ expression: proof_snapshot (Fiat (= (N 1) (N 1))) (Fiat (= (N 3) (N 3))) (Eval)) - (substitution (@v170 (N 1)) (@v171 (N 3)) (@n1 t0))) + (substitution (@n1 t0) (@v1 (N 1)) (@v2 (N 3)))) diff --git a/egglog/tests/snapshots/files__proofs__custom_eqsort_output_rebuild_proof_testing.snap b/egglog/tests/snapshots/files__proofs__custom_eqsort_output_rebuild_proof_testing.snap new file mode 100644 index 00000000..00a9c175 --- /dev/null +++ b/egglog/tests/snapshots/files__proofs__custom_eqsort_output_rebuild_proof_testing.snap @@ -0,0 +1,19 @@ +--- +source: egglog/tests/files.rs +expression: proof_snapshot +--- +(let t0 (f (Num 1) (Num 2))) +(let t1 (f (Num 1) (Num 3))) +(let prf0 (Congr (= t1 t0) (Fiat (= t1 t1)) (Fiat (= (Num 3) (Num 2))) 1)) +(Sym + (= (W (Num 1)) (Num 2)) + (Rule + (= (Num 2) (W (Num 1))) + (name + "(rule ((= o (f k))) + ((union (W k) o)) + :ruleset r )") + (premises + (Trans (= t0 t0) (Sym (= t0 t1) prf0) prf0) + (Fiat (= (Num 2) (Num 2)))) + (substitution (k (Num 1)) (@n (Num 2)) (o (Num 2))))) diff --git a/egglog/tests/snapshots/files__proofs__cyk_proof_testing.snap b/egglog/tests/snapshots/files__proofs__cyk_proof_testing.snap index c152c96f..251235d7 100644 --- a/egglog/tests/snapshots/files__proofs__cyk_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__cyk_proof_testing.snap @@ -51,7 +51,7 @@ expression: proof_snapshot (Fiat (= t3 t3)) (Fiat (= (getString 1 "she") (getString 1 "she"))) (Fiat (= "she" "she"))) - (substitution (pos 1) (s "she") (a "NP") (@n "she"))) + (substitution (a "NP") (s "she") (pos 1) (@n "she"))) (Rule (= t4 t4) (name @@ -84,7 +84,7 @@ expression: proof_snapshot (Fiat (= t9 t9)) (Fiat (= (getString 2 "eats") (getString 2 "eats"))) (Fiat (= "eats" "eats"))) - (substitution (pos 2) (s "eats") (a "V") (@n "eats"))) + (substitution (a "V") (s "eats") (pos 2) (@n "eats"))) (Rule (= t10 t10) (name @@ -107,7 +107,7 @@ expression: proof_snapshot prf1 (Fiat (= (getString 3 "a") (getString 3 "a"))) prf2) - (substitution (pos 3) (s "a") (a "DET") (@n "a"))) + (substitution (a "DET") (s "a") (pos 3) (@n "a"))) (Rule (= t14 t14) (name @@ -120,9 +120,9 @@ expression: proof_snapshot (Fiat (= t15 t15)) (Fiat (= (getString 4 "fish") (getString 4 "fish"))) (Fiat (= "fish" "fish"))) - (substitution (pos 4) (s "fish") (a "N") (@n "fish")))) - (substitution (p2 1) (s 3) (a "NP") (p1 1) (b "DET") (c "N")))) - (substitution (p2 2) (s 2) (a "VP") (p1 1) (b "V") (c "NP"))) + (substitution (a "N") (s "fish") (pos 4) (@n "fish")))) + (substitution (a "NP") (b "DET") (c "N") (p1 1) (s 3) (p2 1)))) + (substitution (a "VP") (b "V") (c "NP") (p1 1) (s 2) (p2 2))) (Rule (= t16 t16) (name @@ -145,7 +145,7 @@ expression: proof_snapshot (Fiat (= t19 t19)) (Fiat (= (getString 5 "with") (getString 5 "with"))) (Fiat (= "with" "with"))) - (substitution (pos 5) (s "with") (a "P") (@n "with"))) + (substitution (a "P") (s "with") (pos 5) (@n "with"))) (Rule (= t20 t20) (name @@ -168,7 +168,7 @@ expression: proof_snapshot prf1 (Fiat (= (getString 6 "a") (getString 6 "a"))) prf2) - (substitution (pos 6) (s "a") (a "DET") (@n "a"))) + (substitution (a "DET") (s "a") (pos 6) (@n "a"))) (Rule (= t22 t22) (name @@ -181,11 +181,11 @@ expression: proof_snapshot (Fiat (= t23 t23)) (Fiat (= (getString 7 "fork") (getString 7 "fork"))) (Fiat (= "fork" "fork"))) - (substitution (pos 7) (s "fork") (a "N") (@n "fork")))) - (substitution (p2 1) (s 6) (a "NP") (p1 1) (b "DET") (c "N")))) - (substitution (p2 2) (s 5) (a "PP") (p1 1) (b "P") (c "NP")))) - (substitution (p2 3) (s 2) (a "VP") (p1 3) (b "VP") (c "PP")))) - (substitution (p2 6) (s 1) (a "S") (p1 1) (b "NP") (c "VP"))) + (substitution (a "N") (s "fork") (pos 7) (@n "fork")))) + (substitution (a "NP") (b "DET") (c "N") (p1 1) (s 6) (p2 1)))) + (substitution (a "PP") (b "P") (c "NP") (p1 1) (s 5) (p2 2)))) + (substitution (a "VP") (b "VP") (c "PP") (p1 3) (s 2) (p2 3)))) + (substitution (a "S") (b "NP") (c "VP") (p1 1) (s 1) (p2 6))) (let t0 (P 5 1 (NonTerm "S"))) (let t1 (Prod (NonTerm "S") (NonTerm "B") (NonTerm "C"))) (let t2 (P 3 1 (NonTerm "B"))) @@ -248,7 +248,7 @@ expression: proof_snapshot prf1 (Fiat (= (getString 1 "a") (getString 1 "a"))) prf2) - (substitution (pos 1) (s "a") (a "A") (@n "a"))) + (substitution (a "A") (s "a") (pos 1) (@n "a"))) (Rule (= t8 t8) (name @@ -261,8 +261,8 @@ expression: proof_snapshot prf3 (Fiat (= (getString 2 "b") (getString 2 "b"))) prf4) - (substitution (pos 2) (s "b") (a "B") (@n "b")))) - (substitution (p2 1) (s 1) (a "C") (p1 1) (b "A") (c "B"))) + (substitution (a "B") (s "b") (pos 2) (@n "b")))) + (substitution (a "C") (b "A") (c "B") (p1 1) (s 1) (p2 1))) (Rule (= t10 t10) (name @@ -275,8 +275,8 @@ expression: proof_snapshot (Fiat (= t11 t11)) (Fiat (= (getString 3 "a") (getString 3 "a"))) prf2) - (substitution (pos 3) (s "a") (a "C") (@n "a")))) - (substitution (p2 1) (s 1) (a "B") (p1 2) (b "C") (c "C"))) + (substitution (a "C") (s "a") (pos 3) (@n "a")))) + (substitution (a "B") (b "C") (c "C") (p1 2) (s 1) (p2 1))) (Rule (= t12 t12) (name @@ -296,7 +296,7 @@ expression: proof_snapshot (union (B 1 pos (NonTerm a)) (T a s))) )") (premises prf1 (Fiat (= (getString 4 "a") (getString 4 "a"))) prf2) - (substitution (pos 4) (s "a") (a "A") (@n "a"))) + (substitution (a "A") (s "a") (pos 4) (@n "a"))) (Rule (= t14 t14) (name @@ -306,9 +306,9 @@ expression: proof_snapshot (union (B 1 pos (NonTerm a)) (T a s))) )") (premises prf3 (Fiat (= (getString 5 "b") (getString 5 "b"))) prf4) - (substitution (pos 5) (s "b") (a "B") (@n "b")))) - (substitution (p2 1) (s 4) (a "C") (p1 1) (b "A") (c "B")))) - (substitution (p2 2) (s 1) (a "S") (p1 3) (b "B") (c "C"))) + (substitution (a "B") (s "b") (pos 5) (@n "b")))) + (substitution (a "C") (b "A") (c "B") (p1 1) (s 4) (p2 1)))) + (substitution (a "S") (b "B") (c "C") (p1 3) (s 1) (p2 2))) (let t0 (P 5 1 (NonTerm "S"))) (let t1 (Prod (NonTerm "S") (NonTerm "A") (NonTerm "B"))) (let t2 (P 3 1 (NonTerm "A"))) @@ -368,7 +368,7 @@ expression: proof_snapshot prf1 (Fiat (= (getString 1 "a") (getString 1 "a"))) prf2) - (substitution (pos 1) (s "a") (a "C") (@n "a"))) + (substitution (a "C") (s "a") (pos 1) (@n "a"))) (Rule (= t8 t8) (name @@ -381,8 +381,8 @@ expression: proof_snapshot prf1 (Fiat (= (getString 2 "a") (getString 2 "a"))) prf2) - (substitution (pos 2) (s "a") (a "C") (@n "a")))) - (substitution (p2 1) (s 1) (a "B") (p1 1) (b "C") (c "C"))) + (substitution (a "C") (s "a") (pos 2) (@n "a")))) + (substitution (a "B") (b "C") (c "C") (p1 1) (s 1) (p2 1))) (Rule (= t9 t9) (name @@ -395,8 +395,8 @@ expression: proof_snapshot (Fiat (= t10 t10)) (Fiat (= (getString 3 "a") (getString 3 "a"))) prf2) - (substitution (pos 3) (s "a") (a "A") (@n "a")))) - (substitution (p2 1) (s 1) (a "A") (p1 2) (b "B") (c "A"))) + (substitution (a "A") (s "a") (pos 3) (@n "a")))) + (substitution (a "A") (b "B") (c "A") (p1 2) (s 1) (p2 1))) (Rule (= t11 t11) (name @@ -416,7 +416,7 @@ expression: proof_snapshot (union (B 1 pos (NonTerm a)) (T a s))) )") (premises prf1 (Fiat (= (getString 4 "a") (getString 4 "a"))) prf2) - (substitution (pos 4) (s "a") (a "C") (@n "a"))) + (substitution (a "C") (s "a") (pos 4) (@n "a"))) (Rule (= t13 t13) (name @@ -426,9 +426,9 @@ expression: proof_snapshot (union (B 1 pos (NonTerm a)) (T a s))) )") (premises prf1 (Fiat (= (getString 5 "a") (getString 5 "a"))) prf2) - (substitution (pos 5) (s "a") (a "C") (@n "a")))) - (substitution (p2 1) (s 4) (a "B") (p1 1) (b "C") (c "C")))) - (substitution (p2 2) (s 1) (a "S") (p1 3) (b "A") (c "B"))) + (substitution (a "C") (s "a") (pos 5) (@n "a")))) + (substitution (a "B") (b "C") (c "C") (p1 1) (s 4) (p2 1)))) + (substitution (a "S") (b "A") (c "B") (p1 3) (s 1) (p2 2))) (let t0 (P 5 1 (NonTerm "A"))) (let t1 (Prod (NonTerm "A") (NonTerm "B") (NonTerm "A"))) (let prf0 (Fiat (= t1 t1))) @@ -475,7 +475,7 @@ expression: proof_snapshot (union (B 1 pos (NonTerm a)) (T a s))) )") (premises prf2 (Fiat (= (getString 1 "a") (getString 1 "a"))) prf3) - (substitution (pos 1) (s "a") (a "C") (@n "a"))) + (substitution (a "C") (s "a") (pos 1) (@n "a"))) (Rule (= t6 t6) (name @@ -485,8 +485,8 @@ expression: proof_snapshot (union (B 1 pos (NonTerm a)) (T a s))) )") (premises prf2 (Fiat (= (getString 2 "a") (getString 2 "a"))) prf3) - (substitution (pos 2) (s "a") (a "C") (@n "a")))) - (substitution (p2 1) (s 1) (a "B") (p1 1) (b "C") (c "C"))) + (substitution (a "C") (s "a") (pos 2) (@n "a")))) + (substitution (a "B") (b "C") (c "C") (p1 1) (s 1) (p2 1))) (Rule (= t7 t7) (name @@ -519,7 +519,7 @@ expression: proof_snapshot prf2 (Fiat (= (getString 3 "a") (getString 3 "a"))) prf3) - (substitution (pos 3) (s "a") (a "C") (@n "a"))) + (substitution (a "C") (s "a") (pos 3) (@n "a"))) (Rule (= t10 t10) (name @@ -532,8 +532,8 @@ expression: proof_snapshot prf2 (Fiat (= (getString 4 "a") (getString 4 "a"))) prf3) - (substitution (pos 4) (s "a") (a "C") (@n "a")))) - (substitution (p2 1) (s 3) (a "B") (p1 1) (b "C") (c "C"))) + (substitution (a "C") (s "a") (pos 4) (@n "a")))) + (substitution (a "B") (b "C") (c "C") (p1 1) (s 3) (p2 1))) (Rule (= t11 t11) (name @@ -546,6 +546,6 @@ expression: proof_snapshot (Fiat (= t12 t12)) (Fiat (= (getString 5 "a") (getString 5 "a"))) prf3) - (substitution (pos 5) (s "a") (a "A") (@n "a")))) - (substitution (p2 1) (s 3) (a "A") (p1 2) (b "B") (c "A")))) - (substitution (p2 3) (s 1) (a "A") (p1 2) (b "B") (c "A"))) + (substitution (a "A") (s "a") (pos 5) (@n "a")))) + (substitution (a "A") (b "B") (c "A") (p1 2) (s 3) (p2 1)))) + (substitution (a "A") (b "B") (c "A") (p1 2) (s 1) (p2 3))) diff --git a/egglog/tests/snapshots/files__proofs__eqsat_basic_proof_proof_testing.snap b/egglog/tests/snapshots/files__proofs__eqsat_basic_proof_proof_testing.snap index fee6d239..42cccd80 100644 --- a/egglog/tests/snapshots/files__proofs__eqsat_basic_proof_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__eqsat_basic_proof_proof_testing.snap @@ -1,38 +1,38 @@ --- source: egglog/tests/files.rs -assertion_line: 279 expression: proof_snapshot --- (let t0 (Mul (Num 2) (Add (Var "x") (Num 3)))) (let t1 (Mul (Num 2) (Var "x"))) -(let t2 (Mul (Num 2) (Num 3))) -(let t3 (premises (Fiat (= t0 t0)))) -(let t4 - (substitution - (@rewrite_var__1 t0) - (a (Num 2)) - (b (Var "x")) - (c (Num 3)))) -(Congr - (= t0 (Add (Num 6) t1)) - (Rule - (= t0 (Add t2 t1)) - (name "(rewrite (Add a b) (Add b a))") - (premises - (Rule - (= t0 (Add t1 t2)) - (name "(rewrite (Mul a (Add b c)) (Add (Mul a b) (Mul a c)))") - t3 - t4)) - (substitution (a t1) (b t2) (@rewrite_var__ t0))) - (Rule - (= t2 (Num 6)) - (name "(rewrite (Mul (Num a) (Num b)) (Num (* a b)))") - (premises - (Rule - (= t2 t2) - (name "(rewrite (Mul a (Add b c)) (Add (Mul a b) (Mul a c)))") - t3 - t4)) - (substitution (a 2) (b 3) (@rewrite_var__3 t2))) - 0) +(let t2 (Add (Num 6) t1)) +(let t3 (Add t1 (Num 6))) +(let t4 (Mul (Num 2) (Num 3))) +(let t5 (premises (Fiat (= t0 t0)))) +(let t6 (substitution (@rewrite_var__1 t0) (a (Num 2)) (b (Var "x")) (c (Num 3)))) +(Trans + (= t0 t2) + (Congr + (= t0 t3) + (Rule + (= t0 (Add t1 t4)) + (name "(rewrite (Mul a (Add b c)) (Add (Mul a b) (Mul a c)))") + t5 + t6) + (Rule + (= t4 (Num 6)) + (name "(rewrite (Mul (Num a) (Num b)) (Num (* a b)))") + (premises + (Rule + (= t4 t4) + (name "(rewrite (Mul a (Add b c)) (Add (Mul a b) (Mul a c)))") + t5 + t6)) + (substitution (@rewrite_var__3 t4) (a 2) (b 3))) + 1) + (Sym + (= t3 t2) + (Rule + (= t2 t3) + (name "(rewrite (Add a b) (Add b a))") + (premises (Fiat (= t2 t2))) + (substitution (@rewrite_var__ t2) (a (Num 6)) (b t1))))) diff --git a/egglog/tests/snapshots/files__proofs__eqsat_basic_proof_testing.snap b/egglog/tests/snapshots/files__proofs__eqsat_basic_proof_testing.snap index fee6d239..42cccd80 100644 --- a/egglog/tests/snapshots/files__proofs__eqsat_basic_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__eqsat_basic_proof_testing.snap @@ -1,38 +1,38 @@ --- source: egglog/tests/files.rs -assertion_line: 279 expression: proof_snapshot --- (let t0 (Mul (Num 2) (Add (Var "x") (Num 3)))) (let t1 (Mul (Num 2) (Var "x"))) -(let t2 (Mul (Num 2) (Num 3))) -(let t3 (premises (Fiat (= t0 t0)))) -(let t4 - (substitution - (@rewrite_var__1 t0) - (a (Num 2)) - (b (Var "x")) - (c (Num 3)))) -(Congr - (= t0 (Add (Num 6) t1)) - (Rule - (= t0 (Add t2 t1)) - (name "(rewrite (Add a b) (Add b a))") - (premises - (Rule - (= t0 (Add t1 t2)) - (name "(rewrite (Mul a (Add b c)) (Add (Mul a b) (Mul a c)))") - t3 - t4)) - (substitution (a t1) (b t2) (@rewrite_var__ t0))) - (Rule - (= t2 (Num 6)) - (name "(rewrite (Mul (Num a) (Num b)) (Num (* a b)))") - (premises - (Rule - (= t2 t2) - (name "(rewrite (Mul a (Add b c)) (Add (Mul a b) (Mul a c)))") - t3 - t4)) - (substitution (a 2) (b 3) (@rewrite_var__3 t2))) - 0) +(let t2 (Add (Num 6) t1)) +(let t3 (Add t1 (Num 6))) +(let t4 (Mul (Num 2) (Num 3))) +(let t5 (premises (Fiat (= t0 t0)))) +(let t6 (substitution (@rewrite_var__1 t0) (a (Num 2)) (b (Var "x")) (c (Num 3)))) +(Trans + (= t0 t2) + (Congr + (= t0 t3) + (Rule + (= t0 (Add t1 t4)) + (name "(rewrite (Mul a (Add b c)) (Add (Mul a b) (Mul a c)))") + t5 + t6) + (Rule + (= t4 (Num 6)) + (name "(rewrite (Mul (Num a) (Num b)) (Num (* a b)))") + (premises + (Rule + (= t4 t4) + (name "(rewrite (Mul a (Add b c)) (Add (Mul a b) (Mul a c)))") + t5 + t6)) + (substitution (@rewrite_var__3 t4) (a 2) (b 3))) + 1) + (Sym + (= t3 t2) + (Rule + (= t2 t3) + (name "(rewrite (Add a b) (Add b a))") + (premises (Fiat (= t2 t2))) + (substitution (@rewrite_var__ t2) (a (Num 6)) (b t1))))) diff --git a/egglog/tests/snapshots/files__proofs__fail_wrong_assertion_proof_testing.snap b/egglog/tests/snapshots/files__proofs__fail_wrong_assertion_proof_testing.snap index 1e71352b..ecf09f2b 100644 --- a/egglog/tests/snapshots/files__proofs__fail_wrong_assertion_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__fail_wrong_assertion_proof_testing.snap @@ -19,7 +19,7 @@ expression: proof_snapshot (= (@ExistsConstructor6) (@ExistsConstructor6)) (name "@prove_exists_rule6") (premises (Fiat (= t0 t0)) (Fiat (= 3 3))) - (substitution (x 3) (@n7 3))) + (substitution (@n7 3) (x 3))) (let t0 (g 1 2 3)) (let prf0 (Fiat (= 3 3))) (let t1 (g 2 3 3)) @@ -27,4 +27,4 @@ expression: proof_snapshot (= (@ExistsConstructor8) (@ExistsConstructor8)) (name "@prove_exists_rule8") (premises (Fiat (= t0 t0)) prf0 (Fiat (= t1 t1)) prf0 prf0) - (substitution (@n9 3) (y 3) (@n10 3) (x 3))) + (substitution (@n9 3) (x 3) (@n10 3) (y 3))) diff --git a/egglog/tests/snapshots/files__proofs__fibonacci_demand_proof_testing.snap b/egglog/tests/snapshots/files__proofs__fibonacci_demand_proof_testing.snap index cf3f8614..8c504b86 100644 --- a/egglog/tests/snapshots/files__proofs__fibonacci_demand_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__fibonacci_demand_proof_testing.snap @@ -1,6 +1,5 @@ --- source: egglog/tests/files.rs -assertion_line: 279 expression: proof_snapshot --- (let prf0 (Fiat (= () ()))) @@ -101,9 +100,9 @@ expression: proof_snapshot (x 0))) 1)) (substitution + (@rewrite_var__ (Fib 2)) (a 1) - (b 0) - (@rewrite_var__ (Fib 2))))) + (b 0)))) (let prf3 (Rule (= (Fib 3) (Num 2)) @@ -132,9 +131,9 @@ expression: proof_snapshot prf2 1)) (substitution + (@rewrite_var__ (Fib 3)) (a 1) - (b 1) - (@rewrite_var__ (Fib 3))))) + (b 1)))) (let prf4 (Rule (= (Fib 4) (Num 3)) @@ -161,7 +160,7 @@ expression: proof_snapshot 0) prf2 1)) - (substitution (a 2) (b 1) (@rewrite_var__ (Fib 4))))) + (substitution (@rewrite_var__ (Fib 4)) (a 2) (b 1)))) (let prf5 (Rule (= (Fib 5) (Num 5)) @@ -181,7 +180,7 @@ expression: proof_snapshot 0) prf3 1)) - (substitution (a 3) (b 2) (@rewrite_var__ (Fib 5))))) + (substitution (@rewrite_var__ (Fib 5)) (a 3) (b 2)))) (Rule (= (Fib 7) (Num 13)) (name "(rewrite (Add (Num a) (Num b)) (Num (+ a b)))") @@ -221,8 +220,8 @@ expression: proof_snapshot 0) prf4 1)) - (substitution (a 5) (b 3) (@rewrite_var__ (Fib 6)))) + (substitution (@rewrite_var__ (Fib 6)) (a 5) (b 3))) 0) prf5 1)) - (substitution (a 8) (b 5) (@rewrite_var__ (Fib 7)))) + (substitution (@rewrite_var__ (Fib 7)) (a 8) (b 5))) diff --git a/egglog/tests/snapshots/files__proofs__filter_or_bool_neq_proof_testing.snap b/egglog/tests/snapshots/files__proofs__filter_or_bool_neq_proof_testing.snap index 707f2444..a832e55b 100644 --- a/egglog/tests/snapshots/files__proofs__filter_or_bool_neq_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__filter_or_bool_neq_proof_testing.snap @@ -23,4 +23,4 @@ expression: proof_snapshot (= (@ExistsConstructor16) (@ExistsConstructor16)) (name "@prove_exists_rule16") (premises prf0 (Fiat (= (Val 2) (Val 2))) prf0 prf0 (Fiat (= () ()))) - (substitution (@v710 (Val 1)) (@v711 (Val 1)) (@v708 (Val 1)) (@v709 (Val 2)))) + (substitution (@v (Val 1)) (@v1 (Val 2)) (@v2 (Val 1)) (@v3 (Val 1)))) diff --git a/egglog/tests/snapshots/files__proofs__include_proof_testing.snap b/egglog/tests/snapshots/files__proofs__include_proof_testing.snap index e4f1e1d9..063b9b9f 100644 --- a/egglog/tests/snapshots/files__proofs__include_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__include_proof_testing.snap @@ -28,9 +28,9 @@ expression: proof_snapshot (premises (Fiat (= (edge 1 2) (edge 1 2)))) (substitution (x 1) (y 2))) (Fiat (= (edge 2 3) (edge 2 3)))) - (substitution (z 3) (x 1) (y 2))) + (substitution (x 1) (y 2) (z 3))) (Fiat (= (edge 3 4) (edge 3 4)))) - (substitution (z 4) (x 1) (y 3))) + (substitution (x 1) (y 3) (z 4))) (Rule (= (path 1 3) (path 1 3)) (name @@ -48,4 +48,4 @@ expression: proof_snapshot (premises (Fiat (= (edge 1 2) (edge 1 2)))) (substitution (x 1) (y 2))) (Fiat (= (edge 2 3) (edge 2 3)))) - (substitution (z 3) (x 1) (y 2))) + (substitution (x 1) (y 2) (z 3))) diff --git a/egglog/tests/snapshots/files__proofs__integer_math_proof_testing.snap b/egglog/tests/snapshots/files__proofs__integer_math_proof_testing.snap index 831300be..fa0a171e 100644 --- a/egglog/tests/snapshots/files__proofs__integer_math_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__integer_math_proof_testing.snap @@ -28,9 +28,9 @@ expression: proof_snapshot (= t2 (Const 0)) (name "(rewrite (Sub a a) (Const 0))") (premises (Fiat (= t2 t2))) - (substitution (a t1) (@rewrite_var__15 t2))) + (substitution (@rewrite_var__15 t2) (a t1))) 1)) - (substitution (a (Var "c")) (@rewrite_var__12 t3))) + (substitution (@rewrite_var__12 t3) (a (Var "c")))) 1) (Sym (= t9 t8) @@ -45,7 +45,7 @@ expression: proof_snapshot (= t0 t5) (name "(rewrite (Mul x (Pow (Const 2) y)) (LShift x y))") (premises (Fiat (= t0 t0))) - (substitution (y (Const 3)) (@rewrite_var__24 t0) (x (Var "a"))))) + (substitution (@rewrite_var__24 t0) (x (Var "a")) (y (Const 3))))) 0) (Rule (= t7 (Var "c")) @@ -60,5 +60,5 @@ expression: proof_snapshot (premises (Fiat (= t6 t6))) (substitution (@rewrite_var__26 t6) (x (Const 1)))) 1)) - (substitution (a (Var "c")) (@rewrite_var__14 t7))) + (substitution (@rewrite_var__14 t7) (a (Var "c")))) 1))) diff --git a/egglog/tests/snapshots/files__proofs__intersection_proof_testing.snap b/egglog/tests/snapshots/files__proofs__intersection_proof_testing.snap index e6e7ec11..62672e78 100644 --- a/egglog/tests/snapshots/files__proofs__intersection_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__intersection_proof_testing.snap @@ -1,6 +1,5 @@ --- source: egglog/tests/files.rs -assertion_line: 279 expression: proof_snapshot --- (let t0 (f (f (Var "a3")))) @@ -18,15 +17,15 @@ expression: proof_snapshot (let t8 (f1 (f (Var "a1")))) (let t9 (substitution + (x3 (Var "a3")) (x1 (Var "a1")) (x2 (Var "a2")) - (f2 (f (Var "a2"))) - (x3 (Var "a3")) - t8)) + t8 + (f2 (f (Var "a2"))))) (let prf0 (Fiat (= t2 t2))) (let t10 (x1 (f (Var "a1")))) -(let t11 (f2 t3)) -(let t12 (f1 t2)) +(let t11 (f1 t2)) +(let t12 (f2 t3)) (let t13 (intersect (f (Var "a1")) (f (Var "b2")))) (let t14 (intersect (Var "b1") (Var "b2"))) (let t15 @@ -36,11 +35,11 @@ expression: proof_snapshot (Fiat (= (f (Var "b2")) (f (Var "b2")))))) (let t16 (substitution + (x3 (Var "b3")) (x1 (Var "b1")) (x2 (Var "b2")) - (f2 (f (Var "b2"))) - (x3 (Var "b3")) - t8)) + t8 + (f2 (f (Var "b2"))))) (let t17 (f (f (Var "b2")))) (Trans (= t0 t1) @@ -69,7 +68,7 @@ expression: proof_snapshot t9) prf0 (Fiat (= t3 t3))) - (substitution t10 (x2 (f (Var "a2"))) t11 (x3 t5) t12)) + (substitution (x3 t5) t10 (x2 (f (Var "a2"))) t11 t12)) (Rule (= t5 (f (Var "a3"))) (name @@ -104,7 +103,7 @@ expression: proof_snapshot t16) prf0 (Sym (= t3 t17) (Fiat (= t17 t3)))) - (substitution t10 (x2 (f (Var "b2"))) t11 (x3 t13) t12)) + (substitution (x3 t13) t10 (x2 (f (Var "b2"))) t11 t12)) (Rule (= t13 (f (Var "b3"))) (name @@ -140,11 +139,11 @@ expression: proof_snapshot (Fiat (= (f (Var "a1")) (f (Var "a1")))) (Fiat (= (f (Var "a2")) (f (Var "a2"))))) (substitution + (x3 (Var "a3")) (x1 (Var "a1")) (x2 (Var "a2")) - (f2 (f (Var "a2"))) - (x3 (Var "a3")) - t2))) + t2 + (f2 (f (Var "a2")))))) (Sym (= (f (Var "b3")) t3) (Rule @@ -160,10 +159,10 @@ expression: proof_snapshot (Fiat (= (f (Var "a1")) (f (Var "b1")))) (Fiat (= (f (Var "b2")) (f (Var "b2"))))) (substitution + (x3 (Var "b3")) (x1 (Var "b1")) (x2 (Var "b2")) - (f2 (f (Var "b2"))) - (x3 (Var "b3")) - t2))) + t2 + (f2 (f (Var "b2")))))) (Fiat (= () ()))) - (substitution (@v610 t0) (@v611 t3))) + (substitution (@v t0) (@v1 t3))) diff --git a/egglog/tests/snapshots/files__proofs__matrix_proof_testing.snap b/egglog/tests/snapshots/files__proofs__matrix_proof_testing.snap index c6583938..a43057b0 100644 --- a/egglog/tests/snapshots/files__proofs__matrix_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__matrix_proof_testing.snap @@ -13,7 +13,7 @@ expression: proof_snapshot (= t4 t1) (name "(rewrite (nrows (MMul A B)) (nrows A))") (premises (Fiat (= t4 t4))) - (substitution (B t3) (@rewrite_var__6 t4) (A t0)))) + (substitution (@rewrite_var__6 t4) (A t0) (B t3)))) (let t5 (nrows (Id (NamedDim "n")))) (let t6 (Times t5 (NamedDim "m"))) (let t7 (Times t5 (nrows (NamedMat "B")))) @@ -55,16 +55,16 @@ expression: proof_snapshot (nrows B)) )") (premises (Fiat (= t0 t0))) - (substitution (B (NamedMat "B")) (e t0) t9))) - (substitution (n (NamedDim "n")) (@rewrite_var__8 t5))) + (substitution (e t0) t9 (B (NamedMat "B"))))) + (substitution (@rewrite_var__8 t5) (n (NamedDim "n")))) 0)) (let t0 (Kron (Id (NamedDim "n")) (NamedMat "B"))) (let t1 (Kron (NamedMat "A") (Id (NamedDim "m")))) (let t2 (MMul t0 t1)) (let prop0 (= t2 t2)) (let t3 (Kron (NamedMat "A") (NamedMat "B"))) -(let t4 (MMul (Id (NamedDim "n")) (NamedMat "A"))) -(let t5 (MMul (NamedMat "B") (Id (NamedDim "m")))) +(let t4 (MMul (NamedMat "B") (Id (NamedDim "m")))) +(let t5 (MMul (Id (NamedDim "n")) (NamedMat "A"))) (let t6 (ncols (Id (NamedDim "n")))) (let t7 (A (Id (NamedDim "n")))) (let t8 (nrows (Id (NamedDim "m")))) @@ -87,8 +87,8 @@ expression: proof_snapshot (nrows B)) )") (premises (Fiat (= t0 t0))) - (substitution (B (NamedMat "B")) (e t0) t7))) - (substitution (n (NamedDim "n")) (@rewrite_var__9 t6))) + (substitution (e t0) t7 (B (NamedMat "B"))))) + (substitution (@rewrite_var__9 t6) (n (NamedDim "n")))) (Sym (= (NamedDim "n") (nrows (NamedMat "A"))) (Fiat (= (nrows (NamedMat "A")) (NamedDim "n"))))) @@ -112,31 +112,31 @@ expression: proof_snapshot )") (premises (Fiat (= t1 t1))) (substitution - (B (Id (NamedDim "m"))) (e t1) - (A (NamedMat "A"))))) - (substitution (n (NamedDim "m")) (@rewrite_var__8 t8))))))) + (A (NamedMat "A")) + (B (Id (NamedDim "m")))))) + (substitution (@rewrite_var__8 t8) (n (NamedDim "m")))))))) (let t10 (substitution - (B (NamedMat "B")) - t7 (@rewrite_var__17 t2) - (D (Id (NamedDim "m"))) - (C (NamedMat "A")))) + t7 + (B (NamedMat "B")) + (C (NamedMat "A")) + (D (Id (NamedDim "m"))))) (let prf0 (Congr (= t2 t3) (Congr - (= t2 (Kron t4 (NamedMat "B"))) + (= t2 (Kron (NamedMat "A") t4)) (Rule - (= t2 (Kron t4 t5)) + (= t2 (Kron t5 t4)) (name "(rewrite (MMul (Kron A B) (Kron C D)) (Kron (MMul A C) (MMul B D)) :when ((= (ncols A) (nrows C)) (= (ncols B) (nrows D))))") t9 t10) (Rule - (= t5 (NamedMat "B")) - (name "(rewrite (MMul A (Id n)) A)") + (= t5 (NamedMat "A")) + (name "(rewrite (MMul (Id n) A) A)") (premises (Rule (= t5 t5) @@ -145,13 +145,13 @@ expression: proof_snapshot t9 t10)) (substitution - (n (NamedDim "m")) - (@rewrite_var__11 t5) - (A (NamedMat "B")))) - 1) + (@rewrite_var__10 t5) + (n (NamedDim "n")) + (A (NamedMat "A")))) + 0) (Rule - (= t4 (NamedMat "A")) - (name "(rewrite (MMul (Id n) A) A)") + (= t4 (NamedMat "B")) + (name "(rewrite (MMul A (Id n)) A)") (premises (Rule (= t4 t4) @@ -159,6 +159,6 @@ expression: proof_snapshot "(rewrite (MMul (Kron A B) (Kron C D)) (Kron (MMul A C) (MMul B D)) :when ((= (ncols A) (nrows C)) (= (ncols B) (nrows D))))") t9 t10)) - (substitution (@rewrite_var__10 t4) (A (NamedMat "A")) (n (NamedDim "n")))) - 0)) + (substitution (@rewrite_var__11 t4) (A (NamedMat "B")) (n (NamedDim "m")))) + 1)) (Trans prop0 prf0 (Sym (= t3 t2) prf0)) diff --git a/egglog/tests/snapshots/files__proofs__naturals_proof_testing.snap b/egglog/tests/snapshots/files__proofs__naturals_proof_testing.snap index 8a189ea8..948564ac 100644 --- a/egglog/tests/snapshots/files__proofs__naturals_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__naturals_proof_testing.snap @@ -39,7 +39,7 @@ expression: proof_snapshot (name "(rewrite (Plus (S m) n) (S (Plus m n)))") t8 t9)) - (substitution t5 (@rewrite_var__ t7))) + (substitution (@rewrite_var__ t7) t5)) 0) 0)) (let t0 (S (S (S (Z))))) @@ -52,7 +52,7 @@ expression: proof_snapshot (let t7 (Plus (S (S (Z))) t6)) (let t8 (premises (Fiat (= t1 t1)))) (let t9 (n t0)) -(let t10 (substitution (m (S (Z))) t9 (@rewrite_var__3 t1))) +(let t10 (substitution (@rewrite_var__3 t1) (m (S (Z))) t9)) (let t11 (premises (Rule @@ -91,7 +91,7 @@ expression: proof_snapshot (name "(rewrite (Times (S m) n) (Plus n (Times m n)))") t8 t10))) -(let t25 (substitution (m (Z)) t9 (@rewrite_var__3 t6))) +(let t25 (substitution (@rewrite_var__3 t6) (m (Z)) t9)) (let t26 (premises (Rule @@ -176,7 +176,7 @@ expression: proof_snapshot "(rewrite (Plus (S m) n) (S (Plus m n)))") t41 t42)) - (substitution t38 (@rewrite_var__ t40))) + (substitution (@rewrite_var__ t40) t38)) 0) 0) 1)) @@ -227,7 +227,7 @@ expression: proof_snapshot (name "(rewrite (Plus (S m) n) (S (Plus m n)))") t20 t21)) - (substitution t13 (@rewrite_var__ t19))) + (substitution (@rewrite_var__ t19) t13)) 0) (Congr (= t6 t0) @@ -259,7 +259,7 @@ expression: proof_snapshot (name "(rewrite (Plus (S m) n) (S (Plus m n)))") t31 t32)) - (substitution (n (Z)) (@rewrite_var__ (Plus (Z) (Z))))) + (substitution (@rewrite_var__ (Plus (Z) (Z))) (n (Z)))) 0) 0) 0) @@ -283,7 +283,7 @@ expression: proof_snapshot (name "(rewrite (Plus (S m) n) (S (Plus m n)))") t46 t47)) - (substitution t44 (@rewrite_var__ t33))) + (substitution (@rewrite_var__ t33) t44)) 0))) 0) (Sym diff --git a/egglog/tests/snapshots/files__proofs__nested_container_dirty_propagation_proof_testing.snap b/egglog/tests/snapshots/files__proofs__nested_container_dirty_propagation_proof_testing.snap index 14ce2f5a..7058037e 100644 --- a/egglog/tests/snapshots/files__proofs__nested_container_dirty_propagation_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__nested_container_dirty_propagation_proof_testing.snap @@ -23,8 +23,8 @@ expression: proof_snapshot (= (w (b)) (b)) (name "(rewrite (w x) x)") (premises (Fiat (= (w (b)) (w (b))))) - (substitution (x (b)) (@rewrite_var__ (w (b))))) + (substitution (@rewrite_var__ (w (b))) (x (b)))) 0) 0) 0)) - (substitution (@rewrite_var__1 t1) (@v61 (b)) (@v62 (vec-of (vec-of (b)))))) + (substitution (@v (b)) (@rewrite_var__1 t1) (@v1 (vec-of (vec-of (b)))))) diff --git a/egglog/tests/snapshots/files__proofs__path_proof_testing.snap b/egglog/tests/snapshots/files__proofs__path_proof_testing.snap index 3dcdd39a..754f43d3 100644 --- a/egglog/tests/snapshots/files__proofs__path_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__path_proof_testing.snap @@ -28,6 +28,6 @@ expression: proof_snapshot (premises (Fiat (= (edge 1 2) (edge 1 2)))) (substitution (x 1) (y 2))) (Fiat (= (edge 2 3) (edge 2 3)))) - (substitution (z 3) (x 1) (y 2))) + (substitution (x 1) (y 2) (z 3))) (Fiat (= (edge 3 4) (edge 3 4)))) - (substitution (z 4) (x 1) (y 3))) + (substitution (x 1) (y 3) (z 4))) diff --git a/egglog/tests/snapshots/files__proofs__path_union_proof_testing.snap b/egglog/tests/snapshots/files__proofs__path_union_proof_testing.snap index c6fdced8..602ede08 100644 --- a/egglog/tests/snapshots/files__proofs__path_union_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__path_union_proof_testing.snap @@ -39,10 +39,10 @@ expression: proof_snapshot (premises (Fiat (= t3 t3))) (substitution (x (mk 1)) (y (mk 2)))) (Fiat (= t4 t4))) - (substitution (z (mk 3)) (x (mk 1)) (y (mk 2)))) + (substitution (x (mk 1)) (y (mk 2)) (z (mk 3)))) (Congr (= t5 (edge (mk 3) (mk 6))) (Fiat (= t5 t5)) (Sym (= (mk 5) (mk 3)) (Fiat (= (mk 3) (mk 5)))) 0)) - (substitution (z (mk 6)) (x (mk 1)) (y (mk 3)))) + (substitution (x (mk 1)) (y (mk 3)) (z (mk 6)))) diff --git a/egglog/tests/snapshots/files__proofs__pathproof_proof_testing.snap b/egglog/tests/snapshots/files__proofs__pathproof_proof_testing.snap index fe492d75..e5086495 100644 --- a/egglog/tests/snapshots/files__proofs__pathproof_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__pathproof_proof_testing.snap @@ -21,4 +21,4 @@ expression: proof_snapshot )") (premises (Fiat (= (edge 2 1) (edge 2 1)))) (substitution (x 2) (y 1)))) - (substitution (z 1) (p (Edge 2 1)) (y 2) (x 3))) + (substitution (x 3) (y 2) (z 1) (p (Edge 2 1)))) diff --git a/egglog/tests/snapshots/files__proofs__points_to_proof_testing.snap b/egglog/tests/snapshots/files__proofs__points_to_proof_testing.snap index d73d74e4..81bb7a21 100644 --- a/egglog/tests/snapshots/files__proofs__points_to_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__points_to_proof_testing.snap @@ -11,7 +11,7 @@ expression: proof_snapshot ((VarPointsTo a b)) )") (premises (Fiat (= t1 t1))) - (substitution (a "o1") (x t1) (b (Class "A")))) + (substitution (x t1) (a "o1") (b (Class "A")))) (let t0 (VarPointsTo "o2" (Class "B"))) (let t1 (New "o2" (Class "B"))) (Rule @@ -21,7 +21,7 @@ expression: proof_snapshot ((VarPointsTo a b)) )") (premises (Fiat (= t1 t1))) - (substitution (a "o2") (x t1) (b (Class "B")))) + (substitution (x t1) (a "o2") (b (Class "B")))) (let t0 (VarPointsTo "o3" (Class "B"))) (let t1 (VarPointsTo "o2" (Class "B"))) (let t2 (New "o2" (Class "B"))) @@ -41,8 +41,8 @@ expression: proof_snapshot ((VarPointsTo a b)) )") (premises (Fiat (= t2 t2))) - (substitution (a "o2") (x t2) (b (Class "B"))))) - (substitution (v1 "o3") (c2 (Class "B")) (v2 "o2") (x (Assign "o3" "o2")))) + (substitution (x t2) (a "o2") (b (Class "B"))))) + (substitution (x (Assign "o3" "o2")) (v1 "o3") (v2 "o2") (c2 (Class "B")))) (let t0 (HeapPointsTo (Class "B") (Field "f") (Class "A"))) (let t1 (Store "o2" (Field "f") "o1")) (let t2 (VarPointsTo "o2" (Class "B"))) @@ -66,7 +66,7 @@ expression: proof_snapshot ((VarPointsTo a b)) )") (premises (Fiat (= t3 t3))) - (substitution (a "o2") (x t3) (b (Class "B")))) + (substitution (x t3) (a "o2") (b (Class "B")))) (Rule (= t4 t4) (name @@ -74,14 +74,14 @@ expression: proof_snapshot ((VarPointsTo a b)) )") (premises (Fiat (= t5 t5))) - (substitution (a "o1") (x t5) (b (Class "A"))))) + (substitution (x t5) (a "o1") (b (Class "A"))))) (substitution + (x t1) + (v1 "o2") + (f (Field "f")) (v2 "o1") (c1 (Class "B")) - (c2 (Class "A")) - (f (Field "f")) - (x t1) - (v1 "o2"))) + (c2 (Class "A")))) (let t0 (VarPointsTo "r" (Class "A"))) (let t1 (Load "r" "o3" (Field "f"))) (let t2 (VarPointsTo "o3" (Class "B"))) @@ -95,7 +95,7 @@ expression: proof_snapshot ((VarPointsTo a b)) )") (premises (Fiat (= t4 t4))) - (substitution (a "o2") (x t4) (b (Class "B"))))) + (substitution (x t4) (a "o2") (b (Class "B"))))) (let t5 (HeapPointsTo (Class "B") (Field "f") (Class "A"))) (let t6 (Store "o2" (Field "f") "o1")) (let t7 (VarPointsTo "o1" (Class "A"))) @@ -118,7 +118,7 @@ expression: proof_snapshot ((VarPointsTo v1 c2)) )") (premises (Fiat (= (Assign "o3" "o2") (Assign "o3" "o2"))) prf0) - (substitution (v1 "o3") (c2 (Class "B")) (v2 "o2") (x (Assign "o3" "o2")))) + (substitution (x (Assign "o3" "o2")) (v1 "o3") (v2 "o2") (c2 (Class "B")))) (Rule (= t5 t5) (name @@ -137,18 +137,18 @@ expression: proof_snapshot ((VarPointsTo a b)) )") (premises (Fiat (= t8 t8))) - (substitution (a "o1") (x t8) (b (Class "A"))))) + (substitution (x t8) (a "o1") (b (Class "A"))))) (substitution + (x t6) + (v1 "o2") + (f (Field "f")) (v2 "o1") (c1 (Class "B")) - (c2 (Class "A")) - (f (Field "f")) - (x t6) - (v1 "o2")))) + (c2 (Class "A"))))) (substitution + (x t1) (v1 "r") - (c1 (Class "B")) - (c2 (Class "A")) - (f (Field "f")) (v2 "o3") - (x t1))) + (f (Field "f")) + (c1 (Class "B")) + (c2 (Class "A")))) diff --git a/egglog/tests/snapshots/files__proofs__repro_equal_constant_proof_testing.snap b/egglog/tests/snapshots/files__proofs__repro_equal_constant_proof_testing.snap index f1e6accd..aa99bf52 100644 --- a/egglog/tests/snapshots/files__proofs__repro_equal_constant_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__repro_equal_constant_proof_testing.snap @@ -6,4 +6,4 @@ expression: proof_snapshot (= (@ExistsConstructor) (@ExistsConstructor)) (name "@prove_exists_rule") (premises (Fiat (= (foo 10) (foo 10))) (Fiat (= () ()))) - (substitution (@v27 10))) + (substitution (@v 10))) diff --git a/egglog/tests/snapshots/files__proofs__repro_filter_bug_proof_testing.snap b/egglog/tests/snapshots/files__proofs__repro_filter_bug_proof_testing.snap index 9367d0e3..2c0e0f37 100644 --- a/egglog/tests/snapshots/files__proofs__repro_filter_bug_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__repro_filter_bug_proof_testing.snap @@ -10,4 +10,4 @@ expression: proof_snapshot (Fiat (= (UF_Exp (E) (E)) (UF_Exp (E) (E)))) (Fiat (= (UF_Stmt (S1) (S2)) (UF_Stmt (S1) (S2)))) (Fiat (= () ()))) - (substitution (c1_leader (E)) (c2 (S1)) (c2_leader (S2)) (c1 (E)))) + (substitution (c1 (E)) (c2 (S1)) (c1_leader (E)) (c2_leader (S2)))) diff --git a/egglog/tests/snapshots/files__proofs__repro_querybug4_proof_testing.snap b/egglog/tests/snapshots/files__proofs__repro_querybug4_proof_testing.snap index d3614d2f..3c5842ef 100644 --- a/egglog/tests/snapshots/files__proofs__repro_querybug4_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__repro_querybug4_proof_testing.snap @@ -12,4 +12,4 @@ expression: proof_snapshot ((union (OtherNum fvar5__) (Num fvar5__))) )") (premises prf0 prf0 prf0) - (substitution (y 2) (fvar5__ 2) (fvar6__ 2))) + (substitution (fvar5__ 2) (fvar6__ 2) (y 2))) diff --git a/egglog/tests/snapshots/files__proofs__repro_typecheck_term_encoding_proof_testing.snap b/egglog/tests/snapshots/files__proofs__repro_typecheck_term_encoding_proof_testing.snap index 5090fb61..6ae5333b 100644 --- a/egglog/tests/snapshots/files__proofs__repro_typecheck_term_encoding_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__repro_typecheck_term_encoding_proof_testing.snap @@ -7,11 +7,11 @@ expression: proof_snapshot (let t2 (premises (Fiat (= t0 t0)))) (let t3 (substitution - (e (Var "x")) - (ctx (NilConst)) (@rewrite_var__3 t0) + (ctx (NilConst)) (x "x") - (t1 (TUnitConst)))) + (t1 (TUnitConst)) + (e (Var "x")))) (Congr (= t0 (TArr (TUnitConst) (TUnitConst))) (Rule @@ -31,8 +31,8 @@ expression: proof_snapshot t2 t3)) (substitution - (t (TUnitConst)) - (ctx (NilConst)) (@rewrite_var__1 t1) - (x "x"))) + (x "x") + (t (TUnitConst)) + (ctx (NilConst)))) 1) diff --git a/egglog/tests/snapshots/files__proofs__resolution_proof_testing.snap b/egglog/tests/snapshots/files__proofs__resolution_proof_testing.snap index c838ac37..b914a5b6 100644 --- a/egglog/tests/snapshots/files__proofs__resolution_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__resolution_proof_testing.snap @@ -1,45 +1,38 @@ --- source: egglog/tests/files.rs -assertion_line: 279 expression: proof_snapshot --- (Fiat (= () ())) -(let t0 (myor (negate (p 0)) (myor (FalseConst) (FalseConst)))) -(let t1 (myor (negate (p 0)) (FalseConst))) -(let t2 (a (negate (p 0)))) +(let t0 (myor (negate (p 0)) (FalseConst))) (let prf0 (Rule - (= t1 (negate (p 0))) - (name "(rewrite (myor a $False) a)") - (premises (Fiat (= t1 t1))) - (substitution t2 (@rewrite_var__5 t1)))) -(let prf1 (Sym (= (negate (p 0)) t1) prf0)) -(let t3 (myor (p 2) t1)) -(let prop0 - (= - (TrueConst) - (myor (negate (p 2)) (myor (FalseConst) (FalseConst))))) -(let t4 (myor (negate (p 2)) (FalseConst))) -(let t5 (myor (p 1) t4)) -(let t6 - (premises - (Congr - (= (TrueConst) (myor (FalseConst) t4)) - (Sym (= (TrueConst) t5) (Fiat (= t5 (TrueConst)))) - (Fiat (= (p 1) (FalseConst))) - 0))) -(let prf2 - (Rule - (= t4 (negate (p 2))) - (name "(rewrite (myor a $False) a)") - (premises (Fiat (= t4 t4))) - (substitution (a (negate (p 2))) (@rewrite_var__5 t4)))) -(let prf3 + (= t0 (negate (p 0))) + (name "(rewrite (myor a $False) a)") + (premises (Fiat (= t0 t0))) + (substitution (@rewrite_var__5 t0) (a (negate (p 0)))))) +(let t1 (myor (p 2) t0)) +(let t2 (myor (negate (p 2)) (FalseConst))) +(let prop0 (= (TrueConst) t2)) +(let t3 (myor (p 1) t2)) +(let prf1 (Rule - (= t4 (TrueConst)) + (= t2 (TrueConst)) (name "(rewrite (myor $False a) a)") - t6 - (substitution (@rewrite_var__4 (TrueConst)) (a t4)))) + (premises + (Congr + (= (TrueConst) (myor (FalseConst) t2)) + (Sym (= (TrueConst) t3) (Fiat (= t3 (TrueConst)))) + (Fiat (= (p 1) (FalseConst))) + 0)) + (substitution (@rewrite_var__4 (TrueConst)) (a t2)))) +(let prf2 (Sym prop0 prf1)) +(let prf3 (Fiat (= t2 t2))) +(let prf4 + (Rule + (= t2 (negate (p 2))) + (name "(rewrite (myor a $False) a)") + (premises prf3) + (substitution (@rewrite_var__5 t2) (a (negate (p 2)))))) (Rule (= (p 0) (FalseConst)) (name @@ -49,15 +42,7 @@ expression: proof_snapshot (premises (Trans (= (negate (p 0)) (TrueConst)) - (Rule - (= (negate (p 0)) t0) - (name "(rewrite (myor (myor a b) c) (myor a (myor b c)))") - (premises (Congr (= (negate (p 0)) (myor t1 (FalseConst))) prf1 prf1 0)) - (substitution - (c (FalseConst)) - t2 - (b (FalseConst)) - (@rewrite_var__ (negate (p 0))))) + (Sym (= (negate (p 0)) t0) prf0) (Sym (= t0 (TrueConst)) (Rule @@ -70,38 +55,25 @@ expression: proof_snapshot (premises (Congr (= (TrueConst) (myor (p 2) (negate (p 0)))) - (Sym (= (TrueConst) t3) (Fiat (= t3 (TrueConst)))) + (Sym (= (TrueConst) t1) (Fiat (= t1 (TrueConst)))) prf0 1) (Congr prop0 - (Congr - (= - (TrueConst) - (myor (TrueConst) (myor (FalseConst) (FalseConst)))) - (Rule - prop0 - (name "(rewrite (myor a (myor b c)) (myor b (myor a c)))") - t6 - (substitution - (@rewrite_var__1 (TrueConst)) - (a (FalseConst)) - (b (negate (p 2))) - (c (FalseConst)))) - (Trans - (= (negate (p 2)) (TrueConst)) - (Sym (= (negate (p 2)) t4) prf2) - prf3) - 0) (Trans - (= (TrueConst) (negate (p 2))) - (Sym (= (TrueConst) t4) prf3) - prf2) + (= (TrueConst) (myor (TrueConst) (FalseConst))) + prf2 + (Congr + (= t2 (myor (TrueConst) (FalseConst))) + prf3 + (Trans + (= (negate (p 2)) (TrueConst)) + (Sym (= (negate (p 2)) t2) prf4) + prf1) + 0)) + (Trans (= (TrueConst) (negate (p 2))) prf2 prf4) 0)) - (substitution - (bs (myor (FalseConst) (FalseConst))) - (a (p 2)) - (as (negate (p 0)))))))) + (substitution (a (p 2)) (as (negate (p 0))) (bs (FalseConst))))))) (substitution (p (p 0)))) (let t0 (myor (negate (p 2)) (FalseConst))) (let t1 (myor (p 1) t0)) @@ -120,7 +92,7 @@ expression: proof_snapshot (= t0 (negate (p 2))) (name "(rewrite (myor a $False) a)") (premises (Fiat (= t0 t0))) - (substitution (a (negate (p 2))) (@rewrite_var__5 t0)))) + (substitution (@rewrite_var__5 t0) (a (negate (p 2)))))) (Rule (= t0 (TrueConst)) (name "(rewrite (myor $False a) a)") diff --git a/egglog/tests/snapshots/files__proofs__rw_analysis_proof_testing.snap b/egglog/tests/snapshots/files__proofs__rw_analysis_proof_testing.snap index 03de7536..05e856c0 100644 --- a/egglog/tests/snapshots/files__proofs__rw_analysis_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__rw_analysis_proof_testing.snap @@ -28,9 +28,9 @@ expression: proof_snapshot )") (premises prf2) (substitution - (val (I 10)) (l (L 4)) - (x (V "x")))) + (x (V "x")) + (val (I 10)))) (Sym (= t8 (Prog (L 4))) prf2) 1)) (let prf4 @@ -56,9 +56,9 @@ expression: proof_snapshot )") (premises prf5) (substitution - (val (I 0)) (l (L 3)) - (x (V "zero")))) + (x (V "zero")) + (val (I 0)))) (Sym (= t12 (Prog (L 3))) prf5) 1)) (let prf7 @@ -84,9 +84,9 @@ expression: proof_snapshot )") (premises prf8) (substitution - (val (I 1)) (l (L 2)) - (x (V "one")))) + (x (V "one")) + (val (I 1)))) (Sym (= t16 (Prog (L 2))) prf8) 1)) (let t18 @@ -112,9 +112,9 @@ expression: proof_snapshot )") (premises prf10) (substitution - (val (I 10)) (l (L 1)) - (x (V "ten")))) + (x (V "ten")) + (val (I 10)))) (Sym (= t20 (Prog (L 1))) prf10) @@ -147,9 +147,9 @@ expression: proof_snapshot )") (premises prf12) (substitution - (val (TopConst)) (l (L 0)) - (x (V "b")))) + (x (V "b")) + (val (TopConst)))) (Sym (= t24 (Prog (L 0))) prf12) @@ -245,46 +245,46 @@ expression: proof_snapshot prf14 prf15) (substitution + (li 1) + (x "ten") t26 (y "b") - (li 1) (@n4 (TopConst)) - (x "ten") (val (TopConst)))) prf14 prf15) (substitution + (li 2) + (x "one") (e (Const (I 1))) (y "b") - (li 2) (@n4 (TopConst)) - (x "one") (val (TopConst)))) prf14 prf15) (substitution + (li 3) + (x "zero") (e (Const (I 0))) (y "b") - (li 3) (@n4 (TopConst)) - (x "zero") (val (TopConst)))) prf14 prf15) (substitution + (li 4) + (x "x") t26 (y "b") - (li 4) (@n4 (TopConst)) - (x "x") (val (TopConst)))) prf14) (substitution - (l1 (L 6)) (l (L 5)) + (b (V "b")) + (l1 (L 6)) (l2 (L 13)) - (@n21 (TopConst)) - (b (V "b")))) + (@n21 (TopConst)))) (Sym (= t4 (Prog (L 5))) prf1) 1)) (let prf17 (Congr prop0 (Trans (= t3 t3) (Sym prop0 prf16) prf16) prf1 1)) @@ -311,13 +311,13 @@ expression: proof_snapshot (substitution (li 4) (x (V "x")) (k (I 10)))) prf18) (substitution - (l1 (L 6)) - (x (V "x")) (l (L 5)) - (l2 (L 13)) - (val (I 10)) (b (V "b")) - (@n18 (I 10))))) + (l1 (L 6)) + (l2 (L 13)) + (x (V "x")) + (@n18 (I 10)) + (val (I 10))))) (let t28 (const-prop (L 13) (V "zero") (I 0))) (let t29 (const-prop (L 5) (V "zero") (I 0))) (let t30 (const-prop (L 4) (V "zero") (I 0))) @@ -354,21 +354,21 @@ expression: proof_snapshot prf20 prf15) (substitution + (li 4) + (x "x") t26 (y "zero") - (li 4) (@n4 (I 0)) - (x "x") (val (I 0)))) prf20) (substitution - (l1 (L 6)) - (x (V "zero")) (l (L 5)) - (l2 (L 13)) - (val (I 0)) (b (V "b")) - (@n18 (I 0))))) + (l1 (L 6)) + (l2 (L 13)) + (x (V "zero")) + (@n18 (I 0)) + (val (I 0))))) (let t31 (add-val (I 10) (I 0))) (Rule (= (@ExistsConstructor) (@ExistsConstructor)) @@ -408,23 +408,23 @@ expression: proof_snapshot )") (premises prf0 prf19 prf18 prf21 prf20) (substitution - (@n5 (I 10)) - (v2 (I 0)) + (l (L 13)) + (x (V "y")) (x1 (V "x")) (x2 (V "zero")) - (l (L 13)) + (@n5 (I 10)) + (v1 (I 10)) (@n6 (I 0)) - (x (V "y")) - (v1 (I 10))))) + (v2 (I 0))))) (substitution (@rewrite_var__14 t31) (x 10) (y 0))))) (substitution + (l (L 13)) + (x (V "y")) (x1 (V "x")) (x2 (V "zero")) - (l (L 13)) (@n9 (I 10)) (@n10 (I 0)) - (val 10) - (x (V "y"))))) + (val 10)))) (substitution (li 13) (x (V "y")) (k (I 10)))) prf18) (substitution (@n23 (I 10)))) diff --git a/egglog/tests/snapshots/files__proofs__stratified_proof_testing.snap b/egglog/tests/snapshots/files__proofs__stratified_proof_testing.snap index 4c9ddab8..35e67375 100644 --- a/egglog/tests/snapshots/files__proofs__stratified_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__stratified_proof_testing.snap @@ -28,4 +28,4 @@ expression: proof_snapshot (premises (Fiat (= (edge 1 2) (edge 1 2)))) (substitution (x 1) (y 2))) (Fiat (= (edge 2 3) (edge 2 3)))) - (substitution (z 3) (x 1) (y 2))) + (substitution (x 1) (y 2) (z 3))) diff --git a/egglog/tests/snapshots/files__proofs__test_combined_proof_testing.snap b/egglog/tests/snapshots/files__proofs__test_combined_proof_testing.snap index 86c09fbd..4d1c2c22 100644 --- a/egglog/tests/snapshots/files__proofs__test_combined_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__test_combined_proof_testing.snap @@ -27,7 +27,7 @@ expression: proof_snapshot (premises (Fiat (= (edge 0 1) (edge 0 1)))) (substitution (x 0) (y 1))) (Fiat (= (edge 1 2) (edge 1 2)))) - (substitution (z 2) (x 0) (y 1))) + (substitution (x 0) (y 1) (z 2))) (Rule (= (path 0 3) (path 0 3)) (name @@ -53,9 +53,9 @@ expression: proof_snapshot (premises (Fiat (= (edge 0 1) (edge 0 1)))) (substitution (x 0) (y 1))) (Fiat (= (edge 1 2) (edge 1 2)))) - (substitution (z 2) (x 0) (y 1))) + (substitution (x 0) (y 1) (z 2))) (Fiat (= (edge 2 3) (edge 2 3)))) - (substitution (z 3) (x 0) (y 2))) + (substitution (x 0) (y 2) (z 3))) (Rule (= (path 0 4) (path 0 4)) (name @@ -81,9 +81,9 @@ expression: proof_snapshot (premises (Fiat (= (edge 0 1) (edge 0 1)))) (substitution (x 0) (y 1))) (Fiat (= (edge 1 2) (edge 1 2)))) - (substitution (z 2) (x 0) (y 1))) + (substitution (x 0) (y 1) (z 2))) (Fiat (= (edge 2 4) (edge 2 4)))) - (substitution (z 4) (x 0) (y 2))) + (substitution (x 0) (y 2) (z 4))) (Rule (= (path 1 2) (path 1 2)) (name @@ -109,7 +109,7 @@ expression: proof_snapshot (premises (Fiat (= (edge 1 2) (edge 1 2)))) (substitution (x 1) (y 2))) (Fiat (= (edge 2 3) (edge 2 3)))) - (substitution (z 3) (x 1) (y 2))) + (substitution (x 1) (y 2) (z 3))) (Rule (= (path 1 4) (path 1 4)) (name @@ -127,4 +127,4 @@ expression: proof_snapshot (premises (Fiat (= (edge 1 2) (edge 1 2)))) (substitution (x 1) (y 2))) (Fiat (= (edge 2 4) (edge 2 4)))) - (substitution (z 4) (x 1) (y 2))) + (substitution (x 1) (y 2) (z 4))) diff --git a/egglog/tests/snapshots/files__proofs__typecheck_proof_testing.snap b/egglog/tests/snapshots/files__proofs__typecheck_proof_testing.snap index e0df6228..1d9e3740 100644 --- a/egglog/tests/snapshots/files__proofs__typecheck_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__typecheck_proof_testing.snap @@ -7,11 +7,11 @@ expression: proof_snapshot (let t2 (premises (Fiat (= t0 t0)))) (let t3 (substitution - (e (Var "x")) - (ctx (NilConst)) (@rewrite_var__3 t0) + (ctx (NilConst)) (x "x") - (t1 (TUnitConst)))) + (t1 (TUnitConst)) + (e (Var "x")))) (Congr (= t0 (TArr (TUnitConst) (TUnitConst))) (Rule @@ -31,10 +31,10 @@ expression: proof_snapshot t2 t3)) (substitution - (t (TUnitConst)) - (ctx (NilConst)) (@rewrite_var__1 t1) - (x "x"))) + (x "x") + (t (TUnitConst)) + (ctx (NilConst)))) 1) (let t0 (App (Var "f") (Var "x"))) (let t1 (Lam "f" (TArr (TUnitConst) (TUnitConst)) t0)) @@ -50,8 +50,8 @@ expression: proof_snapshot (let t10 (typeof (NilConst) t4)) (let t11 (TArr (TArr (TUnitConst) (TUnitConst)) t8)) (let t12 (typeof t6 t1)) -(let t13 (e t4)) -(let t14 (f t3)) +(let t13 (f t3)) +(let t14 (e t4)) (let prf1 (Rule (= t9 t9) @@ -61,16 +61,16 @@ expression: proof_snapshot (typeof ctx e)) )") (premises prf0) - (substitution (t2 t5) t13 (ctx (NilConst)) t14))) + (substitution (ctx (NilConst)) t13 t14 (t2 t5)))) (let t15 (typeof (NilConst) t2)) (let t16 (premises prf1)) (let t17 (f t2)) (let t18 (substitution - (t2 t9) - (e (MyUnitConst)) (ctx (NilConst)) - t17)) + t17 + (e (MyUnitConst)) + (t2 t9))) (let t19 (premises (Rule @@ -84,11 +84,11 @@ expression: proof_snapshot t18))) (let t20 (substitution - (e t1) - (ctx (NilConst)) (@rewrite_var__3 t15) + (ctx (NilConst)) (x "x") - (t1 (TUnitConst)))) + (t1 (TUnitConst)) + (e t1))) (let t21 (premises (Rule @@ -100,20 +100,20 @@ expression: proof_snapshot (let t22 (ctx t6)) (let t23 (substitution - (e t0) - t22 (@rewrite_var__3 t12) + t22 (x "f") - (t1 (TArr (TUnitConst) (TUnitConst))))) + (t1 (TArr (TUnitConst) (TUnitConst))) + (e t0))) (let t24 (typeof t6 (Var "x"))) (let t25 (premises (Fiat (= t10 t10)))) (let t26 (substitution - (e (Var "x")) - (ctx (NilConst)) (@rewrite_var__3 t10) + (ctx (NilConst)) (x "x") - (t1 (TUnitConst)))) + (t1 (TUnitConst)) + (e (Var "x")))) (let prf2 (Rule (= t24 (TUnitConst)) @@ -126,12 +126,12 @@ expression: proof_snapshot t25 t26)) (substitution - (t (TUnitConst)) - (ctx (NilConst)) (@rewrite_var__1 t24) - (x "x")))) -(let t27 (t2 t8)) -(let t28 (t1 t5)) + (x "x") + (t (TUnitConst)) + (ctx (NilConst))))) +(let t27 (t1 t5)) +(let t28 (t2 t8)) (let t29 (typeof t7 (Var "f"))) (let t30 (typeof t7 (Var "x"))) (let t31 (TArr t30 (TUnitConst))) @@ -144,7 +144,7 @@ expression: proof_snapshot t21 t23))) (let t33 (ctx t7)) -(let t34 (substitution t27 (e (Var "x")) t33 (f (Var "f")))) +(let t34 (substitution t33 (f (Var "f")) (e (Var "x")) t28)) (Rule (= t5 (TUnitConst)) (name @@ -203,15 +203,15 @@ expression: proof_snapshot t16 t18)) (substitution - (ctx (NilConst)) - (@rewrite_var__ (typeof (NilConst) (MyUnitConst)))))) + (@rewrite_var__ (typeof (NilConst) (MyUnitConst))) + (ctx (NilConst))))) 0)) (substitution - (t2 t12) - (e (MyUnitConst)) (ctx (NilConst)) t17 - (t1 t9)))) + (e (MyUnitConst)) + (t1 t9) + (t2 t12)))) (Rule (= t12 t11) (name @@ -231,7 +231,7 @@ expression: proof_snapshot prf2 1)) 0)) - (substitution t27 t13 (ctx (NilConst)) t14 t28)) + (substitution (ctx (NilConst)) t13 t14 t27 t28)) (Trans (= t29 t31) (Rule @@ -248,10 +248,10 @@ expression: proof_snapshot t32 t34)) (substitution - (t (TArr (TUnitConst) (TUnitConst))) - t22 (@rewrite_var__1 t29) - (x "f"))) + (x "f") + (t (TArr (TUnitConst) (TUnitConst))) + t22)) (Congr (= (TArr (TUnitConst) (TUnitConst)) t31) (Fiat @@ -278,12 +278,12 @@ expression: proof_snapshot (Fiat (= () ()))) (substitution (@rewrite_var__2 t30) - (ty (TArr (TUnitConst) (TUnitConst))) (y "f") + (ty (TArr (TUnitConst) (TUnitConst))) t22 (x "x"))))) 0))) - (substitution (t2 (TUnitConst)) (e (Var "x")) t33 (f (Var "f")) t28)) + (substitution t33 (f (Var "f")) (e (Var "x")) t27 (t2 (TUnitConst)))) (let t0 (Cons "y" (TUnitConst) (NilConst))) (let t1 (typeof t0 (Lam "x" (TUnitConst) (Var "y")))) (let t2 (typeof (Cons "x" (TUnitConst) t0) (Var "y"))) @@ -291,11 +291,11 @@ expression: proof_snapshot (let t4 (ctx t0)) (let t5 (substitution - (e (Var "y")) - t4 (@rewrite_var__3 t1) + t4 (x "x") - (t1 (TUnitConst)))) + (t1 (TUnitConst)) + (e (Var "y")))) (Congr (= t1 (TArr (TUnitConst) (TUnitConst))) (Rule @@ -320,12 +320,12 @@ expression: proof_snapshot t3 t5) (Fiat (= () ()))) - (substitution (@rewrite_var__2 t2) (ty (TUnitConst)) (y "x") t4 (x "y")))) + (substitution (@rewrite_var__2 t2) (y "x") (ty (TUnitConst)) t4 (x "y")))) (substitution - (t (TUnitConst)) - (ctx (NilConst)) (@rewrite_var__1 t2) - (x "y"))) + (x "y") + (t (TUnitConst)) + (ctx (NilConst)))) 1) (let t0 (TArr (TArr (TUnitConst) (TUnitConst)) (TUnitConst))) (let t1 (Cons "y" t0 (NilConst))) @@ -335,11 +335,11 @@ expression: proof_snapshot (let t5 (ctx t1)) (let t6 (substitution - (e (Var "y")) - t5 (@rewrite_var__3 t2) + t5 (x "x") - (t1 (TUnitConst)))) + (t1 (TUnitConst)) + (e (Var "y")))) (Congr (= t2 (TArr (TUnitConst) t0)) (Rule @@ -364,6 +364,6 @@ expression: proof_snapshot t4 t6) (Fiat (= () ()))) - (substitution (@rewrite_var__2 t3) (ty (TUnitConst)) (y "x") t5 (x "y")))) - (substitution (t t0) (ctx (NilConst)) (@rewrite_var__1 t3) (x "y"))) + (substitution (@rewrite_var__2 t3) (y "x") (ty (TUnitConst)) t5 (x "y")))) + (substitution (@rewrite_var__1 t3) (x "y") (t t0) (ctx (NilConst)))) 1) diff --git a/egglog/tests/snapshots/files__proofs__unification_points_to_proof_testing.snap b/egglog/tests/snapshots/files__proofs__unification_points_to_proof_testing.snap index 779a4e80..c7dedea1 100644 --- a/egglog/tests/snapshots/files__proofs__unification_points_to_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__unification_points_to_proof_testing.snap @@ -13,11 +13,11 @@ expression: proof_snapshot (let t2 (premises (Fiat (= t0 t0)) prf0)) (let t3 (substitution - (v (Expr "v")) - (t2 (Type "int")) (s (Stmt "int *v = malloc(sizeof(int))")) + (t1 (Type "int *")) + (v (Expr "v")) (c (Expr "malloc(sizeof(int))")) - (t1 (Type "int *")))) + (t2 (Type "int")))) (let t4 (struct-lit-field (Expr "(struct s){u, v}") @@ -37,11 +37,11 @@ expression: proof_snapshot (let t7 (premises (Fiat (= t6 t6)) prf0)) (let t8 (substitution - (v (Expr "u")) - (t2 (Type "int")) (s (Stmt "int *u = malloc(sizeof(int))")) + (t1 (Type "int *")) + (v (Expr "u")) (c (Expr "malloc(sizeof(int))")) - (t1 (Type "int *")))) + (t2 (Type "int")))) (Trans (= (AllocVar (Expr "v")) (AllocVar (Expr "u"))) (Trans @@ -133,11 +133,11 @@ expression: proof_snapshot (let t2 (premises (Fiat (= t0 t0)) prf0)) (let t3 (substitution - (v (Expr "v")) - (t2 (Type "int")) (s (Stmt "int *v = malloc(sizeof(int))")) + (t1 (Type "int *")) + (v (Expr "v")) (c (Expr "malloc(sizeof(int))")) - (t1 (Type "int *")))) + (t2 (Type "int")))) (let t4 (struct-lit-field (Expr "(struct s){u, v}") @@ -229,11 +229,11 @@ expression: proof_snapshot )") (premises (Fiat (= t6 t6)) prf0) (substitution - (v (Expr "u")) - (t2 (Type "int")) (s (Stmt "int *u = malloc(sizeof(int))")) + (t1 (Type "int *")) + (v (Expr "u")) (c (Expr "malloc(sizeof(int))")) - (t1 (Type "int *"))))) + (t2 (Type "int"))))) (substitution (l (Expr "(struct s){u, v}")) (f (Field "x")) @@ -250,12 +250,12 @@ expression: proof_snapshot )") (premises (Fiat (= t7 t7)) (Fiat (= t8 t8))) (substitution - (v (Expr "sp")) - (t2 (Type "struct s")) (s (Stmt "struct s *sp = malloc(sizeof(struct s))")) + (t1 (Type "struct s*")) + (v (Expr "sp")) (c (Expr "malloc(sizeof(struct s))")) - (t1 (Type "struct s*"))))) + (t2 (Type "struct s"))))) (Fiat (= () ()))) (substitution - (@v2896 (expr-points-to (Expr "u"))) - (@v2897 (expr-points-to (Expr "sp"))))) + (@v (expr-points-to (Expr "u"))) + (@v1 (expr-points-to (Expr "sp"))))) diff --git a/egglog/tests/snapshots/files__proofs__unify_proof_testing.snap b/egglog/tests/snapshots/files__proofs__unify_proof_testing.snap index ad075110..31da7a5f 100644 --- a/egglog/tests/snapshots/files__proofs__unify_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__unify_proof_testing.snap @@ -12,9 +12,9 @@ expression: proof_snapshot (union b d)) )") (premises (Fiat (= (Mul (Var "a") (Var "a")) (Mul (Lit 1) (Lit 2))))) - (substitution (d (Lit 2)) (a (Var "a")) (b (Var "a")) (c (Lit 1))))) + (substitution (a (Var "a")) (b (Var "a")) (c (Lit 1)) (d (Lit 2))))) (let t0 (premises (Fiat (= (Mul (Var "a") (Var "a")) (Mul (Lit 1) (Lit 2)))))) -(let t1 (substitution (d (Lit 2)) (a (Var "a")) (b (Var "a")) (c (Lit 1)))) +(let t1 (substitution (a (Var "a")) (b (Var "a")) (c (Lit 1)) (d (Lit 2)))) (Trans (= (Lit 2) (Lit 1)) (Rule diff --git a/egglog/tests/snapshots/files__proofs__until_proof_testing.snap b/egglog/tests/snapshots/files__proofs__until_proof_testing.snap index e4408b9a..07fdd4e5 100644 --- a/egglog/tests/snapshots/files__proofs__until_proof_testing.snap +++ b/egglog/tests/snapshots/files__proofs__until_proof_testing.snap @@ -14,10 +14,10 @@ expression: proof_snapshot (name "(birewrite (g* (g* a b) c) (g* a (g* b c)))=>") (premises (Fiat (= t0 t0))) (substitution - (c (g* (AConst) (AConst))) + (@rewrite_var__ t0) (a (AConst)) (b (AConst)) - (@rewrite_var__ t0)))) + (c (g* (AConst) (AConst)))))) (substitution (@rewrite_var__4 t0)))) (Rule (= t1 (IConst)) @@ -25,9 +25,9 @@ expression: proof_snapshot (premises (Congr (= t1 (g* (IConst) (IConst))) - (Congr (= t1 (g* t0 (IConst))) (Fiat (= t1 t1)) prf0 1) + (Congr (= t1 (g* (IConst) t0)) (Fiat (= t1 t1)) prf0 0) prf0 - 0)) + 1)) (substitution (@rewrite_var__2 t1) (a (IConst)))) (Fiat (= () ())) (Fiat (= () ())) diff --git a/egglog/tests/snapshots/files__shared_snapshot_custom_eqsort_output_rebuild.snap b/egglog/tests/snapshots/files__shared_snapshot_custom_eqsort_output_rebuild.snap new file mode 100644 index 00000000..6f27148a --- /dev/null +++ b/egglog/tests/snapshots/files__shared_snapshot_custom_eqsort_output_rebuild.snap @@ -0,0 +1,7 @@ +--- +source: egglog/tests/files.rs +expression: snapshot_content_across_treatments +--- +((Num 3) + (W 1) + (f 1)) diff --git a/egglog/tests/snapshots/files__shared_snapshot_index_any.snap b/egglog/tests/snapshots/files__shared_snapshot_index_any.snap new file mode 100644 index 00000000..468c047a --- /dev/null +++ b/egglog/tests/snapshots/files__shared_snapshot_index_any.snap @@ -0,0 +1,19 @@ +--- +source: egglog/tests/files.rs +expression: snapshot_content_across_treatments +--- +3 +((Add 0) + (Num 15) + (arc 1) + (crossed 6) + (dirty 4) + (edge 3) + (link 3) + (linked 3) + (seed 1) + (selfcol 3) + (touched 3) + (triple 1) + (viaindex 0) + (viaplain 0)) diff --git a/scripts/nightly_bench.py b/scripts/nightly_bench.py index 052f741e..431142bd 100644 --- a/scripts/nightly_bench.py +++ b/scripts/nightly_bench.py @@ -16,10 +16,14 @@ The egraphs-good nightly service (``nightly.cs.washington.edu``) checks out this repository, runs ``make nightly``, and serves that directory, matching ``report=`` in the nightly configuration. + +``nightly/output/`` is git-ignored, so this runs the same way locally as it does +on the host. ``make nightly-local`` is that run at ``--rounds 1``. """ from __future__ import annotations +import argparse import os import shlex import shutil @@ -63,21 +67,24 @@ # The nightly host leaves rustup's shim directory off PATH, so cargo resolves to # Ubuntu's, which predates rust-toolchain.toml's pin; only rustup honours that -# pin. Putting the shims first makes cargo fetch the pinned toolchain. -CARGO_BIN_DIR = Path.home() / ".cargo" / "bin" +# pin. Putting the shims first makes cargo fetch the pinned toolchain. `CARGO_HOME` +# follows the Makefile's CARGO_HOME_DIR, which is where it installs rustup. +CARGO_BIN_DIR = Path(os.environ.get("CARGO_HOME") or Path.home() / ".cargo") / "bin" def _bench_env() -> dict[str, str]: """bench.py's environment: rustup's cargo first, and no browser launch.""" - path = os.environ.get("PATH", "").split(os.pathsep) - if str(CARGO_BIN_DIR) not in path: - path.insert(0, str(CARGO_BIN_DIR)) + # Prepend unconditionally: already being *somewhere* on PATH is not enough, + # since a directory holding a distro cargo can precede it and still win. + shims = str(CARGO_BIN_DIR) + path = [entry for entry in os.environ.get("PATH", "").split(os.pathsep) if entry != shims] + path.insert(0, shims) # Keep the headless nightly host from launching bench.py's best-effort browser. return {**os.environ, "PATH": os.pathsep.join(path), "BROWSER": "true"} -def _run(target: Target, endpoint: Endpoint, *, open_report: bool) -> int: +def _run(target: Target, endpoint: Endpoint, *, open_report: bool, rounds: int | None) -> int: """Benchmark one endpoint against the baseline on the same checkout.""" label, source = target @@ -101,31 +108,51 @@ def _run(target: Target, endpoint: Endpoint, *, open_report: bool) -> int: # Per-file tables make a long run's progress legible. "--detail", "files", + *(["--rounds", str(rounds)] if rounds is not None else []), *(["--open"] if open_report else []), ] print(f"nightly: {' '.join(shlex.quote(part) for part in command)}", file=sys.stderr) return subprocess.run(command, cwd=REPO_ROOT, env=_bench_env(), check=False).returncode +def _positive_int(value: str) -> int: + """Parse ``--rounds``, which bench.py also requires to be positive.""" + + parsed = int(value) + if parsed <= 0: + raise argparse.ArgumentTypeError("must be positive") + return parsed + + def main(argv: Sequence[str] | None = None) -> int: """Populate the endpoint cache and publish ``/index.html``.""" - args = tuple(sys.argv[1:] if argv is None else argv) - if len(args) > 1: - print("usage: nightly_bench.py [output_dir]", file=sys.stderr) - return 2 - output_dir = Path(args[0]).expanduser().resolve() if args else DEFAULT_OUTPUT_DIR + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "output_dir", + nargs="?", + type=Path, + default=DEFAULT_OUTPUT_DIR, + help="directory to publish index.html and index.jsonl into", + ) + parser.add_argument( + "--rounds", + type=_positive_int, + help="rounds per endpoint/file, passed to bench.py", + ) + args = parser.parse_args(list(argv) if argv is not None else None) + output_dir = args.output_dir.expanduser().resolve() # Populate the dropdown with every endpoint. A combination that fails to # build or run drops one option instead of failing the whole nightly. for target in TARGETS: for endpoint in ENDPOINTS: - if _run(target, endpoint, open_report=False) != 0: + if _run(target, endpoint, open_report=False, rounds=args.rounds) != 0: print(f"nightly: skipped {target[0]} {endpoint[0]}/{endpoint[1]}", file=sys.stderr) # The whole cache is now populated, so this last run re-renders it as the # page. Its rows are already cached, so it only rebuilds the report. - if _run(BRANCH, HEADLINE, open_report=True) != 0 or not PAGE_PATH.is_file(): + if _run(BRANCH, HEADLINE, open_report=True, rounds=args.rounds) != 0 or not PAGE_PATH.is_file(): print("nightly: benchmark did not produce a report", file=sys.stderr) return 1 output_dir.mkdir(parents=True, exist_ok=True)