MEP 48. Mochi-to-.NET transpiler: NuGet ecosystem, async/await colouring, NativeAOT single-file binaries
| Field | Value |
|---|---|
| MEP | 48 |
| Title | Mochi-to-.NET transpiler |
| Author | Mochi core |
| Status | Draft |
| Type | Standards Track |
| Created | 2026-05-23 06:30 (GMT+7) |
| Depends | MEP-4 (Type System), MEP-5 (Type Inference), MEP-13 (ADTs and Match), MEP-45 (C transpiler, IR reuse), MEP-46 (BEAM transpiler, IR reuse), MEP-47 (JVM transpiler, IR reuse) |
| Research | ~/notes/Spec/0048/01..12 |
| Tracking | /docs/implementation/0048/ |
Abstract
Mochi today ships vm3 (mochi run), an ahead-of-time C transpiler producing native single-file binaries (MEP-45), an Erlang/BEAM transpiler producing supervised concurrent runtimes (MEP-46), and a JVM transpiler producing Maven-Central-interoperable jars and GraalVM native-image binaries (MEP-47). None of these paths gives users the .NET ecosystem: 6+ million packages on NuGet, CLR reified generics (no JVM-style type erasure), value types as first-class citizens (record struct, readonly struct, Span<T>), LINQ as the canonical query API across IEnumerable<T>/IAsyncEnumerable<T>/IQueryable<T>, System.Threading.Channels as a battle-tested back-pressure-friendly mailbox, async/await colouring as the canonical .NET concurrency model, NativeAOT for ~5MB single-file static binaries with sub-30ms cold start, MAUI / Blazor WASM / Unity / Godot for mobile / web / game integration, and deep first-class Windows enterprise integration via Visual Studio, MSBuild, ASP.NET Core, Orleans, EF Core, and the Microsoft tooling stack. MEP-48 specifies a fourth transpiler pipeline that targets the .NET Common Language Runtime (CLR) via C# source emitted through Roslyn (Microsoft.CodeAnalysis.CSharp), with a System.Reflection.Emit direct-IL fallback (using .NET 9's PersistedAssemblyBuilder for saveable assemblies) for hot lowerings.
The pipeline reuses MEP-45's typed-AST and aotir IR, plus the monomorphisation, match-to-decision-tree, and closure-conversion passes shared with MEP-46 and MEP-47. It forks at the emit stage: instead of emitting ISO C23 (MEP-45), Core Erlang via cerl (MEP-46), or Java source via JavaPoet (MEP-47), it emits C# source as Roslyn SyntaxNode trees, feeds them to an in-process CSharpCompilation, and collects the resulting .dll files. Three packaging targets ship together: --target=dotnet-fx-dependent (default for mochi build) produces a framework-dependent .dll plus .exe apphost runnable via dotnet app.dll on any installed .NET 8+ runtime (~150 KB user code, ~500ms cold start, requires .NET runtime on $PATH); --target=dotnet-self-contained produces a self-contained publish directory bundling a minimal CLR for a specific RID (~50 MB un-trimmed, ~15 MB trimmed, ~200 ms cold start, self-contained); --target=dotnet-aot produces a NativeAOT single-file static binary (~3-6 MB, sub-30 ms cold start, no .NET runtime required, closed-world). A fourth secondary target --target=dotnet-singlefile produces a single-file extracting executable for non-AOT-eligible programs (Phase 2). Phase-3 secondary targets --target=dotnet-maui (mobile / desktop GUI), --target=dotnet-blazor (browser WebAssembly), --target=dotnet-unity (Unity game engine after Unity 6.8 CoreCLR migration), and --target=dotnet-godot (Godot game engine) ship via sub-MEPs MEP-48.1 through MEP-48.4.
The master correctness gate is byte-equal stdout from the produced .dll or NativeAOT binary versus vm3 on the entire fixture corpus, across .NET 8 LTS and .NET 10 LTS, on x86_64-linux-gnu, aarch64-linux-gnu, aarch64-darwin, and x86_64-windows. vm3 is the recording oracle for expect.txt; the transpiler does not link against or depend on vm3.
Five load-bearing decisions:
-
C# source via Roslyn
SyntaxFactory, withSystem.Reflection.Emitfallback. The default emit path produces C# syntax trees, then invokes Roslyn'sCSharpCompilationin-process to generate.dllfiles. C# source carries no version drag (Roslyn ships with the .NET SDK), gives us free static typing checks (catching transpiler bugs at compile time), produces debuggable output (a developer can read the emitted C#), and exploits the entire C# feature set including records, list patterns, switch expressions, primary constructors, collection expressions, and[ModuleInitializer]. For hot lowerings where source is too verbose or where NativeAOT trimming would strip a reflectively-invoked method (agent dispatch trampolines),System.Reflection.Emit(andPersistedAssemblyBuildersince .NET 9) emits IL directly. See 05-codegen-design §1-§4. F#/VB.NET as IR rejected: see 02-design-philosophy §7 and 12-risks-and-alternatives §A1-A2. -
.NET 8 LTS minimum, .NET 10 LTS preferred. .NET 8 (November 2023) is the floor: records (C# 9+, finalised C# 12), primary constructors (C# 12), collection expressions (C# 12), list patterns (C# 11), switch expressions (C# 8),
System.Threading.Channels(matured),IAsyncEnumerable<T>(C# 8 with full library surface),FrozenDictionary<K,V>/FrozenSet<T>(.NET 8), NativeAOT (GA .NET 7, hardened .NET 8). .NET 10 (November 2025) adds: C# 14 with extension types (preview),OrderedDictionary<K, V>in the BCL,dotnet run app.csfile-based-app mode, refined NativeAOT (smaller binaries, faster ILC), improved trim warning diagnostics. The build emits with<TargetFramework>net8.0</TargetFramework>for compatibility; CI runs the full matrix on net8.0 and net10.0. .NET 6 LTS rejected (EOL November 2024). .NET 7 STS rejected (EOL May 2024, no current support). See 02-design-philosophy §2 and 07-dotnet-target-portability §1. -
System.Threading.Channels+ async/await colouring for agents and streams; no Loom equivalent, no actor framework. Mochi agents lower one-to-one to aChannel<TMessage>(whereTMessageis a sealed record class union overon-handlers) plus anasync Taskdispatch loop. Streams lower toIAsyncEnumerable<T>with operators fromSystem.Linq.Async. The async colouring pass propagatesasync Task<T>(red) vs synchronous (blue) function colour through the static call graph; Mochi surface code never typesasyncorawait. No Akka.NET, Orleans, Proto.Actor, or Microsoft.Dataflow as runtime defaults; the runtime exposes adapter helpers for users who want those packages. See 09-agent-streams §1-§6, 02-design-philosophy §13, 12-risks-and-alternatives §A4. -
Reuse MEP-45's
aotirIR. The IR is target-agnostic; monomorphisation, match-to-decision-tree, and closure-conversion passes run once and feed four backends. The fork is at the emit pass:transpiler3/dotnet/lower/lowersaotirto C#-source structural nodes;transpiler3/jvm/lower/lowersaotirto Java-source structural nodes;transpiler3/beam/lower/lowersaotirtocerlrecords;transpiler3/c/emit/lowersaotirto C. Sharing the IR keeps the four targets semantically aligned and amortises pass-implementation work across all four. See 05-codegen-design §5. -
BCL as fat runtime;
Mochi.Runtimeas thin runtime. The runtime libraryMochi.Runtime(Apache-2.0, ~5000 LOC of C#) provides only what the BCL does not: a Mochi-typed function delegate zoo (for arity > 16, the BCL'sFunc/Actionceiling),OrderedMap<K,V>/OrderedSet<T>(on .NET 8;OrderedDictionary<K,V>is BCL on .NET 10+), a small Datalog engine,DiagnosticSourceevent definitions, the AI / FFI dispatch tables, and the agent supervisor. Everything else (HTTP, JSON, time, regex, locale, file I/O) goes through the BCL directly.System.Text.Json,System.Threading.Channels,System.Collections.Immutable,System.Linq.Async,YamlDotNet, and optionalBouncyCastle.Cryptographyare the only vendored NuGet deps. Newtonsoft.Json, Autofac, AutoMapper (non-source-gen modes), and Castle.Core are explicitly rejected as runtime deps. See 04-runtime §1-§17, 02-design-philosophy §4 and §10, 12-risks-and-alternatives §A10.
The gate for each delivery phase is empirical: every Mochi source file in tests/transpiler3/dotnet/fixtures/ must compile via the .NET pipeline and produce stdout that diffs clean against the expect.txt recorded by vm3. Roslyn-clean (<TreatWarningsAsErrors>true</TreatWarningsAsErrors> plus the Mochi-shipped Mochi.Analyzers package) on generated code is the secondary gate. NativeAOT publish (with zero trim warnings IL2026/IL2070/IL2080/IL3050 family) is the tertiary gate. Reproducibility (bit-identical .dll across two CI hosts) is the quaternary gate.
Motivation
Mochi today targets vm3 (for mochi run), the C target (for static native binaries via MEP-45), the BEAM target (for supervised concurrent runtimes via MEP-46), and the JVM target (for Maven-Central-interoperable jars and GraalVM native binaries via MEP-47). None deliver what .NET uniquely provides:
-
NuGet, the canonical .NET package repository. As of 2026-05, NuGet hosts more than 6 million unique package versions across ~500,000 distinct package IDs, with all major Microsoft first-party libraries (System., Microsoft.) plus a deep third-party ecosystem (Polly, MediatR, Serilog, FluentValidation, Mapster, Dapper, EF Core, MSTest / xUnit / NUnit, BenchmarkDotNet, Akka.NET, Orleans, Octopus, NodaTime, OpenTelemetry). A Mochi program that needs Azure SDK access, Office document parsing, BLE communication, ASP.NET Core integration, OAuth, AWS SDK, Google Cloud, Power BI, or any of thousands of other capabilities, can import an existing NuGet package, with a SHA-256 pin and a
packages.lock.jsontransitive lockfile. NuGet is smaller than Maven Central (10M+) but the per-domain coverage is essentially complete for Mochi's workload classes. -
CLR reified generics. Unlike JVM type erasure, the CLR carries full type arguments at runtime. A Mochi
list<int>is a literalList<long>with unboxedlongslots; reflection overtypeof(List<long>)returns the actual type argument; no per-instance type tags or bridge methods. For Mochi, this eliminates an entire class of workaround that MEP-47 had to ship (per-instance Classtokens, type-witness threading). See 06-type-lowering §3. -
Value types as first-class citizens.
struct,record struct,readonly struct,ref structare stack-allocated value types with no GC pressure. Mochi small records (Option, Point, ValueTuple-like records) can lower to value types, eliminating heap allocation for the common case. Span<T>andMemory<T>give zero-copy slice types over arrays and unmanaged memory. None of the other Mochi targets has this story (until JVM Valhalla GAs). -
LINQ as canonical query target. The Mochi query DSL is almost isomorphic to LINQ method-syntax. We get a battle-tested query engine, syntax-tree-to-method-call rewriting handled by Roslyn, and a parallelism story (PLINQ) for free.
IAsyncEnumerable<T>plusSystem.Linq.Asyncextends this to back-pressure-aware streaming. See 08-dataset-pipeline §1-§6. -
System.Threading.Channels+ async/await. A first-class, BCL-supported back-pressure-aware channel implementation, used by ASP.NET Core, Orleans, and the dotnet runtime itself. The Mochi agent mailbox lowers to oneChannel<TMessage>per agent. Bounded vs unbounded is a one-flag choice (BoundedChannelOptions). C#async/awaitpredates Loom by years and is the canonical async story in industry. We pay the function-coloring cost (red vs blue functions) but inherit the entire BCL's async surface. See 09-agent-streams §1-§6. -
NativeAOT. Native Ahead-of-Time compilation produces single static native binaries with sub-30ms cold start and ~3-6 MB binary size, competitive with MEP-45 C output and smaller than GraalVM native-image. Trimming is integrated; closed-world analysis is strict but mature; source generators replace runtime reflection. See 10-build-system §6-§7.
-
BCL is one of the best-engineered runtime libraries in the industry.
System.Text.Jsonis source-generator-AOT-friendly.System.Net.Http.HttpClientis HTTP/3-capable.System.IO.Pathhandles cross-platform paths correctly.System.Threading.ChannelsandSystem.Threading.Tasksare the reference design for async data flow.System.Security.Cryptographycovers TLS, X.509, JWT, AEAD ciphers, message digests. LINQ provides a fluent collection-processing API. Mochi FFI to any of these is direct:import "dotnet/System.Text.Json"lowers tousing System.Text.Json;. See 04-runtime §6-§13. -
Visual Studio, Rider, VS Code. Microsoft, JetBrains, and the C# Dev Kit team ship best-in-class IDE support, with refactoring, debugging, and Roslyn-powered analyzers. Mochi-on-.NET users get a professional IDE story out of the box (Roslyn analyzers, EditorConfig, hot reload during development, F11 step-into into emitted C# source).
-
Windows desktop and enterprise integration. WPF, WinForms, MAUI Windows, ClickOnce, MSIX deployment, Active Directory integration, Windows Authentication, .NET Framework interop via the unified runtime, Office automation via COM Interop, all reachable from Mochi-on-.NET via FFI. The C target's Windows story is C-level; the BEAM target's is poor; the JVM target's is medium. .NET wins this quadrant decisively.
-
Mobile and game-engine reach via MAUI / Unity / Godot. .NET MAUI ships iOS and Android apps from one codebase. Unity (the dominant indie / mobile game engine, ~50% market share) embeds the .NET runtime; Unity 6.8 announced migration to CoreCLR. Godot embeds .NET via Godot Mono / Godot CoreCLR. Phase 3 sub-MEPs (MEP-48.1 through MEP-48.4) target these surfaces. See 07-dotnet-target-portability §6-§9.
The C target (MEP-45) remains the right choice for embedded targets, single-file distribution, and minimal runtime footprint. The BEAM target (MEP-46) remains the right choice for hot-reload services, distributed pubsub, and OTP supervision. The JVM target (MEP-47) remains the right choice for Maven Central deep-cut library access, Loom concurrency, and Android via D8/R8. The .NET target is the right choice for NuGet interop, Windows enterprise, reified generics, value types, LINQ-as-canonical, NativeAOT distribution, and Unity / Godot / MAUI mobile / game integration. All five (vm3 plus four AOT targets) ship; the user picks per workload.
Specification
This section is normative. Sub-notes under ~/notes/Spec/0048/01..12 are informative.
1. Pipeline and IR reuse
MEP-48 shares the front-end and aotir passes with MEP-45, MEP-46, and MEP-47 and forks at the emit stage:
Mochi source
| parser (MEP-1/2/3, reused)
v
AST
| type checker (MEP-4/5/6, reused)
v
Typed AST
| monomorphise (MEP-45 pass 1, reused)
v
Monomorphic typed AST
| lower (MEP-45 pass 2, reused)
v
aotir (MEP-45's IR, reused)
| match-to-decision-tree (MEP-45 pass 3, reused)
v
aotir (matches lowered)
| closure-convert (MEP-45 pass 4, reused)
v
aotir (closures lowered)
| async-colour (MEP-48 pass 1; sync/async colouring per [[05-codegen-design]] §12)
v
aotir (coloured)
| dotnet-lower (MEP-48 pass 2; ./transpiler3/dotnet/lower/)
v
C#-source AST (Roslyn SyntaxNode trees; or System.Reflection.Emit IL builders for hot paths)
| emit (./transpiler3/dotnet/emit/)
v
C# source text (or .dll bytes directly for hot paths)
| Microsoft.CodeAnalysis.CSharp.CSharpCompilation in-process
v
.NET assembly (.dll)
| package as fx-dependent / self-contained / NativeAOT / single-file
v
Distributable artefact
aotir is unchanged. The MEP-48-specific work lives in transpiler3/dotnet/:
transpiler3/dotnet/lower/:aotir-> C#-source AST (Roslyn SyntaxNode builders).transpiler3/dotnet/colour/: async colouring pass (sync vs async per function); runs after closure-convert, before lower.transpiler3/dotnet/emit/: drivesMicrosoft.CodeAnalysis.CSharp.CSharpCompilationto produce.dll.transpiler3/dotnet/ilemit/: optionalSystem.Reflection.Emitdirect emission for hot lowerings (agent trampolines, numeric loops).transpiler3/dotnet/build/: build driver: fx-dependent / self-contained / NativeAOT / single-file targets; cache; lockfile.transpiler3/dotnet/runtime/: the Mochi runtime NuGet package source (C#), built once and vendored.
transpiler3/dotnet/lower/ consumes aotir.Module and produces csharpsrc.CompilationUnit. csharpsrc is a small Go package mirroring Roslyn's SyntaxFactory model: ClassDecl, MethodDecl, Block, Statement, Expression. The emit pass walks this tree, formats Roslyn syntax via the dotnet-side helper (a small mochi-dotnet-codegen binary built with the pinned Roslyn version, invoked over a JSON protocol).
The aotir IR is target-agnostic: it has no notion of .NET assemblies, IL, or async colouring. The lowering pass adds those concerns. The System.Reflection.Emit path is opt-in: a function annotated with @hot (or detected as an agent dispatch trampoline) skips the source path and emits IL directly via System.Reflection.Emit.AssemblyBuilder (or PersistedAssemblyBuilder on .NET 9+ for saveable assemblies).
2. Name mangling and C# reservation safety
C# identifiers are stricter than Mochi's in some ways and more permissive in others. The mangling rules (full table in 06-type-lowering §15):
- Mochi variables that collide with a C# reserved word (per the C# spec:
abstract,as,base,bool,break, ...,where,while,yield, plus contextual keywordsrecord,init,nint,nuint,file,required,scoped) get@prefix in the emitted C#, which is the standard C# escape:class->@class,record->@record. - Mochi package paths
mathutils/extralower to C# namespaceMochi.User.MathUtils.Extrafor user code. Configurable via--dotnet-base-namespace; defaultMochi.User. TheUser.segment makes the runtime/user distinction visible in stack traces. - Mochi record type names lower to C# class names in PascalCase, unchanged (
Book->Book). On collision withSystem.*types (String,Object,DateTime) the backend keeps the Mochi name and adds an explicit using-alias (using SystemString = System.String;) to disambiguate references inside the emitted module. - Mochi sum-type variant constructors lower to C# record classes nested in a
[MochiUnion]-attributed abstract base (Leaf->Tree.Leafinsidenamespace Mochi.User.Foo). - Mochi local function references and method names: PascalCase preserved for public, camelCase preserved for locals. The Mochi-to-C# name map is sidecar in the PDB for debugger / stack-trace mapping.
- Mochi runtime-internal symbols use the
__mochi_prefix to avoid colliding with user names.
The mangling is deterministic; reversal goes through the C# #line directive (preserves Mochi source positions in PDBs and stack traces) plus a .mochi-pdb.json sidecar consumed by the agent supervisor and Mochi.Runtime.Diag. See 05-codegen-design §8.
3. Type lowering table
The full per-type table is in 06-type-lowering §1-§16. The high-level mapping:
| Mochi | .NET | Notes |
|---|---|---|
int | long (C# long = System.Int64); long? nullable | 64-bit; C# int is 32-bit, REJECTED |
float | double; double? nullable | 64-bit IEEE 754 |
bool | bool; bool? nullable | |
string | System.String | UTF-16 internally; Mochi exposes UTF-8 byte length and codepoint indexing |
time | System.DateTimeOffset (or NodaTime Instant via opt-in) | 100-nanosecond precision; tz-aware via opt-in |
duration | System.TimeSpan | 100-nanosecond precision |
bigint | System.Numerics.BigInteger (value struct, immutable) | All arithmetic operators overloaded |
list<T> | System.Collections.Immutable.ImmutableList<T> (default); ImmutableArray<T> for dense numerics; List<T> for hot var-mutated; FrozenSet/FrozenDictionary for init-once lookup tables | Per-allocation choice driven by flow analysis (06-type-lowering §8) |
map<K,V> | System.Collections.Generic.OrderedDictionary<K,V> on net10.0; Mochi.Runtime.OrderedMap<K,V> on net8.0 | Insertion-ordered, per Mochi spec |
set<T> | System.Collections.Immutable.ImmutableHashSet<T> (immutable default); Mochi.Runtime.OrderedSet<T> when iteration order observable | |
T { ... } (record) | C# sealed record class (default) or readonly record struct (small, value-type fields) | All-immutable fields; auto Equals/GetHashCode/ToString/Deconstruct/with |
type T = A | B (sum) | abstract record + sealed record per variant + [MochiUnion] attribute | Exhaustiveness enforced by Mochi.Analyzers + default-arm throw |
fun(A, B) R | System.Func<A, B, R> (arity <= 16); Mochi.Runtime.Func17<...> etc. for higher arity | Captures hoisted into compiler-generated closure class |
stream<T> | System.Collections.Generic.IAsyncEnumerable<T> (C# 8) | Operators via System.Linq.Async plus Mochi runtime extensions |
agent | System.Threading.Channels.Channel<TMessage> + async dispatch loop | TMessage is sealed record union over on handlers |
Option<T> | Mochi.Runtime.Option<T> (sealed abstract record Option<T> + Some<T>(T) / None<T>()); or T? (nullable value type) for value-type fast path | Choice per call site by flow analysis |
Result<T, E> | Mochi.Runtime.Result<T, E> (sealed hierarchy) | Extension methods Map / MapErr / AndThen / Unwrap |
Boxing: CLR reified generics mean list<int> is ImmutableList<long> storing unboxed long slots end-to-end; no boxing at element access or generic-bound boundaries. This is a substantial positive delta vs MEP-47's JVM target. See 06-type-lowering §2-§3.
Immutability: System.Collections.Immutable.* types are immutable by construction. The lowering does NOT need to insert List.copyOf-equivalent calls at function boundaries (unlike MEP-47); the immutable types are immutable. For var-mutated bindings the lowering switches to List<T> / Dictionary<K,V> / HashSet<T> (mutable) per the flow analysis pass; the immutable-vs-mutable choice is per-binding. See 06-type-lowering §8.
4. Expression and statement lowering
Mochi expressions lower to C# expressions; statements lower to C# statements. The full lowering table is in 05-codegen-design §6. Highlights:
- Mochi
if cond { a } else { b }lowers to a C# ternarycond ? a : bwhen both arms are expressions; lowers to a C# 8 switch expression_ => ...shape when arms are pattern matches; lowers toif/elsestatements when arms have side effects. - Mochi
matchlowers to a C# 8 switch expression with record-pattern deconstruction andwhenclauses for guards. Exhaustiveness is enforced byMochi.Analyzers(Roslyn analyzer shipped as a NuGet dependency) plus a runtime default-arm throw. The C# compiler itself does not enforce exhaustiveness on sealed-record hierarchies. - Mochi
for x in xslowers to a C#foreach (var x in xs)loop. For maps,for (k, v) in mlowers to a loop overmwith tuple deconstructionforeach (var (k, v) in m). - Mochi early-
returnlowers to C#return. Mochibreak/continuelower to C# equivalents. - Mochi try/catch (the
try { ... } catch { ... }form per MEP-19) lowers to C# try-catch; Mochi raises Mochi-specificMochiExceptionsubclasses unified underMochi.Runtime.MochiException. - Block-as-expression (the last expression in a block is the block's value) lowers via a synthesised local function
(() => { ...; return e; })()or, when the body is a single expression, via the C# ternary or switch-expression form directly.
Integer arithmetic uses native CLR long ops (silent overflow, two's complement). The --strict-int build flag wraps every arithmetic op in checked(...) blocks, which throw OverflowException. Off by default; on for security-sensitive builds. See 06-type-lowering §1.
Integer division: C# long / long is truncation toward zero, matching Mochi's spec for /. Modulo C# % matches Mochi % for positive operands; for negative operands the Mochi spec is truncated-modulo (sign follows dividend), which is C#'s default. No helper needed. See 06-type-lowering §1.
5. Closures and delegates
Mochi first-class function values lower to C# Func<...> / Action<...> delegates instantiated from lambda expressions. Capture-by-value of effectively-readonly locals; mutable captures hoist into a compiler-generated closure class field (the standard C# closure model). The CLR JIT inlines lambdas aggressively when monomorphic; for hot paths the source code may use [MethodImpl(MethodImplOptions.AggressiveInlining)] attributes via the codegen.
The runtime exposes higher-arity delegate types for signatures the BCL does not provide:
Func<A1, ..., A16, R>andAction<A1, ..., A16>(BCL ceiling of arity 16).Mochi.Runtime.Func17<...>throughMochi.Runtime.Func32<...>(Mochi extension for arity 17 through 32).- Beyond arity 32, the codegen emits a record class with a single
Invoke(args)method.
The lowering picks the most-specific BCL Func/Action type when one fits, falling back to Mochi.Runtime.Func<...> for higher arities. See 05-codegen-design §6 and 06-type-lowering §7.
Hot-path lambda call sites can be System.Reflection.Emit-emitted directly, bypassing the source layer; this is opt-in via @hot annotation or a heuristic (calls-per-second in a profiling pass). See 05-codegen-design §4.
6. Runtime library
The Mochi.Runtime NuGet package (Mochi.Runtime 0.10.0+, Apache-2.0) is the sole external dep that every emitted .NET Mochi program needs. It is published to nuget.org with Authenticode signing (see 10-build-system §5).
Package layout (04-runtime §16):
Mochi.Runtime
.Core // print formatters, panic, MochiException hierarchy
.Str // UTF-8 string ops, ReadOnlySpan<byte> helpers
.Coll // OrderedMap<K,V>, OrderedSet<T>, structural-equality helpers
.Query // Mochi query DSL runtime (group_by, hash_join, sort, set ops over LINQ)
.Streams // cold-stream operators not in System.Linq.Async (window, throttle)
.Agents // agent template, channel mailbox, intent dispatch, supervisor
.Datalog // semi-naive evaluator over Dictionary-of-tuples
.Llm // AI provider abstraction over HttpClient (OpenAI, Anthropic, local)
.Fetch // HttpClient wrapper with JSON decode shim
.Ffi // P/Invoke marshalling helpers (UTF-8 strings, Span)
.Test // xUnit/MSTest/NUnit-compatible expect/test driver
.Io // variadic print, per-type formatter dispatch
.Telemetry // DiagnosticSource event definitions
Vendored deps (pinned, listed in Mochi.Runtime.csproj <PackageReference> with Version lock and packages.lock.json):
System.Text.Json(BCL; explicit reference for source-gen extensions)System.Threading.Channels(BCL)System.Collections.Immutable(BCL)System.Linq.Async(NuGet, ~250KB, Apache-2.0)Microsoft.Extensions.Logging.Abstractions(BCL-adjacent)YamlDotNet(NuGet, pinned, only when YAML used by Mochi.Query.Yaml extension)BouncyCastle.Cryptography(NuGet, pinned, only when crypto FFI used)
Rejected as runtime deps: Newtonsoft.Json, Autofac, AutoMapper (non-source-gen), Castle.Core, FluentValidation. See 10-build-system §6 and 12-risks-and-alternatives §A10.
The runtime NuGet size target: <1 MB before trim; <300 KB after NativeAOT trim.
A companion analyzer package Mochi.Analyzers (Roslyn IIncrementalGenerator plus diagnostic analyzers) ships separately, with diagnostics MOCHI001 through MOCHI006 (11-testing-gates §4). It is referenced as a transitive dependency of Mochi.Runtime.
7. Concurrency and supervision
Mochi agents lower to a Channel<TMessage> plus an async dispatch loop. Each agent declaration produces a C# class with a Channel<TMessage> mailbox (where TMessage is a sealed record union over the on-handlers), an async Task DispatchLoop() method that awaits channel.Reader.ReadAsync(), and a Task.Run(DispatchLoop, TaskCreationOptions.LongRunning) startup. See 09-agent-streams §2 and §3.
public sealed class Counter
{
private readonly Channel<CounterMsg> _mailbox =
Channel.CreateUnbounded<CounterMsg>(new UnboundedChannelOptions
{
SingleReader = true, SingleWriter = false,
});
private long _count;
private readonly Task _loop;
public Counter()
{
_loop = Task.Run(DispatchLoopAsync, TaskCreationOptions.LongRunning);
}
private async Task DispatchLoopAsync()
{
await foreach (var msg in _mailbox.Reader.ReadAllAsync().ConfigureAwait(false))
{
switch (msg)
{
case Inc i: _count += i.Delta; break;
case Value v: v.Reply.SetResult(_count); break;
case Stop: return;
default: throw new MochiMatchExhaustivityError(msg);
}
}
}
public ValueTask SendAsync(CounterMsg m) =>
_mailbox.Writer.WriteAsync(m);
}
public abstract record CounterMsg;
public sealed record Inc(long Delta) : CounterMsg;
public sealed record Value(TaskCompletionSource<long> Reply) : CounterMsg;
public sealed record Stop() : CounterMsg;
Supervision: Mochi's supervisor declaration lowers to a Mochi.Runtime.Agents.Supervisor that re-implements OTP's one_for_one, one_for_all, and rest_for_one strategies in user-space. Crashed agents (uncaught exception escaping the dispatch loop) trigger restart per the declared policy. See 09-agent-streams §10.
spawn f() lowers to Task.Run(() => f()) returning a Task (or Task<T> when f has a return value). await f() lowers to C# await f() inside an async context (the colouring pass guarantees the caller is async). See 09-agent-streams §5.
The "no async/await in surface" position is load-bearing (02-design-philosophy §13). Mochi surface code never types async or await. The colouring pass propagates the colour through the static call graph: any function transitively calling an async primitive (channel read/write, IO, fetch, sleep, ai.chat) lowers to async Task<T> and every call site to it gets an await. Pure (sync-only) functions remain synchronous. See 05-codegen-design §12.
The runtime calls .ConfigureAwait(false) on every internal await to avoid synchronization-context capture (relevant under ASP.NET Core legacy, WPF, WinForms). Surface user code does not need to know.
8. Memory model
The Mochi memory model on the CLR is the .NET memory model (ECMA-335 §I.12.6, Phillipart-Krzeszewski talks at .NET Conf 2025) restricted to Mochi-allowed operations. Highlights:
- All value-type locals (
long,double,bool, value structs) follow .NET value-type semantics: thread-local unless explicitly shared via a channel orTask/TaskCompletionSource<T>. - All reference-type locals (record class instances, ImmutableList/Dictionary references) are immutable from Mochi's POV; mutation is a transpiler-internal concern (the immutable types are immutable by construction). The CLR JIT's escape analysis handles the rest.
- Cross-agent communication via
Channel<T>is a full happens-before edge (the channel's internal synchronization establishes the edge). - Agents do not share mutable state with each other except via channels; the runtime does not provide a "shared variable" primitive.
GC: the build defaults to Server GC on 64-bit (<ServerGarbageCollection>true</ServerGarbageCollection>) with Concurrent GC enabled. For long-running services the runtime documents the <RetainVMGarbageCollection> and <ConcurrentGarbageCollection> tuning knobs. NativeAOT uses a single-threaded GC by default (smaller binary); the build can opt into Server GC for AOT via <IlcOptimizationPreference>Speed</IlcOptimizationPreference>. See 04-runtime §2.
Memory budgets: agents idle at ~2 KB per agent (channel internal state + Task stack); message size plus ~24 B per channel slot. Immutable collections cost more than mutable for small sizes; comparable at large sizes due to structural sharing. See 04-runtime §5.
9. Error model
Mochi exceptions lower to Mochi.Runtime.MochiException (extends System.Exception) with subclasses for each Mochi error kind:
MochiPanic: panic/abortMochiMatchExhaustivityError: non-exhaustive pattern match (should be unreachable post-typecheck)MochiCastError: type cast at FFI boundaryMochiBoundsError: out-of-bounds indexMochiArithmeticError: overflow under--strict-intMochiFFIError: FFI marshalling failure
Mochi try { ... } catch e { ... } lowers to C# try-catch on MochiException (and its subclasses).
CLR OutOfMemoryException, StackOverflowException, AccessViolationException, and non-Mochi Exception are not caught by Mochi's catch; they propagate to the AppDomain.CurrentDomain.UnhandledException handler, which the runtime sets to a DiagnosticSource emitter + structured stderr write. See 06-type-lowering §14.
Stack traces: every emitted method gets #line directives mapping C# lines back to Mochi source positions. A Mochi user sees Mochi-line stack traces; the underlying C# mapping is preserved in PDBs (embedded or sidecar). For NativeAOT the PDBs are shipped alongside the binary or embedded via <DebugType>embedded</DebugType>. See 05-codegen-design §8.
10. Target portability
The portability matrix is in 07-dotnet-target-portability. Tier-1 (full CI coverage; release gate green):
- Linux x86_64 glibc (Ubuntu 24.04)
- Linux aarch64 glibc (Ubuntu 24.04)
- macOS aarch64 (M1+)
- Windows x86_64
Tier-2 (smoke tests; release gate yellow):
- Linux x86_64 musl (Alpine) , NativeAOT only path
- macOS x86_64 (Intel)
- Windows aarch64
- Linux arm32 (Raspberry Pi, advisory)
Tier-3 (best-effort; community-maintained):
- iOS / iPadOS via MAUI (Phase 3, MEP-48.1)
- Android via MAUI (Phase 3, MEP-48.1)
- Browser WebAssembly via Blazor (Phase 3, MEP-48.2)
- Unity (Phase 3, MEP-48.3, post Unity 6.8 CoreCLR migration)
- Godot (Phase 3, MEP-48.4)
TFM matrix: net8.0 LTS and net10.0 LTS on every tier-1 cell. net11.0 STS smoke-tested but non-blocking.
NativeAOT: tier-1 cells get a NativeAOT build per release; trim warning cleanliness is gated.
Distribution: the dotnet SDK from Microsoft (Apache-2.0) is the canonical install path. Users install via Microsoft's installers or apt/brew/winget; the build CLI requires dotnet on PATH. We do not ship a private dotnet SDK (12-risks-and-alternatives §A11). See 07-dotnet-target-portability §3.
11. Build driver
The Mochi build driver routes .NET builds through transpiler3/dotnet/build/. Targets:
--target=dotnet-source: emit C# source only (for review / debugging).--target=dotnet-assembly: emit.dllfiles intotarget/dotnet/assemblies/.--target=dotnet-fx-dependent(default formochi build): framework-dependent publish, runs on any .NET 8+ runtime.--target=dotnet-self-contained: self-contained publish for a specific RID.--target=dotnet-aot: NativeAOT single-file static binary.--target=dotnet-singlefile: single-file extracting executable (Phase 2, non-AOT).
Output layout (mirrors MEP-45's target/c/ and MEP-46's target/beam/):
target/dotnet/
source/ # emitted .cs files
assemblies/ # compiled .dll files
resources/ # embedded resources, .resources files
vendor/ # vendored NuGet packages (Mochi.Runtime, System.Text.Json, ...)
out/ # final artefacts (.dll, .exe wrapper, NativeAOT binary)
cache/ # incremental build cache (per-module fingerprints)
trim/ # trim warning suppression files (per fixture)
packages.lock.json # NuGet coordinate + SHA-256 pin per dep
The build is incremental: per-module fingerprints in cache/ skip unchanged modules. The lockfile pins every transitive dep with SHA-256. Roslyn IIncrementalGenerator outputs are cached keyed by the Mochi AST hash per module. See 10-build-system §2 and §13.
MSBuild integration (a Mochi.Build.Sdk NuGet package using the MSBuild SDK pattern) and the dotnet mochi global tool are first-class: users who already have an MSBuild-based project can add a small Mochi build step via <Sdk Name="Mochi.Build.Sdk" />. See 10-build-system §7 and §8.
12. Reproducibility
Bit-identical builds across two CI hosts are a gate (11-testing-gates §8). The build uses:
- Roslyn's
/deterministicflag (on by default since C# 7.1). <Deterministic>true</Deterministic>in the generated.csproj.- A pinned
PathMap=/_/=so source paths do not leak into PDBs. - A pinned Roslyn version (the dotnet SDK in the build container).
- Source-generator outputs deterministic given input (no
DateTime.Now,Guid.NewGuid, or environmental input). SourceRevisionIdset to a deterministic hash, notgit rev-parse.
PDB files reproduce separately (PDB SHA-256 must also match). Embedded PDBs (<DebugType>embedded</DebugType>) reproduce as part of the .dll SHA-256.
For NativeAOT: ILC (the IL compiler) has worker-scheduling-induced non-determinism on parallel builds. The reproducibility gate uses <IlcMaxThreads>1</IlcMaxThreads>; the default fast build uses parallel ILC. See 10-build-system §12.
The diffoscope tool runs as a CI gate on a subset of fixtures, comparing .dll and NativeAOT binary contents structurally. See 11-testing-gates §8.
13. Hardening
The runtime takes a defensive posture:
- All public runtime APIs validate inputs (null check, range check). Internal APIs trust callers.
- JSON parsing uses
System.Text.JsonwithJsonSerializerContextsource generators (AOT-friendly, no reflection-based polymorphic deserialisation); strict mode by default. - YAML parsing (when used) goes through YamlDotNet's safe deserializer (no arbitrary-type instantiation).
- HTTP via
System.Net.Http.HttpClientdefaults to TLS 1.3, hostname verification on, strict certificate chain via the defaultHttpClientHandler. - Random uses
System.Random.Shared(xorshiro128++) for non-cryptographic randomness;System.Security.Cryptography.RandomNumberGeneratorfor crypto. - No
unsafeblocks in emitted user-facing code; the runtime containsunsafeonly inMochi.Runtime.Unsafe(Span pinning), not exposed.
Supply-chain hardening: NuGet coordinates pinned by SHA-256 in packages.lock.json. mochi audit checks pinned versions against the OSV database. dotnet nuget verify is part of the build. See 10-build-system §5 and 12-risks-and-alternatives §R3.
NativeAOT gets strict trim warning enforcement (<TreatWarningsAsErrors>true</TreatWarningsAsErrors> plus <IlcGenerateTrimmingWarnings>true</IlcGenerateTrimmingWarnings>). Source-generator-emitted [DynamicallyAccessedMembers] attributes are mandatory for any reflectively-accessed surface. See 07-dotnet-target-portability §5.
14. Diagnostics
Emitted C# source carries #line directives mapping back to Mochi source positions. Generated stack traces point to Mochi lines.
DiagnosticSource events (04-runtime §14) cover:
Mochi.AgentStart/AgentStopMochi.MessageSend/MessageReceiveMochi.QueryStart/QueryEnd(with row count, duration)Mochi.GCPressure(custom event on Gen 2 collection)Mochi.FFICall(FFI call boundary, with target namespace)Mochi.LLMCall(LLM provider call, with provider name, model, token count)Mochi.HTTPFetch(HTTP fetch, with status code, latency)
Telemetry: the runtime optionally exports DiagnosticSource events to OpenTelemetry via the OpenTelemetry.Instrumentation.Mochi package (Apache-2.0). See 04-runtime §14.
Compile-time diagnostics: the Mochi-to-C# lowering emits Roslyn-friendly source that Roslyn can lint with <TreatWarningsAsErrors>true</TreatWarningsAsErrors> and the Mochi.Analyzers package (MOCHI001 through MOCHI006). Any warning is a transpiler bug (see 11-testing-gates §4). The transpiler also runs its own checks on aotir before lowering (unreachable code, unused captures, etc.).
15. Debug info
The build emits two kinds of debug info:
- C# debug info (Roslyn PDB): full source/local mapping. Lets debuggers (Visual Studio, Rider, VS Code C# Dev Kit,
dotnet-dump) navigate the emitted C#. - Mochi source maps (
#linedirectives in the emitted C# plus a.mochi-pdb.jsonsidecar): maps C# lines back to Mochi source lines. Lets a Mochi user see Mochi-line stack traces.
PDBs are embedded into the .dll by default (<DebugType>embedded</DebugType>); portable PDBs are written separately when requested. The runtime supervisor and Mochi.Runtime.Diag consume the sidecar to resolve frames.
For NativeAOT, the PDBs are consumed by ILC and embedded into the resulting binary's .symtab. Stack traces in AOT binaries surface Mochi line numbers via the embedded mapping.
16. .NET FFI
The import "dotnet/..." form imports a .NET type as a Mochi type:
import "dotnet/System.Guid" as Guid
import "dotnet/System.DateTimeOffset" as DateTimeOffset
import "dotnet/System.Text.Json.JsonSerializer" as JsonSerializer
let id = Guid.NewGuid()
let now = DateTimeOffset.UtcNow
Lowers to:
using System;
using System.Text.Json;
// ...
var id = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
The lowering exposes the .NET type's public API as Mochi methods, with Mochi-typed signatures derived from the assembly's metadata (via System.Reflection.Metadata or Roslyn MetadataReference at build time). Mochi-.NET type marshalling per 06-type-lowering §13: Mochi int <=> .NET long; Mochi string <=> .NET string (UTF-16 internally, UTF-8 at I/O); Mochi list<T> <=> .NET IReadOnlyList<T> (with element marshalling); etc.
NuGet coordinates can be declared in the Mochi source via a module-level pragma:
@nuget("System.Text.Json:9.0.0")
import "dotnet/System.Text.Json.JsonSerializer" as JsonSerializer
The build resolves the coordinate via dotnet restore, pins the SHA-256 in packages.lock.json, vendors the assembly. See 10-build-system §5.
Null safety at the FFI boundary: every .NET return value declared as nullable (T? in C# 8+ NRT) crosses an explicit null-check, or, for return types declared as Option<T> in the Mochi import, is wrapped via value is null ? new None<T>() : new Some<T>(value). See 06-type-lowering §13.
Native (C/C++) library interop is via [DllImport] or, for C# 11+, [LibraryImport] (source-generated, AOT-friendly). The Mochi extern "c" declaration lowers to a [LibraryImport] with explicit marshalling attributes; Mochi UTF-8 strings cross as ReadOnlySpan<byte> plus length rather than LPStr. The FFI runtime helpers live in Mochi.Runtime.Ffi.
17. Output style
Emitted C# source follows a deterministic style:
- Four-space indentation (C# convention).
- Imports sorted alphabetically; no unused imports (IDE0005 enforced).
- One top-level type per file by default; nested types for sum-type variants under a
[MochiUnion]base. - Class members in fixed order: fields, constructors, properties, methods, nested types.
- Positional records used wherever the Mochi type is immutable and has <= 10 fields.
sealed recordfor all leaf record classes;abstract recordfor sum-type bases.[MochiUnion]attribute on every sum-type base for analyzer interaction.- Switch expressions for
matchlowering, with explicit default arm throwingMochiMatchExhaustivityError. var(local variable type inference) used for emitted locals where the type is unambiguous; explicit types otherwise.#nullable enablein every emitted file.[MethodImpl(MethodImplOptions.AggressiveInlining)]on lambda call sites detected as hot.
The style is enforced by a CI gate (TestDotnetCodegenStyle) that diffs emitted C# against a checked-in expected format for a representative set of fixtures.
Rationale
Why .NET as a fourth target after C, BEAM, and JVM
The C target (MEP-45) gives Mochi static-linked native binaries, sub-10ms cold start, embedded-target portability, and bit-precise control. The BEAM target (MEP-46) gives Mochi telecom-grade concurrency, hot reload, supervision, and cluster pubsub. The JVM target (MEP-47) gives Mochi access to Maven Central, Loom virtual threads, the C2 JIT, GraalVM native-image, and Android. None give Mochi: (a) the second-largest commercial managed runtime ecosystem, with NuGet's 6M+ packages and deep Windows / Azure / Office / Unity integration; (b) CLR reified generics (eliminating MEP-47's type-erasure workarounds); (c) value types as first-class citizens (record struct, readonly struct, Span<T>), eliminating heap allocation for small immutable records; (d) LINQ as the canonical query target across IEnumerable<T>/IAsyncEnumerable<T>; (e) System.Threading.Channels as a battle-tested mailbox primitive; (f) NativeAOT for ~5MB single-file binaries with sub-30ms cold start, closer to MEP-45's distribution shape than MEP-47's GraalVM native-image; (g) MAUI for cross-platform mobile / desktop UI, Unity / Godot for game-engine integration.
The user-facing positioning: vm3 for development, C for embedded and single-file distribution, BEAM for distributed services with hot reload, JVM for Maven Central deep-cut library access, .NET for Windows enterprise / Unity / mobile / value-types performance. See 02-design-philosophy §1.
Why C# source via Roslyn, not IL-only
Five reasons (full discussion 05-codegen-design §3):
- Free static-type checks: the C# compiler itself catches transpiler bugs (mistyped lowering, missing using, scope error). Emitting IL directly skips this layer.
- Debuggable output: a Mochi developer can read the emitted C# to understand what their program will do. IL is opaque.
- No version churn: Roslyn ships with every .NET SDK; no library version-pin needed (we pin a specific Roslyn version for the codegen helper, but the user-side build uses whatever SDK is installed).
- Full feature support: records, list patterns, switch expressions, primary constructors, collection expressions, source generators,
[ModuleInitializer], all work via Roslyn without us implementing them. - Hybrid escape hatch: for hot lowerings (agent dispatch trampolines, numeric loops, JIT-unfriendly reflection sites that would break NativeAOT) where source is too verbose or where trim would strip a reflectively-invoked method, we drop to
System.Reflection.Emit(orPersistedAssemblyBuilderon .NET 9+). This is opt-in, not the default.
F# / VB.NET as IR: rejected because each adds a compiler dependency, output-runtime dependency, and a debugging layer; F# has its own type system that does not match Mochi's. See 02-design-philosophy §7 and 12-risks-and-alternatives §A1-A2.
Why .NET 8 LTS minimum
.NET 8 (November 2023) is the floor for these features:
- Records (C# 9+, finalised in C# 12): how product types lower cleanly.
- List patterns (C# 11): how list destructuring lowers.
- Switch expressions (C# 8+, refined in 9+): how match lowers cleanly.
System.Threading.Channels(matured): how agent mailboxes lower.IAsyncEnumerable<T>(C# 8, library complete): how streams lower.- NativeAOT (GA in .NET 7, hardened in 8): how single-file distribution ships.
FrozenDictionary<K,V>/FrozenSet<T>(.NET 8): how init-once lookup tables ship.- Source generators (Roslyn 4.0+, refined): how AOT-friendly JSON serialisation ships.
.NET 6 LTS rejected (EOL November 2024). .NET 7 STS rejected (EOL May 2024). .NET Framework 4.x rejected (Windows-only, no modern C# features, 12-risks-and-alternatives §A12).
.NET 10 LTS (November 2025) is the preferred target: C# 14 with extension types (preview), BCL OrderedDictionary<K,V>, dotnet run app.cs, refined NativeAOT (smaller binaries, faster ILC), improved trim warning diagnostics. All beneficial; none required.
See 02-design-philosophy §2 and 07-dotnet-target-portability §1.
Why reuse aotir instead of a fresh IR
The aotir IR is target-agnostic by construction (MEP-45 §1, MEP-46 §1, MEP-47 §1). Reusing it for MEP-48:
- Amortises the monomorphisation, match-to-decision-tree, and closure-conversion passes across four backends. Bug fixes in those passes improve all four targets.
- Forces cross-target semantic consistency: a Mochi program's
aotiris the same on all four targets; divergence can only come from the lowering pass, which is auditable per-target. - Reduces implementation effort: only the lowering pass plus the new async-colouring pass are .NET-specific.
The cost: any pass change must be vetted against all four backends. We accept this. See 05-codegen-design §5.
The .NET-specific addition: the async colouring pass (05-codegen-design §12). This pass runs after closure-convert and before lower; it propagates sync vs async colour through the call graph and produces a coloured aotir that the lower pass uses to choose between T Foo(...) and Task<T> FooAsync(...). The colouring pass is the largest novel transpiler component for MEP-48; the JVM target with Loom skipped this entirely.
Why System.Threading.Channels plus async/await, not Loom or actor framework
Mochi-on-.NET does not have a Loom equivalent (no implicit-blocking virtual threads). The available concurrency primitives are:
Task/ValueTask/Task<T>/ValueTask<T>(theasync/awaitsubstrate).System.Threading.Channels.Channel<T>(mailbox, back-pressure-aware).IAsyncEnumerable<T>(cold stream).Thread,ThreadPool(low-level, rarely used directly).
The alternatives, all rejected:
- Orleans (Microsoft virtual actor framework): Orleans is excellent for distributed-actor scenarios but is heavyweight (silo-based, needs storage providers, depends on ASP.NET Core). Overkill for the Mochi agent model. See 12-risks-and-alternatives §A4.
- Akka.NET (port of Akka): requires Akka licensing model decisions and adds a substantial runtime. Mochi avoids actor-framework lock-in. See 12-risks-and-alternatives §A4.
- Proto.Actor: smaller than Akka.NET but still actor-framework lock-in; same rejection.
- Microsoft.Dataflow (
System.Threading.Tasks.Dataflow): a useful library for some workloads but its block-based API does not match Mochi agent semantics; conversion overhead. - TPL Dataflow (legacy): same rejection.
The "no async/await in surface" position (02-design-philosophy §13): Mochi source code is written in a synchronous style; the colouring pass decides per-function whether to emit async Task<T> (red) or T (blue), and call sites get await injected automatically. This is the largest operational delta vs MEP-47 (where Loom obviates colouring) and the largest novel pass in MEP-48.
See 09-agent-streams §1 and §6, 02-design-philosophy §13, 05-codegen-design §12.
Why BCL as fat runtime, Mochi.Runtime as thin
The .NET BCL ships ~10000 types covering HTTP (System.Net.Http), JSON (System.Text.Json), time (System.DateTimeOffset, System.TimeSpan), regex (System.Text.RegularExpressions), locale (System.Globalization), file I/O (System.IO), security (System.Security.Cryptography), concurrency (System.Threading.*, System.Threading.Channels, System.Threading.Tasks), collections (System.Collections.Generic, System.Collections.Immutable, System.Collections.Frozen), spans (System.Span<T>, System.Memory<T>), reflection, diagnostics, and more. Reusing it gives Mochi a runtime library matching or exceeding what any Mochi-specific library could provide.
The Mochi.Runtime NuGet ships only what the BCL does not: Mochi-typed higher-arity delegates, OrderedMap<K,V> for net8.0 compatibility, agent / supervisor classes, the Datalog engine, DiagnosticSource event definitions, FFI dispatch helpers, and the AI / LLM provider abstractions. Total ~5000 LOC; ~1 MB NuGet before trim; ~300 KB after.
Newtonsoft.Json / Autofac / AutoMapper rejected: too transitive, too reflection-heavy (hostile to NativeAOT), supply-chain risk. See 10-build-system §6, 02-design-philosophy §4, 12-risks-and-alternatives §A10.
Why NativeAOT as a first-class build target
Mochi users want a single-file binary they can ship to a server (or hand to a colleague) without requiring a .NET runtime install. NativeAOT (Native Ahead-of-Time, GA .NET 7, hardened .NET 8/9/10) gives us this: ILC AOT-compiles all reachable IL into a static binary, sub-30ms cold start, no .NET runtime on $PATH required.
The trade-off: AOT means closed-world; reflection / dynamic code generation / Assembly.LoadFrom are restricted. We mitigate via source-generator-emitted [DynamicallyAccessedMembers] attributes, the Mochi codegen pass emitting trim roots for each program's actual reflection use, and the Mochi.Runtime package shipping its own trim attributes. See 07-dotnet-target-portability §5 and 10-build-system §6.
NativeAOT improvements in .NET 10 (smaller binaries, faster ILC, refined trim diagnostics) reduce the cost-to-benefit ratio of AOT relative to net8.0 .NET 8 AOT.
Why differential testing as the master gate
For consistency with MEP-45, MEP-46, and MEP-47: every fixture's expected output is recorded by vm3 (the reference Mochi interpreter); the .NET build must produce identical stdout. This makes the transpiler's correctness empirically verifiable.
Four secondary gates: Roslyn-clean (catches transpiler-emitted C# warnings); NativeAOT-publish (catches trim warning regressions); self-contained-publish (catches RID-specific publish issues); reproducible-build (catches non-determinism). See 11-testing-gates §3-§8.
A cross-target differential gate (11-testing-gates §11) compares stdout byte-for-byte across C, BEAM, JVM, and .NET on shared fixtures, catching divergences early.
Backwards Compatibility
MEP-48 is additive. The vm3 default (mochi run) is unchanged. The MEP-45 C target is unchanged. The MEP-46 BEAM target is unchanged. The MEP-47 JVM target is unchanged. A user not invoking --target=dotnet-* sees zero behaviour change.
The aotir IR is shared with MEP-45, MEP-46, and MEP-47. Changes to aotir are out-of-scope for MEP-48 except as required by .NET-specific lowering needs; any such change is vetted against MEP-45, MEP-46, and MEP-47 in the same PR.
The Mochi language surface is unchanged. No new syntax is introduced by MEP-48. The async colouring is invisible to surface code.
Reference Implementation
The reference implementation lives in transpiler3/dotnet/. The directory structure:
transpiler3/dotnet/
README.md
colour/ # async sync/async colouring pass
colour.go
propagate.go
lower/ # aotir -> Roslyn SyntaxNode trees
lower.go
types.go
expr.go
stmt.go
decl.go
closure.go
match.go
query.go
agent.go
stream.go
emit/ # SyntaxNode -> CSharpCompilation -> .dll
emit.go
roslyn.go
format.go
ilemit/ # optional Reflection.Emit direct emission
hot.go
trampoline.go
build/ # build driver: fx-dep / self-cont / NativeAOT / single-file
build.go
fxdep.go
selfcontained.go
nativeaot.go
singlefile.go
runtime/ # source for Mochi.Runtime NuGet package (C#)
Mochi.Runtime/
Mochi.Runtime.csproj
Core/, Str/, Coll/, Query/, Streams/, Agents/, ...
Mochi.Analyzers/
Mochi.Analyzers.csproj
ExhaustivenessAnalyzer.cs
IntLoweringAnalyzer.cs
...
testdata/
phase01-hello/
phase02-scalars/
...
Plus shared with other backends:
transpiler3/aotir/(IR, unchanged from MEP-45)transpiler3/passes/(monomorphisation etc., unchanged)
A small mochi-dotnet-codegen binary built with the pinned Roslyn version is invoked from the Go transpiler over a JSON protocol; this isolates the Roslyn version dependency from the rest of the toolchain. See 05-codegen-design §13.
Phases
The 18 phases mirror the MEP-47 phase structure with .NET-specific additions. Each phase's gate is a Go test (TestPhase{N}{Name} in tests/transpiler3/dotnet/). Phases land only when the gate is green across the full TFM matrix.
Phase 0. Spec freeze and skeleton trees
Deliverable: transpiler3/dotnet/{colour,lower,emit,ilemit,build,runtime,testdata} directories created, README.md written, skeleton Go files compile, dotnet SDK detected at build time. Runtime NuGet source compiles to an empty Mochi.Runtime package.
Gate: TestPhase0Skeleton: directory layout exists; build produces empty assembly; runtime NuGet packs locally.
Phase 1. Hello world
Deliverable: lowering for print("hello, world") to a C# class with Main(string[] args). fx-dependent packaging; dotnet hello.dll prints "hello, world\n".
Gate: TestPhase1Hello: 5 fixtures green on net8.0 and net10.0, all four tier-1 OS cells.
Phase 2. Primitives and control flow
Deliverable: lowering for int, float, bool, string literals; arithmetic; comparisons; boolean ops; if/else; for/while; let/var. Roslyn compiles clean with <TreatWarningsAsErrors>true</TreatWarningsAsErrors>.
Gate: TestPhase2Scalars: 20 fixtures green, Roslyn-clean.
Phase 3. Collections
Phase 3.1 (lists), 3.2 (maps), 3.3 (sets), 3.4 (nested) mirror MEP-46 / MEP-47's phase 3. Each is one gate.
Gate: TestPhase3Lists, TestPhase3Maps, TestPhase3Sets, TestPhase3ListOfRecord. 90 fixtures total.
Phase 4. Records
Deliverable: lowering for type T { ... } to C# sealed record class (or readonly record struct for small value-typed records); methods on records; equality / GetHashCode auto-derived; record pattern matching (C# 9+).
Gate: TestPhase4Records: 25 fixtures green.
Phase 5. Sum types and pattern matching
Deliverable: lowering for type T = A | B | C to abstract record + sealed record variants with [MochiUnion] attribute; match to C# 8+ switch expression with record-pattern deconstruction and when guards; exhaustiveness enforced by Mochi.Analyzers.MOCHI001.
Gate: TestPhase5Sums: 25 fixtures green; analyzer-clean.
Phase 6. Closures and higher-order functions
Deliverable: lowering for fun(...) => to C# lambda + Func<...>/Action<...>; capture-by-value; mutable captures via compiler-generated closure class; higher-arity (Func17 through Func32) via runtime.
Gate: TestPhase6Funs: 25 fixtures green.
Phase 7. Query DSL
Deliverable: lowering for from ... select ... to LINQ method-syntax over IEnumerable<T>; group_by to GroupBy; join to Join / GroupJoin; order_by, take, skip direct.
Gate: TestPhase7Query: 30 fixtures green.
Phase 8. Datalog
Deliverable: lowering for fact / rule / query to runtime Engine.Register calls; semi-naive evaluator in Mochi.Runtime.Datalog.
Gate: TestPhase8Datalog: 20 fixtures green.
Phase 9. Agents and gen_server-equivalent
Deliverable: lowering for agent declarations to a C# class with Channel<TMessage> mailbox + async dispatch loop; spawn, send, call, cast. Supervision via Mochi.Runtime.Agents.Supervisor. Async colouring pass green on agent surface.
Gate: TestPhase9Agents: 25 fixtures green; TestPhase9ChannelClosure channel-shutdown gate green; TestPhase9AgentsDiagnostic event-emission gate green.
Phase 10. Streams and pubsub
Deliverable: lowering for stream<T> declarations to IAsyncEnumerable<T> (producer side) and await foreach (consumer side); subscribe, publish. Replay streams via Mochi.Runtime.Streams.
Gate: TestPhase10Streams: 20 fixtures green.
Phase 11. async/await and structured concurrency
Deliverable: async colouring pass fully integrated; spawn, await, scope. MochiScope wraps a TaskScope user-space supervisor; on .NET 11+ may use the BCL's structured-concurrency proposal if it ships.
Gate: TestPhase11Async: 15 fixtures green; deterministic-mode gate green; colouring-pass property tests green.
Phase 12. .NET FFI and NuGet deps
Deliverable: lowering for import "dotnet/..."; method dispatch via Mochi-typed signature derived from assembly metadata; @nuget(...) pragma for declaring coordinates; lockfile pinning; [LibraryImport] source-generator for P/Invoke.
Gate: TestPhase12FFI: 25 fixtures green; TestPhase12BclFFI curated-BCL-API gate green; TestPhase12NuGetRoundtrip nightly gate green.
Phase 13. LLM (generate)
Deliverable: lowering for ai(...) to Mochi.Runtime.Llm.Ai.CallAsync; provider abstractions for OpenAI, Anthropic, local (Ollama).
Gate: TestPhase13LLM: 10 fixtures green (mocked providers).
Phase 14. fetch (HTTP)
Deliverable: lowering for fetch(...) to HttpClient via Mochi.Runtime.Fetch.FetchAsync; TLS 1.3 default; HTTP/3 capable.
Gate: TestPhase14Fetch: 10 fixtures green (local test server).
Phase 15. NativeAOT packaging
Deliverable: build driver implements --target=dotnet-aot. NativeAOT publish via dotnet publish -p:PublishAot=true; trim warning cleanliness (IL2026/IL2070/IL2080/IL3050 family); single-file binary <8MB; cold start <30ms.
Gate: TestPhase15NativeAot: 30 fixtures green as NativeAOT binaries; trim warnings zero.
Phase 16. Reproducibility
Deliverable: full reproducibility gate. Roslyn /deterministic; <Deterministic>true</Deterministic>; pinned PathMap; SourceRevisionId hash; embedded PDB; ILC --max-threads=1 for NativeAOT.
Gate: TestPhase16Reproducible: bit-identical builds across two CI hosts on a 10-fixture subset; diffoscope differential clean.
Phase 17. Self-contained packaging across RIDs
Deliverable: build driver implements --target=dotnet-self-contained across linux-x64, linux-arm64, osx-arm64, win-x64 RIDs; trim-enabled variant (<PublishTrimmed>true</PublishTrimmed>) gate.
Gate: TestPhase17SelfContained: 20 fixtures green per RID; TestPhase17SelfContainedTrimmed: same fixtures green with trim.
Phase 18. Trim cleanliness and NuGet publication
Deliverable: Mochi.Runtime and Mochi.Analyzers 0.10.x published to nuget.org with Authenticode signing. Trim warnings (full IL2xxx family) zero across the fixture matrix. Performance baselines vs vm3 published.
Gate: TestPhase18Publish nightly verifies the published package is consumable; TestPhase18TrimWarnings: zero trim warnings across all fixtures; perf dashboard updated.
Future sub-MEPs:
- MEP-48.1: .NET MAUI mobile / desktop target (
--target=dotnet-maui). - MEP-48.2: Blazor WebAssembly target (
--target=dotnet-blazor). - MEP-48.3: Unity game engine target (
--target=dotnet-unity), post Unity 6.8 CoreCLR migration. - MEP-48.4: Godot game engine target (
--target=dotnet-godot). - MEP-48.5: Mono LTS branch support for legacy / IL2CPP fallbacks.
Dependencies
- MEP-4 (Type System): types must be fully resolved before lowering.
- MEP-5 (Type Inference): inferred types fill in unannotated declarations.
- MEP-13 (ADTs and Match): the surface syntax this MEP lowers.
- MEP-45 (C transpiler): provides
aotirand shared passes. - MEP-46 (BEAM transpiler): shares
aotirand the closure-conversion / monomorphisation passes. - MEP-47 (JVM transpiler): shares
aotirand the closure-conversion / monomorphisation passes; the .NET target is parallel to the JVM target with deliberate symmetry in note structure.
External:
- .NET 8 LTS SDK or .NET 10 LTS SDK on the developer machine (
dotneton PATH). - A pinned Roslyn version for the
mochi-dotnet-codegenhelper binary. dotnet publish -p:PublishAot=truerequires the OS native toolchain (clang on Linux/macOS, MSVC on Windows) for NativeAOT.
Open questions
- Should
--target=dotnet-aotbe promoted to the default formochi buildonce cold start beats JIT+fx-dependent for the typical fixture? (Currently the default is fx-dependent.) - How aggressive should the async colouring pass be? Should it duplicate functions called from both sync and async contexts (per-context monomorphisation), or escalate the whole module to async on first async use?
ValueTask<T>vsTask<T>: current lean isTask<T>for public surface andValueTask<T>for hot-path internals; should the codegen surface a@hotannotation for users to opt intoValueTask?- Mono LTS branch support: when (and whether) to add as Phase-3 secondary, given the Unity 6.8 CoreCLR migration.
- Source-generator-based exhaustiveness: should
Mochi.Analyzersemit MOCHI001 via an incremental generator (build-time) or via a diagnostic analyzer (IDE-time)? Current lean: both; the generator catches CI, the analyzer catches Visual Studio / Rider authoring.