← board

Measured

2026-09-13 (frankS), at 34a9a7609 and still at f89a1d2eba30:

f = min; print(f([3, 1, 2]))   TypeError: expected a number, got object
f = max; print(f([3, 1, 2]))   TypeError: expected a number, got int
print(min([3, 1, 2]))          1        correct
print(max([3, 1, 2]))          3        correct

All three shapes of the value form fail identically -- f = min; f(xs), map(min, [[...]]) and f = min; map(f, [[...]]) -- so it is the binding, not the call site.

The mechanism, printed rather than reasoned

A temporary WriteLn in PyMakeFuncValueFor's wrapper gate, at the point where the callee is finally chosen:

PROBE nm=min pi=1268 pc=2 req=2 ret=22 alt=1268

pc=2 with req=2 is the tell: this is NOT one of the key: Pointer = nil arms (those are ParamCount 2, required arity 1). It is pylib.pas:2268 function min(const a: Variant; const b: Variant): Variant -- the two-argument scalar form, whose required arity genuinely IS 2.

alt=1268 means PyFindVariantParamOverload was asked for an all-Variant overload of arity 2 and returned the same proc. It could not have done better: its unit scope is ProcUnitIdx[i] <> ProcUnitIdx[ofPi] then Continue, the starting proc is in pylib, and every iterable arm (min(l: TPyList; key: Pointer = nil), min(const v: Variant; key: Pointer = nil), the TPyDict/AnsiString/TPyIter/TPyRange rows) is in pyeval. The two units split one Python builtin across an invisible boundary.

The unit scope is not an oversight and must not simply be widened -- its own comment records two measured silent-wrong-value bugs from a program-wide scan (b = twinmod2.parse answering twinmod's, zz = casemod.Pick answering pick's). Whatever fixes this has to keep that.

What the fix has to decide

In CPython min is ONE function that accepts either an iterable or the values spread out, and a callable VALUE of it has to serve both:

f = min; f([3, 1, 2])   -> 1     the iterable form
f = min; f(3, 1)        -> 1     the scalar form

so binding the bare name to either single arm is wrong for the other. Today it is bound to the scalar arm and the iterable form is the broken one. Note the compiler already knows these two spellings coincide for exactly these two names -- PyStarIsIterableForm is a two-entry list of min/max -- which is the same fact this needs, in the place a fix could reuse it.

This is the same QUESTION as [[bug-n-abs-is-not-a-value-while-len-and-str-are]] ("which overload does a bare builtin name mean as a value?") but a different MECHANISM: there the name has no proc at all, here it has several and picks the wrong one in the wrong unit. They want one answer and two patches; do them in one pass.

Why it is filed rather than fixed

Found while fixing the neighbouring defaulted-tail bug (f = sorted; f(xs) answering [] -- that one is fixed, in the same area of PyMakeFuncValueFor). This one needs a dispatcher that can serve both arities from one value, or a cross-unit resolution rule that does not reinstate the two bugs the unit scope exists to prevent. Neither is a change to guess at beside a different fix.

Positive control for whoever takes it

f = min; f(3, 1) -- the SCALAR form -- IS correct today: measured, not predicted, 1 and 3 against CPython's 1 and 3. That is the arm the name binds to, which is why it works. A fix that makes the iterable form work must not silently trade it away, and a fixture asserting only the iterable form cannot see that happen.

The same question, a much larger population -- measured 2026-09-13 (frankS)

This ticket is filed as two builtin names. It is not two names; it is the general question "how many arguments does a callable value pass?", and the second population is every lib/rtl shim with a defaulted tail:

f = json.dumps; f([1, 2])                  TypeError: expected a number, got int
f = json.dumps; f([1, 2], -1, True, False) [1, 2]      CORRECT today

function dumps(const obj: Variant; indent: Integer = -1;
               ensure_ascii: Boolean = True; sort_keys: Boolean = False): AnsiString;

The wrapper is built at 4. PyCallableValueArity returns PyProcRequiredArity only when the callee's unit is pylib or pyeval, by NAME, and json is neither -- so the defaulted tail is treated as required. Found while fixing [[bug-n-a-stdlib-shim-function-returning-a-container-is-broken-when-taken-as-a-value]], whose return-side and parameter-side gates are now closed; this arity residue is what is left, and it is here rather than there because it is this ticket's question.

Do not fix it by adding json, re, mimic_struct... to that unit list. Unit-name scoping is exactly the "name is not the thing" failure this repo keeps paying for, and here the widened list would ALSO be wrong on the merits: for a pylib builtin we chose required arity and accepted that f = sorted; f(xs, key) cannot pass the optional argument, but json.dumps(obj, indent=2) is ordinary Python, so required arity breaks a real spelling and full arity breaks the common one. Neither single arity is right, which is the same conclusion the min/max section above reaches from the other direction.

The refusal is a MEASUREMENT, not caution, and that is the part to keep. A one-line fix was available and working -- add json to the unit list -- and it was declined because the measurement shows the list was never the mechanism: required arity breaks dumps(obj, indent=2) and full arity breaks dumps(obj), so no membership test over units can be right, however the list is spelled. A widened list would have closed the row and left the defect, which is the shape that gets a ticket reopened later with the fix already in it.

If a discriminator is needed it should be something the DECLARATION carries -- ProcParamHasDefault already does, and ProcSigOff marks a NilPy def -- never where the file sits. The honest shape is a wrapper that forwards a variable count, or one wrapper per reachable arity, which serves min/max too.

The free instrument

procs=N in the compiler's own ok: line counts a synthesized wrapper: the VALUE spelling reads one higher than the CALL spelling when a wrapper was built and identical when it was not. re.findall 2155 -> 2156 (wrapped), json.loads 2393 -> 2393 (not wrapped, before the fix). No probe, no rebuild -- the temporary WriteLn recorded above is not needed to answer this class.

It is a DIFFERENTIAL reading and it is only sound when the two compiles differ in one thing. Anything else that changes between them moves the count too, so it holds for the call-versus-value spelling of ONE name in ONE file and does NOT hold across a pull, a rebuild, or two different programs. Read it as a delta between two invocations you made back to back, never as an absolute.