Allow function inlining in quantifiers#1225
Conversation
|
@keyboardDrummer-bot Please rewrite the PR description. |
|
Done — I've rewritten the PR description with a clearer structure (problem/solution/changes table/testing). |
tautschnig
left a comment
There was a problem hiding this comment.
Suggested theorems to lock the behaviour down:
theorem expandInlineCalls_zero (e : LExpr TBase.mono) : expandInlineCalls F 0 e = e— trivial but worth marking@[simp].theorem expandInlineCalls_no_inline (e : LExpr TBase.mono) (h : ∀ sub, sub ∈ subterms e → ¬ isInlineCall F sub) : ∀ fuel, expandInlineCalls F fuel e = e— pre-pass is identity when there's nothing to do (justifies running it unconditionally).theorem expandInlineCalls_idempotent (h : sufficientFuel) : expandInlineCalls F fuel (expandInlineCalls F fuel e) = expandInlineCalls F fuel e— protects against double-application surprises (relevant if anyone wires it elsewhere).theorem expandInlineCalls_eval_eq : LExpr.eval n σ (expandInlineCalls F m e) = LExpr.eval n σ eforethat contains no inline calls under binders — proves the pre-pass is conservative on termsevalalready handles.- The right long-term fix is to extend
Stepwith congruence rulesstep_under_abs/step_under_quant(currently absent — confirmed by grep) and proveStep* F rf e (expandInlineCalls F fuel e). That ties the pre-pass back into the semantics machinery and would let it eventually be folded intoeval.
| let e2' := expandInlineCalls F fuel' e2 | ||
| let e' : LExpr TBase.mono := .app m e1' e2' | ||
| match F.callOfLFunc e' with | ||
| | some (op_expr, args, lfunc) => | ||
| if h : lfunc.body.isSome | ||
| && (lfunc.attr.contains FuncAttr.inline) then | ||
| let body := lfunc.body.get (Bool.and_eq_true_iff.mp h).1 | ||
| match LFunc.computeTypeSubst lfunc op_expr args with | ||
| | some tySubst => | ||
| let body' := body.applySubst tySubst | ||
| let new_e := substFvarsLifting body' (lfunc.inputs.keys.zip args) | ||
| expandInlineCalls F fuel' new_e | ||
| | none => e' | ||
| else e' | ||
| | none => e' | ||
|
|
There was a problem hiding this comment.
Two issues with this expansion arm:
- Stricter trigger than
eval.eval(line ~223) inlines onattr.contains .inline || constrArgAt findInlineIfConstr || (hasInlineIfAllCanonical && args.all isCanonicalValue). This pre-pass only checks.inline. So aninlineIfConstrfactory whose argument inside a binder would be a constructor at runtime will silently not get expanded by the pre-pass while it would outside the binder. If this asymmetry is intentional (because we can't determine canonicality ofbvars), please add a one-line comment saying so; otherwise mirroreval's logic. - Duplicated logic. The body /
computeTypeSubst/applySubst/substFvarsLiftingchain is now a verbatim copy ofeval's inline branch. Please factor a private helpertryInlineExpand : Factory T → LExpr T.mono → Option (LExpr T.mono)(returningnonewhen the call doesn't apply) and route both call sites through it. That also makes the proof obligations in the top-level review stateable in one place.
Minor: Bool.and_eq_true_iff.mp h |>.1 is more readable than the current (Bool.and_eq_true_iff.mp h).1 once the helper exists, and matches the idiom used in Semantics.lean:2539.
| def eval (E : Env) (e : Expression.Expr) : Expression.Expr := | ||
| -- Pre-pass: expand inline factory calls under quantifier/lambda binders, | ||
| -- because `LExpr.eval` does not descend into closed binder bodies. | ||
| let e := LExpr.expandInlineCalls E.exprEnv.config.factory E.exprEnv.config.fuel e |
There was a problem hiding this comment.
Wiring only here means the bug persists for every other caller of LExpr.eval — at least Lambda.typeCheckAndPartialEval (Strata/DL/Lambda/Lambda.lean:55) and the Lambda evaluator tests. Better to do one of:
- Apply the pre-pass inside
LExpr.evalitself (with a guard that fuel > 0, and proof obligationexpandInlineCalls_no_inline → evalis unchanged), so all consumers benefit. - Or expose a single
LExpr.evalWithInlineExpansionand switch every existing direct caller ofLExpr.eval.
As written, two callers will silently diverge in behaviour for the same input — confusing for anyone debugging.
| -- Same call wrapped inside a forall: original bug — eval leaves it untouched. | ||
| private def quantCall : LExpr TP.mono := | ||
| esM[∀ (int): (((~polyEq : int → int → bool) %0) %0)] | ||
| private def quantExpect : LExpr TP.mono := | ||
| esM[∀ (int): (∀ (int): (%0 == %0))] | ||
|
|
||
| /-- info: true -/ | ||
| #guard_msgs in | ||
| #eval (LExpr.eval 100 polyState quantCall) == quantCall | ||
|
|
||
| /-- info: false -/ | ||
| #guard_msgs in | ||
| #eval (LExpr.eval 100 polyState quantCall) == quantExpect | ||
|
|
||
| -- Fix: expandInlineCalls descends through the forall and rewrites the call. | ||
| /-- info: true -/ | ||
| #guard_msgs in | ||
| #eval (LExpr.expandInlineCalls polyState.config.factory 100 quantCall) == quantExpect | ||
|
|
||
| -- And piping the pre-pass before eval also yields the expected form. | ||
| /-- info: true -/ | ||
| #guard_msgs in | ||
| #eval | ||
| (LExpr.eval 100 polyState | ||
| (LExpr.expandInlineCalls polyState.config.factory 100 quantCall)) == quantExpect | ||
|
|
||
| end Lambda |
There was a problem hiding this comment.
Coverage is thin for the change's blast radius. Please add #guard_msgs cases for:
- Inline call under a
λbinder (the description explicitly mentions lambdas; onlyforallis exercised). - Nested quantifiers with the inline call at the inner level — confirms recursion really does descend, not just stop at the outermost binder.
inlineIfConstr/inlineIfAllCanonicalfactory functions under a binder — locks in the trigger-set asymmetry called out onLExprEval.lean(whichever direction you choose).- Fuel-exhaustion: call with
fuel := 1against a 3-deep nested binder containing an inline call; assert the pre-pass returns something sensible (probably the original) rather than partially expanded garbage. - A negative test: a term with no inline calls under binders is unchanged by the pre-pass (
expandInlineCalls F n e == e). This is the practical witness for theexpandInlineCalls_no_inlinetheorem suggested in the top-level review.
Problem
LExpr.evaldoes not reduce under.absor.quantbinders — these are treated as canonical values with no congruence rule. Consequently, calls toinlinefactory functions inside quantifier or lambda bodies are never expanded.Solution
Introduce
LExpr.expandInlineCalls, a fuel-bounded structural pre-pass that:inlinefactory function with a body, substitutes arguments into the body (usingsubstFvarsLiftingfor correct de Bruijn lifting).This pre-pass is called in
CmdEval.evalbefore the main evaluator, so inline expansion under binders happens transparently.Changes
Strata/DL/Lambda/LExprEval.leanexpandInlineCallsStrata/Languages/Core/CmdEval.leanexpandInlineCallsas a pre-pass beforeLExpr.evalStrataTest/DL/Lambda/InlineInQuantifierTests.lean(new)Testing
New test file verifies:
∀binder is not expanded byevalalone (confirms the bug).expandInlineCallscorrectly expands the call under the binder.evalproduces the fully reduced result.