Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

06 - Units of measure

Units of measure are Pyfun’s clearest demonstration of the gatekeeper model: the compiler does full dimensional analysis, then throws all of it away before emitting Python. The rule is in DESIGN.md §8.2; the code lives alongside HM inference in src/types/mod.rs, in the same file as the type checker rather than a separate sub-module, because unit unification is part of unification itself.

Units as a free abelian group

A unit is a product of base measures and unit variables raised to integer powers, with a dimensionless identity. That is exactly a free abelian group, and the representation follows directly:

#![allow(unused)]
fn main() {
// src/types/mod.rs
pub struct Unit {
    factors: BTreeMap<Atom, i32>,
}
}

Atom is either Base(String) (a declared measure like m) or Var(u32) (a unit variable the checker will solve for). The map never stores a zero exponent, so m^0 is not present and two equal units have identical maps. The group operations are the obvious ones over that map: mul adds exponents key by key, inv negates them, and pow(k) multiplies each by k. If you are new to Rust, note that BTreeMap keeps the factors in a stable sorted order, which is what lets equality and display stay deterministic without any extra sorting step.

Unit unification is not syntactic

Ordinary Hindley-Milner unification matches type constructors structurally. Units cannot work that way: m/s and s^-1 m are the same element, and solving 'u^2 ~ m^2 has to discover 'u = m. So unit equality is solved as a group equation. unify_unit reduces a ~ b to a / b ~ 1 and hands it to solve_unit, which runs Knuth/Kennedy variable elimination:

#![allow(unused)]
fn main() {
// src/types/mod.rs
fn unify_unit(&mut self, a: &Unit, b: &Unit) -> bool {
    let eq = self.apply_unit(a).div(&self.apply_unit(b));
    self.solve_unit(eq)
}
}

solve_unit picks the unit variable with the smallest absolute exponent as the pivot. If that exponent divides every other exponent, the variable is solved outright (v = product of the rest). Otherwise it substitutes a fresh variable to shrink the exponents and recurses. When only base measures remain with non-zero exponents there is no variable to pivot on, and the equation is a genuine dimension mismatch, which is exactly the <m> + <s> case below.

Derived-measure aliases expand to base units

measure N = kg m / s^2 is stored expanded. resolve_unit_against looks each name up in decls.measure_aliases and substitutes its base-unit body, so <N> and <kg m / s^2> become the same Unit value and unify freely. The tradeoff (DESIGN §8.2) is that inferred types display the expanded form, since there is no back-mapping from base units to an alias name.

Unit-aware sqrt halves exponents for free

sqrt has the prelude scheme float<'u^2> -> float<'u>, seeded with Unit::var(...).pow(2). Nothing special-cases roots in the unifier: applying sqrt to float<m^2> asks it to solve 'u^2 ~ m^2, and the elimination step halves the even exponent to give 'u = m. An odd exponent has no integer solution, so sqrt of a non-square unit is rejected with a dimension mismatch. In the running example:

let side = sqrt 16.0<m^2>

infers float<m>, and let norm x = sqrt (x * x) stays unit-polymorphic float<'u> -> float<'u>. cbrt is the identical mechanism with pow(3).

Erasure: a mismatch is caught, a clean program keeps no unit

The mismatch is a real compile error. pyfun check on 3.0<m> + 4.0<s>:

error: type mismatch: expected float<m>, found float<s>
 --> 4:11
  |
4 | let bad = 3.0<m> + 4.0<s>
  |           ^^^^^^^^^^^^^^^

1 error

Once a program checks, units vanish. pyfun compile on the running example lowers the sqrt 16.0<m^2> line to plain arithmetic, with no unit anywhere in the output:

side = math.sqrt(16.0)

The <m^2> annotation, the <m> result type, and every other dimension are gone. Nothing in the emitted module can tell you the program ever had units, which is the whole point: maximum static safety, zero runtime residue.

Where you would add a built-in measure or root

To seed a new prelude unit operation (say a fourth root), you add its scheme in seed_prelude in src/types/mod.rs using Unit::var(PRELUDE_UVAR).pow(n), and its name/arity in types::PRELUDE plus a lowering route in lower_var (src/lowering/mod.rs). The unifier needs no change: exponent division already handles the new power. DESIGN §8.2 explains why the built-in root family deliberately stops at sqrt and cbrt.