Viscosity
Review
Part XI · Viscosity in Rust · Day 45

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.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • 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"]:

Try itExact field lists, and a borrow tied to the module
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);
}
Open in Rust Playground ↗

What a field may hold

Intuition

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.

Definition

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:

Step throughInts, floats and a typed column
%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 # + 1
field.set %21, %24 .count
%28 = field.get %27 .weight
%30 = binop * %28, %29 # * 2
field.set %27, %30 .weight
The int and float facts of a few values, and the column each field getsFACTS%6intCOLUMNScountfloat? (optimistic)weightfloat? (optimistic)
i counts from the int 0 by the int 1. compute_int: + of two ints is an int, and the phi of ints is an int. compute: no float involved, so %6 is not “numeric” (in Python that means float).
1/6
Under the hood

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

ExercisePython’s int and float rules

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!()
}
Open in Rust Playground ↗
Extra practiceset_shape, exactly

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());
    }
}
Open in Rust Playground ↗

What you learned

  • The shape pass in Rust: set_shape compares field lists as Vec<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.
  • inst returns &'m Inst, so set_shape can hold the fields while it changes self.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. 1.

    Two pushes into ps: { 'a,b': i } and { a: i, b: 2 }. What does the fixed engine decide?

  2. 2.

    In set_shape, the record’s field list is borrowed while &mut self.collections[ci] is taken. Why does that compile?

  3. 3.

    In Python, cs.append(Cell(i, i / 2)) with i from range(300), and later cs[i].count += 1. Which columns does Viscosity choose?

  4. 4.

    Why is x ** 0.5 (with x a float) not provably a float in Python?

  5. 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. 6.

    For JavaScript, when is a field numeric?

References

  1. [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. [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)