Wado

WEP: Loam — Lowered Operators, Ahead-of-time Modules

Context

Wado has no tensor story. The closest thing in the tree is example/microgpt.wado, a scalar autograd graph where every arithmetic operation allocates a Value node. It is a faithful port and a useful GC benchmark. It is not a foundation for machine learning work.

What Loam is for is inference on the device: a browser or a phone, on WebGPU. Those places share two constraints that a datacenter never has. Every byte of the module is downloaded before the first token, so the module must carry only what the model uses. And the process starts cold, so there is no warmup to amortize a JIT against. An engine that ships an interpreter and the graph as data pays both; a module compiled from the graph ahead of time pays neither, and that is the shape Loam takes.

Compiling ahead of time buys more than size, and this is the design's center. An engine that runs a graph finds a mismatch when it runs. Loam turns the graph into typed Wado before anything runs, so most of what could go wrong stops the build instead:

What is left for run time is only what a value decides: the checkpoint's bytes and the extents of the tensors forward receives. Both are checked where they arrive, before any kernel reads them, and a disagreement names the tensor and the axis. load returns it as a LoadError. forward stops with it, since an input that disagrees is a bug in the caller. A runtime cannot offer this, however good its kernels are. Its graph is data, and data is checked when it is read. That is why Loam builds again what inference runtimes already provide.

Four findings from the 2026 literature drive this proposal. docs/research-ml-inference-webgpu.md records the survey they come from.

Compile-time shape checking has become implementable. Dependent and refinement types both demand proofs to decide type equality, which is why neither was adopted in practice. Staging replaces the proofs with assertions evaluated as compile-time computations, and the ECOOP 2026 work validated it on ported ocaml-torch programs.

Axis notation has converged on einx, whose bracket marks the axes an operation actually sees. Static checking of it exists only as external stubs, because Python cannot say in a type what an operation does to a shape. That is a constraint of the host language rather than of the idea, and it is the reason this design has no notation layer at all.

Quantization formats are a moving target parameterized by block size, scale representation, and rotation, not a fixed list of names.

Fusion's objective function is not what it was. On WebGPU at batch size 1, dispatch count dominates and kernel quality does not. That objective belongs to the GPU work, and naming it here is what bounds the claims a CPU MVP can make.

Gale is the architectural precedent: a static compiled fast path with escalation to a runtime tier. This WEP adopts five properties of it:

Version drift is a problem the field has not solved. ONNX opset drift and checkpoint incompatibility are both drift between a model artifact and the code that runs it, which is what the last property removes.

Kiln makes the staging available without a language change. Wado has neither const generics nor general compile-time evaluation, so an extent cannot be carried as an integer in type position. A Kiln generator, however, runs arbitrary Wado at compile time and emits Wado source, which is staging. Gale already works this way. Loam is therefore a package, not a language feature.

Decision

Name

Loam — Lowered Operators, Ahead-of-time Modules.

A model that submits to fixed shapes at build time gets fused kernels, and gets its shape errors at build time instead of wrong numbers at run time. The constraint is the mechanism, not a limit on it.

Loam is the earth a foundry mould is made of, and a mould holds its shape before any metal is poured. Kiln fires such a mould, and Loam is a Kiln generator. The name is a short natural noun, as Gale, Kiln, Jade, and Marl are.

A Kiln Generator, Not a Library

Loam is invoked as a Kiln generator over a model graph and its checkpoint:

use { Weights, forward } from "./model.onnxtext" with {
    generator: {
        module: "wado-lang:loam@0.1",
        inputs: ["./model.safetensors"],
    },
};

A library would place shape checking at run time, where every other framework already has it. Only a generator can place it early enough for a shape mismatch to stop the build.

The generator reads the graph and the header of the checkpoint named in inputs, and checks one against the other.

Three properties follow from the choice, and each answers a standing problem in the field:

Loam reads its graph with a Gale-built parser, so its own source imports a module Kiln produces. The reader names ../grammar/Onnx.g4 at its use site, and Kiln builds the parser when it builds the generator. Kiln's WEP covers that under §"A generator may use a generator".

The Graph Is ONNX

A forward pass is read rather than written. Loam takes a model graph in a language the field already has, and the first one is ONNX, with StableHLO to follow as a second front end.

The operators mean what ONNX says they mean: broadcasting, each operator's definition, and the opset version that fixes it. Loam declares the opset range it reads and reinvents none of it. This is where most of the robustness comes from. A graph language of Loam's own would have had semantics of Loam's own, and every rule of them would have been Loam's to specify, to test, and to get wrong. ONNX's rules come with a reference implementation and a conformance suite, and that suite is the oracle the kernels are tested against.

A graph arrives in either of ONNX's two forms, and the generator picks by extension. .onnx is the protobuf form, which is what an exporter writes and what the backend test data ships; package-loam/src/onnx_proto.wado reads it through the types Grog generates from ONNX's own onnx.proto. .onnxtext is the textual format onnx.parser reads and onnx.printer writes, named as onnx-mlir names it, and a Gale grammar parses it.

A node is read as its opset defines it, and the readers restate it in the current definition. That covers Concat before opset 4, whose axis defaulted to 1, and every operator that only moved an attribute to an operand. Two older meanings have no current spelling, and each lowers to a kernel of its own. Softmax before opset 13 normalized every axis from axis on as one lane, which softmax_flattened computes. Arithmetic before opset 7 with broadcast aligned B at A's axis rather than at A's end. Stage 0 reads B followed by A's remaining axes at extent 1, and zip_at computes it. onnxruntime runs no opset that old, so its oracle case runs a graph that unsqueezes B instead.

Both produce the Graph in package-loam/src/graph.wado, so nothing after the reader knows which form it came from. A fixture written here takes the text form, since a textual graph is a few kilobytes and shows a diff, as package-loam/example/mlp.onnxtext does. The .onnx files under package-loam/tests/onnx/ are committed too, and they are copies of ONNX's own test data rather than anything edited here. The two are not equivalent encodings. Protobuf is ONNX's canonical form, so where they disagree it is the one that wins.

An inline value in the text form is read as onnx.parser reads it, into the protobuf field it would fill. A float16 or bfloat16 element is therefore an integer, the bit pattern int32_data holds, and 15360 is 1.0. A float may end at its point (1.) but not start there (.5). It may also be a word: nan, inf or infinity in any case, with an optional minus. The word stays an identifier in the grammar, so a tensor named inf still parses.

The graph names its weights, and either the graph or a checkpoint supplies them. A .onnx may carry its initializers' data itself, and that is enough to run. Otherwise an initializer's name is the key into the checkpoint, so the graph is the from literal and the checkpoint an inputs entry, and a sharded checkpoint is several entries.

An ONNX shape is positional, with a dim_param where a dimension is symbolic. That is the extent half of an axis and none of the identity half, so the axis schema of the next section names the axes of the graph's inputs and initializers, and the shape algebra carries those names and extents through every operator by its ONNX signature. An intermediate value is never named by hand. A dim_param is a symbolic extent; a dim_value is a static one.

A dim_param may spell arithmetic, as an exporter writes past_sequence_length + 1 for the attention mask of a decoder with a past. One whose words are names and integers, separated by +, - and * with spaces around them, reads as that polynomial, with * binding first. Any other dim_param is one opaque name, so seq-len names one symbol. forward binds a name from an input axis that is the name alone. A name no input carries alone is read back from the first input axis whose length is linear in it, once the other names that length reads are bound. A spelling over a name no input axis yields this way is read as one opaque name after all, so batch * seq on an input alone names one symbol. The check that every input axis is as long as its spelling then catches a length that no whole name spells, and a name read back as negative stops the run.

A graph also declares the shapes of its own outputs, and those are merged against what the nodes produced on ONNX's terms (mergeInDimensionInfo): two static extents that differ are the conflict, while a dim_param names the axis in the author's words and a ? names nothing, so neither can disagree with the schema's own axis name. A rank that differs is a conflict.

The invocation may fix what the graph leaves open, before stage 0 reads it. dims gives a dim_param a static extent, as onnxruntime's free dimension overrides do. outputs names the graph outputs forward returns, and the graph is cut down to the nodes, inputs and initializers they read, so a decoder's present.* caches leave with the nodes only they need. A name in either option that the graph does not declare stops the build. dims fixes a name inside a spelled dim_param too. Fixing some of its names and not the others stops the build, and so does fixing them to a negative extent.

Without outputs, forward returns every output the graph declares. A single output is returned as its tensor. Several are returned as an Outputs struct, one field per output in the order the graph declares them, so a decoder hands back its present.* keys and values beside its logits.

A graph computes much of its own shape. An exporter writes x.view(x.size(0), -1) as Shape, Gather, Unsqueeze and Concat feeding a Reshape, and attention's scale as a Cast and a Pow of an extent. Stage 0 evaluates every node whose operands the build knows, by the operator's ONNX definition. The build knows an int64, int32 or bool initializer, a Constant, the extents Shape reads, a symbolic one as its polynomial, and whatever stage 0 has already evaluated. Such a node emits no code. Stage 0 evaluates nothing over a float initializer: a weight belongs to the checkpoint, and the build may not have it.

A folded tensor that a kernel reads or the graph returns becomes a Tensor literal, as a transformer's position ids do once Range has folded. A one-element float is the exception where an elementwise operator can take it: a binary elementwise operand whose partner is a tensor, or a Gemm bias. There it folds into the operator's Expr as a constant and no tensor is allocated.

Stage 0 also tracks each tensor's element type. An int64 tensor, such as a token id or an attention mask, stays i64 through the kernels that move elements without computing on them: the regroupings, Transpose and Identity. Gather reads it as indices, and Cast converts it. Every other kernel computes in f32 and stops the build on any other operand.

This settles who Loam is for. A graph reaching it has already run once, in the framework that exported it, so its shapes are consistent by construction. What the build catches is the seam that framework never saw: a checkpoint whose extents disagree with the graph, and a caller whose tensors disagree with the boundary.

The Graph Is the Schema, the Checkpoint Is Data

The graph is the schema, and the generated code is a function of it. A checkpoint carries data, behind a header of its own: a safetensors header is a length-prefixed JSON blob at the front of the file carrying tensor names, positional shapes, dtypes, and offsets. The build checks that header against the graph and reads nothing further down the file.

Kiln's probe is what makes that pay. A generator reports how many leading bytes determine its output, and the build keys on that range; the mechanism and the two invariants a probe owes are in the Kiln WEP. Loam's probe reads a safetensors header, which satisfies both. The header length is in the first eight bytes, so the extent is determined by bytes inside it.

Without it the build would hash whole checkpoints on every incremental build, proving that bytes the output never depended on did not change.

The graph file is probed too, and its extent is the whole file, since every byte of a textual graph is schema. An extent is a prefix, so a format that interleaves its schema with its data has none, and this is what a protobuf .onnx does: graph and initializers in one message, interleaved, so no prefix excludes the weights. A model whose weights are large must therefore keep them in external data files, which is onnx.save_model(..., save_as_external_data=True) and is what every model over 2 GB already does, since protobuf caps a message there. A stated input requirement is what keeps the extent a length instead of a range set; the alternative was generalizing the extent so one input could name several disjoint ranges, which complicates the key, the record, and every generator that never needed it.

Loam is the reason Kiln's probe receives the invocation's options: inline_weights makes the output depend on the whole file rather than on the header, so a probe blind to it would either report the whole file every time, losing what the extent was for, or report the header and let a payload-only change hit the cache and emit a module carrying the old weights.

Per-input probing is what a real checkpoint needs. Large models ship sharded — model-00001-of-00003.safetensors beside an index — so several huge inputs arrive at one invocation, each with a header of its own.

The generated module contains the typed graph, the kernels, and a loader that checks the incoming payloads against the shapes the header declared. A checkpoint that does not match its header reports which axis disagreed, at load time, rather than producing wrong numbers.

The loader is Weights::load(checkpoint: &ByteList) -> Result<Weights, LoadError>. It takes the checkpoint's bytes rather than a path, so the generated module declares no effect: a command reads the file through core:fs, and a page fetches it. The build checks the header of the checkpoint the invocation names, and the loader checks the one it is given, since the two need not be the same file. An initializer the graph carries data for is embedded, and the checkpoint supplies the rest.

A checkpoint is keyed by the graph's initializer names, and a published one rarely is. GPT-2's drops the transformer. prefix its exported graph carries, and ties the output projection to the embedding where the graph holds the transpose as a tensor of its own. Mapping one onto the other is the architecture tables' work. Until then, package-loam/tools/onnx_split.wado splits an .onnx into the two files Loam reads: a .onnxtext whose float initializers carry no values, and a .safetensors holding them under the graph's names. The build then reads a textual graph, which a diff can show.

A small model may set inline_weights to have the payload emitted as a byte string literal read by List::from_le_bytes, which makes the component self-contained. The compiler folds that call into a data segment, so the Wasm grows by the payload alone and loading it runs no decode loop. This is for test fixtures and small models, not the normal path. A .onnx carrying its own weights takes the same path, and costs the cache nothing, since its extent is the whole file already.

The literal was chosen over the alternatives by measurement. A float literal per element costs every compiler phase a node, about 11 s and 460 MB per MiB. A decode at startup, from protobuf, base64 in __DATA__ or zlib, costs 8 to 40 ms per MiB and grows the Wasm or the startup. The folded byte literal compiles at under 1 s per MiB and loads in one array.new_data.

Axis Identity Is a Type, Extents Are Values

An axis has a name rather than a position. Transposition is then a change of description rather than a movement of data, and an operation states which axes it consumes.

An axis is a marker type, and a tensor carries its axis list as one type parameter: Tensor<f32, [Batch, Seq]>. Naming the same axis variable twice in a signature makes the two positions agree, so a contraction checks itself:

fn matmul<M, K, N>(a: &Tensor<f32, [M, K]>, b: &Tensor<f32, [K, N]>) -> Tensor<f32, [M, N]>

example/tensor_axes.wado is the working form, with a forward pass built on it. Rank-generic operations take the axis list as a plain type parameter, and an operation that changes the rank destructures it with a pack, so a reduction is <..Rest, Last>(t: &Tensor<f32, [..Rest, Last]>) -> Tensor<f32, [..Rest]>.

This needs no language change. It was the open question when this WEP was written, because Wado has no const generics, and it is closed: an extent cannot be carried in a type, but an axis identity can, and that is the half that catches the mistakes people actually make.

Two axes an operation meets are one axis only where they carry one name. Lengths that agree do not make two names one: Seq and Total are two axes even at an input where they are one length, and an operation meeting them stops the build. An axis of 1 is the exception, since it holds one element and broadcasting stretches it to any axis, so it may carry a name of its own. The rule holds wherever an operator meets axes: a contraction, a broadcast, the axes a Concat does not join, and a Conv's channels. A grouped Conv meets two names by length alone, because a group's share of the input channels is an axis of its own.

Loam first took two names whose lengths agreed as one axis, under the first name. A mistake in a layout then built a module that computed, with generated types stating an axis identity the graph never had. No graph Loam runs relied on it.

Extents therefore stay runtime values, and stage 0 is what checks them. A static extent is a dim_value the graph states, and a checkpoint's header is checked against it. A dynamic extent, such as sequence length or batch size, is a named symbol that stage 0 carries through the shape algebra without knowing its value. Stage 0 counts two tensors carrying one symbol as the same length. The caller owes that for the inputs, and forward checks it before any kernel runs.

An extent in general is a polynomial over those symbols with integer coefficients. A merge multiplies its parts, and an exporter's shape arithmetic adds and subtracts them: a causal mask is sliced at the key length less the query length, and a decoder with a past extends the sequence by it. So the integers stage 0 folds hold polynomials too. Shape of a tensor with a symbolic extent yields one, Gather, Concat and the arithmetic operators carry it, and a Reshape target built from it regroups by proving the element counts equal for every value of the symbols. Where a value depends on what a symbol is, as a Range over the sequence does, the node becomes a kernel call, and forward binds each symbol from the first input axis carrying it.

Some shape arithmetic leaves the polynomials. ONNX clamps a Slice bound to its axis and reads a negative one from the end. It counts the steps of a Slice or a Range rounding up, and never below none. A Reshape's -1 rounds a quotient down, and an integer Div truncates one toward zero. An int32 keeps the low 32 bits of its value, and a half rounds to the nearest it holds. Where the build can decide such an operation for every input, it takes the answer. A bound past any length a list holds is one such case, and it is how an exporter writes "to the end". Elsewhere the result is a derived symbol, such as min(N, 8) or ceil(N / 2): a value forward computes from the input symbols before any kernel runs. Stage 0 carries a derived symbol as it carries an input symbol, within the range its operation leaves. The same operation on equal operands derives the same symbol.

Errors split across the two layers. The generated boundary carries these types, so a caller passing the wrong tensor gets a type error from the ordinary compiler. A disagreeing extent inside the graph, or between the graph and its checkpoint, is discharged as compile-time computation in the generator, which is the ECOOP 2026 formulation.

An extent condition that stage 0 cannot discharge is a compile error, with one exception: an inequality over the input symbols. The caller owes it, as it owes the inputs' agreement on a symbol. forward checks it before any kernel runs, and refuses the inputs naming the node that relies on it. Stage 0 first tries to prove the inequality from what every symbol is. An input symbol is a length between 0 and i32::MAX, and a derived one lies within what its operation leaves. One it shows to hold for every input is not owed, and one it shows false for every input stays a compile error. Anything else stage 0 cannot discharge has no runtime tier to defer to, because the MVP has no tier below the static one.

The rule first had no exception. Every condition stage 0 could not discharge was a compile error, "the MVP's boundary, stated rather than worked around". That could not hold once the sequence length became a symbol: GPT-2's causal mask slice needs the length within the context, and only the run knows the length. A condition on the inputs is one the inputs can be checked against, so it became the one exception.

Two lengths of one axis are owed the same way. Where an operation meets two axes of one name whose lengths are both symbolic, and stage 0 can show them neither equal nor unequal, the inputs owe the equality as two inequalities. A merged decoder reaches this. Its mask carries a dim_param of its own, and the keys it masks carry the past's length plus the sequence's. A number meeting a symbol stays a compile error, since dims can bind the symbol at build time.

A derived symbol is the common source of one. GPT-2 slices its causal mask to sequence_length from a buffer as long as its context, so the mask's extent is min(sequence_length, 1024). The scores it masks carry sequence_length itself, and the two must be one length. Where a proof needs two extents equal, or one extent to be 1, and they are not, stage 0 reads each derived symbol in their difference as the operand it takes unchanged. A clamp reads as its bound, a count clamped at none as its span, an index counted from the end as counted from the start, and a wrap as the value it wraps. The reading holds only where the operation does take that operand, so the inputs owe it. GPT-2's mask owes sequence_length <= 1024. A rounded quotient takes no operand unchanged, so only its operand is read.

A broadcast reads the same way. Where a 1 meets a derived symbol that reads as 1, the result keeps the 1, and the inputs owe the reading. A decoder with a past reaches this: one position's causal mask is sliced to a derived length, which reads as that 1.

Derived symbols replaced a stricter rule. A clamp leaves no polynomial, so Loam first read every bound as unclamped, and the inputs owed that it lay within the axis. A wrap was read the same way, and the inputs owed that the value fit int32. That refused inputs ONNX accepts, such as a Slice to N over an axis of 8 at N = 10, and it had no answer where the step was not 1. package-loam/conformance/semantics_test.wado holds Loam to onnxruntime's answers at two lengths.

More conditions are owed where only the run knows a length:

An int64 that some input would carry past i64 is refused rather than owed. forward computes every owed slack in i64 as well, so the check itself would wrap.

The generated module writes each axis as a marker type and the tensors in terms of them, so a caller says Tensor<f32, [Batch, In]> where it means the input. A marker whose axis every input and output fixes to one length carries it as EXTENT. A caller writes Seq::EXTENT rather than the number, so it cannot disagree with the graph, and a graph that stops fixing the axis no longer compiles the caller. It emits no per-tensor alias. Wado's type is a newtype, so an alias would need an as at every construction to restate what the axis list already says, and it would make two tensors of one shape distinct for no reason.

The Axis Schema Is an Invocation Option

Neither the graph nor the checkpoint says which axis is the batch. An ONNX shape and a safetensors header both give a tensor's rank and its lengths and nothing about what they mean. Axis names are a property of the architecture rather than of either file: two Llama checkpoints of different sizes share their axis names and differ only in their extents.

So the architecture is named where the graph is named, in the invocation's options:

use { Weights, forward } from "./model.onnxtext" with {
    generator: {
        module: "wado-lang:loam@0.1",
        inputs: ["./model.safetensors"],
        options: {
            architecture: "llama",
            layout: [{ pattern: "*.q_proj.weight", axes: ["Hidden", "Hidden"] }],
        },
    },
};

layout is what the generator reads. architecture is not read at all: it names the built-in table a tensor falls back to where no pattern reaches it, and roadmap step 17 writes those tables.

Options rather than a second file, because Kiln already rejected a schema a reader has to go elsewhere to find (§"A manifest-side invocation registry"). Options are typed by the generator's own Options struct, so the use site gets validation and completion that a sidecar document could not have, and they are canonically encoded into the cache key, so reformatting one does not regenerate. Kiln's probe receives them too, which inline_weights needs.

Naming every tensor is not an option at either place. A thirty-layer transformer has hundreds, so the schema is a name and a set of patterns: architecture selects a table Loam ships, and layout holds patterns consulted in order ahead of it. A pattern is a shell wildcard over the tensor's name, and the first rule that matches wins, so one pattern overrides a table entry without restating the rest of it.

The schema names the graph's inputs and its initializers. Every other value takes its axes from the operator that produced it, and an ONNX dim_param or ? on a named axis becomes a symbolic extent standing for that axis. An axis an operator creates is the exception, and the next section names it.

A tensor that neither reaches is a build error naming it. Loam does not fall back to positional axes, for the same reason stage 0 does not fall back to a runtime check.

The tables age, because model formats move. That is safe rather than merely accepted: a table is read at build time and its answer is baked into the generated module, so a stale one stops the build with the error above instead of producing a module that computes the wrong thing.

architecture is an option rather than something read from the file. safetensors has only a free-form __metadata__ map, which carries no dependable architecture. A format that does state one, as GGUF's general.architecture does, can have it read and checked against the option.

A Regrouped Axis Is Named by Its Parts

Flatten, Reshape and Unsqueeze produce axes that no input carries. Most of them are regroupings, and a regrouping names its result from its parts.

Merging adjacent axes produces one axis whose identity is the ordered list of its parts, Merged<[C, H, W]>, and whose extent is their product. Splitting it again yields the parts back. A Reshape in general takes its input apart into those parts and gives each output axis the consecutive run of them whose extents multiply to its own, so a merge followed by a different split is still named.

layout writes a merged axis as einops does, "(C H W)". A weight that reads a flattened activation, as the first dense layer after a convolution does, names the axis it contracts that way, and the contraction checks as any other does.

An extent-1 axis holds no elements to regroup, so a merge leaves it out of its parts, and one that no output axis takes is dropped. [N, C, 1, 1] flattens to [N, C], not to a merge of C with two unit axes.

An axis that no regrouping determines is new: the split of an axis that was never merged, the extent-1 axis Unsqueeze inserts, the axis Concat joins along, the axes of a folded tensor, a one-element one included, and every spatial axis a window produces, since Conv and MaxPool place their output on a grid of their own. The schema names those. A window whose output keeps its input's extent may take the input's name, and that is the layout's choice, not the operator's. A layout pattern reaches a node's output by its tensor name, and it is consulted only for an axis the operator left undetermined. One that no pattern reaches stops the build naming the tensor. A generated name would be the positional fallback the previous section refuses.

Unsqueeze and Squeeze carry every other axis by its position, so an inserted axis never takes the name of an extent-1 axis beside it. Concat sums its operands' extents on the axis it joins, and they must agree on every other axis, by name and by extent. A join of more than two operands is a chain of pairwise concat calls from the left, and each partial result names the joined axis as the output does.

Two things follow. An extent is a product of symbols as well as a symbol, since the parts of a merge may be dynamic. And Reshape reads its target shape at build time, so the generator reads an integer tensor, which the graph carries as a Constant or an initializer.

A Branch Is a Block of forward

An exporter can write a decoder as one graph that branches on whether it is given a past. ONNX's If reads a bool of one element and runs one of two subgraphs, each reading the tensors of the graph around it. Loam emits it as one if over that element, with a block of forward for each branch. Both branches compile, and the run takes one.

This is what lets one module hold one set of weights and still read a prompt in one pass. The prompt runs through the branch without a past, all its positions at once, and each step after it through the branch with one. Feeding the prompt one position at a time through the decoder with a past, starting from an empty past, also needs one graph and matches onnxruntime. It was rejected because a prompt of n tokens then costs n dispatches where one would do, and a design that loses performance on WebGPU is not taken.

Stage 0 checks each branch in a copy of the scope around it. What a branch owes is checked at the top of its block rather than of forward. A condition one branch relies on would otherwise refuse inputs the other branch accepts: the branch without a past owes that the mask is as long as the sequence, and the branch with one that it is as long as the past and the sequence together.

The node's outputs take the axes both branches give them. An axis the two name apart takes the name the output's layout gives, and each block relabels its value to it. A length the two branches give differently becomes a symbol of its own, which only the run knows. The two branches must agree on each output's rank and element type, and the build stops where they do not.

A constant of the graph around the branches takes the shape that graph's layout gives it and is spelled once, ahead of the if, so a mask as large as the context is not written into the module twice. A branch that reads it under other axes relabels it.

The two branches may name their tensors alike, and the merged GPT-2 does. A layout rule may therefore name the graph it reaches, by the name the graph declares. A rule without one reaches every graph.

A merged decoder names its key axis and its query axis alike, as the decoder without a past does. Without a past, the keys are the sequence, so the two are one axis there. Telling them apart would need layout to rename an axis that an operator carries through, an exception to the rule that two names never meet silently. The types then do not tell a key from a query in either branch. The run still checks that their lengths agree.

The dtype Is a Storage-Only Primitive

A checkpoint's elements are mostly half precision, and most LLM checkpoints on the Hub are bf16. Wado computes in neither, and Loam never asks it to: the host's job is to move the bytes. So f16 and bf16 are primitives that carry bits and no arithmetic. Adding them is a compiler change rather than a Loam one, specified in WEP: Half-Precision Primitives.

Loam holds such a tensor and never computes in it. A graph whose boundary is float16 or bfloat16 gets a Weights and a forward typed in that primitive, and forward widens each half operand into f32 on the way in and narrows the result on the way out. The kernels see one element type, so a kernel signature states its axes and no dtype.

A dtype becomes a Wado primitive only where a tensor of it can cross the generated module's boundary, as an input embedding or an output logit does. A weight-only format never does. int4 is two elements to a byte with a scale per block, and f8 is never shipped without its scales either; WGSL has no type for either and unpacks both from u32 with shifts. They stay bytes in the checkpoint and a tag inside the generator, and never reach Wado's type system.

The Signature Is the Notation

einx exists because Python cannot say in a type what an operation does to a shape, so it says it in a string. Wado can say it in a type. The runtime declares each kernel, and the generator the model's boundary, as a generic signature that carries every part of the notation:

fn matmul<..Bs, M, K, N>(
    a: &Tensor<f32, [..Bs, M, K]>,
    b: &Tensor<f32, [..Bs, K, N]>,
) -> Tensor<f32, [..Bs, M, N]>

A repeated variable is einx's bracket: K appears in both operands and not in the result, which is what contraction means. The unrepeated scalars are the vectorized axes, the pack is einx's leading ellipsis, and the return type is its arrow. One signature covers rank 2, 3 and 4, because the pack also matches empty.

So there is no notation layer. A tagged template could not have been one anyway: ReflectTemplate exposes the hole types and the literal text as a value, with no associated type derived from the literal, and einx's entire content is in that literal. A tag could only return a tensor whose axes had been erased, discarding what the type layer just won.

A parameter list may declare several packs, each settled by the argument that carries it. So an operation whose input and output axis lists are independent names both, which is what a rank-changing operator such as Reshape needs.

A contraction names its axis where the axis sits. Two packs in one tuple settle neither on their own, so [..Pre, K, ..Post] needs its ends settled by something else, and a generated call spells them in a turbofish. The generator writes both sides of that call, so spelling them costs it nothing.

Whether a middle-axis contraction is then transposed into the trailing position is the kernel's decision, taken for locality rather than forced by the signature. A rank-2 weight broadcasts across batch axes by putting a pack on one side only.

The Backend Is a Trait

A Backend trait, monomorphized at each use, selects where kernels run. Cpu is the MVP's implementation, and a first WebGPU one follows it.

The backend owns the tensor's representation, as a generic associated type. Each kernel is a method, and its signature is the free function's with Tensor read as Self::Tensor, so the notation survives the seam:

trait Backend with _ {
    type Tensor<E: Element, Axes>;

    fn matmul<..Bs, M, K, N>(
        &mut self,
        a: &Self::Tensor<f32, [..Bs, M, K]>,
        b: &Self::Tensor<f32, [..Bs, K, N]>,
    ) -> Self::Tensor<f32, [..Bs, M, N]>;
}

impl Backend for Cpu {
    type Tensor<E: Element, Axes> = Tensor<E, Axes>;
    ...
}

The parameter is generic because half the kernels are: gather, concat and the regroupings move elements of any type without computing on them. One associated type per element type would split each of them into one method per type. Element bounds it to the types the kernels compute in, f32, i64 and bool, and converts one to another as ONNX's Cast does, so cast is one kernel generic over both ends.

The rest of the seam follows from what a GPU backend needs:

The First WebGPU Backend

WebGpuBackend (package-loam/src/runtime/webgpu.wado) is the seam's second implementation. A generated module inlines it after the tensor runtime where the generator's webgpu option is true, and forward runs on it unchanged. It is the first cut of step 14 and checks the seam: nothing in forward or the emitter changed for it.

package-loam/conformance/webgpu.wado runs every shader path against Cpu. wado-run-webgpu's tests run it, since wado test reaches no device. package-loam/example/webgpu/mlp.wado times a three-layer MLP at batch 256 under wado run-webgpu, both backends on one host:

Device Speed over Cpu, second call Largest deviation
GeForce RTX 4060 Ti 19x 1.9e-6

Most of the WebGPU time is the weights crossing on every call, which step 12 removes.

An effect was considered and set aside. Wado's effects lower through stack switching, whose per-operation cost is unmeasured here, and a tensor dispatch is a hot path. A trait's cost is known to be zero.

The CPU backend is for correctness and for the seam, not for speed. Wasm SIMD loads from linear memory and a Wado List<T> is a WasmGC array with no address, so core:simd offers no load: a vector is built by splat, by tuple coercion, or by reinterpreting another vector. The pattern that does pay is core:prng's, where vectors stay in the dataflow and never come from a list, which suits a tile held across a long computation and little else. The performance story belongs to the GPU backend, and a place with no GPU, such as an edge worker, is not a target until the CPU path has a vector load.

The MVP Is Tier 1 Only

Gale's two tiers become three here. This WEP builds the first:

Tier Decided at Covers
Static (tier 1) build time Static shapes, weights, fused blocks
Bucketed (tier 2) first use, cached Residual sequence and batch extents
Dynamic (tier 3) per token MoE routing, speculation, early exit

Tiers 2 and 3 are deliberately absent, not undiscovered.

Tier 2 is a shape-class cache rather than a simulator. Generating a kernel is expensive where Gale's ATN step is cheap, and the generation must finish before the dispatch it serves can run. Tier 3 is the genuine analogue of Gale's ATN: data-dependent control flow that no bucketing covers.

The Generator Reports Every Fusion Decision

gale dump is what makes Gale's escalation auditable, and its absence is why torch.compile's graph breaks arrive as a mystery. Loam's generator reports, per operation, what it fused, what it did not, and why.

The MVP has one tier and reports anyway. A declined fusion carries the reason it was declined, and those reasons are what will route the later tiers.

Every Name Is Minted From One Set

Every identifier the generated module declares is minted from one set. The module binds the kernels it inlines, forward, Weights and the weights parameter before it names anything the graph brings, and the runtime's share is read out of the runtime source, so a declaration added there is reserved without anything else being edited.

The two kinds of name the graph brings are answered differently. An ONNX tensor name is any string and comes out of a model file, so weights, map and Batch are all names a graph may carry, and one that is taken gets a trailing _. An axis name is the schema's own word, so an axis named after a kernel or a marker stops the build naming it: renaming it silently would rewrite what the author wrote, and they are there to change it.

The language's own share of that set is package-wadopoet's is_reserved: every keyword the parser does not take as a name, and every name the prelude puts in scope. A name keyword such as type or from stays free. The predicate is generated from what wado syntax --format json reports, so a keyword added to the compiler is reserved here without anything else being edited. Gale reserves the same set for a grammar's rule names.

What the CPU MVP Cannot Validate

The MVP validates the shape algebra, the generator architecture, and the backend seam. It does not validate the fusion objective.

On CPU the objective is memory traffic. On WebGPU at batch size 1 it is dispatch count. They are different objectives, and a fusion policy tuned against the first is not evidence about the second. The WebGPU backend will have to re-derive its policy from measurement rather than inherit this one.

The CPU arm cannot even reach its own objective's ceiling, for the reason in "The Backend Is a Trait": no vector load from a WasmGC array. A number this MVP produces is a correctness result and a floor, never a claim about the design.

The Tensor Kernels Stay in Loam

The kernels in package-loam/src/runtime/tensor.wado stay in Loam until Loam's own use cases settle them. An ndarray API has to serve several backends (scalar, SIMD, WebGPU), and Rust keeps that layer out of std, split across crates. Moving any of it into the standard library comes after.

Roadmap

  1. [x] The kernels. Each operator lowers to a call into the inlined runtime (package-loam/src/runtime/tensor.wado), and the generated module carries a Weights struct for the graph's initializers and a forward that runs the nodes in order. Each kernel carries the axis notation in its own signature. matmul<..Bs, M, K, N> contracts K, and zip_suffix<..Pre, ..Suf> broadcasts a trailing axis list, so a generated call is checked by the ordinary compiler rather than by Loam. package-loam/conformance/ checks them against ONNX's own outputs: graphs from the backend test data compile through Loam and reproduce the tensors shipped beside them. The models are committed under package-loam/tests/onnx/.
  2. [x] The half-precision element types. float16 and bfloat16 map to the primitives WEP: Half-Precision Primitives adds. Weights and forward state the graph's own element type, and widen / narrow convert at the boundary over the Held trait the two halves implement. package-loam/example/half.wado compiles such a graph through Kiln and prints its tensor.
  3. [x] A model's own weights. Where a graph carries every initializer's data, the generated module adds Weights::embedded(), which returns them. Each payload is a byte literal read by List::from_le_bytes, which the compiler folds into a data segment, so an ONNX file alone is enough to run inference. The conformance graphs with initializers run from Weights::embedded().
  4. [x] Minimal inference on ONNX alone. MNIST-12 from the ONNX Model Zoo runs from its .onnx file and reproduces the logits shipped beside it, which took Conv, MaxPool, Reshape and a stretched bias. The model lives in package-loam/tests/models/ with its provenance, apart from ONNX's own test data. package-loam/example/mnist.wado classifies digits drawn in its source.
  5. [x] A small transformer over static shapes. A GPT-2 decoder as an exporter writes it, with its batch and sequence length fixed at build time. Most of such a graph computes shapes rather than values, so this is where stage 0 evaluates that computation. A tiny GPT-2 with random weights runs from its .onnx and reproduces the logits onnxruntime computes for it. Its layout names the weights and every axis a regrouping leaves new, such as the heads. The model lives in package-loam/tests/models/tiny-gpt2/ with the scripts that wrote its oracle data. package-loam/example/gpt2.wado takes text in and gives text out. Its tokenizer, GPT-2's byte-level BPE, gives the ids Hugging Face's tokenizer gives, and greedy decoding picks the tokens onnxruntime picks.
  6. [x] A symbolic sequence length. The same model with its sequence length left to the run. Finished when one generated module reproduces onnxruntime's logits at two sequence lengths. The tiny GPT-2 does at four tokens and at seven, and both GPT-2 examples feed the tokens so far rather than a padded context.
  7. [x] The checkpoint reader. safetensors in the generator, its header checked against the graph. safetensors comes first because its header is already JSON, with no quantization to model. The generated loader checks each payload against the shape its header declared. Finished when a real checkpoint's shapes check against its graph and its payloads load, and a deliberately mismatched header or payload names the axis. The real checkpoint is GPT-2's 124M-parameter export, split by onnx_split. package-loam/example/gpt2-124m/ downloads it and continues a prompt with the tokens onnxruntime picks. It builds from the graph and the checkpoint's header, both committed, so wado test compiles it without the download.
  8. [x] Incremental decoding. forward returns every output the graph names, so a decoder hands back the attention keys and values it computed. GPT-2's decoder_with_past_model.onnx takes them back as past_key_values, so a step after the prompt computes one position against a past that grows, which needs step 6. Finished when GPT-2 continues a prompt with the tokens onnxruntime picks, computing one position per step after the prompt. The tiny GPT-2's decoder with a past lives in package-loam/tests/models/tiny-gpt2/with-past/, and package-loam/conformance/tiny_gpt2_past_test.wado reproduces onnxruntime's logits for the prompt and for two steps after it. package-loam/example/gpt2.wado reads the prompt once and then one token per step.
  9. [x] One graph for both. An exporter merges the two decoders into one graph that branches with an If on whether a past is given, so one generated module holds one set of weights and still reads a prompt in one pass. Finished when GPT-2 continues a prompt with the tokens onnxruntime picks through one module. The tiny GPT-2's merged decoder lives in package-loam/tests/models/tiny-gpt2/merged/, and package-loam/conformance/tiny_gpt2_merged_test.wado reproduces onnxruntime's logits for the prompt and two steps after it. package-loam/example/gpt2.wado runs on it.
  10. [ ] The tokenizer. Text reaches a model through a tokenizer Loam ships, rather than through code beside each example. Its design is a WEP of its own. Finished when both GPT-2 examples encode and decode through it with the ids Hugging Face's tokenizer gives.
  11. [x] The backend seam. The Backend trait with its CPU implementation. Finished when the MLP runs through the trait and matches the reference within tolerance, and a test backend whose tensor is an opaque handle runs it too, which no generated read of .data survives. The conformance graphs run through Cpu, and the MLP, an If and every operator and semantics case run through the handle backend in package-loam/conformance/opaque_backend.wado, the MLP and the If to the CPU's output exactly.
  12. [ ] Prepared weights. Weights holds each weight as the backend computes it, converted once where load or embedded builds it, rather than widened on every call to forward. The form belongs to the backend, so this follows step 11. It is what f16 and bf16 entered the language for: a half-precision model is downloaded and stored at half its size. Finished when a half-precision model runs forward in a loop that converts no weight, and matches its output converted per call.
  13. [ ] Fusion and its report. The generator fuses operators into one kernel where the CPU objective favours it, and reports each decision, following what gale dump established. Finished when the step-4 model runs with its elementwise chains fused and matches the unfused output, and every operation reports a decision and a reason.
  14. [ ] The WebGPU backend. A second Backend over wasi:webgpu, with the fusion objective re-derived for dispatch count. A benchmark/ row against onnxruntime waits for it, since a CPU number is only a floor. Finished when GPT-2 (124M) runs through it under wado run-webgpu and picks the tokens onnxruntime picks. The first cut is in (see The First WebGPU Backend): the products and the elementwise kernels run on the device, and every other kernel on the CPU.
  15. [ ] The probe. Loam reports the header as a checkpoint's extent, so the build keys on the header rather than hashing and reading the whole file (see The Graph Is the Schema). Closing it takes the Kiln work its WEP records. Finished when a build naming GPT-2 (124M)'s whole checkpoint reads its header and none of its payloads, so the example no longer needs a header file of its own.
  16. [ ] Sharded checkpoints. A model published in shards, with the index that names each tensor's file, arrives as several inputs entries. Finished when a sharded checkpoint checks and loads as its single-file form does, and a tensor the index places in a shard that is absent names the shard.
  17. [ ] The architecture tables. architecture selects one Loam ships, consulted after the layout patterns. Finished when a Llama checkpoint needs no layout entry to name its axes.
  18. [ ] The StableHLO front end. Finished when the MLP exported from JAX checks and runs through the same pipeline as its ONNX form.
  19. [ ] int4. DequantizeLinear with a block_size fused into the MatMul it feeds, which is what makes a model small enough to download. Finished when an int4 checkpoint from GPTQ or AWQ produces the f16 checkpoint's output within tolerance.

Known gaps

The operator set is MatMul, the binary and unary elementwise operators, Transpose, a Gemm lowered to those kernels rather than to one of its own, Reshape, Flatten, Unsqueeze and Squeeze, Split, Concat, MaxPool and Conv over any number of spatial axes, Softmax, ReduceMean and ReduceSum, Where, Gather, Slice, Range, ConstantOfShape, Cast, Identity and If.

Where no generic signature relates a kernel's operands to its result, the kernel takes the axis lists as type parameters and trusts them, as reshape does. The generated call spells the lists stage 0 computed. A permutation of three or more axes is one such kernel, permute, since each of the n! orders is its own type. So is matmul_broadcast, which serves every product whose operands drop a rank-1 operand's axis or stretch a batch axis of 1. So is lift, which reads an elementwise operand under the axes it meets where the layout names an axis of 1 apart from the one it stretches to.

Stage 0 evaluates Shape, and no kernel computes it, so it stops the build where the build does not know its operand. Concat has a kernel, and stage 0 evaluates one whose operands the build knows. Slice, Range and ConstantOfShape have kernels, but read their bounds or their shape at build time, so one the run computes stops the build as well. So does a Range whose delta has a sign the build cannot tell, since that decides which way it counts. Every other operator stops the build naming itself.

Stage 0 bounds each term of a condition apart, takes a bound past 2^64 as none, and stops comparing through derived symbols after a fixed number of failures. So a condition that holds for every input may still be owed, and one false for every input owed rather than refused.

A derived symbol is read as its operand only where two extents must be equal or one must be 1, a Reshape's element count and the sum of a Split's parts among them. Only the difference of the two extents is read, so a derived term both share owes nothing.

An integer Div truncates toward zero, and stage 0 computes it over numbers. Over a symbol, it stops the build unless the dividend is never negative and the divisor is a positive number. There truncating rounds down, and a derived symbol rounds down.

A value forward computes from the symbols, an extent or an owed slack, must fit i64 with every symbol as large as a length can be, or the build stops. That bound takes each symbol apart from the others, so it also stops a length the inputs bound together. [N, M, 3] flattened is 3 * N * M elements, which no input carrying N and M on one tensor takes past a list, and still stops the build.

layout can name a merged axis by its parts, as "(C H W)". That gives the axis an extent but not its parts, so regrouping such an axis stops the build naming the part.

A dim_param spells arithmetic only in the whitespace-separated form above. past_sequence_length+1 is one opaque name, and so is any spelling with parentheses or division.

A model exported as two graphs carries its weights twice. GPT-2's decoder and its decoder with a past each name every weight, so each generated module's Weights holds a copy, and a program running both holds two. The merged export is one graph and holds them once.

A node other than Split and If produces one output. TopK and the recurrent operators name several, and stage 0 gives only those two a shape per output.

A branch that declares inputs or initializers of its own stops the build. So does an attribute holding a list of graphs.

An If output whose branches give it two lengths has a length nothing binds. A node after the If that reads that length stops the build.

A constant that only branch-scoped layout rules name is spelled in each branch that reads it.

A condition the build knows still compiles both branches.

Every arithmetic kernel computes in f32. A boundary of float16 or bfloat16 is held in that primitive and widened. One of int64 is held in i64, and one of bool in bool. Every other element type stops the build naming it. Arithmetic in double and the integer types is a second instantiation of the same kernels. Wado has a primitive for nearly every ONNX element type, so what a step adds is kernels that compute in one, never the mapping.

The conformance suite reaches the graphs ONNX commits, not the per-operator cases. onnx/backend/test/data/node is generated rather than checked in, so what package-loam/conformance/ runs against is pytorch-operator/ and pytorch-converted/: whole small graphs with their inputs and outputs beside them.

Two suites take their expectations from onnxruntime instead. package-loam/conformance/operators_test.wado covers every operator Loam lowers, over static shapes, with the attributes and edge values where a reading can go wrong. semantics_test.wado covers shape arithmetic over a symbolic length, at two lengths. A script beside each, operators.mjs or semantics.mjs, writes one small model per case and what onnxruntime computes from it. The test builds each model as the generator would and runs the result under core:eval, so a refused build, a trap and a wrong answer each show as a difference from onnxruntime. A case Loam does not match yet is a #[TODO] test, so the run lists where the two part.

Those models are copied into package-loam/tests/onnx/ rather than read out of vendor/onnx. A submodule is absent wherever it is not initialized, and a file an import names has to be there for the module to compile, so reading one turns a test into a build failure. The copies keep the upstream layout, and the directory's README.md carries the provenance.

An operator reads only the attributes its shape rule, fold or kernel consults, and is checked as if the rest were absent. The readers carry every attribute as the text it was written as, and whether it was written as strings, since a string may read as 3. An attribute of the wrong kind stops the build. A tensor attribute is carried when its elements are float, float16, bfloat16, int32, int64 or bool. A half is widened to f32, which holds each of its values exactly. It keeps the type it is stored as, so the printer writes it back as bit patterns. A Constant or ConstantOfShape whose value holds any other type stops the build. A double would narrow to f32, which is the element-type gap above.

An initializer naming its own data file stops the build, naming the tensor. The textual format lets one carry a ["location": ...] list, the protobuf form an external_data entry, and Loam takes its weights from the checkpoint the invocation names, so the two say different things about where one tensor's bytes are.

The WebGPU backend runs a shader for the products and the elementwise kernels alone. A softmax, a reduction, a regrouping, a gather and a convolution run on the CPU, and each downloads its operand. So does an elementwise kernel whose operand stretches an axis of 1, the opset-6 zip_at, and a product with more batch elements or tiles than one dispatch dimension holds. A model whose graph alternates the two crosses the boundary at every such node.

A host operand crosses once per kernel that reads it, not once per forward: a weight two products read is written to the device twice. The host tensor is a value, so the backend has no identity to cache the buffer under.

A shader may assume no NaN reaches it, as WGSL allows. Where the CPU keeps a NaN, Relu among them, the device may give a number.

A buffer crosses the component boundary as a list<u8>, which the guest converts from or to f32 one element at a time, so crossing costs more than the device's own copy. Under wado run-webgpu, whose wasmtime is a generation behind wado run's, a loop allocating per element (map and collect) runs twenty times slower than under wado run. The backend writes its own loops for that reason.

Every WebGpuBackend method spells the same ten wasi:webgpu resources, since an impl method's with _ resolves to nothing at a caller.

Tier 2, the bucketed JIT. Residual sequence and batch extents need a shape-class cache populated on first use. Closing it needs a cache key design, a warmup story, and an answer to generation latency becoming user-visible. At build time no user waits on the generator; at first use one does.

Tier 3, dynamic dispatch. MoE routing, speculative decoding, and early exit are data-dependent and no bucketing covers them. Closing it needs the escalation boundary drawn as a decidability question, as Gale's is, rather than as a heuristic.

Quantization beyond int4. Block formats are parameterized by block size, scale representation, and rotation, so the type that carries one must be parameterized the same way rather than named after a format; f8 belongs here, since it is never shipped without its scales. GGUF as an input format arrives with this, not before it, and its metadata is a binary key-value block rather than JSON, so its probe has to parse one to report an extent.

ONNX local functions. The textual format lets a model carry its own function definitions and call them as operators, and the grammar covers neither, nor the @name attribute reference only a function body can carry, so a model holding one does not parse. What they admit is an operator set a model brings with it rather than one Loam knows, which the shape algebra has no account of.

Training. Loam is an inference design. An autograd layer over the same shape algebra is plausible and unowned.

Whether the backend should be an effect after all. The trait is chosen because its cost is known to be zero and the effect's is unmeasured. A second backend makes the measurement possible.