← board

An inline multi-entry dict literal passed as an argument loses its values

from collections import Counter

m = {"x": 5, "y": 0}
a = Counter(); a.update(m)
print("via var:", a["x"], a["y"])          # CPython 5 0 — pxx 5 0   OK

b = Counter(); b.update({"x": 5, "y": 0})
print("inline :", b["x"], b["y"])          # CPython 5 0 — pxx 1 1   WRONG

d = Counter(); d.update({"x": 5})
print("1 entry:", d["x"])                  # CPython 5 — pxx 5       OK

The boundary, measured

Varying one dimension at a time — the counts below are c["x"] after the update, CPython on the left:

argument shape CPython pxx
built in a loop, any size 5 5
named variable holding the dict 5 5
inline literal, ONE entry 5 5
inline literal, TWO+ entries 5 1 (values dropped, each key +1)

So it is neither the entry count as such (a loop-built 3-entry dict is fine) nor update itself — it is specifically an inline {...} with 2+ entries in ARGUMENT position. 1 is what you get from counting the KEYS, i.e. from treating the literal as an iterable, which is exactly what Counter.update does with a non-mapping.

Where to look

Python's {...} is ambiguous between a dict display and a set display, and the disambiguation is the :. A one-entry literal being right while a two-entry one is wrong points at the ARGUMENT-position parse of the literal (a set-vs-dict decision made on a token lookahead that only inspects the first element, or a trial parse whose rewind loses the mapping shape — cf. project_trial_parse_rewind_leaves_its_hoists_queued). Check what type()/len() say about the argument as received, not what the call does with it: len(m) is right for the variable form, so the object built OUTSIDE the call is a proper dict.

Grep the sibling shapes before closing — an inline multi-entry dict literal passed to any pylib routine that accepts "mapping OR iterable" is the same question: dict(...), dict.update, Counter(...), set(...), **kwargs forwarding.

Gate

make test-nilpy + self-host fixedpoint; a .npy test diffed against CPython covering the four rows above.

ROOT CAUSE — the argument COUNTER, not the dict literal (2026-08-13)

Fixed. The literal was never the problem, and neither was set-vs-dict disambiguation: PyDictLiteralAt is correct, and a probe confirmed the object reaching the callee is a proper 2-entry dict (iterating it yields both keys with both values).

CountCallArgsAhead in the SHARED parser.inc — the counter that says how many arguments a call was written with, which drives arity-based overload selection — tracked nesting depth for (/[ but not for braces. In Pascal an opening brace is a comment and never reaches the token stream, so the omission was invisible for the frontend the function was written for. In Nil Python a brace is a dict or set literal, and every comma inside one was counted as an ARGUMENT separator.

So d.update({"x": 5, "y": 0}) was read as a 2-argument call. No update overload takes 2 explicit arguments, so FindUMethOverloadAhead's arity-viable candidate list came back EMPTY (measured: nCand=0, versus 3 for the variable and one-entry forms), and it fell back to FindUMethArity, which answers the FIRST-DECLARED overload — update(TPyList). That arm read a TPyDict as a TPyList: on a Counter it counted the KEYS (hence 1 1), on a plain dict it segfaulted.

That single mechanism explains every row of the boundary table without needing a second story: one entry has no comma, a variable has no comma, and a loop-built dict has no comma in argument position.

Why the measurement was needed

The first two hypotheses — a set-vs-dict misparse (the ticket's own guess) and a type-kind-only overload score treating TPyList and TPyDict as a tie (a real, documented landmine in this codebase) — were both wrong, and both were plausible enough to have been written down as the cause. Probes settled it: the tie-scoring path PyPickOverloadByArgTypes is never reached for this call (no probe output at all), because an ident receiver resolves through parser.inc's FindUMethOverloadAhead instead. Three different method-call paths exist by receiver shape; guessing which one runs is how this would have been "fixed" in the wrong file.

The sibling, found by grepping before closing

The same counter scored a trailing comma as an extra argument, so d.update(m, ) — no literal anywhere — miscounted and segfaulted identically. Python allows trailing commas in call arguments and this parser accepts them, so it is the same bug in a second spelling; fixed alongside, via TrailingCommaAt, which also skips newlines so a multi-line call's trailing comma reads the same.

Also found, filed separately

set().update({...}) is refused outright ("TPyList has no method update") — the |= spelling works and lowers to TPyList.setupdate, so only the method NAME is unmapped. Not fixed here because it is not this bug and has a real design question in it (a set and a list are the same class, and a list must keep refusing update): [[bug-nilpy-set-update-method-is-not-mapped]].

Verified

test/test_nilpy_call_arg_count_braces_and_trailing_comma.npy, expectations generated from CPython, wired into test-nilpy: the reported Counter shape, the segfaulting plain-dict shape, a literal in non-first argument position, a nested literal, both trailing-comma spellings, a multi-entry literal into an ordinary def and as a parameter default, a set literal, and four controls that always worked (get(k, d), dict(literal), len(literal), the variable form).

Gate: make compiler/pascal26 fixedpoint + gate.sh quick + make test-nilpy.

Log