← board

lambda body that is an expression around a call cannot call a def

Measured

def top():
    return 5

f = lambda: top()          # works      -> 5
h = lambda: top() + 1      # RUNTIME FAILURE
print(h())
pyeval: unknown call: top()
exit 1

lambda x: x + 1 (no call at all) works. The failure needs a call inside a larger expression.

Root cause — read out of the source

PyParseLambdaStub (compiler/pyparser.inc) has two paths:

Which path is taken is gated on PyLambdaBodyIsDiscardableCall(bStart, bEnd) (pyparser.inc:4669), which accepts the body only when it is exactly one call: it requires the first token to be an identifier, the LAST token to be ), and at depth 0 nothing but tkIdent / tkDot (the callee chain a.b.c().

So top() + 1 is rejected at the + (a depth-0 token that is neither ident nor dot) and drops to the interpreter — which is precisely the case that cannot work there. The predicate's name is accurate about what it tests; the problem is that what it tests is much narrower than what the lifted path can actually handle.

Shape of a fix

The lifted path compiles an arbitrary expression body — nothing in it is specific to a bare call; the predicate is the only thing holding it back. So the narrow, measurable step is to widen the predicate rather than touch the lifter: accept a body that contains a call anywhere (any tkIdent immediately followed by tkLParen), not only one that IS a call.

Care needed on what the lift genuinely cannot take, which is why the predicate was written conservatively — the capture scan below it walks body tokens and only handles names resolvable via PyProgSym / PyQualifyNested. Widening the predicate exposes that scan to shapes it has not seen (subscripts, nested lambdas, comprehensions). Suggested order:

  1. widen to "contains a call, and every depth-0 token is an operator/ident/dot/ literal" — i.e. a flat expression;
  2. gate each further shape (subscript, nested lambda, comprehension) on its own measurement rather than in one jump.

Anything still rejected keeps the pyeval fallback, which is what makes the widening safe to do incrementally.

Better long-term

The pyeval fallback failing on any call is the real sharp edge: it is a run time error for something the compiler could have diagnosed. Either the fallback should refuse at COMPILE time when the body contains a call (a clear message naming the lambda), or it should go away entirely once the lifter covers enough. The current behaviour — compile clean, die later with an interpreter-flavoured message — is the worst of the three.

Gate

.npy diffed against CPython covering lambda: f() + 1, lambda: f() or 4, lambda x: f(x) * 2, a lambda calling a method, and a lambda calling a nested def that has captures (the transitive-capture case PyParseLambdaStub already documents). Plus the existing lambda tests staying green.

Resolved 2026-08-03 — the predicate was the whole of it

Widening PyLambdaBodyIsDiscardableCall was the fix, exactly as the ticket suspected, and the lifted path needed no change at all. Renamed PyLambdaBodyIsLiftable, since "is a discardable call" stopped describing what it tests.

It now accepts any FLAT expression that CONTAINS a call, instead of a body that IS one call. The old rule demanded the first token be an identifier, the last a ), and nothing but ident/dot at depth 0.

Two conditions survive, and they are what keeps this safe:

The ticket's step 2 (subscripts, nested lambdas, comprehensions one at a time) is deliberately not taken: those are shapes the capture scan below the predicate has not been measured against.

One behaviour change worth stating

A lambda over a capturing sibling nested def used to fall to the interpreter in its add(10) + 1 spelling and fail at run time; it now fails at COMPILE with undefined variable (add) — which is the same error the bare add(10) spelling already gave on pinned. Consistency rather than a new fault, but a program that merely BUILT before, without ever invoking such a lambda, now does not. Filed as [[bug-nilpy-lambda-over-a-capturing-nested-def-does-not-compile]] (pre-existing, reproduced on pinned, independent of the body shape).

Verified

test/test_nilpy_lambda_expression_body.npy (+ .expected, wired into make test-nilpy), byte-identical to CPython across 9 lines: f() + 1, f() or 4 with the side effect counted, f(x) * 2, f(x) + f(x), a comparison result, a ternary over two calls, a METHOD call on a captured object, the bare f() form as a control, and a body with no call at all as the other control. Pinned prints the first line and then dies with pyeval: unknown call: top().

gate.sh quick GREEN, self-host fixedpoint byte-identical, FPC seed clean.

Log