← board

Hoist a constant container literal out of a loop condition

The principle behind it — normalise the special shape into the general one so downstream has a single path — is written up in devdocs/dev/normalise-dont-special-case.md, which this ticket is the motivating instance of. Read that before implementing; it also states the safe-direction rule the predicate below has to obey.

Where this comes from

A while condition's hoisted setup used to be emitted once, before the loop. That was right for a constant literal and wrong for a string method call, which hoists identically and is not loop-invariant — the condition went stale and the loop spun. The fix folds each sub-expression's setup into the sub-expression, so everything is now recomputed per test.

Correct, and for a constant it is needless work:

while x in ("a", "b"):    # the tuple is rebuilt on every test
    ...

CPython rebuilds it too, so this is not a divergence — just an allocation per iteration that a hand-written program would not pay, because a person would write AB = ("a", "b") above the loop.

What to build

Split the hoisted chain at the point it is folded (PyFoldHoistSince, compiler/pyparser.inc): a statement that is provably a constant container build stays hoisted OUTSIDE the loop as before; everything else keeps being folded in.

The predicate is the whole risk, and it must be conservative in the SAFE direction — fold unless proven constant, never the reverse. A container literal hoists as __py_lit* := TPyList.Create followed by N append / setitem calls, so "provably constant" means: the assigned symbol is one of those hidden literal temps, and every argument of every call in the chain is a literal node (AN_INT_LIT / AN_STR_LIT / bool / None), recursively. Anything else — a name, a call, a subscript — fails the test and gets folded.

Getting that backwards reinstates exactly the bug above, silently. That is why it was not done inline with the fix.

Worth checking while in there

Whether the same split is wanted for if conditions and comprehension filters, which share the hoist machinery. If it is, do them together — the last several bugs in this family were all "one path was fixed and its sibling was not".

Gate

Per-fix loop. Extend test/test_nilpy_while_condition_hoist.npy: the constant tuple/dict conditions must keep their values (already covered), and add a NON-constant container in a loop condition (while x in (a, b) over variables, and while x in (f(), g())) to prove those are still folded and still see fresh values. Diff against CPython with tools/pydiff.py.