Compile typed logic into a native module. A host loads it over a plain C ABI — no source shipped, no host rebuild.
You maintain a native application, and some of its rules keep moving. Pricing, discounts, eligibility, the things each customer wants slightly differently.
Recompiling the whole host for every change is not practical. Shipping those rules as readable source is not acceptable either.
Mathless is a small statically-typed language for that gap. You write the rules in its surface syntax, and the compiler turns them into a native module at compile time. Your application loads that module over a plain C ABI. What you ship is the binary.
It is built for native hosts: Delphi, C, C++, C#. Two of those are proven end to end on every
push — a C host and an Object Pascal host both load a module and call it in CI — and real
Delphi (dcc64) does the same in a local gate. C# is not verified yet. See Status below.
한국어 → README.ko.md
.mls → parse / typecheck → typed IR → native codegen → module (.dll) → [ C ABI ] → host
All of that happens at compile time. Today the module is a Windows .dll; a Linux .so is a
target we have not taken on yet. Nothing interprets anything at runtime, and there is no
bytecode VM. The host loads native code and calls it like any other library.
Delphi and Object Pascal give you strong static types and native speed. They also sit in a closed ecosystem with a dated story for extensions and tooling.
Scripting languages solve the flexibility problem, but they are weak on typing. They are weaker still on protecting what you hand to a customer.
Mathless takes the typed, native side and makes it loadable. Four commitments hold throughout, and the rest of this file is measured against them.
- Native only. No default VM, no bytecode runtime.
- No source shipped. What you distribute is a native shared library.
- The C ABI is the one first-class boundary. Per-language bindings are thin wrappers on top of it, which is why this is not a Delphi-only tool.
- Protection is reported as cost, never as impossibility. We measure the exported-symbol count, the stripped binary size, and the absence of source in the artifact. We never translate those into "reversing difficulty".
Phase 1's acceptance is complete on the C side. Everything below is measured on main, on
Windows x64 with a pinned toolchain.
The compiler. mlc runs lex, parse, typecheck, a backend-independent IR, then codegen. The
backend emits no_std, extern "C" Rust and builds it as a cargo cdylib.
The language today. Four types: f64, bool, i32 and string. The numeric ones come
with arithmetic (+, -, *, /, and % on i32), comparisons, and an explicit as
conversion between them; i32 division by zero gives 0 rather than a trap, and inside a
fallible (-> T!) function an i32 result that overflows fails with a reserved status instead
of wrapping.
A string can be a parameter or a -> string! return; it compares with == and != on
bytes, and concatenates with + — which only a return may hold, since the module has no
allocator. i32 as string renders a number. Returning a string uses a caller-allocated
buffer — the module never allocates. An array parameter is written xs: [i32] (elements may
be i32, f64 or bool): it arrives as a borrowed pointer plus a length the compiler appends,
so one surface parameter becomes two C parameters. It is read-only, len(xs) gives the element
count, and xs[i] is bounds-checked — which is why a function that indexes must be declared
-> T!, since an out-of-range read fails with a reserved negative status rather than through an
invisible channel. An array can also be returned, using the caller-allocates protocol a
returned string uses: the body declares its length with result <n>, which is where the host's
capacity is checked, and then writes elements with result[i] = e. Because the check happens
before the first write, a call that does not fit writes nothing at all. One difference worth
knowing: for an array return ml_cap and *ml_needed count elements, where a string
return counts bytes. Control flow is if, while and return; there is no else yet. Locals are
let and let mut, with assignment. Operators include unary - and !, plus && and
||. The built-ins are floor, ceil, round and trunc — which match C's
<math.h> exactly — plus len for arrays, byte_len and byte_slice for strings, and
fixed for decimal formatting. A function can be fallible: -> T! with error NAME = N and
fail NAME, which lowers to an integer status and an out-parameter, and it can declare
extra out parameters to return several values. Internal fn declarations can call each
other, but recursion is rejected at compile time. Constants are named literals: a const
stays inside the module, while an export const is an i32 that the header names
ML_<MODULE>_CONST_<NAME> and the interface fingerprint covers, so renumbering one changes
the fingerprint. docs/LANGUAGE.md keeps the definitive list.
The CLI. mlc build <file.mls> -o <dir> writes four files side by side: the .dll module,
a .h C header, a .pas Delphi import unit, and a .lib import library so a C host can
link against the header instead of resolving every symbol at run time.
Four host paths run in CI, all measured. A Rust kernel32 oracle loads the module and calls
it. So does a real C host built with MSVC, which compiles the generated header and resolves the
exports through LoadLibrary and GetProcAddress (MATHLESS_GATE_D). A second C host does it
the ordinary way — #include the header, link the packaged .lib, call the function, with no
run-time lookup at all. Both C hosts check the module's interface fingerprint first and refuse
one that drifted. And an Object Pascal host built with Free Pascal loads x64 modules and
calls them, alongside a gate that compiles every generated .pas (MATHLESS_GATE_FPC_HOST and
MATHLESS_GATE_FPC).
A fifth runs locally only. MATHLESS_GATE_DELPHI builds hosts/delphi-host with real
dcc64 and calls the modules across the C ABI. The edition available refuses command-line
builds, so the gate drives the IDE instead (bds.exe -b), which it does allow, and it passes on
the development machine. It cannot run in CI: the runner has neither Delphi nor an interactive
session.
Acceptance A, B, C and D all pass. It compiles, the oracle calls it, the export and size proxies
hold, and a real C host loads the same module. The stripped no_std build is about 9.0-9.5 KB —
the exact byte count depends on the machine's linker, so
docs/SECURITY.md keeps the measurements — and it exports only the
functions you declare plus the reserved symbols the ABI defines, cross-checked against
dumpbin /exports so it does not rest on our own PE reader alone.
What CI does check on the Pascal side is Object Pascal, not Delphi: MATHLESS_GATE_FPC
compiles every generated unit and MATHLESS_GATE_FPC_HOST builds a Pascal host that loads x64
modules and calls them, both with Free Pascal. That is a real host path, and it is the reason
the count above is four. It is not a stand-in for the Delphi gate — -Mdelphi is dialect
emulation, and the two disagree on something as basic as what string means. So the precise
gap is Delphi itself, not the language.
For current numbers, open decisions and the next piece of work, see docs/STATUS.md.
export fn discount(price: f64, vip: bool) -> f64 {
let rate = 0.9
if vip { return price * rate }
return price
}
mlc build discount.mls -o out/
# out/discount.dll native module — exports mlx_discount + ml_module_abi_version + ml_iface_hash_discount
# out/discount.h C header
# out/discount.pas Delphi import unit (Delphi: checked by MATHLESS_GATE_DELPHI, which does not run in CI)
# out/discount.lib MSVC import library (link-time binding)A C host loads discount.dll, calls mlx_discount(100.0, true), and gets 90.0 back. The host
itself is never rebuilt.
- Windows x64 for now. A Linux
.sotarget is a decision we have not taken yet, rather than an oversight. - The surface is small on purpose. docs/LANGUAGE.md lists exactly what compiles today, and what does not.
- A call into a module may not return. A
whileloop can run forever, and nothing across the C ABI can time it out or cancel it. Treat modules as trusted code, and call them on a worker thread if your host has to stay responsive. - Names are provisional. The
.mlsand.mllextensions, and the C API names, may still change. - A killed build leaves a staging directory behind.
mlc buildstages its four files in a.mlc-stage-<pid>-<n>directory inside the output directory, so that either all four land or none do. It removes that directory on success and on failure — but a process that is killed outright never gets to. Measured: that leftover is inert, and the next build into the same directory succeeds normally, so it is litter you can delete. One exception, andmlcsays so when it happens: if a failed build could not put your previous artifacts back, it keeps the staging directory on purpose and prints… could NOT be put back — N file(s) are left in <dir> and must be moved back by hand. That directory holds the only copy of those files. Delete a.mlc-stage-*only when no such message was printed.
| Document | What it covers |
|---|---|
| docs/STATUS.md | Measured state of main, open decisions, and the next slice |
| docs/HISTORY.md | The index of the dated session log (§9-N), newest first — one stub per entry, whose text is in docs/history/9-N.md. An archive: look up the entry a document cites, not the whole log. docs/history/ also holds the ▶ block records and the STATUS sections moved out on 2026-09-25 |
| docs/VISION.md | Why Mathless exists, and what success looks like |
| docs/LANGUAGE.md | Surface syntax vs the internal model |
| docs/HOST_ABI.md | The C ABI, host integration, and the liveness contract |
| docs/SECURITY.md | Protection goals, stages, and the measured proxies |
| docs/ROADMAP.md | MVP → expansion |
| docs/DECISIONS.md | Confirmed decisions (D01–D23) and rejected alternatives |
| docs/OPEN_QUESTIONS.md | What is still undecided, and what each answer would cost |
| docs/ARCHITECTURE.md | The intended pipeline and module boundaries |
| docs/COMPETITIVE.md | Where this sits next to the alternatives |
| docs/GLOSSARY.md | The terms this repository uses precisely |
| docs/slices/ | Per-feature SPECs — the unit of work — and their status |
| runtime/ · hosts/c-host/ · hosts/c-host-link/ · hosts/delphi-host/ | The C ABI header, and the three host directories that consume it |
Every change arrives as a PR, and nothing is committed to main directly. Work is test-driven,
and CI runs fmt, clippy and the test suite on two jobs.
windows-latest is the one that matters, because the acceptance tests actually execute there.
ubuntu-latest compiles the platform-independent frontend as insurance.
Setting up a machine — what to install, and which measurements legitimately differ on another one — is in CONTRIBUTING.md. The suite needs Rust and the MSVC Build Tools and nothing else; third-party Rust dependencies are zero. Where to start work is the "▶ 여기서 시작한다" block in docs/STATUS.md §9.
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option. This is the Rust ecosystem's usual pair: MIT keeps things simple and GPLv2-compatible, and Apache-2.0 adds an explicit patent grant.
What mlc produces from your source is yours — see
LICENSE-OUTPUT-EXCEPTION. The generated .dll, .h, .pas and .lib
carry no obligation from the licences above, even though a generated header is mostly our
template text — a generated header for a one-function module carries a single declaration line
from your source and everything else from ours, which is exactly why the exception is written
down rather than assumed. (A line count used to stand here; it was measured once in 2026-09-02
and was wrong by the time anyone re-ran it. Build examples/discount.mls and count.)
It covers output only;
the compiler itself stays Apache-2.0 OR MIT. It is not legal advice.
Unless you say otherwise, any contribution you intentionally submit for inclusion in this work, as defined in the Apache-2.0 license, is dual licensed the same way, with no extra terms.