Guide
Languages / 言語: English · 日本語 — the Japanese edition is a complete machine-assisted translation under review; corrections are welcome. 日本語版は機械翻訳を基にした全訳で、レビューを進めています。修正のご提案を歓迎します。
Navigation: Documentation Root
Onboarding-oriented documentation for new users and embedders. Where the architecture, spec, and reference sections describe what Keleusma is, this section describes how to use it.
You can try Keleusma in your browser at the playground: it compiles and verifies your program with the compiler running as WebAssembly and reports its worst-case execution-time and memory bounds live, no installation required.
The guide is in two layers. The first is a linear course of forty chapters that teaches Keleusma from scratch, sized for video presentation and ordered as a single learning arc. The second is a set of reference pages for lookup and deeper study. The course and the reference pages overlap by design: the course is for first learning, the reference pages are for going back.
The Course
Forty chapters in ten parts. Each chapter is self-contained and demo-driven, sized to roughly one short video. Parts I through VIII teach the language; Part IX teaches embedding into a Rust host; Part X points onward.
Part I — Setting Out
| Chapter | Title |
|---|---|
| 1 | What Keleusma Is, and What It Is Not |
| 2 | Installing Keleusma and the Interactive Prompt |
| 3 | A Complete First Program |
Part II — The Building Blocks
| Chapter | Title |
|---|---|
| 4 | Values and Types |
| 5 | Names and Bindings |
| 6 | Functions |
| 7 | Making Decisions |
| 8 | Bounded Repetition |
| 9 | The Pipeline Operator |
Part III — Shaping Data
| Chapter | Title |
|---|---|
| 10 | Structs |
| 11 | Enums |
| 12 | Tuples and Arrays |
| 13 | Pattern Matching in Depth |
| 14 | Multiheaded Functions and Guards |
Part IV — The Heart of Keleusma
| Chapter | Title |
|---|---|
| 15 | The Three Function Categories |
| 16 | Yield: Talking to the Host |
| 17 | The loop Function |
| 18 | The Data Segment |
Part V — The Verifier and the Guarantees
| Chapter | Title |
|---|---|
| 19 | Why Was My Program Rejected? |
| 20 | Time and Memory Budgets |
Part VI — Going Deeper
| Chapter | Title |
|---|---|
| 21 | Generics and Traits |
| 22 | Newtypes and Refinement Types |
| 23 | Handling Partial Operations |
| 24 | Information-Flow Labels |
Part VII — Shipping a Program
| Chapter | Title |
|---|---|
| 25 | From Source to Bytecode |
| 26 | Signed Modules and Hot Code Swap |
Part VIII — The Capstone: Making Music
| Chapter | Title |
|---|---|
| 27 | The Piano Roll: How It Works |
| 28 | Setting Up Your Own Song Playground |
| 29 | Writing and Modifying a Song |
| 30 | A Tour of the Song Roster |
Part IX — Embedding Keleusma in a Rust Program
Part X — Where to Go Next
| Chapter | Title |
|---|---|
| 40 | Further Reading |
Reference Pages
The reference pages are the topic-organized companions to the linear course. Each is a self-contained document that the corresponding course chapters draw on and that a reader returns to when looking up a specific question.
| Document | Audience | Purpose |
|---|---|---|
| GETTING_STARTED.md | First-time user | Install the CLI, write a first script, run it, embed it in a twenty-line Rust host. Course coverage: Chapter 2, Chapter 31. |
| EMBEDDING.md | Rust host author | The complete host-facing reference: Vm construction, native function registration, arena sizing, the call and resume protocol, error recovery, opaque host types, the Library trait, signed modules. Course coverage: Part IX (Chapters 31 through 39). |
| PIANO_ROLL.md | Song author, host lifter, or host architect | The long-form manual for the piano-roll example: composing songs, lifting the host loop into another application, and using the example as an architectural reference for other control-loop domains. Course coverage: Part VIII (Chapters 27 through 30). |
| ROGUE.md | Game author or host architect | The long-form manual for the roguelike example: gameplay rules, the host and multi-script architecture, dungeon generation, the artificial-intelligence archetypes, item effects, and exercises. Not covered by a chapter; pointed to from Chapter 40. |
| WHY_REJECTED.md | Anyone whose program failed verification | The full catalogue of verifier rejection messages, mapped to root causes and proposed rewrites. Course coverage: Chapter 19 introduces the three most common rejections; this document is the full list. |
| FAQ.md | Anyone who hit a surprise | Common rough edges in V0.2.0, including string handling, escape sequences, the immutable-locals constraint, and migration notes from the V0.1.x line. |
| COOKBOOK.md | Embedder reaching for a known-good pattern | Working recipes for embedding patterns: the data-loader pattern, auto-sizing the arena from a module’s WCMU, narrow-runtime type aliasing, signed bytecode distribution, calibrated WCET with a measured cost model. Course coverage: Chapter 39 names the patterns; this document is the recipes. |
| BIG_NUMBERS.md | Author needing multi-digit arithmetic | The full worked example for the overflow construct: 64-by-64 to 128-bit multiplication via the high half, and addition with explicit carry-out propagation for chained multi-digit arithmetic. Course coverage: Chapter 23 introduces the construct; this document is the full technique. |
| LLM_USAGE.md | Operator using AI coding assistants | Patterns AI tools tend to get wrong, the read-AGENTS-first session protocol, prompt patterns that reduce iteration time. |
| AUTOMATION_SCRIPTING.md | Operator writing devops or sysadmin automation in Keleusma | The consolidated CLI scripting story: the three ways to run a script, script arguments, the entry kinds mapped to one-shot, staged, and daemon deployment shapes, the shell bundle as an orchestrator, signed and encrypted bytecode distribution, the run-tasks multi-script runner, and where the static guarantees do not hold. Ties together Chapters 2, 15 through 18, 25, and 26 with the operator reference docs. |
| SECURITY_POLICY.md | Operator deploying keleusma-cli in constrained environments | The strict-mode signing and encryption policies introduced in V0.2.1: key generation, policy activation, deployment scenarios, the trust model, daemon deployments and tick-interval cadences. |
| METRICS.md | Operator planning embedded or constrained deployments | Binary size, RSS, peak memory footprint, CPU cycles, comparison to bash, Lua, Python, Ruby, and Node.js. Includes steady-state daemon footprint under the tick-interval rate limiter. |
| SHELL_AUDIT.md | Operator evaluating whether stddsl::Shell is rich enough for a planned daemon | Present capabilities of the bundle, closed gaps, and open recommendations for V0.2.x point releases. |
Companion Material
| Path | Content |
|---|---|
examples/scripts/ | Standalone .kel files demonstrating language features. Run any of them with keleusma run examples/scripts/<file>.kel. |
examples/ | Rust embedding examples. Run with cargo run --example <name>. |
examples/piano_roll.rs | End-to-end SDL3 audio host with hot code swap across a song roster. Run with cargo run --release --example piano_roll --features sdl3-example. |
examples/rogue/ | End-to-end SDL3 video host driving a roguelike. Run with cargo run --release --example rogue --features sdl3-example. |
keleusma-cli/ | The standalone command-line frontend installed by cargo install --path keleusma-cli --bin keleusma. |
Reference Cross-Links
When a term is unfamiliar:
- GLOSSARY.md defines core terms.
- LANGUAGE_DESIGN.md describes the function categories, the five guarantees, and the conservative- verification stance.
- GRAMMAR.md is the formal syntax reference.
- TYPE_SYSTEM.md describes primitive types, string discipline, and composite types.
- STANDARD_LIBRARY.md lists the bundled
native functions in the
audio::andmath::namespaces.
Planning Document
OUTLINE.md is the planning document the course was drafted against. It records the pedagogical decisions, the music-to- Keleusma concept map, and the open questions still under review. It is working material rather than user-facing documentation.
Chapter 1. What Keleusma Is, and What It Is Not
Goal
By the end of this chapter you will know what kind of language Keleusma is, what it is built to do, and what it deliberately leaves out. You will not write any code in this chapter. This is the one chapter in the guide that is pure orientation. It sets expectations so that nothing later is a surprise.
A score and an orchestra
Consider a musical score. The score is not the orchestra. It produces no sound on its own. It is a precise and finite set of instructions, and the orchestra is what carries those instructions out and fills the hall with sound.
Keleusma is a language for writing the score. The orchestra is a separate, larger program called the host. The host does the loud and complicated work, whether that is producing sound, drawing a game screen, or driving a motor. The Keleusma program sits inside the host and tells it, precisely and predictably, what to do and when.
This is the first thing to understand. Keleusma is an embedded language. It is not meant to be a whole application on its own. It is meant to be the small, exact, trustworthy part inside a larger program. Throughout the guide the larger program is called the host.
Running on a steady beat
A piece of music has a pulse. The conductor brings the baton down, the players play one beat, and then they wait for the next beat. The pulse does not stop and does not stumble.
A Keleusma program works the same way. It does a small, bounded amount of work, hands control back to the host, and waits to be called again. Each turn is one beat. This guide calls one such turn a tick. An audio program might run one tick per sixteenth note. A game might run one tick per frame. The host decides the tempo. Keleusma fills in what happens on each tick.
What Keleusma does not have, and why
Keleusma leaves out several things that most programming languages include. Every omission is deliberate.
- No unbounded loops. Every repetition in Keleusma has a count that is known before the loop begins. A repeat sign in sheet music tells the player how many bars to repeat. It never says “repeat for a while, and we shall see.”
- No recursion. A Keleusma function may not call itself, directly or through a chain of other functions.
- No free-form input. A Keleusma program does not pause to wait for someone to type at a console. Input arrives in a structured form, from the host, at a tick boundary.
The reason for every one of these omissions is a single promise. Keleusma guarantees, before a program is ever run, that each tick will finish within a bounded amount of time and a bounded amount of memory. The constructs left out are exactly the ones that could run forever or consume memory without limit. A language cannot make the promise and also keep those constructs, so Keleusma keeps the promise.
The promise, stated plainly
Because of these limits, several things can be known about a Keleusma program before it runs at all:
- it will not freeze,
- it will not exhaust memory unexpectedly,
- it will always keep its beat.
Part V of the guide explains how the language checks these properties. For now the point is only that the limits are not arbitrary. They are the price of the promise, and the promise is the reason Keleusma exists. A musician who cannot promise to finish the bar in time is not given a seat in the orchestra. Keleusma applies the same standard to a program.
How this guide works
The guide uses music as its way in. Many ideas in programming already exist in music under different names, and the guide names the musical idea first, then the programming idea, then the precise Keleusma form. You do not need to read musical notation, and you do not need to play an instrument. If you listen to music, you hold enough intuition to follow along.
Every chapter after this one develops one small program, runs it, and shows its output. The programs are short on purpose. The goal is for you to type each one, run it, and change it.
What you now know
- Keleusma is a small language embedded inside a larger host program.
- A Keleusma program runs in bounded turns called ticks.
- The language omits unbounded loops, recursion, and free-form input, in exchange for a guarantee that every tick finishes within bounded time and memory.
The next chapter installs the Keleusma tool and runs a first program.
Chapter 2. Installing Keleusma and the Interactive Prompt
Goal
By the end of this chapter you will have the Keleusma tool installed, you will have run code in the interactive prompt, and you will have saved your first program to a file.
What you need first
Keleusma is built with the Rust toolchain, so the toolchain must be
present before Keleusma can be installed. The standard installer for the
Rust toolchain is rustup, available from the official Rust website.
Install it for your operating system, then confirm it works:
cargo --version
If that command prints a version number, the toolchain is ready.
Installing the Keleusma command-line tool
Keleusma ships a command-line tool, also named keleusma. Install it from
a copy of the Keleusma source repository:
git clone https://github.com/sgeos/keleusma
cd keleusma
cargo install --path keleusma-cli --bin keleusma
Confirm the installation:
keleusma --help
If the shell reports that the command is not found, the directory for
installed Rust programs is not on the search path. That directory is
named .cargo/bin inside your home folder. Add it to the path and try
again.
The interactive prompt
The fastest way to try the language is the interactive prompt, called the REPL. Start it:
keleusma repl
The prompt is a > . Type an expression, press Enter, and the answer
appears on the next line. There is no file to create and no program to
structure. Try some arithmetic:
> 7 + 5
12
> 12 * 2
24
A piano octave has seven white keys and five black keys, twelve in all, and two octaves span twenty-four semitones. Numbers with a fractional part work too:
> 261.6
261.6
That happens to be close to the frequency of middle C, in hertz, a number Chapter 3 returns to.
The prompt can also remember a function for the rest of the session. Define one, then call it:
> fn semitones_in(octaves: Word) -> Word { octaves * 12 }
defined: semitones_in
> semitones_in(3)
36
The word fn begins a function, semitones_in is its name, octaves is
the input it expects, and octaves * 12 is what it computes. Functions
have their own chapter later. For now it is enough to see that the prompt
accepted the definition and then used it.
Type :help to list the prompt commands, and :quit to leave:
> :quit
The interactive prompt is ideal for trying a small idea quickly. It has one limit: it forgets everything when you quit. To keep a program, save it in a file.
Saving a program in a file
Create a file named octave.kel in any folder. The .kel ending marks
it as Keleusma source. Put one line in it:
fn main() -> Word { 7 + 5 }
A program saved in a file must be written as a function named main,
because a program starts at main. The -> Word states that the
function gives back a whole number. The interactive prompt wrapped your
expressions in a main for you. A file is explicit about it.
Run the file:
keleusma run octave.kel
The output is:
12
A Keleusma program does not print text on its own. The tool prints the
single value that main hands back. As a shorthand, the tool also
accepts the file without the word run, as keleusma octave.kel.
An optional step for macOS and Linux
On macOS and Linux a file can be made to run on its own, like any other
command. Add one line to the very top of octave.kel, so the file reads:
#!/usr/bin/env keleusma
fn main() -> Word { 7 + 5 }
That first line is called a shebang. Mark the file as runnable and run it directly:
chmod +x octave.kel
./octave.kel
The output is again 12. The file now behaves like a small program of
its own.
This step is specific to macOS and Linux. On Windows there is no shebang
mechanism, and the file is run with keleusma run octave.kel, which works
on every operating system. The shebang line is harmless on Windows, so a
file that carries it stays usable everywhere.
An executable script becomes most useful when it acts as a small command-
line tool. With the shell native bundle that the CLI registers, a script
can delegate work to ordinary command-line programs through shell::run,
branch on the Word exit code each returns, accumulate in a mutable
private data segment, and set its own exit status with shell::exit. The
repository’s own Markdown link-checker, scripts/check-md-links.kel, is
written this way. The text-scanning is left to POSIX tools; the Keleusma
script orchestrates them and reports the result. The constructs that make
that orchestration safe, the partial-operation family, are covered in
Chapter 23.
What you now know
- The Keleusma tool is installed and runs from the command line.
- The interactive prompt evaluates expressions immediately and can remember functions for a session.
- A program saved in a file is a function named
main, run withkeleusma run <file>. - A Keleusma program produces output by returning a value.
- On macOS and Linux a shebang line plus
chmod +xmakes a script run on its own.
The next chapter writes a complete program with several functions, and it computes something a musician will recognize.
Chapter 3. A Complete First Program: A Note of the Major Scale
Goal
By the end of this chapter you will have written and run one complete program that is larger than a single line. The program computes the frequency of a note of the major scale. You are not expected to understand every detail yet. Each idea used here has its own chapter later. The purpose of this chapter is to see a whole program work, end to end.
The idea: a note is a frequency
Every musical note is a frequency, a number of vibrations per second, measured in a unit called the hertz. The A above middle C vibrates at 440 hertz. That note is the reference the rest of this chapter measures from.
Two facts connect notes to numbers.
- Going up one octave doubles the frequency.
- An octave is divided into twelve equal steps, called semitones.
Because twelve equal steps must multiply the frequency by two in total,
each single step multiplies the frequency by the same fixed amount, the
twelfth root of two. Going up n semitones therefore multiplies the
frequency by two raised to the power n / 12.
Musicians and instruments often refer to a note by a whole number called
its MIDI number. A4, the 440 hertz reference, is MIDI number 69. Middle C
is MIDI number 60. The frequency of MIDI number m is:
frequency = 440 * 2 raised to the power ((m - 69) / 12)
The idea: the major scale is a pattern of steps
A major scale does not use all twelve semitones. Starting from a root note, it rises by a fixed pattern of semitone counts:
0, 2, 4, 5, 7, 9, 11, 12
The first note is the root itself, zero semitones up. The last note is the octave, twelve semitones up. The pattern in between is what gives the major scale its sound.
Building the program
The program is built from three functions. Read the whole program first, then the explanation that follows.
use math::pow
fn midi_to_hz(m: Word) -> Float {
440.0 * math::pow(2.0, ((m - 69) as Float) / 12.0)
}
fn scale_degree_hz(root: Word, degree: Word) -> Float {
let steps = [0, 2, 4, 5, 7, 9, 11, 12];
midi_to_hz(root + steps[degree])
}
fn main() -> Float {
scale_degree_hz(60, 4)
}
Consider each part.
use math::powborrows a function from the host. Raising a number to a power is provided by the host’s math library, andusebrings it into the program by name. Chapter 6 returns to functions, and Part IX explains where host functions come from.fn midi_to_hz(m: Word) -> Floatis the frequency formula from above. It takes a MIDI numberm, aWord, and returns aFloat. AFloatis a number that can have a fractional part, which a frequency needs. The expression(m - 69) as Floatconverts the whole numberm - 69into aFloatso it can be divided by12.0. That conversion is called a cast. Chapter 4 coversWord,Float, and casts.fn scale_degree_hz(root: Word, degree: Word) -> Floatputs the scale pattern in a list called an array, namedsteps. Writingsteps[degree]reads the entry at positiondegree. The function adds that semitone count to the root and asksmidi_to_hzfor the frequency. Chapter 12 covers arrays.fn mainruns the program. It asks for degree4of the major scale built on MIDI number60, which is middle C.
Running it
Save the program as scale.kel and run it:
keleusma run scale.kel
The output is:
391.99543598174927
Position 4 in the array 0, 2, 4, 5, 7, 9, 11, 12 is the value 7, so
the note is seven semitones above middle C. That note is G4, the fifth
note of the C major scale, and its frequency is just under 392 hertz. The
program computed it from first principles.
Change it and run it again
The array steps has eight entries, numbered 0 through 7. Change the
second argument of scale_degree_hz in main and run again:
- degree
0gives the root, middle C itself, near 261.63 hertz, - degree
7gives the octave above the root, near 523.25 hertz, - the degrees in between give the rest of the scale.
Change the first argument to move the whole scale to a different root. MIDI number 69 puts the scale on A.
The program returns one frequency each time it runs, because the
command-line tool prints the single value that main returns, as
Chapter 2 described. Computing a whole scale at once, and hearing it
played, is the work of the piano roll in Part VIII.
What you now know
This one program already used a great deal of the language:
- functions with parameters and return types,
- the
WordandFloattypes, - a cast from one type to another,
- an array and reading an entry from it,
- a function borrowed from the host with
use.
Every one of these has its own chapter ahead, starting with values and types in Chapter 4. You have now seen a complete Keleusma program work. That is the goal of Part I.
Chapter 4. Values and Types
Goal
By the end of this chapter you will know the kinds of value Keleusma works with, and the name of the type that each kind belongs to.
A type is a set of values that make sense together
A frequency, such as 261.6 hertz, is a fractional number. A count of beats is a whole number. Whether a note is sounding right now is a plain yes or no. These are different kinds of value, and a kind of value is called a type. The type is how the language knows what makes sense for a value and what does not.
This chapter uses the interactive prompt. Start it with keleusma repl
and type along.
Word, a whole number
A Word is a whole number. Counts are words: a number of beats, a number
of semitones, a MIDI note number.
> 12
12
> 7 + 5
12
One result will surprise you. Dividing one Word by another throws away
any remainder:
> 7 / 2
3
Seven divided by two is three, with one left over, and the leftover is discarded. Whole-number division always rounds toward zero. When a fraction is needed, the next type is the one to reach for.
Float, a fractional number
A Float is a number that can have a fractional part. Frequencies are
floats. A float is written with a decimal point, and the point is what
tells the language the value is a float and not a word.
> 3.5
3.5
> 7.0 / 2.0
3.5
Note the .0 in 7.0 and 2.0. Dividing two floats keeps the
fractional part, so 7.0 / 2.0 is 3.5, not 3.
bool, true or false
A bool is the answer to a yes-or-no question. It has exactly two
values, true and false. A comparison produces a bool:
> 3 < 5
true
Text, written words
A Text value is a piece of writing. It is written between double
quotes.
> "middle C"
middle C
Unit, no value at all
Unit is the type of (), which is read aloud as “unit.” It means there
is no meaningful value. A function that does something useful but has
nothing to hand back returns ().
> ()
()
A few more number types, met later
Keleusma has three further number types. None is needed in Part II, so they are only named here.
Byteis an eight-bit whole number, used for byte-level work. It appears in Chapter 23.Fixedis a fractional number with fully deterministic, repeatable arithmetic, used where audio code must produce the exact same result every time. The piano roll uses it.Multiword<N, F>is a fixed-width multi-word number,Nwords wide withFfractional bits, for values too large for a singleWord. It appears in Chapter 23.
Why types matter
Every value in a Keleusma program has a type, and the language checks, before the program runs, that values are only used where their type makes sense. Handing a frequency to a function that expects a count of beats is caught at that check, not discovered later as a wrong note. The types are a safety net stretched under the whole program.
What you now know
Wordis a whole number, and whole-number division drops the remainder.Floatis a fractional number, written with a decimal point.boolistrueorfalse.Textis writing in double quotes.Unit, written(), means no value.Byte,Fixed, andMultiword<N, F>are further number types, met later.
The next chapter gives values names.
Chapter 5. Names and Bindings
Goal
By the end of this chapter you will be able to give a value a name, and you will understand an important rule about those names.
Naming a value
A composer who writes a motif gives it a name, so that the rest of the score can refer back to it without writing the notes out again. A program does the same with a value. Giving a value a name is called binding it, and the name is called a binding.
A binding is made with the word let:
fn main() -> Word {
let beats_per_bar = 4;
let bars = 8;
beats_per_bar * bars
}
Save that as phrase.kel and run it with keleusma run phrase.kel. The
output is:
32
The program names two values, beats_per_bar and bars, and then uses
both names in the final line. A piece of eight bars in four-four time has
thirty-two beats.
Stating the type
The language works out the type of a binding on its own. 4 is a whole
number, so beats_per_bar is a Word. The type may also be stated
plainly, after a colon:
let beats_per_bar: Word = 4;
Stating the type is optional. It is useful when the value is complicated, or when writing the type down makes the program clearer to a reader.
Bindings do not change
Here is the important rule. Once a value has a name, that name keeps that
value. A binding cannot be reassigned. Writing let total = 32; and then
later trying to make total equal something else is not allowed.
This may sound limiting, and in one specific way it is. A binding cannot serve as a running total that a loop adds to, because adding to it would mean changing it. Chapter 8 returns to this point, and Part IV shows where changing state is actually done.
The benefit is large. When a name is read further down the program, it still holds exactly the value it was given. Nothing reassigned it in between. A reader, and the language, can trust the name. This is the same discipline as a written score, where a motif marked in the margin means the same thing every time the score points back to it.
What you now know
let name = value;binds a value to a name.- The type can be stated as
let name: Type = value;, but the language can also work it out. - A binding cannot be reassigned. A name keeps its value.
The next chapter groups statements into functions.
Chapter 6. Functions
Goal
By the end of this chapter you will be able to write a function of your own, give it inputs, and use its result.
A function is a named phrase
A phrase in music is a small, complete musical thought that can be played wherever it is wanted. A function is the same idea in a program. It is a named piece of computation. Once it has a name, it can be used anywhere, as often as needed, without writing it out again.
Every program so far has had one function, main. A program may have as
many functions as it needs.
Writing a function
Here is a function that answers a question: how many semitones are there in a given number of octaves?
fn semitone_steps(octaves: Word) -> Word {
octaves * 12
}
fn main() -> Word {
semitone_steps(3)
}
Run it with keleusma run. The output is:
36
Three octaves span thirty-six semitones.
The parts of a function
Read semitone_steps piece by piece.
fnbegins the function.semitone_stepsis its name. A name should say what the function does.(octaves: Word)is the parameter list. A parameter is an input. This function takes one input, namedoctaves, of typeWord. Each parameter states its type.-> Wordstates the type of the result the function gives back.{ octaves * 12 }is the body. The body computes the result.
The body’s last expression is the result. There is no special word for
“give this back.” The function semitone_steps ends with octaves * 12,
so that is what it returns.
Calling a function
Using a function is called calling it. A call is the function’s name
followed by its inputs in parentheses. The call semitone_steps(3) runs
the function with octaves set to 3.
A function may take more than one input. The parameters are separated by commas:
fn interval(low: Word, high: Word) -> Word {
high - low
}
fn main() -> Word {
interval(60, 67)
}
That program returns 7. The distance from MIDI note 60, middle C, up to
MIDI note 67, the G above it, is seven semitones, a perfect fifth.
What you now know
- A function is a named, reusable piece of computation.
fn name(parameter: Type, ...) -> ResultType { body }declares one.- The body’s last expression is the result.
- A call is
name(inputs).
The next chapter lets a program make decisions.
Chapter 7. Making Decisions
Goal
By the end of this chapter you will be able to write a program that chooses between possibilities.
Asking questions: comparison
A decision starts with a question, and a question in a program is a
comparison. A comparison comes out as a bool, either true or false.
Open the prompt with keleusma repl and try some:
> 3 < 5
true
> 7 == 7
true
The comparisons are < less than, > greater than, <= less than or
equal, >= greater than or equal, == equal, and != not equal. Note
that asking whether two values are equal uses a doubled ==, because a
single = is already used to bind a name.
Combining questions: and, or, not
Questions combine. Keleusma writes the combining words as words, not as symbols. This is worth fixing in memory now, because many other languages use symbols here and the habit carries over wrongly.
andis true when both sides are true.oris true when at least one side is true.xoris true when the two sides differ.notflips true and false.
> (3 < 5) and (7 == 7)
true
> not (3 < 5)
false
There is no && and no || in Keleusma. The words are and, or,
xor, and not.
These four evaluate both sides. Two more words, andalso and orelse,
are their short-circuit forms. andalso produces false without looking
at its right side once the left side is false, and orelse produces
true without looking at its right side once the left side is true.
Reach for them when the right side is only meaningful after the left has
been checked, and for the everyday case reach for and and or.
Working with bits
The words above act on a whole bool. Their bit-level cousins act on
every bit of a Word or a Byte at once. band, bor, and bxor
combine two values bit by bit, and bnot flips every bit of one value:
> 12 band 10
8
> 12 bor 10
14
> 12 bxor 10
6
Four shifts move the bits of a value left or right by a count, named by
their assembly mnemonics. lsl and asl shift left. lsr shifts right
filling with zeros, the unsigned form; asr shifts right copying the
sign bit, the signed form. The count may be a constant or a runtime
value.
> 1 lsl 4
16
> 48 lsr 2
12
These operators work on Word and Byte here, and on the multi-word
Multiword<N, F> type of Chapter 23, which
carries the same names.
Choosing a value: if and else
An if expression chooses between two values based on a question:
fn louder_of(a: Word, b: Word) -> Word {
if a > b { a } else { b }
}
fn main() -> Word {
louder_of(80, 100)
}
Run it. The output is 100. The function compares two note velocities,
two measures of loudness, and gives back the larger one. If a > b is
true, the if produces a; otherwise it produces b. The whole if
is one value, and that value is what louder_of returns.
Choosing among many: match
When there are more than two possibilities, match compares one value
against a list of cases:
fn third_quality(semitones: Word) -> Word {
match semitones {
3 => 1,
4 => 2,
_ => 0,
}
}
fn main() -> Word {
third_quality(4)
}
Run it. The output is 2. The interval that defines a triad’s quality is
its third. A third of three semitones is a minor third, written here as
1. A third of four semitones is a major third, written 2. The
underscore _ is the catch-all case, matching anything not listed above
it, and it produces 0.
Every match must cover every possibility. The _ case guarantees that.
Chapter 13 returns to match in depth.
Checking an assumption: assert
A bool question can also guard an assumption during development. The
assert statement checks a condition and, when it is false, stops the
program with an assertion failure:
assert count > 0;
assert index < length, "index past the end of the buffer";
assert is a debug aid, and it follows a deliberate rule. The check is
present only in a debug build, produced with keleusma compile --debug.
An ordinary build compiles the assertion away entirely, so it costs
nothing in a shipped program. A debug build and an ordinary build are
therefore separate compilations rather than one artefact, and the
optional message is recorded as removable debug information that
keleusma strip can take out. Use assert to state what you believe is
true while developing; rely on the type system and the partial-operation
constructs of Chapter 23 for checks that must
hold in a shipped program.
assert is not a reserved word. Written before an expression it is the
assertion statement; written as assert(...) it is an ordinary call to
a function you happen to have named assert.
What you now know
- Comparisons (
<,>,<=,>=,==,!=) produce abool. - Questions combine with the words
and,or,xor, andnot, never with symbols;andalsoandorelseare the short-circuit forms. - Bit-level operators act on every bit of a
WordorByte:band,bor,bxor,bnot, and the shiftslsl,asl,lsr, andasr. if condition { ... } else { ... }chooses between two values.matchchooses among many cases, and_is the catch-all.assert conditionchecks a development-time assumption; it is present only in a--debugbuild and compiles away otherwise.
The next chapter repeats an action a fixed number of times.
Chapter 8. Bounded Repetition
Goal
By the end of this chapter you will be able to write a loop, and you will understand the one rule that makes Keleusma loops different from loops in most other languages.
A loop with a known count
A repeat sign in sheet music says how many bars to repeat. It never says “repeat for a while, and we shall see.” Keleusma loops are the same. Every loop has a count that is known before the loop begins. This is what the word bounded means in the chapter title.
A loop is written with for. It walks through a range of numbers, or
through the entries of an array:
fn main() -> Word {
let durations = [4, 4, 8, 2];
for d in durations {
let _step = d * 2;
}
for beat in 0..4 {
let _tick = beat;
}
durations[2]
}
Run it with keleusma run. The output is:
8
The first loop walks through the four entries of the array durations,
binding each in turn to d. The second loop walks through the range
0..4, which is the four numbers 0, 1, 2, and 3. Both loop counts are
fixed before the loop starts: an array has a known length, and a range
states its bounds.
The honest limitation
Look closely at that program. The two loops run, but the result, 8,
does not depend on them. It is durations[2], the entry at position 2 of
the array, computed without any loop at all.
This is deliberate, and it follows directly from Chapter 5. A binding
cannot be reassigned. A loop therefore cannot keep a running total,
because a running total is a value that changes on every pass. Inside an
atomic function, the kind declared with fn, a loop can walk through
values but cannot accumulate a result from them.
So why learn the loop now? Because the loop earns its place in Part IV,
inside a different kind of function called a loop function. There, each
pass of the loop can hand a value to the host or update stored state, and
the repetition does real work. This chapter teaches the shape of the loop
so that it is already familiar when it matters.
Leaving a loop early
A loop can stop before its count is reached, with break:
for beat in 0..16 {
if beat == 4 {
break;
}
}
When beat reaches 4, break ends the loop at once. Note the semicolon
after break. Because break can stop the loop early, the count is
still bounded: the loop runs at most its full count, and possibly fewer
passes, but never more.
What you now know
for name in 0..n { ... }loops over a range of numbers.for name in array { ... }loops over the entries of an array.- Every loop count is known before the loop begins.
- Inside an atomic
fn, a loop cannot accumulate a result, because bindings do not change. The loop does real work in Part IV. break;leaves a loop early.
The next chapter threads a value through a chain of functions.
Chapter 9. The Pipeline Operator
Goal
By the end of this chapter you will be able to write a chain of transformations that reads from left to right.
A chain of transformations
A guitarist sends a signal through a chain of effects pedals. The sound leaves the guitar, enters the first pedal, leaves changed, enters the next, and so on. The chain reads in one direction, and each stage feeds the next.
A program often does the same with a value: take a starting value, pass it through one function, pass that result through another. Written as ordinary calls, the chain nests inside out, and the reader has to start from the middle. Keleusma offers a clearer way to write it.
The pipeline operator
The pipeline operator is written |>. The expression x |> f(args) means
“call f, with x as its first argument, followed by args.” It takes
the value on the left and threads it into the call on the right as that
call’s first argument.
fn up(note: Word, semitones: Word) -> Word {
note + semitones
}
fn main() -> Word {
60
|> up(7)
|> up(5)
}
Run it with keleusma run. The output is:
72
Read the chain from the top. The starting value is 60, the MIDI number
of middle C. The line |> up(7) calls up(60, 7), raising the note by a
perfect fifth to 67. The next line |> up(5) calls up(67, 5), raising
that by a perfect fourth to 72. A fifth stacked on a fourth spans an
octave, and 72 is indeed middle C raised by one octave.
Why the pipeline helps
The same program without the pipeline would be written up(up(60, 7), 5).
That is correct, but it reads from the inside out. The starting value
60 is buried in the middle, and the first step applied, up(7), sits
inside the second. The pipeline version places 60 first and lists each
step in the order it happens. It reads the way the music moves, one
transformation after another.
What you now know
x |> f(args)callsfwithxas its first argument.- A pipeline chains transformations so they read top to bottom in the order they occur.
- The pipeline is a clearer way to write what would otherwise be nested calls.
That completes Part II. You can now name values, write functions, make decisions, repeat actions, and chain transformations. Part III turns to building larger shapes of data.
Chapter 10. Structs
Goal
By the end of this chapter you will be able to bundle several related values into one named shape.
A note is more than a pitch
A single note carries several facts at once. It has a pitch. It has a loudness, often called velocity. It might have a duration. These facts belong together. Passing them around as three separate values, always in the right order, is error-prone. A struct bundles them into one value with named parts.
Declaring a struct
A struct declaration lists the parts, each with a name and a type:
struct Note {
pitch: Word,
velocity: Word,
}
This declares a new type, Note. A Note value has two parts, called
fields: a pitch and a velocity, each a Word.
Building and using a struct
Here is a complete program that builds a Note and reads its fields:
struct Note {
pitch: Word,
velocity: Word,
}
fn brightness(n: Note) -> Word {
n.pitch + n.velocity
}
fn main() -> Word {
let middle_c = Note { pitch: 60, velocity: 90 };
brightness(middle_c)
}
Run it with keleusma run. The output is:
150
Three things happen.
Note { pitch: 60, velocity: 90 }builds aNote. Each field is given a value by name. This is called construction.brightnesstakes one parameter, a wholeNote, rather than two loose numbers. The two facts travel together.n.pitchandn.velocityread the fields. A field is reached by writing the value, a dot, and the field name.
Why bundle
A struct lets a function signature say what it really means. brightness
takes a Note, not a pair of numbers that the caller must remember to
pass in the correct order. The structure of the data is written down once,
in the declaration, and every part of the program then agrees on it.
What you now know
struct Name { field: Type, ... }declares a new bundled type.Name { field: value, ... }constructs a value of it.value.fieldreads a field.
The next chapter describes a value that is one of a fixed set of choices.
Chapter 11. Enums
Goal
By the end of this chapter you will be able to describe a value that is exactly one of a fixed set of choices.
One of a fixed set
A struct bundles several facts that are all present at once. Sometimes a value is instead exactly one of a small, fixed set of possibilities. The articulation of a note is one of staccato, legato, or accent. It is always exactly one. A value like that is described by an enum.
Declaring and matching an enum
Each choice in an enum is called a variant:
enum Articulation {
Staccato,
Legato,
Accent,
}
fn length_percent(a: Articulation) -> Word {
match a {
Articulation::Staccato => 50,
Articulation::Legato => 100,
Articulation::Accent => 90,
}
}
fn main() -> Word {
length_percent(Articulation::Staccato)
}
Run it. The output is 50. A staccato note is held for about half its
written length.
A variant is named with the enum name, two colons, and the variant name,
as in Articulation::Staccato. The match checks which variant the
value is and chooses the matching arm.
Notice that the match has no _ catch-all. It does not need one. The
language knows every variant of Articulation, and all three are listed,
so the match is complete. If a variant were left out, the program would
be rejected before it ran. This is a real safety net. Add a fourth
articulation later, and every match that forgot to handle it is caught
at once.
Variants that carry a value
A variant can also carry a value of its own. An interval might be a unison, or a rise of some number of semitones, or a fall:
enum Interval {
Unison,
Up(Word),
Down(Word),
}
fn semitone_shift(i: Interval) -> Word {
match i {
Interval::Unison => 0,
Interval::Up(n) => n,
Interval::Down(n) => 0 - n,
}
}
fn main() -> Word {
semitone_shift(Interval::Up(7))
}
Run it. The output is 7. The variants Up and Down each carry a
Word. When a match arm names that carried value, as Interval::Up(n)
does, the value becomes available as n inside the arm. Building such a
variant looks like a function call: Interval::Up(7).
What you now know
enum Name { Variant, ... }declares a value that is one of a fixed set.Name::Variantnames a variant, andmatchchooses on it.- A
matchover an enum must cover every variant, and the language checks this. - A variant may carry a value, written
Variant(Type), and amatcharm can name and use that carried value. - A
Wordcan be turned back into an enum value with the discriminant-to-enum constructd as Name { ... }, the reverse of casting an enum to its discriminant. See Chapter 23.
The next chapter groups values by position rather than by name.
Chapter 12. Tuples and Arrays
Goal
By the end of this chapter you will be able to group values by position, in two different ways: a tuple and an array.
A tuple: a fixed group of values
A struct names its parts. Sometimes a small group of values does not need names, only an order. A pair of values, written in parentheses, is a tuple:
let event = (60, 4);
That tuple holds a pitch and a beat count, in that order. The parts of a
tuple are reached by position, starting at zero: event.0 is 60 and
event.1 is 4.
A tuple can also be taken apart into named bindings in one step. This is called destructuring:
let (pitch, beats) = event;
After that line, pitch is 60 and beats is 4.
An array: a fixed-length row of one type
An array is a row of values, all of the same type, with a length fixed when the program is written. It is written in square brackets:
let scale = [0, 2, 4, 5, 7, 9, 11, 12];
An entry is read by its position, again starting at zero, as
scale[2]. The type of that array is written [Word; 8], meaning eight
Word values.
The length is part of the type and never changes. An array does not grow or shrink. This is what makes its memory use known in advance, which Chapter 1 noted as one of the language’s promises.
A program using both
fn main() -> Word {
let event = (60, 4);
let (pitch, beats) = event;
let scale = [0, 2, 4, 5, 7, 9, 11, 12];
pitch + scale[2] + beats
}
Run it with keleusma run. The output is:
68
The tuple event is destructured into pitch, which is 60, and
beats, which is 4. The array scale holds the major-scale step
pattern from Chapter 3, and scale[2] is its third entry, 4. The sum
is 60 + 4 + 4, which is 68.
Tuple or struct, array or enum
A tuple and a struct both group values that are present together. Reach for a struct when the parts deserve names, and a tuple when a short, ordered group is clearer without them.
An array holds many values of one type. An enum holds one value out of a fixed set of types. They are not alternatives to each other. They answer different questions.
What you now know
- A tuple,
(a, b), groups values by position, read with.0,.1, and so on, or destructured withlet (a, b) = .... - An array,
[a, b, c], is a fixed-length row of one type, read witharray[index]. - An array’s length is fixed and is part of its type.
- An index that points past the end is handled with the indexing
construct
array[i] { ok(v) => ..., invalid_index(idx) => ... }. See Chapter 23.
The next chapter studies match, the tool for choosing on the shape of a
value, in depth.
Chapter 13. Pattern Matching in Depth
Goal
By the end of this chapter you will understand match thoroughly: the
kinds of pattern it accepts, the rule that it must be complete, and the
guard that refines an arm.
A recap
Earlier chapters used match to choose among cases. Chapter 7 matched a
Word against literal numbers. Chapter 11 matched an enum against its
variants. This chapter gathers the full picture.
A match has a value and a list of arms. Each arm is a pattern, then
=>, then a result. The first arm whose pattern fits the value is the
one that runs.
The kinds of pattern
Three kinds of pattern appear in an arm.
- A literal, such as
3, fits only that exact value. - A binding, such as
midi, fits any value and gives it that name inside the arm. - The wildcard,
_, fits any value and names nothing. It is the catch-all.
An enum variant pattern, such as Signal::Note(midi), fits one variant
and binds the value the variant carries.
A worked example
enum Signal {
Rest,
Note(Word),
}
fn loudness(s: Signal) -> Word {
match s {
Signal::Rest => 0,
Signal::Note(midi) when midi >= 60 => 2,
Signal::Note(midi) => 1,
}
}
fn main() -> Word {
loudness(Signal::Note(72))
}
Run it with keleusma run. The output is:
2
The value is Signal::Note(72). The first arm wants Signal::Rest, and
does not fit. The second arm wants a Signal::Note, binds its carried
value as midi, and then asks a further question with when midi >= 60.
A when on an arm is a guard. The arm runs only if its pattern fits and
its guard is true. Here 72 >= 60 is true, so the arm runs and the
result is 2.
If the note had been below 60, the guarded arm’s guard would be false,
and matching would fall through to the third arm, Signal::Note(midi),
which has no guard and produces 1.
A match must be complete
Every match must account for every possible value. A match over an
enum is complete when every variant is covered, and the language checks
this for you. A match over a Word, which has far too many values to
list, is completed with a _ wildcard arm.
Completeness is not a formality. It is the guarantee that the match
produces a result no matter what value arrives. There is no case the
program forgot.
Taking apart tuples and structs
match is at its best on enums, where the language can check
completeness precisely. For the other shapes from this part, simpler
tools were already given. A tuple is taken apart with let (a, b) = ...,
shown in Chapter 12. A struct’s fields are read with value.field, shown
in Chapter 10. Reach for those first, and reserve match for choosing
among an enum’s variants and among literal values.
What you now know
- A
matcharm is a pattern,=>, and a result. - Patterns are literals, bindings, the wildcard
_, and enum variant patterns that bind carried values. - A
whenguard refines an arm with a further condition. - Every
matchmust be complete, and the language enforces it.
The next chapter spreads a single function across several heads.
Chapter 14. Multiheaded Functions and Guards
Goal
By the end of this chapter you will be able to write a function as several heads, each handling a different case.
A prepared response for each cue
A performer rehearses a prepared response for each cue the conductor might give. The responses are written out separately, one per cue, rather than as one tangled instruction. Keleusma allows a function to be written the same way. A single function name may have several heads, each with its own case, and the right head is chosen when the function is called.
Heads that match a value
Here a function gives back the MIDI number of a fret on a guitar’s high E string. The open string, fret zero, is treated as its own case:
fn fret_note(0) -> Word { 64 }
fn fret_note(n: Word) -> Word { 64 + n }
fn main() -> Word {
fret_note(5)
}
Run it with keleusma run. The output is 69.
There are two heads for fret_note. The first head matches only the
exact argument 0. The second head, with the binding n, matches any
argument. The heads are tried in the order written, and the first that
fits is the one used. The call fret_note(5) does not match 0, so it
falls to the second head, which gives 64 + 5, that is 69, the MIDI
number of A4.
The order matters. The specific case, 0, is written before the general
case, n. Written the other way round, the general head would catch
every call and the 0 head would never be reached.
Heads with guards
A head may instead carry a when guard, the same guard seen on match
arms in Chapter 13. The head is used only when its guard is true:
fn tempo_class(bpm: Word) -> Word when bpm < 60 { 0 }
fn tempo_class(bpm: Word) -> Word when bpm < 120 { 1 }
fn tempo_class(bpm: Word) -> Word { 2 }
fn main() -> Word {
tempo_class(90)
}
Run it. The output is 1. The call tempo_class(90) tries the first
head, whose guard 90 < 60 is false, then the second, whose guard
90 < 120 is true. The second head runs and gives 1, the class for a
moderate tempo. The final head has no guard and catches everything that
reached it.
Multiheaded functions or match
A multiheaded function and a match express related ideas. A match
chooses inside one function body. A multiheaded function chooses which
body to enter at all. Use a multiheaded function when the cases are
substantial enough to deserve separate definitions, and a match when
the choice is a small step within a single computation.
What you now know
- A function name may have several heads, each handling one case.
- A head may match a literal argument or bind it with a name.
- A head may carry a
whenguard. - Heads are tried in source order, and the first that fits is used, so specific cases come before general ones.
That completes Part III. You can now shape data as structs, enums,
tuples, and arrays, take it apart with match, and dispatch a function
across several heads. Part IV turns to the heart of the language: the
three kinds of function and the way a program talks to its host.
Chapter 15. The Three Function Categories
Goal
By the end of this chapter you will know the three kinds of function Keleusma has, and what each kind is allowed to do.
Three kinds, three words
Every Keleusma function is exactly one of three kinds. The kind is fixed
by the word that begins the declaration: fn, yield, or loop. Every
function in the guide so far has been an fn. This chapter introduces all
three, and the chapters after it take the other two in turn.
fn, a finished calculation
A function declared with fn is an atomic total function. Atomic means
it runs in one piece, start to finish, without pausing. Total means it
always finishes. An fn function takes its inputs, computes, and returns
a result. It may not pause to talk to the host, it may not run forever,
and it may not call itself.
fn perfect_fifth(root: Word) -> Word {
root + 7
}
fn main() -> Word {
perfect_fifth(60)
}
Run it with keleusma run. The output is 67. An fn function is like
working out the notes of a chord: a definite question, a definite answer,
and then it is done.
yield, a phrase that pauses
A function declared with yield is a non-atomic total function.
Non-atomic means it may pause partway through. A yield function can hand
a value to the host and pause, then continue when the host resumes it. It
may pause many times, but it must eventually finish.
yield main(input: Word) -> Word {
let reply = yield input;
reply
}
A yield function is like a phrase that pauses for the conductor’s cue
and then, after however many cues, comes to an end. Chapter 16 is about
the pause itself.
loop, the piece that never ends
A function declared with loop is a productive divergent function.
Divergent means it never finishes. A loop function repeats forever. The
word productive is the condition attached: it must hand a value to the
host on every single cycle.
loop main(input: Word) -> Word {
let _ = yield input;
0
}
A loop function is the piece itself, an ostinato that grooves on and on
for as long as the host keeps it running. Chapter 17 is about it.
The rules between them
- A program has at most one
loopfunction. If it has one, thatloopfunction is the program’s entry point. - A
yieldfunction may be an entry point, or a helper. - An
fnfunction is a pure calculation, used by any of the three.
The kind of a function is a promise written into its first word. An fn
will finish. A loop will keep producing. The language relies on these
promises to make the guarantees of Chapter 1.
Running yield and loop programs
The two programs above were shown but not yet run. The keleusma
command-line tool drives all three function kinds. For fn main the
tool calls the function and prints the returned value. For yield main
and loop main the tool drives a tick-counter protocol: it calls the
script with tick = 1, accepts a yielded Word, and resumes with the
next tick number. A yield main script terminates when control returns
from the function; a loop main script runs until the script calls
shell::exit(code) or the operator interrupts the process. The
--tick-interval <duration> flag rate-limits the loop with humanized
durations such as 100ms, 1s, 1h, or 1d. Chapters 16 and 17 use
these driver shapes directly. Part VIII runs a real loop program, a
song, inside the piano roll.
What you now know
fnis an atomic total function: it runs straight through and finishes.yieldis a non-atomic total function: it may pause and resume, and must eventually finish.loopis a productive divergent function: it never finishes and must yield on every cycle.- A program has at most one
loopfunction, and it is the entry point.
The next chapter examines the pause itself: yield.
Chapter 16. Yield: Talking to the Host
Goal
By the end of this chapter you will understand the exchange between a
program and its host, and the yield expression that carries it out.
A program does not run alone
Chapter 1 described a Keleusma program as a score and the host as the
orchestra. The picture is now exact. A program does not simply run from
start to finish on its own. It runs in a conversation with its host, and
yield is one turn of that conversation.
The exchange
yield does two things in a single step. It hands a value out to the
host, and it pauses the program. The host then does whatever it does, and
when it is ready it resumes the program, handing a value back. That
returned value is the result of the yield.
This is the metronome tick. On the tick, the program hands the host a value and stops. The host acts. On the next tick, the host hands the program a value and the program continues.
In a program, the exchange is written as part of a let:
let reply = yield question;
Read it as: hand question to the host, pause, and when the host
resumes, let reply be the value it hands back.
A program that uses yield
yield main(input: Word) -> Word {
let reply = yield input;
reply
}
This program is started with a value, input. It yields input to the
host and pauses. The host resumes it with some value, which becomes
reply. The program then returns reply and finishes.
The dialogue
Two types are in play at every yield. There is the type of the value
handed out, and the type of the value handed back. Together they form the
program’s dialogue with the host, the agreed shape of the conversation.
In the program above both are Word: the program yields a Word and is
resumed with a Word.
Running a yielding program
Save the program above as echo.kel and run it:
keleusma run echo.kel
The output is:
1
The command-line tool drives a yielding program through a tick-counter
protocol. It calls the script with tick = 1, the script yields input
which is 1, the host resumes with the next tick which is 2, and the
script returns the resumed value. The tool prints the returned value and
the program ends. A yield program may pause and resume many times
before finishing. Part VIII runs a more elaborate one, a song, inside
the piano roll.
The same program can also be compiled to a bytecode file for later execution:
keleusma compile echo.kel -o echo.bin
The tool prints a line such as wrote echo.bin (2316 bytes). That line
means the program lexed, parsed, type-checked, and passed the structural
verifier.
What you now know
- A Keleusma program runs in a conversation with its host.
yield valuehandsvalueto the host and pauses the program.- When the host resumes, the value it hands back is the result of the
yieldexpression. - The pair of types, yielded out and resumed in, is the dialogue.
- The command-line tool drives
yield mainprograms through a tick-counter protocol; the program finishes when control returns from the entry function. keleusma compileproduces a bytecode file for later execution.
The next chapter turns to the function that never finishes: loop.
Chapter 17. The loop Function
Goal
By the end of this chapter you will understand the function that never finishes, and the rule that keeps it honest.
A program for a stream
A yield function pauses and resumes, but in the end it finishes. A
loop function never finishes. It is the right shape for anything that
goes on as long as the host keeps it running: an audio engine, a game, a
control loop. It runs, and runs, and runs.
loop main(input: Word) -> Word {
let _ = yield input;
0
}
The return to the top
When the last statement of a loop body has run, the program does not
stop. Execution returns to the top of the body and runs the whole thing
again. This return to the top is called RESET. RESET is the only point in
a Keleusma program where execution jumps backward.
Each pass through the body is one cycle. The program above yields input,
ignores the value it is resumed with by binding it to _, reaches the
final 0, and then RESET carries it back to the top for the next cycle.
The parameter is refreshed
The parameter input is not asked for. At the top of every cycle the host
hands in the value of input for that cycle. A game might hand in the
latest controller state. An audio sequencer might hand in the current
tick number. The program reads input and responds, every cycle, with
fresh data from the host.
The productivity rule
A loop function must hand a value to the host, with yield, on every
cycle. This is not advice. It is enforced. A loop whose body could run a
whole cycle without reaching a yield is rejected before it ever runs.
Chapter 19 shows that rejection.
The musical reading is exact. A player holding down an ostinato must sound something every bar. A player who falls silent, with no plan to return, has broken the groove and stopped the music. The productivity rule is the language refusing to let that happen.
One loop per program
A program has at most one loop function, and when it has one, that
function is the entry point. The piece has one groove at its center.
Running a loop program
Save the program above as pulse.kel and run it:
keleusma run pulse.kel --tick-interval 1s
The command-line tool drives the loop through the same tick-counter
protocol as a yield program, except that a loop never finishes. The
tool calls the script with tick = 1, accepts each yielded Word,
sleeps until the next tick interval, and resumes the script with the
next tick number. The --tick-interval flag accepts humanized durations
such as 100ms, 1s, 1m, 1h, 1d, or 1w. Without the flag the
loop runs as fast as the script yields. To stop a running loop press
Control-C. A loop can also stop itself by calling shell::exit(code).
The same program can be compiled to a bytecode file for later execution:
keleusma compile pulse.kel -o pulse.bin
The tool prints wrote pulse.bin (2372 bytes) confirming the program is
valid. Part VIII runs a more elaborate loop program, a song, inside
the piano roll.
What you now know
- A
loopfunction never finishes. Each pass of its body is one cycle. - RESET is the return to the top of the body at the end of each cycle.
- The parameter is refreshed by the host at the top of every cycle.
- A
loopmustyieldon every cycle. This productivity rule is enforced. - A program has at most one
loopfunction, and it is the entry point.
The loop above does the same thing every cycle, because it remembers nothing from one cycle to the next. The next chapter gives it a memory.
Chapter 18. The Data Segment
Goal
By the end of this chapter you will be able to keep state that survives
from one cycle of a loop function to the next.
The problem of memory
A loop function runs the same body, cycle after cycle. Suppose it needs
to remember something: which beat of the bar it is on, which note comes
next. Chapter 5 established that a binding cannot change, and a binding
made inside the body is gone by the time the next cycle begins. As the
language stands so far, a loop function cannot remember anything.
The data segment
The answer is the data segment. It is the one region of a program’s
memory that may be changed, and whose values survive from one cycle to
the next. It is declared with the word data:
data state {
steps: [Word; 4],
}
loop main(input: Word) -> Word {
for i in 0..4 {
state.steps[i] = state.steps[i] + 1;
}
let _ = yield state.steps[0];
0
}
A data block looks like a struct, and its fields are read with a dot,
as state.steps. The difference is that its fields may be assigned, with
=, and what is assigned is still there on the next cycle.
The data segment begins with every field zeroed. The program above has four counters. On the first cycle each becomes 1. On the second cycle, having survived RESET, each becomes 2. The counters climb, cycle after cycle, because the data segment remembers.
This is where loops do real work
Look again at the for loop above, and recall Chapter 8. There, a for
loop inside an atomic fn could not build a result, because bindings do
not change. Here the same for loop does real work. Each pass writes to
state.steps, and the data segment does change. This is the place the
loop earns its keep, exactly as Chapter 8 promised.
Three kinds of data block
A data block may be marked with its visibility.
- A bare
datablock, as above, is shared. The host may read and write it too. It is how the host and the program pass state between them. - A
private datablock is the program’s own memory. It persists across cycles, but the host does not see it. - A
const datablock is read-only configuration, fixed for the life of the program and never assigned.
Shared is the common case and the one to start with.
Running the program
Save the program as counters.kel and run it:
keleusma run counters.kel --tick-interval 1s
The program yields once per second, and on each cycle the data segment preserves its state from the previous cycle. Press Control-C to stop.
The program can also be compiled to a bytecode file:
keleusma compile counters.kel -o counters.bin
The tool prints a line such as wrote counters.bin (2716 bytes). Part
VIII runs a more elaborate program that uses the data segment, a song,
inside the piano roll.
What you now know
- The data segment is the one region of memory that may change and that survives from one cycle to the next.
data name { field: Type, ... }declares it;name.fieldreads a field;name.field = value;writes one.- A
forloop inside aloopfunction does real work by writing to the data segment. - A data block is
sharedby default, orprivate, orconst.
That completes Part IV, the heart of the language. You have seen the
three kinds of function, the yield exchange with the host, the loop
function and its cycle, and the data segment that gives it memory. Part V
explains the checks a program must pass before it is allowed to run at
all.
Chapter 19. Why Was My Program Rejected?
Goal
By the end of this chapter you will understand what it means for a program to be rejected, you will have seen it happen, and you will know how to respond.
The verifier
Chapter 1 made a promise: before a program runs, the language guarantees that each tick finishes within bounded time and memory. The part of the language that keeps that promise is the verifier. Every program passes through it. A program the verifier cannot prove bounded, it rejects, and the program does not run.
A rejection is not a malfunction. It is the promise doing its work. A rejected program is simply one the language was unable to vouch for.
A worked rejection: recursion
Anyone who has seen a little programming reaches, sooner or later, for a function that calls itself. It is a natural way to express “do this again.” Here is one that counts down from a number:
fn count_down(n: Word) -> Word {
if n <= 0 { 0 } else { count_down(n - 1) }
}
fn main() -> Word {
count_down(5)
}
Run it with keleusma run. There is no result, only an error:
error: verify: VerifyError("count_down: recursive call detected during WCMU topological sort")
The phrase that matters is recursive call detected. The rest names the internal check that found it.
Why recursion is rejected
A function that calls itself could call itself any number of times. The depth depends on the input. The language cannot see, before the program runs, how deep the calls will go, so it cannot promise a bound on the work or the memory. Chapter 1 listed “no recursion” among the things Keleusma leaves out. This error is that rule being enforced.
The rewrite
The instinct behind the recursive count_down was “repeat five times.”
Keleusma expresses a fixed number of repetitions with a for loop whose
count is written as a plain constant:
fn repeat_five() -> Word {
for _i in 0..5 {
let _step = 1;
}
0
}
fn main() -> Word {
repeat_five()
}
This runs, and returns 0. The count, 5, is written into the program
and visible to the verifier, so the verifier can prove the loop is
bounded. Recall from Chapter 8 that such a loop cannot accumulate a
result inside an fn. When a running total is genuinely needed, the data
segment of a loop function holds it, as Chapter 18 showed.
Two more rejections
A for loop whose count is not a constant is also rejected:
fn process(n: Word) -> Word {
for i in 0..n {
let _step = i;
}
0
}
This produces an error containing no statically extractable iteration bound. The count n arrives at runtime, and the verifier cannot see it
in advance. The fix is the same: a constant bound, or iteration over an
array whose length is fixed.
A loop function with no yield is rejected with Stream block must contain at least one Yield. That is the productivity rule from Chapter
17, enforced by the verifier.
Two categories of rejection
Rejections fall into two groups.
- Some programs are rejected because no bound exists at all. Recursion is one. No future improvement to the language will admit it, because there is nothing to prove. The only response is to rewrite the program.
- Some programs are rejected because, although a bound exists, the present analysis cannot yet work it out. The loop with a runtime count is one. A future, sharper verifier might admit it unchanged.
Either way, the response a beginner needs is the same: rewrite the
program into a form the verifier accepts. The repository document
WHY_REJECTED.md lists the rejection messages and their rewrites in
full.
What you now know
- The verifier checks every program and rejects any it cannot prove bounded.
- Recursion is rejected, because its depth cannot be known in advance.
- A loop with a non-constant count is rejected, for the same reason.
- A
loopwith noyieldis rejected by the productivity rule. - A rejection is the language keeping its promise, not a failure.
The next chapter explains the promise itself: the budgets a program is proved to fit within.
Chapter 20. Time and Memory Budgets
Goal
By the end of this chapter you will understand the two budgets every Keleusma program is proved to fit within, and the promises that rest on them.
Two budgets per tick
Chapter 19 showed the verifier rejecting programs. This chapter explains what it is protecting. The verifier holds every program to two budgets, and it checks both before the program is allowed to run.
The time budget
The first budget is time. Before a program runs, the language works out the largest amount of work any single tick could possibly do, across every path the program might take, and proves that amount is finite. This is the worst-case execution time.
The musical reading is direct. A beat at a given tempo has only so much room in it. A player cannot sound an unlimited number of notes inside one beat. The verifier proves that the program’s busiest tick, the one that does the most work, still fits inside its beat.
The budget is measured in a unit called pipelined cycles. A pipelined cycle is a measure of work, a count of small machine steps, not a count of seconds. The language proves a bound in that measure. Turning the measure into real seconds depends on the machine the program runs on, and that conversion is the host’s concern. What the language guarantees is that the amount of work per tick is bounded.
The memory budget
The second budget is memory. The language works out the largest amount of working memory any tick could need, and proves that amount is finite too. This is the worst-case memory usage. The host then sets aside exactly that much memory, in a fixed region called the arena.
The arena is a music stand of a fixed size. The verifier proves that the program never needs more paper on the stand than the stand can hold. A program whose memory need cannot be proved finite, or whose proved need is larger than the arena provided, does not run.
The promises
The two budgets, together with the function categories of Chapter 15, support a set of promises the language makes about every program it accepts.
- Totality: every
fnfunction finishes. - Productivity: every
loopfunction produces a value on every cycle. - Bounded time: every tick fits the time budget.
- Bounded memory: every tick fits the memory budget.
- Safe swapping: a program’s code can be replaced with new code at a RESET boundary without breaking its conversation with the host. Chapter 26 returns to this.
These are not hopes. They are proved, before the program runs, for every program the verifier accepts.
Acceptance is a proof
This program is accepted:
fn main() -> Word {
for _b in 0..8 {
let _beat = 1;
}
0
}
Run it with keleusma run and it returns 0. Nothing dramatic appears.
But before that 0 was produced, the verifier proved that the loop runs
exactly eight times, and therefore that the program’s time and memory are
both bounded. Acceptance is quiet, but acceptance is a proof. Every
program that runs has passed it.
The conservative stance
The verifier rejects any program it cannot prove bounded, even a program that would in fact have behaved perfectly well. It would rather turn away a safe program than admit an unsafe one. This is why Chapter 19’s recursive count-down was rejected even though it would have stopped at zero. The verifier did not know that in advance, and “in advance” is the whole point.
This is the trade Keleusma makes. The language accepts a smaller set of programs than other languages do, and in exchange it can promise things about every program in that set that other languages cannot promise about any program at all. For an audio engine that must never stutter, or a controller that must always answer in time, that trade is the reason to reach for Keleusma.
What you now know
- Every program is held to a time budget and a memory budget, both checked before it runs.
- The time budget is worst-case execution time, measured in pipelined cycles.
- The memory budget is worst-case memory usage, held within the arena.
- The language promises totality, productivity, bounded time, bounded memory, and safe swapping for every program it accepts.
- The verifier rejects whatever it cannot prove, by design.
That completes Part V. You now understand both what the language guarantees and what it asks of a program in return. Part VI returns to the language itself, with several features that build on everything so far.
Chapter 21. Generics and Traits
Goal
By the end of this chapter you will be able to attach behavior to a type with a trait, and write a function that works for many types with a generic.
A trait: a named role
In an ensemble, a role such as “the instrument carrying the melody” can be filled by a flute one night and a violin the next. The role is named; the instrument that fills it varies. A trait is a named role for a type.
A trait declares behavior. An impl block provides that behavior for one
particular type:
trait Transpose {
fn up_octave(x: Word) -> Word;
}
impl Transpose for Word {
fn up_octave(x: Word) -> Word {
x + 12
}
}
fn main() -> Word {
let n: Word = 60;
n.up_octave()
}
Run it with keleusma run. The output is 72.
The trait Transpose declares that a type filling this role has an
up_octave behavior. The impl Transpose for Word provides it: for a
Word, raising by an octave is adding twelve. The call n.up_octave()
uses it. The value before the dot, n, is the one the behavior acts on.
Calling one method and then another on the result, as in
n.up_octave().up_octave(), needs a typed binding in between for now.
Bind the intermediate result with let m: Word = n.up_octave(); and call
the next method on m.
A generic: a function for many types
A generic function is written once and works for many types. The type it works on is left as a parameter, a stand-in name in angle brackets:
fn first<T>(a: T, b: T) -> T {
a
}
fn main() -> Word {
first(64, 67)
}
Run it. The output is 64.
The <T> introduces a type parameter named T. Inside first, both
parameters and the result are T, whatever T turns out to be. The call
first(64, 67) uses Word values, so for that call T is Word. The
same function would serve Float values or any other type. A generic
function is a phrase written so that it works whatever the instrument.
A const generic: a compile-time number
A type parameter stands in for a type. A const parameter stands in for a
number fixed at compile time. It is written const n: Word in the angle
brackets, and inside the body n is an ordinary Word value:
fn plus<const n: Word>() -> Word {
n + 10
}
fn main() -> Word {
plus::<7>()
}
Run it. The output is 17. The ::<7> after the name is a turbofish,
and it supplies the const value for this call. A const value is always
written out this way, never inferred, because there is no value argument
for the compiler to read it from.
A const parameter can set the length of an array, so a function can take a fixed-size buffer whose size is part of its signature:
fn first<const n: Word>(a: [Word; n]) -> Word {
a[0]
}
fn main() -> Word {
first::<3>([10, 20, 30])
}
The output is 10. Structs take const parameters too, mixed after any
type parameters, and construction supplies the const with the same
turbofish:
struct Buf<const n: Word> {
items: [Word; n],
}
fn get(b: Buf<3>) -> Word {
b.items[2]
}
fn main() -> Word {
get(Buf::<3> { items: [10, 20, 30] })
}
The output is 30. A const value can be built from other const values
with +, -, and *, as in Buf<n + 1> or Multiword<2 * n>. There is
no const division, so const arithmetic is always total.
Every const parameter is replaced by its concrete number when the program
is specialized, before the worst-case bounds are computed. The verifier
therefore never sees a symbolic size; a [Word; n] has become a
[Word; 3] by the time its memory is measured. This is why a const
generic keeps the definitive time and memory bounds intact.
What you now know
- A
traitdeclares a named behavior, and animplblock provides that behavior for one type. value.method()calls a behavior, acting on the value before the dot.- A generic function uses a type parameter, written
<T>, to work for many types at once. - A const parameter, written
<const n: Word>, stands in for a compile-time number, supplied by the turbofishf::<7>()and usable as an array length, aMultiworddimension, or aWordvalue. It is erased to its concrete number before the bounds are computed.
The next chapter gives a type a distinct name and a rule.
Chapter 22. Newtypes and Refinement Types
Goal
By the end of this chapter you will be able to give a type a distinct name, and attach a rule that every value of it must satisfy.
The problem of look-alike numbers
A channel number is a Word. A note velocity is a Word. A MIDI pitch
is a Word. They are all whole numbers, and so the language, left to
itself, would let any of them be used where another was meant. Handing a
velocity to a function expecting a channel is a real mistake, and one the
type Word cannot catch, because all three are the same type.
A newtype: a distinct name
A newtype gives an underlying type a new, distinct name:
newtype Channel = Word;
fn raw(c: Channel) -> Word {
c as Word
}
fn main() -> Word {
raw(Channel(2))
}
Run it with keleusma run. The output is 2.
Underneath, a Channel is a Word, and runs exactly as fast. To the
type system, though, Channel and Word are different types. A
Channel is built by writing Channel(2). The underlying Word is
recovered by writing c as Word. The language will not let a plain
Word be used where a Channel is expected, or the reverse, without one
of those explicit steps. The look-alike numbers are now kept apart.
A refinement: a name with a rule
A newtype can carry a rule, called a refinement. The rule is an ordinary
function that takes the underlying value and answers true or false:
fn in_range(x: Word) -> bool {
x >= 0 and x <= 127
}
newtype Velocity = Word where in_range;
fn raw(v: Velocity) -> Word {
v as Word
}
fn main() -> Word {
let soft = Velocity(40);
raw(soft)
}
Run it. The output is 40. A MIDI velocity must lie between 0 and 127.
The where in_range clause attaches that rule to Velocity. Every time
a Velocity is built, the rule is checked. The value 40 passes, so
Velocity(40) succeeds.
A broken value is caught
Change the program to build a velocity outside the range:
fn main() -> Word {
raw(Velocity(200))
}
Run it, and there is no result, only:
error: compile: refinement check `in_range` provably fails for newtype `Velocity` at compile time on argument 200
The value 200 is written into the program, so the language checks the
rule then and there, before the program runs, and rejects it. A
Velocity simply cannot hold an out-of-range number. The rule, written
once in the where clause, is enforced at every construction. It is a
part written so that a wrong note cannot be played.
What you now know
newtype Name = Underlying;gives an underlying type a distinct name.Name(value)builds one;value as Underlyingrecovers the underlying value.newtype Name = Underlying where predicate;attaches a rule, checked at every construction.- A construction that provably breaks the rule is rejected before the program runs.
- When a value can break the rule only at run time, the
newtype-construction construct
Name(value) { ok(v) => ..., invalid_newtype(x) => ... }handles the rejection in place. See Chapter 23.
The next chapter handles the operations that can fail, including the construction rejection above.
Chapter 23. Handling Partial Operations
Goal
By the end of this chapter you will be able to handle the operations that can fail, namely arithmetic that does not fit, division by zero, an index past the end of an array, and a few others, so that your program stays total and never fails silently.
Total and partial operations
Most operations always produce a value. Adding two small numbers, taking
a struct field, comparing two values: these are total, defined for every
input. A few operations are different. They are mathematically partial,
meaning undefined on some inputs. Arithmetic can overflow the range of a
Word. Division by zero has no answer. An index can point past the end
of an array. A refinement can reject its value. Each of these is a real
input the language must do something with.
Keleusma does not let a partial operation pass silently or crash. It gives each one a defined outcome and a construct that performs the operation and reports which case happened, so your program decides what to do. This chapter covers that family of constructs. We begin with arithmetic.
The checked arithmetic construct
The construct is an arithmetic expression followed by arms in braces:
fn add_checked(a: Word, b: Word) -> Word {
a + b {
ok(v) => v,
overflow(_, _) => 0,
underflow(_, _) => 0,
}
}
fn main() -> Word {
add_checked(20, 22)
}
Run it with keleusma run. The output is 42.
The expression a + b is performed, and the result is routed to one of
the arms.
ok(v)runs when the true result fits in aWord. The result is bound tov.overflowruns when the true result is too large.underflowruns when the true result is too far below zero.
For 20 + 22, the result 42 fits, so the ok arm runs.
When the result does not fit
Change main to add the largest Word and one more:
fn main() -> Word {
add_checked(9223372036854775807, 1)
}
That sum is one past the largest Word. Now the overflow arm runs
instead, and the function returns 0. The arithmetic did not fail
silently and did not produce a quietly wrong answer. The construct
reported the overflow, and the program decided what to do about it.
The high and low halves
The overflow and underflow arms were written overflow(_, _) above,
ignoring what they carry. On a Word they carry two values, the high
half and the low half of the true result, computed in a number twice as
wide as a Word:
overflow(high, low) => ...
These two halves are the foundation of big-number arithmetic. A number
too large for one Word is carried as a pair, a high half and a low
half, and the carry from one position threads into the next. The bundled
example examples/scripts/09_big_numbers.kel, and the guide page
BIG_NUMBERS.md, work this technique in full.
The first-class multi-word type
You do not have to thread the carry by hand for the common case. The
Multiword<N, F> type is a fixed-width multi-word fixed-point value, N
words wide with F fractional bits, that carries the halves for you. The
form Multiword<N> is the integer case, equal to Multiword<N, 0>. You
construct one from a tuple of its words, least significant first, and
index its words back out:
fn main() -> Word {
let a = (9223372036854775807, 0) as Multiword<2>;
let b = (1, 0) as Multiword<2>;
let s = a + b;
s[1]
}
The low word of a is the largest Word. Adding 1 sets that word’s
top bit, turning it into the smallest Word, but no bit carries out of
the low word, so the high word s[1] stays 0. This is the correct
unsigned multi-word carry, which is not the same as the signed-overflow
report of the checked construct above. Addition, subtraction,
and the six comparisons are lowered to the very carry and borrow cascade
this chapter describes, so those operations add no new instructions.
Integer and fixed-point multiplication, division, and modulo, which
apply the fractional scale F, along with the four shifts lsl, asl,
lsr, and asr and the per-limb bitwise operators band, bor,
bxor, and bnot, are also available. The type was delivered as B19.
Optional arms and the wrapping default
The overflow and underflow arms are optional. When you omit them, an
out-of-range result wraps around in two’s complement, the same as bare
machine arithmetic. So a construct with only an ok arm is exactly the
ordinary wrapping operation, written out so the intent is visible:
let total = a + b { ok(v) => v };
You add the arms only for the cases you want to handle. The ok arm is
the one you must always write.
Division by zero
Division and modulo have a different failure, a zero divisor, with no
result at all. The zero_divisor arm handles it and binds the numerator:
fn safe_div(a: Word, b: Word) -> Word {
a / b {
ok(q) => q,
zero_divisor(n) => 0,
}
}
fn main() -> Word {
safe_div(10, 0)
}
The output is 0. Without the zero_divisor arm, a division by zero
stops the program with a recoverable error rather than producing a
silent wrong answer.
The other number types
The construct works on the four numeric types, not only Word. On
Byte, Float, and Fixed<N> an overflow or underflow arm binds a
single result rather than two halves, because those types do not carry
the big-number high half:
fn main() -> Byte {
200Byte + 100Byte {
ok(v) => v,
overflow(w) => w,
}
}
The sum 300 does not fit in a Byte, so the overflow arm runs and
binds the wrapped result w, which is 44; a Byte result prints as
Byte(44), the value tagged with its type. The supported operators are
+, -, *, /, %, the arithmetic left shift asl, and unary -,
with the admissible arms depending on the type. An unsigned Byte, for
instance, can overflow on addition but can only go below zero on
subtraction. The arithmetic left shift asl is the value x * 2^k, so
on a Word it can overflow or go below zero exactly as a multiply does,
and it takes the same overflow and underflow arms.
Saturating to the edge
Inside an arm body, the keywords saturate_max and saturate_min stand
for the largest and smallest value of the construct’s type. They let you
clamp an out-of-range result to the edge of the range instead of choosing
a number by hand:
fn main() -> Byte {
200Byte + 100Byte {
ok(v) => v,
overflow(_) => saturate_max,
}
}
The output is Byte(255), the largest Byte. On Word the keywords are the
word bounds, on Float the largest and most-negative finite value, and
on Fixed<N> the extremal fixed-point value. When the result type is a
refined newtype that declared a with saturate_max or with saturate_min value, the keyword resolves to that declared bound.
A family of constructs
The same brace-and-arms shape handles every partial operation in the language, each with its own arm keywords.
Indexing. An array index can point past the end. The invalid_index
arm binds the offending index, and ok binds the element:
fn main() -> Word {
let a = [10, 20, 30];
a[9] {
ok(v) => v,
invalid_index(_) => 0,
}
}
The index 9 is out of range, so the result is 0.
Newtype construction. Constructing a refined newtype can fail when
the value breaks the rule. The invalid_newtype arm binds the value the
predicate rejected:
fn is_positive(x: Word) -> bool { x > 0 }
newtype Positive = Word where is_positive;
fn main() -> Word {
let p = Positive(0 - 4) {
ok(v) => v as Word,
invalid_newtype(_) => 1,
};
p
}
The value -4 fails the rule, so the invalid_newtype arm runs and the
result is 1.
Discriminant to enum. A Word can be turned back into an enum value,
the reverse of casting an enum to its discriminant. A unit variant
converts to itself, the payload_discriminant arm supplies a
payload-bearing variant’s payload, and invalid_discriminant catches a
Word that matches no variant:
enum Signal { Stop = 0, Go = 1 }
fn main() -> Word {
let s = 1 as Signal {
invalid_discriminant(_) => Signal::Stop,
};
s as Word
}
The discriminant 1 is the Go variant, so the result is 1.
Native call. A native function provided by the host can report a
failure. The error arm binds the Word error code the native reports,
and ok binds the success value:
let row = host::lookup(id) {
ok(v) => v,
error(code) => code,
};
A native call is exercised from an embedding host rather than from
keleusma run. Chapter 33, Registering Natives
shows the host side, including how a host reports the error code.
Two backends, one contract
Every construct here shares one contract. The bytecode virtual machine,
the verifying interpreter you run with keleusma run, traps on any
unhandled partial operation. A trap is a recoverable error the host
receives, not a crash. A future native build of the same program, the
subject of a later milestone, instead produces a defined, non-crashing
value, using the hardware result where the hardware does not fault and a
small inserted check where it would. The two builds can differ only on a
partial operation you did not handle. Handle every outcome through these
constructs and your program is total: it produces the same result on
both backends and never traps. The full contract, including the value
each backend produces for each operation, is specified in
RUNTIME_FAULTS.md.
What you now know
- A few operations are partial, undefined on some inputs. The language gives each a defined outcome and a construct to handle it.
- Checked arithmetic,
a + b { ok(v) => ..., overflow(...) => ... }, reports overflow, underflow, and the zero divisor. Theoverflowandunderflowarms are optional and default to wrapping;okis required. OnWordthe arms carry the high and low halves, the foundation of big-number arithmetic; onByte,Float, andFixed<N>they carry a single result. saturate_maxandsaturate_minclamp to the edge of the type’s range.- The same shape handles indexing (
invalid_index), newtype construction (invalid_newtype), the discriminant-to-enum conversion (payload_discriminant,invalid_discriminant), and native calls (error). - An unhandled partial operation traps on the virtual machine. Handling every outcome makes a program total.
The next chapter, the last of Part VI, marks data as confidential and lets the language track where it flows.
Chapter 24. Information-Flow Labels
Goal
By the end of this chapter you will be able to mark a value as confidential and let the language check that it does not flow somewhere it should not.
This is an advanced chapter, and the last of Part VI. The features in it are not needed for everyday programs. They are here for programs that must keep some data confidential.
A label rides on a type
A master recording is not meant to leave the studio. A program may handle
data with the same quality: a value that must not reach a public output.
Keleusma lets a type carry a label, a tag that marks the value and rides
along with it. A label is written after the type with an @:
fn main() -> Word@Master {
classify 42@Master
}
Run it with keleusma run. The output is 42.
Word@Master is a Word carrying the label Master. The operator
classify 42@Master takes the plain value 42 and attaches the Master
label to it. The label names are chosen by the programmer; Master here
is one such name.
The label exists only while the program is being checked. Once the
program runs, the label is gone, and the value is an ordinary 42. The
label costs nothing at run time. It is purely a check the language
performs beforehand.
A label blocks a leak
The point of a label is that the language follows it. A labelled value
may not flow into a place that does not accept the label. Here a function
broadcast sends a plain Word to a public output:
fn broadcast(x: Word) -> Word {
x
}
fn main() -> Word {
let take = classify 42@Master;
broadcast(take)
}
Run it, and there is no result:
error: compile: type error: argument to `broadcast` expects Word, got Word@Master
The value take carries the Master label. The parameter of broadcast
is a plain Word, with no label, so it does not accept Master-labelled
data. Handing take to broadcast would let the master recording reach
a public output. The language calls that a leak and rejects the program
before it runs.
Declassify: a deliberate release
Sometimes confidential data genuinely should be released, by an explicit
decision. The operator declassify removes a label:
fn broadcast(x: Word) -> Word {
x
}
fn main() -> Word {
let take = classify 42@Master;
broadcast(declassify take@Master)
}
Run it. The output is 42. The declassify take@Master removes the
Master label, producing a plain Word, which broadcast accepts.
The two operators are not equal in weight. classify only adds a
restriction, and is always safe. declassify removes one, and so it is
the single, visible place in the program where confidential data is
released. A reviewer reading the program can find every release by
searching for declassify.
What you now know
- A type can carry a label, written
T@Label, that marks a value. classify expr@Labeladds a label;declassify expr@Labelremoves one.- The language tracks labelled values and rejects, before the program runs, a flow into a place that does not accept the label.
- Labels are erased before the program runs and cost nothing at run time.
declassifyis the deliberate, visible point where confidential data is released.
That completes Part VI. You have now seen the whole language a script author writes. Part VII turns to what happens to a program after it is written: how it is compiled, signed, and swapped.
Chapter 25. From Source to Bytecode
Goal
By the end of this chapter you will understand what keleusma run has
been doing all along, and you will be able to compile a program into a
file that runs directly.
Source and bytecode
A .kel file holds source: the text a person writes and reads. The
virtual machine does not run source. It runs bytecode, a compact form
produced from the source by the compiler. Every time keleusma run has
been used in this guide, it has quietly done four steps in a row: read
the source, compile it to bytecode, verify the bytecode, and run it.
Those steps can be separated. The compiling can be done once, ahead of time, and the result saved.
Compiling ahead of time
Write a small program and save it as tune.kel:
fn main() -> Word { 60 + 7 }
Compile it:
keleusma compile tune.kel -o tune.kel.bin
The tool prints:
wrote tune.kel.bin (2400 bytes)
tune.kel.bin is the compiled bytecode. Run it directly:
keleusma run tune.kel.bin
The output is 67. The tool recognized the file as bytecode and ran it
without compiling, because the compiling was already done.
What is in a bytecode file
A bytecode file is a self-describing package. It begins with a short marker so the runtime can recognize it as Keleusma bytecode. After the marker comes a header carrying the program’s facts, then the program body, and at the end a checksum. The runtime reads the header before anything else and refuses a file that is not genuine Keleusma bytecode or that is built for an incompatible machine. The checksum lets it detect a file that was damaged in storage or transit.
A beginner does not need the byte-by-byte layout. The point is that a bytecode file is checked, by the runtime, before a single instruction of it runs.
A compiled file can carry a shebang
Chapter 2 added a shebang line to a source file on macOS and Linux. A compiled bytecode file can carry one too, so a finished, compiled program can be made directly runnable in the same way.
Why compile ahead
Compiling once and shipping the bytecode has two benefits. The machine that runs the program does not need the compiler, only the runtime. And the program starts at once, with no compile step first. Bytecode is the finished, engraved score, ready to hand to a player, as distinct from the working manuscript that the source is.
Selecting a target
The default compile targets the same machine running the compiler. To
build a bytecode artefact for a different machine, pass --target:
keleusma compile tune.kel --target embedded_16 -o tune.kel.bin
The recognised target names are host (the default), wasm32,
embedded_32, embedded_16, and embedded_8. The chosen target
controls word, address, and float widths and validates the program
against the configuration. A program that uses literals or constants
outside the target’s representable range is rejected at compile time.
What you now know
- Source is the text you write; bytecode is the compact form the runtime executes.
keleusma runcompiles and runs in one step;keleusma compileproduces a bytecode file you can run later.- A bytecode file is self-describing, version-checked, and protected by a checksum.
- Compiling ahead of time means the running machine needs only the runtime, and the program starts immediately.
If you want to see the individual instructions the runtime executes, the Instruction Set reference lists every opcode with its operands, cost, and effect on the time and memory budgets. The playground shows the same disassembly for any program you write.
The next chapter covers two more things that happen to a finished program: it can be signed, and it can be swapped.
Chapter 26. Signed Modules and Hot Code Swap
Goal
By the end of this chapter you will understand two things that can happen to a finished program: it can be signed so its origin is provable, and it can be swapped for new code while it runs.
Signing: proving where a program came from
A bytecode file travels. It is compiled on one machine and may run on another, far away. The machine that runs it has a question: is this the genuine program, from the expected author, unaltered? A signature answers that question.
The intended use is multi-party delivery. A studio compiles a program and signs it with a private key that only the studio holds. A device in the field holds the matching public key, and it refuses to run any program whose signature does not check out against that key.
Marking a program as signed
A program opts in with the signed modifier on its entry function:
signed fn main() -> Word {
21 + 21
}
signed marks the program as one that must carry a valid signature
before it will load. It is allowed only on the entry function, and it
works on any of the three function kinds: signed fn main,
signed yield main, signed loop main.
The signing flow
First, make a key pair:
keleusma keygen --seed studio.seed --public studio.pub
This writes two files. studio.seed is the private key, the secret the
studio guards. studio.pub is the public key, given to anyone who needs
to verify. The tool refuses to overwrite an existing key file, because a
key is a long-lived secret.
Save the program above as app.kel, then compile and sign it:
keleusma compile app.kel --signing-key studio.seed -o app.kel.bin
Run the signed bytecode, supplying the public key to verify against:
keleusma run app.kel.bin --verifying-key studio.pub
The output is 42. The runtime checked the signature against
studio.pub, found it valid, and ran the program.
Run it without the key, and the program does not load:
error: verify_module_signature: InvalidSignature
A signed program will not run unless its signature checks out. It is a
sealed and signed score, and a player who trusts the seal. The signature
scheme is Ed25519, a standard and well-trusted one.
Encryption: keeping the program confidential in transit
A signed bytecode artefact carries an integrity guarantee, but its contents are still readable to anyone who intercepts it. For deployments that require confidentiality as well, the bytecode can be encrypted to a specific recipient at compile time.
The recipient generates an encryption keypair (X25519) and gives the public half to whoever will produce the artefact:
keleusma keygen --kind encryption --seed device.seed --public device.pub
The compile step takes both a signing key and the recipient’s public encryption key:
keleusma compile app.kel \
--signing-key studio.seed \
--encryption-key device.pub \
-o app.kel.bin
The recipient runs the artefact with both the verifying key and their own decryption key:
keleusma run app.kel.bin \
--verifying-key studio.pub \
--decryption-key device.seed
Encryption is layered above signing. The signature covers the encrypted body so an adversary cannot strip the encryption and substitute cleartext without invalidating the signature. The encryption uses X25519 key exchange combined with AES-256-GCM authenticated encryption.
The SECURITY_POLICY.md reference describes
both gates as deployment policies. The two policies are independent and
either may be activated through enrolled key stores in
platform-conventional directories.
Hot code swap: changing the program while it runs
The second thing that can happen to a finished program is that it can be replaced, while running, by a new one.
Recall RESET from Chapter 17: the boundary at the top of each cycle of a
loop function. RESET is also the moment at which a program can be
swapped. The running program finishes a cycle. At the RESET boundary, the
host installs new code in place of the old. The next cycle runs the new
program.
The conversation is not interrupted. The one thing that must stay the
same across a swap is the dialogue from Chapter 16, the agreed pair of
types exchanged at each yield. As long as the new program speaks the
same dialogue, the host keeps talking to it without a break. This is
swapping to a new arrangement at the next downbeat, without stopping the
band.
A program does not swap itself. The host performs the swap, at a RESET boundary. Part VIII shows it directly: in the piano roll, pressing a key swaps the running song for another, and that is a hot code swap.
What you now know
- A bytecode file can be signed, so a running machine can prove its origin.
- The
signedmodifier on the entry function marks a program as requiring a valid signature. - The flow is
keleusma keygen, thenkeleusma compile --signing-key, thenkeleusma run --verifying-key. - A signed bytecode artefact can additionally be encrypted to a
specific recipient with
--encryption-key. The recipient runs it with--decryption-key. - Strict-mode deployment policies live in
SECURITY_POLICY.md. - Hot code swap replaces a running program with new code at a RESET boundary, and the dialogue must stay the same across the swap.
That completes Part VII. The program is written, compiled, and shippable. Part VIII puts a real one to work, and makes it audible.
Chapter 27. The Piano Roll: How It Works
Goal
By the end of this chapter you will understand how the piano roll example
works: how a Keleusma loop program becomes music.
A real loop program
Part IV introduced the loop function, a program that never finishes and
yields to a host on every cycle, and said a real one would be run in Part
VIII. The piano roll is that real program. A song is a loop main, and
the piano roll example is the Rust host that drives it.
Three pieces
The piano roll has three pieces, working at three different speeds.
- The song is a Keleusma
loop main(input: Word) -> Word. Each cycle of the loop is one sixteenth-note tick. On each tick the song decides which notes start or stop. - The host’s main thread drives the song. It resumes the song once per tick. At 120 beats per minute, a tick is 125 milliseconds.
- The host’s audio thread produces the sound. It runs far faster, at forty-eight thousand samples per second, and it never enters the Keleusma virtual machine.
The song schedules, the host synthesizes
This is the key idea. The song does not make sound. It schedules events. When the song decides that channel 0 should sound MIDI note 60, it calls a host native:
host::play(0, 60)
That call writes into the host’s voice state. The audio thread reads that state and turns it into sound. The song’s job is the timing and the note choices. The host’s job is the synthesis. The native function calls, introduced in Chapter 16’s idea of talking to the host, are the bridge.
The song’s memory
A song remembers where it is using the data segment from Chapter 18.
Every bundled song declares a data state block with the same shape: a
one-shot setup flag, a loop counter, a section pointer, a few
general-purpose slots, and two arrays of eight counters, one tracking
each channel’s position in its note pattern and one tracking the ticks
left before that channel’s next note.
The init block
The first thing a song’s loop main body does, on its very first cycle,
is a one-shot setup block guarded by the state.init flag:
if state.init == 0 {
host::song_name("C major progression, four-bar loop");
host::set_waveform(0, 0);
host::set_adsr(0, 5, 80, 700, 150);
host::set_enable(0, 1);
// ... configure the other channels ...
state.init = 1;
};
The data segment starts zeroed, so state.init is 0 on the first
cycle, the block runs, and it sets state.init to 1. On every cycle
after that, the block is skipped. This is where each channel’s instrument
is chosen.
The per-tick body
After the init block, the song handles each channel the same way. For a
channel, if its remaining-ticks counter has reached zero, the song looks
up the channel’s next note, calls host::play or host::silence, sets
the counter to the new note’s duration, and advances the channel’s
position. Otherwise it simply counts the counter down by one. Then the
song yields, and the cycle ends.
Every tick does a fixed, small amount of work. The bounded-step guarantee from Chapter 20 holds throughout: the song cannot overrun its tick.
Swapping songs
The piano roll holds ten songs. Pressing a key swaps the running song for the next one. That swap is the hot code swap of Chapter 26, happening at a RESET boundary, made audible.
What you now know
- A song is a
loop main, and the piano roll is its host. - The song schedules note events with host native calls; the host’s audio thread synthesizes the sound.
- A song keeps its position in the data segment.
- A one-shot init block configures the instruments on the first cycle.
- Each tick does a bounded amount of work.
The next chapter builds the piano roll on your own machine.
Chapter 28. Setting Up Your Own Song Playground
Goal
By the end of this chapter you will have the piano roll built and running on your own machine.
You already have the code
Chapter 2 installed the Keleusma command-line tool from a clone of the Keleusma source repository. That same clone contains the piano roll example and all ten songs. There is nothing new to download. The work of this chapter happens inside that repository folder.
One extra requirement: CMake
The piano roll produces sound, and for that it uses an audio library called Simple DirectMedia Layer 3, or SDL3. On its first build, SDL3 is compiled from source, and compiling it needs a tool called CMake.
Install CMake for your operating system before continuing. This is the one real setup cost in the whole guide. It is heaviest on Windows, where neither CMake nor a C build toolchain is present by default. On macOS and Linux a C toolchain is usually already installed, and only CMake needs adding.
Building and running
From inside the Keleusma repository folder, run:
cargo run --release --example piano_roll --features sdl3-example
Read the command in parts. cargo run builds and runs. --release
builds the fast version, which audio needs. --example piano_roll
selects the piano roll. --features sdl3-example switches on the SDL3
audio support.
The first time, this takes a few minutes, because SDL3 is being built from source. That happens once. Every later run reuses the built SDL3 and starts quickly.
What you will see and hear
When it starts, the piano roll prints its commands, begins playing the first song, and listens for single-key commands typed into the terminal:
sswaps to the next song.rrestarts the current song.ppauses, andpagain resumes.- A number key jumps straight to that song.
- Pressing Enter alone quits.
Sound should be coming from your speakers. If the build succeeded but you hear nothing, check that the terminal program is allowed to use the audio device, and that the system volume is up.
The songs are right here
The ten songs live in the repository at
examples/scripts/piano_roll/, named piano_roll_0.kel through
piano_roll_9.kel. They are ordinary Keleusma source files. The next
chapter opens one and changes it.
What you now know
- The piano roll example is part of the repository clone from Chapter 2.
- Building it needs CMake, because SDL3 is compiled from source on the first build.
cargo run --release --example piano_roll --features sdl3-examplebuilds and runs it.- The songs are
.kelfiles inexamples/scripts/piano_roll/.
The next chapter changes a song and hears the difference.
Chapter 29. Writing and Modifying a Song
Goal
By the end of this chapter you will have changed a song and heard the change.
A song is everything you have learned
Open examples/scripts/piano_roll/piano_roll_0.kel in a text editor. It
is the simplest song in the roster, and every part of it is something
this guide has already covered.
- It begins with
uselines, importing the host natives from Chapter 16. - It declares an
enum Pitch, from Chapter 11, listing the twelve pitch classes and aRest. - It has ordinary
fnhelpers, from Chapter 6, that look up notes. Each uses amatch, from Chapter 13. - It declares a
data stateblock, from Chapter 18. - Its entry point is a
loop main, from Chapter 17, with the init block and per-tick body that Chapter 27 described.
A song is not a special kind of file. It is a Keleusma program, built from the pieces of Parts I through VII.
The note tables
The notes a channel plays are listed in the helper function
channel_note. For channel 0, the melody, it holds a match on the note
position. Its first arm is the first melody note:
0 => (Pitch::C, 5, 4), // C major: C E G E
The tuple means pitch C, octave 5, duration 4 sixteenths, a quarter note. This is the note the melody opens on.
Make a change
Change that first note. Edit the arm to open the melody on E instead of C:
0 => (Pitch::E, 5, 4), // C major: C E G E
Before running, check that the song still compiles:
keleusma compile examples/scripts/piano_roll/piano_roll_0.kel -o /tmp/song.bin
The tool prints a wrote ... bytes line. The change is valid Keleusma,
because Pitch::E is a real variant of the Pitch enum and the tuple
shape is unchanged. Had the edit broken the program, this step would have
reported the error before any sound was attempted.
Hear it
Run the piano roll again:
cargo run --release --example piano_roll --features sdl3-example
Song 0 now opens its melody on E. The host reads the song file fresh when it builds, so editing the file and rerunning is the whole loop. This is the loop of composition: change the score, hear the result, change it again.
Going further
The same channel_note function holds the bass line, in channel 1, and
the harmony, in channel 2. Every note of song 0 is an arm in one of those
match blocks. Change pitches, change octaves, change durations. Change
the host::set_waveform calls in the init block to give a channel a
different instrument. Each change is checked by keleusma compile and
then heard by running the example.
What you now know
- A song is an ordinary Keleusma program, assembled from the features of the whole guide.
- A song’s notes are tuples in the
matcharms of its note-table functions. - The edit loop is: change the
.kelfile, check it withkeleusma compile, run the piano roll, listen.
The next chapter tours the full roster of ten songs.
Chapter 30. A Tour of the Song Roster
Goal
By the end of this chapter you will have heard the full range of what a Keleusma song can do, and understood why that range is so wide.
A song is a program
The reason the roster is worth a tour is a single fact established across
the whole guide. A song is not a list of notes. A song is a program. It
can compute, branch, count, and change its own behaviour over time,
because it is built from functions, match, the data segment, and a
loop. Anything a bounded program can do, a song can do. The ten bundled
songs are chosen to show that range.
Run the piano roll from Chapter 28 and press s, or a number key, to
move through them.
The roster
- Songs 0 and 1 are the introductory pieces. Three channels, a four-bar chord progression. They are the ones to read first, and the ones Chapter 29 modified.
- Song 2 is an arrangement of a Bach prelude across five channels. It shows the host’s envelope and retrigger handling on real repertoire.
- Song 3 is an eight-channel piece in D minor that exercises every host native, shifts into a seven-beat meter partway through, and ramps its tempo continuously.
- Song 4 runs its tempo under a slow sine wave, between 60 and 300 beats per minute, and varies its material every time its loop counter advances, in the manner of an algorithmic variation form.
- Song 5 is a process piece. Eight voices play the same twelve-note pattern, but each advances at a different rate, so the voices drift into and out of alignment over minutes and hours.
- Song 6 is a canon whose four voices share one melody but move at four different speeds at once, producing genuine four-voice counterpoint.
- Song 7 is a microtonal drone. Its eight voices are tuned to the natural harmonic series, using fine pitch offsets the host supports directly.
- Song 8 is a conventional pop song, with a bridge and a key change. It is here to show that the same engine that runs the experimental pieces also handles ordinary songwriting.
- Song 9 is a long experimental loop that cycles through sixteen variations of scale and instrument, running about fifty minutes before it repeats.
Why this matters
Several of these songs do things a conventional music program, a digital audio workstation, would struggle with. A tempo shaped by a continuous sine wave, four meters running at once, a tuning drawn from the harmonic series, a piece that restructures itself on a counter: these are not points on a menu. They are consequences of the song being a program.
That is the demonstration. The experimental songs are not there because they are pleasant. They are there because they could not easily exist any other way, and the fact that they run, within the same proved time and memory bounds as song 0, is the language showing what it is for.
What you now know
- Every song in the roster is a program, which is why the roster ranges so widely.
- The bundled songs span introductory progressions, a Bach arrangement, several experimental process and tuning pieces, and a conventional pop song.
- Techniques that are awkward or impossible in a conventional music program follow naturally when the song is a program.
That completes Part VIII, and with it the part of the guide written for someone learning the language. Part IX is a separate track, for a developer who wants to host Keleusma inside a Rust program of their own.
Chapter 31. Embedding Keleusma: Orientation
Goal
By the end of this chapter you will understand what this part covers, who it is for, and you will have a minimal Keleusma host compiling and running.
Who this part is for
Parts I through VIII were written for someone learning the Keleusma
language. This part is different. It is for a Rust developer who wants to
embed Keleusma inside a Rust program of their own. It assumes a working
knowledge of Rust: cargo, dependencies, traits, closures, and Result.
The music framing of the earlier parts is set aside. The prose here is
plain and technical.
The worked example throughout this part is the piano roll. Part VIII met the piano roll from the song side. This part builds the host behind it.
The host and the script
A Keleusma deployment has two halves.
- The script is a
.kelprogram. It carries the bounded, verified logic. - The host is a Rust program. It compiles the script, verifies it, constructs a virtual machine, drives that machine, and supplies the native functions the script calls.
The host is where Keleusma is embedded. Everything in this part is host code.
A minimal host
A host is an ordinary Rust binary project with keleusma as a
dependency. The Cargo.toml needs one line:
[dependencies]
keleusma = "0.2"
The src/main.rs below is a complete host. It compiles a one-line
script, constructs a VM, runs it, and prints the result:
use keleusma::compiler::compile;
use keleusma::lexer::tokenize;
use keleusma::parser::parse;
use keleusma::vm::{DEFAULT_ARENA_CAPACITY, Vm, VmState};
use keleusma::{Arena, Value};
const SCRIPT: &str = "fn main() -> Word { 60 + 7 }";
fn main() {
let tokens = tokenize(SCRIPT).expect("lex");
let program = parse(&tokens).expect("parse");
let module = compile(&program).expect("compile");
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena).expect("verify");
match vm.call(&[]).expect("call") {
VmState::Finished(Value::Int(n)) => println!("result: {}", n),
other => panic!("unexpected: {:?}", other),
}
}
Run it with cargo run. The output is:
result: 67
Every later chapter of this part builds on this skeleton. The next chapter examines the construction phases in detail. The remaining chapters add native functions, the resume protocol, arena sizing, bytecode loading, hot swap, and cost models, and the part closes with a full walkthrough of the piano roll host.
What you now know
- This part is for a Rust developer embedding Keleusma in a host program.
- A Keleusma deployment is a host and a script; the host is the Rust side.
- A minimal host adds
keleusmaas a dependency and runs the lex, parse, compile, construct, call sequence.
The next chapter takes that sequence apart.
Chapter 32. Constructing a VM and Running a Module
Goal
By the end of this chapter you will understand each phase of VM construction and the lifetime relationship between the VM and the arena.
The four phases
Constructing a VM from source is four phases, each producing a distinct type.
#![allow(unused)]
fn main() {
let tokens = tokenize(SOURCE)?; // Vec<Token>
let program = parse(&tokens)?; // Program
let module = compile(&program)?; // Module
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena)?;
}
tokenizeturns source text into aVec<Token>.parseturns the tokens into aProgram, the syntax tree.compileturns the program into aModule, the bytecode object.Vm::newconsumes the module, borrows the arena, runs structural verification and resource-bounds verification, and returns a VM ready to call.
Each phase returns a Result. A failure in any phase is a typed error:
LexError, ParseError, CompileError, or, from Vm::new,
VmError::VerifyError.
Verification happens at construction
Vm::new is where the guarantees of Part V are enforced. It runs the
structural verifier and the resource-bounds verifier before returning. A
program the verifier rejects never yields a VM; Vm::new returns
Err(VmError::VerifyError(message)), and the message is the one
documented in WHY_REJECTED.md. No script code has run at that point.
The host learns a program is unacceptable at construction, not partway
through execution.
The arena and its lifetime
The arena is the bounded working memory the VM uses for its operand stack and for dynamic strings. The host creates it and the VM borrows it.
The borrow is enforced by the Rust borrow checker. Vm carries an
'arena lifetime parameter, and the arena must outlive the VM. In
practice this means the arena is declared before the VM and dropped
after it, which ordinary block scoping gives for free:
#![allow(unused)]
fn main() {
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena)?;
// vm is used here; arena outlives it
}
Chapter 35 covers how to choose the arena capacity. For now,
DEFAULT_ARENA_CAPACITY, sixty-four kilobytes, serves.
Running an atomic module
Once constructed, an atomic fn main module is run with call:
#![allow(unused)]
fn main() {
match vm.call(&[])? {
VmState::Finished(value) => { /* use value */ }
other => panic!("expected Finished, got {:?}", other),
}
}
call takes a slice of arguments. An fn main that takes no parameters
is called with &[]. The atomic module runs to completion and call
returns VmState::Finished(value), carrying the return value.
A yield or loop module does not finish on the first call. It
returns VmState::Yielded instead, and driving it requires the resume
protocol. Chapter 34 covers that. Native functions, which most real
scripts need, come first, in the next chapter.
What you now know
- VM construction is four phases:
tokenize,parse,compile,Vm::new. Vm::newruns verification; a rejected program fails here, before any code runs.- The VM borrows the arena, and the arena must outlive the VM.
call(&[])runs an atomic module and returnsVmState::Finished.
The next chapter registers the host functions a script calls.
Chapter 33. Registering Native Functions
Goal
By the end of this chapter you will be able to register host functions that scripts call, by both the ergonomic and the lower-level routes.
What a native function is
A native function is a Rust function the host registers with the VM under
a name. A script calls it by that name, after a use declaration. Native
functions are the bridge: the script decides what should happen, the
native does it. The piano roll’s host::play is a native; the script
calls it, and the Rust side updates the audio voice state.
Native functions are registered after Vm::new and before the script
runs.
The ergonomic route: register_fn
The recommended route is register_fn. It accepts any Rust function or
closure of arity zero through four whose argument and return types
implement the KeleusmaType marshalling trait, which the primitive types
already do:
#![allow(unused)]
fn main() {
vm.register_fn("math::add", |a: i64, b: i64| -> i64 { a + b });
vm.register_fn("math::sin", |x: f64| -> f64 { libm::sin(x) });
}
Argument extraction, arity checking, and return-value wrapping are
handled automatically. For a function that may fail, register_fn_fallible
accepts a Result<R, VmError> return:
#![allow(unused)]
fn main() {
vm.register_fn_fallible("io::read_setting", |key: String| -> Result<String, VmError> {
fetch(&key).map_err(|e| VmError::NativeError(format!("{}", e)))
});
}
A host struct or enum crosses the boundary by deriving KeleusmaType:
#![allow(unused)]
fn main() {
#[derive(KeleusmaType, Debug, Clone)]
struct Point { x: f64, y: f64 }
}
The lower-level route: register_native_closure
register_fn cannot capture host state, because its argument is a plain
function shape. When a native must read or write state the host owns, use
register_native_closure. It takes a closure that receives the raw
&[Value] arguments and returns Result<Value, VmError>:
#![allow(unused)]
fn main() {
let voices = shared_voices.clone();
vm.register_native_closure("host::silence", move |args: &[Value]| {
let channel = match args[0] {
Value::Int(n) => n as usize,
ref other => return Err(VmError::TypeError(
format!("expected Int, got {:?}", other))),
};
voices.lock().unwrap()[channel].gate = false;
Ok(Value::Unit)
});
}
This is the route the piano roll uses for every one of its natives,
because each one captures the shared Arc<Mutex<[Voice; 8]>> voice
table. The closure owns its captured clone, and inspecting the raw
Value lets the native validate its arguments explicitly. A plain
function pointer with no captured state can instead use register_native.
Bundled libraries
The keleusma::stddsl module ships three bundles of natives, registered
in one call each:
#![allow(unused)]
fn main() {
vm.register_library(stddsl::Math); // math::sqrt, math::pow, ...
vm.register_library(stddsl::Audio); // audio::midi_to_freq, ...
vm.register_library(stddsl::Shell); // shell::getenv, shell::run, shell::sleep_ms,
// shell::read_file, shell::write_file, ...
}
The Shell bundle in V0.2.1 covers environment access (getenv,
has_env, setenv), subprocess execution (run, run_full,
run_checked, run_timeout), process termination (exit), timing (sleep_ms,
now_unix_ms), file I/O (read_file, write_file, append_file,
file_exists), stderr output (write_err, writeln_err), and host
metadata (pid, hostname, arg_count, arg, pwd, cd). See
STANDARD_LIBRARY.md for the full list.
Each bundle exposes a public SIGNATURES constant containing
source-form use declarations. Hosts that want compile-time type and
arity validation prepend the constant to the script source before
parsing; the bundled keleusma-cli does this for all three bundles. A
custom bundle can implement the Library trait and expose a parallel
SIGNATURES constant to participate in the same validation flow.
What you now know
- A native function is a host Rust function a script calls by name.
register_fnandregister_fn_fallibleare the ergonomic route, for functions whose types implementKeleusmaType.register_native_closureis the route for natives that capture host state, as every piano roll native does.register_libraryinstalls a bundled or custom set of natives.- A fallible native’s failure is handled on the script side with the
native-error construct
native(args) { ok(v) => ..., error(code) => ... }. The host reports theWorderror code by returning an error built with theKeleusmaErrorderive, which maps a fieldless enum’s variants to their discriminants. See Chapter 23.
The next chapter drives a script that yields.
Chapter 34. The Coroutine Protocol from the Host Side
Goal
By the end of this chapter you will be able to drive a yield or loop
script from the host, and recover from a runtime error.
call, resume, and VmState
The host has two entry points into a VM. call(&[Value]) starts
execution. resume(Value) continues it after a yield. Both return
Result<VmState, VmError>, where VmState has three variants:
#![allow(unused)]
fn main() {
pub enum VmState {
Finished(Value),
Yielded(Value),
Reset,
}
}
The variants correspond to the three function categories of Chapter 15.
- An atomic
fnscript runs to completion:callreturnsFinished(value). - A
yieldscript hands a value out:callreturnsYielded(value), and the host callsresume(input)to continue. - A
loopscript yields every cycle and resets at the end of its body.callreturnsYielded,resumedrives the next yield, and once a body completes the next call returnsReset.
The drive loop
A host drives a yielding script with a match on VmState:
#![allow(unused)]
fn main() {
let mut state = vm.call(&[Value::Int(seed)])?;
loop {
match state {
VmState::Yielded(out) => {
let reply = host_response(&out);
state = vm.resume(reply)?;
}
VmState::Reset => {
state = vm.resume(Value::Int(next_input))?;
}
VmState::Finished(value) => {
handle_result(value);
break;
}
}
}
}
The piano roll’s tick loop is this pattern. Once per sixteenth-note tick
it calls resume with the current tick number, the script runs one
cycle and yields, and the host sleeps until the next tick boundary. When
the script’s body completes a cycle, the state is Reset, which is the
boundary where a hot swap may happen, the subject of Chapter 37.
The dialogue type
The value passed to resume and the value carried by Yielded are the
two halves of the script’s dialogue, introduced in Chapter 16. The host
and the script must agree on these two types. The agreement is not
checked by the Rust compiler, because both are carried as the runtime
Value enum; it is the host author’s responsibility to supply resume
values of the type the script expects.
Error recovery
A runtime error from call or resume returns Err(VmError). The VM is
left in an intermediate state. The host has two choices.
- Discard the VM and reconstruct it. Constructing a new VM against the arena resets the arena.
- Call
vm.reset_after_error(), which clears the operand stack, the call frames, and the arena, while preserving the data segment.
#![allow(unused)]
fn main() {
match vm.call(&[arg]) {
Ok(state) => handle_state(state),
Err(VmError::TypeError(msg)) => {
eprintln!("script error: {}", msg);
vm.reset_after_error();
}
Err(other) => return Err(other.into()),
}
}
VmError enumerates the runtime conditions: StackUnderflow,
TypeError, DivisionByZero, IndexOutOfBounds, FieldNotFound,
NoMatch, NativeError, InvalidBytecode, Trap, VerifyError, and
LoadError. Of these, VerifyError and LoadError fire at construction,
before any script code runs; the rest fire during execution.
What you now know
callstarts a VM,resumecontinues it after a yield.VmStateisFinished,Yielded, orReset, matching the three function categories.- A host drives a yielding script with a match-and-resume loop.
- The dialogue types are the host’s responsibility to honor.
reset_after_errorrecovers a VM while preserving the data segment.
The next chapter sizes the arena.
Chapter 35. Sizing the Arena and Reading the Bounds
Goal
By the end of this chapter you will be able to choose an arena capacity deliberately rather than relying on the default.
What the arena holds
The arena is a single contiguous block of memory the VM borrows. The operand stack grows from one end and dynamic strings grow from the other. The total used during one Stream-to-Reset iteration is bounded by the worst-case memory usage, the WCMU analysis of Chapter 20. The host chooses the arena’s capacity at construction. There are three options.
Option A: the default capacity
DEFAULT_ARENA_CAPACITY is sixty-four kilobytes, sufficient for most
scripts:
#![allow(unused)]
fn main() {
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
}
This is the right starting point. Move to a computed or fixed size only when there is a reason.
Option B: compute the capacity from the module
auto_arena_capacity_for walks a module and returns the capacity its
WCMU bound requires:
#![allow(unused)]
fn main() {
let cap = keleusma::vm::auto_arena_capacity_for(&module, &[])?;
let arena = Arena::with_capacity(cap);
let vm = Vm::new(module, &arena)?;
}
The second argument is a slice of per-native heap attestations; pass an empty slice when the script’s natives do not allocate into the arena, and attested values otherwise. This option sizes the arena to exactly what the program needs, no more.
Option C: a static buffer
On an embedded target without a heap, the arena can run from a host-owned buffer placed in static memory:
#![allow(unused)]
fn main() {
static mut ARENA_BUFFER: [u8; 16 * 1024] = [0; 16 * 1024];
let arena = unsafe {
Arena::from_static_buffer(core::ptr::addr_of_mut!(ARENA_BUFFER))
};
}
This is the pattern for a no_std deployment, where the arena is a fixed
region of .bss rather than a heap allocation.
When the arena is too small
If the chosen capacity is below the module’s analyzed WCMU, Vm::new
returns VmError::VerifyError. The error is surfaced at construction,
before any code runs. An undersized arena is therefore not a runtime
hazard. It is a construction-time rejection, the same kind of rejection
as any other the verifier produces.
This is the memory budget of Chapter 20 seen from the host side. The
verifier proved a WCMU bound; the host must provide an arena at least
that large; and the check that the two agree happens at Vm::new.
What you now know
- The arena is bounded working memory; its size is the host’s choice.
DEFAULT_ARENA_CAPACITYis the default;auto_arena_capacity_forcomputes an exact size;Arena::from_static_bufferruns from static memory.- An arena smaller than the module’s WCMU is rejected at
Vm::new, before any code runs.
The next chapter loads bytecode that was compiled ahead of time.
Chapter 36. Loading Precompiled and Signed Bytecode
Goal
By the end of this chapter you will be able to load precompiled bytecode, load signed bytecode against a trust matrix, and understand the trust-skip constructor.
Loading precompiled bytecode
A host with a precompiled .kel.bin file, produced by keleusma compile
or by a build pipeline, skips the lex, parse, and compile phases:
#![allow(unused)]
fn main() {
let bytes = std::fs::read("script.kel.bin")?;
let mut vm = Vm::load_bytes(&bytes, &arena)?;
}
Vm::load_bytes validates the wire-format framing, runs structural
verification, runs resource-bounds verification, and returns the VM. A
framing failure is VmError::LoadError; an analysis failure is
VmError::VerifyError. The verification is the same as for a
source-compiled module; loading bytecode does not skip the safety
checks, only the compilation.
Loading signed bytecode
A module compiled from a signed entry function carries a
FLAG_REQUIRES_SIGNATURE bit, and Vm::load_bytes refuses it, directing
the caller to the signed path. A signed module loads through
Vm::load_signed_bytes, which takes a slice of trusted public keys:
#![allow(unused)]
fn main() {
let pub_bytes: [u8; 32] = std::fs::read("pub.bin")?.try_into().unwrap();
let key = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)?;
let mut vm = Vm::load_signed_bytes(&signed_bytes, &arena, &[key])?;
}
The slice is the trust matrix. The module loads if its signature verifies against any key in the slice. An empty slice rejects every signed module. The matrix is copied onto the constructed VM, so later signed hot-swap loads inherit the same keys.
A host that boots from an unsigned baseline and accepts only signed updates afterward registers keys after construction:
#![allow(unused)]
fn main() {
let mut vm = Vm::new(unsigned_baseline_module, &arena)?;
vm.register_verifying_key(operator_key);
}
The trust-skip constructor
Vm::new_unchecked skips the resource-bounds verification. Structural
verification still runs, because the execution loop depends on it for
memory safety.
#![allow(unused)]
fn main() {
let vm = unsafe { Vm::new_unchecked(module, &arena) };
}
It is marked unsafe to capture a trust contract: the caller attests
that the bytecode’s resource bounds were verified earlier, at build time.
The intended use is a build pipeline that verifies once and ships
bytecode that need not be re-verified on every load. Using it to admit a
program that would fail the safe verifier is intentional misuse outside
the language’s guarantees, and it is documented as such. The bounded-time
and bounded-memory guarantees of Part V hold under Vm::new and weaken
to host attestation under Vm::new_unchecked.
What you now know
Vm::load_bytesloads a precompiled.kel.bin, verifying it the same as a source-compiled module.Vm::load_signed_bytesloads signed bytecode against a slice of trusted public keys.register_verifying_keyadds keys to a VM after construction.Vm::new_uncheckedskips the resource-bounds check under an explicit,unsafetrust contract.
The next chapter replaces a running module with a new one.
Chapter 37. Hot Code Swap from the Host
Goal
By the end of this chapter you will be able to replace a running module with a new one, from the host, at a reset boundary.
The swap point
Hot code swap is host-driven. A loop script does not replace itself.
The host replaces it, and only at one point: a VmState::Reset, the
boundary between two iterations of the loop body. At that boundary the
script’s operand stack is empty, and the private data segment is the only
live script-owned state, which is what makes the swap safe. Shared data is
the host-owned buffer of Chapter 34, not script-owned state, so it is not
part of the swap.
The host watches for Reset in its drive loop and calls
Vm::replace_module:
#![allow(unused)]
fn main() {
match vm.resume(input)? {
VmState::Reset => {
let new_module = load_new_version()?;
let slot_count = new_module_private_slot_count;
let initial_data = vec![Value::Int(0); slot_count];
vm.replace_module(new_module, initial_data)?;
vm.call(&[Value::Int(next_input)])?;
}
other => { /* ... */ }
}
}
replace_module takes the new module and an initial private-data vector
whose length must match the new module’s declared private data slot
count; pass an empty vector for a module with no private data. Shared data
is not a hot-swap input; it stays in the host’s own buffer across the swap.
After the swap, the VM’s coroutine state is cleared, so the new module is
driven from its entry point with call, not with resume.
What survives a swap
Three things must be understood about what crosses a swap.
- The dialogue type must stay stable. The new module must yield and resume the same types as the old one, because the host keeps driving the conversation without a break.
- The private data segment is handed in fresh. The host may pass the old values forward, re-initialize them to zero, or run migration code to fit a new schema. The piano roll passes a freshly zeroed vector, so each incoming song’s init block runs against a clean slate.
- Native function registrations live on the VM, not on the module, so they persist across the swap. The new module sees the same natives the old one did.
Signed updates
When a swap installs a module delivered as signed bytecode, the host uses
Vm::replace_module_from_bytes. It verifies the signature against the
trust matrix the VM carries, the matrix registered in Chapter 36, before
installing the module. A signed hot swap is the mechanism behind the
multi-party delivery scenario: a baseline device receives a signed update
over a link and installs it only if the signature checks out.
The piano roll’s swap
The piano roll is the worked example. Its stdin thread turns a keypress
into a swap request. The main loop, on the next VmState::Reset, calls
replace_module with the next song’s module and an empty private-data
vector, resizes and re-zeroes the shared-data buffer for the new song,
resets its host-owned voice state, and calls the new module’s entry point.
The audible song change is exactly this code path.
What you now know
- Hot swap is host-driven and happens only at a
VmState::Reset. replace_moduleinstalls a new module and a fresh private data segment; the new module is then driven fromcall.- The dialogue type must stay stable; native registrations persist; the private data segment is the host’s to carry, reset, or migrate, while shared data stays in the host’s buffer across the swap.
replace_module_from_bytesinstalls a signed update against the trust matrix.
The next chapter measures execution cost.
Chapter 38. Calibrated WCET and Cost Models
Goal
By the end of this chapter you will understand how the worst-case execution time is costed, and how a host obtains a figure calibrated to its hardware.
The cost model
The WCET analysis of Chapter 20 counts the work on the longest path between two yields. To turn an opcode count into a cycle figure, it consults a cost model: a table assigning a pipelined-cycle cost to each opcode, plus the size of a value slot.
The runtime ships NOMINAL_COST_MODEL. Its costs are estimates, not
measurements: one cycle for data movement, two for arithmetic, three for
division, five for composite construction, ten for a function call. The
nominal model is sound for comparing two programs on one platform, but
its numbers are not the cycle counts of any specific processor.
A measured model
A host that needs WCET figures calibrated to a real deployment processor
uses a measured cost model. The keleusma-bench workspace member
benchmarks each opcode on a target and emits a generated
MEASURED_COST_MODEL fragment. The host includes the fragment for its
target and passes the model to the _with_cost_model variant of the
WCET API:
#![allow(unused)]
fn main() {
include!(concat!(env!("CARGO_MANIFEST_DIR"),
"/measured_cost_models/aarch64_apple_darwin.rs"));
use keleusma::verify::wcet_stream_iteration_with_cost_model;
let cycles = wcet_stream_iteration_with_cost_model(chunk, &MEASURED_COST_MODEL, &[])?;
}
The keleusma-bench crate documents the capture workflow for generating
a fragment for a new target.
Native function attestation
The WCET and WCMU analyses cost a native function call as zero by default, because the verifier cannot see inside the host’s Rust code. A host that needs a sound bound declares per-native costs before the analysis runs:
#![allow(unused)]
fn main() {
vm.set_native_bounds("math::sin", 25, 0)?;
vm.set_native_bounds("text::upper", 100, 256)?;
}
The first number is the worst-case pipelined-cycle cost of the native, the second is its worst-case arena heap allocation in bytes. These are the host’s promise. The verifier accepts the declared values without independently measuring them, so the host bears responsibility for their accuracy, established by measurement or by bounded analysis of the native.
What the figure means
The bound the analysis produces is in pipelined cycles, a measure of work. Converting it to wall-clock seconds on a deployment platform requires a platform-specific factor that accounts for cache and pipeline stalls and the clock period. The language guarantees the pipelined-cycle bound. The host attests to the conversion factor for its hardware. This division of responsibility is the same one Chapter 20 described, seen here from the host’s side.
What you now know
- A cost model assigns pipelined-cycle costs to opcodes; the analysis uses it to produce a WCET figure.
NOMINAL_COST_MODELis unmeasured and good for relative comparison; aMEASURED_COST_MODELfromkeleusma-benchis calibrated to a target.set_native_boundsattests the cost of a native function so the analysis can account for it.- The bound is in pipelined cycles; converting to wall-clock time is the host’s responsibility.
The next chapter walks a complete host end to end.
Chapter 39. A Full Host, End to End
Goal
By the end of this chapter you will be able to read the complete piano roll host and see where each technique from this part appears in it.
The file
The piano roll host is the single file examples/piano_roll.rs in the
repository. It is around thirteen hundred lines, and it is written to be
read. This chapter is a map of it.
Two kinds of code
The most useful thing to recognize on a first read is that the file contains two kinds of code, and only one of them is about embedding Keleusma.
The embedding code is the subject of this part. It is a small fraction of the file:
build_modulerunstokenize,parse, andcompile, the phases of Chapter 32.runconstructs theArenaand theVm, the rest of Chapter 32.register_nativesregisters fifteen natives withregister_native_closure, the captured-state route of Chapter 33.- The host allocates a zeroed shared-data buffer of
vm.shared_data_bytes()bytes and lends it to the script at each call; the script reads and writes it directly. A host that needs to inspect the segment from Rust hasvm.get_shared/vm.set_sharedfor per-field scalar access andvm.marshal_shared_into/vm.unmarshal_sharedfor whole-segment round-trip against a host struct that mirrors the segment and derivesKeleusmaType(B34); the piano roll needs neither. - The main tick loop calls
resume_with_shared, matchesVmState, and handles theResetcase, the protocol of Chapter 34. - The
Resetarm callsreplace_module(with empty private data) to swap songs and re-sizes the shared buffer, the hot swap of Chapter 37.
The audio-synthesis code is everything else, and it is not about
Keleusma at all. The Mixer, the AudioCallback implementation,
advance_envelope, waveform_sample, the Voice and EnvState
structs, and the SDL3 device setup are an ordinary software synthesizer.
Any audio program would need code like it. When reading the file to learn
embedding, this code can be skimmed.
The main and run split
The file separates two concerns. The function main carries application
chrome: argument handling and process-level setup. The function run
carries the embeddable host loop: build the VM, open the audio device,
register natives, drive the tick-and-yield cycle. The boundary between
them is the boundary between what a different application would discard
and what it would copy. A developer lifting the piano roll into another
program copies the body of run.
Patterns from the cookbook
The repository’s COOKBOOK.md collects host-side patterns that recur
across applications and that the piano roll touches: sizing the arena
from a module’s WCMU, a data-loader pattern for host configuration,
narrow-runtime type aliasing for sub-64-bit targets, signed bytecode
distribution, and calibrated WCET with a measured cost model. Each is a
short recipe building on a technique from this part.
What you now know
- The piano roll host is one readable file of roughly thirteen hundred lines.
- It contains embedding code and audio-synthesis code; only the first is about Keleusma, and it is a small fraction of the file.
- Every technique of this part appears in it: construction, native registration, the resume protocol, hot swap.
- The
mainandrunsplit separates application chrome from the embeddable host loop.
That completes Part IX. You have the full host-side surface: construction, native functions, the coroutine protocol, arena sizing, bytecode loading, hot swap, and cost models. The final part points to where to go next.
Chapter 40. Further Reading
This is the final chapter of the guide.
Goal
This chapter closes the guide and points to where to go next.
What you have done
Parts I through VIII taught the Keleusma language: its values and types, its functions and control flow, its data shapes, the three function categories and the host conversation, the verifier and the guarantees, the deeper features, how a program is shipped, and the piano roll as a working capstone. Part IX taught the other side, embedding Keleusma in a Rust host. Between them, the guide has covered the whole of what a Keleusma developer, on either side, needs to begin.
What follows is where to deepen that knowledge.
A second worked example: the roguelike
The piano roll is one worked example. The repository carries a second,
larger one: a roguelike game, in examples/rogue/, with its long-form
manual at docs/guide/ROGUE.md. Where the piano roll has one loop
script, the roguelike is driven by a roster of scripts: a game-tick
loop, a dungeon generator, a set of monster behaviors, combat math, and
item effects. It is the example to study for how a larger application
divides its logic across many Keleusma scripts behind one Rust host.
The reference documents
The guide explained the language. For precise lookup, the repository’s
docs/spec/ directory holds the authoritative specifications: the formal
grammar, the type system, the standard library, the instruction set, and
the wire format. The docs/architecture/ directory holds the narrative
descriptions of the design: LANGUAGE_DESIGN.md for the design goals and
guarantees, and EXECUTION_MODEL.md for the runtime model. When a
question needs an exact answer rather than an explanation, these are the
documents to open.
Host-side and troubleshooting references
For embedding work beyond Part IX, docs/guide/COOKBOOK.md collects
host-side recipes, and docs/guide/EMBEDDING.md is the full host-facing
reference. When the verifier rejects a program, docs/guide/WHY_REJECTED.md
maps the rejection messages to their causes and rewrites.
docs/guide/FAQ.md collects the rough edges and surprises that early
users meet. The docs/reference/ directory holds a glossary of terms and
RELATED_WORK.md, which places Keleusma against the academic and
industrial work it draws on.
The wider repository
The examples/ directory holds smaller programs, each demonstrating one
embedding technique. The examples/rtos/ directory holds a cooperative
real-time microkernel that runs Keleusma on embedded hardware, the
clearest demonstration of the language’s intended deployment target.
Closing
Keleusma is a small language, deliberately. It leaves out a great deal so that it can promise a little with certainty: that a program will keep its beat, within a known budget of time and memory, every cycle, forever. Everything in this guide followed from that promise. A program written inside it can be trusted in places an ordinary program cannot.
That is the end of the guide. The next step is to write something.
Getting Started
Navigation: Guide | Documentation Root
This document walks a new user through installing the Keleusma command-line frontend, writing and running a first script, and embedding the same script in a Rust host program. The walkthrough assumes a working Rust toolchain at edition 2024 and minimum supported Rust version 1.88.
Install the CLI
Keleusma ships a standalone CLI binary called keleusma. The CLI provides a script runner, a bytecode compiler, and an interactive REPL. Install it from the workspace root.
git clone https://github.com/sgeos/keleusma
cd keleusma
cargo install --path keleusma-cli --bin keleusma
Verify the installation.
keleusma --help
If the command is not found, ensure Cargo’s bin directory is on the shell PATH. The default location is ~/.cargo/bin.
A First Script
Create a file called hello.kel with the following contents.
fn double(x: Word) -> Word {
x + x
}
fn main() -> Word {
double(21)
}
Run the script.
keleusma run hello.kel
Expected output.
42
The runner parses, compiles, verifies, and executes the script. Atomic total functions declared with fn may not yield to the host or contain unbounded recursion. The main function is the entry point. The return type appears in the function signature and is required.
Compile to Bytecode
The CLI can serialize a script to bytecode. The serialized form is loadable through the embedding API.
keleusma compile hello.kel -o hello.kel.bin
The output file uses the framed wire format with magic, length, version, target word and address widths, body, and CRC trailer. A host loads the file through Vm::load_bytes.
Interactive REPL
Start the REPL to explore the language interactively.
keleusma repl
The REPL accumulates declarations into a session prefix and evaluates expressions against the current prefix. The REPL supports the colon-prefixed commands :help, :quit, :reset, and :show.
> 1 + 2
3
> fn double(x: Word) -> Word { x + x }
defined: double
> double(21)
42
> :quit
The REPL wraps each expression as fn main() -> T { <expression> } and tries return types Word, Float, bool, Text, and () in order. The first type that compiles is used. Expressions whose type lies outside this list require an explicit function declaration.
Embed in a Rust Host
The same script runs from a Rust host program. Create a new Cargo project.
cargo new --bin keleusma-hello
cd keleusma-hello
Add Keleusma to Cargo.toml.
[dependencies]
keleusma = "0.2"
keleusma-arena = "0.3"
Replace src/main.rs with the following.
use keleusma::compiler::compile;
use keleusma::lexer::tokenize;
use keleusma::parser::parse;
use keleusma::vm::{DEFAULT_ARENA_CAPACITY, Vm, VmState};
use keleusma::{Arena, Value};
const SCRIPT: &str = "
fn double(x: Word) -> Word { x + x }
fn main() -> Word { double(21) }
";
fn main() {
let tokens = tokenize(SCRIPT).expect("lex");
let program = parse(&tokens).expect("parse");
let module = compile(&program).expect("compile");
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena).expect("verify");
match vm.call(&[]).expect("call") {
VmState::Finished(Value::Int(n)) => println!("{}", n),
other => panic!("unexpected: {:?}", other),
}
}
Build and run.
cargo run
Expected output.
42
The host code performs the same compile-verify-run pipeline as the CLI runner. The four steps are visible in the source: lex, parse, compile, and execute. The Arena is the bounded-memory region the VM borrows for its operand stack and dynamic-string allocations. DEFAULT_ARENA_CAPACITY is sixty-four kilobytes, sufficient for most scripts and the value used by the bundled examples.
Next Steps
The walkthrough above produces a running Keleusma host. Common next steps include the following.
- Read EMBEDDING.md for the full embedding surface, including native function registration, arena sizing, the call and resume loop for stream-classified scripts, and error recovery.
- Read WHY_REJECTED.md when the verifier rejects a program. The document maps error messages to root causes and proposes rewrites.
- Explore
examples/scripts/for short scripts demonstrating common language features. Each script runs throughkeleusma run. - Explore
examples/for Rust embedding examples that demonstrate WCMU computation, native attestation, error propagation through yield, and string interoperability. - Run
examples/piano_roll.rsfor a feature-gated end-to-end SDL3 audio demonstration. Eight voices with parameter-controlled waveform, envelope, vibrato, low-pass filter, and stereo per-speaker volume, sequenced by a Keleusma tick loop and rotating across a roster of precompiled songs through hot code swap. Run withcargo run --release --example piano_roll --features sdl3-example. See PIANO_ROLL.md for the long-form manual covering song composition, host lifting, and architectural patterns for other control-loop domains.
Embedding
Navigation: Guide | Documentation Root
This document describes the host-facing embedding surface of Keleusma. It covers VM construction, native function registration, arena sizing, the call and resume protocol for coroutine scripts, and error recovery. The reference for this surface is src/vm.rs. Worked examples live in examples/.
VM Lifecycle
A Keleusma VM is a single-threaded coroutine driver. The host owns the bytecode and the arena. The VM borrows the arena for the lifetime of its existence.
The minimal lifecycle is the following.
#![allow(unused)]
fn main() {
use keleusma::compiler::compile;
use keleusma::lexer::tokenize;
use keleusma::parser::parse;
use keleusma::vm::{DEFAULT_ARENA_CAPACITY, Vm};
use keleusma::Arena;
let tokens = tokenize(SOURCE)?;
let program = parse(&tokens)?;
let module = compile(&program)?;
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
let mut vm = Vm::new(module, &arena)?;
}
The four phases produce four distinct value types. tokenize produces a Vec<Token>. parse produces a Program syntax tree. compile produces a Module bytecode object. Vm::new consumes the module, borrows the arena, runs structural verification and resource-bounds verification, and returns a ready-to-call VM.
The VM and the module share the arena’s lifetime. The host must keep the arena alive at least as long as the VM. This is enforced by the borrow checker through the 'arena lifetime parameter on Vm.
Loading Precompiled Bytecode
A host that has a precompiled .kel.bin file skips the lex, parse, and compile steps.
#![allow(unused)]
fn main() {
let bytes = std::fs::read("script.kel.bin")?;
let mut vm = Vm::load_bytes(&bytes, &arena)?;
}
The wire format is self-describing. The header carries magic, length, version, and target word, address, and float widths. Vm::load_bytes validates the framing, runs structural verification, runs resource-bounds verification, and returns the VM. Validation failure is returned as VmError::LoadError for framing failures or VmError::VerifyError for analysis failures.
Calling the Script
The VM exposes two entry points: Vm::call(args) to start execution and Vm::resume(value) to continue after a yield. Both return Result<VmState, VmError>.
#![allow(unused)]
fn main() {
pub enum VmState {
Finished(Value),
Yielded(Value),
Reset,
}
}
The three states correspond to the three function categories.
fn(atomic total). The script terminates and returns a value.callreturnsVmState::Finished(value).yield(non-atomic total). The script yields a value to the host.callreturnsVmState::Yielded(value). The host callsresume(value)with a host-provided input. The script either yields again or finishes.loop(productive divergent). The script yields on every iteration and resets at the end of the body.callreturnsVmState::Yielded(value). The host callsresume(value)to drive the next yield. After the body completes, the next call returnsVmState::Reset. Hot code swap is admissible at the reset boundary.
A typical yield-driven loop looks like the following.
#![allow(unused)]
fn main() {
let mut state = vm.call(&[Value::Int(seed)])?;
loop {
match state {
VmState::Yielded(out) => {
let reply = compute_host_response(&out);
state = vm.resume(reply)?;
}
VmState::Reset => {
state = vm.resume(Value::Int(next_seed))?;
}
VmState::Finished(value) => {
handle_result(value);
break;
}
}
}
}
Native Functions
Native functions are Rust functions registered with the VM that scripts may call by name. The host declares the function name, the function pointer or closure, and (optionally) the WCET and WCMU bounds.
Ergonomic Typed Registration
The recommended path uses the marshalling layer. Any Rust function or closure of arity zero through four whose argument and return types implement KeleusmaType registers through register_fn.
#![allow(unused)]
fn main() {
vm.register_fn("math::add", |a: i64, b: i64| -> i64 { a + b });
vm.register_fn("math::sin", |x: f64| -> f64 { libm::sin(x) });
vm.register_fn("strings::upper", |s: String| -> String { s.to_uppercase() });
}
For functions that may fail, register_fn_fallible accepts Result<R, VmError>.
#![allow(unused)]
fn main() {
vm.register_fn_fallible("io::read_setting", |key: String| -> Result<String, VmError> {
fetch(&key).map_err(|e| VmError::NativeError(format!("{}", e)))
});
}
The argument extraction, arity checking, and return-value wrapping happen automatically. Type mismatches at the boundary surface as VmError::TypeError at runtime.
Custom Types via the Derive Macro
Host structs and enums become marshallable through the KeleusmaType derive.
#![allow(unused)]
fn main() {
use keleusma::KeleusmaType;
#[derive(KeleusmaType, Debug, Clone)]
struct Point {
x: f64,
y: f64,
}
vm.register_fn("geom::midpoint", |a: Point, b: Point| -> Point {
Point {
x: (a.x + b.x) / 2.0,
y: (a.y + b.y) / 2.0,
}
});
}
The script must declare a structurally compatible type for the host’s Point to flow correctly across the boundary. See TYPE_SYSTEM.md for the admissible interop types.
Lower-Level Registration
When the function must inspect the raw Value enum, register a function pointer that accepts &[Value] and returns Result<Value, VmError> directly.
#![allow(unused)]
fn main() {
fn first_argument(args: &[Value]) -> Result<Value, VmError> {
args.first()
.cloned()
.ok_or_else(|| VmError::NativeError(String::from("missing arg")))
}
vm.register_native("debug::first_argument", first_argument);
}
A boxed closure variant register_native_closure captures host state. A context-aware variant register_native_with_ctx receives a NativeCtx<'a> carrying a borrow of the arena, used by natives that allocate dynamic strings into arena memory.
Bundled Natives
V0.2.0 retired the script-side text-composition machinery (the to_string, concat, slice, length utility natives and the f-string interpolation surface). The runtime ships a small bundled set:
keleusma::utility_natives::register_utility_nativesregistersprintln(a debug primitive that is a no-op onno_stdtargets; hosts that want output override with aregister_native_closure).keleusma::audio_natives::register_audio_nativesregistersaudio::midi_to_freq,audio::freq_to_midi,audio::db_to_linear,audio::linear_to_db, and themath::*functions enumerated in STANDARD_LIBRARY.md.keleusma::stddsl::Math,Audio, andShellregister throughVm::register_library(see the “Standard DSL Libraries” section below).
All register through register_fn or register_native under the hood. Hosts can register all bundled natives, register a subset, or replace any function with their own implementation.
Host-Defined String Helpers
The language is not the right vehicle for heavy string manipulation, and V0.2.0 does not ship a string standard library. Where an application needs string work in context, register native Rust functions and let the script consume them through use declarations. Rust’s standard library handles formatting, splitting, regex, Unicode operations, and encoding conversion far better than anything reasonable to build inside the script.
#![allow(unused)]
fn main() {
vm.register_fn("text::upper", |s: String| -> String { s.to_uppercase() });
vm.register_fn("text::trim", |s: String| -> String { s.trim().to_string() });
vm.register_fn_fallible(
"text::split_first_word",
|s: String| -> Result<String, VmError> {
s.split_whitespace()
.next()
.map(|w| w.to_string())
.ok_or_else(|| VmError::NativeError("empty input".into()))
},
);
}
Script side:
use text::upper
use text::trim
use text::split_first_word
fn greet(name: Text) -> Text {
text::upper(text::trim(name))
}
See FAQ.md for the broader framing on strings.
Standard DSL Libraries
The keleusma::stddsl module ships three bundled libraries that hosts register through a single call. Each bundle is a unit struct implementing the Library trait. The trait’s register method installs the bundle’s native functions on the VM.
#![allow(unused)]
fn main() {
use keleusma::stddsl;
let mut vm = Vm::new(module, &arena)?;
vm.register_library(stddsl::Math); // math::sqrt, math::floor, ...
vm.register_library(stddsl::Audio); // audio::midi_to_freq, ...
vm.register_library(stddsl::Shell); // shell::getenv, shell::run, shell::exit
}
stddsl::Math and stddsl::Audio require the floats cargo feature. stddsl::Shell requires the shell feature, which adds a std dependency and is therefore incompatible with no_std builds. The keleusma-cli crate enables both features and registers all three bundles by default. Hosts that want bundled text composition register a host-side format / to_string / concat native through register_verified_native (see the “Host-Defined String Helpers” section above) or implement their own Library bundle.
Hosts that want to ship their own reusable bundles implement the Library trait on a host-side type. The trait is the extensibility surface; the bundled libraries are an example of the pattern, not a closed set.
#![allow(unused)]
fn main() {
use keleusma::stddsl::Library;
use keleusma::vm::Vm;
pub struct MyDsl;
impl Library for MyDsl {
fn register<'a, 'arena>(self, vm: &mut Vm<'a, 'arena>) {
vm.register_fn("mydsl::greet", |name: i64| -> i64 { name + 1 });
// ... register more natives ...
}
}
// Use site:
vm.register_library(MyDsl);
}
Single-file scripts
Keleusma scripts are necessarily single-file. There is no import or mod mechanism inside the language; cross-script reuse is intentionally outside the V0.2 surface. If your application’s needs grow to where you find yourself wishing for modularisation, the recommended path is to roll a custom DSL library: implement Library on a host-side unit struct that registers the natives your scripts call, and let every script consume the same vocabulary through use declarations. The host-side library is the unit of reuse, not the script.
Opaque Host Types
Hosts that need to expose Rust values to scripts without revealing their internal structure use the HostOpaque trait introduced in V0.2.0. The host implements the trait for its concrete Rust type; the script declares the type by name in function signatures, and the type checker resolves the name as Type::Opaque. Native functions produce opaque values through host_arc and consume them by extracting a typed reference through dyn HostOpaque::downcast_ref.
#![allow(unused)]
fn main() {
use keleusma::{host_arc, HostOpaque, Value};
// Newtype required to avoid violating Rust's orphan rule when
// `impl`-ing a foreign trait on a foreign type.
struct RustString(String);
impl HostOpaque for RustString {
fn type_name(&self) -> &'static str { "RustString" }
}
vm.register_native("make_string", |args| {
// Construct an opaque from a Rust value.
Ok(Value::Opaque(host_arc(RustString(String::from("hello")))))
});
vm.register_native("upper_case", |args| {
// Consume an opaque, return a new opaque.
let opaque = match &args[0] {
Value::Opaque(o) => o.clone(),
other => return Err(VmError::TypeError(format!(
"expected RustString, got {}", other.type_name()))),
};
let s = opaque.as_ref().downcast_ref::<RustString>().ok_or_else(|| {
VmError::TypeError(format!(
"expected RustString, got opaque {}", opaque.type_name()))
})?;
Ok(Value::Opaque(host_arc(RustString(s.0.to_uppercase()))))
});
}
Script side:
use make_string
use upper_case
fn main() -> RustString {
let s = make_string();
upper_case(s)
}
The opaque value is host-managed through Arc, so it has a lifetime independent of the arena. It may cross the yield boundary in the dialogue type and persists across arena resets. Pointer identity is the equality semantics: two opaque values compare equal only if they share the same Arc allocation.
Opaque values contribute zero to the script-side WCMU bound because the allocation is host-managed. For heavy work whose memory footprint matters, attach a per-native attestation through Vm::set_native_bounds so the verifier sees a bounded host contribution.
See examples/opaque_rust_string.rs for a complete walkthrough that exposes std::string::String to scripts.
WCET and WCMU Attestation
Native function calls participate in WCET and WCMU analysis. By default, native calls are attested as zero-cost in cycles and zero-bytes in heap. Hosts that need a sound bound declare per-native bounds before VM construction or, for already-constructed VMs, before calling verify_resources.
#![allow(unused)]
fn main() {
// vm.set_native_bounds(name, wcet_cycles, wcmu_bytes)
vm.set_native_bounds("math::sin", 25, 0)?;
vm.set_native_bounds("strings::upper", 100, 256)?;
}
The bounds are the host’s promise: wcet is the worst-case pipelined-cycle count and wcmu_bytes is the worst-case arena heap allocation. The verifier accepts the declared values without independent measurement. The host bears responsibility for accuracy, typically through measurement or bounded-loop analysis on the native function. See examples/wcmu_attestation.rs for a complete walkthrough.
Calibrated WCET in CPU cycles
The bundled NOMINAL_COST_MODEL returns per-opcode pipelined-cycle estimates suitable for relative ordering on a single platform. The values are not measured for any specific host CPU; they assign 1 to data movement, 2 to arithmetic, 3 to division, 5 to composite construction, 10 to function calls. Hosts that want WCET in actual CPU cycles for the deployment target consume a MEASURED_COST_MODEL generated by the keleusma-bench workspace member.
The wiring is include! of a measured-model fragment from keleusma-bench/measured_cost_models/, then a call to the _with_cost_model variant of the WCET API:
#![allow(unused)]
fn main() {
include!(concat!(env!("CARGO_MANIFEST_DIR"),
"/keleusma-bench/measured_cost_models/aarch64_apple_darwin.rs"));
use keleusma::verify::wcet_stream_iteration_with_cost_model;
let cycles = wcet_stream_iteration_with_cost_model(chunk, &MEASURED_COST_MODEL)?;
}
The cookbook section Calibrated WCET with a measured cost model is the recipe walkthrough. examples/measured_wcet.rs is the minimal working example. keleusma-bench/measured_cost_models/README.md catalogues the pre-generated fragments and the capture workflow for new targets.
Arena Sizing
The arena holds the operand stack at the bottom and dynamic strings on the top. Total bytes used during a Stream-to-Reset iteration is bounded by the WCMU analysis. The host has three options.
Option A. Use the default capacity. DEFAULT_ARENA_CAPACITY is sixty-four kilobytes. Sufficient for most scripts.
#![allow(unused)]
fn main() {
let arena = Arena::with_capacity(DEFAULT_ARENA_CAPACITY);
}
Option B. Compute the capacity from the module before VM construction. The function auto_arena_capacity_for walks the module and returns the bound.
#![allow(unused)]
fn main() {
let cap = keleusma::vm::auto_arena_capacity_for(&module, &[])?;
let arena = Arena::with_capacity(cap);
let vm = Vm::new(module, &arena)?;
}
The empty slice argument represents per-native heap attestations. Pass attested values when the script calls heap-allocating natives. See examples/wcmu_basic.rs for the auto-sizing pattern.
Option C. Provide a static buffer. The arena can run from a host-owned buffer in .bss for embedded targets without a heap.
#![allow(unused)]
fn main() {
static mut ARENA_BUFFER: [u8; 16 * 1024] = [0; 16 * 1024];
let arena = unsafe {
Arena::from_static_buffer(core::ptr::addr_of_mut!(ARENA_BUFFER))
};
}
If the chosen capacity is below the analyzed WCMU, Vm::new returns VmError::VerifyError. The error is surfaceable before any code runs.
Error Recovery
Errors during call or resume return Err(VmError). The VM is not automatically reset; volatile state may remain on the operand stack and in the arena. Two paths exist.
Path 1. Discard the VM. Drop and reconstruct. The arena resets when the new VM is constructed against it.
Path 2. Recover and continue. Call Vm::reset_after_error to clear volatile state while preserving the data segment.
#![allow(unused)]
fn main() {
match vm.call(&[arg]) {
Ok(state) => handle_state(state),
Err(VmError::TypeError(msg)) => {
eprintln!("script error: {}", msg);
vm.reset_after_error();
}
Err(other) => return Err(other.into()),
}
}
The data segment, if declared, persists across error events. Long-running streams that accumulate state should rely on the data segment rather than local bindings for state that must survive errors.
Error Variants
VmError enumerates the runtime error conditions.
| Variant | Condition |
|---|---|
StackUnderflow | Empty operand stack on pop |
TypeError(msg) | Operand type does not match the operation |
DivisionByZero | Integer or modulo by zero |
IndexOutOfBounds(idx, len) | Array or tuple index out of range |
FieldNotFound(struct, field) | Field access on a struct that does not declare the field |
NoMatch(value) | No match arm or multiheaded function head matched |
NativeError(msg) | Native function returned Err |
InvalidBytecode(msg) | Bytecode shape unexpected at runtime |
Trap(msg) | Script halted by a Trap instruction |
VerifyError(msg) | Structural or resource-bounds verification failed at construction |
LoadError(msg) | Wire-format framing failed during load_bytes |
VerifyError is the only variant that fires before any script code executes. The other variants fire during execution. See WHY_REJECTED.md for VerifyError interpretation.
Hot Code Swapping
The VM supports replacing the loaded module at the reset boundary of a loop script. The host calls Vm::replace_module after observing VmState::Reset and starts the new module’s entry point with Vm::call. The signature takes the new module and an initial data-segment vector whose length must match the new module’s declared schema.
#![allow(unused)]
fn main() {
match vm.resume(input)? {
VmState::Reset => {
let new_module = recompile_or_load_new_version()?;
// Re-initialise the data segment. Length must match the
// new module's declared `data` block size; preserve or
// migrate values as appropriate.
let initial_data = vec![Value::Int(0); new_module_data_slot_count];
vm.replace_module(new_module, initial_data)?;
// The swap clears coroutine state. Drive the new module
// from the entry point, not via `resume`.
vm.call(&[Value::Int(next_seed)])?;
}
other => { /* ... */ }
}
}
The dialogue type, the yielded type and the resume type, must remain stable across swaps. The data segment may carry forward (pass current values), may be re-initialized to the new schema, or may be replaced by host migration code. Native function registrations live on the VM, not on the module, and persist across swaps. See EXECUTION_MODEL.md for the full hot-swap specification, and examples/piano_roll.rs for a runnable end-to-end demonstration.
Signed Modules
The optional signatures cargo feature enables Ed25519 signing of compiled bytecode. Source scripts declare the requirement with the signed modifier on the entry function (signed fn main, signed yield main, signed loop main); the compiler emits FLAG_REQUIRES_SIGNATURE in the framing header. The runtime refuses to load such a module unless its signature verifies against a trust matrix the host populates before the load.
Signing at build time
The host (or a build pipeline) takes a 32-byte Ed25519 seed and uses wire_format::module_to_signed_wire_bytes to produce signed bytes. The CLI exposes this through keleusma compile --signing-key seed.bin -o out.bin. The keleusma keygen --seed seed.bin --public pub.bin subcommand generates a fresh keypair from the OS RNG; the seed file is written with 0o600 permissions on Unix and existing files are not overwritten.
#![allow(unused)]
fn main() {
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed_bytes);
let signed = keleusma::wire_format::module_to_signed_wire_bytes(&module, &signing_key)?;
std::fs::write("script.kel.bin", &signed)?;
}
Verifying and loading
The host loads a signed module through Vm::load_signed_bytes(bytes, arena, &keys). The keys slice carries one or more public keys; the first matching key admits the module. An empty slice rejects every signed module with LoadError::InvalidSignature. The matrix is also copied onto the constructed VM so subsequent Vm::replace_module_from_bytes calls inherit the same keys.
#![allow(unused)]
fn main() {
let pub_bytes: [u8; 32] = std::fs::read("pub.bin")?.try_into().unwrap();
let key = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)?;
let mut vm = Vm::load_signed_bytes(&signed, &arena, &[key])?;
}
Hosts that bootstrap from an unsigned baseline and only accept signed bytecode at hot-swap construct the VM normally, register keys post-construction, and hot-swap signed updates:
#![allow(unused)]
fn main() {
let mut vm = Vm::new(unsigned_baseline_module, &arena)?;
vm.register_verifying_key(signer_key);
// ... later, after receiving a signed update over the comm link ...
vm.replace_module_from_bytes(&update_bytes, initial_data)?;
}
Vm::load_bytes refuses signed modules with a diagnostic redirecting the caller to Vm::load_signed_bytes. Without the signatures feature, the variant returned is LoadError::SignaturesUnsupported so the operator sees that the build cannot verify, not just that the path is wrong.
The signing message convention is the full framed buffer with the signature payload bytes and the CRC trailer bytes zeroed. The verifier reconstructs the same view by zeroing both regions on its private copy before the cryptographic operation. The CRC trailer covers the full file including the real signature, so framing-level tamper is caught by the CRC alone; signature mutation is caught by the cryptographic check after CRC repair.
See R42 in docs/decisions/RESOLVED.md for the design rationale and docs/spec/WIRE_FORMAT.md for the header layout.
Trust-Skip Construction
Programs whose verification cost is paid at build time, not at every load, may use Vm::new_unchecked to skip the resource-bounds check. Structural verification still runs.
#![allow(unused)]
fn main() {
let vm = unsafe { Vm::new_unchecked(module, &arena) };
}
This is intentional misuse if used to admit programs that would fail the safe verifier. The intended use is precompiled bytecode that the host already verified once at build time. See LANGUAGE_DESIGN.md for the contract. Vm::new_unchecked also skips the signed-module flag check; the caller attests that any signature verification was performed at build time.
Cross-References
examples/wcmu_basic.rsshows the auto-sizing pattern end to end.examples/wcmu_attestation.rsshows native bound declaration.examples/wcmu_rejection.rsshows the verifier rejecting an undersized arena.examples/string_ops.rsshows host-registered text concatenation and slicing natives.examples/yield_error.rsshows error propagation through yield with a script-definedResult-shaped enum.examples/method_call.rsshows method dispatch through receiver-style syntax.examples/piano_roll.rsis a feature-gated end-to-end SDL3 audio host. It exercises bounded-step execution under a real-time audio deadline, thread-safe handoff between the Keleusma main thread and the SDL3 audio callback, multi-voice control flow through the data segment, and hot code swap across a roster of precompiled songs (piano_roll_<N>.kel, currentlypiano_roll_0.kel,piano_roll_1.kel, andpiano_roll_2.kel). Run withcargo run --release --example piano_roll --features sdl3-example. Presssto cycle to the next song,rto restart the current song, a digit to select a song by index, or Enter alone to quit. The long-form manual is PIANO_ROLL.md, which covers writing songs, lifting the host loop into another application, and using the example as an architectural reference for embedding Keleusma in other control-loop domains.- LANGUAGE_DESIGN.md describes the language model.
- EXECUTION_MODEL.md describes the runtime model.
- WHY_REJECTED.md describes verifier rejection categories.
Instruction Set
This chapter is the bytecode reference. It lists every instruction the Keleusma virtual machine executes, so the disassembly shown in the playground bytecode view has a place to be looked up. It reproduces the authoritative docs/spec/INSTRUCTION_SET.md from the repository.
The Keleusma VM executes a stack-based bytecode using block-structured control flow. All instructions operate on a value stack. This document lists every instruction with its operands, behavior, and cost contribution to the WCET (worst-case execution time) and WCMU (worst-case memory usage) analyses.
Each instruction carries a relative integer cost. Costs are unitless relative weights, not cycle counts. Higher values indicate more expensive operations. The cost table is consulted by wcet_stream_iteration(); the per-instruction stack and heap effects are consulted by wcmu_stream_iteration().
For details on how bytecode is generated from source, see Chapter 25, From Source to Bytecode. For the bytecode wire format including the framing header, opcode-stream encoding, and operand pool, see EXECUTION_MODEL.md. For the structural ISA specification including block hierarchy and verification rules, see STRUCTURAL_ISA.md.
Constants
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| Const | u16 index | 1 | Push constant from the chunk’s constant pool. |
| PushImmediate | u8 value | 1 | Push an inline immediate value. The operand encodes one of a small set of sentinel values or small integers; see “PushImmediate encoding” below. |
PushImmediate encoding
| Operand value | Meaning |
|---|---|
0 | Value::Unit |
1 | Value::Bool(true) |
2 | Value::Bool(false) |
3 | Value::None (the Option::None sentinel) |
4 | Value::Int(0) |
5 | Value::Int(1) |
| … | … |
19 | Value::Int(15) |
20..255 | Reserved. Decoder treats as a corruption signal. |
Sixteen small-integer literals (Int(0) through Int(15)) are encoded inline. Larger or non-immediate literals continue to use Const referencing the constant pool. The extraction rule is predictable: operand values 0..3 select sentinels; values 4..19 select Int(value - 4); values 20..255 signal corruption.
Local Variables
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| GetLocal | u16 slot | 1 | Push local variable onto stack. |
| SetLocal | u16 slot | 1 | Pop stack into local variable slot. |
Data Segment
The unified slot index space partitions into shared slots [0, shared_count) and private slots [shared_count, shared_count + private_count). Shared slots live in the host-owned buffer borrowed at each call and are reached by the host through Vm::get_shared/Vm::set_shared (B28 item 2); private slots are script-only and live in the arena’s persistent region. The opcodes below admit both partitions; the runtime dispatches by comparing the slot index against the cached shared_slot_count, sending a shared slot to the borrowed buffer by byte offset and a private slot to the arena. Const data fields do not consume a slot; field reads compile to Const and writes are compile errors.
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| GetData | u16 slot | 1 | Push data segment slot value onto stack. |
| SetData | u16 slot | 1 | Pop value and store into a data segment slot. A scalar stores inline; a flat composite copies its body into the persistent composite pool at the offset the module’s private-composite layout table records for the slot (no dedicated composite-write opcode). |
| GetDataIndexed | u16 base, u16 len | 2 | Pop array index, bounds-check against len, push the value at base + index. |
| SetDataIndexed | u16 base, u16 len | 2 | Pop array index then pop value, bounds-check against len, store into the slot at base + index. |
| BoundsCheck | u16 bound | 2 | Peek the top of the stack as an Int, trap if outside [0, bound). Emitted by the compiler between levels of a multi-dimensional indexed access. |
Arithmetic
Integer arithmetic uses the checked-arithmetic family. Each CheckedAdd, CheckedSub, CheckedMul, and CheckedNeg opcode pops Value::Int operands (two for the binary forms, one for CheckedNeg), computes the true result in i128, and pushes three slots: the low half, the high half, and an outcome flag (Int(0) ok, Int(1) overflow, Int(2) underflow). The push order places low at the bottom and flag on top so that surface-level wrapping expressions, such as a + b on Int operands, compile to the checked opcode followed by PopN(2) and leave the wrapping result on the stack. Source-level pattern-arm matching destructures the three outputs.
The wrapping arithmetic opcodes Add, Sub, Mul, and Neg remain in the instruction set but no longer accept Value::Int operands. Their permitted operand types are Byte, Fixed, and Float. The V0.2.0 Consolidation B pass narrowed these opcodes by routing all Int arithmetic through the checked family; the compiler emits CheckedXxx; PopN(2) for every Int operand position. Operands whose type the compiler cannot statically infer fall through to the Int path as well, because Word is the default numeric type.
Op::Div and Op::Mod remain polymorphic over Int, Byte, and Float. Their checked counterparts CheckedDiv and CheckedMod expose the corner cases of signed division.
CheckedMul and CheckedDiv carry a u8 fraction-bit count that selects integer or Q-format arithmetic, where 0 is integer and a positive count is Fixed. This is the only place the integer and fixed-point datapaths differ, namely a shift by the fraction-bit count around the multiply or divide, so a single parameterized opcode serves both rather than separate opcodes. CheckedAdd, CheckedSub, CheckedMod, and CheckedNeg need no such parameter because their Fixed forms involve no shift, and they dispatch on the operand type alone.
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| CheckedAdd | none | 2 | Pop two Int operands; push (low, high, flag). The flag and high halves report at the bytecode-declared word width through the shared vm::checked_arith_outputs helper. |
| CheckedSub | none | 2 | Pop two Int operands; push (low, high, flag). |
| CheckedMul | u8 frac_bits | 2 | Pop two operands; push (low, high, flag). The fraction-bit count selects the format. With 0 it is integer multiply and the high half is the load-bearing value for big-number multiplication. With a count greater than 0 the operands are Fixed, the i128 product is shifted right by that many bits before the range check, the wrapped result is a single word, and the high slot is unused. So 0 fraction bits is exactly integer multiply. |
| CheckedNeg | none | 2 | Pop one Int operand; push (low, high, flag). The only overflow case under the default 64-bit declared width is -i64::MIN. |
| CheckedDiv | u8 frac_bits | 2 | Pop two operands; push (low, high, flag). The fraction-bit count selects the format. With 0 it is integer divide whose only overflow case at the default 64-bit width is i64::MIN / -1. With a count greater than 0 the operands are Fixed and the dividend is left-shifted by that many bits in the i128 domain before dividing. A zero divisor reifies as flag 3 carrying the numerator rather than trapping. So 0 fraction bits is exactly integer divide. |
| CheckedMod | none | 2 | Pop two Int operands; push (low, high, flag). Traps on divide-by-zero. The only overflow case under the default 64-bit declared width is i64::MIN % -1. |
| Add | none | 2 | Pop two operands of type Byte, Fixed, or Float; push the wrapping or IEEE 754 sum. The Int operand position is excluded; the compiler routes Int + Int through CheckedAdd; PopN(2). |
| Sub | none | 2 | Pop two operands of type Byte, Fixed, or Float; push the wrapping or IEEE 754 difference. The Int operand position is excluded. |
| Mul | none | 2 | Pop two operands of type Byte or Float; push the wrapping or IEEE 754 product. Fixed multiplication uses FixedMul(n); the Int operand position is excluded. |
| Neg | none | 2 | Pop one operand of type Byte, Fixed, or Float; push the wrapping or IEEE 754 negation. The Int operand position is excluded. |
| Div | none | 3 | Pop two values; push quotient. Traps on divide-by-zero. No overflow flag. |
| Mod | none | 3 | Pop two values; push remainder. Traps on divide-by-zero. No overflow flag. |
Comparisons
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| CmpEq | none | 2 | Pop two values, push true if equal. |
| CmpNe | none | 2 | Pop two values, push true if not equal. |
| CmpLt | none | 2 | Pop two values, push true if less than. |
| CmpGt | none | 2 | Pop two values, push true if greater than. |
| CmpLe | none | 2 | Pop two values, push true if less than or equal. |
| CmpGe | none | 2 | Pop two values, push true if greater than or equal. |
Logic
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| Not | none | 1 | Pop boolean, push logical negation. |
Short-circuit AND and OR are encoded as If-branching at the bytecode level; there are no LogicalAnd or LogicalOr opcodes.
Bitwise
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| BitAnd | none | 2 | Pop two Int operands, push bitwise AND. |
| BitOr | none | 2 | Pop two Int operands, push bitwise OR. |
| BitXor | none | 2 | Pop two Int operands, push bitwise XOR. |
| Shl | none | 2 | Pop shift count then value; push value shifted left by count & (word_width - 1). |
| Shr | none | 2 | Pop shift count then value; push arithmetic-right-shifted value (sign-preserving). |
Control Flow
Block-structured control flow opcodes carry u16 jump targets. A chunk’s opcode count is therefore bounded at 65,535 (CHUNK_SIZE_HARD_LIMIT). The compiler emits a CompileWarning when a single chunk crosses 80% of the limit (52,428 ops, CHUNK_SIZE_SOFT_WARN_THRESHOLD), prompting decomposition into helper functions; the bytecode at the limit remains valid. Chunks exceeding CHUNK_SIZE_HARD_LIMIT are rejected at compile time as a CompileError. The host invokes keleusma::compiler::compile_with_warnings(program, target) to receive the warning vector alongside the module; compile_with_target and compile discard the warnings for callers that do not need them.
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| If | u16 offset | 1 | Pop boolean. If false, skip forward to matching Else or EndIf. |
| Else | u16 offset | 1 | Unconditional skip forward to matching EndIf. |
| EndIf | none | 1 | End of if or if-else block. No-op. |
| Loop | u16 offset | 1 | Start of loop block. Offset is distance to matching EndLoop. |
| EndLoop | u16 offset | 1 | Unconditional jump backward to matching Loop. |
| Break | u16 depth | 1 | Exit enclosing loop at the given nesting depth. |
| BreakIf | u16 depth | 1 | Pop boolean. If true, exit enclosing loop at the given nesting depth. |
Function Calls
Native function calls partition into two classes distinguished by the source-level use declaration and a matching host-side registration ABI:
- Verified natives. Imported with
use module::name. Host registers throughVm::register_verified_native(name, fn, wcet_bound, wcmu_bound). The host-attested cost folds into the iteration’s WCET and WCMU budget. Compiler emitsCallVerifiedNative. - External natives. Imported with
use external module::name. Host registers throughVm::register_external_native(name, fn, max_invocations_per_iteration). The host attests an upper bound on per-iteration invocation count rather than per-call cost. Compiler emitsCallExternalNative.
The runtime cross-checks each declared native against its host registration at the entry of Vm::call_function and at every explicit invocation of Vm::verify_native_classifications. The check walks every native call site in the module and rejects a mismatch (e.g., a bytecode importing use math::sqrt but a host registering sqrt through register_external_native) as VmError::VerifyError. The result is cached after the first successful walk; any register_* call or replace_module invocation invalidates the cache. Hosts that prefer to surface mismatches at a deployment-validation step rather than at first call may invoke verify_native_classifications explicitly after registration.
V0.2.0 Phase 5 introduced the verified-versus-external split. The legacy Op::CallNative opcode was retired; every native call site compiles to either Op::CallVerifiedNative or Op::CallExternalNative. Hosts that previously called Vm::register_native continue to register verified natives because that method ascribes the verified classification.
The max_invocations_per_iteration attestation on external natives is recorded at registration but is not yet folded into the WCMU bound. The verifier treats external natives as contributing zero to the script’s per-iteration WCMU budget; the host accepts that external natives are outside the script’s resource contract and has separately verified their resource use. The chunk-level integration (which would bound external-native cost as max_invocations_per_iteration * per_call_wcmu per chunk rather than per static call site) is forward-looking work tracked under B20.
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| Call | u16 chunk_idx, u8 argc | 10 | Direct call to a compiled chunk by index with argc arguments. |
| CallVerifiedNative | u16 native_idx, u8 argc | 10 | Call a verified native function. Cost folds into the iteration budget per host attestation. |
| CallExternalNative | u16 native_idx, u8 argc | 10 | Call an external native function. Iteration cost budget pauses during the call; the verifier tracks invocation count per iteration. |
| Return | none | 2 | Return from the current chunk. |
The closure-construction and indirect-dispatch opcodes (PushFunc, MakeClosure, MakeRecursiveClosure, CallIndirect) are not present in the ISA. Closure-shaped surface expressions are rejected at the type-checker stage with a diagnostic that names the construct; first-class function values are likewise rejected. The Value::Func runtime variant was retired alongside the opcodes in V0.2.0 Phase 4. Surface programs that previously used closures must be rewritten as top-level fn definitions or trait methods.
Coroutine and Streaming
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| Yield | none | 1 | Pop output value and suspend. On resume, the host’s input value is pushed onto the stack. |
| Stream | none | 1 | Entry of the streaming region. Only Reset may target it. |
| Reset | none | 1 | Clear the arena’s top region, activate hot swap if scheduled, jump to the matching Stream. |
Stack
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| PopN | u8 count | 1 | Discard count values from the top of the stack. The compiler emits PopN(1) for single-slot pops and PopN(n) for multi-slot discards, including the three-slot discard after checked-arithmetic opcodes when the wrapping semantics is wanted. |
| Dup | none | 1 | Duplicate top of stack. |
Type Construction
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| NewComposite | kind, count, byte_size or meta | 5 | Pop count values and construct one composite of the given kind (struct, tuple, array, or enum). The flat form packs the popped values into byte_size bytes; the boxed form builds a heap composite from the template index meta. An enum’s leading discriminant counts as one of the count values. The single opcode replaces the four V0.2.0 construct opcodes (wire ids 34-37, retired). |
A tuple is an anonymous struct, an array a homogeneous struct, and a flat enum a struct whose first packed value is the discriminant, so flat construction is one operation across all four kinds. The operand carries the composite kind, the operand-stack pop count, and either the exact flat allocation size in bytes (flat form) or a struct-template index (boxed form). The flat byte size is the precise worst-case-memory-usage contribution the verifier sums; see the Heap allocation table below.
The Option::None sentinel and Option::Some wrap are handled through PushImmediate(3) and the natural representation of the wrapped value, respectively; there are no dedicated PushNone or WrapSome opcodes.
Field Access
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| GetField | StructField (flat/nested/boxed) | 3 | Pop struct, push field value. The baked StructField operand selects a flat read at a byte offset, a nested-composite extraction, or a boxed positional/named lookup. |
| GetIndex | ArrayElem (flat/nested/boxed) | 2 | Pop index and array, push element. The baked ArrayElem operand selects a flat read at index * element_size, a nested-composite extraction, or a boxed index. |
| GetTupleField | TupleField (flat/nested/boxed) | 2 | Pop tuple, push element. The baked TupleField operand selects a flat read at a byte offset, a nested-composite extraction, or a boxed positional index. |
| GetEnumField | EnumField (flat/nested/boxed) | 2 | Pop enum variant, push payload field. The baked EnumField operand selects a flat read past the discriminant word, a nested-composite extraction, or a boxed positional index. |
| Len | none | 2 | Pop composite value (Array, Text, Tuple), push length as Int. |
Type Testing
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| IsEnum | u16 enum-name, u16 variant-name, u16 discriminant-value (all constant indices) | 3 | Peek the top of the stack; push true if it matches the enum type and variant. |
| IsStruct | u16 name | 3 | Peek the top of the stack; push true if it matches the struct type. |
Casting and Fixed-Point Arithmetic
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| IntToFloat | none | 2 | Pop Word, push as Float. Gated on the floats feature. |
| FloatToInt | none | 2 | Pop Float, push as Word (truncates toward zero). Gated on the floats feature. |
| WordToByte | none | 2 | Pop Word, push the low 8 bits as a Byte. |
| ByteToWord | none | 2 | Pop Byte, zero-extend to Word. |
| WordToFixed | u8 frac_bits | 2 | Pop Word, push the corresponding Q-format Fixed value with the given fraction-bit count. |
| FixedToWord | u8 frac_bits | 2 | Pop Fixed, push the integer portion as a Word; saturating. |
| FixedMul | u8 frac_bits | 2 | Pop two Q-format Fixed values, push their product. Shifts the i128 product right by the fraction-bit count and saturates. |
| FixedDiv | u8 frac_bits | 2 | Pop two Q-format Fixed values, push their quotient. Left-shifts the dividend by the fraction-bit count before dividing and saturates. |
Faults
| Instruction | Operands | Cost | Description |
|---|---|---|---|
| Trap | u16 message index | 1 | Halt execution with an error message from the constant pool. |
Opcode count and operand-shape inventory
The instruction set contains 66 opcodes. The B28 consolidation retired the four V0.2.0 construct opcodes (NewStruct, NewEnum, NewArray, NewTuple, wire ids 34-37) in favour of the single NewComposite opcode (wire id 69). The retired ids are reserved and not reused; the maximum live wire id is 69. Operand shapes:
The shapes below are the Op-variant operand types as defined in src/bytecode.rs, which differ from the flattened inline wire encodings documented in WIRE_FORMAT.md. In particular the four baked field-and-element access opcodes carry bespoke operand structs rather than a plain integer.
| Shape | Used by |
|---|---|
| None (zero-operand) | 33 opcodes (arithmetic, comparison, bit ops, type coercions, stack manipulation, streaming, coroutine, etc.) |
u8 | 8 opcodes (PushImmediate, PopN, WordToFixed, FixedToWord, FixedMul, FixedDiv, CheckedMul, CheckedDiv) |
u16 | 14 opcodes (Const, GetLocal, SetLocal, GetData, SetData, IsStruct, If, Else, Loop, EndLoop, Break, BreakIf, BoundsCheck, Trap) |
(u16, u8) | 3 opcodes (Call, CallVerifiedNative, CallExternalNative) |
(u16, u16) | 2 opcodes (GetDataIndexed, SetDataIndexed) |
(u16, u16, u16) | 1 opcode (IsEnum, carrying the enum-name, variant-name, and discriminant-value constant indices) |
| Baked access operand (bespoke) | 4 opcodes (GetField(StructField), GetIndex(ArrayElem), GetTupleField(TupleField), GetEnumField(EnumField)). Each carries a bespoke B28 operand struct with a compiler-selected Flat, FlatNested, or Boxed form. The flat forms bake a byte offset and a scalar kind (or a nested composite size and variant); the boxed forms carry a positional index or a field-name constant index for the pre-B28 Vec body. See the StructField, ArrayElem, TupleField, and EnumField enums in src/bytecode.rs. |
| NewComposite (bespoke) | 1 opcode (NewComposite). The flat form packs the composite kind, the operand-stack pop count (0 through 62), and the exact flat byte size into the three operand bytes of the record. The boxed form, or a flat field count above 62, spills a 24-bit operand-pool index to a (u16, u16, u8) entry carrying (count, byte_size-or-meta, boxed_flag). |
The wire encoding flattens these Op-level shapes: 62 of the 66 opcodes always carry their operand inline in the 4-byte opcode record. Three opcodes (GetDataIndexed, SetDataIndexed, and IsEnum) always reference an entry in the operand pool by index. NewComposite carries its operand inline in the common small flat form and references a (u16, u16, u8) operand-pool entry only for the boxed form or a flat field count above 62. The (u16, u8) opcodes (Call, CallVerifiedNative, CallExternalNative) fit inline because the u8 lands in byte 3 of the record. The four baked access opcodes fit their compiler-selected operand form inline. See EXECUTION_MODEL.md and WIRE_FORMAT.md for the wire format that encodes these shapes.
Cost Summary
The cost groupings reproduce bytecode::nominal_op_cycles. Hosts that need wall-clock WCET supply a custom CostModel calibrated to the target.
| Cost | Instructions |
|---|---|
| 1 | Const, PushImmediate, GetLocal, SetLocal, GetData, SetData, Dup, Not, If, Else, EndIf, Loop, EndLoop, Break, BreakIf, Stream, Reset, Yield, Trap, PopN |
| 2 | Add, Sub, Mul, Neg, CheckedAdd, CheckedSub, CheckedMul, CheckedNeg, CheckedDiv, CheckedMod, CmpEq, CmpNe, CmpLt, CmpGt, CmpLe, CmpGe, GetIndex, GetTupleField, GetEnumField, Len, IntToFloat, FloatToInt, WordToByte, ByteToWord, WordToFixed, FixedToWord, FixedMul, FixedDiv, Return, GetDataIndexed, SetDataIndexed, BoundsCheck, BitAnd, BitOr, BitXor, Shl, Shr |
| 3 | Div, Mod, GetField, IsEnum, IsStruct |
| 5 | NewComposite |
| 10 | Call, CallVerifiedNative, CallExternalNative |
WCMU contributions
WCMU costs are reported separately as stack slot growth, stack slot shrink, and heap allocation in bytes. The constant VALUE_SLOT_SIZE_BYTES converts slot counts to bytes; the parametric Vm<W, A, F> shape uses size_of::<GenericValue<W, F>>() directly. Computed by wcmu_stream_iteration() in src/verify.rs.
Stack growth (peak net delta during execution)
The values reproduce Op::stack_growth in src/bytecode.rs. For multi-output opcodes the value is the net peak delta against the starting depth, not the raw push count: e.g. CheckedAdd pops two and pushes three, so the peak depth relative to the start is +1. IsEnum and IsStruct peek the scrutinee (no pop) and push a Bool, so their net delta is +1.
| Growth | Instructions |
|---|---|
| 0 | Not, Neg, Add, Sub, Mul, Div, Mod, CmpEq, CmpNe, CmpLt, CmpGt, CmpLe, CmpGe, SetLocal, SetData, SetDataIndexed, BoundsCheck, If, BreakIf, Else, EndIf, Loop, EndLoop, Break, Stream, Reset, Yield, Return, GetField, GetIndex, GetTupleField, GetEnumField, Len, IntToFloat, FloatToInt, WordToByte, ByteToWord, WordToFixed, FixedToWord, FixedMul, FixedDiv, Trap, PopN, BitAnd, BitOr, BitXor, Shl, Shr |
| 1 | Const, PushImmediate, GetLocal, GetData, Dup, GetDataIndexed, CheckedAdd, CheckedSub, CheckedMul, CheckedDiv, CheckedMod, Call, CallVerifiedNative, CallExternalNative, NewComposite, IsEnum, IsStruct |
| 2 | CheckedNeg |
Stack shrink (slots popped during execution)
The values reproduce Op::stack_shrink. For opcodes whose net delta is non-negative (e.g. CheckedAdd, CheckedNeg) the shrink reads zero because the verifier accounts for the peak through stack_growth and there is no net pop.
| Shrink | Instructions |
|---|---|
| 0 | Const, PushImmediate, GetLocal, GetData, Dup, Not, Neg, CheckedAdd, CheckedSub, CheckedMul, CheckedNeg, CheckedDiv, CheckedMod, BoundsCheck, Else, EndIf, Loop, EndLoop, Break, Stream, Reset, Return, Len, IsEnum, IsStruct, IntToFloat, FloatToInt, WordToByte, ByteToWord, WordToFixed, FixedToWord, FixedMul, FixedDiv, Trap |
| 1 | Add, Sub, Mul, Div, Mod, CmpEq, CmpNe, CmpLt, CmpGt, CmpLe, CmpGe, SetLocal, SetData, GetDataIndexed, If, BreakIf, Yield, GetField, GetIndex, GetTupleField, GetEnumField, BitAnd, BitOr, BitXor, Shl, Shr |
| 2 | SetDataIndexed |
| n | PopN(n), Call(, n), CallVerifiedNative(, n), CallExternalNative(_, n), NewComposite(count) |
Heap allocation (bytes)
| Heap | Instructions |
|---|---|
| 0 | All instructions not listed below |
byte_size from operand | NewComposite, flat form. The exact flat allocation size is baked into the operand at compile time, so the worst-case-memory-usage contribution is the precise byte count rather than a count * VALUE_SLOT_SIZE_BYTES estimate. The boxed form reports zero flat bytes; its body is the heap Vec, accounted separately. |
| host-attested | CallVerifiedNative through host registration; CallExternalNative through per-iteration invocation-count bound |
Keleusma Cookbook
Navigation: Guide | Documentation Root
Recipes are working patterns for embedding Keleusma in larger systems. Each recipe states the problem it solves, the constraint it respects, and a minimal working example. Recipes link to the bundled examples where they instantiate the pattern at production scale; the linked sections are the place to read deeper.
Index
| Recipe | Use it when |
|---|---|
Working with Text | The host or scripts need to handle strings. |
| Auto-sizing the arena from the module | The host wants exact WCMU-bounded arena sizing instead of a hardcoded capacity. |
| The data-loader pattern | The host needs read-only configuration data that benefits from script-side editing. |
| Narrow-runtime type alias | The host targets a sub-64-bit native runtime (16-bit or 8-bit signed word). |
| Distributing signed bytecode | The host delivers compiled modules over an untrusted channel and needs origin authenticity. |
| Calibrated WCET with a measured cost model | The host wants WCET estimates in actual CPU cycles for the deployment target instead of nominal relative weights. |
Working with Text
Problem
The host or a script needs to handle strings. Names, log messages, error reports, configuration values, identifiers from the outside world. Keleusma is not a value-add for string processing, but real applications routinely need some string work at the boundary.
Solution
Two rules.
One. Use string literals for static text. Source-level string literals compile to Value::StaticStr and live in the bytecode’s read-only constant pool. They are immutable, fixed-size handles, and admissible in function arguments, return values, and yield payloads. The script’s surface type is Text; the runtime preserves the static-versus-dynamic distinction internally.
fn label() -> Text {
"ready"
}
Two. Register Rust functions for every text operation beyond literals. V0.2.0 retired the bundled concat, to_string, slice, and length utility natives along with f-string interpolation. Script-side string composition flows through host-registered functions. Hosts that need formatting, splitting, regular expressions, Unicode operations, or encoding conversion register a Rust function and the script imports it through use.
#![allow(unused)]
fn main() {
vm.register_fn("text::upper", |s: String| -> String { s.to_uppercase() });
vm.register_fn("text::trim", |s: String| -> String { s.trim().to_string() });
}
use text::upper
use text::trim
fn greet(name: Text) -> Text {
text::upper(text::trim(name))
}
Host-produced dynamic strings reside in the arena heap as Value::KStr (arena-handled). They are admissible on the stack and in local bindings but cannot cross a yield boundary; the verifier rejects programs that would carry an arena-resident KStr across the host-VM boundary.
Why this works for an RTOS or embedded target
Static strings live in the read-only data section and cost no allocation. A script that returns names or log labels through static strings consumes zero arena. Host-produced dynamic strings cost arena heap that the host attests through register_verified_native(name, fn, wcet, wcmu_bytes); the verifier folds the per-call WCMU into the iteration budget. There is no path by which string work can grow unbounded; either it goes through a fixed-size static-string handle, or it counts against a verifier-bounded heap allocation, or it never compiles.
Cross-references
- FAQ.md, Strings covers the surface caveats and the static-string escape table.
- TYPE_SYSTEM.md, Text Types is the type-system specification.
- The rogue example’s bestiary script returns monster names through this pattern.
Auto-sizing the arena from the module
Problem
Every Keleusma Vm needs an arena. The host picks the capacity. Pick too small and the verifier rejects the module at Vm::new with VerifyError; pick too large and the host wastes memory it does not need. Embedded targets in particular want exact sizing because they may not have a heap at all (the arena runs from a static [u8; N] buffer in .bss).
Solution
Use keleusma::vm::auto_arena_capacity_for(&module, native_wcmu) to compute the minimum-required capacity from the compiled module before constructing the VM. The function walks the module’s Stream chunks, sums each chunk’s stack and heap WCMU, and returns the largest total. The result is the smallest capacity that admits the module under the supplied native attestations.
#![allow(unused)]
fn main() {
use keleusma::vm::{auto_arena_capacity_for, Vm};
use keleusma::Arena;
let cap = auto_arena_capacity_for(&module, &[])?;
let arena = Arena::with_capacity(cap);
let vm = Vm::new(module, &arena)?;
}
The second argument is a slice of per-native heap-allocation attestations. Pass an empty slice when no native allocates from the arena. Pass the appropriate u32 values when the host has registered heap-allocating natives.
#![allow(unused)]
fn main() {
// Script that uses host-registered text or buffer natives. The
// host's per-call attestations flow through the slice in the
// same order as the module's `native_names` table.
let native_wcmu = &[upper_wcmu, trim_wcmu];
let cap = auto_arena_capacity_for(&module, native_wcmu)?;
}
When to use which arena-sizing option
The library offers three patterns.
| Option | Use it when |
|---|---|
Arena::with_capacity(DEFAULT_ARENA_CAPACITY) | Hosted development and quick prototyping; a generous default capacity is acceptable. |
auto_arena_capacity_for | Production hosts that want the smallest correct capacity, especially when running many VMs or when host memory is tight. |
Arena::from_static_buffer | Bare-metal targets with no heap. The host owns a fixed-size buffer in .bss and hands its pointer to the arena. |
The auto-sizing option composes with the static-buffer option. Compute the capacity at compile time (if the module is const-loadable through include_bytes!) or at build time (running the host once to print the value), then declare the static buffer at that size.
Failure mode
If the chosen capacity is below the module’s WCMU, Vm::new returns VmError::VerifyError before any code runs. This is detected at construction time, not at execution time, so the failure is observable up front rather than in the middle of a run.
Cross-references
- EMBEDDING.md, Arena Sizing is the embedding-guide reference.
- The bundled
examples/wcmu_basic.rsshows the full auto-sizing pattern end to end.
The data-loader pattern
Problem
The host needs a table of configuration data. The data is structurally homogeneous (a fixed-shape record per entry) but designer-tunable (game balance, look-up tables, content). Storing the table in Rust source means designers must rebuild the host to retune. Storing it in a script file lets designers edit a .kel file and reload at runtime.
Keleusma does not currently support module-scope const declarations for arrays of records, inline string tables, or runtime allocation of growable structures. The pattern below works inside those constraints.
Solution
Encode the table as a Keleusma script with three pieces.
- A data segment declared on the script side, holding one field per output column of the record. The data segment is the host-script I/O struct.
- A multi-headed dispatcher with one head per entry. Each head writes the per-entry constants into the data segment.
- A loader function that resolves the index (including the negative-index convention) and chains into the dispatcher.
The host runs the script once per entry at startup, reads the data segment after each call, and caches the result in a regular Rust container (Vec<T>, HashMap<K, T>, or similar). After the cache is warm, runtime reads go through the Rust cache; the script is touched again only when the host wants to reload.
The pattern admits runtime hot reload because the table is in script form. A host that re-compiles the script, re-runs the loader, and atomically replaces the cache can swap data without restarting. A host that caches once at startup still benefits because the table moves out of Rust source and into a file that designers can edit.
Three component techniques
The pattern composes three techniques that are individually known but compose well.
Multi-headed dispatch encoding a constant table. Keleusma admits multi-headed function definitions with integer-pattern parameters. One head per entry, each body assigning the entry’s fields, is functionally equivalent to a constant array. The encoding is verifier-friendly because every body is straight-line code. Prolog facts and Erlang or Elixir pattern matching are close analogues.
Data segment as host-script I/O struct. The data segment is normally the place where a loop main script preserves state across resumes. Repurposing it for one-shot pure functions as an output struct works because the host lends the shared segment as a buffer at each call and reads scalar fields out of it afterward through get_shared. The script reads the input through its function argument and writes outputs through state.field = ... assignments.
Negative-index size discovery. The loader resolves negative indices to count + n (Python sequence convention). Calling fn main(-1) writes the last entry’s fields, including an id slot equal to count - 1. The host reads the id slot to learn the table size with one call, sizes its cache from that, and asserts the value against any parallel host-side constant. This avoids hard-coding the count in the Rust source.
Minimal example
A table of three colours, each with red, green, blue channels.
// colours.kel
data state {
id: Word,
r: Word, g: Word, b: Word,
}
fn main(n: Word) -> Word {
let count = 3;
let i = if n < 0 { count + n } else { n };
fill(i);
0
}
fn fill(0) -> Word { state.id = 0; state.r = 255; state.g = 0; state.b = 0; 0 } // red
fn fill(1) -> Word { state.id = 1; state.r = 0; state.g = 255; state.b = 0; 0 } // green
fn fill(2) -> Word { state.id = 2; state.r = 0; state.g = 0; state.b = 255; 0 } // blue
fn fill(_n: Word) -> Word { 0 }
Host side, with the cache discovered from the script.
#![allow(unused)]
fn main() {
use std::sync::OnceLock;
pub struct Colour { pub r: u8, pub g: u8, pub b: u8 }
static COLOURS: OnceLock<Vec<Colour>> = OnceLock::new();
pub fn colours() -> &'static [Colour] {
COLOURS.get().expect("colours not loaded")
}
fn load_colours(vm: &mut Vm) -> Result<(), Box<dyn std::error::Error>> {
// The host lends a zeroed shared buffer; the script writes its fields into
// it and the host reads them back out (B28 item 2).
let mut shared = vec![0u8; vm.shared_data_bytes()];
// Discover the count by calling with -1.
vm.call_with_shared(&mut shared, &[Value::Int(-1)])?;
let count = read_int(vm, &shared, 0)? as usize + 1;
let mut table = Vec::with_capacity(count);
for i in 0..count {
vm.call_with_shared(&mut shared, &[Value::Int(i as i64)])?;
table.push(Colour {
r: read_int(vm, &shared, 1)? as u8,
g: read_int(vm, &shared, 2)? as u8,
b: read_int(vm, &shared, 3)? as u8,
});
}
let _ = COLOURS.set(table);
Ok(())
}
fn read_int(vm: &Vm, shared: &[u8], slot: usize) -> Result<i64, Box<dyn std::error::Error>> {
match vm.get_shared(shared, slot)? {
Value::Int(n) => Ok(n),
other => Err(format!("expected Int at slot {}, got {:?}", slot, other).into()),
}
}
}
Variations
Multiple tables in one script. If two tables share the same data-segment shape, dispatch on a leading table argument. fn main(table, tier) dispatches to one of two per-table inner functions based on table. Each table is independently discoverable via -1.
fn main(table: Word, tier: Word) -> Word {
let count = 20;
let i = if tier < 0 { count + tier } else { tier };
if table == 0 { weapon(i); }
else { if table == 1 { armor(i); } };
0
}
fn weapon(0) -> Word { ... }
fn armor(0) -> Word { ... }
Chained dispatchers. When some output fields are derived from others, chain two dispatchers in the loader. The first dispatcher sets the keying field; the second reads it from the data segment and sets the derived fields. The host receives a fully populated entry from a single call.
fn main(n: Word) -> Word {
let count = 100;
let i = if n < 0 { count + n } else { n };
fill(i);
corpse_fill(state.shape);
0
}
Names through the return value. Keleusma’s data segment does not currently accept string fields in source. When entries have a name, encode it as a third multi-headed dispatcher returning Text and call it as the last expression in fn main. The host receives the string as the return value while the data segment carries the numeric fields. The host can leak the returned static string once at startup to obtain a &'static str.
When to use
The pattern fits when all of the following hold.
- The table has more than about ten entries. Below that, the script overhead exceeds the savings.
- Each entry is a small struct of integers or enum ordinals. Strings, floats with quirky precision, or variable-size payloads need workarounds.
- The data benefits from being designer-editable without a host rebuild. If only the Rust author ever touches the table, leave it in Rust.
- Runtime hot reload is desirable, even if the initial implementation caches once. The pattern keeps the path open.
When not to use
- The data is already dense in Rust (one line per entry with no per-entry struct boilerplate). The migration adds script-loading overhead without compressing the storage.
- The data has lifecycle hooks (constructors, drop). Keleusma cannot carry those. Keep them in Rust.
- The data is keyed on a type that the script cannot represent. Strings, floats with specific precision requirements, or compound keys all push the pattern out of fit.
Examples in this repository
The rogue example uses this pattern for its bestiary and equipment tables; see ROGUE.md, Reading the bestiary script.
Narrow-runtime type alias
Problem
The host targets a sub-64-bit native runtime. A 16-bit microcontroller, a retro-class 8-bit machine, a 32-bit embedded core. The default Vm<'a, 'arena> is GenericVm<'a, 'arena, i64, u64, f64>. Carrying 64-bit values on a 16-bit native target wastes memory and forces software arithmetic on machine operands the hardware does not natively support. The host wants the runtime’s word, address, and float widths to match the target.
Solution
The Vm shape is generic over three trait parameters that mirror the bytecode header’s word_bits_log2, addr_bits_log2, and float_bits_log2 declared widths. Instantiate GenericVm<W, A, F> directly with the host’s chosen widths and define a type alias for the ergonomic call sites.
#![allow(unused)]
fn main() {
use keleusma::vm::GenericVm;
// 16-bit signed word, 16-bit unsigned address, 32-bit float.
type NarrowVm<'a, 'arena> = GenericVm<'a, 'arena, i16, u16, f32>;
// 8-bit signed word, 16-bit unsigned address, 32-bit float
// (6502-class retro target with floats kept for future opcodes).
type RetroVm<'a, 'arena> = GenericVm<'a, 'arena, i8, u16, f32>;
}
Bytecode for the narrow target is produced through compile_with_target. The embedded_16 preset rejects floating-point opcodes; use a custom Target if floats are wanted at a narrower width.
#![allow(unused)]
fn main() {
use keleusma::Arena;
use keleusma::compiler::compile_with_target;
use keleusma::lexer::tokenize;
use keleusma::parser::parse;
use keleusma::target::Target;
let module = {
let tokens = tokenize(src).expect("lex");
let program = parse(&tokens).expect("parse");
compile_with_target(&program, &Target::embedded_16()).expect("compile")
};
let arena = Arena::with_capacity(4096);
let mut vm: NarrowVm<'_, '_> = NarrowVm::new(module, &arena).expect("verify");
}
Host functions speak Rust’s natural types
The marshall layer (KeleusmaType, IntoNativeFn, IntoFallibleNativeFn) is parametric over (W, F), with universal impls for i64, f64, bool, (), Option<T>, fixed arrays, and tuples (arities 2 to 5). The universal KeleusmaType<W, F> for i64 impl bridges through Word::to_i64 and Word::from_i64_wrap; the universal KeleusmaType<W, F> for f64 impl bridges through Float::to_f64 and Float::from_f64.
The host author writes i64 and f64 in closure signatures regardless of the script’s narrower word and float types. The runtime truncates at the boundary.
#![allow(unused)]
fn main() {
vm.register_fn("host::triple", |x: i64| -> i64 { x * 3 });
}
On a NarrowVm, the script-side i16 argument widens to i64 for the host closure; the i64 return truncates back to i16 through Word::from_i64_wrap. Hosts that want native-width Rust types (a closure body that takes i16 directly to avoid widening) can add their own KeleusmaType<i16, f32> for i16 impl in their crate.
Standard library bundles work on narrow runtimes
The three stddsl bundles implement Library<W, A, F> universally and register on any admissible runtime shape. Math and Audio carry their inner closures in f64; on a runtime whose F is f32, every closure argument and return value passes through Float::from_f64 and Float::to_f64 at the marshall boundary, narrowing intermediates and constants. The narrowing is mathematically defined and silent. Shell has no floating-point surface and so quantifies over F without precision implications.
#![allow(unused)]
fn main() {
let mut vm: NarrowVm<'_, '_> = NarrowVm::new(module, &arena).expect("verify");
vm.register_library(keleusma::stddsl::Math);
vm.register_library(keleusma::stddsl::Audio);
}
Programs that require full f64 precision should declare a runtime whose F is f64 rather than relying on the silent narrowing. The narrow-float runtime is the appropriate choice when the target’s FPU is single-precision and the script does not need the extra mantissa.
Word-width arithmetic discipline
Script-side arithmetic on a narrow runtime wraps at the runtime’s word boundary, not at 64 bits. The Word trait’s wrapping_add, wrapping_sub, wrapping_mul, wrapping_div, wrapping_rem, and wrapping_neg methods drive every arithmetic dispatch site. On NarrowVm, 30_000 + 10_000 produces -25_536 rather than 40_000. Programs that depend on wider arithmetic should declare a wider word, or perform the operation host-side through a registered native that takes the natural Rust type.
Cross-references
examples/narrow_runtime.rsis the worked demonstrator.tests/narrow_vm.rsis the integration test that pins the pattern.docs/decisions/BACKLOG.md, B16 records the architectural rationale for the parametric shape.- The
Word,Address, andFloattraits live insrc/word.rs,src/address.rs, andsrc/float.rs. Custom impls are admissible; the bundled impls cover the standard widths.
Distributing signed bytecode
Problem
The host application loads compiled bytecode that arrives from an untrusted source — over a comms link, from disk, from a content-delivery channel — and needs to refuse modules that were not produced by an authorised signer. The threat model is multi-party: one or more known signer identities are trusted to sign modules; everything else is rejected.
Solution
Three steps. The cargo feature signatures is off by default; turn it on for both the producer and the consumer.
One. Generate a keypair. The keleusma keygen --seed seed.bin --public pub.bin subcommand writes a 32-byte Ed25519 seed and the matching public key to separate files. On Unix the seed file is mode 0o600; existing files are not overwritten. Treat the seed as a long-lived secret kept on the signing system. The public key is freely distributable and is what the consumer trusts.
Two. Declare the requirement and sign. The source script declares the signing requirement with the signed modifier on the entry function:
signed loop main(input: Word) -> Word {
let next = yield input * 2;
next
}
keleusma compile script.kel --signing-key seed.bin -o script.kel.bin produces signed bytecode. The compiler emits FLAG_REQUIRES_SIGNATURE in the framing header and the signer appends an Ed25519 signature.
Three. Verify at the consumer. The host populates a trust matrix with the public keys it accepts, then loads through the signature-aware entry points:
#![allow(unused)]
fn main() {
let key = ed25519_dalek::VerifyingKey::from_bytes(&public_key_bytes)?;
let mut vm = Vm::load_signed_bytes(&signed_bytes, &arena, &[key])?;
}
For hot-swap delivery (signer/device pattern), the host constructs the VM from an unsigned baseline, registers the trust matrix, and accepts signed updates over the comm link:
#![allow(unused)]
fn main() {
let mut vm = Vm::new(baseline_module, &arena)?;
vm.register_verifying_key(signer_key);
// later, after receiving an update over the wire:
vm.replace_module_from_bytes(&update_bytes, initial_data)?;
}
The signature is verified before the new bytecode is decoded; an invalid signature rejects the swap and the current module continues to run.
Why this works for embedded targets
The verification path uses ed25519-dalek under no_std + alloc. The examples/rtos demonstrator builds with --features keleusma-signatures and verifies a built-in signed fixture at boot before entering the scheduler loop. Ed25519 verification on a Cortex-M33 at 600 MHz runs in low milliseconds; the cost is paid at each module load or hot-swap, not at every yield.
Cross-references
R42indocs/decisions/RESOLVED.mdis the design rationale.docs/spec/WIRE_FORMAT.mddocuments the header extension layout.docs/guide/EMBEDDING.md, Signed Modules covers the embedding-side API in more depth.examples/scripts/11_signed.kelis a worked signed script.
Calibrated WCET with a measured cost model
Problem
The host wants WCET (worst-case execution time) estimates in actual CPU cycles for the deployment target, not the relative-weight estimates the bundled NOMINAL_COST_MODEL ships with. The nominal model assigns 1 to data movement, 2 to arithmetic, 3 to division, 5 to composite construction, 10 to function calls. These ratios are useful for ordering programs against each other on the same host, but the absolute scale is not pipelined CPU cycles for any specific hardware.
Real WCET analysis for a deployment target wants measured numbers: data movement on a Cortex-M55 at 800 MHz runs hundreds of cycles per VM dispatch; the same workload on an Apple M1 Max P-core runs tens. The two are different units, and a host that wants to convert WCET to wall-clock time needs the right one.
Solution
The keleusma-bench workspace member measures per-opcode pipelined cycles on a host CPU and emits a Rust source fragment defining a MEASURED_COST_MODEL constant. The host crate includes the fragment and passes the constant to the _with_cost_model variant of whichever WCET API it consumes.
One. Obtain a fragment. The crate ships pre-generated fragments under keleusma-bench/measured_cost_models/ for the dev host and the STM32N6570-DK. For other hosts, regenerate:
# Host bench (writes a fragment for the running CPU)
cargo run --release --bin keleusma-bench -- \
--cpu-hz 3500000000 \
--output keleusma-bench/measured_cost_models/<target-triple>.rs
# Embedded bench (captures defmt RTT, parses to a fragment)
cd examples/rtos
cargo run --release --bin bench_n6 --target thumbv8m.main-none-eabihf \
--no-default-features --features stm32n6570dk-platform \
2>&1 | tee /tmp/bench.log
cd -
cargo run --release -p keleusma-bench -- \
--from-log /tmp/bench.log \
--output keleusma-bench/measured_cost_models/thumbv8m_main_none_eabihf.rs
--cpu-hz declares the host’s CPU clock for the cycle calculation. The default is documented in keleusma-bench/src/counter.rs (DEFAULT_ASSUMED_CPU_HZ). See keleusma-bench/measured_cost_models/README.md for the capture workflow per target.
Two. Include the fragment in the host crate. The fragment is plain Rust source that declares MEASURED_COST_MODEL and a backing measured_op_cycles function:
#![allow(unused)]
fn main() {
include!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/path/to/keleusma-bench/measured_cost_models/<fragment>.rs"
));
}
Multiple fragments cohabit cleanly when gated by cfg(target_arch = ...):
#![allow(unused)]
fn main() {
#[cfg(all(target_arch = "aarch64", target_os = "macos"))]
include!(concat!(env!("CARGO_MANIFEST_DIR"),
"/keleusma-bench/measured_cost_models/aarch64_apple_darwin.rs"));
#[cfg(target_arch = "arm")]
include!(concat!(env!("CARGO_MANIFEST_DIR"),
"/keleusma-bench/measured_cost_models/thumbv8m_main_none_eabihf.rs"));
}
Three. Pass the model to the verifier. The _with_cost_model variants of the WCET APIs accept a &CostModel:
#![allow(unused)]
fn main() {
use keleusma::verify::wcet_stream_iteration_with_cost_model;
for chunk in module.chunks.iter() {
if chunk.block_type != BlockType::Stream { continue; }
let cycles = wcet_stream_iteration_with_cost_model(chunk, &MEASURED_COST_MODEL)?;
println!("Chunk `{}`: {} CPU cycles per iteration", chunk.name, cycles);
}
}
For resource-bound verification at load time, verify::verify_resource_bounds_with_cost_model takes the same &CostModel and threads it through both the WCET and WCMU paths.
Why this matters
The bundled NOMINAL_COST_MODEL is honest about being relative-only. It cannot answer “how many microseconds per iteration.” The measured model can, conditional on the bench’s calibration assumptions (CPU clock, warm caches, predicted branches). For deployments where the WCET bound informs scheduler decisions, this conversion is the load-bearing step.
Failure modes
- The fragment was measured on a different host than the deployment target. The cycle counts are then dev-host estimates, not calibrated for the executing CPU. The fragment header records the host and the assumed CPU clock; check both before using.
- The host CPU’s actual clock differs from the bench’s assumption. Re-bench with
--cpu-hz <Hz>set to the actual clock, or pass--cpu-hztokeleusma-bench --from-logto correct the documented value post-capture. - The bench does not measure
YieldorCallin isolation. Both categories fall back to scaled nominal values. The scaled values are conservative for WCET but are extrapolations, not measurements. This is documented in the fragment header.
Cross-references
examples/measured_wcet.rsis the minimal working example: compiles a small Stream-classified program, prints WCET under both nominal and measured models.keleusma-bench/README.mddocuments the bench tool’s methodology and CLI.keleusma-bench/measured_cost_models/README.mdcatalogues the pre-generated fragments and the capture workflows.docs/architecture/LANGUAGE_DESIGN.md§ Cost model explains theCostModelcontract and the host’s role in calibration.examples/rtosdemonstrates the wiring in a headline example: each task logs its measured WCET per iteration at boot.
Frequently Asked Questions
Navigation: Guide | Documentation Root
This document collects surprises that early adopters have run into. The intent is to answer the questions that the rest of the documentation does not yet anticipate, not to be exhaustive.
Strings
Strings are not the Keleusma value proposition. The language’s value proposition is definitive Worst-Case Execution Time and Worst-Case Memory Usage verification for embedded real-time scripting. For string-heavy standalone work, a dynamic language with a rich standard library is the better tool. Python, Ruby, JavaScript, or any of the many shell-and-text-processing languages will all handle strings more ergonomically and with more built-in utility than Keleusma. Strings in Keleusma exist as a host-boundary type and as a debugging convenience; they are not the surface to optimise for.
Text surface in V0.2.0
V0.2.0 ships only the static-string surface at the script level. String literals ("...") compile to Value::StaticStr constants in the bytecode’s read-only constant pool. The Text primitive type names the surface type for static strings, host-produced dynamic strings (Value::KStr arena handles), and string-typed parameters across the host boundary. The bundled to_string, concat, slice, and length utility natives retired alongside f-string interpolation in the V0.2.0 Phase 3.5 text-composition removal. The runtime still distinguishes static (StaticStr) and dynamic (KStr) variants behind Text; the cross-yield prohibition continues to apply to dynamic strings.
The recommended pattern is to register native Rust functions that perform the string work and expose them to the script. Rust’s standard library handles formatting, splitting, regex, encoding conversion, and Unicode operations far better than anything reasonable to build inside the script. The host writes a small Rust function, registers it with one register_fn call, and the script gets a single use declaration that yields native performance and full Rust ecosystem access.
#![allow(unused)]
fn main() {
// Rust host code.
use keleusma::{Arena, Value, vm::Vm};
let mut vm = Vm::new(module, &arena)?;
// Host-defined string helpers using Rust's standard library.
vm.register_fn("text::upper", |s: String| -> String {
s.to_uppercase()
});
vm.register_fn("text::trim", |s: String| -> String {
s.trim().to_string()
});
vm.register_fn_fallible(
"text::split_first_word",
|s: String| -> Result<String, keleusma::VmError> {
s.split_whitespace()
.next()
.map(|w| w.to_string())
.ok_or_else(|| keleusma::VmError::NativeError("empty input".into()))
},
);
// The script imports each native by name.
//
// use text::upper
// use text::trim
// use text::split_first_word
//
// fn greet(name: Text) -> Text {
// text::upper(text::trim(name))
// }
}
The host owns the string-handling vocabulary; the script consumes it through use declarations. See EMBEDDING.md for the full native-registration surface.
Where text works
- Static string literals. Compiled to
Value::StaticStrand reside in the bytecode’s constant pool. May flow anywhere admissible, including across the yield boundary in the dialogue type. - Arena-resident dynamic strings. Produced by host-registered native functions through the
KString::allocarena boundary. Carried asValue::KStrhandles that resolve through the host-owned arena and become stale on the next arena reset. Subject to the cross-yield prohibition. See TYPE_SYSTEM.md for the full text-type discipline.
The Value::DynStr global-heap variant present in V0.1.x was removed in V0.2.0. All dynamic text is arena-resident.
Escape table for static string literals
| Escape | Result |
|---|---|
\n | newline (U+000A) |
\t | tab (U+0009) |
\r | carriage return (U+000D) |
\\ | literal backslash |
\" | literal double quote |
\0 | null byte |
All other characters appear directly without escaping. Any other backslash sequence is a lex error. V0.2.0 retired the f-string-specific \{ and \} escapes alongside f-string interpolation; { and } are ordinary characters inside "...".
WCMU Coverage
Exponential string concatenation bypasses the WCMU bound
A program like
fn main() -> Text {
let s = "a";
let s = s + s;
let s = s + s;
/* sixty doublings later */
s
}
would in V0.1.x have compiled, been admitted by Vm::new, and exhausted the host process at runtime. V0.2.0 addresses both the allocator and the static-analysis dimensions.
Issue one: string + previously allocated from the global allocator, not the arena. Resolved in V0.2.0. Op::Add on text operands now produces a Value::KStr allocated through KString::alloc in the arena’s top region. Allocation failure surfaces as VmError::OutOfArena rather than exhausting the host process. The Value::DynStr variant has been removed entirely.
Issue two: the WCMU pass did not previously track text sizes statically. Resolved in V0.2.0. The verifier now runs a text-size abstract interpretation pass over each chunk that tracks a per-slot TextSize::{NotText, Known(u32), Unbounded} lattice through the bytecode, evaluating the OpCost::Dynamic cost of Op::Add on text against the operand bounds and accumulating the result into the chunk’s WCMU heap total. Programs that doubly grow a text value cumulatively saturate the bound to u32::MAX, which the safe constructor rejects under the default OverflowPolicy::Reject. The doubling-string example above is now rejected at Vm::new when expressed as a Stream block.
Limitations of the V0.2.0 text-size analysis.
- Loops widen text values to
Unbounded. Text operations inside afororloopbody produce conservativeu32::MAXcontributions because the pass is linear, not iterative. Programs whose text concatenation happens once per stream iteration are handled precisely; programs that mix loops and text are conservative. - Branches widen text values to
Unbounded. Text values written conditionally inside anif/elselose their precise bound. The pass continues to correctly bound text written outside conditionals. - Native return values are
Unbounded. Text returned from a registered native function is tracked as unbounded; any subsequentOp::Addagainst it saturates the contribution. Hosts that need a tighter bound for their natives supply per-native heap attestations throughVm::set_native_bounds. - Atomic-total programs (no Stream block) are not subject to the per-iteration WCMU bound. A
fn main() -> Text { let s = "a"; let s = s + s; ... }compiles and runs because the resource-bounds check applies only to Stream chunks. The arena exhaustion path throughVmError::OutOfArenaprovides the graceful-failure guarantee for these programs.
Recommendation for text-heavy work. Treat heavy text work as host-attested and out-of-band. Register native Rust functions (see the section above) that perform the work in a bounded way and let scripts consume them. Host-side text helpers can be implemented to fail safely on large input rather than allocate unboundedly.
A worked example of the host-attestation pattern for arena-resident allocations lives in examples/wcmu_attestation.rs.
V0.2.0 fail-fast on too-small arenas
The previous releases admitted an Arena::with_capacity(0) through Vm::new and then aborted the host process via handle_alloc_error on the first push. V0.2.0 changes this in two layers.
Construct-time minimum reservation. Vm::new and Vm::new_unchecked pre-reserve a small minimum operand-stack and call-frame allocation in the arena’s bottom region. If the arena cannot hold the minimum, both constructors return the new VmError::OutOfArena variant rather than aborting. The minimum is conservative (four stack slots and one call frame); arenas around five hundred bytes or larger pass.
Run-time push paths return OutOfArena. Every operand-stack and call-frame push in the VM execution loop now routes through internal sp! and fp! macros that call Vec::try_reserve first and return VmError::OutOfArena on allocation failure. Programs whose runtime usage exceeds the arena no longer abort the host process; the host gets a typed error and can decide how to handle it (drop the VM, reset state via Vm::reset_after_error, retry with a larger arena, or surface the error to the user).
The combination means the arena-resident parts of execution — the operand stack and call frames — are now fully arena-bounded with graceful failure.
#![allow(unused)]
fn main() {
let arena = Arena::with_capacity(2 * 1024);
let mut vm = Vm::new(module, &arena)?;
// ...
match vm.call(&[]) {
Ok(state) => /* handle state */,
Err(VmError::OutOfArena(msg)) => {
eprintln!("arena exhausted: {}", msg);
// recover or reconfigure
}
Err(other) => /* handle other errors */,
}
}
For sizing the arena, use auto_arena_capacity_for plus a host-side margin, or the bundled DEFAULT_ARENA_CAPACITY of sixty-four kilobytes for typical embedded scripting.
Other Surprises
Vm::call rejects wrong arg count or type up front
Hosts that drive Keleusma scripts from Rust pass arguments through vm.call(&[Value::Int(1), Value::Int(2)]). The runtime validates the argument count against the entry chunk’s param_count and each argument’s runtime type against the parameter’s declared TypeTag before any bytecode runs. Too few or too many arguments, or a wrong-typed argument, produces a VmError::TypeError at the call boundary rather than a confusing arithmetic error later.
Typical reproduction:
// Script: fn main(a: Word, b: Word) -> Word { a + b }
vm.call(&[Value::Int(1)])
// -> VmError::TypeError("function `main` expected 2 arguments, got 1")
vm.call(&[Value::Int(1), Value::Float(2.5)])
// -> VmError::TypeError("function `main` parameter 1 expected Word, got Float")
Hosts that genuinely want to pass an opaque or composite value receive TypeTag::Composite validation, which accepts any Value. The param_types field of each chunk is the source of truth for what the runtime will accept; the compiler populates it from the function’s declared parameter types.
Vm::resume validates the resume value’s type for Stream blocks
A loop main(x: T) -> R script yields a value of type R and resumes with the next iteration’s value of type T. The host calls vm.resume(value) to drive the next iteration. The runtime validates value against the loop’s parameter type before pushing it into the parameter slot.
#![allow(unused)]
fn main() {
// Script: loop main(x: Word) -> Word { let z = yield x; z }
vm.call(&[Value::Int(11)]) // Ok(Yielded(Int(11)))
vm.resume(Value::Float(1.5)) // VmError::TypeError(
// "loop `main` resume expected Word, got Float")
}
The yield expression’s type and the resume value’s type are the same by language design (the parameter type), so a single tag at the chunk level covers both directions of the dialogue.
Parser rejects deeply nested expressions
The parser is a recursive-descent walker. Deeply nested parens (around a thousand or more) used to overflow the host process’s stack. The parser now bails with a typed ParseError at MAX_PARSE_DEPTH = 32 levels of nesting. The limit applies at the three recursive entry points (parse_expr, parse_type_expr, parse_pattern).
Hosts that produce Keleusma source programmatically (templating, code generation) should keep expression nesting well under thirty-two levels. Realistic hand-written source rarely approaches the limit; the bound exists to prevent a malicious or accidental input from killing the host process.
Local bindings are immutable
let bindings cannot be rebound or mutated. The data segment is the only region of mutable state observable to a script, and it is accessible only from a loop-classified entry point. Accumulation across a loop iteration in an atomic-total fn is therefore not possible without either (a) a loop main script using the data segment, or (b) a host-side fold native. See WHY_REJECTED.md under the recursive-closure entry for examples of both rewrites.
Closures are rejected at the type-checker stage
V0.2.0 Phase 4 retired the closure family: the Op::PushFunc, Op::MakeClosure, Op::MakeRecursiveClosure, and Op::CallIndirect opcodes are gone, the Value::Func runtime variant is gone, and the closure-hoisting compiler pass is gone. The type checker now rejects Expr::Closure with the diagnostic closures are not supported; V0.2.0 admits only direct calls and trait dispatch under the conservative-verification stance. Rewrite as a top-level fn or trait method. First-class function references (e.g. let f = my_func;) are likewise rejected by the compiler. This is the conservative-verification stance documented in LANGUAGE_DESIGN.md. The valid form of unbounded execution is the top-level loop block enforced by the productivity rule.
Pipeline operator requires parentheses
The right-hand side of |> must be a function call with parentheses, even when the function takes no additional arguments. expr |> f is a parse error; expr |> f() is correct.
What does the signed modifier do?
V0.2.0 introduces a signed modifier on the entry function declaration (signed fn main, signed yield main, signed loop main). It sets FLAG_REQUIRES_SIGNATURE in the framing header so the load-time runtime refuses the module unless a cryptographic signature is attached and verifies against the host’s trust matrix.
The signing operation itself is a toolchain step independent of the compiler. keleusma compile script.kel --signing-key seed.bin produces an Ed25519-signed bytecode file; the consumer registers the matching public key on the VM (Vm::register_verifying_key) and loads through Vm::load_signed_bytes or hot-swaps signed updates through Vm::replace_module_from_bytes. Vm::load_bytes refuses signed modules with a diagnostic that names the alternate entry point.
The feature requires the signatures cargo feature, which is off by default and pulls in ed25519-dalek. Builds without the feature accept unsigned modules normally and reject signed modules with LoadError::SignaturesUnsupported. The signed surface keyword still parses without the feature so source files remain portable.
Use case: multi-party module delivery to embedded targets. A signer compiles scripts and signs them; a device flashed with the signer’s public key verifies before loading. See the Distributing signed bytecode cookbook recipe and R42 in RESOLVED.md.
If-else at statement position requires a trailing semicolon
The parser does not auto-insert semicolons. An if-else expression used as a statement (followed by another statement) requires ; even though the expression evaluates to unit.
if state.rem0 == 0 {
/* ... */
} else {
state.rem0 = state.rem0 - 1;
}; // <-- this semicolon is required
state.rem1 = state.rem1 - 1;
Opaque types flow through the native boundary as Value::Opaque
V0.2.0 introduced first-class opaque type support through the HostOpaque trait in the keleusma::opaque module. The host implements the trait for a Rust newtype around the value it wants to expose; native functions produce opaque values through host_arc(...) and consume them by extracting a typed reference through dyn HostOpaque::downcast_ref::<T>(). The script declares the type by name in function signatures, and the type checker resolves the name as Type::Opaque. Opaque values are host-managed through Arc, have a lifetime independent of the arena, may cross the yield boundary, and contribute zero to the script-side WCMU bound. See the “Opaque Host Types” section of EMBEDDING.md and the worked example in examples/opaque_rust_string.rs.
Integer arithmetic wraps to the target word width
Keleusma’s Word is a fixed-width signed integer whose width is declared by the target descriptor. Arithmetic operations mask the result to that width using a sign-extending shift on every step. Overflow does not produce a typed error; the result silently wraps in the modular sense the declared width permits.
fn main() -> Word {
let max = 9223372036854775807;
max + 1
}
// On a sixty-four bit target this returns -9223372036854775808.
This choice is intentional. The Worst-Case Execution Time and Worst-Case Memory Usage bounds the language guarantees depend on every arithmetic operation having a fixed step count. A trapping-overflow semantics would either inflate the worst-case cost of every operation or introduce a control-flow edge that the static analysis would have to enumerate. The wrapping semantics gives a predictable step count and a closed result domain that the analysis can reason about uniformly.
Hosts that need overflow detection register a native that performs the checked operation against a wider Rust integer and surfaces an error through VmError::NativeError. The host owns the checked-arithmetic vocabulary; the script consumes it through use declarations.
Loop-calls-loop is rejected by lexical productivity
The productivity rule that admits loop blocks is enforced by a purely lexical structural check. The verifier walks the syntactic body of each loop and requires that every control-flow path through one iteration contains at least one yield. A loop block whose body’s only yield is inside a function it calls is rejected because the structural pass does not chase the call.
yield helper() -> Word { yield 1 }
loop main() -> Word {
let v = helper(); // <-- structural pass does not see the yield
v
}
This program is rejected with loop body has no yield on at least one path. The rule errs conservative on purpose. A semantic check that chased calls would be unsound for parameter-dependent dispatch or trait method resolution and would also have to handle mutually recursive call graphs. The lexical check is sound, fast, and easy to explain at the cost of forcing the yield to appear at the top level of the loop body.
The recommended pattern is to keep yield at the top of the loop body and call helpers around it.
yield helper() -> Word { yield 1 }
loop main() -> Word {
let v = yield helper(); // direct yield satisfies the rule
v
}
The same constraint applies to if/else and match branches inside the loop body. Every fall-through path must contain a yield, or the branch must break out.
V0.2.0 boundary diagnostics
The construction and call surfaces were tightened in V0.2.0 so that several previously silent or misleading cases now produce typed diagnostics at the appropriate boundary.
- Integer literals that overflow
i64are nowLexError. The previous behaviour silently producedValue::Int(0)for literals such as99999999999999999999999999999. The lexer now reportsinteger literal does not fit in i64with the source span of the literal. - Untyped parameters are inferred when context resolves them. Writing
fn main(x) -> Word { x }previously parsed and registeredxwith no inferred type, then tripped a confusing error later. The type checker now writes inferred primitive types back into the AST. Forfn main(x) -> Word { x }the return-type constraint forcesx: Word, and the chunk’sparam_typescarriesTypeTag::WordsoVm::call(&[Value::Float(1.5)])is rejected at the API boundary. If inference does not resolve the parameter (no constraint), the chunk recordsTypeTag::Compositeand the runtime accepts any value. - Duplicate function heads are rejected, entry point or not. Two function definitions that share the same name whose parameter signatures cannot be disambiguated as multi-headed pattern matching (same shape, no guard) used to keep the first and silently discard the rest. The compiler now reports
function head is dead codeat the second definition. The rule applies to all categories (fn,yield,loop) and to every function, not just the entry point. - Multi-headed entry points are accepted for
fn,yield, andloop. All three function categories admit pattern-matched entry points. Multi-headedloop main(...)Stream blocks compile to a singleOp::Streamand singleOp::Resetenvelope around a dispatch wrapped inOp::Loop/Op::EndLoop; each matched head’s body ends withOp::PopandOp::Breakso the structural verifier’s Stream invariants hold. - Modules without an entry point are now
VmError::VerifyError. A module compiled from source that omitsfn main,yield main, orloop mainpreviously surfaced asVmError::InvalidBytecode("no entry point")at the firstVm::call. The constructorVm::new(andVm::new_unchecked) now rejects the module withmodule has no entry pointat the API boundary. - Premature
Vm::resumeis nowVmError::NotSuspended. Callingvm.resume(value)beforevm.call(args)previously surfaced asVmError::InvalidBytecode("cannot resume: VM not suspended"), which conflated API misuse with corrupt bytecode. The runtime now returns the dedicatedVmError::NotSuspendedvariant. - Structural-verification rejections now carry source spans. Compile-pipeline rejections for
CallIndirectandMakeRecursiveClosureused to attachSpan::default(), which hid the offending source position. Each rejection now points at the originating function or closure declaration so editors can underline the construct.
Where to look for more
- The full language reference is GRAMMAR.md (descriptive; the normative reference is the parser at
src/parser.rs). - The verifier rejection catalog is WHY_REJECTED.md.
- The embedding API surface is EMBEDDING.md.
- The conservative-verification stance is in LANGUAGE_DESIGN.md.
When in doubt about whether a behaviour is intended or a bug, the parser, type checker, and verifier are authoritative; the documentation is descriptive.
Keleusma as a Scripting and Automation Tool
Navigation: Guide | Documentation Root
This reference consolidates the operator-facing story for using the keleusma command-line tool to write devops and sysadmin automation. The numbered guide chapters teach the language; this document gathers the pieces that turn a script into a deployable command, a streaming filter, or a long-running daemon, and explains how to distribute that artefact as signed and optionally encrypted bytecode.
The material here is drawn together from several chapters and reference documents that each cover one facet. Where a topic has a fuller treatment elsewhere, this document links to it rather than restating it.
Audience
Operators and script authors who want to run Keleusma programs as standalone tools rather than embed the runtime in a Rust host. Readers who want to embed the runtime should start at Chapter 31 instead.
Three ways to run a script
A source script runs in three interchangeable forms. All three accept the same script arguments.
| Form | Command | Platform |
|---|---|---|
| Explicit subcommand | keleusma run script.kel | All |
| Extension shorthand | keleusma script.kel | All |
| Shebang executable | ./script.kel after chmod +x | macOS and Linux |
The shebang form requires the first source line to read #!/usr/bin/env keleusma. The lexer skips that line while preserving source line numbers in diagnostics, so the same file remains compilable on Windows, where it runs through keleusma run. Chapter 2 introduces the shebang at tutorial pace. A compiled bytecode file may also carry a shebang, covered in Chapter 25.
Script arguments
A script reads its own arguments through the shell bundle. shell::arg(0) returns the script path and shell::arg(1) onward the positional arguments the launcher passed after it, mirroring the $0 and $1 convention of POSIX shells. shell::arg_count() reports the number of entries, counting argument zero.
The CLI collects positional arguments for both keleusma run script.kel a b c and the shebang form ./script.kel a b c. A -- terminator marks the end of CLI options, after which every token is treated as a script argument even when it begins with a dash. An unrecognized leading-dash token before -- is rejected as a flag typo rather than passed through.
./report.kel --since 2026-01-01 -- --raw
In that invocation the CLI consumes nothing it does not recognize, and the script receives --since, 2026-01-01, and --raw as positional arguments one through three.
Entry kinds map to deployment shapes
A script declares one of three entry kinds. The CLI inspects the compiled entry block and drives it accordingly. The entry kind is the script author’s primary lever for the deployment shape. Chapter 15, Chapter 16, and Chapter 17 cover the language semantics; the table below maps each to its operational role.
| Entry kind | Declaration | Termination | Operational shape |
|---|---|---|---|
| Atomic | fn main() -> Word | Runs to completion in one call | One-shot command. Suited to cron jobs, build steps, and manual invocation. |
| Staged | yield main(tick: Word) -> Word | Returns rather than yields | Cooperative task that pauses and resumes across ticks, then finishes. |
| Daemon | loop main(tick: Word) -> Word | Only on shell::exit or a termination signal | Long-running service. Returning is treated as an error. |
A few operational consequences follow from how the CLI drives each kind.
- An atomic
fn mainhas its returned value printed to standard output. The process exit status is not taken from the return value. A script that needs a specific exit code callsshell::exit(code). The repository link-checker uses exactly this pattern. - A
loop maindaemon is rate-limited with--tick-interval, which accepts humanized durations such as100ms,1s,1m,1h,1d, and1w, up to a maximum of four weeks. The runtime is genuinely idle between iterations, so a long-cadence daemon costs page-fault avoidance rather than computation. SeeMETRICS.mdfor the memory-residency analysis andSECURITY_POLICY.mdfor the operator guide to daemon cadences.
Delegating work through the shell bundle
Without host-registered natives the language admits only pure total functions and the productive-divergent loop. The CLI registers the shell bundle, which is what makes orchestration possible. A script delegates work to ordinary command-line programs, branches on the Word exit code each returns, accumulates across the run in a mutable private data segment, and sets its own process exit status.
| Native | Purpose |
|---|---|
shell::run(cmd) -> (Word, Text) | Run a command through sh -c; returns exit code and stdout. Stderr is discarded. |
shell::run_full(cmd) -> (Word, Text, Text) | As above, returning exit code, stdout, and stderr. |
shell::run_checked(cmd) -> Text | Run a command; trap on a non-zero exit. |
shell::run_timeout(cmd, ms) -> (Word, Text) | Run a command with a wall-clock deadline. |
shell::read_file, shell::write_file, shell::append_file | File input and output. |
shell::writeln_err, shell::write_err | Log-shaped output to stderr. |
shell::arg, shell::arg_count | The script’s own arguments. |
shell::exit(code) | Terminate the process with an exit status. |
The full list, signatures, and per-native contracts are in STANDARD_LIBRARY.md. The bundle’s capability assessment and its standing limitations are in SHELL_AUDIT.md.
Worked example: the Markdown link-checker
The repository’s own Markdown link-checker, scripts/check-md-links.kel, is a complete worked example of the orchestrator pattern and runs in continuous integration. It delegates the partial text-scanning work to POSIX tools through shell::run, drives control flow on the returned Word, accumulates failures in a private data segment described in Chapter 18, and propagates the result through shell::exit. The constructs that make the orchestration total, the partial-operation family, are covered in Chapter 23.
Distributing scripts as signed and encrypted bytecode
A finished script can be compiled to bytecode and delivered as a tamper-evident, optionally confidential artefact. The two policies are independent. Neither, signing only, encryption only, or both may be active.
| Step | Command |
|---|---|
| Generate a signing keypair | keleusma keygen --seed sign.seed --public sign.pub |
| Generate an encryption keypair | keleusma keygen --kind encryption --seed dest.seed --public dest.pub |
| Compile, sign, and encrypt | keleusma compile script.kel --signing-key sign.seed --encryption-key dest.pub -o script.kel.bin |
| Run, verifying and decrypting | keleusma run script.kel.bin --verifying-key sign.pub --decryption-key dest.seed |
Signing requires the entry function to carry the signed modifier; otherwise the toolchain produces unsigned bytecode and refuses the signing key. Encryption uses X25519 key agreement so the artefact is sealed to a recipient’s public key and opened with the matching private seed. The signing and encryption design is covered in Chapter 26 and the wire format in WIRE_FORMAT.md.
Strict-mode key stores
On a managed host the trust decision is taken out of the operator’s hands. In strict signing mode the CLI loads trusted public keys from a system directory and refuses source files, unsigned bytecode, and bytecode signed by keys outside the store. The --verifying-key argument is rejected so an unprivileged operator cannot relax the policy. Strict encryption mode behaves analogously for the decryption-key store. The directories, environment variables, and threat model are documented in SECURITY_POLICY.md.
A signed and encrypted bytecode artefact that also carries a shebang is directly executable through the operating system shell while remaining tamper-evident and confidential. This is the distributable-runbook delivery shape, described for courier-delivered media in SECURITY_POLICY.md.
Running several scripts under supervision
For workloads that need more than one script, the run-tasks subcommand drives a set of scripts from a TOML manifest through a cooperative scheduler with an event queue, supervised restart, and per-task signing and encryption policy. It lifts the cooperative-RTOS pattern from examples/rtos/ onto the desktop and server.
keleusma run-tasks fleet.toml --quiet
The manifest declares a scheduler tick interval, a [[task]] table for each script with a name, a bytecode path, and a restart policy of never, on_error, or always, and an optional event table. The manifest format, validation rules, and scheduler semantics are documented in RUN_TASKS.md.
Where the static guarantees do not hold
The language otherwise rejects programs whose worst-case execution time or memory cannot be statically bounded. The orchestration natives are an explicit boundary where that guarantee does not extend.
- A subprocess spawned through
shell::runand its siblings has time and memory the verifier cannot model. The static worst-case bounds do not cross these calls.shell::run_timeoutcaps wall-clock time per call but not memory. shell::read_fileand the run natives allocate output buffers on the host heap, outside the script arena’s budget.- The orchestration natives read mutable external state, so a script that uses them is not reproducible.
shell::exitterminates the host process directly.
These properties belong to the ambient-authority shell bundle, not to the language. A deployment that needs confinement or proven bounds registers a curated subset or its own narrower natives in a custom host rather than shipping the full bundle to untrusted scripts. The complete limitation list is in SHELL_AUDIT.md.
Related reading
- Chapter 2: Installing Keleusma and the Interactive Prompt
- Chapter 15: The Three Function Categories, Chapter 16: Yield, Chapter 17: The loop Function
- Chapter 18: The Data Segment
- Chapter 25: From Source to Bytecode, Chapter 26: Signed Modules and Hot Code Swap
SHELL_AUDIT.md,SECURITY_POLICY.md,METRICS.mdSTANDARD_LIBRARY.md,RUN_TASKS.md
StdDSL::Shell Audit
Navigation: Guide | Documentation Root
Assessment of the V0.2.1 stddsl::Shell bundle against typical devops and sysadmin workloads delivered as signed-and-encrypted loop main scripts. The initial audit identified three priority gaps. All three have been closed under V0.2.1; the bundle is now adequate for the daemon use case. A subsequent review of the script-orchestration path found that shell::arg/arg_count reported the host process argv rather than the script’s own arguments and that shell::run discarded captured stderr; both were corrected under V0.2.1. This document records the current state, the corrections, and the limitations that remain.
Present capabilities
The bundle ships twenty-two natives, registered through vm.register_library(stddsl::Shell).
| Native | Signature | Purpose |
|---|---|---|
shell::getenv | (name: Text) -> Option<Text> | Read an environment variable; Some(value) or None |
shell::has_env | (name: Text) -> bool | Test whether an environment variable is set |
shell::run | (cmd: Text) -> (Word, Text) | Execute cmd through sh -c; returns (exit_code, stdout). Captured stderr is discarded. Non-zero exit code is not an error |
shell::run_full | (cmd: Text) -> (Word, Text, Text) | Execute cmd through sh -c; returns (exit_code, stdout, stderr) |
shell::run_checked | (cmd: Text) -> Text | Execute cmd through sh -c; returns stdout. Non-zero exit code surfaces as NativeError |
shell::run_timeout | (cmd: Text, ms: Word) -> (Word, Text) | Execute cmd with a wall-clock deadline; traps on timeout after killing the subprocess |
shell::exit | (code: Word) -> () | Terminate the host process with the given exit code |
shell::sleep_ms | (ms: Word) -> () | Sleep the current thread for ms milliseconds without spawning a subprocess |
shell::now_unix_ms | () -> Word | Return the current Unix timestamp in milliseconds |
shell::read_file | (path: Text) -> Text | Read a file’s contents; traps on I/O failure or non-UTF-8 |
shell::write_file | (path: Text, content: Text) -> () | Replace a file with the given content; traps on I/O failure |
shell::append_file | (path: Text, content: Text) -> () | Append to a file, creating when absent; traps on I/O failure |
shell::file_exists | (path: Text) -> bool | Test whether a filesystem entry exists; follows symlinks |
shell::write_err | (text: Text) -> () | Write to stderr without a trailing newline |
shell::writeln_err | (text: Text) -> () | Write to stderr with a trailing newline |
shell::arg_count | () -> Word | Number of entries in the script argument vector (script path plus positional arguments); falls back to the host process argv when none is installed |
shell::arg | (index: Word) -> Option<Text> | Script argument at index; index zero is the script path, one onward the positional arguments; None when out of range or negative |
shell::setenv | (name: Text, value: Text) -> () | Set an environment variable for subprocesses spawned through shell::run |
shell::pid | () -> Word | Current process identifier, for pidfile creation |
shell::hostname | () -> Text | Host name reported by the operating system; traps when unavailable |
shell::pwd | () -> Text | Current working directory; traps on failure |
shell::cd | (path: Text) -> () | Change the current working directory; traps on failure |
Combined with the bundled println from utility_natives, scripts can produce output, run external processes (with or without captured stderr and a timeout), sleep without forking, read and write files directly, route log-shaped output to stderr, inspect their own arguments, and manipulate the process environment and working directory.
Closed gaps
The initial audit identified three critical gaps and four important gaps. All seven have been closed under V0.2.1, with one design adjustment from the original recommendation.
| Original recommendation | Status | Notes |
|---|---|---|
shell::sleep_ms | Implemented | Negative or zero inputs return immediately rather than rejecting. |
shell::now_unix_ms | Implemented | Returns the Unix timestamp in milliseconds; clamped to the Word range when the system clock returns a value beyond i64 milliseconds, which is implausible in practice. |
shell::write, shell::writeln, shell::write_err, shell::writeln_err | Partially implemented | The stderr variants are present. The stdout variants (write and writeln) were not added because the existing println from utility_natives covers the common case and the inline-write idiom is sufficiently rare to defer. |
shell::file_exists | Implemented | Follows symlinks; the no-follow variant is not yet exposed but can be added if a use case surfaces. |
shell::read_file | Implemented | Returns Text directly and traps on I/O failure or non-UTF-8 content via NativeError, matching the shell::run_checked error pattern. The originally proposed Result<Text> return type was reduced to a plain trap because the language does not yet have a generic Result type; the design decision was to keep the consistent trap-on-error pattern across the bundle rather than introduce Result solely for file I/O. |
shell::write_file, shell::append_file | Implemented | Same trap-on-error pattern as read_file. |
shell::read_lines | Not implemented | The per-line iteration use case is served by read_file plus host-side splitting; a dedicated native can be added when the per-line trap-on-error semantics become a real requirement. |
shell::arg, shell::arg_count argv correctness | Corrected | The pair read std::env::args directly, so a script saw keleusma, run, and the script path ahead of its own arguments with no stable offset. They now report a script argument vector installed by the CLI (set_script_args): index zero is the script path, one onward the positional arguments. The CLI collects positionals for both keleusma run and shebang invocation and honours a -- terminator. |
shell::run stderr capture | Closed | shell::run captures and discards stderr. The new shell::run_full returns (exit_code, stdout, stderr) for callers that need the command’s diagnostic stream. |
The trap-on-error convention is documented in the per-native contracts in STANDARD_LIBRARY.md. A future generic Result type or refinement-newtype wrapper could replace the trap pattern; that is a language-design question rather than a bundle-design one.
Net assessment
The current bundle is adequate for both one-shot and daemon-shaped workloads. The three V0.2.1 additions (sleep, time, file I/O) closed the gaps that previously made the daemon use case awkward. A loop daemon can now pace itself without forking, read configuration from files, write log records to files or stderr, and reason about elapsed time, all without delegating to subprocesses.
The signed-and-encrypted delivery model is fully usable in both atomic and productive-divergent modes today.
Open recommendations
The convenience natives proposed by the initial audit (pid, hostname, setenv, pwd, cd, run_timeout) are all implemented and listed under Present capabilities. The remaining convenience item is small and blocks nothing.
| Priority | Native | Use case |
|---|---|---|
| Low | shell::read_lines(path: Text) -> Array<Text> | Common per-line iteration; today scripts read the whole file and split host-side. |
Limitations that remain
These are properties of the stock bundle, not missing natives. They matter because the Shell bundle grants ambient process authority, so a host that ships it to untrusted scripts inherits the following caveats. A host that needs confinement or proven bounds should register a curated subset or its own narrower natives rather than the full bundle.
- WCET and WCMU are not bounded for effectful natives.
shell::run,shell::run_full,shell::run_checked, andshell::run_timeoutspawn arbitrary subprocesses whose time and memory the verifier cannot model. The static worst-case bounds the language otherwise guarantees do not extend across these calls.shell::run_timeoutcaps wall-clock time per call but not memory, and the cap is dynamic rather than statically verified. - Unbounded host-heap allocation.
read_file,run, andrun_fullallocate output buffers sized by the file or subprocess, on the host heap, outside the script arena’s budget. A large file or chatty subprocess can exhaust host memory. shell::exitterminates the host process. It callsstd::process::exit, bypassing VM teardown and any host-side cleanup. This is appropriate for a standalone CLI script but hazardous for an embedder that runs scripts inside a larger process.- Determinism is abandoned.
getenv,run,now_unix_ms,hostname,pid,pwd, and the filesystem natives all read mutable external state. Scripts using them are not reproducible. read_filerequires UTF-8. Non-UTF-8 file content traps rather than returning bytes; the bundle has no binary-file accessor.set_script_argsis thread-local. A host that callsset_script_argson one thread and runs the script on another observes thestd::env::argsfallback rather than the installed vector. The CLI runs both on the main thread, so this is invisible there but is a constraint for multi-threaded embedders.
Net judgment on the daemon use case
stddsl::Shell is now rich enough to write substantial devops and sysadmin loop daemons in Keleusma. The use cases that work today: signed-and-encrypted artefacts deployed to operator-controlled hosts, executing operational logic in a productive-divergent loop with built-in rate limiting via --tick-interval, reading configuration files, writing log records to stderr or to disk, exiting cleanly via shell::exit. The trap-on-error pattern for I/O failures gives daemon scripts fail-fast behaviour when the host filesystem disagrees with expectations.
Why Was My Program Rejected?
Navigation: Guide | Documentation Root
Keleusma’s verifier rejects programs that the WCET and WCMU analyses cannot prove bounded. This is intentional. The language’s value proposition is definitive bounds on execution time and memory, and the safest place to draw the boundary is the analysis’s current capability. See LANGUAGE_DESIGN.md for the full statement.
This document maps verifier error messages to root causes and proposes rewrites. The error messages are the actual strings produced by src/verify.rs and src/compiler.rs. When the verifier rejects a program, search this document for a substring of the error message.
Rejection Taxonomy
Rejected programs fall into two categories, distinguished by whether the rejection is fundamental or analytical.
First category: provably unbounded. The construct admits unbounded execution at runtime by construction. No future verifier improvement will admit it without an external attestation, because the bound does not exist. The remedy is to rewrite the program in a bounded form.
Second category: bounded but not yet proven. The runtime behavior is bounded in fact, but the static proof has not been implemented. A future analysis can move such programs into the admitted set without changing the surface language. The remedy is to rewrite the program in a form the present analysis can handle, or to wait for a future verifier extension.
The categories are coherent because the language treats rejection as the safety property: a program admitted by Vm::new is one whose bound is proved, not one whose bound exists in principle. See LANGUAGE_DESIGN.md for the architectural rationale.
Common Rejection Messages
MakeRecursiveClosure
type error: closures are not supported; V0.2.0 admits only direct calls and
trait dispatch under the conservative-verification stance. Rewrite as a
top-level fn or trait method.
Category. First. V0.2.0 Phase 4 retired the closure family entirely: the closure opcodes are gone from the Op enum, the Value::Func runtime variant is gone, and the type checker rejects Expr::Closure directly. The diagnostic surfaces from the type checker rather than the load-time verifier; the rejection moves earlier in the pipeline so the error message names the construct rather than the lowered opcode.
Trigger. A let binding refers to itself by name, producing a closure value that captures its own environment slot.
let factorial = |n: Word| if n <= 1 { 1 } else { n * factorial(n - 1) };
Rewrite. Locals in Keleusma are immutable; accumulation across a loop requires either the data segment, which is itself accessible only from a loop-classified entry point, or a host-supplied native that performs the fold. Two structural rewrites apply.
The first is to reclassify the entry point as loop and accumulate across iterations through a data block.
data state { result: Word }
loop main(input: Word) -> Word {
state.result = state.result * input;
let _next = yield state.result;
state.result
}
The host must initialize state.result to a meaningful starting value through the initial_data vector passed to Vm::replace_module (or through the script’s own init block) before driving the script.
The second is to register a host-side fold native and call it from a fn.
use math::fold_product
fn main() -> Word {
math::fold_product([1, 2, 3, 4, 5])
}
The choice depends on whether the iteration count is unbounded (the host drives loop) or finite and known at compile time (the host registers a fold native).
First-class function references
first-class function references are not supported in V0.2.0; rewrite `name` as
a direct call site or as a trait-bounded generic
Category. First. V0.2.0 Phase 4 retired the Op::PushFunc and Op::CallIndirect opcodes alongside the closures they served. A bare reference to a top-level function name in a value position (rather than a call position) used to compile to Op::PushFunc; the compiler now rejects the pattern with the diagnostic above.
Trigger. A let binding holds a function value, or a function value flows through an argument.
fn increment(x: Word) -> Word { x + 1 }
fn main() -> Word {
let f = increment; // rejected: first-class function reference
f(5)
}
Rewrite. Replace the indirect dispatch with a direct call or a trait method.
fn increment(x: Word) -> Word { x + 1 }
fn main() -> Word {
increment(5)
}
For compositional patterns that previously used first-class functions, V0.2.0 admits trait-bounded generics whose impls dispatch statically through monomorphization. See LANGUAGE_DESIGN.md for the trait surface.
Loop Iteration Bound Not Extractable
loop at instruction <ip> has no statically extractable iteration bound; strict
mode requires loops with fall-through bodies to match the canonical for-range
pattern
Category. Second. The runtime loop count may be bounded by a runtime-known value, but the present verifier extracts the iteration count only from the canonical for i in 0..N shape with N a compile-time constant.
Trigger. A for loop iterates over a range whose end is a parameter or a function-call result.
fn process(n: Word) -> Word {
for i in 0..n { ... }
0
}
Rewrite. Use a compile-time constant bound, or iterate over an array whose length is known.
fn process() -> Word {
for i in 0..10 { ... }
0
}
When the bound is genuinely runtime-known, the program is outside the safe-verification surface today and may either wait for the loop-bound inference to extend or ship through Vm::new_unchecked with the host accepting the unbounded risk.
Recursive Call Detected
recursive call detected during WCMU topological sort
Category. First. Direct or mutual recursion in fn or yield functions is rejected by language design; only loop admits cyclic execution and only through the productive RESET cycle.
Trigger. A fn calls itself directly or transitively through another fn.
fn count_down(n: Word) -> Word {
if n <= 0 { 0 } else { count_down(n - 1) }
}
Rewrite. As with the recursive-closure case above, the rewrite depends on whether the iteration count is bounded. For a compile-time-bounded count, use a for loop and structure the computation so the result is determined by the iteration count rather than by accumulation. For an unbounded count, move the cyclic behavior into the top-level loop block, where the productivity rule admits it.
The pure-functional rewrite for a count-down is a no-op when the script does not need the per-step output.
fn count_down(n: Word) -> Word {
for _ in 0..n { let _step = 1; }
0
}
When the per-step output is needed, accumulate through the data segment in a loop script as shown in the recursive-closure rewrite above.
Stream Block Missing Yield
Stream block must contain at least one Yield
Category. First. A loop function must yield on every iteration to satisfy the productivity guarantee. A loop function whose body contains no yield admits unbounded silent computation.
Trigger. A loop declaration with a body that does not call yield.
loop main(input: Word) -> Word {
input * 2
}
Rewrite. Add a yield expression to the loop body.
loop main(input: Word) -> Word {
let doubled = input * 2;
let _next = yield doubled;
doubled
}
Reentrant Block Missing Yield
Reentrant block must contain at least one Yield
Category. First. A yield-classified function must contain a yield expression on every path or the classification is wrong.
Trigger. A function declared yield but whose body never yields.
Rewrite. Either add a yield expression to the body, or change the classification to fn if the function actually returns directly.
Resource Bounds Exceeded
verify_resource_bounds: arena capacity <cap> bytes is below WCMU bound
of <wcmu> bytes
Category. Second in spirit, first in effect. The program is bounded in memory, but the configured arena is too small for the bound.
Trigger. Either the arena was configured by hand and is too small, or the script’s WCMU exceeds expectations.
Rewrite. Use auto_arena_capacity_for to size the arena from the module, or increase the explicit capacity.
let cap = keleusma::vm::auto_arena_capacity_for(&module, &[])?;
let arena = Arena::with_capacity(cap);
let vm = Vm::new(module, &arena)?;
When the WCMU is itself surprising, inspect verify::module_wcmu output per chunk to identify the high-cost path. See examples/wcmu_basic.rs for the inspection pattern.
Block Boundary Errors
EndIf at <ip> with no matching If
EndLoop at <ip> with no matching Loop
Break at <ip> outside any Loop block
Category. First. Bytecode-level block boundaries are inconsistent. These messages indicate a bug in the source-to-bytecode pipeline rather than a user-program issue. If a Keleusma user encounters one of these, the issue is a compiler bug; please file an issue against the project.
When the Surface Compiles but the Verifier Rejects
The conservative-verification stance accepts that the surface language is broader than the verifier’s admittance set. A program that lexes, parses, type-checks, and compiles successfully may still be rejected at Vm::new. This is the second category in action: the language describes the construct so the verifier can reject it precisely, rather than approximately.
The standard response is to rewrite the program. The alternative responses are these.
- Use
Vm::new_uncheckedand accept the unbounded risk explicitly. This is intentional misuse outside the WCET contract and is documented as such. - Wait for a future verifier improvement. BACKLOG.md tracks pending verifier extensions; B3 closures and B14 CallIndirect flow analysis are V0.1-era entries that V0.2.0 Phase 4 superseded by removing the closure surface entirely.
- File an issue with the rejected program if you believe the analysis should admit it. Worked examples are valuable for prioritizing analysis improvements.
Cross-References
- LANGUAGE_DESIGN.md is the canonical statement of the conservative-verification stance.
- EXECUTION_MODEL.md describes the structural verifier’s operation.
- BACKLOG.md tracks future verifier extensions.
src/verify.rscontains the actual rejection logic and error message strings.
Security Policy
Navigation: Guide | Documentation Root
Operator-facing guide for the V0.2.1 strict-mode signing and encryption policies. Covers key generation, policy activation, deployment scenarios, and the trust model.
Audience
Operators deploying keleusma-cli in environments where bytecode execution must be cryptographically constrained. Examples include locked-down production servers, air-gapped workstations, regulated workflow execution, and embedded fleet deployments. The mechanisms described here are optional and additive; the V0.2.0 permissive behaviour remains the default.
The four policy states
The CLI carries two independent strict modes. Either may be active in any combination.
| Signing gate | Encryption gate | Policy summary |
|---|---|---|
| Inactive | Inactive | V0.2.0 permissive default. Accepts source files, unsigned bytecode, signed bytecode with --verifying-key, encrypted bytecode with --decryption-key. |
| Active | Inactive | Source files and unsigned bytecode rejected. Signed bytecode admitted only when the signature validates against an enrolled signer. The --verifying-key command-line argument is rejected. |
| Inactive | Active | Unencrypted bytecode rejected (signed or unsigned). Encrypted bytecode admitted only when an enrolled decryption key matches the artefact’s recipient identifier. The --decryption-key command-line argument is rejected. |
| Active | Active | Strict signing AND strict encryption. Bytecode must be both signed by an enrolled signer and encrypted to an enrolled recipient. |
Activating strict signing
Three knobs activate strict signing, in precedence order.
KELEUSMA_TRUSTED_KEYS_DIRenvironment variable points at a directory of*.pubfiles. Each file holds a 32-byte Ed25519 verifying key.- Platform-conventional directory:
/etc/keleusma/trusted_keyson Unix-like systems,%PROGRAMDATA%\keleusma\trusted_keyson Windows. Used when the environment variable is unset. KELEUSMA_REQUIRE_SIGNED=1environment variable forces strict mode even with an empty trust store. Fail-closed for everything.
Strict signing activates when the trust store is non-empty OR when the force-strict variable is set.
Discovery is fail-closed. A malformed key file (wrong size, invalid Ed25519 encoding) causes the CLI to refuse to start with a clear diagnostic. This prevents partial-trust-list edge cases.
Activating strict encryption
The encryption gate uses parallel mechanisms. Three knobs in precedence order:
KELEUSMA_DECRYPTION_KEYS_DIRenvironment variable points at a directory of*.seedfiles. Each file holds a 32-byte X25519 private key.- Platform-conventional directory:
/etc/keleusma/decryption_keyson Unix-like systems,%PROGRAMDATA%\keleusma\decryption_keyson Windows. KELEUSMA_REQUIRE_ENCRYPTED=1environment variable forces strict encryption mode even with an empty decryption-key store.
Strict encryption requires the encryption Cargo feature to be enabled on the runtime. The keleusma-cli binary ships with the feature on; embedders building their own runtime opt in explicitly.
Key generation
The keleusma keygen command generates 32-byte seed and 32-byte public-key files for either signing (Ed25519) or encryption (X25519). The two key kinds are not interchangeable.
# Ed25519 signing keypair (default).
keleusma keygen --seed sign.seed --public sign.pub
# Equivalent explicit form.
keleusma keygen --kind signing --seed sign.seed --public sign.pub
# X25519 encryption keypair.
keleusma keygen --kind encryption --seed enc.seed --public enc.pub
The seed file is the private half and must be treated as a secret. On Unix systems, keygen tightens permissions on the seed file to mode 0600 (owner read/write only) as a defence-in-depth measure. The public-key file is safe to distribute to verifiers (for signing) or compilers producing artefacts for this host (for encryption).
keygen refuses to overwrite an existing seed or public-key file. Rotation requires explicit removal of the old files first.
Compiling artefacts
The keleusma compile command produces unsigned, signed, or signed-and-encrypted artefacts.
# Unsigned. Will not run under strict signing.
keleusma compile script.kel -o script.kel.bin
# Signed. Source must declare the entry function with the `signed` modifier.
keleusma compile script.kel --signing-key sign.seed -o script.kel.bin
# Signed and encrypted to a specific recipient.
keleusma compile script.kel \
--signing-key sign.seed \
--encryption-key recipient.pub \
-o script.kel.bin
Encryption requires signing. The wire format ties the two together because the signature covers the encrypted body; an adversary cannot strip the encryption layer and substitute cleartext without invalidating the signature.
Running artefacts
The keleusma run command executes a compiled artefact. The CLI auto-detects the bytecode shape from the framing header.
In permissive mode (no enrolled keys, no force-strict flag):
# Unsigned bytecode runs unconditionally.
keleusma run script.kel.bin
# Signed bytecode runs if the signature validates against --verifying-key.
keleusma run script.kel.bin --verifying-key sign.pub
# Encrypted bytecode runs if the signature validates AND the decryption key matches.
keleusma run script.kel.bin --verifying-key sign.pub --decryption-key host.seed
In strict mode, the command-line key flags are rejected. The CLI uses only enrolled keys from the system-managed trust stores.
Deployment scenarios
Air-gapped office distribution
A head office distributes operational scripts to remote employees on air-gapped workstations.
Initial provisioning (trusted personnel, on-site):
- Generate per-employee X25519 keypairs at the head office. Deliver each employee’s private key to that employee’s workstation through trusted personnel.
- Generate the head office’s Ed25519 signing keypair. Keep the seed at the head office. Distribute the public key to each workstation.
- Install the
keleusma-clibinary on each workstation. - Enrol the head office’s verifying key into each workstation’s
/etc/keleusma/trusted_keys/directory. - Enrol that workstation’s specific X25519 private key into
/etc/keleusma/decryption_keys/.
Both strict modes are now active on each workstation.
Per-script distribution (head office):
keleusma compile script.kel \
--signing-key head_office.seed \
--encryption-key workstation_42.pub \
-o script_for_42.kel.bin
The artefact is encrypted to workstation 42 specifically. It will not decrypt on any other workstation even if intercepted in transit.
Delivery: courier-delivered storage media (USB sticks, removable drives). The shebang-equipped artefact is executable directly through the operating system’s shell.
Execution (workstation 42):
./script_for_42.kel.bin
The CLI enforces both strict modes automatically. The script runs if signed by the head office and decryptable with workstation 42’s enrolled key. Otherwise it is rejected.
Captured artefact: a stolen artefact on the delivery channel is opaque ciphertext. The adversary cannot read its contents.
Compromised workstation: a compromised workstation reveals only its own private key. Artefacts intended for other workstations remain confidential.
Production server fleet
A production environment runs only release-team-signed builds.
# On each production server, install the build server's verifying key:
sudo cp /tmp/release_key.pub /etc/keleusma/trusted_keys/release.pub
# The CLI now enforces strict signing.
keleusma run /opt/keleusma/scripts/job.kel.bin
Local operators on the production server cannot run unauthorised scripts; the strict-signing policy rejects them. The --verifying-key command-line argument is rejected, preventing local relaxation.
Regulated workflow execution
A medical informatics pipeline must demonstrate to auditors that only validated workflow scripts ran on patient data.
- Each workflow version is signed by the approval team.
- Production processing nodes carry only the approval team’s verifying key in their trust store.
- Audit logs (host-side, outside the script) record which signed bytecode hash ran which input.
The strict-signing gate ensures rogue scripts cannot bypass the approval process.
Kiosk or quarantine deployment
A kiosk that should run only specific pre-installed scripts (and reject everything else):
export KELEUSMA_REQUIRE_SIGNED=1
export KELEUSMA_REQUIRE_ENCRYPTED=1
# No keys enrolled. No bytecode admissible. The kiosk is locked.
Combine with an enrolled key store to allow specific signed and encrypted bytecode while keeping the strict-mode posture.
Daemon deployments and tick-interval cadences
The CLI’s productive-divergent loop runner is the primary path for long-lived signed-and-encrypted daemon workloads. The --tick-interval <duration> flag rate-limits the loop. See the CLI README for the flag reference and the script-side natives shell::set_tick_interval and shell::tick_interval.
Fail-fast configuration
The setter native can fail at runtime if the supplied string is not a valid humanized duration. Call shell::set_tick_interval at the top of the loop body so a malformed argument surfaces on the first iteration and the daemon terminates before any operational state is built up. The recommended pattern is:
loop main(tick: Word) -> Word {
let _ = shell::set_tick_interval("1s");
// Operational logic from here.
...
}
A daemon that calls the setter mid-loop based on a runtime decision can mask a configuration error for an extended period. Operators should treat the interval as a static configuration knob.
Memory residency as a feature
Deliberately keeping a Keleusma loop daemon in memory addresses a class of operational scenarios where allocation failures are expected. When the host is under memory pressure such that fresh process launches fail, an already-resident daemon retains its mapped pages and continues to execute. This is a documented use case for the CLI loop runner.
Pattern: run a Keleusma loop daemon with a small footprint (single-digit megabytes of resident set size; see METRICS.md) and a long tick interval. The daemon remains scheduled even when the system cannot launch new processes, and is available for diagnostic or recovery work that requires already-loaded code.
The default zero-interval behaviour spins as fast as the script yields, which is appropriate for batch processing but not for memory-resident-on-call deployments. Set an explicit interval (--tick-interval 30s, --tick-interval 5m, depending on cadence needs) when running as a memory-resident daemon.
Failing cleanly under memory pressure
The runner also fails cleanly when a host genuinely cannot satisfy a program’s memory. A verified program’s worst-case arena is bounded and known, so the CLI sizes the arena to that bound and allocates it fallibly. A host that cannot provide it exits with an out of memory: this program needs an N-byte arena diagnostic and a non-zero status rather than aborting with SIGABRT, so a supervisor or orchestrator can observe and react. To provision or qualify a host in advance, keleusma run <file> --print-memory reports the program’s worst-case arena footprint, the total along with its persistent and transient parts, and exits without running.
Cadences longer than four weeks
The --tick-interval flag rejects intervals longer than four weeks. Operators with monthly or quarterly cadences have two options.
External scheduler. Use cron, systemd timers, or the equivalent on the deployment platform to invoke a one-shot Keleusma script at the desired cadence. This approach is appropriate when the only requirement is timing.
Noop yield cycles. Run a Keleusma loop daemon with a shorter interval (one hour, one day) and count internal ticks against the desired cadence. Most iterations do nothing but yield. Periodic iterations perform the actual work.
loop main(tick: Word) -> Word {
let _ = shell::set_tick_interval("1d");
// Tick counts days. Real work runs every thirtieth day.
let _ = if tick % 30 == 0 {
// Operational logic.
...;
};
let _ = yield tick;
tick
}
This approach is appropriate when memory residency is part of the requirement (see above). It also preserves the signed-and-encrypted delivery model end-to-end; the script never exits and is never relaunched.
Key compromise, revocation, and rotation
The model rests on two private keys whose consequences on compromise are very different. An operator should understand the asymmetry and protect each key accordingly.
The two private keys and their blast radii
- The signing seed (
sign.seed, held by the producer) is the critical secret. Every host that enrols the corresponding verifying key trusts anything signed by it. A leaked signing seed lets an adversary forge artefacts that pass strict-signing verification on the entire fleet, a total loss of authenticity across every deployment that enrolled the key. Protect it the most. Keep it on an offline or air-gapped signing host, restrict access to the smallest possible set of operators, and prefer a hardware security module or equivalent where the threat model warrants it. This is the single most consequential secret in the system, and the leaked-signing-key case is the one that undermines the whole model. - A decryption seed (
dest.seed, held by a recipient) has a bounded blast radius. A leaked decryption seed lets an adversary read artefacts encrypted to that one recipient, and only that recipient. Artefacts for other recipients stay confidential, as noted under the air-gapped scenario above. There is no forward secrecy at the recipient-key level, so a leaked decryption seed also exposes any past artefacts encrypted to it that an adversary retained, not only future ones. Generate each recipient’s keypair on the recipient host so the seed never transmits, and rely on the mode-0600 permissionskeygensets on Unix.
No expiry, and revocation is manual
Enrolled keys are raw Ed25519 and X25519 keys with no embedded validity period, so an enrolled key is trusted until an operator removes it. There is no certificate-revocation list and no online revocation check; that is inherent to the air-gapped enrolled-key model. Revoking a key therefore means removing its file from the trust store or decryption-key store on every affected host, one host at a time, and the revocation takes effect on a host only once that host has been updated. Plan for this latency. A fleet-wide revocation is a deployment operation, not an instant broadcast.
Rotation procedure
Rotate on a schedule as a matter of policy, and immediately on suspected compromise. Signing-key rotation is:
- Generate a new signing keypair on the signing host (
keleusma keygen --seed sign.v2.seed --public sign.v2.pub). - Distribute the new verifying key and enrol it in every host’s trust store, authenticated out of band (see the residual-risk note on enrolment authenticity), keeping the old key enrolled during the transition.
- Re-sign the artefacts that must remain runnable with the new key.
- Once every host carries the new key and every live artefact is re-signed, remove the old verifying key from every trust store. The old signing seed is then powerless and should be destroyed.
Decryption-key rotation is symmetric. Generate a new recipient keypair on the recipient host, distribute the new public key to producers, re-encrypt the artefacts that recipient still needs, and remove the old decryption seed once nothing in flight is encrypted to it.
Trust model
Trusted components:
- The
keleusma-clibinary itself. An adversary who replaces the binary defeats all policies; binary integrity is the operator’s responsibility (filesystem permissions, executable signing at the OS layer, integrity-monitoring tools). - The trust-store directories. An adversary who modifies the enrolled-keys files can extend the trust list. Use filesystem permissions (root-owned, mode 0644 on key files, mode 0755 on the directory) to prevent unprivileged tampering.
- The host operating system. Adversaries with root or kernel-level access can defeat any user-space mechanism.
Untrusted components:
- Delivery channels (network, USB, courier). Bytecode in transit is assumed to be inspectable and potentially substitutable.
- Bytecode files on disk. An adversary who replaces a bytecode file cannot get it to run unless the substitute also passes the active policies.
- Local operators on the deployment host. Unprivileged users cannot relax the policy; the system-managed trust stores override command-line arguments.
Known residual risk:
- An adversary with memory access on the running runtime can recover decrypted plaintext from RAM after the decryption step. Closing this gap requires hardware isolation (TrustZone-M on Cortex-M55, equivalent on other platforms). This work is tracked as B24 in
docs/decisions/BACKLOG.md. - Side-channel attacks against the cryptographic operations (timing, power analysis) are out of scope for the current implementation. The pure-Rust crypto crates (
ed25519-dalek,x25519-dalek,aes-gcm) provide constant-time implementations of the core primitives but the broader host environment may leak through other channels. - No anti-replay or freshness binding. A signature attests origin and integrity, not recency. An artefact carries no timestamp, sequence number, or nonce that the host checks, and the host keeps no record of artefacts it has already run, so an adversary who retained a previously valid artefact can re-deliver it and the host will verify and run it. Where running a superseded but once-valid artefact is harmful, for example an old workflow script, enforce freshness outside the model: deliver over an integrity-controlled channel, rotate the signing key between supersessions so the old artefact stops verifying, or track artefact hashes host-side.
- Classical, not post-quantum, cryptography. Ed25519 and X25519 are not quantum-resistant. An adversary who records encrypted artefacts today could decrypt them once a cryptographically relevant quantum computer exists, the harvest-now-decrypt-later threat, which matters for long-lived confidential payloads. The wire format reserves a
scheme_idbyte for migration to a post-quantum scheme without an ABI break, but no such scheme is implemented today. - Enrolment authenticity is the operator’s responsibility. The trust stores protect against tampering after enrolment, but the initial public-key exchange must be authenticated out of band. Enrolling a verifying key an adversary substituted makes the fleet trust the adversary’s signatures, and encrypting to a recipient public key an adversary substituted discloses the payload to the adversary. Verify key provenance, by fingerprint comparison over a separate channel, a trusted courier, or an existing trust anchor, before enrolment.
- Metadata is not concealed. Artefact size, delivery timing, and the
recipient_key_idcarried in an encrypted artefact’s header are visible to anyone who observes the channel. The contents are protected; the fact and shape of a delivery are not. This is minor for physical air-gapped transfer and more relevant over an observable network.
Cross-references
- R49 in RESOLVED.md for the strict-mode signing gate design record.
- R50 in RESOLVED.md for the encryption layer design record.
- R42 in RESOLVED.md for the underlying Ed25519 signing infrastructure.
- WIRE_FORMAT.md for the wire-format details of signed and encrypted artefacts.
- EXECUTION_MODEL.md for the load-time pipeline including the decryption step.
- B24 in BACKLOG.md for the future hardware-isolation work.
Big Numbers
Navigation: Guide | Documentation Root
The V0.2 numeric overflow construct binds the high and low halves of an i128 intermediate result on every checked arithmetic operation. This is the load-bearing mechanism for multi-digit arithmetic against the bundled Word type, which is a 64-bit signed integer. This guide walks through the pattern with the worked example in examples/scripts/09_big_numbers.kel.
What the construct exposes
The construct’s surface form is
op_expr {
ok(v) => arm_body,
overflow(h, l) => arm_body,
underflow(h, l) => arm_body,
}
The runtime computes the true result of op_expr in i128, splits it into a high and a low half, and pushes (high, low, flag) on the operand stack. The compiler dispatches on flag to one of three outcome classes (ok, overflow, underflow) and binds the pattern variables in that arm’s body to the corresponding slot values. The ok arm binds a single Word against the in-range result; the overflow and underflow arms bind two Word values against the high and low halves.
The high half is the carry-out for additive operations and the upper 64 bits of the true product for multiplication. Together with the low half (the wrapped i64 result) this is sufficient to express chained multi-digit arithmetic.
Pattern: full 64x64 -> 128-bit multiplication
fn mul_full(a: Word, b: Word) -> (Word, Word) {
a * b {
ok(v) => (0, v),
overflow(h, l) => (h, l),
underflow(h, l) => (h, l),
}
}
When the true product fits in Word the ok arm fires and the high half is zero by definition. When the product needs more than 64 bits the construct routes to the overflow arm and binds the upper 64 bits of the true product to h.
Worked example: 2^32 * 2^32 = 2^64. The true product is the bit pattern 0x0000000000000001_0000000000000000 interpreted as a 128-bit value. The high half is 1, the low half is 0. The script’s main returns 1 confirming this decomposition.
Pattern: addition with carry-out
fn add_with_carry(a: Word, b: Word) -> (Word, Word) {
a + b {
ok(v) => (0, v),
overflow(_, l) => (1, l),
underflow(_, l) => (1, l),
}
}
The carry-out is derived from the overflow class rather than from the high half directly. For signed Word addition the high half of the i128 intermediate is the sign extension of the i64 wrap, not the unsigned carry; the cleaner abstraction is to read the carry from the outcome class. The wrapped result remains in the low slot.
A chained two-digit add propagates the carry to the next-higher position:
fn add_two_digits(a_hi: Word, a_lo: Word, b_hi: Word, b_lo: Word) -> (Word, Word) {
let (carry_lo, sum_lo) = add_with_carry(a_lo, b_lo);
let (_, partial_hi) = add_with_carry(a_hi, b_hi);
let (_, sum_hi) = add_with_carry(partial_hi, carry_lo);
(sum_hi, sum_lo)
}
For a full 256-bit add, repeat the same step over four Word positions, threading the carry through each.
Caveats
The Word type is signed i64. Treating it as an unsigned u64 digit in multi-digit arithmetic requires care:
-
The i128 intermediate’s high half reflects signed arithmetic. For two non-negative operands whose sum exceeds
i64::MAX, the high half is0and the low half is the wrap (a negativei64whose bit pattern matches the high bit of the unsigned sum). The carry-out is one regardless, derivable from the overflow class. -
For two operands whose sum needs more than 65 bits (impossible for
i64addition but reachable through multiplication), the high half carries genuine bits 64-127. The multiplication example demonstrates this case directly. -
Division and modulo route through dedicated
Op::CheckedDivandOp::CheckedModopcodes that compute the true result ini128and dispatch to the same(h, l, flag)shape as the other checked operations. Thei64::MIN / -1corner (true result2^63, decomposed ashigh=0, low=i64::MIN, flag=1) routes to the overflow arm; thei64::MIN % -1corner (true result0, division step overflows) likewise flags through the overflow arm withhigh=0, low=0. Division by zero continues to trap withVmError::DivisionByZerobecause the opcode fails before arm dispatch runs.
Where the pattern is and is not appropriate
The construct is appropriate when:
- The arithmetic needs to detect or recover from
Word-range overflow at well-defined points in the program. - The high half of a multiplication carries useful information (the load-bearing case for true 64x64 -> 128 products).
- The carry-out of an addition needs to thread into a higher-order digit.
The construct is not a substitute for an arbitrary-precision BigInt type. Multi-digit arithmetic at runtime through the construct works but is not ergonomically zero-cost; a future iteration may introduce a dedicated BigInt standard-library type with native arithmetic operators that compile to the chained checked operations under the hood.
Cross-references
- GRAMMAR.md, Section 7.5 — the formal grammar of the numeric overflow construct.
- LANGUAGE_DESIGN.md, “Partial Operation Handling” — the design rationale, of which checked arithmetic is one member of the construct family.
examples/scripts/09_big_numbers.kel— the worked example this guide walks through.tests/big_number_arithmetic.rs— the integration test that compiles the example and verifies the result.
Piano Roll Manual
Navigation: Guide | Documentation Root
This document is the long-form companion to the piano_roll example. The example couples a Keleusma script driving 16th-note ticks against a Rust audio host that synthesizes eight-voice polyphonic output through Simple DirectMedia Layer 3. The example is small enough to read in one sitting and dense enough to exercise the patterns that recur across Keleusma host applications.
Contents
This document carries three major sections, each addressed to a different reader.
- Composing songs is for someone who wants to write a new
.kelsong to play through the example or through an adaptation of it. The reader will learn the mental model, the data segment conventions, the per-tick body structure, and the available host native function calls. - Lifting the example is for someone who wants to copy this example into a larger application. Two paths exist. The first embeds the host loop into another program such as a game or a music editor. The second extends the example in place into a more fully featured tool. Both paths are addressed.
- Embedding patterns is for someone who wants to study the architecture as a pattern for embedding Keleusma in a different control-loop application. The piano roll was chosen as a low-stakes canonical because audio is easy to audit and because the patterns that work here transfer to more demanding domains.
How this document relates to the source
The module-level documentation comment in examples/piano_roll.rs carries the authoritative catalog of host native functions, parameter ranges, defaults, waveform codes, and data segment slot layout. This document narrates around that catalog. Where the docstring lists what is available, this document explains how to use it and why it was structured that way. A reader trying to look up the argument shape of a specific native should consult the docstring. A reader trying to understand the architecture or to write a new song should read this document.
The bundled roster contains ten songs. Songs 0 and 1 are three-channel chord-progression scripts that introduce the host. Song 2 is a five-channel Bach Prelude arrangement demonstrating the host’s native ADSR and retrigger. Song 3 is a long-form eight-channel boss-theme stress test in D minor that exercises every host native in active, inactive, and dynamic states across a ten-section dual-peak loop including a 7/8 time-signature pivot, a whole-tone snap-down gesture, three doubling techniques (stereo unison with detune, detuned octave doubling, parallel-interval harmonization in F major), and per-tick BPM updates ramping between 90 BPM and 250 BPM. Song 4 is a second full-matrix demonstration that runs the tempo under continuous sine-wave modulation between 60 BPM and 300 BPM across a 1024-tick loop body, with four iteration variations (Awakening, Descent, Malfunction, Apocalypse) cycling on the loop count over a constant D-minor chord skeleton in the manner of an algorithmic chaconne. Song 5 is a minimalist process piece in the phase-music tradition where eight channels play the same twelve-note pattern in D natural minor at different advance rates, producing inter-channel canonical relationships that drift across timescales from minutes to hours. Song 6 is a polymetric canon in G Dorian where four canonic voices share the same four-note subject but advance at different tick strides (4, 3, 5, 7 ticks per subject position corresponding to 4/4, 3/4, 5/4, 7/4 meters), producing genuine four-voice polyphonic counterpoint at a 1680-tick metric superperiod. Song 7 is a microtonal drone piece where eight voices play the just-intonation harmonic-series partials 1, 2, 3, 5, 7, 9, 11, 13 of an A2 fundamental, realised through 12-TET MIDI pitches plus integer cents-of-detune offsets, demonstrating the host’s set_detune native as a continuous full-spectrum pitch-control mechanism. Song 8 is a textbook mainstream pop song at 108 BPM in C major with relative-minor bridge and half-step modulation to D-flat major for the final chorus, demonstrating that the implementation engine handles conventional commercial-pop songwriting with the same facility as it handles the experimental songs. Song 9 is a semi-experimental loop composition with a chiptune core, presenting sixteen iteration variations across a four-by-four matrix of scale (C major, A minor, D Dorian, D Phrygian dominant) and lead waveform (Sawtooth, Square, Pulse, Triangle), each iteration a twelve-section pop-form with confusion zone, bridge, modulation, and final chorus; tempo travels 60 to 300 BPM through segmented ramps and one continuous-sine confusion zone per iteration; approximately fifty minutes per full meta-loop. See docs/extras/SONG_3_SPEC.md, docs/extras/SONG_4_SPEC.md, docs/extras/SONG_5_SPEC.md, docs/extras/SONG_6_SPEC.md, docs/extras/SONG_7_SPEC.md, docs/extras/SONG_8_SPEC.md, and docs/extras/SONG_9_SPEC.md for the full implementation specifications.
Meta-note
This document also serves as a concrete documentation example for Keleusma host applications. The shape of its sections, the depth of its prose, and the relationship between manual and source docstring are themselves the patterns. A team building a Keleusma host in another domain can adopt the same shape for their own manual.
Compositional and music theory are out of scope. The closing of the script-author section lists a few durable category names for readers who want to pursue programmatic composition further.
Composing songs
A song is a Keleusma program with the entry point loop main(input: Word) -> Word. The host calls main once at startup and then calls Vm::resume on every 16th-note tick. The script’s body runs against the current tick value, calls zero or more host native functions, and yields control. Between iterations the host arena resets, so any per-iteration arena allocations release at no cost to the script author.
Mental model
The script does not synthesize audio. The script schedules events. The host owns the synthesis state for each voice. The script writes to that state through host native function calls. The audio thread reads from that state and renders samples without ever entering the Keleusma virtual machine.
A song is therefore three distinct pieces of state working together. The data segment carries persistent per-channel position counters and sequencer-level state across ticks. The host voice state carries the instrument parameters such as waveform, envelope, and per-speaker volume. The script body decides at each tick which voices to play, silence, or reconfigure.
The init block
Every bundled song begins its loop main body with a one-shot setup block guarded by a slot named state.init. The slot is zero at startup and remains zero across hot swap because the host zeroes the data segment at every song load. The init block calls every host native that configures voice state for the song and then sets state.init to one.
The init block is the only place in the script where instrument parameters are configured. Channels start in a disabled state. The init block enables the channels the song uses and configures their waveform, envelope, and volume. Channels not mentioned in the init block remain disabled and produce no sound.
Data segment conventions
The host reserves twenty-three slots in the data segment. The first seven slots carry sequencer-level state. The remaining sixteen slots are per-channel position and remaining-tick counters for the full eight-voice channel count.
Slot zero, init, is the one-shot setup gate described above.
Slot one, loop_count, is bumped by the script when its progression wraps. Songs use this to vary their behaviour on subsequent loops. A first-time-through intro section can run only when loop_count is zero. A fade-out can begin once loop_count reaches a chosen threshold. A transposition can apply on every odd loop.
Slot two, section, is a song-section pointer. A song with a multi-part structure uses this to track which section is currently active. The value zero denotes the first section, one denotes the next, and so on. The script reads the value to dispatch to the correct note table.
Slots three through six, user0 through user3, are general-purpose slots for state the host has no opinion about. Suitable uses include a random seed, a transposition offset, a per-channel mute mask, a fill-pattern selector, or anything else the song needs to track.
Slots seven through fourteen carry idx: [Word; 8], the per-channel position counters for the full eight-voice channel count. Slots fifteen through twenty-two carry rem: [Word; 8], the matching per-channel remaining-ticks counters. The script addresses each counter through the indexed-array form state.idx[ch] or state.rem[ch] where ch is a Word in [0, 8). The compiler emits a bounds-checked indexed read or write against the underlying flat slot region; out-of-range indices trap rather than silently addressing a different counter. A script that needs to walk every channel can use for ch in 0..8 { ... state.idx[ch] ... } and the compiler lowers the iteration to direct indexed slot reads without materialising a Value::Array.
The data segment is host-owned at the schema level and script-owned at the semantic level. The host reserves the slots and zeroes them. The script decides what each slot means. The conventions above are followed by every bundled song so that the schema stays consistent across the roster.
Per-tick body structure
After the init block, each per-channel block follows the same shape. The script checks whether the channel’s remaining-ticks counter is zero. If so, it looks up the next note in the channel’s note table, calls host::play or host::silence based on whether the note is a rest, sets the remaining-ticks counter to the note’s duration, and advances the channel’s position counter. Otherwise the script decrements the remaining-ticks counter.
This pattern keeps the per-tick cost bounded. Each tick performs a constant number of native function calls plus a small amount of data segment arithmetic. The bounded-step guarantee that Keleusma provides at the language level holds throughout.
Working with sequencer state
A song that uses loop_count should bump the slot at the same boundary as the channel zero position counter, because channel zero typically holds the longest part. When channel zero’s position counter wraps to zero, the song has completed one full progression. The increment goes immediately after the wrap.
A song that uses section should advance the slot at section boundaries the song author defines. Sections might be tied to bar counts, to specific loop_count values, or to a manual schedule. The reading of section then drives the per-channel note-table lookups so that each section can have its own progression.
Hot swap and song-name announcement
The host announces the song’s title once per load through host::song_name. The init block calls the native with a static string literal. Subsequent calls with the same string are silently ignored by the host. On every hot swap the host clears the tracked name so the next song announces unconditionally.
Resources for programmatic composition
Compositional theory and musical practice are out of scope for this document. Readers who want to pursue programmatic composition further may consult the tracker-module documentation maintained by the chiptune community, surveys of algorithmic composition, and the documentation of Music Macro Language. Each of these traditions has a long history and an active community that can provide depth this document does not attempt to match.
Lifting the example
This section is for the Rust host developer who wants to take this example into their own project. Two paths are addressed. The first embeds the example into a larger application. The second extends the example into a more fully featured tool. The two paths share most of their concerns and are addressed together.
The main and run split
The example separates application chrome from the embeddable host loop. The function main carries command-line argument parsing and other process-level concerns. The function run carries the actual host work, building the Keleusma virtual machine, opening the audio device, registering host native functions, and driving the tick-and-yield cycle.
A developer embedding the example into another program copies the body of run into their own host code. The function takes no arguments today. Extending it to accept a song roster, an arena capacity, a default tempo, or alternative host native registrations is a localized change.
A developer extending the example into a fuller tool keeps run as it is and grows main. Command-line flags for choosing the starting song, an alternative tempo, or a different audio device land in main without touching run. The two functions stay distinct so that an embedder reading the source can recognize which part to copy and which part to discard.
Native registration boundary
The function register_natives carries every host-script crossing the example offers. Each entry is a separate vm.register_native_closure call with a closure that captures shared state. The pattern is verbose by design. A reader can trace any native from its name through to its effect in two reads.
A production host will likely shorten this through a macro or through a registration helper. The bundled register_library trait described in the embedding guide is the supported abstraction for that step. The example does not use it so that the data flow stays explicit on the page.
Pointer to exercises
The module-level documentation comment in the example lists ten substantial features that were intentionally left out so the example stays an example rather than a product. The list carries rough Rust-side line-of-code estimates for tremolo, filter envelope, delay, reverb, arpeggio, polyphonic voice allocation, sample playback, frequency modulation synthesis, wavetable synthesis, a real-time visualizer, and Musical Instrument Digital Interface input. A developer extending the example can pick any of these as a starting point. The estimates are rough and meant to scope effort rather than to commit to a precise count.
Data segment expansion caveats
The data state block declared in every song script defines the data-segment schema, and every song in the roster must declare the same schema. The script declares the layout; the host passes an empty initial_data vector to replace_module so the segment reinitialises to zero on each swap. A song whose data state schema differs from the currently loaded one is rejected at hot swap by the replace_module schema-hash check unless the host opts into replace_module_unchecked.
The recommendation is to settle the data segment schema in advance, before any songs are written. The host author and the song author collaborate on what slots are needed for sequencer state, per-channel counters, and any application-specific bookkeeping. Once the schema is in place, every song targets it.
Mid-project changes happen, however. A host author may need to add slots to support a new sequencer feature. The cost is small for the host author and meaningful for every song already written, because each song’s data state block must be updated to match the new schema. Mitigation strategies include scheduling schema changes to coincide with broader content revisions, reserving generous user slots up front so that schema growth happens within those slots rather than at the schema level, and documenting the schema version somewhere visible. A version stamp comment at the head of every data state block is one approach.
Cargo feature requirements
The piano-roll example requires the sdl3-example Cargo feature to build. The feature pulls in the Simple DirectMedia Layer 3 dependency and cmake-builds SDL3 from source. The example’s required-features declaration in Cargo.toml lists compile, verify, and sdl3-example; the first two are on by default. The build command is therefore cargo run --release --example piano_roll --features sdl3-example.
Static string literals (used by the bundled songs for the host::song_name call) are unconditional in V0.2.0. The retired V0.1.x text cargo feature is no longer present. A host derived from the example with a different audio backend replaces the sdl3-example requirement with whatever its own backend needs.
Embedding patterns
This section is for the developer who wants to study the example as a reference for embedding Keleusma in a different control-loop application. Audio is the chosen domain because audio is easy to audit and because real-time deadline pressure is familiar to most developers. The patterns that work here transfer to other control loops where the cost of a missed deadline or a corrupted state may be substantially higher.
Why this example was chosen as the canonical
The piano roll exercises the full Keleusma host surface. It uses a Stream block as its entry point. It maintains persistent state across ticks through the data segment. It performs deterministic-step iteration through loop main. It survives hot code swap. It coordinates two threads, one running the Keleusma virtual machine and one rendering output at a different rate. It uses host native functions to bridge between the script’s logical events and the host’s physical state.
None of these patterns are specific to audio. The same architecture serves a control loop running at any rate that schedules events on a regular cadence against host-owned state.
State separation
The example splits its state into two domains. The host-owned domain carries the audio voices, the master volume, the tick interval, and the song-name dedup cache. This state lives in Rust types behind synchronization primitives. The audio thread reads it. The script writes it through host native function calls.
The script-owned domain carries the per-channel position counters, the loop count, the section pointer, and the application-specific user slots. This state lives in the Keleusma data segment. The script reads and writes it directly. The host zeroes it at every load.
The separation is principled. Host-owned state is everything the host’s hot path needs to read without taking a Keleusma virtual machine call. Script-owned state is everything the script needs to reason about across iterations without the host caring about its semantics.
This separation generalizes. Any control loop in which a Keleusma program decides what should happen and a Rust thread enacts the decision should split state the same way. The script’s invariants live in the data segment. The host’s invariants live in Rust types behind appropriate synchronization. The native function boundary is the only crossing. The crossing is bounded, typed, and auditable, which are the same properties a serious host wants on every other boundary in its application.
Tick-and-yield boundary
The script yields once per 16th-note tick, not once per audio sample. The decision was deliberate.
A sample-rate yield is too fine. At forty-eight thousand samples per second the script would have a budget of roughly twenty microseconds per yield, which is hard to keep clear of jitter and leaves no margin for the host work that also runs on the main thread.
A very coarse-grained yield is also wrong. It would leave the host with long stretches between opportunities to swap, restart, or reconfigure, and any input the host took during those stretches would land at the next tick boundary instead of the current one. The 16th-note tick at one hundred twenty beats per minute lands at one hundred twenty-five milliseconds between yields. This is a comfortable budget for the script’s work and an acceptable latency for hot swap and command processing.
The general rule is straightforward. The tick rate should be the highest meaningful frequency at which the script makes decisions. A control loop that makes decisions every ten milliseconds should yield every ten milliseconds, not every millisecond and not every hundred milliseconds. A host that picks the wrong granularity pays a price either in latency or in budget pressure.
Hot swap semantics
The host calls Vm::replace_module only when the virtual machine is in the VmState::Reset state. The Reset state is the boundary between iterations of the Stream block. At that point the script’s stack is empty and the data segment is the only live script-owned state.
The host resets the data segment by passing a fresh zero-initialized vector to replace_module. The host also resets the host-owned voice state and the song-name dedup cache before issuing the swap. The incoming script’s init block therefore runs against a clean slate of both domains.
The relevant principle is that hot code swap is safe only when the application’s invariants live in a bounded, host-readable region. Keleusma enforces this by requiring the swap to happen at a Reset boundary. A host application embedding Keleusma in a domain other than music should respect the same constraint. Any state that needs to survive a swap belongs in the data segment, and the host should reset the host-owned domain at the same boundary so that the incoming script does not observe stale state.
Concurrency choice
The example uses a single Mutex<[Voice; 8]> shared between the audio thread and the main thread. The lock is acquired for one snapshot copy per audio callback. The contention window is microseconds.
This choice was made for clarity. A reader follows the data flow on one read. The pattern would not survive a host with hundreds of voices, where the lock would become a contention point. A production host operating in that regime would either move to per-voice atomic types, to a lock-free queue, or to a triple-buffer arrangement.
The general rule is to choose the simplest synchronization primitive that meets the deadline budget. Promotion to a more complex primitive is justifiable when profiling shows contention. It is not justified by abstract scalability concerns alone. The simpler primitive keeps the data-flow visible, which matters more for an example and often matters more in production than is granted at the design stage.
Native registration
The example registers every native function once at startup. The Keleusma virtual machine accepts native function registrations only before any script is loaded. The registration boundary is therefore the boundary between host initialization and host operation.
A host that wants different scripts to see different native function sets cannot do that within a single virtual machine instance. The available options are to register a superset and let scripts choose which to call, to use multiple virtual machine instances, or to reload the host between script changes. The example takes the first option. Every song sees the full native function surface, and a song uses the subset it needs.
The trade-off is that adding a native function later requires every loaded script to be recompiled if it uses the new function. The trade-off is acceptable for a host whose script roster is known in advance and whose natives stabilize early. Hosts whose native surface is genuinely dynamic should consider the multiple-virtual-machine pattern.
Reset convention
The host owns the reset. The script does not reset itself. When the host loads a new module, the host clears the data segment, clears host-owned voice state, and clears any other host-side per-load caches. The script’s init block then writes the values the script needs.
The convention keeps the script simple. The script author does not write defensive code for the case in which a previous song left a state machine in an unexpected configuration. The host guarantees the clean slate, and the script author can trust the guarantee.
The general principle is that reset is a host responsibility. Pushing it to the script is appropriate only if the host cannot determine which state to clear, which is rare in practice. Most host-side state has a known shape and a known reset value, and the host can clear it directly.
Closing
The piano roll’s specific opcodes do not transfer to other domains. The patterns that surround them do. State separation, tick-and-yield discipline, Reset-bounded hot swap, simple synchronization, host-owned reset, and one-shot script initialization through a flagged init block are all features of the example that an embedder in a different domain can adopt directly. The example was sized and shaped so that a reader can absorb each pattern in isolation and then assemble them into a host that fits a different application.
Roguelike Manual
Navigation: Guide | Documentation Root
Contents
- How this document relates to the source
- What this example demonstrates
- Building and running
- Controls
- Gameplay rules
- Host and script architecture
- Reading the game-tick script
- Reading the dungeon generator
- Hot reload
- Reading the player AI script
- Reading the combat script
- Reading the artificial-intelligence archetypes
- Reading the item-effect scripts
- Reading the bestiary script
- Reading the consume and descend scripts
- Exercises for the reader
- Capstone projects
- Reference tables
How this document relates to the source
The roguelike example splits its source across two directories. The Rust host code lives under examples/rogue/. The twenty-four Keleusma scripts live under examples/scripts/rogue/. The include_str! lines in examples/rogue/main.rs reference the script directory through a relative path, and the SCRIPT_DIR constant in the same file points there for the hot-reload path. This manual is the long-form companion to the example. It describes the rules of the game, the architecture of the host, the responsibilities of each script, and a graded set of exercises a reader can attempt to deepen familiarity with the embedding pattern.
The bestiary, item, and stat tables are defined inline in the host source rather than reprinted in this manual. The numbers cited in the gameplay section are stable design defaults, but the source is authoritative if they ever drift.
What this example demonstrates
The example is built around a thin-client philosophy. The Rust host does three things and three things only: capture user input, display user output, and manage Keleusma script invocation including initialisation and native plugging. Every gameplay rule lives in a Keleusma script. The host’s natives are the application programming interface boundary between display-and-input and game logic.
Seven patterns are on display.
First, a loop main script that drives every game tick. rogue_game.kel is the example’s per-turn orchestrator. The host resumes it once per player input. The body applies the player command, iterates every monster, dispatches each monster’s archetype, and ticks end-of-turn book keeping. See Reading the game-tick script.
Second, a one-shot generator script. rogue_dungen.kel writes the map through host natives. The script runs to completion once per floor descent. This is the natural use of fn main with side-effecting natives.
Third, a per-event pure function. Seven of the eight artificial-intelligence archetypes are fn main scripts that take a snapshot of monster and world state and return an action tuple. The script does not mutate the world directly; the host validates the returned action and commits the change. Several monster kinds share each archetype, and stat differences come from the host-side bestiary table.
Fourth, a loop main script that holds state across calls. The boss archetype uses the stream-chunk shape with yield. A data-segment turn counter persists across calls so the boss runs a multi-turn attack pattern. See The boss loop main shape.
Fifth, host-driven dispatch with thin scripts. The item-effect scripts are tiny match tables mapping an effect identifier to a delta plus a status code. The host applies the deltas and executes the status action. This split keeps engine-touching code in the host and gameplay rules in the script.
Sixth, hot reload. The F5 keybind re-reads every script from disk, recompiles, and atomically swaps the new modules into the running virtual machines. The world state survives the swap. See Hot reload.
Seventh, the player as an actor. rogue_player_ai.kel is shaped like every other artificial-intelligence script. The host dispatches it through the same per-actor path it uses for monsters. The player’s distinction is the source of intent, not the dispatch shape. Combat math also lives in script through rogue_combat.kel. See Reading the player AI script and Reading the combat script.
The example also demonstrates the conservative-verification discipline. Every loop in every script has a statically extractable iteration bound. Dynamic upper limits are written as fixed-bound loops with conditional bodies. Recursion is absent. The verifier accepts every shipped script.
Known deferred items
These behaviours are intentionally left as exercises rather than shipped features. Each is documented in the Exercises for the reader section with the relevant entry number.
- Sleep, Confusion, and Remove Curse scrolls emit placeholder messages. The script-side dispatch produces the correct status codes; the host-side application is deferred. See Exercise 3.7.
- Starvation tuning overshot. The combination of halved hunger cadence plus corpse drops makes starvation effectively impossible. See Exercise 5.1.
- The bestiary and gear scripts are not on the F5 hot-reload path. Both load once at startup; modders editing those scripts must restart. The pattern admits reload but the wiring is not yet in place.
- Weapon and armor names live in
WEAPON_NAMESandARMOR_NAMEShost-side constants. Monster names already moved into the bestiary script; the equivalent migration for the gear script is mechanical and is filed as Exercise 4.3. - The placeholder potion effects (Speed, Levitation, See Invisible) have script-side handlers but no host-side response. The status codes propagate; the host treats them as no-ops with a generic message.
None of these block normal play. The game is reachable from floor one to the floor one hundred exit with the shipped configuration.
Building and running
cargo run --release --example rogue --features sdl3-example
The example requires the sdl3-example Cargo feature, which pulls in the Simple DirectMedia Layer 3 dependency that powers the window and event loop. Static string literals used by the item-message system are unconditional in V0.2.0; the retired V0.1.x text cargo feature is no longer present.
The host opens a sixty-four-by-forty tile grid window. A two-row head-up display sits above the grid and a message row sits below. Each display tile is sixteen pixels square so the window is one thousand twenty-four by seven hundred and twelve pixels, which matches a sixteen-by-ten aspect ratio for the map area and fits comfortably on standard laptop displays. The procedural sprite art is authored at twenty-four pixels and downscaled to sixteen at copy time so the larger authoring size preserves the original sprite detail.
Controls
| Key | Action |
|---|---|
Arrow keys, h, j, k, l | Cardinal movement, one tile per press. |
y, u, b, n | Diagonal movement. |
| Period, Space | Wait one turn in place. |
Q | Quaff the held potion. The potion’s effect resolves immediately and the slot empties. |
R | Read the held scroll. The scroll’s effect resolves immediately and the slot empties. |
F5 | Hot reload every Keleusma script from disk. See Hot reload. |
| Escape | Quit the example. |
There is no inventory management surface. Food eats on contact. Gold piles add to the score on contact. Weapons and armor auto-equip when an upgrade is stepped over; non-upgrade weapons and armor are destroyed on contact rather than left blocking the cell. Potions and scrolls auto-pickup when the corresponding slot is empty. If the slot is full, a message describes the ground item by its disguised name and the held item by its disguised name.
The head-up display is split across two rows. The top row is the hit-point pip strip by itself. The player’s hit-point cap grows by three on every stairs descent, so at deep floors the strip can run across most of the window; giving it the whole row removes the layout collisions the prior single-row design produced at high floor counts. The second row carries, from left to right, an icon plus tier pip strip for the equipped weapon, an icon plus tier pip strip for the equipped armor, cyan depth ticks at the centre, a text readout giving the current floor number and the player’s gold-as-score counter, the held potion and held scroll icons tinted by the per-run appearance colour, and amber hunger pips on the right. The tier pip strip fills one pip per gear level on a zero through nineteen scale. Bitmap-font text rendering is local to the example through examples/rogue/text.rs.
On death or victory the game blocks gameplay input and overlays a centred panel showing the outcome title plus final floor, gold, and turn count. Any keypress while the panel is shown exits the example.
Gameplay rules
Combat
- Walking into a monster initiates a melee attack.
- The hit roll is
1d20 + attacker_skill >= 10 + defender_evasion. - A natural one is an automatic miss. A natural twenty is an automatic hit. Critical hits double the attacker’s damage input before armor is subtracted.
- Damage is
attacker_damage - defender_armor, floored at one. - Defender evasion for the player is the player’s current level. Defender armor for the player is the equipped armor’s defense value.
- Monsters with the Fast artificial-intelligence archetype act twice per turn.
Hit points, hunger, and regeneration
- The player begins at twelve out of twelve hit points.
- Hunger starts at one hundred and ticks down by one every two turns. Food restores forty hunger. At hunger zero, the player loses one hit point per turn from starvation.
- The player regenerates one hit point every ten turns when hunger is positive and current hit points are below maximum.
Levelling
- The player gains a level upon descending stairs to a new floor. Maximum hit points increase by three. Skill increases by one. Current hit points gain the same delta as maximum so the newly added slots come in full, but pre-existing damage persists.
Floors and bestiary distribution
- One hundred floors. Stairs down lead deeper. Floor one hundred has an exit tile rather than stairs down.
- Each floor has a favourite monster kind. Half the spawned monsters on a floor are of the favourite kind. The other half are drawn from the pool of monster kinds the player has already encountered on previous floors, sampled with equal weight.
- Floor one has only the floor-one favourite. Floor two onward draws from the cumulative pool.
Items
- The held potion slot and the held scroll slot each carry one item. Quaffing or reading the held item resolves the effect and empties the slot.
- Item-effect scripts decide the effect. The host applies the script’s returned deltas and status action.
- Each potion and scroll has a stable per-run hidden identity. The bottle colour or the scroll’s mock title shows in messages until the player first uses an item of that type. After first use, all future messages refer to the type by its true name.
- Slain monsters have a chance of leaving a corpse on the cell where they fell. The drop chance and the corpse’s effect on hunger and hit points come from the bestiary entry’s shape. Larger creatures yield more meat. Serpents, insects, and the mage shapes are poisonous and inflict a small hit-point penalty when eaten. Skeletons, ghosts, and slimes leave nothing behind. Stepping onto a corpse autopickups and eats it in the same turn. Players who wish to avoid a poisonous corpse should kill the offending creature in an open room rather than a corridor so they can step around the body.
Victory and death
- Stepping onto a stairs-down tile descends automatically. Stepping onto the exit tile on floor one hundred wins the game.
- Reaching zero hit points ends the game.
If the player arrives on stairs through teleportation rather than movement, the auto-descend does not fire because teleport does not pass through the movement resolver. Stepping off and back onto the stairs triggers descent normally.
Host and script architecture
The host owns all mutable game state. The map, the player, the monster table, the item table, the field-of-view buffers, and the random-number generator state all live in Rust. Scripts read inputs through function parameters and write outputs through return values. The few mutating natives are confined to the dungeon generator’s surface.
+-------------------+ +-------------------+
| examples/rogue/ | Arc<> | World state |
| host code | <-----> | (map, player, |
| (Rust + SDL3) | | monsters, ...) |
+-------------------+ +-------------------+
|
| per-virtual-machine `register_native_closure` and `vm.call(...)`
v
+------------------------------------------+
| Twenty-four Keleusma virtual machines |
| - rogue_game.kel (loop main) |
| - rogue_dungen.kel (one-shot) |
| - rogue_player_ai.kel (pure fn) |
| - rogue_combat.kel (pure fn) |
| - rogue_book_keeping.kel (pure fn) |
| - rogue_pickup.kel (pure fn) |
| - rogue_move_resolve.kel (pure fn) |
| - rogue_ai_idle.kel (pure fn) |
| - rogue_ai_chaser.kel (pure fn) |
| - rogue_ai_wander.kel (uses rng) |
| - rogue_ai_sleeper.kel (pure fn) |
| - rogue_ai_ranged.kel (pure fn) |
| - rogue_ai_fast.kel (pure fn) |
| - rogue_ai_smart.kel (pure fn) |
| - rogue_ai_boss.kel (loop main) |
| - rogue_ai_tracker.kel (loop main) |
| - rogue_ai_hunter.kel (loop main) |
| - rogue_item_potion.kel (pure fn) |
| - rogue_item_scroll.kel (pure fn) |
| - rogue_descend.kel (pure fn) |
| - rogue_consume.kel (uses natives)|
| - rogue_scroll_apply.kel (uses natives)|
| - rogue_bestiary.kel (startup load)|
| - rogue_gear.kel (startup load)|
+------------------------------------------+
The host’s role is intentionally narrow. It captures user input through the Simple DirectMedia Layer 3 event pump, displays the world state through Simple DirectMedia Layer 3 rendering, and manages the virtual machines (compile sources, build the pool, register natives, drive the dispatch). Gameplay rules live in scripts. Combat math, player input interpretation, monster behaviour, item effects, and dungeon generation are all script-side. The host’s natives are mostly primitive accessors and the orchestration glue that the scripts cannot replicate inside the verifier’s bounds.
Each virtual machine has its own arena. The arenas live for the duration of the program. Scripts call vm.call(...) per invocation; the machine resets between calls.
Modules in the host source.
| Module | Responsibility |
|---|---|
main.rs | Entry point, SDL3 setup, script compilation, event loop. |
world.rs | Map, player, monster, item, message log, field-of-view buffers. |
bestiary.rs | One hundred monster kinds organised easy-to-hard. |
items.rs | Weapon, armor, potion, scroll tables. Per-run shuffled appearances. |
tiles.rs | Procedural sprite atlas built from primitives on SDL3 textures. |
render.rs | Head-up display, tile grid with field-of-view shading, monster and item draws, message bar. |
input.rs | Keyboard-to-command translation. |
fov.rs | Recursive shadowcasting on the eight octants. |
combat.rs | Thin wrapper that samples the d20 roll, dispatches the combat virtual machine, and applies damage to the world. |
ai.rs | Pool of artificial-intelligence, item-effect, player, and combat virtual machines. |
natives.rs | Host natives the scripts call. Includes the game-tick natives (host::run_player_turn, host::monster_count, host::run_monster_ai, host::tick_book_keeping) and the dungeon-generator natives. |
Reading the game-tick script
rogue_game.kel is a loop main script the host resumes once per player input. Each turn the body applies the player’s command, iterates every monster on the floor, dispatches each monster’s artificial-intelligence script, and ticks end-of-turn book keeping. The script yields one outcome code per turn and the host reads it to decide between continuing play, regenerating the next floor, ending the run with victory, or ending the run with the player’s death.
loop main(cmd: Word) -> Word {
let player_outcome = host::run_player_turn(cmd);
let outcome = if player_outcome == 0 {
let count = host::monster_count();
for i in 0..24 {
if i < count {
host::run_monster_ai(i);
};
}
host::tick_book_keeping()
} else {
player_outcome
};
let _ = yield outcome;
0
}
This is the example’s most direct demonstration of two patterns at once.
First, loop main with persistent state. The script is a coroutine whose body re-executes per host call. The data segment carries any state the script wants to remember across calls. The boss artificial-intelligence script demonstrates non-trivial state through a turn counter; the game-tick script keeps the data segment empty because each turn is computed afresh from the world state queried through natives.
Second, the for-each monster pattern. The script iterates every monster slot with a fixed-bound for loop and skips iterations beyond the current monster count. Inside the body, host::run_monster_ai(i) performs the per-monster work. The native does the heavy lifting. It looks up the monster’s archetype, locks the artificial-intelligence pool, dispatches the matching virtual machine with the monster’s current position and the player’s position and a line-of-sight flag, releases the pool lock, and applies the returned action against the world. The script merely orchestrates the iteration.
The four natives the script consumes.
| Native | Effect |
|---|---|
host::run_player_turn(cmd) | Dispatch the player artificial-intelligence script with the player’s current position and the supplied keypress, then route the returned action through the same per-actor resolver that handles monster actions. Returns 0 to continue the turn, 1 if stairs were descended, 2 if the exit on floor one hundred was reached, 3 if the player died. |
host::monster_count() | Number of monsters currently on the floor. |
host::run_monster_ai(idx) | Dispatch the artificial-intelligence for monster idx and apply the returned action. Internally handles the Fast archetype’s two-action turn. |
host::tick_book_keeping() | Advance hunger by one on every second turn, apply starvation damage if hungry, regenerate one hit point if conditions allow, and recompute the field of view. Returns 0 if alive, 3 if the player died from starvation. |
The command codes the script and the host agree on.
| Code | Meaning |
|---|---|
| 0 | Wait |
| 1 to 8 | Move north, south, west, east, north-west, north-east, south-west, south-east |
| 9 | Descend stairs or step on exit |
| 10 | Quaff held potion |
| 11 | Read held scroll |
The fixed loop bound is twenty-four. The verifier accepts the loop because the bound is a literal. The dynamic monster count is enforced by the if i < count guard inside the body.
Reading the dungeon generator
rogue_dungen.kel is a one-shot fn main(floor: Word) -> Word invoked once per floor descent. The script lays out a rooms-and-corridors map through host natives.
The high-level shape.
- Call
host::clear_floorto reset the map and entity lists. - Place eight rectangular rooms at random positions. Room dimensions are between four and nine tiles per axis.
- Connect consecutive rooms in placement order with an L-shaped corridor between their centres. After seven corridors every room is reachable from room zero.
- Place the player at room zero’s centre.
- Place stairs down at room seven’s centre. On floor one hundred, place the exit tile instead.
- Spawn monsters per the floor distribution. Half are the floor’s favourite kind; the other half draw from the previous-floor pool.
- Spawn items. Three to five food, two to four potions, two to four scrolls, zero to one weapon and armor upgrade, four to seven gold piles.
The chain is correctness-safe because carve_room only writes the interior as floor and relies on the solid wall left by host::clear_floor for the room’s outline. Two overlapping rooms merge their floors into one connected region rather than fighting over the overlap cells. An earlier version of the carver wrote walls over the entire room rectangle before carving the interior, which would subdivide overlapping rooms and produce small unreachable pockets the chain corridor could not breach. Removing that destructive wall-fill restored connectivity at the cost of one line of code.
The verifier-driven idiom. Every loop in the script uses a fixed upper bound and a conditional body so the structural verifier accepts the iteration bound. Where the script wants a dynamic count, the loop runs to the maximum possible count and the body is guarded by if i < count. Room storage uses fixed-size arrays declared in the data segment because the verifier rejects dynamic growth.
The host natives the script consumes.
| Native | Effect |
|---|---|
host::clear_floor() | Reset every map cell to wall, drop every monster and item. |
host::map_set(x, y, tile) | Set the tile identifier at (x, y). |
host::map_get(x, y) | Read the tile identifier at (x, y). |
host::map_w(), host::map_h() | Map dimensions. |
host::place_player(x, y) | Position the player. |
host::place_stairs(x, y), host::place_exit(x, y) | Stairs down or exit. |
host::spawn_monster(kind, x, y) | Spawn from the bestiary. |
host::spawn_item(kind, subtype, x, y) | Spawn into the item table. |
host::rng_range(lo, hi) | Random integer in [lo, hi). |
host::floor() | Current floor number. |
Tile identifiers are stable. Zero is floor, one is wall, two is door closed, three is door open, four is stairs down, five is exit. The script uses these as integer literals.
Hot reload
Pressing F5 re-reads every Keleusma script from disk, recompiles each, and atomically replaces the running virtual machines. The world state is not touched; the player keeps current hit points, hunger, equipment, and floor. The next monster turn dispatches against the freshly reloaded artificial-intelligence scripts and the next stairs descent invokes the freshly reloaded dungeon generator.
The reload reads from the directory recorded at compile time through concat!(env!("CARGO_MANIFEST_DIR"), "/examples/scripts/rogue"). The initial script load uses include_str! so the example runs without filesystem access. Hot reload requires the script files to be present at the recorded path.
The reload is atomic. Every script is read, then every script is compiled. If any source fails to read or compile, no virtual machine is touched and the message log records the failure together with the offending script name. If every source compiles, every virtual machine is swapped at once.
This is the primary mechanic for a Keleusma-driven modding workflow. An author edits a script in another window, presses F5 in the running game, and observes the new behaviour immediately. Examples of common workflows.
- Tune a monster’s chase behaviour in
rogue_ai_chaser.kel, save the file, pressF5, and verify the change against the monster currently on screen. - Add a new potion effect by editing
rogue_item_potion.kel, then quaff the corresponding potion to test the new branch. - Adjust the dungeon-generation parameters in
rogue_dungen.kel, then descend stairs to generate the next floor with the new rules.
Reading the player AI script
The example treats the player as an actor with its own artificial-intelligence script. rogue_player_ai.kel is symmetric in shape with the monster archetypes. The host dispatches it once per turn through the same per-actor pattern that dispatches every monster. The only difference is the source of intent. Monster intent comes from per-archetype logic. Player intent comes from the keyboard, encoded as a small integer.
fn main(mx: Word, my: Word, cmd: Word) -> (Word, Word, Word) {
// cmd 0..=8: wait or move (eight directions)
// cmd 9: descend stairs
// cmd 10: quaff held potion
// cmd 11: read held scroll
// Returns (action, tx, ty) in the same shape monster archetypes use.
}
The host’s host::run_player_turn(cmd) native dispatches this script, then routes the returned action through the same resolver that handles monster actions. Movement and melee flow through the same MoveOrMelee path. Player-only actions (descend, quaff, read) are additional action codes that the monster archetypes never emit.
The benefit of this symmetry is conceptual rather than mechanical. A reader who understands how a monster’s turn works automatically understands how the player’s turn works.
Reading the combat script
rogue_combat.kel holds the hit and damage rules. The host samples the d20 roll from its random number generator and dispatches the combat virtual machine with attacker skill, attacker damage, defender evasion, defender armor, and the roll. The script returns (hit_kind, damage) where hit_kind is zero for a miss, one for an ordinary hit, or two for a critical hit.
fn main(
attacker_skill: Word,
attacker_damage: Word,
defender_evasion: Word,
defender_armor: Word,
roll: Word,
) -> (Word, Word)
Combat math is now a single Keleusma file that a reader can rebalance without touching the host. The Rust side of combat is the thin wrapper in examples/rogue/combat.rs, which samples the roll, dispatches the script, applies the returned damage to the world, and posts the message.
Reading the artificial-intelligence archetypes
Each monster kind in the bestiary names an artificial-intelligence archetype. The host instantiates one Keleusma virtual machine per archetype. Per monster turn, the host invokes the archetype’s virtual machine with the monster’s position, the player’s position, and a line-of-sight flag, and reads back an action tuple.
Call convention.
fn main(mx: Word, my: Word, px: Word, py: Word, sees_player: Word)
-> (Word, Word, Word)
Return tuple (action, tx, ty).
| Action | Meaning |
|---|---|
| 0 | Wait. (tx, ty) ignored. |
| 1 | Move or melee into cell (tx, ty). The host applies the move if walkable, resolves a melee attack if the cell is the player’s, and rejects non-adjacent targets. |
| 2 | Ranged attack at cell (tx, ty). The host applies the attack only if (tx, ty) is the player’s cell. |
The eight archetypes shipped with the example.
| Script | Behaviour |
|---|---|
rogue_ai_idle.kel | Waits. Used by stationary creatures. |
rogue_ai_chaser.kel | Greedy one-step chase when the player is visible. |
rogue_ai_wander.kel | Random cardinal step when the player is not visible. Greedy chase when visible. |
rogue_ai_sleeper.kel | Waits until the player enters line of sight, then chases. |
rogue_ai_ranged.kel | Retreats when adjacent. Ranged attack when in sight and out of melee. |
rogue_ai_fast.kel | Behaves like the chaser. The host double-invokes the script per turn. |
rogue_ai_smart.kel | Heuristic step on the dominant axis when the player is visible. |
rogue_ai_boss.kel | Four-turn attack pattern alternating ranged and chase. Implemented as loop main with a turn counter in the data segment so the phase persists across calls. Currently bound only to the Balrog Lord on floor one hundred. See The boss loop main shape. |
rogue_ai_tracker.kel | Remembers the player’s last seen position in the data segment. Chases directly when the player is visible; moves toward the remembered cell when out of sight. Implemented as loop main with a three-slot data segment. Bound to wraiths, specters, vampire spawns, mind flayers, and the bone devil. |
The wander script uses host::rng_range for the random direction. Every other archetype except the boss is a pure function of its inputs.
The archetypes are intentionally minimal so the patterns are easy to read. A reader who wants more elaborate behaviour writes a new script and assigns it to a bestiary entry through the AiKind enumeration. The exercises section below works through this case.
The boss loop main shape
The Balrog Lord’s artificial intelligence is the example’s demonstration of loop main. Where every other archetype is a one-shot fn main, the boss script declares a data segment with a turn counter and yields one action per host call.
data state {
turn: Word,
}
loop main(input: (Word, Word, Word, Word, Word)) -> (Word, Word, Word) {
// Body computes the action.
// ...
state.turn = state.turn + 1;
let _ = yield action;
(0, 0, 0)
}
The five-tuple input packs the same five fields the other archetypes receive as separate arguments. Packing into a tuple is necessary because vm.resume accepts exactly one Value. The body executes the standard ranged-then-chase decision based on state.turn % 4, increments the counter, and yields the action.
The host dispatches the boss differently from the other archetypes. The first turn calls vm.call(...); every subsequent turn calls vm.resume(...). Each call or resume runs the body once from its current position. On reaching the trailing (0, 0, 0) expression after yield, the virtual machine emits VmState::Reset. The host walks past Reset and calls vm.resume again to drive the body through its next iteration. The result is one yielded action per host-driven logical turn.
The data segment persists across calls so state.turn increments monotonically. Hot reload resets the data segment, so the four-turn pattern restarts from phase zero whenever the script is replaced.
Future archetypes with multi-turn behaviour follow the same pattern. A guardian that patrols between waypoints, a necromancer that periodically spawns minions, or a hydra that grows new heads as it takes damage all fit naturally into loop main with state.
Restarting a loop main script after game-over
The same Reset semantics that drive the per-turn cadence also give the example a clean replay path on game-over. The host does not rebuild the boss, tracker, or hunter virtual machines when the player presses R to start a new run. It zeroes their data segments and leaves the virtual machines parked at their current yield point. On the next dispatch the body resumes, observes the freshly reset state, and produces the first action of a new run identical in shape to the first action of the original run. The wrap from the trailing tuple back to the top of the body happens through the existing Reset boundary that already separates one turn from the next.
The data-segment reset is the only step required because the body re-reads the world through natives on every iteration. Persistent state lives in two places only. The bestiary entry is host-owned and identical between runs. The data segment is per-virtual-machine and is what the reset clears. The body’s local variables do not persist past a yield because the stack frame is the activation record for a single body execution rather than the script as a whole. Combined with a fresh World and a new dungeon, the next call against a reset virtual machine therefore produces a clean replay equivalent to the first run.
The implementation is AiPool::reset_loop_main_data in the example’s ai.rs. It walks the three slot-zeroing pointers into the boss, tracker, and hunter data segments and writes zero across each one. No equivalent reset is needed for the per-monster fn main archetypes because they carry no data segment.
Reading the item-effect scripts
rogue_item_potion.kel and rogue_item_scroll.kel are one-shot fn main scripts the host invokes when the player quaffs or reads the held item. The scripts dispatch on the effect identifier and return a five-element tuple (hp_delta, max_hp_delta, skill_delta, status_code, status_arg). The host applies the deltas to the player and executes the status action.
Status codes.
| Code | Action |
|---|---|
| 0 | None. |
| 1 | Magic mapping. Reveal the entire floor as explored. |
| 2 | Teleport. Warp the player to a random walkable cell. |
| 3 | Identify. Mark every potion as identified. |
| 4 | Enchant weapon. Advance the equipped weapon tier by status_arg. |
| 5 | Enchant armor. Advance the equipped armor tier by status_arg. |
| 6 | Light. Mark a small radius around the player as explored. |
| 7 | Detect monsters. Post the floor’s monster count to the message log. |
| 8 | Sleep. Placeholder, no effect. |
| 9 | Confusion. Placeholder, no effect. |
| 10 | Remove curse. Placeholder, no effect. |
| 11 | Restoration. Heal the player to full hit points. |
The split between effect logic in script and status action in host is deliberate. Scripts describe what the effect produces. The host applies the engine-specific changes. Effects with delta-only behaviour stay in script. Effects that touch the field-of-view buffers, the random-number generator, or the equipment tables route through the status-action mechanism.
The status-action mechanism itself runs in a second script. rogue_scroll_apply.kel takes the (status_code, status_arg) pair and dispatches to one of eight fine-grained natives: host::set_explored_all, host::set_explored_radius, host::teleport_player_random, host::identify_all_potions, host::change_weapon_tier, host::change_armor_tier, host::sense_monsters, and host::scroll_placeholder. Each native applies its world mutation and pushes its message. The split means modders can change which status code triggers which native, add new status codes, or compose multiple natives per status code, all by editing the script. The host’s natives stay small and orthogonal.
Reading the bestiary script
rogue_bestiary.kel is the data half of the monster system. The host runs this script once per monster id at startup and reads the resolved values from the script’s data segment. The result is cached behind a OnceLock in examples/rogue/bestiary.rs so that runtime accesses through bestiary::kind(idx) are plain reads against a Vec<MonsterKind>.
The data-loader pattern
The bestiary script is a worked example of an idiom that this example demonstrates for Keleusma. The pattern is documented in detail in the Keleusma Cookbook’s data-loader recipe. A short summary follows; the cookbook covers minimal examples, variations, and when to reach for the pattern.
The pattern composes three techniques. Each is individually known. The combination fits Keleusma’s specific constraints (no module-scope constants, fixed-size data segment, bounded execution) particularly well, and the composition is what makes it idiomatic here.
First, multi-headed function dispatch encodes the constant table. Keleusma has no module-scope const for arrays of records, but the verifier accepts multi-headed function definitions with integer patterns. One head per entry, each body assigning the entry’s fields, is functionally equivalent to a constant array. The encoding is verifier-friendly because every body is straight-line code; the dispatch itself compiles to a jump table when the integer keys are dense.
Second, the data segment serves as the host-script I/O struct. The data segment is normally used for state that the script preserves across loop main resumes. Here it carries the output fields of a one-shot pure function. The host lends a shared buffer through vm.call_with_shared, writes the input (the entry index) as the call argument, and reads the outputs (the entry’s fields) out of the buffer through vm.get_shared after the call returns. No language change is required because the shared buffer and get_shared/set_shared are part of the host boundary; the repurposing is on the script side.
Third, the negative-index convention discovers the table size. Calling fn main(-1) resolves the index to MONSTER_COUNT - 1 inside the script, writes the last entry’s fields into the data segment, and returns. The host reads the id slot to learn the table size with one call. The host can therefore size its cache from the script rather than from a parallel host-side constant, with an assertion catching any drift.
The pattern is well-suited to other tables that share the shape “small fixed-size struct of integers, indexed by ordinal”. The bestiary’s per-shape corpse stats also use this pattern: the fn corpse_fill(N) dispatcher inside the same script reads the shape ordinal from a slot the fn fill(N) step has already written, then fills three more slots from a twelve-entry table.
Worked example
The data segment declares one field per output column. The fields the fill(N) heads set directly (id, shape, three primary colour channels, three accent colour channels, five combat stats, ai archetype, first floor, score). The fields the corpse_fill(shape) step sets indirectly (drop chance, satiation, hit-point delta). One hundred multi-headed fill(N) functions write the per-entry constants. Twelve multi-headed corpse_fill(N) functions write the per-shape corpse stats. The fn main(n) entry point resolves negative indices to MONSTER_COUNT + n, calls fill(i), then chains into corpse_fill(state.shape).
data state {
id: Word,
shape: Word,
primary_r: Word, primary_g: Word, primary_b: Word,
accent_r: Word, accent_g: Word, accent_b: Word,
max_hp: Word, skill: Word, evasion: Word, damage: Word, armor: Word,
ai: Word,
first_floor: Word,
score: Word,
corpse_drop_chance: Word,
corpse_satiation: Word,
corpse_hp_delta: Word,
}
fn main(n: Word) -> Word {
let count = 100;
let i = if n < 0 { count + n } else { n };
fill(i);
corpse_fill(state.shape);
0
}
fn fill(0) -> Word {
state.id = 0; state.shape = 0;
state.primary_r = 120; state.primary_g = 90; state.primary_b = 60;
state.accent_r = 60; state.accent_g = 40; state.accent_b = 30;
state.max_hp = 3; state.skill = 0; state.evasion = 1;
state.damage = 1; state.armor = 0;
state.ai = 2; state.first_floor = 1; state.score = 1;
0
}
// ninety-nine more fill heads
fn fill(_n: Word) -> Word { 0 }
fn corpse_fill(0) -> Word {
state.corpse_drop_chance = 50; state.corpse_satiation = 8; state.corpse_hp_delta = 0;
0
} // Tiny
// eleven more corpse_fill heads
fn corpse_fill(_n: Word) -> Word { 0 }
Monster names live in the script too. A third multi-headed dispatcher, fn name(N) -> Text, returns each entry’s name as a static string. The bestiary script’s fn main returns name(i) as its last expression, so the host receives the name as the call’s Finished(StaticStr(...)) payload while the data segment carries the numeric fields. The host leaks the returned string once at startup to obtain a &'static str for caching. Keleusma’s data segment does not currently accept string fields in source, but a function return is a clean alternative and admits the no-host-side-name-table outcome. The host’s MONSTER_COUNT constant mirrors the script’s count literal; the startup assertion catches any drift between the two.
The shipped script weighs in at roughly three hundred and eighty lines for the three dispatchers combined (one hundred fill heads, twelve corpse_fill heads, one hundred name heads). The prior Rust MonsterKind struct-literal form took fourteen lines per entry plus a parallel hundred-line name array plus three twelve-arm corpse methods. The bestiary migration is a clear net reduction concentrated where the per-entry density mattered most.
Reading the consume and descend scripts
Two short scripts wrap recurring world mutations so the host’s autopickup driver and stairs-descent path can stay narrow.
rogue_consume.kel is the per-kind consumption table. After rogue_pickup.kel returns consume, the host invokes rogue_consume.kel with the item kind and subtype. The script dispatches to one of seven fine-grained natives: host::consume_food, host::take_gold, host::equip_weapon, host::equip_armor, host::stash_potion, host::stash_scroll, and host::eat_corpse. Each native applies the matching world mutation and pushes the matching message. Adding a new item kind takes one new native plus one new arm in the script.
rogue_descend.kel is the per-floor level-up calculator. The host snapshots the player’s current level, hit-point cap, hit points, and skill plus the current floor; the script returns the post-descent five-tuple. The shipped script adds three to the hit-point cap and three to current hit points, adds one to skill, and increments level by one. Modders can change the progression curve or split the increment across stats without touching the host.
Exercises for the reader
The exercises below are graded by depth. Tier one exercises change values in existing scripts or tables. Tier two exercises introduce new content that fits the existing dispatch shape. Tier three exercises change the dispatch shape itself.
Each exercise lists what to change, where to change it, and a verification suggestion. Most are answerable without writing tests; readers who want to be thorough can extend tests/rogue_scripts.rs to cover their additions.
Tier one: parameter tuning
Exercise 1.1. Raise the player’s starting hit points from twelve to twenty. Locate the change site in examples/rogue/world.rs and start a fresh run to confirm the head-up display draws twenty pips. Hypothesis to verify. The hit-point gauge layout still fits because the head-up display row spans the full window width.
Exercise 1.2. Tune the hunger cadence. The shipped configuration ticks hunger down by one every two turns. Find the tick site in examples/scripts/rogue/rogue_book_keeping.kel and try faster (every turn) or slower (every three turns) cadences. Observe the run-length effect at floor counts of ten, twenty, and fifty. Hypothesis. Faster cadence forces aggressive monster killing for corpses; slower cadence makes hunger irrelevant. Pick the cadence that produces the most interesting decisions about when to fight, when to flee, and when to eat a poisonous corpse.
Exercise 1.3. Add a ninth weapon tier called “soulrender” with damage forty-two. The weapons table lives in examples/rogue/items.rs. Confirm that the dungeon generator’s tier-clamp expression still places it correctly on the deepest floors.
Exercise 1.4. Make the Sewer Rat’s first appearance start with five hit points instead of three. The bestiary table is in examples/rogue/bestiary.rs. Run a fresh game and observe how many hits the player takes to fell a rat.
Exercise 1.5. Change the room count in the dungeon generator from eight to twelve. The room storage in rogue_dungen.kel uses a fixed-size array. The array declaration must change in lockstep with the literal in the for i in 0..8 loops. Inference. The verifier rejects the script if the array bound and the loop literal disagree.
Tier two: shaped additions
Exercise 2.1. Add a new artificial-intelligence archetype called Coward that chases the player when above half hit points and flees when below. The script takes the same five inputs as every other archetype but returns the move-toward-player action when monster_hp > monster_max_hp / 2 and the move-away action otherwise. The script does not currently receive monster hit points, so the exercise has two parts. First, extend the call convention to accept hit points as additional parameters. Second, write the script. The host call sites that need adjustment are in examples/rogue/natives.rs::run_one_monster_turn and examples/rogue/ai.rs::AiPool::dispatch. Inference. Adding parameters affects every archetype script, so the call convention change is the larger commitment.
Exercise 2.2. Add a new potion effect called “agility” that increases the player’s evasion by one. The status code system already supports this if you introduce code twelve and apply it in the host. The script change is in rogue_item_potion.kel. The host change is in examples/rogue/natives.rs::apply_potion_status. The player has no dedicated evasion stat; you will need to add one to examples/rogue/world.rs::Player and reflect it in examples/scripts/rogue/rogue_combat.kel.
Exercise 2.3. Add doors to the dungeon generator. When a corridor crosses a room wall, the tile at the crossing should be tile identifier two (closed door) rather than zero (floor). The corridor carving helpers in rogue_dungen.kel currently write floor over wall unconditionally. The exercise is to detect the wall-crossing case and write a door instead. The host already renders both open and closed door sprites.
Exercise 2.4. Add a “rangedeyes” status code that reveals every monster on the floor for the next twenty turns. The detect-monsters scroll currently returns a single message about the floor’s monster count. A real reveal-all-monsters effect would set a host-side timer and render every monster regardless of field of view while the timer counts down. The host work is in examples/rogue/world.rs for the timer field and in examples/rogue/render.rs for the conditional monster draw.
Exercise 2.5. Add a vault generator that occasionally replaces the standard room-and-corridor layout with a prefab room laid out symmetrically with treasure in the centre. The vault should appear with probability one in five on floors beyond ten. The exercise is to write a vault_floor(floor) branch in rogue_dungen.kel and gate it on the floor and a random draw.
Exercise 2.6. Add background music. The examples/piano_roll.rs example already demonstrates the full SDL3 audio pipeline against a Keleusma loop main score sequencer. Its design opens the SDL3 audio device, shares an eight-voice array under Arc<Mutex<_>> with an SDL3 audio callback, renders square-wave and triangle-wave samples on the audio thread, and drives note triggers from a Keleusma script at sixteenth-note ticks. The exercise is to adapt that pattern to the rogue host. The source-code hook is the add audio processing here comment in examples/rogue/main.rs at the SDL3 init site. The script work is a new rogue_music.kel loop main script that yields note triggers per tick. The host work is opening the audio device, spawning the audio thread, and resuming rogue_music.kel at a chosen cadence (per turn, per second, or per floor descent for new-floor stings). Concerns to address. The audio thread and the game tick run independently; the score should be sampled by the audio thread without blocking the game thread. The piano roll’s mutex-guarded voice array works for this. Hypothesis. A slow bassline that shifts with floor depth would carry atmosphere without distracting from the silence between player inputs that this turn-based example currently relies on.
Tier three: dispatch and architectural changes
Exercise 3.1. Replace the shadowcasting field-of-view algorithm with a symmetric ray-casting implementation that traces from the player to every cell within radius. Compare the visible footprint between the two algorithms on the same dungeon. The algorithmic substitution lives in examples/rogue/fov.rs. Inference about the trade. Shadowcasting is faster and handles thin walls cleanly. Ray casting is simpler to read and easier to make symmetric for the monster-sees-player check the host already performs separately.
Exercise 3.2. Add a save-and-load surface. The world record is Serialize-able by writing one. The exercise is to dump the entire world to a file on a dedicated keybind and load it back on next run. The Keleusma scripts do not need to change. The host work is in examples/rogue/main.rs and examples/rogue/world.rs. Unaddressed concern. The arena state is not part of the world. Scripts that hold per-call state in the data segment would lose it on reload. The dungen, artificial-intelligence, and item scripts shipped with the example are stateless across calls, so the concern is theoretical for the current roster.
Exercise 3.3. Replace the host-driven monster turn loop with a per-turn driver script. The current architecture has the host iterate monsters and dispatch their archetypes. An alternative is a rogue_game.kel script that the host calls once per turn with the world state as input and the host commits the script’s emitted commands. This exercise is the largest in the manual because every host native that touches the world needs to accept calls from the new script. The pay-off is that the entire turn-loop sequencing becomes user-editable code.
Exercise 3.4. Replace the procedural sprite atlas with a sprite sheet loaded from a Portable Network Graphics file. The host already builds textures through SDL3 surfaces. Loading a Portable Network Graphics file and slicing it into per-tile sub-textures is mechanical work in examples/rogue/tiles.rs. The exercise’s actual challenge is choosing the sprite sheet. The bestiary has one hundred entries.
Exercise 3.5. Add ranged attacks for the player. The current rules give the player melee only. Adding ranged means a wand or bow item type, an aimed-attack keybind, line-of-sight resolution from the player to the target cell, and a damage formula that accounts for distance. The host work is in examples/rogue/natives.rs to recognise the new action code and route it through the resolver; the script work is in examples/scripts/rogue/rogue_player_ai.kel to emit the ranged action and in examples/scripts/rogue/rogue_combat.kel to apply the distance falloff. Hypothesis. The bestiary’s ranged-archetype monsters become disproportionately easy if the player can return fire from outside their attack envelope, so the damage formula may need a distance falloff to keep balance.
Exercise 3.6. Replace the symmetric monster line-of-sight rule with a per-monster shadowcast. The current implementation in examples/rogue/natives.rs::monster_sees_player treats the player’s field-of-view bitmap as the ground truth: if the player can see the monster, the monster can see the player. This is symmetric by construction but ties monster perception to the player’s vantage. An independent per-monster cast originating at the monster’s cell with the same eight-tile radius would produce different results in pillar-like wall configurations. Implement the cast and compare. Hypothesis. The independent cast feels more “fair” but is more expensive; at the current scale the cost is negligible.
Exercise 3.7. Implement the placeholder potion and scroll effects. The status-code dispatch infrastructure is already in place. The pieces that remain.
- Potion of Speed (effect 7). Grant the player extra turns. Add an
extra_turnscounter to the player state. Each tick, if positive, decrement and run the player turn before yielding. Hypothesis. The player effectively moves twice; cost is balanced because the potion is consumed. - Potion of Levitation (effect 8). Add a
levitatingtimer to the player state. While positive, ignore traps (when traps land in a future revision) and treat the cell as walkable for the chamber-of-pits use case. Currently no traps exist, so the effect is a no-op against current content but reads correctly in messages. - Potion of See Invisible (effect 9). Add an
invisibleflag to monster kinds and to instances. While See Invisible is active, the renderer shows invisible monsters as if they were visible. Requires the bestiary to gain an invisible flag and a tier of monsters that uses it. - Scroll of Sleep (effect 8 status code). Add a
sleeping_turnsfield to each monster. While positive, the host’s per-monster dispatch returns Wait immediately and decrements the counter without invoking the archetype’s virtual machine. Effective range two on read. - Scroll of Confusion (effect 9 status code). Add a
confused_turnsfield to each monster. While positive, the host scrambles the artificial-intelligence-returned action: with probability fifty per cent the action is rerolled as a random adjacent step. - Scroll of Remove Curse (status code 10). Add a curse flag to weapons and armor. Some weapons spawn cursed; cursed gear cannot be unequipped. Remove Curse lifts the flag.
Each effect is a few lines on the host side. Reuse the existing status code values so the script side does not need to change.
Tier four: research-flavoured questions
Exercise 4.1. What is the smallest set of artificial-intelligence archetype scripts that still produces a recognisable rogue feel across the one-hundred-floor descent? The current set is nine. Try removing one at a time and assess whether the gameplay degrades noticeably. Inference. The boss archetype is the most replaceable because it is bound to a single monster.
Exercise 4.2. What is the worst-case execution time of the dungeon generator on a single floor? The structural verifier accepts the script, but a measured profile would identify the dominant cost. Reading the script’s per-loop body and counting host-native calls is a fair starting point. The arena-bounded text-size analysis already proves the worst-case memory usage is bounded; this exercise asks for the time companion.
Exercise 4.3. The bestiary now lives entirely in rogue_bestiary.kel, including the monster names; see the Reading the bestiary script section above. The names flow through the script’s return value rather than the data segment. The remaining open question is the weapon and armor names in rogue_gear.kel. Apply the same return-value-as-name pattern to the gear script and remove WEAPON_NAMES and ARMOR_NAMES from examples/rogue/items.rs. The migration is mechanical given the bestiary precedent.
Exercise 4.4. What is the right division of responsibility between the host and the scripts in a Keleusma example? The piano-roll example puts almost everything in scripts; the roguelike example splits roughly in half by line count. Write a short essay arguing for one extreme or the other across both examples.
Tier five: game balance
Exercise 5.1. Tune starvation back to a real threat. The first playtest passes ran out of food before floor four, which felt unfair. The shipped fix combined two changes. Hunger now ticks down by one every two turns rather than every turn, doubling the run length under a single starting ration. Slain monsters now have a shape-derived chance of leaving a corpse the player can step onto and eat. The combination overshot. Starvation is no longer a real threat at all; corpse pickups in particular keep the player fed indefinitely past the first few combats. The exercise is to find a configuration that puts the player back in the danger zone without making early-game starvation inevitable. Candidates to tune. The hunger cadence in examples/scripts/rogue/rogue_book_keeping.kel. The food restoration amount in examples/rogue/natives.rs::autopickup. The food spawn count in examples/scripts/rogue/rogue_dungen.kel::spawn_items. The corpse drop probabilities and per-shape satiation in examples/rogue/bestiary.rs. The art of game balance is finding the combination that produces interesting decisions about when to eat a poisonous corpse, when to skip a fight to conserve hunger ticks, and when to push for the next floor rather than searching for food.
Exercise 5.2. Calibrate the floor difficulty curve. Play five fresh runs and record how many turns and how many hit points the player loses on each floor up to floor twenty. Hypothesis to test. The difficulty should rise smoothly. In practice the curve has visible discontinuities at the floor boundaries that introduce a new shape (around floor twelve when serpents appear, around floor thirty-six when dragons appear). The exercise is to smooth the curve by retiming the bestiary’s first_floor fields and the dungeon generator’s monster-count scaling. Inference. A smooth curve gives the player a sense of growing power; a spiky curve produces frustration on the spike floors.
Exercise 5.3. Audit the loot distribution. Count how many of each item kind the player encounters in a five-floor run. The shipped configuration tends to produce more gold piles than potions and scrolls combined. Gold is purely a score counter in the current rules, so a flood of gold drops feels meaningless. The exercise is to retune spawn_items in the dungeon generator so the player encounters roughly equal counts of food, potions, scrolls, and gold piles. Hypothesis. Equal counts make every pickup feel deliberate; lopsided counts make rare drops feel disproportionately exciting. Pick the configuration that fits the desired play feel.
Exercise 5.4. Combat damage scaling. The shipped weapon table goes from two damage at tier zero to one hundred and eighteen damage at tier nineteen, and the armor table goes from zero to forty defense. The bestiary’s monster hit points range from two for vermin to two hundred for the boss. The exercise is to play a run and identify the floor at which the player’s current weapon stops being satisfying. Adjust either the weapon damage progression or the monster hit-point scaling so that floors thirty to fifty feel as decisive as floors one to ten. Hypothesis. The decisive feeling comes from kills per attack rather than absolute damage; a weapon that one-shots half the bestiary on its floor produces a different rhythm than one that requires three swings per kill.
Capstone projects
These projects each take several days of focused work. They synthesise multiple tier-three exercises into a complete feature.
Capstone A. The Ranged Combat Update. Implement ranged attacks for the player (Exercise 3.5), add a bow item type with a quiver of arrows tracked on the head-up display, add a fire-bow keybind, and rebalance the floor twenty through forty bestiary entries so the new combat option is meaningful but not dominant. Deliverable. A playable run from floor one to floor twenty that uses ranged attacks at least ten times.
Capstone B. Per-Run Persistence. Implement save-and-load (Exercise 3.2), add an autosave at every stairs descent, and add a high-score table that survives game-over. Deliverable. A polished run that can be exited mid-floor and resumed.
Capstone C. The Asset-Driven Renderer. Replace the procedural sprite atlas (Exercise 3.4), add four-direction-aware sprites for humanoid monsters and the player, and add per-tile animation frames for water, lava, and the exit tile on floor one hundred. Deliverable. A rendered run that visibly differs from the procedural-sprite version.
Capstone D. The Quest System. Add a quest-board native that the host queries at floor entry. Each floor’s quest is a string and a completion condition (kill ten of a kind, collect a kind of item, reach a particular tile). Add a quest-tracking field to the player struct. Add a small reward on completion. Deliverable. A run where the floor-three quest is visibly tracked and completed.
Reference tables
Tile identifiers
| ID | Tile |
|---|---|
| 0 | Floor |
| 1 | Wall |
| 2 | Closed door |
| 3 | Open door |
| 4 | Stairs down |
| 5 | Exit |
Item kind identifiers
| ID | Kind |
|---|---|
| 0 | Food |
| 1 | Gold |
| 2 | Weapon |
| 3 | Armor |
| 4 | Potion |
| 5 | Scroll |
| 6 | Corpse |
Artificial-intelligence action codes
| Code | Action |
|---|---|
| 0 | Wait |
| 1 | Move or melee into target cell |
| 2 | Ranged attack at target cell |
Item-effect status codes
See Reading the item-effect scripts above for the full table.
Default tuning parameters
| Parameter | Default |
|---|---|
| Map size | Sixty-four by forty tiles |
| Display tile size | Sixteen by sixteen pixels. Sprite art is authored at twenty-four pixels and downscaled to sixteen on copy. |
| Field-of-view radius | Eight tiles |
| Starting hit points | Twelve |
| Starting hunger | One hundred |
| Hunger tick | Minus one every two turns |
| Food restoration | Forty hunger |
| Hit-point regeneration | One per ten turns when hunger is positive |
| Starvation damage | One per turn at hunger zero |
| Hit-point gain per level | Three |
| Skill gain per level | One |
| Rooms per floor | Eight |
| Monsters per floor | Floor plus four, capped at twelve |
| Food per floor | Three to five |
| Potions per floor | Two to four |
| Scrolls per floor | Two to four |
| Weapons per floor | Zero to one |
| Armor pieces per floor | Zero to one |
| Gold piles per floor | Four to seven |
Metrics
Navigation: Guide | Documentation Root
Resource footprint and execution metrics for the V0.2.1 Keleusma CLI compared with popular scripting languages. The numbers below are intended for operators planning embedded or constrained deployments.
Measurement methodology
All numbers measured on a single Apple M1 (arm64-apple-darwin23) host, May 2026. Each runner executed the same conceptual workload: load the runtime, parse-or-load a trivial program, print the integer 42, exit. Five runs per runner; the typical mid-range value is reported.
- Binary size: stripped or default release-build size as reported by
ls -l. Path resolved throughreadlinkwhere present. - Maximum resident set size: peak RSS as reported by
/usr/bin/time -lon macOS. Includes the runtime’s own memory plus all shared libraries page-resident at any point. - Peak memory footprint: peak dirty + anonymous memory as reported by
/usr/bin/time -l. Lower than maximum RSS because it excludes shared library text pages. - Cycles elapsed: CPU cycles consumed from process start to process exit.
- Real time: wall-clock duration from process start to exit. Rounded to 10 ms by the measurement tool.
The Keleusma measurement runs the full strict-mode-capable CLI binary including the encryption feature stack (X25519 + AES-256-GCM + HKDF-SHA-256). The binary as measured is what an operator would deploy.
Results
| Runner | Binary size | Max RSS | Peak footprint | Cycles | Real time |
|---|---|---|---|---|---|
| bash 5.x (system) | 1.3 MB | 1.85 MB | 1.26 MB | 6.3 M | < 10 ms |
| Keleusma 0.2.1 | 2.1 MB | 2.85 MB | 1.49 MB | 8.4 M | < 10 ms |
| Lua 5.4 (typical published) | 0.3 MB | ~2.0 MB | ~1.5 MB | ~7 M | < 10 ms |
| Python 3.13 (MacPorts) | 34 KB launcher + framework | 11 MB | 5.7 MB | 54.8 M | 20 ms |
| Ruby 3.1 (MacPorts) | 34 KB launcher + framework | 30.7 MB | 25.7 MB | 128 M | 40 ms |
| Node.js (MacPorts) | 77 MB | 42.5 MB | 13.7 MB | 108 M | 40 ms |
The Python and Ruby launcher binaries are tiny shims that load shared libraries; the substantial code lives in the Python/Ruby framework dynamic libraries on disk. The launcher size understates total install footprint. Node.js statically links most of V8 and the runtime, hence the large binary.
The Lua row uses published numbers from the Lua 5.4 reference build rather than measurement on this machine because Lua was not available locally. Numbers are representative of a default ./configure && make build with liblua linked statically.
Headline finding
Keleusma is essentially bash-tier in resource consumption. Binary size, RSS, peak footprint, and cycle count are all within 30 to 60 percent of bash for the same trivial workload. Both runners load and execute in under 10 ms.
The other interpreted scripting languages (Python, Ruby, Node.js) carry 5 to 20 times more memory pressure and 7 to 15 times more CPU cycles for the same workload. Their advantage is the ecosystem; the cost is the footprint.
Keleusma sits in the same operational class as bash and Lua while delivering substantially more guarantees than either. The next section addresses what the operator gets for the small overhead.
What the operator gets
For roughly half a megabyte of additional binary and one megabyte of additional RSS versus bash, the Keleusma deployment includes:
- Verified bytecode: scripts cannot crash from memory issues, infinite loops, or unbounded recursion. The structural verifier rejects programs that would defeat the bounds.
- Statically computed WCMU and WCET bounds: arena memory is sized to exactly the bytecode’s declared bound. Bash and Lua have no equivalent.
- Ed25519 signed delivery: scripts cryptographically authenticated to a release key. Bash scripts can be signed externally via separate tooling (gpg, codesign), but verification at execution time is not built in.
- X25519 plus AES-256-GCM encrypted delivery: scripts encrypted to a specific destination host. Plaintext is not readable from the artefact alone.
- Information-flow labels: type-system-level data-flow tracking that catches policy violations at compile time. Bash and the other scripting languages have no static IFC.
- Strict-mode policy gate: the CLI enforces that only signed and decryptable artefacts run. Configurable via filesystem and environment variables. No script-side mechanism can bypass.
None of these features cost the operator significant footprint. The crypto stack adds about 200 KB to the binary (out of the 2.1 MB total). The strict-mode policy machinery is a small portion of the rest.
Per-feature footprint breakdown (approximate)
| Feature | Approx. binary contribution |
|---|---|
| Core compiler and VM (parser, type checker, verifier, executor) | ~1.4 MB |
signatures feature (Ed25519) | ~150 KB |
encryption feature (X25519, AES-GCM, HKDF, SHA-256) | ~200 KB |
shell feature (process spawn, env vars, exit) | ~50 KB |
| CLI surface (argument parsing, REPL, keygen, strict-mode policy) | ~250 KB |
| Total stripped release binary | 2.1 MB |
Embedders who do not need a feature can omit it through Cargo feature flags. A minimal embedded build with --no-default-features --features compile,verify (no encryption, no signing, no shell) is approximately 1.2 MB. A library-only build linked into a host application has no CLI surface and is approximately 800 KB to 1 MB of additional binary.
Loop daemon workload
Running an indefinite loop main at one iteration per millisecond for one minute under the CLI uses approximately:
- Constant 2.9 MB RSS (no growth over the run; the arena is sized at startup)
- 1 to 2 percent of one M1 core (the tick rate dominates; the per-iteration cost is microseconds)
- Zero allocator pressure (the arena’s transient region is reset on each yield-resume cycle)
Comparable steady-state daemon behaviour in Python or Ruby runs at 15 to 30 MB RSS with comparable or higher CPU usage for an empty loop body. The advantage compounds as the daemon’s runtime extends.
Steady-state at sleep cadence
At one tick per second under --tick-interval 1s, the daemon’s CPU drops to effectively zero (microseconds of compute per iteration, ~999.9 ms idle in the rate-limiter’s sleep). The RSS is unchanged from the high-rate workload because the arena is sized at startup and reused. The drift-compensated sleep reduces cumulative drift to under one percent over a typical operating period.
For long-cadence daemons (--tick-interval 1h, --tick-interval 1d), CPU usage is dominated by the OS scheduler’s wakeup mechanism rather than Keleusma itself. The runtime is genuinely idle between iterations. A memory-resident-on-call daemon at one tick per hour consumes operationally the same resources as a 2.9 MB resident memory mapping; the cost is page-fault avoidance, not computation. See SECURITY_POLICY.md for the operator guide to memory-resident deployments.
Notes on the comparison
The comparison is intentionally selective. Each runner has different design goals; direct feature comparisons are unfair.
- bash: shell interpreter. Designed for shell pipelines, not general programming. Faster startup than Keleusma because it does less per-statement validation, but no static type checking, no bounded resource analysis, no signature verification.
- Lua: embeddable scripting. Closest analogue to Keleusma in operational footprint. Lua admits unbounded recursion and unbounded loops; runtime errors are possible. No WCMU or WCET bounds. No built-in signing or encryption.
- Python, Ruby: general-purpose dynamic languages with rich standard libraries. Operationally heavier; type errors caught only at runtime. No bounded resource analysis. Signing and encryption available through third-party packages, not built in.
- Node.js: V8 plus Node runtime. Designed for high-throughput server workloads; the JIT amortizes startup cost across long-running services. Heavy for one-shot scripts. No built-in signing or encryption of code.
Where Keleusma genuinely wins: deployments that need verified execution properties (regulated industries, embedded), and deployments where the CLI runs many short-lived scripts (low per-script overhead). Where the comparators win: deployments that need rich ecosystems and where startup cost is amortized across long-running services.
Reproducibility
The measurements above were generated on May 22 2026 with the following commands. Operators can reproduce on their own hardware.
# Build keleusma CLI release binary
cargo build --release -p keleusma-cli
# Trivial source program
echo 'fn main() -> Word { 42 }' > /tmp/hello.kel
# Measure
/usr/bin/time -l ./target/release/keleusma run /tmp/hello.kel
# Comparators (where installed)
/usr/bin/time -l bash -c 'echo 42'
/usr/bin/time -l lua -e 'print(42)'
/usr/bin/time -l python3 -c 'print(42)'
/usr/bin/time -l ruby -e 'puts 42'
/usr/bin/time -l node -e 'console.log(42)'
Numbers vary by host CPU, OS version, and installed-runtime version. The relative ordering (bash and Keleusma at the bottom, interpreted languages at the top) is stable across machines I have tested.
LLM Usage
Navigation: Guide | Documentation Root
Guidance for operators who use AI coding assistants (Claude Code, Codex, Cursor, Aider, and similar) on Keleusma source or scripts. The advice below addresses patterns that AI tools trained on general Rust code tend to get wrong on first attempt, plus practical prompts that reduce iteration time.
This document is written for the operator driving the AI. It is also useful as direct context to feed an AI session at the start of work. The convention for direct-to-AI context files in the broader ecosystem is AGENTS.md and llms.txt at the project root. Both reference this document for deeper guidance.
Audience
Two operator profiles benefit from this guide.
- Embedders writing Keleusma scripts for an embedded application. Keleusma’s surface is unusual enough that LLM-generated scripts fail verification frequently on first attempt.
- Contributors editing the Keleusma runtime in Rust. The runtime’s
no_std + allocposture and conservative-verification stance push back against several common Rust idioms.
Both profiles need similar context. The patterns flagged below apply across the divide.
Reading order for any AI session
Have the AI read these documents at the start of a session before making changes.
AGENTS.mdfor the project’s conventions and the per-session protocol.- This document for the AI-specific pattern gotchas.
docs/architecture/LANGUAGE_DESIGN.mdfor the why behind the design.docs/decisions/RESOLVED.mdfor the historical record of architectural decisions. Most “why was it done this way” questions are answered here.docs/process/TASKLOG.mdfor current sprint state anddocs/process/REVERSE_PROMPT.mdfor the most recent AI-to-human handoff.
A useful first prompt to give any AI session:
Please read AGENTS.md, llms.txt, docs/guide/LLM_USAGE.md, the process documents
at docs/process/, then walk the knowledge graph under docs/. Summarise what
you learned in three to five paragraphs before proceeding to any task.
The “summarise before proceeding” forces the AI to demonstrate that it actually read rather than skimmed.
Patterns AI tools tend to get wrong
The items below have surfaced repeatedly in AI-assisted work on this codebase. The pattern is documented; the AI is expected to read it.
no_std + alloc only
Keleusma’s runtime crate targets no_std with alloc. The standard library is not available.
The AI’s first reflex tends to be wrong on several fronts. Replace each with the indicated alloc equivalent.
| Wrong (std-only) | Right (no_std + alloc) |
|---|---|
std::collections::HashMap | alloc::collections::BTreeMap |
std::collections::HashSet | alloc::collections::BTreeSet |
std::fs, std::io, std::process | Not available; surface as a host-registered native function |
std::sync::Mutex, std::sync::RwLock | Not available; the runtime is single-threaded |
Box::leak, Box::pin | Available through alloc::boxed::Box but the leak pattern is rejected |
std::time::Instant, std::time::SystemTime | Not available; clock access is host-provided through a native |
println!, eprintln! | Not available; the println native is host-registered |
The keleusma-cli crate links against std and can use these APIs at the binary layer. The runtime crate cannot.
Determinism matters even where std is in scope
Even in tests and the CLI, prefer BTreeMap over HashMap. The byte-identical bytecode property the test suite enforces depends on stable iteration order. Hash-map iteration order is not stable across Rust versions, and even within a single version it can drift when capacity grows. Tests that pass on one machine may fail on another if iteration order leaks into observable output.
The same applies to any other source of non-determinism: floating-point reductions, std::hash::DefaultHasher, system time, random number generators not explicitly seeded, file iteration order via read_dir. If the AI introduces any of these, the change is wrong regardless of what the prompt asked for.
Conservative-verification stance
The safe verifier rejects constructs that defeat the worst-case execution time (WCET) and worst-case memory usage (WCMU) analyses. Specifically:
- All recursion is rejected at compile time, even when the recursive call is provably terminating. The analyses require a directed acyclic call graph.
- Closures are rejected at the type-checker stage because dynamic dispatch through closure invocation breaks the per-call cost model.
dyn Traitis rejected in most positions because virtual dispatch defeats the per-call-site cost analysis.- Flat control-flow opcodes (
Jmp,Branch) are not present in the instruction set; only block-structured forms (If,Else,EndIf,Loop,EndLoop,Break,BreakIf) are admitted. - Loops whose iteration count cannot be statically extracted are rejected by the strict-mode bounded-iteration analysis (R38).
The AI’s natural reflex is to use any of these constructs when the algorithm seems to call for them. The right response is to rewrite the algorithm to use the work-stack pattern (explicit stack rather than recursion), enum-based dispatch (rather than dyn Trait), or a fixed-iteration loop (rather than an unbounded one).
The verifier’s diagnostic message names the rejected construct and points to the corresponding guidance in docs/guide/WHY_REJECTED.md. Do not silence the diagnostic by relaxing the verifier; the rejection is by design.
Trait-bounded generics over trait objects
Where the AI would reach for &dyn Trait, prefer fn foo<T: Trait>(x: T) or fn foo<T: Trait>(x: &T). The monomorphizing form is admitted; the dynamic form is rejected by the conservative-verification stance in most positions.
This is the same posture as Rust embedded best practice, but the AI may not recognise the pattern. Cite the convention up front in the prompt if the work involves new trait surfaces.
Persistent state lives in the data block, not in module-level statics
A common AI reflex when porting Rust code is to introduce a module-level static or lazy_static for per-run state. In Keleusma, persistent state across loop iterations belongs in the program’s declared data block, accessed through GetData and SetData. The host owns the underlying storage; the script reads and writes by slot index. There is no module-level mutable state at the language level.
Per-session protocol
Per docs/process/PROCESS_STRATEGY.md, each session has a specific shape:
- Read
docs/process/TASKLOG.mdfor current task state. - Read
docs/process/REVERSE_PROMPT.mdfor the last AI-to-human handoff. - Wait for human prompt before proceeding.
After completing each task:
- Update task status in
TASKLOG.md. - Overwrite
REVERSE_PROMPT.mdwith verification, questions, concerns, and intended next step. - Commit only if the operator explicitly asks. Otherwise leave changes uncommitted.
The protocol is documented in AGENTS.md and in CLAUDE.md but is worth restating: AI sessions in this project run on a strict per-session contract.
Scratch directories
Use tmp/ for transient files (drafts, probe outputs, scratch scripts, design specs awaiting integration). The contents of tmp/ are gitignored by convention and are never committed.
A common AI reflex is to put work in the repository root or in a new subdirectory of docs/. For draft material, tmp/ is correct. For finished material, the appropriate docs/ subdirectory is correct. The pattern is: drafts in tmp/, then promote to docs/ once reviewed.
Useful prompt patterns
The patterns below produce higher-quality output than open-ended prompts.
The “read first, summarise, then proceed” pattern
Please read [specific document paths]. Summarise what you learned in three
to five paragraphs. Then [the actual task].
The summarise-first step forces the AI to demonstrate that it actually read the documents. It also gives the operator visible signal that the relevant context was loaded.
The “design before implementation” pattern
Please produce a design document at tmp/<topic>.md before writing any code.
The design should cover [the specific concerns]. Length budget two pages.
Identify any places where the design does not map cleanly onto existing
patterns in the codebase.
For non-trivial work, design-before-implementation produces a reviewable artefact and reduces the cost of changes-of-direction. The pattern is established with tmp/enrolled_keys_execution.md and tmp/call_site_identifier.md as worked examples.
The “structural-bound” pattern for algorithm work
Please implement [algorithm] using the work-stack pattern documented in
docs/research/r3_1_recursion_to_iteration.md. The verifier rejects
recursion; do not use recursive helper functions. Declare the stack
capacity statically.
Explicit reference to the work-stack pattern (or other Keleusma-specific patterns) prevents the AI from defaulting to recursive Rust idioms.
The “verify before claiming done” pattern
After implementing, run the full verification suite:
cargo test && cargo clippy --tests -- -D warnings
Do not report the task complete until both pass.
Forces the AI to actually run the validation rather than claim success based on inspection.
What this guide is not
This guide is not a tutorial on Keleusma. Operators new to the language should start with GETTING_STARTED.md. This guide is also not a tutorial on AI tools; it assumes operator familiarity with whichever AI assistant is in use.
This guide is not exhaustive. New patterns surface with each AI-assisted session. When a recurring AI failure-mode is identified that is not documented here, add a section. This is a living document.
Related prior art
The idea of publishing an LLM-targeted guidance document is borrowed from the Rex project (https://github.com/peterkelly/rex), which publishes docs/src/LLMS.md for the same purpose. The two projects share architectural patterns (pure functional language embedded in Rust via host-injected natives) and the LLM-targeting framing translates cleanly. Operators familiar with Rex’s LLMS.md will recognise the shape of this guide.
The broader llms.txt convention was proposed by Jeremy Howard et al. in 2024 as a structured-markdown convention for declaring an AI-readable project entry point. Keleusma’s llms.txt at the project root follows that convention.