There is no promotion ladder and no coercion at a call site. Implicit conversion only forgets type facts or adds a union tag; a conversion that changes a value is an explicit construction the author writes.
THE LANGUAGE
A dynamic language that's static.
Luna feels dynamic because the compiler is the interpreter: it lives inside the runtime, compiling and checking each definition the moment it is written. You get the freedom of a scripting language — define, call, and reshape while the application runs — and nothing unchecked ever executes.
Write source in the running application and it becomes retained bytecode the moment it is defined: the whole body is checked once — types, calls, effects, ownership — then lowered into the instructions the executor will run. Every later call is checked against the retained signature and links that same artifact. Nothing is parsed or inferred again.
Interactive goes deeper than typing. The compiler is present at run time, so programs can compute with it: read a signature, build a type, declare a checked function. More on that below, where code works on its own structure.
fn worst_kept(
// one quality per face, where 1.0 is equilateral
qualities: list<float>,
// slivers below the floor are ignored
floor: float = 0.05) -> float {
let kept = luna.sort(value: luna.filter(domain: qualities,
keep: fn (q: float) => q >= floor))
return if luna.at(value: kept, index: 0) is float worst { worst } else { 1.0 }
}
> fn.worst_kept(qualities: [0.9, 0.01, 0.4])
0.4
> luna.signature(of: fn.worst_kept)
fn.worst_kept(qualities: list<float>, floor: float = 0.05) -> floatChecked once and retained as bytecode; every later call links it. The omitted floor is supplied by compilation, and luna.at answers float | missing, so all-sliver input answers 1.0 instead of faulting.
Five sessions, each run as shown: the definitions are one cell, every > line a later cell, and the lines beneath it what that cell printed. Arguments are named at the call, and parameter comments remain discoverable documentation.
An agent's first act is asking. luna.help() answers with the installed language: every construct, tool, and type, each carrying its signature, its one-line documentation, and its tags. Built-in controls like luna.fold present exactly as any function you write would: a listed set of overloads, one per kind of domain, every kind declared in the face. Nothing is read from a manual — the catalog is rendered from the same contracts the compiler checks, so what you discover is never stale. A fresh agent with no prior knowledge can be dropped into the console and learn the language in minutes — it asks, and the application answers.
It goes to any depth: luna.signature(of:) renders any callable honestly — liveness included — and luna.parameters(of:) answers a function's parameters with their documentation and defaults, as data a program can compute with. luna.catalog(search:) finds a row by name, signature, prose, or tag; luna.doc(of:) answers its full document with examples; luna.source(of:) returns the source a function was written as. Twelve topics group the vocabulary; the language is its own reference.
$ luna
Luna 0.1.0 — luna.help() to get started
mounted: luna.cli, luna.pose, luna.session — :catalog lists them, :help the session
imports: ~/work/project, then the shipped modulesThe banner says what is mounted and where an import will resolve from: the directory the terminal opened in, then the modules Luna ships.
Six questions, asked in a running session and answered from the installed language. Built-in controls list as any function you write would: luna.fold is an overload set with its kinds declared (T1: type, Ws: ..window), one face per domain family — list, range over int, index, array — and a second set of four for its parallel merge: form. The catalog is executable truth — the same test suite that gates the compiler pins this text.
03 / MODULES
A module is one source file that declares module <id>; its imports declare its dependencies. The dependency closure is checked before anything compiles, so independent modules compile in parallel and a cycle is refused by construction.
Compile once holds at this scale too. A module's functions are compiled to retained artifacts when it loads, and every later call links the retained artifact: nothing recompiles at use.
04 / THE PROOFS
There is no promotion ladder and no coercion at a call site. Implicit conversion only forgets type facts or adds a union tag; a conversion that changes a value is an explicit construction the author writes.
A fault ends the invocation with its exact source position: an invalid index, an integer overflow. A refusal is an ordinary union value, such as int | error, handled with is. Nothing silently converts between the tiers.
Recursion is refused by construction, including through callbacks. Iteration uses bounded sequence controls, so the compiler proves every Luna computation finite. Only work that leaves Luna — a native call, an outside future — depends on the outside.
An immutable value can share storage safely; when the final owner ends, the storage is released immediately, without a tracing garbage collector. Cyclic ownership is impossible, so nothing needs a cycle collector. Success and fault paths both release their obligations. A capture, retained snapshot, or history entry can deliberately keep storage alive; release follows the final owner.
parallel: true changes time, nothing elseAn ordinary Boolean argument: same meaning, identical values, only time changes. The compiler follows the callback's entire call graph and refuses a world write on workers — "a worker performs no world step — no effect and no wait — and enters only natives their own host declared reentrant". The artifact's cost estimate is advice for scheduling that changes no value anywhere.
Every program that runs was issued by the compiler and passed the verifier. A structural lookalike is data, not executable authority.
A local list and a retained snapshot refer to the same immutable storage.
// the spelling is the type: this literal is list<float>[3]
let weights = [1.5, 2.0, 2.5]
// a map keeps its domain's count: list<float>[3] again
let scaled = luna.map(step: fn (w: float) => w * 2.0, domain: weights)
// a read that can miss answers float | missing; is decides the union
let floor = if luna.at(value: weights, index: 0) is float w { w } else { 0.0 }
// the domain is bounded, so the compiler proves this fold ends
let total = luna.fold(step: fn (a: float, w: float) => a + w,
domain: scaled, seed: floor)
// parallel is admission-checked; the values are identical either way
luna.map(step: fn (w: float) => w / total, domain: scaled, parallel: true)The same compile-time judgment that proves the big properties also settles the language's everyday questions.
A name can answer a set of signatures, and every call picks exactly one while compiling: by the argument names it spells, then by their solved types. Execution performs no overload search; an ambiguous call refuses, listing what exists. The language's own controls follow the same law: map, filter and fold are ordinary overload sets, no different from a function you write.
The language has two callable citizens. A metafunction is an instruction for making a function — compiled once per exact shape, on first use — and it overloads under the same law: names first, then the kinds of what the call hands over. T: type and T: field are simply two different metafunctions. There are five comptime kinds — type, integer, symbol, boolean and window — and a pack such as Ws: ..window is a product of them.
type gives a value a distinct identity with no allocation and no tag; alias names a transparent one. A color is not an int, and the compiler holds that line for free.
none is absence held as a value; missing is the absence of an answer. A list of optional values can distinguish an empty element from nothing there — most languages cannot say the difference.
enum demo.side = left | right declares a union of named units; the member test is the same is every union uses. No second enum machinery exists.
Everything a body receives is in its signature: defaults, unions, even liveness — watch.attach(..live to: Ts, …) says its seats are observed live entries. If the signature does not say it, it does not happen.
05 / PROGRAMMING AT TWO TIMES
Types and fields are values the compiler can compute with. At compile time, a product and its list of fields are two spellings of one structure. Those fields can declare a function's parameters, carrying their names, types, documentation and defaults.
Ordinary luna.map, luna.filter and luna.fold can transform that structure. Feed the answer into a declaration and the compiler checks the resulting function exactly as it checks one written by hand. The same source language does both jobs, with no separate macro syntax.
Read a dataset's column names and commit them. The next submission can turn those names into fields and declare a function for that schema. The compiler pins the committed revision it reads. A function built this way can be published for later calls.
fn numeric_columns() -> int {
let columns = [int, str, float, bool]
let numeric = luna.filter(domain: columns, keep: fn (t) => t == int || t == float)
return luna.length(array: numeric)
} The same filter used for face qualities can select numeric types. This body becomes return 2; the type list and its traversal never exist at runtime.
Compile-time parameters make a function a metafunction. Each exact instantiation compiles once and is retained. A mixed computation can resolve its structure now and leave its numeric work for execution.
A field list retains declaration order, defaults and docs. Named product type identity uses only names and types. Declared product aliases retain their defaults for construction; positional products retain their order.
A live parameter names a binding. Its delivered value has the binding's type or missing. Liveness belongs to the parameter name.
watch.attach(Ts: ..type, ..live to: Ts, body: (..Ts | missing) -> none) -> watch.id06 / NATIVE ARRAYS
Dense multidimensional array is a Luna type, with a window per axis: array<float> is the flat rank-one array, array<float>[any, 3] has one dynamic axis and a fixed three, [2..5, 3] bounds an axis, and array<float>[..any] erases the rank. Broadcasting, reductions, selection, reshape and indexed gathers operate on native typed buffers. Shapes known at compile time travel with the type; what stays dynamic is checked at the operation.
luna.spawn hands a checked call to the host's dispatcher and returns future<T>. The caller can continue before asking for its answer. The task owns copies of its inputs; nothing borrows the caller's memory.
luna.await answers T | error. Failed or abandoned work settles as an error value, handled by the same is test as any union. With no dispatcher, the call runs inline and returns an already settled future.
A host can admit blocking waits, refuse an actual pending wait, or let a retained invocation yield and resume when ready. Yielding leaves the application free to process its next turn. Parallel sequence work is part of the correctness story: the compiler checks it like everything else.
Follow a retained invocationfn work(x: int) -> int { return x + 7 }
fn both(a: int, b: int) -> int {
let pending = luna.spawn(call: work(x: a))
let here = work(x: b)
return if luna.await(value: pending) is int there { here + there } else { here }
} The caller computes here, then consumes the spawned answer. This example uses here alone if the other call failed.
An application's moving state — the selection, a slider mid-drag, a tool's working values — lives in the same environment as everything committed, under the same names and the same exact types. That is the live tier, and it is the language's own: typed bindings at interaction rate, with checked bodies standing on them.
Declare it live. live name: type = value — the same environment, the same exact types as every committed binding. The one difference is history: a live entry has none.
Write where it stands. := performs the write at interaction rate — no act, no trace. A mention of the name reads a fresh snapshot that cannot tear.
Stand a body on it. A watch names the live entries it observes, and the compiler checks its body as it checks any function — types, effects, termination. When an input changes, the body runs with the newest values.
Keeping is the deliberate step — see how a recording run lands it as a numbered actimport watch
---
// live bindings: a declared type, a first value, no history
live controls.angle: int = 0
live controls.turns: int = 0
// a standing body observes live entries and runs when one changes
watch.attach(to: controls.angle, body: fn (angle: int | missing) {
controls.turns := controls.turns + 1
})
// := writes the entry where it stands — interaction rate, no act
controls.angle := controls.angle + 15
// a mention reads a fresh snapshot that cannot tear
io.print(controls.angle * 2) This script prints 30: the write performed where it stood, and the read after it saw the fresh snapshot. The standing body runs on every later change.
09 / MEASURED PERFORMANCE
Each one is the same algorithm in Luna, C++, Lua and Python, over the same input, with every answer identical to the last bit. Pick one, read the four sources, and see where each language lands against hand-written C++.
fn face_membership(faces: array<int>[any, 3]) -> jagged<int> {
let count = luna.shape(value: faces)[0] * 3
let corners = luna.reshape(value: faces, shape: [count])
let order = luna.argsort(value: corners)
let sorted = luna.take(value: corners, indices: order, axis: 0)
let opens = luna.nonzero(value: luna.not_equal(a: luna.slice(value: sorted, axis: 0, start: 1, stop: count), b: luna.slice(value: sorted, axis: 0, start: 0, stop: count - 1)))
let offsets = luna.concat(values: [array(values: [0]), opens + 1, array(values: [count])], axis: 0)
return luna.jagged(offsets: offsets, data: order / 3)
}the corners put in vertex order by a stable `argsort`, a block opening wherever the vertex changes, each corner answering its face
Bars are multiples of the C++ time, which is pinned at 1×. Lower is better.
Same algorithm, same loops, same data order, in every language. Every answer is hashed; a single differing bit fails the run.
Apple M4 Max. Wall-clock milliseconds, the best of several calls after a warming call (Luna and C++ best of five, Lua best of ten, Python best of three) over an input of 100,352 faces built untimed; Luna on one thread, C++ -O3 with LTO on one thread, PUC-Lua 5.4.7 and PUC-Lua 5.5.1, CPython 3.14.7 with plain lists; every answer checked bit for bit against the C++ twin's. Lua here is standard Lua (PUC-Lua), not LuaJIT. A selection from the Luna benchmark harness; methods and the full set ship with it. Every implementation answers the same bits: the FNV-1a digest of the answer's integers and float bits is b9953bcec8deb097 in Luna (one thread), C++, Lua 5.4, Lua 5.5 and Python.
Safety gives the compiler room to simplify. Exact types and an acyclic call graph tell it where values live, which functions can run, and when cleanup is needed. It can inline small calls and sequence callbacks, turn a fold inside a map into nested counted loops, and reuse scratch storage where lifetimes do not overlap.
Those decisions shape retained bytecode: direct typed operations, linked calls, and only the control flow the program needs. The executor runs it without parsing source or resolving types again, which is why the same loops, written in Luna, run far closer to hand-written C++ than the scripting runtimes do.
One installed string-length call through each language's native-function boundary — cycles per call, one interleaved session on Apple M4 Max. K = 1 versus K = 16 at one million calls, three repetitions. This measures Luna’s typed native door against PUC-Lua’s C function door. Lower is better.
Luna has native multidimensional arrays for typed data work. Here, five common workflows run on Luna arrays, NumPy arrays, Python lists, and Lua tables at 1,000 and 100,000 elements. The chart shows how each approach performs the same work.
Double a dense block, then read a result.
Luna 1.37 cycles / elementSum each 100-element row.
Luna 1.01 cycles / elementGather alternating positions, then sum.
Luna 2.26 cycles / elementBuild the input, double it, then sum; construction is timed.
Luna 2.49 cycles / elementSelect by a boolean mask, double the selected values, then sum.
Luna 3.40 cycles / elementRatios use warm cycles per original input element; Luna is 1× in every row. Each labeled group has its own scale, so compare bar lengths within that group and read the ratios across groups. A ratio below 1× means that competitor is faster.
Apple M4 Max; Release, serial execution with TBB disabled. CPython 3.13.11, NumPy 2.4.2 and PUC-Lua 5.5.1. Luna and NumPy were remeasured together after the serial array optimizations; the unchanged CPython-list and PUC-Lua-table programs come from earlier lanes of the same suite. Complete outputs and checksums were checked. Input setup is excluded except in the build workflow. The large gather row was repeated with a longer five-sample budget after its first Luna slope exceeded the 12% drift limit. Results recorded 29 September 2026 in the Luna array benchmark suite.
These are the named workflows on Apple M4 Max, not a claim about every array shape or workload distribution. All four lanes report warm cycles per original input element; methodology and samples are recorded with the Luna benchmark harness.
LUNA RUNTIME
See how the running product holds live state, records deliberate changes, and delivers standing programs.