179. Shape inference in Rust
A shape is a list of field names, and Rust compares lists exactly, which is how the port caught the reference treating one field called `a,b` as two. Then the other half of a shape: what each field may hold, where Python’s ints and floats need rules JavaScript never did.
- Read set_shape in analysis.rs and explain the exact field-list comparison and the comma bug it replaced.
- Explain why set_shape can hold the record’s fields while changing the collection.
- Say how a field earns a typed column, in JavaScript and in Python.
- Apply Python’s int and float rules for `/`, `**` and mixed arithmetic.
One field or two?
This is comma_field_name.js, a program in the reference’s corpus:
const ps = [];
for (let i = 0; i < 100; i++) {
ps.push({ 'a,b': i });
ps.push({ a: i, b: 2 });
}
(Rewrapped: the real file has the loop on one line.) The first record
has one field, named a,b. The second has two. They are different
shapes, so ps must stay an array of objects. Both engines now say so
(real output of the Rust CLI, wrapped):
ps (line 3) {a, b} → kept as array of
objects
· unsafe: line 4: records with
different fields or field order
({a,b} vs {a, b})
The reference used to get it wrong. It compared shapes by their field
names joined into one string, and ["a,b"] and ["a", "b"] join to
the same thing. It is bug 3 in the port’s README, found during porting,
now fixed in both engines and pinned by that corpus program. Notice the
message still prints the one field without quotes: {a,b}.
set_shape
The Rust version, trimmed:
fn set_shape(&mut self, ci: usize,
r: ValueId, location: &str) {
let Op::RecordNew { fields } =
&self.inst(r).op
else { unreachable!() };
let aliased =
self.uses.of(r).len() != 1;
let at = self.inst(r).src;
let c = &mut self.collections[ci];
if c.fields.is_empty() {
c.fields = fields.clone();
} else if c.fields != *fields {
// bail: "records with different
// fields or field order (...)"
}
if aliased {
// bail: "...could be aliased"
}
}
c.fields != *fields compares two Vec<String>s: same length, then
each name in order. Nothing gets joined, so no name can run into
another. The fixed TypeScript compares JSON.stringify of both
arrays, which quotes every name. Two engines, two spellings, one
answer, and the parity fixtures check that it stays one answer.
The second check is chapter 96’s: the new record must have exactly one
use, the push. A second use, such as r.x = 500, would still expect
an object r after the rewrite.
Holding the fields, changing the collection
fields is borrowed from an instruction, and two lines later the
function takes &mut self.collections[ci]. That only compiles because
of chapter 178’s 'm: inst returns &'m Inst, a borrow of the
module, which self merely points to. Give the accessor the elided
lifetime, tied to &self, and rustc 1.97 says (a small copy, trimmed
and rewrapped):
error[E0502]: cannot borrow `self.cols`
as mutable because it is also
borrowed as immutable
| let fields = self.rec(r);
| ---- immutable borrow
| let c = &mut self.cols[ci];
| ^^^^^^^^^ mutable borrow
| c.fields = fields.clone();
| ------ immutable
| borrow later used
The runnable copy has the 'm in place. Delete it and see; it also
prints what joining does to ["a,b"] and ["a", "b"]:
struct Module {
records: Vec<Vec<String>>,
}
struct Col {
fields: Vec<String>,
bails: Vec<String>,
}
struct An<'m> {
m: &'m Module,
cols: Vec<Col>,
}
impl<'m> An<'m> {
// Try: -> &Vec<String> (no 'm).
fn rec(
&self,
r: usize,
) -> &'m Vec<String> {
&self.m.records[r]
}
fn set_shape(
&mut self,
ci: usize,
r: usize,
) {
let fields = self.rec(r);
let c = &mut self.cols[ci];
if c.fields.is_empty() {
c.fields = fields.clone();
} else if c.fields != *fields {
let m = "different fields";
c.bails.push(m.into());
}
}
}
fn main() {
let s = |x: &str| x.to_string();
let one = vec![s("a,b")];
let two = vec![s("a"), s("b")];
println!(
"joined equal: {}",
one.join(",") == two.join(",")
);
let m = Module {
records: vec![two, one],
};
let col = Col {
fields: vec![],
bails: vec![],
};
let mut an = An {
m: &m,
cols: vec![col],
};
an.set_shape(0, 0);
an.set_shape(0, 1);
println!("{:?}", an.cols[0].bails);
}
What a field may hold
A shelf of egg boxes is only useful if nothing but eggs goes in. One cupcake and the boxes are just boxes again. A typed column is the egg box: one value that isn’t a number, anywhere, and the field gets a plain column instead.
A shape is the field names plus what each field may hold (chapter 97).
The second half comes from chapter 177’s fixpoint. Each field of each
collection is a node, starting optimistic. field_stays_numeric keeps
it only if every record added puts a provable number in that position
and every field.set on an element writes one. Fields that survive go
into numeric_fields and get Float64Array columns; the rest get
plain arrays.
In JavaScript every number is a double, so the arithmetic rule is
short: an operator from ARITH is a number if both sides are. Python
is different.
The shape pass in Rust is set_shape plus the numeric facts. The
first record a collection receives fixes its field list. Every later
record must have exactly the same Vec<String> and exactly one use.
Each field is then typed by the fixpoint: in JavaScript, numeric if
every value stored is a JS number; in Python, if every value stored is
a float, with ints tracked as a separate fact.
Python: ints are not floats
A Python column becomes array('d'), an array of C doubles that only
holds floats[1]. An int goes in, but a float
comes out. With Python 3.13.18, here:
a = array('d', [0.0])
a[0] = 1
print(a[0], type(a[0]).__name__)
print(7 / 2, 6 / 2, 7 // 2)
print(2 ** 3, 2 ** -1, 2.0 ** 3)
print((-1.0) ** 0.5)
1.0 float
3.5 3.0 3
8 0.5 8.0
(6.123233995736766e-17+1j)
So in Python “numeric” must mean “provably a float”, or a program that
printed 1 would print 1.0. Ints get their own fact (compute_int),
because they still matter: an int times a float is a float. True
division always gives a float[2]. And
** is treacherous: an int to an int power can be an int or a float,
and a float to a float power can be complex. The float rule for a
binary operator, trimmed from compute:
match operator.as_str() {
"/" => n_or_i(a) && n_or_i(b),
// (-1.0) ** 0.5 is 1j
"**" => n(a) && self.i(b),
"+" | "-" | "*" | "%" | "//" =>
(n(a) && n_or_i(b))
|| (self.i(a) && n(b)),
_ => false,
}
n is “a float”, i is “an int”. The analyzer decides which rules
apply once, py: m.language == "python", as the reference does. Watch
them type a real corpus program, ints_stay_ints.py:
%6 = phi %4, %14 # i%10 = binop / %6, %9 # i / 2%11 = record {count, weight} %6, %10%22 = field.get %21 .count%24 = binop + %22, %23 # + 1field.set %21, %24 .count%28 = field.get %27 .weight%30 = binop * %28, %29 # * 2field.set %27, %30 .weight
The README lists the Python rules where the Rust core lagged the
reference, now ported: ** gives a float only with an int exponent,
and the Python wording of the list-literal and append bailouts. The
corpus program power_complex.py pins the first: its only field is
written with p.x ** 0.5, so no field is provably numeric and the
collection is kept (“no field is provably numeric, so there are no
typed columns to gain”). The fuzzer generates JavaScript only, so for
Python the corpus and the differential runs with python3 carry the
weight.
Your turn
Write py_binop, the Python half of compute and compute_int in analysis.rs for a binary operator. The result is an int when the operator is + - * % // and both sides are ints. It is a float when: the operator is / and both sides are numbers of either kind; or it is **, the base is a float and the exponent an int; or it is one of + - * % // and one side is a float while the other is a number. Anything else (comparisons, say) is neither.
// What the fixpoint knows about a
// Python value: provably a float,
// provably an int, or neither.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct K {
pub float: bool,
pub int: bool,
}
// Is it a number of either kind?
fn num(k: K) -> bool {
k.float || k.int
}
pub fn py_binop(
op: &str,
a: K,
b: K,
) -> K {
todo!()
}Finish the comparison in set_shape: a collection takes the fields of its first record; a later record whose field list differs, in names or order, is a bailout. (The test names show the case that fooled the reference.)
pub struct Col {
pub fields: Vec<String>,
pub bails: Vec<String>,
}
fn list(fs: &[String]) -> String {
format!("{{{}}}", fs.join(", "))
}
// A record with fields fs and n uses
// joins collection c.
pub fn set_shape(
c: &mut Col,
fs: &[String],
n: usize,
) {
let differs =
todo!();
if c.fields.is_empty() {
c.fields = fs.to_vec();
} else if differs {
let m = format!(
"different: {} vs {}",
list(fs),
list(&c.fields)
);
c.bails.push(m);
}
if n != 1 {
c.bails.push("aliased".into());
}
}What you learned
- The shape pass in Rust:
set_shapecompares field lists asVec<String>s, exactly. Comparing joined names let{'a,b'}pass for{a, b}; the port found it. - The record must have exactly one use, or it could be aliased.
instreturns&'m Inst, soset_shapecan hold the fields while it changesself.collections(E0502 otherwise).- A field is typed if every value stored in it is a number. In Python
that means a float:
/always gives one,**only for a float base and an int exponent, and an int anywhere else keeps a list column.
Check your understanding
0/6- 1.
Two pushes into
ps:{ 'a,b': i }and{ a: i, b: 2 }. What does the fixed engine decide? - 2.
In
set_shape, the record’s field list is borrowed while&mut self.collections[ci]is taken. Why does that compile? - 3.
In Python,
cs.append(Cell(i, i / 2))withifromrange(300), and latercs[i].count += 1. Which columns does Viscosity choose? - 4.
Why is
x ** 0.5(withxa float) not provably a float in Python? - 5.
A record is pushed and then also changed through its variable:
const r = { x: i }; ps.push(r); r.x = 5;. What does the shape pass report? - 6.
For JavaScript, when is a field numeric?
References
- [1] Python Software Foundation. array — Efficient arrays of numeric values. Python 3.13 documentation (The Python Standard Library). docs.python.org/3.13/library/array.html (checked 2026-10-31)
- [2] 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)