· 6 min read
The IR has a front door
Gpufuck frontends author Functional Surface objects, pack them into a versioned WebGPU transport, and receive numeric resolved Core only after GPU checking.

The most tempting type in gpufuck is FunctionalCoreNode. It already contains local depths, global definition indices, constructor indices, and evaluation modes. Start a frontend there and you have arrived after the decision you were supposed to delegate: the GPU creates that evidence while resolving and checking the program.
The repository actually exposes three related forms, each with its own job: frontends author Functional Surface objects, buildFunctionalSurfaceModule() packs them into a versioned transport made of fixed-width words, and resolved Core comes back after GPU compilation to feed evaluation or Wasm emission. This is the missing companion to the lane-machine view: calling all three “the IR” makes the frontend boundary look harder than it is.
Eight words for 42#
The shortest parser-independent fixture in the current compiler test suite constructs an encoded module by hand. Its only expression is this record:
const none = FUNCTIONAL_NO_INDEX;const integer42 = Uint32Array.of( FunctionalExpressionTag.Integer, 0, 2, 42, none, none, none, none,);Eight words. For 42. They mean tag, start byte, end byte, payload, three child slots, and parent, and the absent relationships hold 0xffffffff. A definition record then points symbol zero at node zero, while counts, names, type metadata, and the selected evaluation profile complete the module.
I like this fixture because it proves the packed ABI is real rather than a diagram in the architecture document. I would still hate to build a language this way. Every new expression would make the frontend own preorder layout, parent links, symbol interning, span encoding, and whichever ABI version happens to be current.
The builder takes the clerical work#
The intended entrance keeps the same semantics in ordinary TypeScript objects. Here is a complete module for 40 + 2, followed by the compile and emission boundary:
const module = buildFunctionalSurfaceModule( [{ name: "main", parameters: [], annotation: null, body: surface.binary( FunctionalBinaryOperator.Add, surface.integer(40), surface.integer(2), ), }], [], "main", sourceBytes, { evaluationProfile: FunctionalEvaluationProfile.StrictEager },);const compilation = await compiler.compileModule(module);if (!compilation.ok) throw new Error(compilation.diagnostics[0].message);try { const core = await compilation.module.readCoreNodes(); const wasm = await compileFunctionalModuleToWasm(compilation.module); console.log(core, wasm.byteLength);} finally { compilation.module.destroy();}The Surface builder interns names, inserts the reserved unit and pair declarations, curries parameter lists, records source spans, and chooses strict or call-by-need node tags from the evaluation profile. The returned object is still an EncodedFunctionalModule, so the GPU receives flat buffers rather than a JavaScript object graph — but the clerical work now has exactly one owner instead of a copy per frontend.
sourceBytes deserves attention. Gpufuck reports UTF-8 byte spans, and the frontend later maps those offsets back to files, lines, excerpts, and source-language wording. Passing zero everywhere compiles the program, then makes every diagnostic point at the same blank patch of source.
A toy language reaches the surface#
Once the module wrapper is understood, a small frontend becomes a tree translation. This expression language has integers, names, addition, and calls:
type Expr = | { kind: "int"; value: number } | { kind: "name"; name: string } | { kind: "add"; left: Expr; right: Expr } | { kind: "call"; callee: Expr; arguments: readonly Expr[] };function lower(expression: Expr): FunctionalSurfaceExpression { switch (expression.kind) { case "int": return surface.integer(expression.value); case "name": return surface.name(expression.name); case "add": return surface.binary( FunctionalBinaryOperator.Add, lower(expression.left), lower(expression.right), ); case "call": return surface.apply( lower(expression.callee), ...expression.arguments.map(lower), ); }}The pleasant surprise is surface.name(). Our toy frontend can leave portable lexical and global names unresolved, because the GPU replaces them with local depths and definition indices. A Rust frontend still has to reject an illegal move first, and an OCaml frontend must preserve its sequential scope. Those rules belong to the source language before this lowerer runs.
I have not wrapped this snippet in a parser, and it omits spans to keep the switch readable. Each returned shape matches the current public Surface contract, though, and adding span to the objects is the practical next line rather than a different lowering strategy.
The first datatype spends more information#
Algebraic data reveals why the target keeps declarations beside expressions. Suppose the toy language grows Option and one case expression. The frontend supplies the nominal type, constructor fields, arm names, and binders:
const optionType = { name: "Option", parameters: ["value"], constructors: [ { name: "None", fields: [] }, { name: "Some", fields: [{ name: "value", type: { kind: "parameter", name: "value" }, }], }, ],} satisfies FunctionalSurfaceTypeDeclaration;const unwrapOrZero = { name: "unwrapOrZero", parameters: ["option"], annotation: null, body: { kind: "case", value: surface.name("option"), arms: [ { constructor: "None", binders: [], body: surface.integer(0) }, { constructor: "Some", binders: ["value"], body: surface.name("value") }, ], },} satisfies FunctionalSurfaceDefinition;const main = { name: "main", parameters: [], annotation: null, body: surface.apply( surface.name("unwrapOrZero"), surface.apply(surface.name("Some"), surface.integer(42)), ),} satisfies FunctionalSurfaceDefinition;At packing time, Option, None, and Some enter symbol, type, and constructor tables. The case arms remain names. GPU resolution later checks constructor ownership and binder arity, type inference discovers unwrapOrZero : Option<i32> -> i32, and coverage can complain if an arm is missing. At that point the agreeable objects turn back into a substantial pile of u32s. Good.
Writing those tables directly would force the frontend to calculate the first constructor index for every nominal type and maintain the linked case-arm shape by hand. The Surface form lets the frontend describe the language fact—this constructor has one field—while the encoder chooses the transport layout.
Core is the receipt#
After compileModule() succeeds, readCoreNodes() exposes the checked result for traces, tests, caches, and the host Wasm backend. The lowerer should inspect that output when debugging, but normal source compilation does not require it to manufacture local depths or constructor indices itself.
One semantic choice remains visible before packing: evaluation. A strict language selects StrictEager; a Haskell-shaped frontend chooses LazyCallByNeed and may override individual binding or argument boundaries. If our toy syntax eventually adds lazy, the lowerer has to record that promise where the target can preserve it through Core and Wasm.
After the Option example compiles, the readback names Some by constructor index and option by local depth. The first fixture remains smaller and stranger: its integer record ends with four copies of 0xffffffff, patiently reserving places for children and a parent that 42 never needed.