Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Part II — The Building Blocks

Part III — Shaping Data

Part IV — The Heart of Keleusma

Part V — The Verifier and the Guarantees

Part VI — Going Deeper

Part VII — Shipping a Program

Part VIII — The Capstone: Making Music

Part IX — Embedding Keleusma in a Rust Program

Part X — Where to Go Next

ChapterTitle
40Further 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.

DocumentAudiencePurpose
GETTING_STARTED.mdFirst-time userInstall the CLI, write a first script, run it, embed it in a twenty-line Rust host. Course coverage: Chapter 2, Chapter 31.
EMBEDDING.mdRust host authorThe 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.mdSong author, host lifter, or host architectThe 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.mdGame author or host architectThe 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.mdAnyone whose program failed verificationThe 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.mdAnyone who hit a surpriseCommon 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.mdEmbedder reaching for a known-good patternWorking 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.mdAuthor needing multi-digit arithmeticThe 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.mdOperator using AI coding assistantsPatterns AI tools tend to get wrong, the read-AGENTS-first session protocol, prompt patterns that reduce iteration time.
AUTOMATION_SCRIPTING.mdOperator writing devops or sysadmin automation in KeleusmaThe 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.mdOperator deploying keleusma-cli in constrained environmentsThe 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.mdOperator planning embedded or constrained deploymentsBinary 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.mdOperator evaluating whether stddsl::Shell is rich enough for a planned daemonPresent capabilities of the bundle, closed gaps, and open recommendations for V0.2.x point releases.

Companion Material

PathContent
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.rsEnd-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.

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:: and math:: 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 with keleusma run <file>.
  • A Keleusma program produces output by returning a value.
  • On macOS and Linux a shebang line plus chmod +x makes 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::pow borrows a function from the host. Raising a number to a power is provided by the host’s math library, and use brings 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) -> Float is the frequency formula from above. It takes a MIDI number m, a Word, and returns a Float. A Float is a number that can have a fractional part, which a frequency needs. The expression (m - 69) as Float converts the whole number m - 69 into a Float so it can be divided by 12.0. That conversion is called a cast. Chapter 4 covers Word, Float, and casts.
  • fn scale_degree_hz(root: Word, degree: Word) -> Float puts the scale pattern in a list called an array, named steps. Writing steps[degree] reads the entry at position degree. The function adds that semitone count to the root and asks midi_to_hz for the frequency. Chapter 12 covers arrays.
  • fn main runs the program. It asks for degree 4 of the major scale built on MIDI number 60, 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 0 gives the root, middle C itself, near 261.63 hertz,
  • degree 7 gives 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 Word and Float types,
  • 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.

  • Byte is an eight-bit whole number, used for byte-level work. It appears in Chapter 23.
  • Fixed is 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, N words wide with F fractional bits, for values too large for a single Word. 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

  • Word is a whole number, and whole-number division drops the remainder.
  • Float is a fractional number, written with a decimal point.
  • bool is true or false.
  • Text is writing in double quotes.
  • Unit, written (), means no value.
  • Byte, Fixed, and Multiword<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.

  • fn begins the function.
  • semitone_steps is 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, named octaves, of type Word. Each parameter states its type.
  • -> Word states 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.

  • and is true when both sides are true.
  • or is true when at least one side is true.
  • xor is true when the two sides differ.
  • not flips 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 a bool.
  • Questions combine with the words and, or, xor, and not, never with symbols; andalso and orelse are the short-circuit forms.
  • Bit-level operators act on every bit of a Word or Byte: band, bor, bxor, bnot, and the shifts lsl, asl, lsr, and asr.
  • if condition { ... } else { ... } chooses between two values.
  • match chooses among many cases, and _ is the catch-all.
  • assert condition checks a development-time assumption; it is present only in a --debug build 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) calls f with x as 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 a Note. Each field is given a value by name. This is called construction.
  • brightness takes one parameter, a whole Note, rather than two loose numbers. The two facts travel together.
  • n.pitch and n.velocity read 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.field reads 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::Variant names a variant, and match chooses on it.
  • A match over an enum must cover every variant, and the language checks this.
  • A variant may carry a value, written Variant(Type), and a match arm can name and use that carried value.
  • A Word can be turned back into an enum value with the discriminant-to-enum construct d 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 with let (a, b) = ....
  • An array, [a, b, c], is a fixed-length row of one type, read with array[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 match arm is a pattern, =>, and a result.
  • Patterns are literals, bindings, the wildcard _, and enum variant patterns that bind carried values.
  • A when guard refines an arm with a further condition.
  • Every match must 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 when guard.
  • 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 loop function. If it has one, that loop function is the program’s entry point.
  • A yield function may be an entry point, or a helper.
  • An fn function 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

  • fn is an atomic total function: it runs straight through and finishes.
  • yield is a non-atomic total function: it may pause and resume, and must eventually finish.
  • loop is a productive divergent function: it never finishes and must yield on every cycle.
  • A program has at most one loop function, 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 value hands value to the host and pauses the program.
  • When the host resumes, the value it hands back is the result of the yield expression.
  • The pair of types, yielded out and resumed in, is the dialogue.
  • The command-line tool drives yield main programs through a tick-counter protocol; the program finishes when control returns from the entry function.
  • keleusma compile produces 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 loop function 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 loop must yield on every cycle. This productivity rule is enforced.
  • A program has at most one loop function, 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 data block, 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 data block is the program’s own memory. It persists across cycles, but the host does not see it.
  • A const data block 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.field reads a field; name.field = value; writes one.
  • A for loop inside a loop function does real work by writing to the data segment.
  • A data block is shared by default, or private, or const.

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 loop with no yield is 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 fn function finishes.
  • Productivity: every loop function 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 trait declares a named behavior, and an impl block 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 turbofish f::<7>() and usable as an array length, a Multiword dimension, or a Word value. 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 Underlying recovers 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 a Word. The result is bound to v.
  • overflow runs when the true result is too large.
  • underflow runs 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. The overflow and underflow arms are optional and default to wrapping; ok is required. On Word the arms carry the high and low halves, the foundation of big-number arithmetic; on Byte, Float, and Fixed<N> they carry a single result.
  • saturate_max and saturate_min clamp 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@Label adds a label; declassify expr@Label removes 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.
  • declassify is 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 run compiles and runs in one step; keleusma compile produces 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 signed modifier on the entry function marks a program as requiring a valid signature.
  • The flow is keleusma keygen, then keleusma compile --signing-key, then keleusma 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:

  • s swaps to the next song.
  • r restarts the current song.
  • p pauses, and p again 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-example builds and runs it.
  • The songs are .kel files in examples/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 use lines, importing the host natives from Chapter 16.
  • It declares an enum Pitch, from Chapter 11, listing the twelve pitch classes and a Rest.
  • It has ordinary fn helpers, from Chapter 6, that look up notes. Each uses a match, from Chapter 13.
  • It declares a data state block, 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 match arms of its note-table functions.
  • The edit loop is: change the .kel file, check it with keleusma 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 .kel program. 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 keleusma as 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)?;
}
  • tokenize turns source text into a Vec<Token>.
  • parse turns the tokens into a Program, the syntax tree.
  • compile turns the program into a Module, the bytecode object.
  • Vm::new consumes 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::new runs 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 returns VmState::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_fn and register_fn_fallible are the ergonomic route, for functions whose types implement KeleusmaType.
  • register_native_closure is the route for natives that capture host state, as every piano roll native does.
  • register_library installs 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 the Word error code by returning an error built with the KeleusmaError derive, 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 fn script runs to completion: call returns Finished(value).
  • A yield script hands a value out: call returns Yielded(value), and the host calls resume(input) to continue.
  • A loop script yields every cycle and resets at the end of its body. call returns Yielded, resume drives the next yield, and once a body completes the next call returns Reset.

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

  • call starts a VM, resume continues it after a yield.
  • VmState is Finished, Yielded, or Reset, 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_error recovers 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_CAPACITY is the default; auto_arena_capacity_for computes an exact size; Arena::from_static_buffer runs 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_bytes loads a precompiled .kel.bin, verifying it the same as a source-compiled module.
  • Vm::load_signed_bytes loads signed bytecode against a slice of trusted public keys.
  • register_verifying_key adds keys to a VM after construction.
  • Vm::new_unchecked skips the resource-bounds check under an explicit, unsafe trust 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_module installs a new module and a fresh private data segment; the new module is then driven from call.
  • 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_bytes installs 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_MODEL is unmeasured and good for relative comparison; a MEASURED_COST_MODEL from keleusma-bench is calibrated to a target.
  • set_native_bounds attests 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_module runs tokenize, parse, and compile, the phases of Chapter 32.
  • run constructs the Arena and the Vm, the rest of Chapter 32.
  • register_natives registers fifteen natives with register_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 has vm.get_shared/vm.set_shared for per-field scalar access and vm.marshal_shared_into/vm.unmarshal_shared for whole-segment round-trip against a host struct that mirrors the segment and derives KeleusmaType (B34); the piano roll needs neither.
  • The main tick loop calls resume_with_shared, matches VmState, and handles the Reset case, the protocol of Chapter 34.
  • The Reset arm calls replace_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 main and run split 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 through keleusma run.
  • Explore examples/ for Rust embedding examples that demonstrate WCMU computation, native attestation, error propagation through yield, and string interoperability.
  • Run examples/piano_roll.rs for 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 with cargo 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. call returns VmState::Finished(value).
  • yield (non-atomic total). The script yields a value to the host. call returns VmState::Yielded(value). The host calls resume(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. call returns VmState::Yielded(value). The host calls resume(value) to drive the next yield. After the body completes, the next call returns VmState::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_natives registers println (a debug primitive that is a no-op on no_std targets; hosts that want output override with a register_native_closure).
  • keleusma::audio_natives::register_audio_natives registers audio::midi_to_freq, audio::freq_to_midi, audio::db_to_linear, audio::linear_to_db, and the math::* functions enumerated in STANDARD_LIBRARY.md.
  • keleusma::stddsl::Math, Audio, and Shell register through Vm::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.

VariantCondition
StackUnderflowEmpty operand stack on pop
TypeError(msg)Operand type does not match the operation
DivisionByZeroInteger 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.rs shows the auto-sizing pattern end to end.
  • examples/wcmu_attestation.rs shows native bound declaration.
  • examples/wcmu_rejection.rs shows the verifier rejecting an undersized arena.
  • examples/string_ops.rs shows host-registered text concatenation and slicing natives.
  • examples/yield_error.rs shows error propagation through yield with a script-defined Result-shaped enum.
  • examples/method_call.rs shows method dispatch through receiver-style syntax.
  • examples/piano_roll.rs is 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, currently piano_roll_0.kel, piano_roll_1.kel, and piano_roll_2.kel). Run with cargo run --release --example piano_roll --features sdl3-example. Press s to cycle to the next song, r to 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

InstructionOperandsCostDescription
Constu16 index1Push constant from the chunk’s constant pool.
PushImmediateu8 value1Push 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 valueMeaning
0Value::Unit
1Value::Bool(true)
2Value::Bool(false)
3Value::None (the Option::None sentinel)
4Value::Int(0)
5Value::Int(1)
19Value::Int(15)
20..255Reserved. 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

InstructionOperandsCostDescription
GetLocalu16 slot1Push local variable onto stack.
SetLocalu16 slot1Pop 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.

InstructionOperandsCostDescription
GetDatau16 slot1Push data segment slot value onto stack.
SetDatau16 slot1Pop 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).
GetDataIndexedu16 base, u16 len2Pop array index, bounds-check against len, push the value at base + index.
SetDataIndexedu16 base, u16 len2Pop array index then pop value, bounds-check against len, store into the slot at base + index.
BoundsChecku16 bound2Peek 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.

InstructionOperandsCostDescription
CheckedAddnone2Pop 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.
CheckedSubnone2Pop two Int operands; push (low, high, flag).
CheckedMulu8 frac_bits2Pop 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.
CheckedNegnone2Pop one Int operand; push (low, high, flag). The only overflow case under the default 64-bit declared width is -i64::MIN.
CheckedDivu8 frac_bits2Pop 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.
CheckedModnone2Pop 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.
Addnone2Pop 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).
Subnone2Pop two operands of type Byte, Fixed, or Float; push the wrapping or IEEE 754 difference. The Int operand position is excluded.
Mulnone2Pop 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.
Negnone2Pop one operand of type Byte, Fixed, or Float; push the wrapping or IEEE 754 negation. The Int operand position is excluded.
Divnone3Pop two values; push quotient. Traps on divide-by-zero. No overflow flag.
Modnone3Pop two values; push remainder. Traps on divide-by-zero. No overflow flag.

Comparisons

InstructionOperandsCostDescription
CmpEqnone2Pop two values, push true if equal.
CmpNenone2Pop two values, push true if not equal.
CmpLtnone2Pop two values, push true if less than.
CmpGtnone2Pop two values, push true if greater than.
CmpLenone2Pop two values, push true if less than or equal.
CmpGenone2Pop two values, push true if greater than or equal.

Logic

InstructionOperandsCostDescription
Notnone1Pop 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

InstructionOperandsCostDescription
BitAndnone2Pop two Int operands, push bitwise AND.
BitOrnone2Pop two Int operands, push bitwise OR.
BitXornone2Pop two Int operands, push bitwise XOR.
Shlnone2Pop shift count then value; push value shifted left by count & (word_width - 1).
Shrnone2Pop 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.

InstructionOperandsCostDescription
Ifu16 offset1Pop boolean. If false, skip forward to matching Else or EndIf.
Elseu16 offset1Unconditional skip forward to matching EndIf.
EndIfnone1End of if or if-else block. No-op.
Loopu16 offset1Start of loop block. Offset is distance to matching EndLoop.
EndLoopu16 offset1Unconditional jump backward to matching Loop.
Breaku16 depth1Exit enclosing loop at the given nesting depth.
BreakIfu16 depth1Pop 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 through Vm::register_verified_native(name, fn, wcet_bound, wcmu_bound). The host-attested cost folds into the iteration’s WCET and WCMU budget. Compiler emits CallVerifiedNative.
  • External natives. Imported with use external module::name. Host registers through Vm::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 emits CallExternalNative.

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.

InstructionOperandsCostDescription
Callu16 chunk_idx, u8 argc10Direct call to a compiled chunk by index with argc arguments.
CallVerifiedNativeu16 native_idx, u8 argc10Call a verified native function. Cost folds into the iteration budget per host attestation.
CallExternalNativeu16 native_idx, u8 argc10Call an external native function. Iteration cost budget pauses during the call; the verifier tracks invocation count per iteration.
Returnnone2Return 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

InstructionOperandsCostDescription
Yieldnone1Pop output value and suspend. On resume, the host’s input value is pushed onto the stack.
Streamnone1Entry of the streaming region. Only Reset may target it.
Resetnone1Clear the arena’s top region, activate hot swap if scheduled, jump to the matching Stream.

Stack

InstructionOperandsCostDescription
PopNu8 count1Discard 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.
Dupnone1Duplicate top of stack.

Type Construction

InstructionOperandsCostDescription
NewCompositekind, count, byte_size or meta5Pop 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

InstructionOperandsCostDescription
GetFieldStructField (flat/nested/boxed)3Pop 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.
GetIndexArrayElem (flat/nested/boxed)2Pop 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.
GetTupleFieldTupleField (flat/nested/boxed)2Pop tuple, push element. The baked TupleField operand selects a flat read at a byte offset, a nested-composite extraction, or a boxed positional index.
GetEnumFieldEnumField (flat/nested/boxed)2Pop 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.
Lennone2Pop composite value (Array, Text, Tuple), push length as Int.

Type Testing

InstructionOperandsCostDescription
IsEnumu16 enum-name, u16 variant-name, u16 discriminant-value (all constant indices)3Peek the top of the stack; push true if it matches the enum type and variant.
IsStructu16 name3Peek the top of the stack; push true if it matches the struct type.

Casting and Fixed-Point Arithmetic

InstructionOperandsCostDescription
IntToFloatnone2Pop Word, push as Float. Gated on the floats feature.
FloatToIntnone2Pop Float, push as Word (truncates toward zero). Gated on the floats feature.
WordToBytenone2Pop Word, push the low 8 bits as a Byte.
ByteToWordnone2Pop Byte, zero-extend to Word.
WordToFixedu8 frac_bits2Pop Word, push the corresponding Q-format Fixed value with the given fraction-bit count.
FixedToWordu8 frac_bits2Pop Fixed, push the integer portion as a Word; saturating.
FixedMulu8 frac_bits2Pop two Q-format Fixed values, push their product. Shifts the i128 product right by the fraction-bit count and saturates.
FixedDivu8 frac_bits2Pop two Q-format Fixed values, push their quotient. Left-shifts the dividend by the fraction-bit count before dividing and saturates.

Faults

InstructionOperandsCostDescription
Trapu16 message index1Halt 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.

ShapeUsed by
None (zero-operand)33 opcodes (arithmetic, comparison, bit ops, type coercions, stack manipulation, streaming, coroutine, etc.)
u88 opcodes (PushImmediate, PopN, WordToFixed, FixedToWord, FixedMul, FixedDiv, CheckedMul, CheckedDiv)
u1614 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.

CostInstructions
1Const, PushImmediate, GetLocal, SetLocal, GetData, SetData, Dup, Not, If, Else, EndIf, Loop, EndLoop, Break, BreakIf, Stream, Reset, Yield, Trap, PopN
2Add, 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
3Div, Mod, GetField, IsEnum, IsStruct
5NewComposite
10Call, 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.

GrowthInstructions
0Not, 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
1Const, PushImmediate, GetLocal, GetData, Dup, GetDataIndexed, CheckedAdd, CheckedSub, CheckedMul, CheckedDiv, CheckedMod, Call, CallVerifiedNative, CallExternalNative, NewComposite, IsEnum, IsStruct
2CheckedNeg

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.

ShrinkInstructions
0Const, 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
1Add, 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
2SetDataIndexed
nPopN(n), Call(, n), CallVerifiedNative(, n), CallExternalNative(_, n), NewComposite(count)

Heap allocation (bytes)

HeapInstructions
0All instructions not listed below
byte_size from operandNewComposite, 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-attestedCallVerifiedNative 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

RecipeUse it when
Working with TextThe host or scripts need to handle strings.
Auto-sizing the arena from the moduleThe host wants exact WCMU-bounded arena sizing instead of a hardcoded capacity.
The data-loader patternThe host needs read-only configuration data that benefits from script-side editing.
Narrow-runtime type aliasThe host targets a sub-64-bit native runtime (16-bit or 8-bit signed word).
Distributing signed bytecodeThe host delivers compiled modules over an untrusted channel and needs origin authenticity.
Calibrated WCET with a measured cost modelThe 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.

OptionUse it when
Arena::with_capacity(DEFAULT_ARENA_CAPACITY)Hosted development and quick prototyping; a generous default capacity is acceptable.
auto_arena_capacity_forProduction hosts that want the smallest correct capacity, especially when running many VMs or when host memory is tight.
Arena::from_static_bufferBare-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.rs shows 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.

  1. 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.
  2. A multi-headed dispatcher with one head per entry. Each head writes the per-entry constants into the data segment.
  3. 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.rs is the worked demonstrator.
  • tests/narrow_vm.rs is the integration test that pins the pattern.
  • docs/decisions/BACKLOG.md, B16 records the architectural rationale for the parametric shape.
  • The Word, Address, and Float traits live in src/word.rs, src/address.rs, and src/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


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-hz to keleusma-bench --from-log to correct the documented value post-capture.
  • The bench does not measure Yield or Call in 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

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::StaticStr and 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::alloc arena boundary. Carried as Value::KStr handles 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

EscapeResult
\nnewline (U+000A)
\ttab (U+0009)
\rcarriage return (U+000D)
\\literal backslash
\"literal double quote
\0null 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 a for or loop body produce conservative u32::MAX contributions 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 an if/else lose 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 subsequent Op::Add against it saturates the contribution. Hosts that need a tighter bound for their natives supply per-native heap attestations through Vm::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 through VmError::OutOfArena provides 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 i64 are now LexError. The previous behaviour silently produced Value::Int(0) for literals such as 99999999999999999999999999999. The lexer now reports integer literal does not fit in i64 with the source span of the literal.
  • Untyped parameters are inferred when context resolves them. Writing fn main(x) -> Word { x } previously parsed and registered x with no inferred type, then tripped a confusing error later. The type checker now writes inferred primitive types back into the AST. For fn main(x) -> Word { x } the return-type constraint forces x: Word, and the chunk’s param_types carries TypeTag::Word so Vm::call(&[Value::Float(1.5)]) is rejected at the API boundary. If inference does not resolve the parameter (no constraint), the chunk records TypeTag::Composite and 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 code at 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, and loop. All three function categories admit pattern-matched entry points. Multi-headed loop main(...) Stream blocks compile to a single Op::Stream and single Op::Reset envelope around a dispatch wrapped in Op::Loop/Op::EndLoop; each matched head’s body ends with Op::Pop and Op::Break so the structural verifier’s Stream invariants hold.
  • Modules without an entry point are now VmError::VerifyError. A module compiled from source that omits fn main, yield main, or loop main previously surfaced as VmError::InvalidBytecode("no entry point") at the first Vm::call. The constructor Vm::new (and Vm::new_unchecked) now rejects the module with module has no entry point at the API boundary.
  • Premature Vm::resume is now VmError::NotSuspended. Calling vm.resume(value) before vm.call(args) previously surfaced as VmError::InvalidBytecode("cannot resume: VM not suspended"), which conflated API misuse with corrupt bytecode. The runtime now returns the dedicated VmError::NotSuspended variant.
  • Structural-verification rejections now carry source spans. Compile-pipeline rejections for CallIndirect and MakeRecursiveClosure used to attach Span::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

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.

FormCommandPlatform
Explicit subcommandkeleusma run script.kelAll
Extension shorthandkeleusma script.kelAll
Shebang executable./script.kel after chmod +xmacOS 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 kindDeclarationTerminationOperational shape
Atomicfn main() -> WordRuns to completion in one callOne-shot command. Suited to cron jobs, build steps, and manual invocation.
Stagedyield main(tick: Word) -> WordReturns rather than yieldsCooperative task that pauses and resumes across ticks, then finishes.
Daemonloop main(tick: Word) -> WordOnly on shell::exit or a termination signalLong-running service. Returning is treated as an error.

A few operational consequences follow from how the CLI drives each kind.

  • An atomic fn main has 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 calls shell::exit(code). The repository link-checker uses exactly this pattern.
  • A loop main daemon is rate-limited with --tick-interval, which accepts humanized durations such as 100ms, 1s, 1m, 1h, 1d, and 1w, 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. See METRICS.md for the memory-residency analysis and SECURITY_POLICY.md for 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.

NativePurpose
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) -> TextRun 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_fileFile input and output.
shell::writeln_err, shell::write_errLog-shaped output to stderr.
shell::arg, shell::arg_countThe 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.

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.

StepCommand
Generate a signing keypairkeleusma keygen --seed sign.seed --public sign.pub
Generate an encryption keypairkeleusma keygen --kind encryption --seed dest.seed --public dest.pub
Compile, sign, and encryptkeleusma compile script.kel --signing-key sign.seed --encryption-key dest.pub -o script.kel.bin
Run, verifying and decryptingkeleusma 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::run and its siblings has time and memory the verifier cannot model. The static worst-case bounds do not cross these calls. shell::run_timeout caps wall-clock time per call but not memory.
  • shell::read_file and 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::exit terminates 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.

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).

NativeSignaturePurpose
shell::getenv(name: Text) -> Option<Text>Read an environment variable; Some(value) or None
shell::has_env(name: Text) -> boolTest 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) -> TextExecute 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() -> WordReturn the current Unix timestamp in milliseconds
shell::read_file(path: Text) -> TextRead 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) -> boolTest 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() -> WordNumber 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() -> WordCurrent process identifier, for pidfile creation
shell::hostname() -> TextHost name reported by the operating system; traps when unavailable
shell::pwd() -> TextCurrent 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 recommendationStatusNotes
shell::sleep_msImplementedNegative or zero inputs return immediately rather than rejecting.
shell::now_unix_msImplementedReturns 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_errPartially implementedThe 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_existsImplementedFollows symlinks; the no-follow variant is not yet exposed but can be added if a use case surfaces.
shell::read_fileImplementedReturns 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_fileImplementedSame trap-on-error pattern as read_file.
shell::read_linesNot implementedThe 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 correctnessCorrectedThe 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 captureClosedshell::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.

PriorityNativeUse case
Lowshell::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, and shell::run_timeout spawn 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_timeout caps wall-clock time per call but not memory, and the cap is dynamic rather than statically verified.
  • Unbounded host-heap allocation. read_file, run, and run_full allocate 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::exit terminates the host process. It calls std::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_file requires UTF-8. Non-UTF-8 file content traps rather than returning bytes; the bundle has no binary-file accessor.
  • set_script_args is thread-local. A host that calls set_script_args on one thread and runs the script on another observes the std::env::args fallback 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_unchecked and 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

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 gateEncryption gatePolicy summary
InactiveInactiveV0.2.0 permissive default. Accepts source files, unsigned bytecode, signed bytecode with --verifying-key, encrypted bytecode with --decryption-key.
ActiveInactiveSource 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.
InactiveActiveUnencrypted 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.
ActiveActiveStrict 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.

  1. KELEUSMA_TRUSTED_KEYS_DIR environment variable points at a directory of *.pub files. Each file holds a 32-byte Ed25519 verifying key.
  2. Platform-conventional directory: /etc/keleusma/trusted_keys on Unix-like systems, %PROGRAMDATA%\keleusma\trusted_keys on Windows. Used when the environment variable is unset.
  3. KELEUSMA_REQUIRE_SIGNED=1 environment 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:

  1. KELEUSMA_DECRYPTION_KEYS_DIR environment variable points at a directory of *.seed files. Each file holds a 32-byte X25519 private key.
  2. Platform-conventional directory: /etc/keleusma/decryption_keys on Unix-like systems, %PROGRAMDATA%\keleusma\decryption_keys on Windows.
  3. KELEUSMA_REQUIRE_ENCRYPTED=1 environment 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):

  1. Generate per-employee X25519 keypairs at the head office. Deliver each employee’s private key to that employee’s workstation through trusted personnel.
  2. Generate the head office’s Ed25519 signing keypair. Keep the seed at the head office. Distribute the public key to each workstation.
  3. Install the keleusma-cli binary on each workstation.
  4. Enrol the head office’s verifying key into each workstation’s /etc/keleusma/trusted_keys/ directory.
  5. 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.

  1. Each workflow version is signed by the approval team.
  2. Production processing nodes carry only the approval team’s verifying key in their trust store.
  3. 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 permissions keygen sets 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:

  1. Generate a new signing keypair on the signing host (keleusma keygen --seed sign.v2.seed --public sign.v2.pub).
  2. 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.
  3. Re-sign the artefacts that must remain runnable with the new key.
  4. 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-cli binary 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_id byte 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_id carried 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

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:

  1. The i128 intermediate’s high half reflects signed arithmetic. For two non-negative operands whose sum exceeds i64::MAX, the high half is 0 and the low half is the wrap (a negative i64 whose bit pattern matches the high bit of the unsigned sum). The carry-out is one regardless, derivable from the overflow class.

  2. For two operands whose sum needs more than 65 bits (impossible for i64 addition but reachable through multiplication), the high half carries genuine bits 64-127. The multiplication example demonstrates this case directly.

  3. Division and modulo route through dedicated Op::CheckedDiv and Op::CheckedMod opcodes that compute the true result in i128 and dispatch to the same (h, l, flag) shape as the other checked operations. The i64::MIN / -1 corner (true result 2^63, decomposed as high=0, low=i64::MIN, flag=1) routes to the overflow arm; the i64::MIN % -1 corner (true result 0, division step overflows) likewise flags through the overflow arm with high=0, low=0. Division by zero continues to trap with VmError::DivisionByZero because 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

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 .kel song 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

  1. How this document relates to the source
  2. What this example demonstrates
  3. Building and running
  4. Controls
  5. Gameplay rules
  6. Host and script architecture
  7. Reading the game-tick script
  8. Reading the dungeon generator
  9. Hot reload
  10. Reading the player AI script
  11. Reading the combat script
  12. Reading the artificial-intelligence archetypes
  13. Reading the item-effect scripts
  14. Reading the bestiary script
  15. Reading the consume and descend scripts
  16. Exercises for the reader
  17. Capstone projects
  18. 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_NAMES and ARMOR_NAMES host-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

KeyAction
Arrow keys, h, j, k, lCardinal movement, one tile per press.
y, u, b, nDiagonal movement.
Period, SpaceWait one turn in place.
QQuaff the held potion. The potion’s effect resolves immediately and the slot empties.
RRead the held scroll. The scroll’s effect resolves immediately and the slot empties.
F5Hot reload every Keleusma script from disk. See Hot reload.
EscapeQuit 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.

ModuleResponsibility
main.rsEntry point, SDL3 setup, script compilation, event loop.
world.rsMap, player, monster, item, message log, field-of-view buffers.
bestiary.rsOne hundred monster kinds organised easy-to-hard.
items.rsWeapon, armor, potion, scroll tables. Per-run shuffled appearances.
tiles.rsProcedural sprite atlas built from primitives on SDL3 textures.
render.rsHead-up display, tile grid with field-of-view shading, monster and item draws, message bar.
input.rsKeyboard-to-command translation.
fov.rsRecursive shadowcasting on the eight octants.
combat.rsThin wrapper that samples the d20 roll, dispatches the combat virtual machine, and applies damage to the world.
ai.rsPool of artificial-intelligence, item-effect, player, and combat virtual machines.
natives.rsHost 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.

NativeEffect
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.

CodeMeaning
0Wait
1 to 8Move north, south, west, east, north-west, north-east, south-west, south-east
9Descend stairs or step on exit
10Quaff held potion
11Read 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.

  1. Call host::clear_floor to reset the map and entity lists.
  2. Place eight rectangular rooms at random positions. Room dimensions are between four and nine tiles per axis.
  3. Connect consecutive rooms in placement order with an L-shaped corridor between their centres. After seven corridors every room is reachable from room zero.
  4. Place the player at room zero’s centre.
  5. Place stairs down at room seven’s centre. On floor one hundred, place the exit tile instead.
  6. Spawn monsters per the floor distribution. Half are the floor’s favourite kind; the other half draw from the previous-floor pool.
  7. 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.

NativeEffect
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, press F5, 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).

ActionMeaning
0Wait. (tx, ty) ignored.
1Move 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.
2Ranged 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.

ScriptBehaviour
rogue_ai_idle.kelWaits. Used by stationary creatures.
rogue_ai_chaser.kelGreedy one-step chase when the player is visible.
rogue_ai_wander.kelRandom cardinal step when the player is not visible. Greedy chase when visible.
rogue_ai_sleeper.kelWaits until the player enters line of sight, then chases.
rogue_ai_ranged.kelRetreats when adjacent. Ranged attack when in sight and out of melee.
rogue_ai_fast.kelBehaves like the chaser. The host double-invokes the script per turn.
rogue_ai_smart.kelHeuristic step on the dominant axis when the player is visible.
rogue_ai_boss.kelFour-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.kelRemembers 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.

CodeAction
0None.
1Magic mapping. Reveal the entire floor as explored.
2Teleport. Warp the player to a random walkable cell.
3Identify. Mark every potion as identified.
4Enchant weapon. Advance the equipped weapon tier by status_arg.
5Enchant armor. Advance the equipped armor tier by status_arg.
6Light. Mark a small radius around the player as explored.
7Detect monsters. Post the floor’s monster count to the message log.
8Sleep. Placeholder, no effect.
9Confusion. Placeholder, no effect.
10Remove curse. Placeholder, no effect.
11Restoration. 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_turns counter 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 levitating timer 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 invisible flag 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_turns field 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_turns field 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

IDTile
0Floor
1Wall
2Closed door
3Open door
4Stairs down
5Exit

Item kind identifiers

IDKind
0Food
1Gold
2Weapon
3Armor
4Potion
5Scroll
6Corpse

Artificial-intelligence action codes

CodeAction
0Wait
1Move or melee into target cell
2Ranged attack at target cell

Item-effect status codes

See Reading the item-effect scripts above for the full table.

Default tuning parameters

ParameterDefault
Map sizeSixty-four by forty tiles
Display tile sizeSixteen by sixteen pixels. Sprite art is authored at twenty-four pixels and downscaled to sixteen on copy.
Field-of-view radiusEight tiles
Starting hit pointsTwelve
Starting hungerOne hundred
Hunger tickMinus one every two turns
Food restorationForty hunger
Hit-point regenerationOne per ten turns when hunger is positive
Starvation damageOne per turn at hunger zero
Hit-point gain per levelThree
Skill gain per levelOne
Rooms per floorEight
Monsters per floorFloor plus four, capped at twelve
Food per floorThree to five
Potions per floorTwo to four
Scrolls per floorTwo to four
Weapons per floorZero to one
Armor pieces per floorZero to one
Gold piles per floorFour 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 through readlink where present.
  • Maximum resident set size: peak RSS as reported by /usr/bin/time -l on 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

RunnerBinary sizeMax RSSPeak footprintCyclesReal time
bash 5.x (system)1.3 MB1.85 MB1.26 MB6.3 M< 10 ms
Keleusma 0.2.12.1 MB2.85 MB1.49 MB8.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 + framework11 MB5.7 MB54.8 M20 ms
Ruby 3.1 (MacPorts)34 KB launcher + framework30.7 MB25.7 MB128 M40 ms
Node.js (MacPorts)77 MB42.5 MB13.7 MB108 M40 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)

FeatureApprox. 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 binary2.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 + alloc posture 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.

  1. AGENTS.md for the project’s conventions and the per-session protocol.
  2. This document for the AI-specific pattern gotchas.
  3. docs/architecture/LANGUAGE_DESIGN.md for the why behind the design.
  4. docs/decisions/RESOLVED.md for the historical record of architectural decisions. Most “why was it done this way” questions are answered here.
  5. docs/process/TASKLOG.md for current sprint state and docs/process/REVERSE_PROMPT.md for 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::HashMapalloc::collections::BTreeMap
std::collections::HashSetalloc::collections::BTreeSet
std::fs, std::io, std::processNot available; surface as a host-registered native function
std::sync::Mutex, std::sync::RwLockNot available; the runtime is single-threaded
Box::leak, Box::pinAvailable through alloc::boxed::Box but the leak pattern is rejected
std::time::Instant, std::time::SystemTimeNot 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 Trait is 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:

  1. Read docs/process/TASKLOG.md for current task state.
  2. Read docs/process/REVERSE_PROMPT.md for the last AI-to-human handoff.
  3. Wait for human prompt before proceeding.

After completing each task:

  1. Update task status in TASKLOG.md.
  2. Overwrite REVERSE_PROMPT.md with verification, questions, concerns, and intended next step.
  3. 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.

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.