Skip to content

Expose extraction support APIs#934

Open
saulshanabrook wants to merge 24 commits into
egraphs-good:mainfrom
saulshanabrook:codex/greedy-dag-extractor
Open

Expose extraction support APIs#934
saulshanabrook wants to merge 24 commits into
egraphs-good:mainfrom
saulshanabrook:codex/greedy-dag-extractor

Conversation

@saulshanabrook

@saulshanabrook saulshanabrook commented Jun 24, 2026

Copy link
Copy Markdown
Member

Extraction support APIs

This is the core half of the greedy-DAG extraction split. It does not add :extractor greedy-dag to core egglog; that command syntax and the extractor implementation live in the downstream experimental PR:

Related context:

Changes

  • Refactors extraction cost traits so downstream extractors can share base-value hooks without inheriting tree-only total-cost semantics.
  • Adds direct multi-root tree extraction APIs on EGraph, returning named structs with a shared TermDag.
  • Represents ordinary per-root extraction misses as None in the batch result; strict convenience APIs and commands convert that back into ExtractError where appropriate.
  • Routes builtin (extract ...), (output ...), print-function/function_to_dag, and extract_value* through the high-level extraction path.
  • Makes (output ...) validate the whole extraction batch before appending to disk.
  • Exposes value reconstruction helpers needed by downstream custom extractors.
  • Adds a read-state constructor/e-class lookup API that uses the existing backend column index for a named constructor table.
  • Adds the minimal bridge/core-relations row-iteration hooks needed to implement that lookup without exposing projection buffers or an arbitrary bridge-level column scan.
  • Passes the extraction-facing constructor to custom cost models when extraction uses a term-constructor view table.

Public Interfaces Added Or Changed

Extraction module:

  • BaseCostModel<C>: base primitive/container-independent value cost hook.
  • TreeCostModel<C>: replaces CostModel<C> for total tree costs with total_enode_cost and total_container_cost.
  • CommutativeMonoid: provides identity and combine.
  • Cost: now the extraction-facing marker over CommutativeMonoid + Ord + Eq + Debug.
  • ExtractedTerm<C>, ExtractedTerms<C>, ExtractedTermVariants<C>: named result types with shared TermDag storage.
  • ExtractedTerms<C>::terms is now Vec<Option<ExtractedTerm<C>>>; None means the corresponding requested root is unextractable.
  • Extractor is no longer public; ordinary callers should use the high-level EGraph extraction APIs.

EGraph:

  • extract_best(roots, cost_model)
  • extract_variants(roots, nvariants, cost_model)
  • container_inner_values(sort, value)
  • reconstruct_base_value(sort, value, termdag)
  • reconstruct_container_value(sort, value, termdag, element_terms)
  • Existing extract_value_with_cost_model now takes a TreeCostModel<DefaultCost> and maps a missing single root to ExtractError.

Read/introspection helpers:

  • Read::enodes_for_eclass(name, eclass, callback): indexed lookup for rows in one constructor/relation table whose output eclass matches eclass.
  • Function::is_constructor(): declared constructor/relation table predicate.
  • Function::is_unextractable()

Backend/bridge:

  • core_relations::ExecutionState::for_each_matching_col
  • egglog_bridge::TableAction::for_each_output_value

Review Notes

The normal (extract ...) command remains tree extraction and still defaults to the existing tree behavior. Greedy-DAG extraction, :extractor greedy-dag, and the performance-sensitive secondary-map data structures live in egglog-experimental.

After #941, proof extraction is root-directed and separate from the normal user extractor. This PR no longer changes that proof-extraction path.

This PR deliberately does not expose internal union-find canonicalization. The downstream greedy-DAG extractor works in normal mode on the (sort, value) roots and constructor row outputs it is given; term/proof-encoding internals stay out of the custom extractor API for now.

The bridge/core-relations additions intentionally expose only row iteration by an indexed column and output-value iteration for a TableAction. Earlier arbitrary projection/window scan APIs were removed to keep the support surface focused on the read-state eclass lookup.

The core integration tests cover the new custom tree-cost public surface with a non-primitive cost type, mixed optional extraction results, semantic round-tripping of extracted terms, and the indexed Read::enodes_for_eclass lookup over merged/subsumed rows.

Validation

  • cargo check
  • cargo test --test api_query enodes_for_eclass
  • cargo test --test integration_test extract
  • cargo test --test extraction_proof_mode
  • make nits
  • git diff --check

@saulshanabrook

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Refactors the extraction cost model API from a single CostModel trait to separate BaseCostModel and TreeCostModel traits over a CommutativeMonoid-based Cost domain, introduces dedicated result structs (ExtractedTerm, ExtractedTerms, ExtractedTermVariants), rewires EGraph extraction APIs and command dispatch, updates proof extraction, and adds an indexed for_each_matching_col scan on ExecutionState that powers a new enodes_for_eclass API on the Read trait.

Changes

Indexed column-matching scan

Layer / File(s) Summary
for_each_matching_col + for_each_output_value
core-relations/src/free_join/mod.rs, core-relations/src/action/mod.rs, egglog-bridge/src/lib.rs
get_column_index_from_tableinfo made pub(crate); ExecutionState gains for_each_matching_col doing an indexed equality scan with fast/slow subset splitting; TableAction gains for_each_output_value delegating to that scan and producing ScanEntry values with subsumed.
enodes_for_eclass Read API + test
src/exec_state.rs, tests/api_query.rs
Read trait gains enodes_for_eclass that verifies the constructor table subtype and iterates matching rows via for_each_output_value; test asserts eclass identity, subsumed flag, and child values.

Extraction cost model and API refactor

Layer / File(s) Summary
Cost domain and cost model traits
src/extract.rs
Cost redefined as CommutativeMonoid + Ord + Eq + Debug; BaseCostModel and TreeCostModel replace the old CostModel interface; numeric impls use num::Zero.
Extractor internals and result structs
src/extract.rs
TreeAdditiveCostModel reimplemented; ExtractedTerm, ExtractedTerms, ExtractedTermVariants structs added; Extractor accepts boxed TreeCostModel and an extraction mode; container/enode cost calls updated; extract_best_with_sort/extract_variants_with_sort return new structs; extract_best_for_proofs added as pub(crate).
EGraph extraction APIs and function_to_dag
src/extract.rs, src/lib.rs, src/lib.md, src/prelude.rs
EGraph::extract_best/extract_variants accept TreeCostModel and return new result types; extract_value_with_cost_model updated; function_to_dag batch-collects roots and populates term IDs from ExtractedTerms; Function helpers is_constructor/is_unextractable and EGraph helpers container_inner_values/reconstruct_* added; docs updated.
Extract/Output command dispatch
src/lib.rs
ResolvedNCommand::Extract and Output handlers rewritten to build (sort, value) root lists, call extract_best/extract_variants, and propagate errors via ?, removing prior manual Extractor/TermDag setup.
Proof extraction update
src/proofs/proof_extraction.rs
ProofInstrumentor::prove_exists switches to extract_best_for_proofs; old Extractor/TermDag local setup removed; imports updated.
Integration and API tests
tests/integration_test.rs
CustomCost monoid and CustomCostModel defined; tests added for extract_best with a custom cost type and for multi-root extraction with unextractable roots.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • egraphs-good/egglog#812: Both PRs modify ProofInstrumentor::prove_exists in src/proofs/proof_extraction.rs, and this PR's extract_best_for_proofs directly replaces the extraction flow introduced or modified there.
  • egraphs-good/egglog#813: The "extract-with-proof" workflow introduced in that PR is the direct consumer of extract_best_for_proofs added here.

Suggested labels

status:needs decision

Suggested reviewers

  • ezrosent
  • oflatt
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change set: exposing extraction support APIs.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly matches the extraction, lookup, and bridge changes in the diff.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@saulshanabrook

saulshanabrook commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@codecov-commenter

codecov-commenter commented Jun 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.21168% with 46 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.72%. Comparing base (9b98ba8) to head (7bb03e1).
⚠️ Report is 33 commits behind head on main.

Files with missing lines Patch % Lines
src/lib.rs 46.47% 38 Missing ⚠️
core-relations/src/action/mod.rs 83.33% 6 Missing ⚠️
src/extract.rs 98.41% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #934      +/-   ##
==========================================
- Coverage   86.85%   86.72%   -0.14%     
==========================================
  Files          91       92       +1     
  Lines       27290    28731    +1441     
==========================================
+ Hits        23703    24917    +1214     
- Misses       3587     3814     +227     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/extract/dag_extract.rs`:
- Around line 336-342: The hot path in discover_node is cloning the full
constructor list from extractable_funcs_by_output_sort just to drop the borrow
before mutating self. Change the builder/storage type for that map to
Arc<[String]> and convert the lists once in prepare so the lookup at
discover_node can keep using .cloned() but only performs a cheap Arc clone;
verify func_names.iter() still works unchanged in the downstream loop.

In `@src/lib.rs`:
- Around line 1692-1694: The variant-count check in the extraction path
currently panics on a negative value, which can abort the interpreter when a
user-supplied expression evaluates to a bad count. Update the logic in the
extraction routine that handles n so it returns an Error instead of calling
panic, and propagate that error through the surrounding extraction/evaluation
flow so malformed input is reported cleanly without crashing.

In `@src/proofs/proof_extraction.rs`:
- Around line 94-97: The proof-term extraction path in proof_extraction.rs drops
the underlying error by using unwrap_or_else(|_| ...), so the panic message
loses the failure cause. Update the extract_best_for_proofs handling in the
proof extraction logic to bind the error value and include it in the panic
message alongside func.name, so failures from extract_best_for_proofs report the
actual Error details.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e682c190-3fe6-4637-aca1-44bf610e9938

📥 Commits

Reviewing files that changed from the base of the PR and between e4a6535 and 58c6ecd.

⛔ Files ignored due to path filters (4)
  • Cargo.lock is excluded by !**/*.lock
  • tests/greedy-dag-taylor.egg is excluded by !**/*.egg
  • tests/greedy-dag-vec-extract.egg is excluded by !**/*.egg
  • tests/snapshots/files__proof_unsupported_files.snap is excluded by !**/*.snap
📒 Files selected for processing (21)
  • .agents/logs/2026-06-24-greedy-dag-extractor-perf.md
  • .github/workflows/build.yml
  • Cargo.toml
  • core-relations/src/free_join/mod.rs
  • core-relations/src/lib.rs
  • egglog-bridge/src/lib.rs
  • src/ast/desugar.rs
  • src/ast/mod.rs
  • src/ast/parse.rs
  • src/extract.rs
  • src/extract/dag_extract.rs
  • src/extract/secondary_map.rs
  • src/lib.md
  • src/lib.rs
  • src/prelude.rs
  • src/proofs/proof_encoding.rs
  • src/proofs/proof_encoding_helpers.rs
  • src/proofs/proof_extraction.rs
  • src/typechecking.rs
  • tests/files.rs
  • tests/integration_test.rs
📜 Review details
⏰ Context from checks skipped due to timeout. (15)
  • GitHub Check: benchmark (ubuntu-latest, greedy-dag-vec-extract)
  • GitHub Check: benchmark (ubuntu-latest, taylor51)
  • GitHub Check: benchmark (ubuntu-latest, math-microbenchmark)
  • GitHub Check: benchmark (ubuntu-latest, greedy-dag-taylor)
  • GitHub Check: benchmark (ubuntu-latest, herbie)
  • GitHub Check: benchmark (ubuntu-latest, stresstest_large_expr)
  • GitHub Check: benchmark (ubuntu-latest, math_normal)
  • GitHub Check: benchmark (ubuntu-latest, proof_testing_math)
  • GitHub Check: benchmark (ubuntu-latest, proof_testing_eqsat-basic)
  • GitHub Check: benchmark (ubuntu-latest, conv1d_128)
  • GitHub Check: benchmark (ubuntu-latest, rectangle)
  • GitHub Check: benchmark (ubuntu-latest, rust_rule_tableaction_hot_path)
  • GitHub Check: benchmark (ubuntu-latest, eggcc-2mm)
  • GitHub Check: test
  • GitHub Check: coverage
🧰 Additional context used
📓 Path-based instructions (1)
**/*.{md,txt,rs}

📄 CodeRabbit inference engine (CLAUDE.md)

Keep documentation concise and avoid duplicate information

Files:

  • src/prelude.rs
  • src/ast/desugar.rs
  • core-relations/src/lib.rs
  • src/typechecking.rs
  • src/lib.md
  • src/proofs/proof_encoding_helpers.rs
  • tests/files.rs
  • core-relations/src/free_join/mod.rs
  • src/proofs/proof_encoding.rs
  • src/ast/mod.rs
  • src/extract/dag_extract.rs
  • egglog-bridge/src/lib.rs
  • src/ast/parse.rs
  • tests/integration_test.rs
  • src/proofs/proof_extraction.rs
  • src/lib.rs
  • src/extract/secondary_map.rs
  • src/extract.rs
🔇 Additional comments (30)
tests/integration_test.rs (2)

10-115: LGTM!


578-710: LGTM!

tests/files.rs (1)

271-280: LGTM!

.agents/logs/2026-06-24-greedy-dag-extractor-perf.md (1)

1-1123: LGTM!

.github/workflows/build.yml (1)

72-73: 🩺 Stability & Availability

Benchmark input files confirmed present.

Both greedy-dag-vec-extract.egg and greedy-dag-taylor.egg are checked in under the tests directory. The build job will not fail due to missing inputs.

src/extract/dag_extract.rs (3)

20-89: LGTM!


389-660: LGTM!


662-870: LGTM!

src/lib.rs (3)

49-49: LGTM!

Also applies to: 1659-1690


1695-1720: LGTM!


1791-1803: LGTM!

src/proofs/proof_extraction.rs (2)

1-6: LGTM!


98-109: LGTM!

src/ast/desugar.rs (1)

167-169: LGTM!

src/ast/mod.rs (1)

77-77: LGTM!

Also applies to: 162-169, 276-283, 846-847, 962-967, 1754-1756, 1850-1855, 1975-1982

src/ast/parse.rs (1)

114-131: LGTM!

Also applies to: 653-683

src/proofs/proof_encoding.rs (1)

1335-1335: LGTM!

Also applies to: 1353-1353

Cargo.toml (1)

152-152: LGTM!

src/lib.md (1)

41-41: LGTM!

core-relations/src/lib.rs (1)

39-39: LGTM!

src/typechecking.rs (1)

460-460: LGTM!

Also applies to: 482-482

src/proofs/proof_encoding_helpers.rs (1)

564-564: LGTM!

src/extract/secondary_map.rs (3)

27-225: LGTM!


227-530: LGTM!


532-650: LGTM!

src/extract.rs (2)

6-54: LGTM!

Also applies to: 56-94, 105-183, 200-314, 526-639, 647-677, 741-818, 848-879


148-156: 🚀 Performance & Scalability

find_canonical does a full UF-table scan on every call; use the new indexed lookup.

This iterates the entire UF parent table via for_each for each invocation, even though the comment describes it as a one-hop "lookup". The greedy-DAG extractor calls find_canonical per node (compute_cost_node, discover_node, reconstruct_termdag_node_helper), so extraction becomes roughly O(nodes × uf_table_size) — directly at odds with this PR's performance goal. The for_each_matching_col helper added in this PR resolves matches via the lazy column index (with a scan fallback), and the single-parent invariant guarantees at most one matching row, so semantics are preserved.

⚡ Proposed switch to indexed column lookup
     let mut canonical = value;
     egraph
         .backend
-        .for_each(uf_func.backend_id, |row: egglog_bridge::ScanEntry<'_>| {
-            // UF table has (child, parent) as inputs
-            if row.vals[0] == value {
-                canonical = row.vals[1];
-            }
-        });
+        .for_each_matching_col(uf_func.backend_id, 0, value, |row| {
+            // UF table has (child, parent) as inputs
+            canonical = row.vals[1];
+        });
 
     canonical
src/prelude.rs (1)

93-93: LGTM!

core-relations/src/free_join/mod.rs (1)

31-34: LGTM!

Also applies to: 777-802

egglog-bridge/src/lib.rs (1)

475-520: LGTM!

Comment thread src/extract/dag_extract.rs Outdated
Comment thread src/lib.rs
Comment thread src/proofs/proof_extraction.rs Outdated
@codspeed-hq

codspeed-hq Bot commented Jun 25, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 37 untouched benchmarks
⏩ 227 skipped benchmarks1


Comparing saulshanabrook:codex/greedy-dag-extractor (7bb03e1) with main (6f0d26e)

Open in CodSpeed

Footnotes

  1. 227 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@saulshanabrook saulshanabrook marked this pull request as ready for review June 25, 2026 00:35
@saulshanabrook saulshanabrook requested a review from a team as a code owner June 25, 2026 00:35
@saulshanabrook saulshanabrook requested review from FTRobbin and removed request for a team June 25, 2026 00:35
@saulshanabrook saulshanabrook changed the title [codex] Add greedy DAG extraction Add greedy DAG extraction Jun 25, 2026
@saulshanabrook

saulshanabrook commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

Things to look into from talking to @oflatt:

  • verify that cycles are not extracted with a small test case, if you combine two extractions that it gives you a cycle?
  • Verify that this works on all examples, that the extraction after output can be turned into an s-expr and then checked against the e-graph to make sure its equal, also do for normal extraction
  • look in again if it could be moved to experimental
  • change producer language to parent if appropriate?

@saulshanabrook saulshanabrook force-pushed the codex/greedy-dag-extractor branch from 4e48f01 to 225396e Compare June 29, 2026 03:20
@saulshanabrook saulshanabrook force-pushed the codex/greedy-dag-extractor branch from 225396e to d94c326 Compare June 29, 2026 03:23
@saulshanabrook saulshanabrook changed the title Add greedy DAG extraction Expose extraction support APIs Jun 29, 2026
@saulshanabrook

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/extract.rs`:
- Around line 638-645: The public extract_variants_with_sort / extract_variants
path is unwrapping an Option from compute_cost_hyperedge and panicking when a
candidate variant is uncostable because one of its children cannot be extracted.
Update the extraction loop to treat compute_cost_hyperedge returning None as a
skipped candidate, continue searching for other variants, and stop early if no
more extractable candidates remain. Make sure the behavior returns fewer
ExtractedTerm results rather than crashing, and keep the change localized around
extract_variants_with_sort and the candidate selection logic it uses.
- Around line 355-364: The cost calculation in the extraction path is using the
internal Function instead of the extraction-facing constructor, which can cause
custom cost models to see a different name than reconstruction. Update the call
from the extraction logic in extract.rs so total_enode_cost receives the same
constructor identifier used by extraction_term_name(), likely by passing the
extraction-facing Function/term name from the extraction flow rather than the
view/internal func. Ensure the symbols extraction_output_index(),
extraction_term_name(), and total_enode_cost stay aligned so func.name() in
custom models matches reconstruction.

In `@src/lib.rs`:
- Around line 362-365: The public helper is_constructor() in lib.rs is narrower
than the extraction-visible table set because it only checks
FunctionSubtype::Constructor and misses view tables marked by
term_constructor.is_some(). Update this API so downstream users of
is_constructor() get the same table classification used by extract.rs, either by
expanding the method to include the view-table case or by renaming it to make
the current behavior explicit and adding a separate helper for the
extraction-visible rule.
- Around line 1800-1807: The extraction loop in the write path can partially
append to disk before encountering an invalid root, so the output file may be
left in a half-written state. In the code around the `extracted.terms` loop,
first validate and collect every root into `TermId`s from `extract_best`’s full
batch, then perform the `writeln!` calls only after all roots have successfully
resolved; keep the existing `Error::ExtractError` and `Error::IoError` handling,
but move writing behind the upfront validation step.

In `@tests/api_query.rs`:
- Around line 143-185: The current enodes_for_eclass test only covers singleton
eclasses, so it does not verify behavior for merged representatives or subsumed
rows. Extend the test in enodes_for_eclass_basic to create two Add terms that
are unioned into the same eclass, then assert rs.enodes_for_eclass returns both
rows for that representative; also include a subsumed row in the same assertion
path to validate the subsumed flag mapping.

In `@tests/integration_test.rs`:
- Around line 589-591: The integration test in extracted.terms only checks that
the first entry is Some, which can miss a wrong term for the extractable root.
Tighten the assertion in the shared-TermDag multi-root extraction test so the
first result is verified to reconstruct the visible term "(visible)" using the
extracted term from the batch, while keeping the second result asserted as None.
- Around line 550-554: The current assertion in the integration test only checks
the pretty-printed TermDag output, so it can miss reconstruction regressions.
Update the test around the extracted TermDag and root/value setup to also verify
semantic round-tripping by asserting the reconstructed term/value equals the
original value, using the existing extracted, root, and value symbols in this
test. Keep the existing string assertion, but add an equality check that
exercises the value-reconstruction path instead of relying only on to_string.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: bfabbf43-503b-47fa-b0c1-0db367cce812

📥 Commits

Reviewing files that changed from the base of the PR and between 42a4fb6 and 2f69c60.

📒 Files selected for processing (11)
  • core-relations/src/action/mod.rs
  • core-relations/src/free_join/mod.rs
  • egglog-bridge/src/lib.rs
  • src/exec_state.rs
  • src/extract.rs
  • src/lib.md
  • src/lib.rs
  • src/prelude.rs
  • src/proofs/proof_extraction.rs
  • tests/api_query.rs
  • tests/integration_test.rs

Comment thread src/extract.rs
Comment thread src/extract.rs Outdated
Comment thread src/lib.rs Outdated
Comment thread src/lib.rs Outdated
Comment thread tests/api_query.rs Outdated
Comment thread tests/integration_test.rs
Comment thread tests/integration_test.rs

@oflatt oflatt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me (PR descriptions very out of date)

@oflatt

oflatt commented Jun 30, 2026

Copy link
Copy Markdown
Member

One thing I'd love is to have extraction work inside a ReadState as well as on the EGraph. But not this PR's problem.

@yihozhang yihozhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you!

Comment thread core-relations/src/action/mod.rs Outdated
| Constraint::LtConst { .. }
| Constraint::GtConst { .. }
| Constraint::LeConst { .. }
| Constraint::GeConst { .. } => return true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should they be impossible cases?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, thank you!

Comment thread src/extract.rs
}

impl TreeCostModel<DefaultCost> for TreeAdditiveCostModel {
fn total_enode_cost(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember we discussed this and decided to keep the existing enode_cost? The original one is also more natural to me.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing enode_cost has the problem where its only ever used to produce total cost with the fold, so it seemed like odd to keep it. Like the functions were ever only called changed together, with enode_cost feeding into the total call.

So that it returned a "Cost" type was also a bit weird, really it could return anything that the fold took in, like I had to return odd stuff from it from Python to basically just pass data between them and do all the computation in total_enode_cost.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you pass odd stuff because there was no DAG cost model and extractor, or do you still need to do it even with the new DAG extractor?

If we use enode_cost here, TreeCostModel exposes a superset of cost functions of DAG cost model's, which is nice and matches our (my) intuition. My mental model of the tree cost model is that the term is annotated with cost tags and the cost of a term is just a fold over these cost tags. So, for example, the proposed cost model forbids you from doing it if you want to visualize an e-graph with per-node cost annotation, or serialize an e-graph with a custom cost model.

@saulshanabrook saulshanabrook Jul 2, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand it, we should have the cost trait reflect what the underlying extraction algorithm needs. The fact that you can split this up into a fold and an e-node cost is an implementation choice of a particular cost model, not a requirement of the extractor.

So for example, you could have a cost model for the tree extractor which says "on this particular e-node multiply the child costs by ten and on this other one multiply the child costs by twenty. In the other interface, you would have to smuggle the e-node ID through the cost output of enode_cost and then interpret it in fold.

If we use enode_cost here, TreeCostModel exposes a superset of cost functions of DAG cost model's, which is nice and matches our (my) intuition.

We can already use any dag cost model as a tree cost model, do you mean something else?

So, for example, the proposed cost model forbids you from doing it if you want to visualize an e-graph with per-node cost annotation, or serialize an e-graph with a custom cost model.

This is already true with fold, which is more general than what is allowed in the visualizer or in serialization. Both of those in fact do not currently support the set-cost even, so if we want to somehow make those features aware of this, we would have to change things.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this is not an implementation choice, but the definition of the tree cost model.

The idea is that enode_cost returns all e-graph specific cost information, and fold is an e-graph--agnostic way to combine such information. This gives a clear description of the tree extraction problem, which can be specified as an e-graph with per-node cost information, and a generic fold function. I believe you cannot do this with total_node_cost.

"On this particular e-node multiply the child costs by ten and on this other one multiply the child costs by twenty" is a combine function we can define, while I think smuggling e-node ID is a bad idea. If you want to return odd stuff from enode_cost (e.g., the constant folded result of the children as a loop count estimate), the user can define the cost as a pair (ActualCost, Option) and ignore the second part for comparison, or better, we can tweak the egglog interface to support that.

We can already use any dag cost model as a tree cost model, do you mean something else?

I meant you could convert a tree cost model into a DAG one by disregarding combine.

Both of those in fact do not currently support the set-cost even

But we can implement this, right? Again, a serialized e-graph with node costs + an e-graph--agnostic fold function gives a complete specification of the extraction problem. But committing to total_enode_cost would eliminate this possibility.

Maybe another way to think about this is that TreeAdditiveCostModel and SetCostModel are essentially the same cost model, besides how they obtain the per-node cost, but the new interface loses this connection.

Comment thread src/extract.rs
&ch_costs,
self.cost_model.enode_cost(egraph, func, &enode),
))
let cost_func = func

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain what this does to me as someone who hasn't caught up with the proof development?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In proof mode, view tables like AddView are used for querying and extraction, but the actual name of the function is still Add. The term_constructor attribute tells you the original name. Does that help?

It's annoying, but in proof mode everywhere we expose the API we need to keep in mind to use the "term_cosntructor" names

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a better way to clean this up! Some sort of wrapper internally so we get the right names and right tables.

Comment thread src/extract.rs
/// subterm should always lead to a non-worse superterm, to guarantee the extracted term
/// being optimal under the given cost model.
/// If this is not followed, the extractor may panic on reconstruction
pub struct Extractor<C: Cost + Ord + Eq + Clone + Debug> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is Extractor no longer public?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We dont need it. Every use case of it just extracted a few particular nodes, so I cleaned up the interface to just expose that to keep the details more private.

@yihozhang yihozhang Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought part of the point of the extractor interface is to provide a zoo of different extractor implementations (like extraction-gym for egglog). It would be weird if someone implemented a faster extractor but found they couldn't get a handle on the plain Extractor for comparison. I'm also in favor of minimizing interface changes unless it's a strict improvement.

Another potential use case of the extractor is if the user wants to interactively extract a series of terms, without building the Extractor every time

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have an extractor interface. We just have a tree extractor and a dag extractor, and they expose different methods on the e-graph. Without anything that calls this interface, I am not sure what it would be for?

You can of course compare against the main extractor, you just call the methods on the e-graph.

Another potential use case of the extractor is if the user wants to interactively extract a series of terms, without building the Extractor every time

Yeah I just thought since I hadn't seen any use cases in experimental and main that exercised this pattern, we could remove it. That also frees us up to change how the extractor performs in the future. Like now it does this search at a sort level of the e-graph to trim, and does all extractions for that. But we could change it to do a more precise traversal of the e-graph and only build extractions for those expressions, instead of all pieces. By keeping the interface smaller then that doesn't have to be a breaking change.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will want an extractor interface, right? There are many DAG extractor implementations (ILP, branch and bound, ASP, etc.), and there's the Dijkstra algorithm for tree extraction. Just like we have an interface for extensible schedulers, we should do the same for extractors. I'm all in for standardizing the interfaces around DAG and tree extractors, just like what we did in extraction-gym.

Not in experimental and main does not mean users won't have this pattern? A user could import egglog, build an e-graph, and interactively extract terms from the Rust side.

@saulshanabrook saulshanabrook Jul 8, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure but would that interface be? We dont define it in this PR and I would opt to defer it.

If we define that interface, then we want a way to actually use that interface in main somehow, like being able to switch extractors.

A user could import egglog, build an e-graph, and interactively extract terms from the Rust side.

Yeah you can do that today with the methods on the e-graph.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically I think there are two related points here:

  1. There is a question about having a re-usable extractor interface. I like this idea, but I didn't implement it in this PR. There was no previous extractor trait, just a concrete implementation of extractor. That extractor happened to work in a way that traverses first the e-graph at a sort level first, prepares all of those items, then allows extractions. It's not clear that we would want to use this as "the" interface, because for example, the DAG extractor does not traverse the whole e-graph by sort. It does a depth first traversal based on the actual nodes you want to extract. I would prefer not to standardize an extractor interface in this PR and defer that to later work. If we do have an interface, we should have a way to use that in the e-graph in some way.
  2. There is the loss of functionality with this changes around the default extractor. Previously, you could do the work of creating costs for a subset of the e-graph based on a list of sorts and all their possible children. I have shrunk the API to simply providing a list of expressions and getting their extractions out. I didn't see any uses for this more flexible API. I decided to shrink the exposed syntax to make it easier in the future if we want to change out default tree based extractor to use a different design that might not require traversing the e-graph by sort first. By keeping this public API it keeps us locked into that syntax, so further performance improvements would need to conform to it as well. If there are actual use cases today for this interface that would be helpful to understand why it's important to preserve.

Comment thread src/extract.rs
///
/// This is the normal user extraction path: it respects `:unextractable`
/// and hidden internal functions.
pub fn extract_best<C: Cost, M: TreeCostModel<C> + 'static>(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They seem to overlap with extract_value and extract_value_with_cost_model. Do we need all of them?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah extact_value and extract_value_with_cost_model were previous helpers, I can remove them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

Comment thread src/extract.rs
impl Cost for $cost {
impl CommutativeMonoid for $cost {
fn identity() -> Self { 0 }
fn unit() -> Self { 1 }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we no longer have unit() for a cost? This provides a default for e.g., base values.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You dont need it. It was only used in the default trait implementation and not used at all in the actual extractor, it was just used in the deafult trait implementation, but that wasn't really needed. so it seems cleaner to remove it. Like there is no reason for a cost to require it.

@yihozhang yihozhang Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It provides a default head cost for functions and values. For example, in your implementation of TreeAdditiveCostModel and CustomCostModel, you use the magic number 1. It does not need to be magic if we have unit(). Alternatively, consider how you would make a generic AstSizeCostModel<C> where C: Cost if without unit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it provides a default cost, but its not clear to me its worth having there, because if you define your own cost model, its not needed. I.e. cost should be about what the extraction/cost model needs, and its not required, its optional basically.

Like in the current implementation, this is how we implement the cost model for u64:

impl CostModel<DefaultCost> for TreeAdditiveCostModel {
    fn fold(
        &self,
        _head: &str,
        children_cost: &[DefaultCost],
        head_cost: DefaultCost,
    ) -> DefaultCost {
        children_cost.iter().fold(head_cost, |s, c| s.combine(c))
    }

    fn enode_cost(&self, egraph: &EGraph, func: &Function, _enode: &Enode<'_>) -> DefaultCost {
        func.extraction_head_cost(egraph)
    }
}

It isn't clear to me how this is better than doing:

impl CostModel<DefaultCost> for TreeAdditiveCostModel {
    fn fold(
        &self,
        _head: &str,
        children_cost: &[DefaultCost],
        head_cost: DefaultCost,
    ) -> DefaultCost {
        children_cost.iter().fold(head_cost, |s, c| s.combine(c))
    }

    fn enode_cost(&self, egraph: &EGraph, func: &Function, _enode: &Enode<'_>) -> DefaultCost {
        func.extraction_head_cost(egraph)
    }

    fn base_value_cost(&self, egraph: &EGraph, sort: &ArcSort, value: Value) -> C {
        1
    }
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated it to make TreeAdditiveCostModel generic, so that you can parameterize it with different unit costs for different cost types.

Comment thread src/extract.rs
}

/// Requirements for a type to be usable as a cost by a [`CostModel`].
pub trait Cost {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to change the name?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can change it back

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted, I think its clearer now

@yihozhang

Copy link
Copy Markdown
Collaborator

Small nit: it seems some relatively big files are checked into the git history. Maybe worth doing a squash merge at the end.

@saulshanabrook saulshanabrook requested a review from yihozhang July 8, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants