115. Lowering Python to VIR
A Python loop and a JavaScript loop can look alike and mean different things. Lowering is where those differences have to be settled, because after it the analyses see only VIR and believe it completely.
- Lower Python’s counting loops, element loops, dataclass calls and `append` to VIR.
- Explain why `for i in range(n)` needs a hidden counter, and what that buys the bounds proofs.
- Say why “numeric” means “provably a float” for Python, and how ints are tracked.
- Spot a lowering that copies the spelling of Python but not its meaning.
Two loops that look alike
Run these two programs:
for (let i = 0; i < 3; i++) {
console.log(i);
i += 5;
}
for i in range(3):
print(i)
i += 5
JavaScript prints 0 and stops: i += 5 changed the loop variable, so
the next test i < 3 fails. Python prints 0, 1, 2. Its for
hands out the next value from range(3) each time, whatever the body
did to i.
Same shape, same words, different meaning. If the Python plugin lowered
this for the way the JavaScript plugin lowers its for, every
analysis after it would reason about a loop that Python doesn’t run.
The analyses can’t catch that. They only ever see VIR.
Languages have false friends: words that look the same in two languages but mean different things. The Spanish embarazada means pregnant, not embarrassed. A translator who goes by spelling gets it wrong in a way nobody reading the translation can detect. Lowering is translation, and Python and JavaScript have plenty of false friends.
Semantic differences are the places where two languages give different meanings to code that looks the same: loops, numbers, indexing, comparisons, scope. A front-end for a multi-language IR must lower each construct by what it means in its own language, so that the shared analyses can trust the IR completely.
Line by line
Here is the plugin at work on a program you have seen before. Step through it:
ps = []for i in range(1000):ps.append(P(float(i), 1.0))for p in ps:p.x += p.vx
Most of it is the JavaScript lowering again, which is the point of a
multi-language IR: append is push, len(ps) is a length read,
p.x is field.get. The interesting lines are the ones where the two
plugins had to differ.
A hidden counter
This is counting() from lower.ts, simplified. It handles
for i in range(a, b):
// before the loop: start and bound,
// both evaluated exactly once
const key = `range${s.start}`;
writeVariable(key, start);
// header: test the hidden counter
const i = readVariable(key);
condbr(binop('<', i, bound),
body, exit);
// body: copy the counter into i
write(target, readVariable(key));
stmts(body);
// continue: bump the hidden counter
writeVariable(key,
binop('+', readVariable(key), 1));
The loop variable and the counter are two different variables. key
is named after the loop’s position in the file, so no Python name can
clash with it. The body may do anything to i; the next pass copies a
fresh value from the counter. That is exactly Python’s meaning, and it
is better for the analyses: the counter is a clean induction variable,
so a bounds proof about it holds even if the body reassigns i. In
this test program the body does i += 50 and then indexes again:
for i in range(len(ps)):
ps[i].x += 1.0
i += 50
if i < len(ps):
ps[i].x += 0.5
The report says 1 of 3 element accesses proven in bounds: the first
ps[i] is proven, the one after i += 50 gets a check (as does a
final print(ps[60].x)), and the
compiled program prints exactly what the original does.
for p in ps goes the other way. It reads the length in the loop
header on every pass, because a Python list iterator notices elements
appended during the loop. The corpus file append_while_iterating.py
appends inside such a loop and checks both versions agree.
An int is not a float
In JavaScript, 1 and 1.0 are the same value. In Python they are two
types: 1 is an int, 1.0 a float. Dividing two ints with /
gives a float, but // keeps an int[1].
That matters to Viscosity because a typed column, array('d'), holds
C doubles. Put an int in, and a float comes out: 1 would print as
1.0.
So the lowering keeps the difference (each const carries a note,
int or float), and the numeric inference in the core asks the
language. For Python it computes two facts per value, “is a float” (n)
and “is an int” (I):
case 'const':
return i.note === 'float';
case 'binop':
if (op === '/') // int / int: float
return nOrI(a) && nOrI(b);
// ** may be complex: float ** int only
if (op === '**') return n(a) && I(b);
// + - * % //: float if a float
// meets a float or an int
return (n(a) && nOrI(b)) ||
(I(a) && n(b));
A field gets a typed column only if every value stored in it is
provably a float. Here is ints_stay_ints.py, whose count field holds
i and weight holds i / 2:
cs (line 9) {count, weight} → struct of
arrays
· typed array('d') columns for weight;
list columns for count (may hold
non-floats)
The int fact has a second use: an index must be an int. ps[k] with
k = 1.5 is a TypeError in Python, so the report says
indexes the array with something that may not be an int and keeps
the list.
Records are dataclass calls
JavaScript has object literals. Python has classes, and the closest
thing to a record is a dataclass: a class whose @dataclass decorator
generates an __init__ that stores each annotated field, in order
[2]. So the plugin first collects the
module-level dataclasses whose bodies are only annotated fields, then
lowers P(...) to record.new with the fields in declaration order.
Arguments are matched the way Python matches them: positionals first,
then keywords, then defaults. In the corpus file
keywords_defaults.py, Body(i * 1.0, y=1.5), with m defaulting to
2.0, becomes a record of i * 1.0, 1.5 and 2.0. Keywords in a different
order from the fields, like P(y=1.0, x=v), are not lowered to a
record at all. Python evaluates arguments left to right, and putting
them into field order would change that order, so the plugin refuses
rather than guess. The report then says the list holds something that
isn’t a record.
Defaults are a false friend, and the plugin got them wrong at first
(found while writing this chapter; since fixed). Python evaluates a
field’s default once, when the class is defined. The lowering copied
the default’s expression to every call site. With y: float = K and a
later K = 3.0, Python’s records got 2.0 but the compiled program
stored 3.0. The analysis agreed with the compiled code, so no check
noticed. Now only literal defaults like 2.0 are modelled; a class
with any other default isn’t treated as a record. A second one: the
numeric rule once said float ** float is a float, but (-1.0) ** 0.5
is a complex number in Python, which an array('d') refuses with a
TypeError. Now ** is a float only with an int exponent. Both are
exactly the kind of bug a semantic difference produces: invisible to
the shared core, caught only by comparing real runs. The corpus
programs nonliteral_default.py and power_complex.py keep them
fixed.
Scope, briefly
Python has no block scope: a name assigned anywhere in a function
belongs to the whole function, and a loop variable is still there after
the loop. So the plugin collects names per function, not per block. A
name becomes a VIR binding (rather than an SSA value) if another
function touches it, if a global statement names it, or if it is ever
bound to a list. Everything else is SSA, built by the same shared
builder as JavaScript’s.
Notice where the knowledge lives. The tokenizer and parser know Python’s
spelling. The lowering knows its meaning: the hidden counter, the
dataclass argument rules, the int and float notes. The core knows almost
nothing about Python, only the few facts it can’t do without (what
fits in a typed column, what counts as an index). Every one of the bugs
above is in the meaning layer, which is why the plugin’s differential
test runs both programs with the real python3 and compares their
output and exceptions.
Your turn
Write pyKind(e), Python’s number rule from the core’s numeric inference. A constant is { k: 'int' }, { k: 'float' } or { k: 'str' }. An operation is { op, a, b } with op one of + - * / //. Return 'int', 'float' or 'other': a string is other, and so is any operation on an other. / of two numbers is always a float. The other operators give an int when both sides are ints, and a float when either side is a float.
function pyKind(e) {
return 'float';
}Write pyIndex(xs, i), which reads xs[i] with Python’s rules, on a JavaScript array. A non-integer i throws a TypeError with message 'list indices must be integers'. A negative i counts from the end (-1 is the last element). Anything outside -len ≤ i < len throws an Error with message 'list index out of range'. JavaScript would quietly return undefined in both cases.
function pyIndex(xs, i) {
return xs[i];
}What you learned
- Semantic differences are where similar-looking code means different things. Lowering must translate meaning, because the shared analyses trust VIR completely.
for i in range(...)uses a hidden counter and a bound evaluated once.for p in xsre-reads the length on every pass.- For Python, “numeric” means provably a float; ints are tracked separately, and an index must be an int.
- A dataclass call becomes
record.newwith fields in order; keywords out of order are refused. Copying a default’s expression is a semantic bug.
Check your understanding
0/6- 1.
In VIR for
for i in range(1000):, where is the bound 1000 computed? - 2.
A Python loop
for i in range(len(ps)):readsps[i].x, then doesi = 0. What happens to the bounds proof forps[i]at the top of the body? - 3.
A field holds
ifor every element, whereicomes fromrange. What column does it get? - 4.
Which expression does Python’s rule (as in the core) classify as a float?
- 5.
What does
P(y=1.0, x=v)lower to, for a dataclass with fieldsx, y? - 6.
Why can’t the shared core catch a lowering that copies Python’s spelling but not its meaning?
References
- [1] Python Software Foundation. The Python Language Reference: 6. Expressions. Python 3.13 documentation. docs.python.org/3.13/reference/expressions.html (checked 2026-10-31)
- [2] Python Software Foundation. dataclasses — Data Classes. Python 3.13 documentation (The Python Standard Library). docs.python.org/3.13/library/dataclasses.html (checked 2026-10-31)