Repository navigation
perf(check): avoid redundant kind checks for let bindings - #1952
Merged
nicolas-abril merged 5 commits intoNov 3, 2026
Merged
Conversation
check-let checked every binder's inferred type against Kind(qj). infer-app returns B(a) with the argument substituted, so checking that type infers the ADT and checks the argument again; a lambda argument holding another such let doubled the work per level, 11*2^n-9 checker calls for n nested lets. For an ordinary or erased binder the check only re-proved that an inferred type is a type; only a + binder adds an obligation, Data. Check the kind at a + binder alone, as the BendTT kernel's let rule does: 6n+2 calls, and depth 24 checks in 0.03s instead of 35.87s. Closes bendlang#1940
Follow-up to the review of this PR. The guard tested the binder's quantity, but a + binder of an @unsafe def only has to be a type, so those lets stayed exponential; test the kind the binder needs instead. A dead region or an @unsafe def can name a def below it, whose signature is not checked yet, so its inferred type is not known to be a type: x : 0n reached the context and the error blamed a correct h(x). Check the kind when the value's head has no body, which restores the old error at the let. A parameterized family below the let still gets the later error. The depth-10 test passed on the exponential build, so it enforced nothing. let_dependent_type and the new let_unsafe_dependent_type are 30 deep, past the gates' 30 s cap if the doubling returns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nicolas-abril
force-pushed
the
perf/let-kind-check-plus-only
branch
from
November 2, 2026 15:17
7a649fd to
8f78d6a
Compare
Name the condition instead of spelling it inside the if, read the head with the file's usual destructuring, and fit the rule's note on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard only moved a misleading error for a malformed signature below a dead or @unsafe let, and the restored error still points into the caller. It brought the doubling back for lets headed by forward defs, unfilled laws and self-calls. check-let is the kernel's let rule again, with the kind each binder needs, so + binders of an @unsafe def stay linear. The misleading error is a documented limitation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
check-let does not check an ordinary or erased binder's type, so a type from a signature below the let enters the context unchecked. the signature's own event is what that relies on: let_forward_refused fails if it stops running. The rule comment now says which Aj the premise covers and where the others are checked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1940
Cause
check-let checks every binder's inferred type against
Kind(qj). infer-app returnsB(a)with the surface argument substituted, sox = tag(q => ..)has typeTag<q => ..>. Checking that type infers the ADT, andtele_checkchecks the lambda again. A lambda whose body holds another such let repeats the inner pair, so n nested lets cost 11·2ⁿ − 9 checker calls.For an ordinary or erased binder that check is redundant.
Kind(g)fitsKind(h)for everyhother than+, so it can only confirm that the inferred type is a type, and inference already guarantees that: book types are checked when defined, context types come from checked goals and earlier lets, andB(a)is a type becauseBwas checked underx : Ain the signature andachecks againstA. Only an unrestricted+binder adds an obligation, that its type is Data.Change
check-let calls
term_check_kindonly when the binder's kind is Data, and the rule comment says so. That is a+binder outside an@unsafedef: in one,lhs_kindonly asks a+binder to be a type.This is BendTT's let rule.
Typed.lettinbendtt.leanrequiresV : T2only whenq = Q2, andTerm.checkchecksVagainstT2onlyif q == Q2.Measurements
Checker calls (
term_check+term_infer) for the issue's family with n nested lets:Before is exactly 11·2ⁿ − 9 and after is exactly 6n + 2, through n = 32. At n = 24,
--check-onlytakes 35.87 s before and 0.03 s after. The five checker benches are unchanged within noise.Tests
let_dependent_type: the issue's family 30 deep, a shadowed binder and an erased binder; accepted.let_unsafe_dependent_type:+binders of an@unsafedef, 30 deep; accepted.let_dependent_domain: a domainT: Datathat fits the ADT's-T: Type; accepted.let_dependent_domain_bad: a domainT: Typethat does not fit-T: Data; refused at the signature.let_plus_dependent_type:+xat a dependent type that is Type; refused.let_forward_refused: a malformed type from a signature below the let; refused at that signature's own event, which is what the skipped check relies on.Depth 30 is 2^30 checks if the doubling returns, past the gates' 30 s cap, so a regression fails the run. A depth-10 test still passed on the exponential build.
On the rebased branch, against its base
db64cf76:--check-onlyover all 1837 tests: identical verdicts and output, except the two 30-deep tests, which time out at 25 s on the base.-o .bendttover the accepted tests: byte-identical.bun gates/_run.ts: test 1824 / 1824, repo 54 / 54, perf 124 / 124; ping skipped (no site repo).--verdicton the five tests above: the same verdicts as--check-only.Merged onto
mainatd0949580: test 1825 / 1825; perf 123 / 124, the one miss being themerklecompile cell at 1.16x, whichmainalso misses.Not run:
gates/safe.ts, which needs a kernel binary built on a Lean node.Limitations
Only let binders are fixed. infer-app still substitutes the surface argument into
B(a), so other checks of that type double the same way, and these stay exponential, to be handled in a follow-up PR:+lets outside@unsafe, which still check their type's kind. The kernel does the same atQ2.+lambdas, in and outside@unsafe.An inferred type is not yet known to be a type when it comes from a signature below the let. A dead region or an
@unsafedef can name one, and so can a live call to a foreign def. Every such program I probed is still refused, at that signature's event at the latest, but a let can now bind the malformed type first, with two effects:def g() -> 0nbelow it,-x = g(); -y = h(x);reportsexpected Nat, observed 0nath(x), notexpected Kind(&0), observed Natat the let. With a foreigndef bad(a: Nat) -> 0nbelow it,x = bad(0n); -y = h(x); 0nin a live def does the same.def g() -> {(x => x(x)) : Nat}((x => x(x)))below it,-x = g(); -y = h(x);loops where the base refuses at the let. This is a new route to an existing hang, not a new kind:-y = h(g())already hangs on the base.