Skip to content

Store a function's signature once, and expose it to primitives - #986

Open
oflatt-claude wants to merge 4 commits into
egraphs-good:mainfrom
oflatt-claude:subst-egraph-introspection
Open

Store a function's signature once, and expose it to primitives#986
oflatt-claude wants to merge 4 commits into
egraphs-good:mainfrom
oflatt-claude:subst-egraph-introspection

Conversation

@oflatt-claude

@oflatt-claude oflatt-claude commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Three additions to what a primitive body can see. None are specific to any one
extension: together they are what an out-of-tree primitive needs to walk the
term structure under an e-class and build a modified copy of it.

The motivating consumer is an unstable-subst substitution primitive in
egraphs-good/egglog-experimental#60, which lives there rather
than here because substitution is a language experiment, not core e-graph
machinery. This PR is only the introspection; it is worth reviewing on its own
terms.

Read::enodes_for_eclass(name, eclass, f)

Walks a constructor's rows by output e-class through the backend's lazy column
index, instead of scanning the table and filtering. Cherry-picked from #934,
along with the core-relations ExecutionState::for_each_matching_col and
egglog-bridge TableAction::for_each_output_value it rests on — credit to
that PR; this takes only the traversal hunks and none of the extraction work.

Read::constructor_schema(name), Read::function_schema(name), Read::table_subtype(name)

A table's declared signature, and whether it is a constructor or a function.
EGraph::functions_iter already exposes this from &EGraph, but a primitive
body only ever sees a state wrapper, and those carried no sort information at
all — so a primitive could read rows without being able to tell an e-class
column from a base value, or a constructor's eclass column from a function's
output.

The schema accessor is split by subtype and errors with WrongSubtype on a
mismatch, matching how the rest of Read and Write already work
(lookup/eclass_of, constructor_enodes/function_entries, set/add).
That split is not just cosmetic: a constructor's last column is an e-class and
a function's is an output. table_subtype is the error-free predicate for code
that accepts either.

Breaking: a signature is now stored once

The first cut of this branch added a FunctionSchemas registry to back those
accessors. That was a third copy of data egglog already kept twice —
TypeInfo::func_types holds a FuncType {name, subtype, input, output}, and
Function held the same content again as a ResolvedSchema plus
decl.subtype (and a third time, unresolved, in decl.schema). What was
missing was never the data, only a way to reach it from a state wrapper.

So TypeInfo::func_types became the single store and the shared cell, and the
new registry is gone:

  • TypeInfo::get_func_type returns Option<Arc<FuncType>>, not
    Option<&FuncType>.
  • Function points at the same Arc<FuncType>;
    Function::schema() -> &ResolvedSchema becomes
    Function::func_type() -> &FuncType.
  • ResolvedSchema is removed; its get_by_pos moves to FuncType.
  • declare_function reuses the signature typechecking already resolved instead
    of resolving the sorts a second time. It still resolves and records the
    functions desugaring generates (global bindings, proof tables), which never
    go through typechecking — so func_types now covers every declared table.

Two subtleties worth a reviewer's eye. TypeInfo::clone deep-copies the map: a
clone is an independent e-graph — a pushed copy, or the parallel typechecking
the proof checker keeps in original_typechecking — and declaring a function in
one must not make it resolve in the other. And pop restores the pushed
contents into the live cell the registered primitives already hold, rather than
swapping in a cell they have no handle to.

Primitive invocation cost is unchanged: the wrapper holds the unlocked handle
and the schema accessors lock only when called, so a primitive that never asks
pays nothing.

table_subtype also retires an existing workaround in egglog-experimental,
which determines a table's subtype by starting a constructor scan and reading
the error off the subtype check — removed in the companion PR.

Core::rebuild_container(type_id, value, remap)

Remaps a container value's contents and interns the result, over the existing
ContainerValues::rebuild_val_with. Out-of-tree code cannot reach this through
Core::register_container, which requires naming the container's Rust type —
impossible for an arbitrary container sort.

Notes

  • No behaviour change to existing programs; this is additive.
  • FunctionSchemas costs one HashMap entry per declared table.
  • Adds one rule to the repo's tidy-diff-docs skill: prefer an import over an
    inline full path for a type.
  • Verified: full workspace test suite passes (including the 799-program files
    harness), cargo clippy --tests --workspace clean, cargo fmt --check clean,
    and cargo doc adds no new warnings.

🤖 Generated with Claude Code

Three additions to what a primitive body can see, none of them specific to any
one extension. Together they are what an out-of-tree primitive needs to walk
the term structure under an e-class and build a modified copy of it.

Read::enodes_for_eclass(name, eclass, f) walks a constructor's rows by output
e-class through the backend's lazy column index, instead of scanning the table
and filtering. Cherry-picked from egraphs-good#934 along with the core-relations
ExecutionState::for_each_matching_col and egglog-bridge
TableAction::for_each_output_value it rests on.

Read::table_schema(name) and Read::table_subtype(name) report a table's
declared column sorts and its subtype. EGraph::functions_iter already exposes
this from &EGraph, but a primitive body only ever sees a state wrapper, and
those carried no sort information at all - so a primitive could read rows
without being able to tell an e-class column from a base value. Backed by a
FunctionSchemas map the e-graph shares with the wrappers exactly as it already
shares ActionRegistry, and snapshot/restored across push/pop so a popped table
stops resolving. table_subtype also replaces probing a subtype by starting a
scan and reading the error, which egglog-experimental does today.

Core::rebuild_container(type_id, value, remap) remaps a container value's
contents and interns the result. Out-of-tree code cannot go through
Core::register_container, which requires naming the container's Rust type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
table_schema returned a schema for either subtype, which reads past the fact
that a constructor's last column is an e-class and a function's is an output.
Splitting it matches how the rest of Read and Write already work - lookup /
eclass_of, constructor_enodes / function_entries, set / add - so it is now
constructor_schema and function_schema, each erroring with WrongSubtype on a
mismatch. table_subtype stays as the error-free predicate to dispatch on when
either subtype is acceptable, which is what retires the subtype probe in
egglog-experimental.

Also applies the tidy-diff-docs skill to the comments this branch adds, and
records a new rule in that skill: import a type rather than naming it by an
inline full path, which is what FunctionSchemas was doing with
crate::util::HashMap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oflatt
oflatt requested review from yihozhang and removed request for saulshanabrook August 6, 2026 21:18
The first cut of this branch added a FunctionSchemas registry so a primitive
body could see column sorts. That was a third copy of data egglog already kept
twice: TypeInfo::func_types holds a FuncType {name, subtype, input, output},
and Function holds the same content again as a ResolvedSchema plus decl.subtype
(and a third time, unresolved, in decl.schema). What was actually missing was
not the data but a way to reach it from a state wrapper.

So there is now one store. TypeInfo::func_types becomes the shared cell -
Arc<RwLock<HashMap<String, Arc<FuncType>>>> - and the state wrappers hold a
handle to it, which is what backs constructor_schema / function_schema /
table_subtype. FunctionSchemas is gone. Function points at the same Arc<FuncType>
rather than storing its own copy, so Function::schema() -> &ResolvedSchema
becomes Function::func_type() -> &FuncType and ResolvedSchema is removed, its
get_by_pos moving to FuncType. declare_function reuses the signature
typechecking already resolved instead of resolving the sorts a second time; it
still resolves and records the functions desugaring generates (global bindings,
proof tables), which never go through typechecking.

Two things this had to get right. TypeInfo::clone deep-copies the map: a clone
is an independent e-graph - a pushed copy, or the parallel typechecking the
proof checker keeps - and declaring a function in one must not make it resolve
in the other. And pop restores the pushed contents into the live cell the
registered primitives already hold, rather than swapping in a cell they have no
handle to.

Also drops the second RwLock acquisition per primitive invocation the first cut
introduced: the wrapper holds the unlocked handle and the schema accessors lock
only when called, so a primitive that never asks pays nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oflatt-claude oflatt-claude changed the title Expose enough e-graph introspection to walk and rebuild a sub-e-graph Store a function's signature once, and expose it to primitives Aug 6, 2026
The additions belong with the name-indexed e-graph access entry they extend,
not as a standalone block longer than anything else on the list. The signature
consolidation keeps a one-line breaking bullet next to the other breaking ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codspeed-hq

codspeed-hq Bot commented Aug 6, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 37 untouched benchmarks
⏩ 227 skipped benchmarks1


Comparing oflatt-claude:subst-egraph-introspection (1118c51) with main (716c320)

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.

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.

2 participants