← board

A nested def's default is evaluated at value time

def make():
    seed = 5
    def h(v=seed):
        return v
    seed = 99
    return h

print(make()())          # CPython 5     pxx 99

Silent. Found 2026-08-15 alongside [[bug-nilpy-a-nested-defs-default-parameter-ignores-the-callers-value]] — a third defect in the same lowering, and the probe written for this one found a fourth (below).

Two mechanisms for one concept, disagreeing

PyEvalParamDefault evaluates every NON-CONSTANT default into a hidden $pdef.<def>.<param> global at the def statement — the point Python specifies, and what makes the shared-mutable-default idiom accumulate. The direct-call path has read that global all along (ir.inc, the omitted-argument fill).

PyNestedDefClosureValue — the path a def taken AS A VALUE goes down — instead RE-PARSED the default expression at the point of the value, and its comment claimed that was "the scope where the def statement stands — Python's default-at-def-time rule". Right about the scope, wrong about the time: return h can stand many statements after the def. It now binds a read of the global, so both paths get their answer from the one evaluation. devdocs/dev/normalise-dont-special-case.md.

uforth's DOES>/DEFER, which the re-parse was written for, is unaffected — the store is IN the enclosing body, so a fresh enclosing invocation still evaluates fresh defaults. A CONSTANT default has no global and still re-parses; a literal cannot observe the difference.

The fourth bug, found by this one's probe

def make4():
    s = 7
    def h(v=s):
        return v
    r = h()          # CPython 7
    return r         # pxx None -- while `return h()` directly was correct

$pdef.make4.h.v is allocated by the enclosing body's local-typing TRIAL parse and ROLLED BACK with the rest of that trial's scope. The real parse allocates it afresh at whatever index is free NOW — one higher, if the body meanwhile declared a local like r. PyQueueNestedDef only carried the signature onto the Proc when it REGISTERED it, which the trial had already done, so ProcParamDefaultSym kept the trial's index and the call read the symbol that inherited it. With no r there was no index to lose, which is why the same def answered correctly through return h() — the difference was the caller's own locals, not the def.

Re-recorded on every real pass now, beside the by-ref capture re-apply two lines up, which exists for exactly the same reason. Recycled index, plausible wrong value — the neighbour of project_tsymbol_field_landmine.

Gate

test/test_nilpy_nested_def_default_at_def_time.npy (+.expected, in the Makefile), byte-identical to CPython: the reassignment between def and return; the value taken twice from one enclosing call; a FRESH enclosing invocation re-evaluating (the DOES> shape); direct calls before and after the reassignment; the result assigned to a local; a local declared after the def (the index shift); a caller-supplied override; a constant and a captured default together; the mutable-container accumulator; and a default reading the enclosing def's PARAMETER. gate.sh quick GREEN.

Log