Expand description
Hash-consed term storage — the representation a kernel needs to be as fast as Lean’s.
Term is a Box-recursive TREE. Every walk over it is native recursion whose depth is
the term’s depth, every clone copies the whole subtree, and a subterm that occurs n
times is n separate trees that get checked n times. Lean’s Expr is the other thing:
a maximally-shared DAG behind a structural hash, so constructing an identical subterm
twice yields the SAME pointer, a == b is usually a pointer compare, and a cache keyed
on that identity checks a shared subterm ONCE.
This module is that representation, built and measured STANDALONE:
TermStore::internturns aTerminto aTermId— aCopyu32handle. Identical structure interns to an identical id, soa == bis ONE integer compare (TermStore::same) instead of a tree walk.- Every node carries its
depth, its unfoldedtree_size, its O(1)structural_hash, its hash-consed FREE-VARIABLE set, and the set of binder names below it. All are computed compositionally at construction, so no query re-walks the term. IdCacheis a DENSE cache keyed on node identity —Vec<Option<V>>indexed by the id, so a lookup is a bounds check and not a hash.TermStore::foldvisits each DISTINCT node exactly once, which is what “checked once, not once per occurrence” is.TermStore::instantiateis SIMULTANEOUS capture-avoiding substitution — Lean’sinstantiateRevshape: a whole argument vector in one pass, not one substitution per binder. A node whose free variables do not meet the substitution’s domain is returned UNCHANGED, detected by a 64-bit mask intersection, which is what takes a deep spine from quadratic to linear.- Nothing here recurses natively. Interning, extraction, folding, substitution and
normalisation are all explicit-stack loops, and the store’s own
Dropis a flatVecdrop — so depth is bounded by the heap, not by 2 MiB of thread stack.
§What this module is NOT
TermStore::beta_zeta_normalize is a MEASUREMENT INSTRUMENT, not a kernel tier. It
implements β and ζ and nothing else — no δ, no ι, no η, no Fix unfolding, no universe
algebra. It is not a replacement for reduction::normalize.
beta_delta_equivalent now uses it behind a conservative conversion gateway:
checked closed monomorphic definitions are expanded, unsupported shapes are refused,
and only alpha-equal results prove conversion; all other answers use the main kernel.
§The measured case for wiring this in
Four numbers, all measured on this box against the kernel as it stands.
-
The deep-spine corpus case used to ABORT, and no longer does — CLOSED.
lean_conformance_t00_terms::a_deep_spine_costs_linearly_in_its_depth_and_not_quadraticallydied by SIGABRT — a stack overflow ininfer_type’s ownApparm — before it reached its assertion.MAX_TERM_DEPTHis 1600; BISECTED in a child process,infer_typedied at 106 rungs of a Π telescope and at 52 rungs of the corpus’s own saturated dependent telescope, so the door was fifteen to thirty-one times too permissive and ADMITTED terms the kernel then killed the process on.double_checkdied at the SAME 52, because it callsinfer_typefirst — so across that whole band the second kernel, whose entire job is to disagree with the first, was never consulted.deepen_stackis now wired intoinfer_type,is_subtype,normalize_checked_at,recheckanddouble_check, and a whole-crate run of 1413 tests reports zero SIGABRT/SIGSEGV: the case now FAILS AN ASSERTION, which is a verdict instead of a corpse. (The corpus case’s own prose puts the ceiling at “depth 4000”, which is wrong by 77×.)One band is still open, and
deepen_stackdoes not close it. The door and the sizer both measure the term they were HANDED.infer_type’sVararm isctx.get(name).cloned(), andTerm’sCloneis derived — so it recurses over the BINDING’s type, a depth neither ever saw. MEASURED in a child process:Var("x"), one node deep, underx : Nat → … → Natat 4000 arrows kills the process BARE and throughdeepen_stack, and is answered bydeepen_stack_with_depth. Every arm inherits it, so the input term’s depth is a LOWER BOUND on what a kernel call descends, never the bound. -
A β-step on a spine pays FOUR Θ(size) walks, and the corpus’s ratio is the square of that. ITEMISED AND COUNTED, in units a loaded box cannot move — node visits, by
W_perf_extra::the_spines_four_walks_are_quadratic_in_node_visits_and_the_owned_step_is_linear:per β-step on a spine visits at depth 500 at depth 1500 ratio substitute’sfree_vars(replacement)499 500 4 498 500 9.01× substitute’s clone of the replacement499 500 4 498 500 9.01× the driver’s recursive Drop501 500 4 504 500 8.98× Brent’s current == tortoise(and its saves)513 500 4 550 366 8.86× all four 2 014 000 18 051 866 8.96× an OWNED step that moves instead of copying 2 000 6 000 3.00× The corpus case asks for
≤ 5.00×at 3× the depth, and the kernel’s own wall clock on this box reads 6.9× to 12.3× across repeated runs of one binary (40–94 ms at depth 500, 428 ms to 1.16 s at depth 1500). Each of the four is quadratic ON ITS OWN, so removing any three leaves the ratio where it was — which is the measurement that says no constant factor reaches it. Three of the four can go insidetype_checker.rsandreduction.rs:free_vars(replacement)is only needed when a BINDER in the body could capture, and on a spine the body isVar("x")and has none; the clone and theDropboth go if the reducer OWNS its term instead of borrowing it. The fourth cannot. Brent’s cycle detection needs an equality test on the WHOLE term, and aBox-recursive tree has no O(1) one — every cheap surrogate (a bounded prefix compare, a depth-limited compare, a periodic compare) is either unsound or useless on exactly this shape, because two spines of different lengths agree for every rung of the shorter. A hash-consed store has both:sameis au32compare andstructural_hashis computed at construction. That is the measured case for stage 2 below, and this store already reduces the same spine at 2.77× (1.64 ms → 4.55 ms, interning included). -
Nathas no literal fast path.try_primitive_reducematchesLiteral::IntandLiteral::BigIntonly, becauseterm::lit_bigintreturnsNoneforLiteral::Nat, sonatLepeels oneSuccper unit. The two slowest tests in the kernel crate are both this, at 61.7 s and 52.4 s of a 66 s suite against a median under 0.3 s. That fix lives inreduction.rs, not here;W_perf_extra’sa_nat_order_fast_path_must_agree_with_the_unary_unfoldingis the specification it has to satisfy. -
The main kernel has no
whnfand no cache of any kind.is_subtypenormalises BOTH sides to a FULL normal form and then compares, with onealpha_eqtree walk as its only shortcut, on every conversion. Lean’sisDefEqis whnf-driven over a hash-consedExprwith awhnfcache and anisDefEqcache. MEASURED: one convertible-but-not-α-equal pair costs 6.55 ms once and 6.62 ms per call a hundred times over — a FLAT per-call cost, which is what “no cache anywhere” looks like from outside. That is the largest remaining algorithmic gap after (3), andTermStore::alpha_id,IdCache,TypeCacheandDefEqCache::scopedare the four pieces it needs — the last two carrying the lifetime and the environment key that make a kernel cache SOUND rather than merely fast.
§The wiring plan
Three routes, and only one is open.
- Change
Term’s children fromBoxtoRc. Hundreds ofBox::newTerm-child sites sit in FROZEN test files. Closed by rule. - Add a
Term::Shared(Rc<Term>)variant. Seven frozen kernel test files matchTermexhaustively and wildcard-free, deliberately —lean_conformance_t00_terms.rssays so in a doc comment (“a new variant has to answer here”). Closed by rule. - Keep
Termas the BOUNDARY type and make the store the kernel’s internal representation. This is the recommendation. Every frozen test builds aTerm, calls a kernel entry point, and compares aTerm; none of them can observe what happens in between. The boundary is EIGHT public functions taking&Term:type_checker::{infer_type, is_subtype, substitute},reduction::{normalize, normalize_at, normalize_checked}(plusnormalize_checked_at), andrecheck::{recheck, double_check}. Intern once on the way in, extract once on the way out. The entry points already pay an O(size)term.clone()before their first step —normalize_checked_atpays two — so interning replaces a cost rather than adding one.
Four stages, each independently green.
- Stage 1 — conversion,
type_checker.rsonly. Fivealpha_eqcall sites becomealpha_same, andis_subtypegains the cache it has never had:HashMap<(TermId, TermId), bool>. The walk is replaced by a walk of the same order that MEMOISES, so the second conversion mentioning a subterm is O(1) instead of O(size). Hold the store thread-local behind the existingINSIDE_KERNELdoor, so the outermost entry creates it andDoorGuarddrops it — a cache with the lifetime of one kernel call cannot grow without bound or leak across aContextchange. - Stage 2 — the reducer,
reduction.rs.normalize_checked_atinterns once and runs its fixpoint and Brent cycle detection onTermIds: (2) above goes away outright. The 54reduce_step_atarms port one at a time withrecheck_reduction_differential.rsas the oracle. Depth stops binding, because the traversal is this file’s explicit stack. - Stage 3 —
infer_type,type_checker.rs. Its 33 recursive self-calls become onefoldover anIdCacheof types — which ISa_shared_subterm_is_checked_once_and_not_once_per_occurrence, by construction rather than by a special case — and the 16 KB frame that aborts the process disappears with the recursion. - Stage 4 — the second kernel,
recheck.rs. It gets its OWNTermStore. Do not share one between the two kernels: six soundness bugs this campaign were miscounted identically in both, sodouble_checkagreed with the bug throughout. A shared store is a shared bug.
§What this file hands the kernel TODAY, with no representation change
The four stages above all move the kernel onto TermId. These work against the
Box-recursive Term the kernel already has, and each is one line at a call site. The
first three are WIRED IN — type_checker::{infer_type, is_subtype},
reduction::normalize_checked_at, recheck::{recheck, double_check} and
reduction::reduce_step_opt’s Term::Let arm all call into this file now:
deepen_stack— the kernel takes its own stack, sized from the term it was handed, so the door’s advertised limit becomes the real one and no admitted term can abort the process. One line at the top of each public entry, with no re-indentation of its body.zeta_or_share_opt— the whole occurrence-gated ζ rule, as one call, in theOption<Term>shapereduce_step_opthas. Both kernels used to ζ-substitute UNCONDITIONALLY, so a value bound once and used n times was written and checked n times, which isa_shared_subterm_is_checked_once_and_not_once_per_occurrenceexactly: 329 nodes against a ceiling of 97, and 92 under this rule — the case is green.occurrences_in_termis the gate underneath it andzeta_expand_head_termis what conversion owes in return. All three are iterative, so none has the ceilingtype_checker::body_statsdoes.kernel_stack_bytes—recheck.rs’s stack budget was a hard-coded 1 MiB that assumed a 2 MiB thread. Derived from this instead, the second kernel stops answeringUnsupportedon a deep term the first kernel now checks happily.deepen_stack_with_depth— NOT yet wired, and it closes a hole the other three do not. The door anddeepen_stackboth measure the term they were HANDED;infer_type’sVararm walks the CONTEXT’s type instead. MEASURED in a child process: a one-node termVar("x")under a binding of depth 4000 kills the process both bare AND throughdeepen_stack, and is answered when the sizer is told the context’s depth.TypeCache+TermStore::env_for—inferTypememoised on(environment, term), which isa_shared_subterm_is_checked_once_and_not_once_per_occurrenceon the CHECKING side. The environment is in the key becauseTermnames its variables andContext::extendshadows; Lean’sExpris de Bruijn and does not need it, which is exactly why porting itsinferTypecache across unchanged is unsound here.
Two things not to do. Do not put the store in Context — Context is cloned on every
extend and would copy the store with it. And do not key a CACHE on a term’s identity alone:
Term names its variables and Context::extend SHADOWS, so one TermId has one type per
scope. TypeCache carries the EnvId that makes the key sound, and KernelCall gives
it a lifetime it cannot escape.
The float question is SETTLED. Literal’s PartialEq is hand-written in term.rs and
compares f64::to_bits(), so it is total and reflexive and the store’s interning key is the
kernel’s own equality. literal_identity_eq is the INDEPENDENT second implementation of
that rule, locked against term.rs’s over an adversarial grid, and
eq_faithful is the conservative guard that was correct under the
old rule and stays correct under the new one.
§Example
use logicaffeine_kernel::term_store::TermStore;
use logicaffeine_kernel::Term;
let mut store = TermStore::new();
let x = Term::Var("x".to_string());
let twice = Term::App(Box::new(x.clone()), Box::new(x.clone()));
// The two occurrences of `x` are ONE node.
let id = store.intern(&twice);
assert_eq!(store.dag_size(id), 2); // the App and the one shared Var
assert_eq!(store.tree_size(id), 3); // what a tree walk would count
// Interning identical structure yields the identical handle: equality is O(1).
assert_eq!(store.intern(&twice), id);
assert_eq!(store.extract(id), twice);Structs§
- DefEq
Cache - A cache of decided conversions, keyed on a PAIR of node identities.
- EnvId
- Identity of a hash-consed LOCAL TYPING ENVIRONMENT — the fragment of a
Contextthat decides a term’s type, and therefore half of the key aninferTypecache must have. - IdCache
- A cache keyed on node identity, dense:
Vec<Option<V>>indexed by the id, so a lookup is a bounds check and NOT a hash. - Kernel
Call - A token that lives exactly as long as ONE outermost kernel call, and that a cache BORROWS.
- LevelId
- Identity of an interned universe level.
- LitId
- Identity of an interned literal.
- Name
SetId - Identity of a hash-consed, sorted set of names.
- Scoped
DefEq Cache DefEqCachebranded with the lifetime of ONE outermost kernel call. SeeDefEqCache::scoped.- Shape
Mask - The set of
NodeShapes occurring anywhere in a subterm — oneu16, computed once at interning time and answered in O(1) thereafter. - SubstId
- Identity of a hash-consed simultaneous substitution.
- TermId
- Identity of an interned term — a
Copyhandle whose equality IS structural equality. - Term
Store - A hash-consed term store: maximal structural sharing, O(1) equality, O(1) annotations.
- TooLarge
- A DAG whose unfolding is too large to materialise as a
Term. - Type
Cache inferType, memoised on(environment, term)— the cache Lean has and we do not, with the half Lean does not need because itsExpris de Bruijn.
Enums§
- Level
View - A borrowed view of one interned universe level.
- Node
- One term node with its children replaced by handles — the flat shape that makes the store a DAG rather than a tree.
- Node
Shape - Which
Nodekind a subterm is — one perNodevariant, and wildcard-free everywhere it is matched for the same reasonNodeis. - Occurrences
- How often a name occurs — the only distinction an occurrence-gated ζ rule needs.
- Store
Normal Form - The result of
TermStore::beta_zeta_normalize— honest about a budget it did not finish inside, the wayreduction::normalize_checkedis.
Constants§
- BASE_
STACK_ BYTES - The floor under any stack the kernel sizes for itself, whatever the term’s depth — room for the non-recursive part of a kernel call, and for a panic to unwind and format its message.
- BYTES_
PER_ RUNG - Stack reserved per rung of nesting past
INLINE_DEPTH. - INLINE_
DEPTH - The deepest term the kernel will walk on a stack it did not size.
Functions§
- beta_
zeta_ normal_ form - THE PURE β/ζ DOOR. The
Term-level entry point a tree reducer wires in so a β-heavy reduction stops being quadratic — and the only one it needs, because it answersNonefor every term it is not certain about. - beta_
zeta_ normal_ form_ in_ context beta_zeta_normal_formwith every policy question answered from theContext— the form a reducer wires in, with nothing left for the caller to get wrong.- deepen_
stack - Run
fon a stack sized forterm— or report that this call is already on one. - deepen_
stack_ all deepen_stackfor an entry point that takes more than one term —is_subtypeanddef_eqcompare two, and the stack has to hold the DEEPER of them.- deepen_
stack_ all_ with_ depth deepen_stack_alltold about depth the TERMS DO NOT SHOW — and it is not a refinement, it is a hole in the door that this is the only way to close.- deepen_
stack_ with_ depth deepen_stacktold about depth the term does not show — seedeepen_stack_all_with_depthfor the defect this closes.- kernel_
stack_ bytes - How much stack the kernel call on this thread is entitled to use.
- kernel_
stack_ rungs - The rungs (
BYTES_PER_RUNGeach) of the stack this thread runs on, by the kernel’s own sizing rule: at leastINLINE_DEPTH, at most the kernel’s term-depth door. - literal_
identity_ eq - THE ONE RULE. Total, reflexive literal equality — bit-exact on floats — offered as a
FUNCTION so
term.rsand this store cannot hold two different opinions about it. - occurrence_
count_ in_ term - How many FREE occurrences of
nameaTermhas, saturating atu32::MAX. - occurrences_
in_ term occurrence_count_in_term, classified — everything an occurrence-gated ζ rule needs, and strictly CHEAPER than the count, because it stops at the second occurrence.- occurs_
free_ in_ term - Whether
nameoccurs free inbodyat all — stops at the first occurrence. - stack_
bytes_ for_ depth - How many bytes of stack a term of this depth is given.
- term_
depth - The nesting depth of a
Term, counted in EDGES: a leaf is 0. - within_
kernel_ depth - Whether syntax is within the kernel’s existing term-depth limit.
- zeta_
expand_ head_ term - ζ-expand a
Letat the head, whatever its occurrence count — the step a conversion check must be able to take on demand oncezeta_or_sharelets aLetsurvive into a normal form.Nonefor anything that is notLet-headed. - zeta_
or_ share - ONE ζ step, OCCURRENCE-GATED, against a reducer that always returns a
Term. - zeta_
or_ share_ in_ place zeta_or_share_opton aLetthe caller owns, stepping its parts where they lie: the same rule, in the same order, with the same answer, but a part that a step leaves alone is never copied and nothing is rebuilt around the one that moved.step_in_placemust be the in-place twin ofstep(reduce_step_in_placeofreduce_step_opt), whichreduction::owned_normalize_controlspins shape by shape.falsefor anything notLet.- zeta_
or_ share_ opt zeta_or_shareagainst a reducer that reports PROGRESS:stepanswersNonewhen there is no redex.reduction.rs’sreduce_step_optcalls it for everyTerm::Let.