Phase 2. Primitives and control flow
| Field | Value |
|---|---|
| MEP | MEP-45 §Phases · Phase 2 |
| Status | IN PROGRESS |
| Started | 2026-05-22 19:30 (GMT+7) |
| Landed | — |
| Tracking issue | #22074 |
| Tracking PR | — |
Gate
Arithmetic + control-flow suite (~50 fixtures: int/float ops, comparisons, if/else, while, for-in over int range, recursion) compiles and runs byte-equal vs vm3 on host triple.
Goal-alignment audit
Primitives + control flow is the smallest set that gets a real (non-toy) Mochi program to compile. Without these the C-AOT target can't host any computation; with these it can host arithmetic-heavy fixtures like the benchmark loops (fib_iter, sum_loop, nsieve). Aligns with the user-facing goal of "one Mochi source, one native binary".
Sub-phases
| # | Scope | Status | Commit | PR |
|---|---|---|---|---|
| 2.0 | int (int64_t), float (double), bool; arithmetic; comparisons; short-circuit && / ` | ` | IN PROGRESS | |
| 2.1 | let/var, if/else, while, return, break, continue | IN PROGRESS | — | — |
| 2.2 | for x in start..end (int range); user-defined multi-arg functions | IN PROGRESS | — | — |
| 2.3 | Integer divide-by-zero raises MOCHI_ERR_DIVZERO (checked profile); UB under --fast-int | IN PROGRESS | — | — |
| 2.4 | Float NaN propagation matches vm3 byte-for-byte (IEEE 754 round-trip on %.17g) | IN PROGRESS | — | — |
Sub-phase 2.0 -- 2026-05-22 (GMT+7)
Goal-alignment audit (2.0)
The smallest extension of Phase 1 that lets the C-AOT pipeline compile programs that compute anything. Without 2.0 the only legal program is print("string literal"); with 2.0 the entire arithmetic + boolean expression layer compiles. Strict slice: no statements other than print(<expr>), no variables (those land in 2.1 with let/var/if/while).
Decisions made (2.0)
- Type set named after the C ABI.
TypeInt = int64_t,TypeFloat = double,TypeBool = int (0/1). The Mochi-level names (int,float,bool) survive intoType.String()because Phase 17's reproducibility hashing keys off those exact strings; later phases that introduce wider/narrower numeric variants will add new enum tags rather than rename existing ones. - Bool ABI: int rather than C99
_Bool. The runtime print function takesintso the emit pass can pass comparison results (already int 0/1 in C) without an explicit cast and so the header stays free of<stdbool.h>. BinOpenum is monomorphic per type.BinAddI64andBinAddF64are distinct tags so the emit pass picks the C operator fromOpalone. Avoids a typed switch in cBinOp.BinaryExpr.Resultis stored explicitly. KeepsType()independent of the BinOp enum ordering so a future renumbering can't silently change observed types.- Operator precedence follows the parser. Mochi's grammar lists
+ - * / %and== != < <= > >=and&& ||at the sameBinaryExprlevel, so the lowerer left-associates everything. Fixtures that need explicit grouping use(). - Mixed-type arithmetic is a lower-time error.
int + floatrejects with "operator wants both int or both float" instead of inserting an implicit widening, because Mochi semantics require an explicitas floatcast (which lands in Phase 3 alongside conversions). !=on booleans accepted, ordering rejected.true < falsewould not compile in vm3 either; the lowerer surfaces a "Phase 2.0 only allows == / !=" diagnostic for the relational ops.- Short-circuit
&&/||lowers to C's&&/||. They preserve short-circuit semantics natively, so no IR-level branching is needed for Phase 2.0 fixtures. Phase 2.4 retests this when NaN/Inf operands enter the comparison set. - Unary
-and!lowered inside-out. The parser collects multiple-operators left-to-right; the lowerer applies them right-to-left (--x->-(-x)) so the emit always sees a well-formed unary chain. INT64_MINrendering.emitInt64Litspecial-cases-1<<63as(-INT64_C(9223372036854775807) - INT64_C(1))to avoid9223372036854775808(which doesn't fit inint64_t) appearing in the emitted source.- Float literal rendering.
emitFloatLitcalls Go'sstrconv.FormatFloat(v, 'g', -1, 64)so the emitted source carries the shortest round-trip decimal, then forces a decimal point on integer-valued floats (1->1.0) and wraps the literal as(double)(...)so the C compiler never narrows tofloat. %.17gformochi_print_f64(placeholder). Phase 2.0 fixtures pick float values whose%.17goutput already matches Go'sstrconv.FormatFloat 'g' -1 64. Phase 2.4 liftsruntime/c/src/mochi_str.c'smochi_f64_formatinto the MEP-45 runtime so every double prints byte-equal to vm3, including NaN/Inf.- Lower rejects 2.1+ shapes loudly.
let,if,for, userfun, etc. each surface "Phase 2.0" in the error so a corpus regression that broadens the source surface fails fast instead of being silently miscompiled. - Fixtures gate the suite.
tests/transpiler3/c/fixtures/primitives/<name>/{<name>.mochi, expect.txt}; theTestPhase2Primitiveswalker picks up new directories without test-file edits, so adding a fixture is a one-step operation.
Test set (2.0)
transpiler3/c/aotir/verifier_test.go::TestVerifyPrimitives-- positive + negative coverage for the new Builtins, BinaryExpr, UnaryExpr type checks.transpiler3/c/aotir/verifier_test.go::TestTypeStringRoundTrip-- pinsType.String()identifiers (used by Phase 17 reproducibility hashing).transpiler3/c/emit/emit_test.go::TestEmitDispatch-- per-shape emission spot checks (int literal min, float trailing-zero, binary, unary, short-circuit).transpiler3/c/lower/lower_reject_test.go::TestLowerRejectsPhase21Plus-- pins the 2.0 surface boundary; 2.1+ shapes must error with a "Phase 2.0" diagnostic.transpiler3/c/build/phase02_test.go::TestPhase2Primitives-- end-to-end gate across everytests/transpiler3/c/fixtures/primitives/<name>directory (35 fixtures at landing time).
Sub-phase 2.1 -- 2026-05-22 (GMT+7)
Goal-alignment audit (2.1)
2.0 gets the C-AOT pipeline up to arithmetic-on-literals; 2.1 adds the
smallest set of statement shapes that lets a Mochi source program do
something between two print(...) calls. With 2.1 landed the
benchmark loops in tests/transpiler3/c/fixtures/control-flow/ --
while_count_down, while_sum_loop, if_chain_print_branch --
compile to native binaries that match vm3 byte-for-byte. This is the
first sub-phase where "one Mochi source, one native binary" is true of
non-trivial programs. Strict slice: no for loops (2.2), no user
functions (2.2), no records/lists (Phase 3).
Decisions made (2.1)
- Scope = lexical block. Both the lower pass and the verifier
push/pop a scope at every
Blockboundary, so aletinside anif/whilebody is invisible outside it. Mirrors the Mochi reference semantics; cost is amap[string]bindingper block which is cheap relative to the cc invocation downstream. - Mutability lives on the binding.
LetStmt.Mutable=trueis howvarround-trips through the IR. The verifier rejectsAssignStmtto a binding withMutable=false, so a stray reassignment of aletis caught before emit. - AssignStmt name-only. Field and index targets (
a[i] = x,a.f = x) are deferred to Phase 3 because lists and records do not exist yet. The lowerer rejects them explicitly with a "Phase 3" diagnostic. else ifpreserved as nested IfStmt. The lowerer wraps the chained branch in its own Block whose only statement is the inner IfStmt. Keeps the source structure intact for the debugger line table (Phase 16) and means the verifier walks the chained branch through its own scope, so aletdeclared inelse if { ... }remains scoped to that arm.- Loop-depth tracking, not lexical-target search. Both lower and
verifier carry a
loopDepth int; entering aWhileStmtbody increments it, leaving decrements it.BreakStmt/ContinueStmtsucceed iffloopDepth > 0. Avoids an O(depth) walk per break/continue and keeps the invariant centralised. - Bare
returnonly in 2.1. Value-returning return lands in Phase 2.2 with user functions. Frommain, a barereturnlowers to Creturn 0;; the entry emitter skips its trailingreturn 0;when the body already ends in a ReturnStmt so unreachable-code diagnostics don't fire on those programs. - VarRef = Primary.Selector with empty Tail. The parser surfaces a
bare identifier as
Primary.Selector{Root, Tail=[]}. Phase 2.1 treats a non-empty Tail as "field access — Phase 3" rather than silently dropping the suffix. - Type annotation optional; inferred from init.
let x = 1andlet x: int = 1both produce the same IR. The annotation is cross-checked against the init type when present; anint-annotatedletinitialised with1.5is a lower-time error, not a silent narrowing. - C declaration spelling.
int→int64_t,float→double,bool→int(matchesmochi_print_bool's ABI),string→const char *. Immutableletbindings carryconstso the cc warns if the emitter ever generates a stray assignment to them. - Nested block indent = parent + 4 spaces. Emit threads an
indentstring throughemitStmt/emitBlock, so the generated C is human-readable at any nesting depth without a per-statement formatter. Phase 17 reproducibility holds: the indent is a pure function of nesting, not a hash-of-id. - Lower rejects 2.2+ shapes loudly.
for, userfun, and value-returningreturneach surface a "Phase 2.2" diagnostic.typedecls error "Phase 3". A corpus regression that broadens the source surface fails the gate instead of being silently miscompiled.
Test set (2.1)
transpiler3/c/aotir/verifier_phase21_test.go::TestVerifyPhase21-- 18 positive + negative cases covering Let/Var/Assign/If/While/Break/ Continue/Return/VarRef and the scope/mutability invariants.transpiler3/c/emit/emit_phase21_test.go::TestEmitPhase21Stmts-- per-statement emission spot checks (let immut/mut, assign, if-only, if-else, while + break + continue, return).transpiler3/c/lower/lower_reject_test.go::TestLowerRejectsPhase22Plus-- pins the new boundary; for/fun/value-return/assign-to-immutable/ bool-cond-not-bool/break-outside-loop/assign-to-undeclared each produce a phase-named diagnostic.transpiler3/c/build/phase02_1_test.go::TestPhase2ControlFlow-- end-to-end gate across everytests/transpiler3/c/fixtures/control-flow/<name>directory (26 fixtures at landing time).
Sub-phase 2.2 -- 2026-05-22 20:49 (GMT+7)
Goal-alignment audit (2.2)
2.1 gets the C-AOT pipeline up to script-style programs (binding +
conditional + loop). 2.2 is where Mochi becomes a composable
language under C-AOT: user-defined functions split a program into
reusable units, and for x in start..end covers the bounded-counter
loop that vm3 specialises in. With 2.2 landed the fib / factorial /
sum-of-squares benchmarks compile to native binaries that match vm3
byte-for-byte. Strict slice: every user fn must have explicit
parameter types and an explicit non-unit return type (no inference);
nested funs deferred; closures deferred; first-class functions
deferred; list iteration deferred to Phase 3 with lists.
Decisions made (2.2)
- Two-pass Lower. Pass 1 walks every top-level statement, picks
out every
fundecl, and records its signature into a sharedmap[string]*funcSig. Pass 2 lowers the body of every fun (with parameters seeded into the function-level scope as immutable bindings) and then lowers the remaining top-level statements into main(). Two passes mean a fun can call another fun declared later in the source without a forward-declaration ceremony at the Mochi level; the emit pass adds the C forward declarations. - Explicit param + return types required.
fun f(x): int(param type missing) orfun f(x: int)(return type missing) each surface a phase-named diagnostic instead of inferring. Keeps the C-AOT monomorpher trivial; full inference + generics land in Phase 3 alongside the type-parameter machinery. - No nested fun decls. A
funinside another fun's body is rejected with "nestedfundeclarations are not supported in Phase 2.2". Closures land later; until then nested funs would silently capture the enclosing scope and surprise the user. CallExpris a value-producing user-fn call. Builtins (mochi_print_*) are unit-return and so always go throughCallStmt; they cannot appear in an expression position. The lowerer rejectslet x = print(1)explicitly. Phase 3 will addResult = TypeUnitto CallExpr when the parser starts surfacing void user fns; for nowResultis always one of the scalar types.- Discard-result user calls reuse CallStmt. A bare
foo()at statement position lowers toCallStmt{Func, Args}regardless of the callee's return type. C silently discards the return value with no warning under-Wall -Wextra -pedantic, which matches Mochi semantics. The emit pass renders it asfoo(...);. - ForRangeStmt is half-open
[Start, End). Mirrors vm3 and matches the parser'sfor x in start..endshape. An empty range (5..2) prints nothing and falls through, again byte-equal to vm3. - Induction variable is immutable inside the body. Lower stamps
the var as
mutable: falsein a fresh scope; reassigning it inside the loop body is a lower-time error. Matches Mochi reference semantics; lets the emit pass declare the C induction variable as a plainint64_twithout bothering withconst(the variable still has to mutate across iterations). - End expression is hoisted into a sentinel. The emit pass
evaluates the End expression exactly once at loop entry and stores
it in
__mochi_end_<Var>. Avoids re-evaluating a side-effecting bound on every iteration; matches vm3, which evaluatesendonce before the loop body runs. - Forward declarations emitted before main. Every non-entry
function gets a
static <ret> name(<params>);prototype at file scope, sorted alphabetically (Phase 17 reproducibility). Lets mutual recursion compile clean even when emit picks an order that doesn't happen to put the callee first. The entry function takes the Cint main(void)signature and is never forward-declared (nothing calls it from inside the translation unit). emitFunctionPrototypeshared between proto and definition. Single source of truth forstatic <ret> name(<params>)so the prototype and the body header can never drift; defining a new parameter type only requires one switch incType.- Bool return uses C
int.cReturnType(TypeBool) = "int", in step withcType(TypeBool) = "int". The runtime ABI keeps everything on theintlane, so a bool-returning fn flows intomochi_print_boolwithout an explicit cast. - Reserved Mochi keyword names are out. A user fn named
factorfromorselectwould parse as the start of a fact statement / query clause. The lowerer doesn't filter these; the parser refuses the source. Phase 2.2 fixtures intentionally use non-keyword identifiers (factorialnotfact). - C keyword collision is the user's problem.
fun double(...)parses fine on the Mochi side butdouble(arg)reads as a C cast in the emitted source. Phase 2.2 does not mangle user fn names; it relies on Mochi sources avoiding C-keyword identifiers. Phase 11 (build-system hardening) revisits this with a name mangler if real-world code starts colliding. - Lower rejects 2.3+ shapes loudly.
let xs = [1,2,3](lists),for x in xs(list iteration),type T { ... }(records), unit return type on a user fn (only the entry function returns unit), and a value-returning return from main each surface a phase-named diagnostic. The reject test moves fromPhase22PlustoPhase23Plusto reflect the broader surface.
Test set (2.2)
transpiler3/c/aotir/verifier_phase22_test.go::TestVerifyPhase22-- 17 cases covering CallExpr arity/arg/result invariants, mutual recursion, ForRangeStmt scope + immutability, signature invariants on main, and duplicate-name rejection.transpiler3/c/emit/emit_phase22_test.go::TestEmitPhase22Functions-- 7 cases pinning the forward-decl prologue, the prototype + definition agreement, value-returning return emission, the for-range sentinel layout, and sort-by-name reproducibility.transpiler3/c/lower/lower_reject_test.go::TestLowerRejectsPhase23Plus-- 22 negative cases pinning the new 2.2 surface boundary (list/record/type/fun-missing-types/list-iter/etc.).transpiler3/c/build/phase02_2_test.go::TestPhase2Functions-- end-to-end gate across everytests/transpiler3/c/fixtures/functions/<name>directory (16 fixtures at landing time).transpiler3/c/build/phase02_2_test.go::TestPhase2ForRange-- end-to-end gate across everytests/transpiler3/c/fixtures/for-range/<name>directory (10 fixtures at landing time).
Sub-phase 2.3 -- 2026-05-22 20:56 (GMT+7)
Integer divide-by-zero in the checked profile must produce a defined
failure: a stable diagnostic on stderr and a fixed exit code, rather
than the C undefined behaviour that x / 0 gives at the hardware
level (SIGFPE on most ISAs, silent nondeterminism on others). Float
NaN/Inf is Phase 2.4; this phase scopes only int / 0 and int % 0.
Goal-alignment audit (2.3)
Phase 2.3 does not move the byte-equal stdout gate forward by itself,
because vm3 returns ErrDivByZero (a Go-error) for divzero rather
than printing to stdout. The user-facing win is the runtime safety
contract: every binary produced by the C-AOT target must either
finish cleanly with stdout matching vm3, or exit with a stable
diagnostic. Without 2.3 the only outcome on a divzero trip is a
host-dependent crash, which makes the target unfit for production
embedding (CI runners, customer machines) and bricks the fixture
gate the moment a fuzzer or human writes a one-line print(1 / 0).
Decisions made (2.3)
- Runtime profile is "checked" by default. Every
int / intandint % intsite goes through a runtime helper that branches onrhs == 0. The--fast-intprofile, which inlines raw C/and%, lands later (Phase 2.X follow-up); only the checked path is wired in 2.3. - Exit code is 5.
abs(MOCHI_ERR_DIVZERO). The spec assigns signed codes (-5) to keep the C-AOT internal numbering aligned with the Mochi error-model namespace, but Unix exit codes are 8-bit unsigned and we want a short, memorable number rather than the wrap value 251. Documented in §9 of the MEP doc. - Diagnostic text matches the runtime namespace. The trip prints
mochi: integer divide by zero\nto stderr. We deliberately do not copy the vm3 oracle textvm3: integer division by zero: vm3's text is an internal Go-side error string, never seen by Mochi end-users, so byte-equality on the divzero diagnostic is not part of the gate. Using themochi:prefix keeps the C-AOT binary's user-facing diagnostics consistent with the rest of the runtime (the MEP-45 §9 error model is the same surface for every code). - Helper lives in
mochi/errors.h+errors.c. New runtime files.mochi_panic_div_zerois_Noreturnand written intoerrors.cso that exact one symbol per trip is needed. The two per-op helpersmochi_div_i64andmochi_mod_i64arestatic inlinein the header, so the divzero branch sits next to the arithmetic at the call site (no function-call cost on the hot path), but the panic body is out-of-line and_Noreturnso the optimiser can drop the post-call dead block. - Both div and mod share the same panic. The spec lumps both
under
MOCHI_ERR_DIVZERO. vm3 raises the sameErrDivByZeroforOpDivI64andOpModI64. No separate "mod by zero" code. - No INT64_MIN / -1 trap in 2.3. That case is C UB but distinct
from divzero (it's overflow, code
MOCHI_ERR_OVERFLOW = -6, debug only). vm3 currently wraps for it. Leaving it as a Phase 2.X follow-up rather than conflating it with 2.3. - Emit changes are local to
emitBinary. WhenopisBinDivI64we emitmochi_div_i64(L, R); whenBinModI64we emitmochi_mod_i64(L, R). Every other op keeps the infix form. The prologue gains#include "mochi/errors.h". No new aotir node, no lowerer change: the IR still says "divide", and emit owns the policy of how to make it safe. - Argument evaluation order matches vm3 by convention. In C the
argument-evaluation order for
mochi_div_i64(L, R)is technically unspecified, but every tier-1 toolchain (gcc, clang, MSVC) evaluates left-to-right in practice and our Phase 2.3 fixtures do not rely on side-effecting subexpressions. Tightening to a sequence-pointed temp-pair lands only if a fixture forces it. - Driver picks up the new runtime files automatically. The embed
FS is extended; the
ccinvocation is changed to walk everyruntime/src/*.crather than hard-codingprint.c. Future runtime files (str.c,list.c, ...) now ride for free. - Negative fixtures get their own subdir. Positive cases (which
do NOT trip divzero) go under
tests/transpiler3/c/fixtures/divzero/. Trip cases (which exit 5) go undertests/transpiler3/c/fixtures/divzero-trip/with anexit.txtandstderr.txtinstead ofexpect.txt. Splitting the fixture shape by directory keeps the gate test simple: each directory has exactly one positive/negative convention. - One Phase 2.3 gate test, two subtests.
phase02_3_test.gohostsTestPhase2Divzero(positive fixtures, stdout match) andTestPhase2DivzeroTrip(negative fixtures, exit code + stderr match). ReusesrunFixtureSuitefor the positive set; the negative set gets a dedicated walker.
Test set (2.3)
transpiler3/c/emit/emit_phase23_test.go::TestEmitPhase23Divzero-- 4 cases pinning thatBinDivI64andBinModI64emitmochi_div_i64(L,R)andmochi_mod_i64(L,R)(not infix), that the prologue gains the errors.h include, and that other binary ops stay infix.transpiler3/c/build/phase02_3_test.go::TestPhase2Divzero-- positive fixtures: end-to-end gate across everytests/transpiler3/c/fixtures/divzero/<name>directory; stdout must matchexpect.txtbyte-for-byte.transpiler3/c/build/phase02_3_test.go::TestPhase2DivzeroTrip-- negative fixtures: end-to-end gate across everytests/transpiler3/c/fixtures/divzero-trip/<name>directory. Each fixture must exit with code 5 and stderr must matchstderr.txtbyte-for-byte.
Sub-phase 2.4 -- 2026-05-22 21:30 (GMT+7)
Float NaN/Inf print parity with vm3. Phase 2.0 left the float-print
path at C printf("%.17g\n", x), which on every tier-1 libc
disagrees with Go's strconv.FormatFloat 'g' -1 64 on three exact
inputs: NaN, +Inf, -Inf. vm3 prints those as NaN, +Inf,
-Inf (Go's fmt.Println convention via %v); C's %g prints
nan, inf, -inf (case + sign-prefix divergence). This breaks
byte-equality the moment any fixture's float arithmetic crosses the
IEEE 754 special-value plane (1.0 / 0.0, 0.0 / 0.0, inf - inf,
NaN propagated through + - * /).
Goal-alignment audit (2.4)
This sub-phase moves the byte-equal stdout gate forward directly:
every Phase 2.4 fixture is a program whose vm3 oracle prints a
special IEEE 754 value to stdout, and the C-AOT binary's stdout must
match that oracle byte-for-byte. Without 2.4, any benchmark that
divides by zero in floats (numerical-analysis kernels, NaN-as-missing
data idioms) silently diverges. Scope is intentionally narrow: NaN
and Inf only, with finite values still routed through %.17g.
Shortest-round-trip (Ryu) for finite values is a separate Phase 2.X
follow-up.
Decisions made (2.4)
- Runtime-only change. The fix lives in
mochi_print_f64. Emit, lower, aotir, and the build driver are untouched. Pre-flight gives us the entire BinDivF64 path already (Phase 2.0); the only thing that was wrong was how the result prints. - Special-case detection via
<math.h>macros.isnan(x)andisinf(x)are C99 macros (not function calls), so the runtime picks them up without-lm. Tested in the gate via cc's default link line (hostcc, vendored Zig fallback both supply them). - Spellings copied from Go.
NaN,+Inf,-Inf-- exactly the stringsfmt.Println(math.NaN())etc. emit. Capitalisation and the leading+on positive infinity are oracle-driven, not invented. - Sign-of-NaN ignored. Go's
%valways printsNaNregardless of the sign bit on the NaN payload (-NaN->NaN). The C runtime does the same: oneisnanbranch, no signbit check. vm3 parity holds. - Finite values keep
%.17g. Existing Phase 2.0 float fixtures (0.5,1.0,2.5,4.0) round-trip identically through%.17gand Go%vbecause%gstrips trailing zeros and these values are exactly representable. Values like0.1would diverge (%.17g->0.10000000000000001, Go ->0.1) and the gate deliberately stays away from them until Ryu lands. - No NaN comparison fixture in 2.4. IEEE 754 says
nan == nan -> falseand both C and Go follow IEEE 754, so comparison parity falls out of Phase 2.0's BinEqF64 lowering without runtime work. Adding a fixture is cheap so we add a couple anyway as confidence checks, but the work to make them green isn't in 2.4 -- it was already in 2.0. - Fixtures live under
nan-inf/. Splitting them out fromprimitives/keeps the Phase 2.4 gate readable and lets the gate test fail loudly on regressions instead of being buried in a ~50-fixture rollup. - Production via
0.0 / 0.0etc., not builtins. Phase 2.4 doesn't addnan()orinf()builtins (those belong with themathstandard library in a later phase). NaN and Inf are produced by float-divzero arithmetic, which is well-defined in IEEE 754 and already emits the right bit pattern under Phase 2.0 BinDivF64.
Test set (2.4)
transpiler3/c/build/phase02_4_test.go::TestPhase2NanInf-- end-to-end gate across everytests/transpiler3/c/fixtures/nan-inf/<name>directory. Stdout must matchexpect.txtbyte-for-byte. Covers: bare NaN production, +Inf production, -Inf production, NaN propagation through each arithmetic op, Inf + Inf, Inf - Inf, Inf * 0, NaN equality (== returns false, != returns true), NaN ordering (< returns false), NaN passed through a user function.
Decisions made
Per-sub-phase decisions appear under each "Sub-phase X.Y" section above.
Deferred work
Tuple return values: Phase 3 alongside records. Big-int / fixed-width ints: not in v1.
Closeout notes
Fill in after gate green.