The five guiding principles behind the Mochi-to-C transpiler (spec-first, boring C, no ABI surprises, portability over performance, verifiable output), plus the runtime shape and a sample C output.
MEP-45 research note 02, Design philosophy
Author: research pass for MEP-45. Date: 2026-05-22 (GMT+7).
This note records the principles the C target should be designed against,
written before any code is touched, so that the implementation can be
audited against them later. The philosophy is distilled from three places:
the language surface (note 01), the Mochi project’s own existing
priorities (README.md “small, statically typed, … zero-dependency single
binary, … built for clarity, safety, and expressiveness”), and the modern
state of the art that the background research agents are gathering.
1. Why a C target at all
There are five concrete reasons a Mochi → C transpiler is worth building in 2026, even though Mochi already has a fast bytecode VM and a JIT:
- AOT distribution. A C output gives Mochi programs an unencumbered path to a single, statically-linked native binary on every triple the user’s C compiler supports, Linux amd64/arm64, macOS arm64, Windows x64, FreeBSD, wasm32-wasi, and embedded targets that the Go-based vm3 cannot reach (microcontrollers, kernels, hypervisors, restricted environments where a Go runtime is unwelcome).
- Smaller artifacts. A linked native binary that pulls in only the runtime objects it actually uses can be ~100 KB for a hello-world; a Go-runtime binary cannot. Important for CDN-deployed scripts and for future “Mochi on your fridge” stories.
- Cold-start latency. Native C, with statically-linked initial heap, starts in <1 ms. The Mochi VM cold-start is dominated by Go runtime init. This matters for serverless and CLI usage.
- Interop with native C libraries. A C-emitting backend can
#includeand call libcurl, libsodium, libsqlite, BLAS, etc. without the marshalling cost ofimport go. This unblocks systems-programming use cases the VM target cannot reach. - An alternate verifiable lowering. The threat-model document names the verifier as the single point of policy. Having a second lowering path (vm3 bytecode and C source) is a structural check against verifier blind spots: bugs that survive both paths are real semantic bugs, not lowering bugs.
These reasons do not override the VM. They make C an additional output
target that the user selects at build time (mochi build --target=c hello.mochi), the same way Go is built or interpreted as the user
prefers.
2. Non-goals
What the C target deliberately does not try to do:
- Replace the VM as the canonical runtime. The VM keeps the hot-iterate, REPL, agent-sandbox, and verified-bytecode positions. The C target is for AOT shipping.
- Match the VM’s startup cost on multi-megabyte programs. Optimised C
link times will be slower than
mochi run. - Expose C-level UB to user code. A Mochi program that compiles to C
must remain memory-safe at the source language level: every dereference
is bounds-checked, every union access is tag-checked, every
intoverflow traps in debug builds. - Be a “Mochi as a C library” embedding API. That is a different MEP. The C target emits C, it is not an API for C callers to invoke Mochi ad hoc.
- Encode every language feature in the first release. The phasing
plan in the MEP body explicitly defers
generate, full agents, full Datalog, and theimport go|python|typescriptFFI to later phases. The first ship is a Mochi-Core subset that covers ~80 % of the SPOJ corpus.
3. Five guiding principles
3.1 Spec-first
Every code-generation rule is documented in the MEP body before it is
implemented. The MEP is the unit of review; the code lands in PRs that
cite the MEP section they implement. This is the “spec-in-sync” rule
already imposed on the Mochi MEP corpus (memory feedback_spec_in_sync)
and it applies to MEP-45 from day zero.
3.2 Boring C
The C we emit should look like something a competent human would write, not like the inscrutable output of a bytecode-flattener. Concretely:
- Named locals, not
t1,t2,t3. - Comments that point back to the source line (
// from main.mochi:42). - One C function per Mochi function, not a giant trampoline.
- C control flow (
if,for,while) for Mochi control flow. - No goto except for
break/continueto labelled enclosing loops, and for exception unwinding where setjmp/longjmp is in play.
This is non-negotiable: the C target is also a teaching tool, a debugging
aid, and an emergency-egress route for users who need to read the output.
Nim’s nimcache output and Vala’s C output both demonstrate that this is
achievable.
3.3 No surprises in the C ABI
The runtime is one library (libmochi.a / libmochi.so) with a stable
header (mochi.h). The emitted user code links against it. The header is
the contract; semantic versioning applies and the MEP body fixes the
v1 surface.
3.4 Portability over performance, where they conflict
The first release targets correctness on every triple zig cc supports.
Performance comes from a second pass once the targets are green. This
ordering matters because portability bugs are caught by the CI matrix,
and adding portability later requires rewrites that performance work
makes harder. (Memory feedback_umbrella_phase_targets: an umbrella
phase only lands when every target in the matrix is green.)
3.5 The C output is verifiable
Every C output should compile under:
-std=c23 -Wall -Wextra -Wpedantic -Werror-fsanitize=address,undefined-D_FORTIFY_SOURCE=3-fstack-protector-strong
with no warnings, on the matrix of (gcc-15, clang-19, zig cc 0.16). Any warning surfaces a bug in the codegen, not a feature of the user code. The CI gate enforces this on the BG corpus.
4. The shape of the runtime
The runtime split is the design’s main lever. The current plan, before the background-research results land, is:
- mochi-core: GC, value-tagging, list, map, set, string, time, duration, error, panic, longjmp-based try/catch, print, format.
- mochi-query: dataset, group, sort, join, set ops; loaders (CSV, JSON, JSONL, YAML, Parquet stub).
- mochi-net: fetch (libcurl), generate (provider shims), webhook helpers.
- mochi-stream: stream/agent scheduler, fiber pool, channel.
- mochi-logic: facts, rules, query engine (semi-naive eval).
- mochi-ffi-go, mochi-ffi-python, mochi-ffi-ts: optional sidecar processes (one binary per host language) speaking the same JSON-over-pipe protocol the existing Go runtime uses.
- mochi-ffi-c: header-only macros for direct
#include+ extern decl.
Each layer is a separate static archive so that a hello-world program
links only mochi-core (target: <100 KB). A program that uses generate
brings in mochi-net + the LLM provider’s TLS/HTTP code (~500 KB). A
program that uses streams brings in mochi-stream (~80 KB). This linker
slicing matters because the C target’s distribution story depends on
keeping the small case small.
5. The taxonomy of what “compiling to C” actually means
A literature review (see notes 03 and 04) shows three distinct flavours of “language → C” transpilation, and we have to pick:
- Fully type-erased: emit
void *everywhere, dispatch through a uniform value representation. This is what classical Scheme-to-C compilers (chicken, gambit) do. Lowest implementation cost, worst cache behaviour, hardest to debug. - Boxed-by-default, unboxed-by-analysis: every value is a tagged union by default; escape analysis unboxes when safe. This is the Nim / Crystal / OCaml-mlton style.
- Monomorphised: every type instantiation produces its own C type and its own C function copy. Generics are specialised. This is the Rust / Cone / Roc / TigerBeetle style; closest to “human-readable C”.
MEP-45 picks #3 with selective boxing for sum types and any-typed
ports. Concretely:
list<int>,list<string>,list<Point>are three separate C types.- A method
areaonCircleand onSquarebecomes two separate C functions. - A sum-type variant payload is boxed when the variant carries a non-trivial payload, inline when nullary.
- A closure carrying an
intcaptures it by value; carrying alist<T>captures by handle.
Rationale: monomorphisation gives us the small clean C output we need for principle 3.2, eliminates indirect calls in the hot loops the BG corpus measures, and aligns with how Roc and TigerBeetle structure their C-adjacent output. The cost is code-size growth from type-parametric helpers; the mitigation is amalgamation + LTO, which collapses identical instantiations.
6. Memory management
The principle: GC by default, opt-in regions for systems users.
Default heap: a conservative or precise tracing GC. The default option in note 04 is BDW-GC because it works on every target zig cc supports, ships in every distro, integrates with C trivially, and Crystal / Nim-refc-fallback / many Schemes all rely on it for the same reasons.
A second option, considered seriously in note 04, is Perceus-style reference counting in the Koka tradition: the type-checker tracks unique vs shared values, RC operations are elided where the analysis proves uniqueness, and a Bacon-Rajan cycle collector cleans up. This generates predictable-latency code and gives systems users the “deterministic free” they care about.
The MEP body proposes: ship v1 with BDW-GC behind a single mochi_gc.h
abstraction, and reserve mochi_gc_perceus as a Phase-3 alternative
that flips at link time. Same C output, different runtime.
7. Concurrency
Two layers:
- Stream/agent dispatch maps to lightweight tasks. The plan: an M:N scheduler over OS threads with assembly-stack-switched fibers (minicoro or libco; both BSD-licensed, both portable to every triple including wasm via Asyncify). Per-agent serial execution; cross-agent parallel.
- Test replay uses the same scheduler with
MOCHI_SCHED=detforced; tasks are popped FIFO and stepped one at a time so atestblock sees a deterministic interleaving.
C11 atomics + pthreads underlie the scheduler. Optional io_uring / kqueue / IOCP / wasi-poll under the libuv-like abstraction.
8. Error model
try/catch lowers to setjmp/longjmp around a per-fiber exception
buffer. Caught values carry a mochi_error struct. This is the same
mechanism Lua, OCaml-bytecode, Nim, and Crystal use; the cost is one
setjmp per try (typically <10 ns on M-class hardware) and one frame
of buffer overhead per active try.
The alternative, full algebraic effect handlers compiled in capability-passing style, is the more elegant lowering and the background research is gathering papers on it. The MEP body proposes shipping setjmp/longjmp for v1 and re-evaluating once Phase 2 has landed; the lowering is sufficiently localised that swapping in effect handlers later is a Phase-N change rather than an architectural one.
9. Build system
One command (mochi build --target=c -o app foo.mochi) is the entire
user-facing surface. Under the hood:
- The transpiler emits one or more
.cand.hfiles into.mochi-build/. - A bundled
cc(default:zig cc, fallback: the system compiler) compiles them. - The runtime archives (
libmochi-core.a,libmochi-query.a, …) are shipped inside themochibinary as embedded blobs (Phase 2 uses#embedfrom C23). - The resulting binary is the user’s artifact.
Reproducibility: set SOURCE_DATE_EPOCH, pin the zig version, emit a
.mochi-build-manifest.json recording every input hash, compiler flag,
and linker order.
10. The “what does the C look like?” answer
A sample to anchor the rest of the design. Given:
fun double(x: int): int { return x * 2 }
let xs = [1, 2, 3]
for n in xs { print(double(n)) }The emitted C should look approximately like:
// hello.mochi.c, generated by mochi 0.x.y on 2026-05-22T17:00:00Z
#include "mochi/core.h"
static mochi_int hello_double(mochi_int x) {
return mochi_imul(x, 2);
}
int main(int argc, char **argv) {
mochi_init(argc, argv);
mochi_list_int xs = mochi_list_int_of3(1, 2, 3);
mochi_for_int(n, xs, {
mochi_print_int(hello_double(n));
mochi_print_newline();
});
mochi_list_int_drop(xs);
return mochi_shutdown();
}Notes embedded in this sketch that the MEP body must fix:
mochi_intisint64_t. Always.mochi_imulis a checked-multiply macro that traps in debug, plain*in release (using<stdckdint.h>from C23 where present).mochi_list_intis the monomorphised list type. The_of3constructor is a varargs-free fixed-arity helper; longer literals callmochi_list_int_from_array(arr, n).mochi_for_intis a macro that wraps aforloop with the right cursor type; no hidden allocation.mochi_list_int_dropis RC-aware in the Perceus variant, no-op under GC.
This sample passes principle 3.2 (boring C), 3.4 (portable, no extensions), and 3.5 (compiles under sanitisers).
11. Closing, the philosophy in one sentence
A Mochi-to-C transpiler should emit C that a Mochi-fluent C programmer would have written by hand, that runs on every target zig cc supports, and that preserves every safety guarantee Mochi makes at the source level.
Everything else in MEP-45 is downstream of that statement.