Mixed-type guard fires only for VARIANT operands; fully static operands do pointer math
- Type: bug (NilPy semantics, silent wrong value) — Track N
- Opened: 2026-08-01, from a CPython differential sweep (1094 cases,
self-hosted binary at
3f2c5b915).
The isolating experiment
vals = [3, [1, 2]]
va, vxs = vals[0], vals[1] # VARIANT: type known only at run time
sa, sxs = 3, [1, 2] # STATIC: int and TPyList known at compile time
print(va - vxs) # CPython TypeError pxx TypeError OK
print(va - sxs) # CPython TypeError pxx TypeError OK
print(sa - vxs) # CPython TypeError pxx TypeError OK
print(sa - sxs) # CPython TypeError pxx 840957547 WRONG, SILENT
The guard fires whenever either operand is a variant. It fails only when both are statically typed — that case lowers straight to IR arithmetic with the container's HANDLE as a number.
Scale
108 of the sweep's 174 divergences are this one root: CPython raises
TypeError, pxx returns a plausible number. It is not confined to containers or
to one operand type — every statically-typed mismatched pair diverges on the
same operator set:
| operands | operators that silently compute |
|---|---|
int/bool/float/zero/negint vs list |
- / // % < <= > >= |
str vs list |
- / // < <= > >= |
list vs int |
// % < <= > >= |
list vs list |
- // % < >= |
str vs str |
- * / // — e.g. "ab" - "ab" → 1, "ab" / "ab" → 1.0 |
+, *, **, == and != are consistently correct, because those have real
container/string semantics (concat, repeat) or a defined cross-type answer, and
so were implemented deliberately.
The ORDERING rows are the most dangerous: they return a clean True/False
decided by an allocation address, so a condition takes a branch with nothing in
the output suggesting a type error occurred.
Why the existing test did not catch it
test_nilpy_mixed_type_operands asserts exactly these cases (sub-list TypeError, mul-dict TypeError, lt TypeError, …) and passes. Its
operands come from a heterogeneous literal:
vals = [3, "ab", [1, 2], {"k": 1}]
a, s, xs, d = vals[0], vals[1], vals[2], vals[3]
so every one of them is a variant — the path that already works. The test covers only the guarded half and reads as full coverage. That is why the static-path hole survived: it is not that nobody tested mixed-type operands, it is that the test could not reach the broken lowering.
Cause
The check is implemented in the runtime variant helpers (pyvar_*,
compiler/builtin/pylib.pas), which inspect VType and raise. A binop whose
operand types are both known at compile time never routes through them.
IRPyNumStrClash (compiler/ir.inc:3841) is the static-side counterpart, and
it deliberately fires only for a str-vs-number pair — so the static path has
one narrow special case where the variant path has a general rule.
Measure before fixing: dump the inferred kinds with PXXDBG (n.locals,
a.ir:<proc>) rather than assuming which predicate is short. An earlier reading
of this same sweep concluded the bug was bool-specific, then float-specific;
both were artifacts of reading a partially-written results file. The
static-vs-variant split is the axis that actually reproduces.
Fix shape
Give the static path the general rule the variant path has: at binop typing,
reject operand-kind pairs that Python does not define for that operator, as a
genuine runtime TypeError (a PyNotOrderableError-style pylib raise, so
try/except still compiles), rather than falling through to numeric lowering.
Do NOT simply widen IRPyNumStrClash — it answers "str vs number", and the
table above needs container-vs-number, container-vs-container and str-vs-str
too. Deriving the allowed pairs per operator from CPython's own table is the
approach least likely to leave a neighbour wrong.
Sequencing note: +/*/**/==/!= currently work and carry real semantics
(list concat, str repeat, pathlib /). Land per-operator, and keep the
PyRecIsPylibOwnClass exclusion (compiler/symtab.inc) in mind — pylib's own
containers must keep their behaviour.
Separate finding from the same sweep — bool forces UNSIGNED arithmetic
Both operands static scalars, no container involved:
| expression | CPython | pxx |
|---|---|---|
True // -7 |
-1 |
18446744073709551615 (2⁶⁴−1) |
True % -7 |
-6 |
18446744073709551610 |
7 // -7 and 7 % -7 are correct, so the result type is taken from the bool's
own unsigned type-kind instead of being promoted to a signed integer. In Python
bool is a subclass of int, so it should promote. Likely independent of the
guard hole above (it needs no type MISmatch at all) — if it survives that fix it
wants its own ticket.
Gate
make test-nilpy + self-host byte-identical, plus the tables above added as
STATICALLY-typed operands (literals and single-type locals) — not via a
heterogeneous list, which is what hid this. Keep the existing variant-typed
cases as-is so both paths are covered. Diff against CPython's own output, and
re-run the operator×operand sweep afterwards rather than only the listed rows.
2026-08-01 — the static path is not ONE path: two different typings, measured
Reading PyWiden (compiler/pyparser.inc) suggested a single root, and
measuring killed that idea. Recorded because it would otherwise be re-derived.
PyWiden(a, b) ends with a rule widening any class-meets-scalar pair to
tyVariant. That rule is right for the job it was written for — unifying the
possible types of one LOCAL across rebindings (temp_file = 'tmp.pdf' then
temp_file = NamedTemporaryFile(...), which Python allows). It is meaningless
as a binop RESULT type, and PyWiden(tyClass, tyInteger) → tyVariant is
exactly what made obj & 1 SEGFAULT: the handle was stored through the variant
path and dereferenced as a variant record
([[bug-nilpy-bitwise-shift-on-class-operand-segfaults]], now fixed by
dispatching before the widening is reached).
But that is NOT the mechanism for the operators in this ticket. Measured
with PXXDBG=a.ir:g on y = 7 - xs:
47: binop a=45 b=46 c=71 ival=0 tk=1 <- tk=1 is tyInteger, not tyVariant
So - types its result as a plain integer and does pointer arithmetic in
registers; it never reaches PyWiden at all. The bitwise operators call
PyWiden explicitly from pyparser.inc; + - * / and the comparisons are
typed by the shared parser.inc expression chain.
Consequence for the fix: there is no single entry point to guard. The
per-operator legality check has to be added where each family is typed — the
parser.inc binop typing for arithmetic and comparison, and (already handled)
the pyparser.inc bitwise routines. Anyone starting from "just fix PyWiden"
will fix the bitwise family only and conclude the rest is unrelated.
2026-08-01 — a self-inflicted regression here, caught by re-sweeping
Landing the dunder work introduced, then removed, a case of THIS bug.
Adding not PyRecIsPylibOwnClass(...) to ParseTerm's dunder branch condition
excluded pylib's containers from the branch entirely — so [1,2] / 3 no
longer reached the branch's error path and fell through to the generic
arithmetic, silently computing on the list handle. It had previously produced a
loud (if compile-time) error. One operator per list_* group, ~9 cases: the
sweep's TypeError-family count went 108 → 117 while everything else improved.
Fixed by moving the exclusion from the branch CONDITION into the branch BODY:
pylib rows still skip the dunder lookup, but they now fall to the runtime
TypeError rather than out of the branch. [1,2] / 3 raises;
[1,2] * 3 still repeats (an earlier branch claims it).
The general lesson for whoever fixes this ticket: a container operand that reaches an arithmetic branch with no earlier branch having claimed it has NO Python meaning for that operator — the correct answer is the raise, not a fall-through. Excluding pylib rows from a dispatch branch is right for the dunder lookup and wrong for the error path, and the two are easy to conflate because one guard sits in the condition of both.
Only visible by re-running the sweep and comparing group counts against the
previous run — the per-fix tests all passed, because none of them covered
list / int.
2026-08-01 — FIXED for DIFFERING operand kinds; same-kind residue recorded
The static path now gets the general rule the variant path has, emitted as a
genuine runtime TypeError via pylib's PyUnsupportedOperandError — so
try/except TypeError still compiles and runs its handler, per the fix shape
this ticket asked for.
compiler/ir.inc:
IRPyOperandKind(n)— the Python kind of a statically typed operand: 1 number (int/bool/float), 2 str, 3 list/tuple, 4 dict, 5 bytes, and 0 = not confidently classifiable, which always means "leave today's behaviour". Variants and user classes are 0: a user class may define__sub__/__lt__, and the variant path already raises correctly.IRPyStaticPairUndefined(node)— fires for-///%<<=>>=when both kinds are known and DIFFER, with"fmt" % argsexempt (string formatting, the same carve-outIRPyNumStrClashmakes).- the
AN_BINOPlowering raises instead of computing when it fires.
Why differing-kinds only, and NOT the whole table
Deliberate, and measured rather than assumed: pxx backs a Python set with
TPyList, and
set([1, 2, 3]) - set([2]) # -> [1, 3] — WORKS today
so a blanket "list minus list is undefined" rule would break set difference, a real feature, for a real user. Since sets and lists share a row there is no static way to tell them apart, so same-kind pairs are left alone.
Residue, still silently computing (same-kind, not covered here):
"ab" - "ab" → 0, "ab" / "ab", "ab" // "ab", [1] // [2], [1] % [2].
Fixing those needs either a set-vs-list distinction in the type system or a
runtime kind check on the same-kind path; worth its own ticket rather than a
guess bolted on here.
Verified
test/test_nilpy_static_mixed_type_guard.npy, wired into make test-nilpy,
byte-identical to CPython.
The first version of this test was VACUOUS and was rewritten. It wrapped
each expression in lambda: 3 - [1,2] — and passed on the PRE-FIX binary,
because the lambda routes its operands through the variant path, which always
raised correctly. That is precisely the trap this ticket documents
test_nilpy_mixed_type_operands falling into, reproduced while writing its
replacement. The landed version uses direct literals in straight-line
try/except, and is confirmed RED pre-fix:
int-list 1331691499 int/list 0.000000000000021
int//list 0 int%list 3
int<list True int>=list False
str-list 1337101271
(str<int, list//int, list%int already raised before this change.)
Also confirmed unbroken: set difference, [1,2]*2, "ab"*2, "%d" % 5,
ordinary numeric arithmetic, list/bytes/str ordering, + on lists and strs,
dict equality, and pathlib's Path("a") / "b" (a user class, kind 0).
Native: build + byte-identical self-host fixedpoint, testmgr --tier quick GREEN.
Log
- 2026-08-01 — resolved, commit PENDING.