Spec M24 -- Tier 4: primitives and hardware instructions taught from the surface
Owner's direction (2026-09-04): "capabilities become surface." A developer must be able to
introduce NEW PRIMITIVES without touching mc -- new value types (fp8/fp16 for a GPU particle
study, i128/u128, floats in general) and hardware-specific instructions (a legacy SSE/MMX op, a new
AVX op on a specific CPU) usable on those types -- from the surface, in a module, teachable through
mc build [compiler].modules.
This replaces the previous M24, which put f32/f64 keywords in the lexer, a decimal-to-binary
routine in lex_number, thirteen mf_* machine tasks and a #machine directive inside src/.
That spends ~450 core lines on ONE capability and leaves the next one (fp16, i128, an AVX op, an
8-bit word) exactly as unreachable as before. The rule for this milestone is the plan's:
only mechanisms enter src/, each priced in lines; f32/f64 become the FIRST LIBRARY built
on them. The C seed (stage0/) is frozen, only ever compiles src/mc.mc, and learns nothing.
What already exists #
- Seven types and one width rule.
TY_VOID..TY_UPTR,TY_MAX 7(src/ast.mc:62-69);type_widthis a four-line ladder whose default branch returns 8 (src/ast.mc:213-218);type_nameanswers"?"outside the table (:205-211). - Every type position is already extensible.
type_of_tokenends inalias_find(src/parse.mc:685), and its seven callers are globals, locals, parameters,extern, casts, array elements andp_type()(:771,:1136,:1693,:1921,:1948,:2034).type_aliasregisters the word throughword_addand refusesbase >= TY_MAX(src/hooks.mc:324-333). - The machine seam is complete and already derivable. 31 slots, contract v2
(
src/gen_walk.mc:64-95);machine(name, tab)appends and makes the table current,machine_findsearches back to front (src/hooks.mc:384-400), so re-registering under"arm64"shadows the built-in for every backend'smachine_use.src/machine_x86_64.mc:829-840already derives the Win64 machine by copyingld64(m_x86_64 + t*8)in a loop and swapping one slot. Three ofMAXMACHINES 8are used.dslot,in_reg,val_reg,dst_reg,dst_done,save_live,REG_BASEare entirely private to the machine (src/machine_arm64.mc:97-128,src/machine_x86_64.mc:217-239) -- a second register file needs zero contract lines. - Half of a float ABI is already reachable.
MTASK_PARAMcarriesty(src/gen_walk.mc:65,:881-883), so the callee side could route a float parameter today;MTASK_BIN/CMP/UN/BOOL/CALL/ RETcarry no type at all, so the caller side cannot. That asymmetry is the exact size of the gap. #opcodereaches fixed registers only. It folds constants (#opcode argument not constant,src/gen_walk.mc:601-605) andemit()is exactly 32 bits (:579-580; both machines' word task ends inbuf_u32).examples/conc/lib/atomic.mcdelivers a whole LSE atomic runtime in 60 lines and zero core lines that way, and its own header records the ceiling: an operand must "happen to sit inx0..x7", and anldaxr/stlxrretry loop cannot be expressed at all.- Lowering a taught type to calls is the zero-line baseline.
examples/langdoes exactly that:type_alias("str", TY_UPTR),type_alias("bool", TY_U8), every method an ordinary call through a vtable andcallp. It is the right answer for a target with no FPU, and it stops at four places: the ABI, register-resident values, hardware instructions on values, and the type's size. - A literal cannot be entered.
lex_numberstops at.(src/lex.mc:709-729),.is not core punctuation (:265-292), and every Tier 3 hook is keyed by a tokenword_addcreated (src/hooks.mc:217-222), which can never yieldT_INT. N_BLOB(M21.5) already puts arbitrary bytes into a section (src/gen_walk.mc:372-383);parse_initlist/parse_globalrequire anN_INTelement (src/parse.mc:1974-1978,:2005-2007).
Design #
The rule: a type id below TY_MAX is a core type and behaves exactly as it does today, byte for
byte. A type id at or above TY_MAX was registered by a module, and every core decision about it is
delegated -- width and alignment to the registry, representation/register file/spill/ABI to the
machine, literals to a syntax_lit handler, instruction selection to a derived machine table. The
core never learns what a float is. Eight mechanisms, 187 core lines, contract version 3 with
no slot added and no signature changed (the bump is documentation: what a module may rely on).
M1 -- the type registry (~55 lines) #
i64 type_new(uptr name, i64 width, i64 align, i64 kind) -> ty // ty >= TY_MAX
i64 type_width(i64 t) // existing name, registry-aware above TY_MAX
uptr type_name(i64 t) // ditto: --dump-ast prints `type=f64`, not `?`
i64 type_kind(i64 t) i64 type_align(i64 t) i64 type_count()
#define TK_INT 0 / TK_FLOAT 1 / TK_WIDE 2 / TK_OPAQUE 3
type_new registers the word through the SAME table type_alias uses, so type_of_token
(src/parse.mc:677-686) needs one line and the word becomes valid in all seven type positions
at once. type_alias's guard widens from base >= TY_MAX to base >= type_count(). A growable
arena block under M23's rule (it scales with what a module teaches), seeded with the seven core
widths. kind is what a machine dispatches on when it does not know the exact id; the core consumes
only width and align. Seed: none -- an ordinary function called from user_init(), no
keyword and no directive, so tok_init (src/lex.mc:246-293) is untouched, K_U8..K_EXTERN do not
shift and check-lex keeps cross-checking the two lexers.
M2 -- the literal's type survives resolve (1 line) #
res_expr's N_INT arm sets TY_I64 unconditionally (src/gen_resolve.mc:409), discarding
nd_type. Under the design below a taught literal is an N_INT whose nd_type is the module's, so
without this the type is thrown away before the walker or any machine sees it. Honour nd_type when
it is >= TY_MAX. This one line is load-bearing and was missed by every proposal.
M3 -- constant folding stops at a type the core did not define (~6 lines) #
fold_binary folds any two N_INTs regardless of type (src/parse.mc:958-969), fold_unary one
(:941-957), fold_cast masks by 1/2/4 bytes (:971-983). With a float literal carried as its
IEEE bit pattern, 1.5 + 2.5 would fold to an INTEGER add of two bit patterns and produce an
infinity at compile time with no diagnostic; -1.5 would integer-negate a bit pattern. Three early
returns on nd_type(...) >= TY_MAX. The core has no arithmetic for a type it did not define, so it
leaves the node to the module's machine. Inert today: no node in the tree carries such a type.
M4 -- the depth type, as walker functions (~30 lines) #
i64 walk_depth_type(i64 d) // the type of the value at depth d; TY_I64 by default
i64 walk_ret_type() // the type the value ABOUT to land at this depth will have
A MAXDEPTH-sized array beside frame_off, reset in gen_func (src/gen_walk.mc:870), written
from res_type(n) at the top of gen_expr (:660-679). Not a task slot: no signature moves,
the contract stays additive, and a machine that never reads it emits byte-for-byte what it emits
today. This is the mechanism the old spec's thirteen mf_* slots existed to work around --
MTASK_BIN(MOP_ADD, d, d2) with walk_depth_type(d) == f64 is fadd, MTASK_RET(d) returns in
v0, and MTASK_CALL(d, na, sym) walks walk_depth_type(d + i) and runs the AAPCS64 NGRN/NSRN
split -- the whole float ABI, for free.
Re-announcement is part of the mechanism, not a caveat. Five sites produce a value whose type
differs from what the child announced, and each must set the entry after the task:
gen_binary's comparison branch (:527), gen_logic's shortcut MTASK_CONST (:518),
MUN_LNOT, gen_intrin's load, and gen_call -- where depth d is overwritten by argument 0
(:648-656), which is exactly why walk_ret_type() is mandatory rather than convenient.
Without this, (a < b) + 1 on taught floats emits a float add on an integer 0/1, silently.
M5 -- the frame slot is sized by the type (2 lines) #
slot_new(8) is unconditional for a scalar local (src/gen_walk.mc:693) and for a parameter
(:881); both become slot_new(type_width(ty)). Provably byte-identical for all seven core
types, because slot_new rounds (size + 7) & ~7 (:321-324) -- widths 1..8 give the same
offset. A 16-byte type gets 16. No asymmetric helper, no TY_MAX clause. The local-array path
(:686-688) and gen_globals (:924-933) already read type_width.
M6 -- syntax_lit(&f) (~28 lines) #
void syntax_lit(uptr fn); // i64 f() -> node index, or 0 = the core handles it
Consulted by parse_primary where it is about to build the N_INT/N_CHAR node
(src/parse.mc:705-712); shaped like on_stmt (src/hooks.mc:116-139), short-circuited by
if (nonlit == 0) so an untaught compiler does not even make the callp. The handler reads the raw
lexeme through p_start() (:1737) and does its own decimal-to-binary conversion. This is the one
grammar position Tier 3 genuinely cannot reach, and it is generic: it says "numeric literal", not
"float". The decimal-to-binary routine therefore lives in the MODULE, not in lex_number --
which is what keeps --dump-tokens byte for byte what the frozen stage0/lex.c produces and
check-lex meaningful over the whole tree. The old spec would have ended that.
The documented fallback stays: #token "." + syntax_infix(".", prec, &f) works for D.D, costs
zero core lines, and is rejected as the mechanism because it reserves . program-wide (colliding
head-on with examples/lang, whose lg_dot owns it, and syntax_infix refuses the second
registration outright), cannot spell 1e-3 at all, and makes the handler guess the token span.
M7 -- intrinsic(name, nargs, ty, &f) (~55 lines) #
void intrinsic(uptr name, i64 nargs, i64 ty, uptr fn); // void f(i64 d, i64 nargs)
i64 intrinsic_find(uptr name);
One row inserted between opc_find and func_find in the dispatch both res_call
(src/gen_resolve.mc:366-394) and gen_call (src/gen_walk.mc:638-658) already run in that order,
so a core intrinsic can never be shadowed and every existing diagnostic keeps its order. The handler
receives its arguments already lowered to depths d..d+nargs-1 with walk_depth_type filled in.
This is the mechanism the owner's principle actually asks for and the only one with no zero-line
alternative. Traced: #opcode refuses a non-constant argument, so a named instruction can only be
applied to pinned registers inside a whole leaf function; syntax_expr can build any node the CORE
already lowers but gen_expr ends in expression with no codegen (:678), so a module cannot
introduce a node kind; gen_call ends in call to unknown function (src/gen_resolve.mc:389), so
it cannot introduce a call. ldf64(p), f16_to_f32(h), sqrt_f64(x) and one AVX op are the same
twelve module lines after this.
Paired with M7, contract v3 publishes val_reg(d, scratch), dst_reg(d) and dst_done(d, reg)
-- present with identical signatures in both machines (src/machine_arm64.mc:115-128,
src/machine_x86_64.mc:226-237) -- as the names a handler may call to find where the allocator put
its operands. Zero lines; it is a decision about what is frozen, taken deliberately rather than by
convention.
M8 -- deriving a machine (~10 lines) #
uptr machine_tab(uptr name); void machine_slot(uptr tab, i64 task, uptr fn);
machine_task writes the global m_arm64 by name (src/machine_arm64.mc:702) while
docs/reference/hooks.md:145-146 tells a module author to derive a table with it -- following the
published recipe corrupts arm64's own table (M39's G9). A module can already derive with ld64/
st64 (the Win64 machine does), so this is contract and safety, not capability, and the doc bug is
fixed with it. Delegation needs no new names: the module copies the table first, so the
built-in implementation of any slot is a pointer it holds and can callp -- which is what keeps a
float module from reimplementing integer codegen without freezing a64_bin/a64_const as surface.
M9 -- --dump-machine (~40 lines, step B, observability not mechanism) #
Per registered machine, one line per task: task name, bundled or the module's symbol. This is the
audit the old § 4 promised, kept; the #machine directive it was attached to is dropped.
Why #machine is dropped (a reversal, recorded rather than silently omitted): it is a fourth
encoding-template language after #opcode and x86_desc; a task is not an instruction --
MTASK_BIN(MOP_ADD, d, d2) on a spilled depth is a load, an op and a store
(src/machine_arm64.mc:113-126), so a one-word template is false for every deep expression; and it
would be a directive the frozen stage0/lex.c dir_names[] cannot parse, so it could never appear
in src/*.mc anyway. machine_slot + #opcode + intrinsic + --dump-machine reach the same
place with no new syntax.
Step 1 -- <float>, the first library #
All outside src/, bundled (tools/bundle.list +4). lib/user_default.mc does not register
it: the stock mc has no floats, which makes "objects identical to the seed" a structural fact
rather than a tested coincidence, and float programs are built by a taught compiler the way
examples/api and examples/lang are.
| file | lines | what |
|---|---|---|
lib/float.mc | ~340 | type_new("f64", 8, 8, TK_FLOAT), f32, f64raw; the syntax_lit handler with correctly-rounded decimal-to-binary in .mc; ldf*/stf* and sqrt_f64/fabs/fmin/fmax via intrinsic; putf64; user_init |
lib/machine_arm64_float.mc | ~280 | machine_tab("arm64") copied, ~14 slots replaced, the rest delegating through the copied pointers |
lib/machine_x86_64_float.mc | ~320 | the same over x86_64 and x86_64-win (SSE2) |
lib/mc_float.mc | ~20 | #include <mc/core> + the three + user_init() |
tests/float/*, scripts/check-float.sh | ~420 | the proof |
The literal is an ordinary N_INT whose nd_type is the module's and whose val is the IEEE-754
bit pattern. That single decision removes four would-be mechanisms: parse_initlist and
parse_global accept it unchanged, so f64 tbl[] = {1.5, 2.5}; parses; glob_place writes it at
type_width bytes into __data with no float-aware code in any object writer; and MTASK_CONST(d,
val) reaches the machine, which materialises movz/movk into a scratch x and one fmov d, x
-- no literal pool, no relocation, no new task. M2 and M3 are what make it sound.
The ABI, per host -- all of it inside the module's machine, none of it in src/:
| target | int args | float args | float return | float depths | notes |
|---|---|---|---|---|---|
| macOS/arm64, linux/aarch64 | x0..x7 (NGRN) | v0..v7 (NSRN) | v0 | v16..v23 | never d8..d15 (callee-saved); M38 stack-arg path reused as written |
| linux/x86_64 (SysV) | rdi rsi rdx rcx r8 r9 | xmm0..xmm7 | xmm0 | xmm8..xmm13 | xmm14/15 spill scratch |
| windows/x86_64 (Win64) | rcx rdx r8 r9 | xmm0..xmm3, slot-shared | xmm0 | xmm0..xmm5 | xmm6..xmm15 are callee-saved, so depths spill from 6 |
| windows/aarch64 | as AAPCS64 | as AAPCS64 | v0 | v16..v23 | prologue slot only |
% on floats: bin_op maps K_MOD to MOP_UMOD for any non-i64 type
(src/gen_walk.mc:472-483, :528); the module's MTASK_BIN refuses it with no float remainder.
That is a die in the module, not a core typing rule -- res_binary already inherits the left
operand's type and already yields TY_I64 for comparisons and &&/||
(src/gen_resolve.mc:398-404), which is the whole rule a float family needs.
putf64(x, digits) is a fixed-precision formatter (half-up rounding from a table of f64raw bit
patterns, fcvtzs, digit loop, nan/inf by bit pattern, hex fallback past 2^63). No Ryu, no
shortest-representation claim; said plainly in the guide.
ldf64/stf64 are intrinsic registrations (M7), one instruction inline. The zero-core-line
alternative -- a second registered type f64raw reached through a cast pair -- is documented in
lib/float.mc's header as the fallback, with its cost named: one extra GPR round-trip per element
access, which a particle study will feel.
Generality -- the acceptance of the principle #
fp16: feasible, zero further core lines. type_new("f16", 2, 2, TK_FLOAT). Width 2 drives
glob_place (buf_u16), array bounds (src/parse.mc:1047, :2011) and, through M5, a 2-byte frame
slot; MTASK_LOCAL_LOAD/GLOBAL_LOAD already carry ty, so ldr h/str h need no task. Where the
hardware converts (fcvt s, h; vcvtph2ps with F16C) that is one intrinsic; where it does not,
the same registration lowers to a call into a softfloat routine the module pushes as a second source
(p_push_source, the sd_rt pattern). The module states what it built for -- there is no
CPU-feature model in mc and there should not be one.
i128: feasible memory-resident, zero further core lines. type_new("i128", 16, 16, TK_WIDE).
M5 gives the 16-byte local and parameter slot; M4 tells the machine the depth is wide. The value
lives in ONE depth backed by a 16-byte slot the machine asks slot_new(16) for -- a value spanning
two depths would collide with gen_binary's depth + 1 (src/gen_walk.mc:525) and gen_call's
depth + i, i.e. it would change the walker's own arithmetic. Carry survives the spill on both
hosts (ldr/str do not touch NZCV, mov does not touch x86 flags): adds/adc, subs/sbc,
mul/umulh, cmp/sbcs, whose result the walker already types TY_I64. Two known holes, both
with zero-line answers: MTASK_CONST and N_INT's val are one i64 each, so a 128-bit literal
goes through a module-private global plus a load; and glob_place writes width 1/2/4 and otherwise
buf_u64 (:377-383), so a 16-byte global initializer must be emitted as an N_BLOB, not as one
element. Register pairs would need MTASK_DEPTH_SPAN (~20 lines, a real contract change) and are
deferred -- nothing has yet shown the memory form too slow.
One AVX instruction named by encoding, on x86-64: feasible with M7.
type_new("v8f32", 32, 32, TK_OPAQUE) gives 32-byte globals and slots;
intrinsic("vaddps", 2, ty_v256, &do_vaddps) gives the handler two depths, and it calls val_reg/
dst_reg for the registers the allocator chose, builds the VEX bytes and adds its own opcode, which
its derived machine encodes, sizes and dumps. Measured limits, on the record: VEX2 with a bare
[reg] operand is exactly 4 bytes (vmovups ymm0,[rdi] = C5 FC 10 07) and is reachable through
#opcode today in a fixed-register leaf function; VEX3 (vfmadd231ps, 5 bytes), any displacement
and any immediate are not, because gen_word refuses > 32 bits and both MTASK_WORD slots write
buf_u32. A module's OWN machine has no such limit (x86_put already emits 1..10 bytes), so M7
reaches them; only emit()/#opcode from a source file stay 4-byte-bound. emitb(v, n) +
MTASK_BYTES (~40 lines, one appended slot) is deferred to the milestone that names a VEX3
instruction from a source file. Also record the trap: on x86 the #opcode word is a little-endian
byte string, so C5 FC 10 07 is written emit(0x0710FCC5). Frame alignment is 16
(src/gen_walk.mc:892), so a 32-byte-aligned spill is unreachable and the machine uses vmovups --
which it would use anyway.
The narrow word (M39 G10, M40): a separate axis. Not unlocked. M1 makes the width of a type a
registry entry and M5 removes the two slot_new(8)s -- 3 of M39's ~400 lines. The other ~397
are the data model and are untouched: N_INT is typed TY_I64 by the parser and by res_expr;
callp returns TY_I64 unconditionally (src/gen_resolve.mc:375); ld64/st64 are the only
8-byte accessors and every #define offset in lib/*.mc, examples/*/lib/* and the compiler's own
records is a multiple of 8; MAXDEPTH 64 spill slots at 8 bytes is 512 bytes of a 2 KiB SRAM before
the first local; and tests/024-arena.mc's 4096-byte heap does not fit at all. The wide side (an
i64 as four AVR registers) IS the i128 mechanism above, in a machine, with no core change. The
pointer side would need type_width to become machine-declared, which makes array-size diagnostics
(src/parse.mc:1047, :2011) depend on --machine= -- a source whose meaning varies with a flag,
a decision M40 must take deliberately. docs/specs/M39.md:195 and :253-265 stand: out of reach,
and this spec must not be written as if it followed.
Out of scope #
#machine in every form. Any float in stage0/ or in src/mc.mc. Float literals, NaN semantics,
putf64, ordered predicates, %-on-float -- all module. A CPU-feature model. Correctly-rounded
shortest float printing. a[i], struct, typed pointers, aggregates, members, generics, user
conversions: type_new gives PRIMITIVES, and a module that wants structure is back to
examples/lang's lowering-to-uptr, which is fine and must be said plainly.
Deferred with a price: emitb/MTASK_BYTES (~40, one slot), MTASK_DEPTH_SPAN (~20, one slot),
type_set_width for M40 (a mechanism with no caller is dead core).
Files and estimated deltas #
| file | delta | what |
|---|---|---|
src/hooks.mc | +95 | M1 registry, M6 syntax_lit, M7 intrinsic, M8 two accessors; word_is_taught +1 |
src/ast.mc | +12/-6 | type_width/type_name fall through to the registry |
src/parse.mc | +12 | one line in type_of_token, M3's three guards, M6's dispatch |
src/gen_resolve.mc | +14 | M2's one line; M7's lookup and result type |
src/gen_walk.mc | +36/-2 | M4 array, readers, re-announcements; M5's two lines; M7's branch |
src/main.mc, src/driver.mc | +45 | --dump-machine |
src/machine_*.mc | +0 | untouched; three names become published contract |
lib/float.mc, lib/machine_{arm64,x86_64}_float.mc, lib/mc_float.mc | +960 | step 1 |
lib/f16.mc, lib/i128.mc, examples/avx/ | +~700 | generality proofs |
tests/float/, scripts/check-float.sh, tools/bundle.list | +420 | the corpus |
| docs | -- | machine.md v3 (§ 3 and § 4 rewritten), hooks.md (+9 symbols, :145-146 fixed), language.md § 2/§ 11, cli.md (16 flags), new docs/guide/96-a-new-primitive.md |
Core total 187 lines in step A+B (132 inert + 55), plus 40 for --dump-machine.
stage0/ untouched, 2846/3000.
Acceptance #
Two gated steps, as M21 (decision 7.5) and M17 were.
Step A -- the inert half (M1-M6, M8; 132 lines). The gate is that nothing moves:
check-obj 32/32 byte-identical to the frozen build/mc0, check-asm 74/74, check-ast
74/74, check-lex 74/74, bootstrap at a fixed point (cmp build/mc2.o build/mc3.o) with an empty
--dump-asm diff between mc1 and mc2, golden rewritten once and only after both,
check-limits 17/17 under 90%, test-linux/test-linux-x86_64/test-windows-x86_64 unchanged.
Plus the M17-step-A proof, which is the only one that settles it: a copy of build/mc1 from before
the change and the one after produce byte-identical objects for all 32 tests/*.mc, for
src/mc.mc, and -- through the taught compilers each builds -- for examples/api/main.mc,
examples/lang/main.lx, examples/conc/main.lx and examples/desktop/main.mc.
Step B -- intrinsic and --dump-machine (55 + 40). Golden rewritten once, after the empty asm
diff; check-obj still 32/32, because no test in the corpus registers anything.
scripts/check-surface.sh gains one case per mechanism: a type_new colliding with a core
keyword (cannot redefine core keyword); a taught type reaching a cast, a parameter and a 16-byte
slot; a probe machine that asserts walk_depth_type at every task over the whole of src/mc.mc
(the "prove it over 996 functions" style the ABI assertions already use); a module registering
syntax_lit and returning 0 for every literal, whose --dump-ast from main on is byte-identical
to mc1's (the M21.5 inertness shape); an intrinsic that cannot shadow ld64; the fold guards
through +, -, ~, ! and a cast; and a re-registered arm64 being what --dump-machine
reports after machine_use("arm64").
Step 1, <float>: tests/float/ covering arithmetic and precedence; 1.5, 2.0, 1e-3,
0.25f, -0.0; (i64) f truncation toward zero on both signs; (f64) i and (f64) u; f32<->
f64; a NaN pair where all six ordered predicates are false and != is true; a call with 6 integer
and 6 float arguments forcing both the spill and the stack-argument path; an array of f64;
putf64(3.5, 3) -> 3.500; and sqrt(2.0) through extern f64 sqrt(f64) -- the case that is
flatly unreachable today, because MTASK_CALL cannot be told an argument is a double.
Bit-exact: each stores its result with stf64, reads it back with ld64 and prints 16 hex
digits against a literal in the test header, produced once by python3 and recorded, so the suite
has no python3 dependency. The same source file is the oracle on all five legs -- macOS/arm64,
linux/aarch64, linux/x86_64, windows/aarch64, windows/x86_64 -- via make check-float plus the
existing cross-compile scripts, self-skipping without Docker/ld.lld.
llvm-mc sweep, mandatory for any machine that lands in lib/: every distinct instruction the
float machines emit while compiling the whole corpus re-assembles byte-identically under
llvm-mc -triple=aarch64-... and -triple=x86_64-linux-musl -- the standard M17 step B (948) and
M20 (967) were held to, and the only credible check on a hand-written encoder or on a
MTASK_INS_SIZE/MTASK_ENCODE disagreement.
Generality, proved by three modules whose git diff src/ is empty: f16 (a global array of 8
occupying 16 bytes, checked in the object; an f32->f16->f32 round trip against a recorded reference
where round-to-nearest-even bites; the software fallback exercised by building x86-64 without F16C);
i128 (--dump-asm showing a 16-byte slot and an adds/adc pair, a value passed to a
two-register callee, mc limits showing the frame grow by 16); and examples/avx (one instruction
named by encoding applied to two values the allocator placed, re-assembling under llvm-mc, with
// skip-<arch>: on the other legs and a README stating which VEX forms are reachable).
A deliberately wrong machine_slot(tab, MTASK_BIN, &wrong_fadd) changes the program's answer and
--dump-machine shows the changed origin -- the observable-override proof the old § 4 asked of
#machine, delivered without the directive.
make check green end to end, RC 0, with check-float added.
Risks #
fold_*is a silent corrupter. M3 is six lines that do nothing today and everything on the first float program; land it in the same commit as anything that can produce a typedN_INT, with its own negative test. Same class as the post-M11 8-byte-relocation bug.- A stale depth type is wrong code, not a diagnostic. M4's re-announcement list is the
mechanism; the probe machine over all of
src/mc.mcis the only check that can cover it. .is a scarce resource.syntax_infixrefuses a second registration outright (operator already taught), so a compiler taught bothlxand a.-based float literal fails atuser_init-- the good failure, but a failure. M6 avoids it; the fallback must document the chaining precedent (lg_more(),conc.mcwrappinglg_block).- Machine composition is last-wins and lossy.
machine(name, tab)replaces the whole table, so two modules teaching two type families collide silently.type_newcomposes (distinct ids) and the intrinsic registry composes (distinct names); the machine does not. A stated limit, with the chain-of-wrappers protocol named as a design of its own. MAXMACHINES 8, 3 used,<float>shadowing three names. Either reuse the slot when a registration shadows an existing name (~6 lines) or raise to 16 with the reason written down -- but do not lettoo many machinesbe discovered by a user stacking two taught modules.check-lex.sh:37iterateslib/*.mcand has NOseed-skipescape (check-asm.sh:28andcheck-ast.sh:38do, used once atlib/sys_windows_host.mc:4). The frozen seed cannot lex1.5, so no file underlib/may contain a float literal --lib/float.mcuses bit patterns, and anything that needs literals lives intests/float/or is pushed as a second source. Add the escape tocheck-lex.sh(~6 lines) as the fallback.res_typeanswersTY_VOIDfor nodes built aftergen_resolve(src/gen_resolve.mc:225-235, deliberate, forgen_opcode). Taught modules build nodes, so this becomes far easier to hit and surfaces asvalue of type void. Worth a diagnostic that names the situation.- Seed capacity, not seed lines.
stage0/is untouched but must still compile asrc/mc.mccarrying all of this (~10 functions, ~25 strings). M17 step B already raisedHEAP_SIZEto 64 MiB and left funcs at 996/2048, heap at 46%.make check-limitsis the gate; if a row goes tight, the answer is the seventeenth-row discipline, not a second seed change. type_newreserves the word program-wide (word_add), so loading<float>breaks a project with a variable calledf32. Correct and consistent withtype_alias; keeping<float>out oflib/user_default.mcmeans nobody gets it by accident.- Scope. Eight mechanisms plus a library plus three proofs plus five CI legs is large. The
review question for every proposed core line is: would fp16, i128, an AVX op and an 8-bit word
each need this, or only floats? If only floats, it belongs in
<float>.
Decisions (architect, 2026-09-04 -- every recommendation below is adopted) #
intrinsic(M7): in, or deferred? The two verdicts split. One holds that af64rawcast pair plus#opcodeleaf functions already reach it at zero core lines; the other that a named hardware instruction on values the allocator placed has no route at all. Recommend: in, at step B. The cast trick reaches reinterpretation only, and the leaf-function trick is fixed-register by construction --examples/conc/lib/atomic.mc's own header records that anldaxr/stlxrloop cannot be expressed and that it passes on Apple silicon, i.e. a silent portability trap. Without M7 the AVX half of the owner's principle is not delivered.val_reg/dst_reg/dst_donepublished (contract v3). Recommend: yes, and keep the published set to exactly those three; delegation to built-in task implementations goes through the copied table pointer, so noa64_*name is frozen.--dump-machine(M9). Recommend: keep, at step B, priced separately as observability. It is what makes a derived machine reviewable and is the cheapest observable-override test. Cut first if the budget binds.machine_tab/machine_slot(M8) or a documentation fix alone? M39's D7 offers the 0-line option. Recommend: take the 10 lines, and fixhooks.md:145-146with them -- a published recipe that corrupts a live table is worse than either.MAXMACHINES. Recommend: reuse the slot on a shadowing registration (~6 lines) rather than raising the constant; it keeps the "does not scale with the program" argument true.- Where does
<float>live? Recommendlib/+ bundle, out ofuser_default, with risk 6's rule (no float literal underlib/) and thecheck-lexescape added as insurance. emitb/MTASK_BYTESandMTASK_DEPTH_SPAN. Recommend: both deferred, each with its price written here, to the milestone that actually needs it (a VEX3 instruction named from a source file; a register-pair wide value shown to be needed).type_set_width(TY_UPTR, w). Recommend: not built. A mechanism with no caller is dead core, and makingtype_widthmachine-dependent makes a source's meaning depend on--machine=. It lands with M40, which owns that decision.- Two gated steps or one commit? Recommend two: step A is inert and verifiable alone, and if
step 1 slips,
examples/kernelandexamples/langstill get M8 and M4 immediately.
Adopted, one line each: D1 intrinsic in, at step B; D2 val_reg/dst_reg/dst_done are the
published set, nothing else of a machine's internals; D3 --dump-machine kept, #machine dropped
for the three reasons recorded under M9; D4 machine_tab/machine_slot built and hooks.md
corrected with them; D5 a shadowing machine() registration reuses the slot; D6 <float> lives
in lib/ and the bundle, outside lib/user_default.mc, with the check-lex seed-skip escape
added; D7 emitb/MTASK_BYTES and MTASK_DEPTH_SPAN deferred at the prices above; D8
type_set_width not built -- the narrow word is M40's decision; D9 two gated steps, each with
make check green and the golden rewritten once. The architect's additions: (a) the three
generality modules (f16, i128, examples/avx) are ACCEPTANCE, not stretch goals -- the
principle is only proved when a module the core has never heard of works; (b) tests/float/ is the
same source on all five legs; (c) the review question of risk 10 is applied to every core line in
the PR description, line by line; (d) M40 is written as its own spec before any narrow-word code.