← board

A nested comprehension over range() runs once, and every row is the same list

Measured (before, self-hosted at d3e8bd796; identical on pinned)

print([[i * 3 + j for j in range(3)] for i in range(3)])
# CPython [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
# pxx     [[0, 1, 2], [0, 1, 2], [0, 1, 2]]

print([[i for j in range(2)] for i in range(3)])
# CPython [[0, 0], [1, 1], [2, 2]]
# pxx     [[0, 0], [0, 0], [0, 0]]

The rows are not merely equal, they are the SAME OBJECT:

g = [[i for j in range(2)] for i in range(3)]
g[0][0] = 99
print(g)
# CPython [[99, 0], [1, 1], [2, 2]]
# pxx     [[99, 0], [99, 0], [99, 0]]

Cross-tabulating the two iterable kinds localised it exactly — only the OUTER one matters, and the inner one is irrelevant:

outer inner verdict
range() range() WRONG
range() container WRONG
container range() correct
container container correct

A single-level range comprehension ([i * 10 for i in range(3)]) was always correct, which rules out the counted-loop lowering itself.

Cause

An inner comprehension in the element expression hoists its own build loop onto PyHoistHead. The CONTAINER path (PyParseForIn) ends up draining that per-iteration; the range path drained nothing after parsing its element expression, so the inner build stayed hoisted to the enclosing statement and was emitted BEFORE the outer loop. It therefore ran exactly once, with the outer loop variable still at its initial value, and the outer loop appended the resulting list handle on every iteration — hence both the frozen value and the aliasing, from one cause.

This is the residual of [[feature-nilpy-nested-comprehension]], whose own notes identified hoisting as the hazard: "The inner comprehension's own setup goes through PyHoistStmt, which pushes statements out to the enclosing STATEMENT, i.e. in front of the outer loop." That ticket fixed the undefined variable error the hoist caused on the container path; the range path was left silently wrong, which is the worse half and is why it outlived the fix.

Fix

In the range path's comprehension branch (compiler/pyparser.inc), after building the append/store body from the element expression:

    bodyNode := PySeqAppend(PyFlushHoist(-1), bodyNode);

— drain the pending hoist INTO the loop body, ahead of the append that consumes its result. One line, mirroring what the container path already achieves.

Verified

test/test_nilpy_nested_comprehension_over_range.npy (new, wired into make test-nilpy) covers: outer-range/inner-range, outer-range/inner-container, both container-outer controls, the two aliasing cases, single-level range comprehensions, and a real build/transpose/multiply over a 3x3 matrix. All 14 lines match CPython. The wider probe corpus is unchanged and tools/gate.sh quick is GREEN.

Log