Fixing Generics and Adding Verification

· 6 min read
Building Hew: Part 15 of 16

Three bugs this week had the same shape: the compiler did something wrong, and the only way to find it was reading raw MLIR dumps. So I spent a day building tools to make those dumps readable. (Probably should have done this weeks ago.)

The monomorphization collision

Generic structs in Hew are monomorphized — each concrete instantiation produces a separate MLIR struct type. Pair<int> becomes one struct, Pair<float> becomes another. Except when it doesn’t. (Spoiler: it wasn’t doing that.)

The bug was that the second specialization reused the first’s struct type. Create Pair<int> and then Pair<float>, and the float pair silently used the integer struct layout. Field access on the float pair would load an i64 from a slot that held an f64. No crash — just the wrong value, printed with full confidence:

type Pair<T> {
    first: T;
    second: T;
}

fn main() {
    let a = Pair { first: 1, second: 2 };         // Pair<int>
    let b = Pair { first: 1.0, second: 2.0 };     // Pair<float>
    println(a.first);
    println(b.first);   // printed int, not float
}

Root cause was in convertType(), which had two paths for resolving generic struct types — one for explicit type arguments (Pair<int>) and one for implicit substitutions (when a generic function instantiates Pair<T> with T→int). Both paths needed to mangle the struct name with the concrete type arguments so MLIR would create distinct types. The first path did. The second didn’t.

// Before: both specializations shared one MLIR struct
//   Pair { i64, i64 }    ← first specialization wins
//
// After: each gets a mangled name
//   Pair_int   { i64, i64 }
//   Pair_float { f64, f64 }

The fix unified both paths: resolve concrete type argument names, mangle the struct name (Pair_int, Pair_float), look up or create the MLIR struct type by that mangled name. generateStructInit got the same treatment. The type checker also improved — better validation of generic struct instantiation, better error messages when type parameter counts don’t match.

Two new E2E tests exercise exactly this: Entry<int, float> alongside Entry<float, int>, verifying each produces the right field types.

Asking the type checker

The C++ codegen had been tracking types through two mechanisms: string-based variable maps (populated during declarations) and the type checker’s resolved types (computed during analysis but never actually consumed by codegen). (Why compute them if you’re not going to use them? I don’t know.) I wired up the second mechanism as the primary path:

// Without resolvedTypeOf():
//   codegen sees vec.push(x) and guesses Vec<???>
//   from string-based variable tracking maps
//
// With resolvedTypeOf():
//   codegen asks the type checker: "what is vec?"
//   type checker answers: Vec<String>
//   codegen emits the correct hew.vec.push specialization

The change touched method call dispatch for collections, actors, handles, and trait objects. Each dispatch site now tries resolvedTypeOf() first — asking the type checker — and falls back to string maps only when the type checker has no answer. This matters most for method chains: vec.iter().map((x) => x * 2).collect() produces intermediate types that only the type checker can resolve. The string maps never knew about them.

I extended the same approach to for-loops and indexed assignment. A for item in collection loop needs to know the element type of the collection to emit the right iteration code. Previously it guessed, but now it asks the type checker directly.

Verification before lowering

MLIR has a verification infrastructure — every operation can declare invariants, and the framework checks them. Hew wasn’t calling mlir::verify() before lowering to LLVM IR. So if MLIRGen produced a malformed operation — wrong number of operands, type mismatch, out-of-bounds field index — the error would surface deep in LLVM lowering with a cryptic message about an unexpected operand type. (I spent longer than I should have debugging those before realizing I could just… call verify first.)

I added mlir::verify() before any lowering passes. Now the error message says what it means:

// StructInitOp with wrong number of fields
%bad = hew.struct_init("Point", %x)
// → error: 'hew.struct_init' expected 2 operands
//   for struct 'Point' with 2 fields, got 1

// FieldGetOp with out-of-bounds index
%bad = hew.field_get %point[5]
// → error: 'hew.field_get' field index 5 is
//   out of bounds for struct with 2 fields

I added 21 unit tests for the dialect — 3 verifier negative tests, 5 folder tests for constant folding, 2 side-effect tests. The side-effect tests matter because they verify that collection operations have correct MemoryEffects annotations:

// Before: MLIR optimizer could reorder collection ops
hew.vec.push %vec, %val    // side effect: MemWrite
hew.vec.len %vec           // side effect: MemRead

// After: MemoryEffects annotations prevent reordering
// Optimizer knows push writes memory, len reads memory
// Pure ops (enum tag extract, tuple project) marked NoMemoryEffect

I added those annotations to 19 operations across collections, strings, and regex. Without them, MLIR’s optimizer is free to reorder operations that it believes are side-effect-free — which means a vec.push could be moved after a vec.len that depends on it. The annotations are a contract with the optimizer: these operations touch memory, respect their order.

The lint gets smarter

I added 40 regression tests for the lint system. The type checker now tracks variable usage: is it used? Is it mutated? Was it defined with an underscore prefix? Is it synthetic?

fn main() {
    let count = 0;        // warning: unused variable 'count'
    var total = 0;        // warning: 'total' is never mutated,
    println(total);       //   consider using 'let' instead

    foo(pooint);          // error: unknown variable 'pooint'
                          //   did you mean 'point'?
}

The fuzzy suggestion engine uses Levenshtein distance to find similar names when a variable or function isn’t found. The tests verify: identical strings have distance zero, single edits have distance one, results sorted by distance, exact matches excluded, at most three suggestions returned. A misspelled variable name with a “did you mean?” is the difference between staring at a screen and fixing the typo.

Making the IR visible

Four visualization tools landed in a single morning. Each takes MLIR output from hew build --emit-mlir and generates interactive HTML:

hew-ir-viz renders the MLIR module as a navigable tree — functions, operations, types, attributes — with syntax highlighting and cross-references. Click an operation to see its operands and results. Click a type to see where it’s used.

hew-actor-topo extracts the actor topology from a program: which actors exist, what messages they send to each other, and how the supervision tree is structured. The output is a directed graph where nodes are actors and edges are message channels.

hew-fn-cfg generates control flow graphs for individual functions. Basic blocks, branches, loops, returns — the actual control flow that the optimizer sees, not the source-level abstraction.

hew-system-explorer combines all three into a single interactive page: actor topology at the top, per-actor CFGs in the middle, and the full IR at the bottom. It’s a dashboard for understanding what a Hew program actually does after the compiler is done with it.

None of these tools required changes to the compiler — they parse the text output of --emit-mlir, which is a stable representation. The compiler doesn’t need to know about visualization.