Frequently Asked Questions
Navigation: Guide | Documentation Root
This document collects surprises that early adopters have run into. The intent is to answer the questions that the rest of the documentation does not yet anticipate, not to be exhaustive.
Strings
Strings are not the Keleusma value proposition. The language’s value proposition is definitive Worst-Case Execution Time and Worst-Case Memory Usage verification for embedded real-time scripting. For string-heavy standalone work, a dynamic language with a rich standard library is the better tool. Python, Ruby, JavaScript, or any of the many shell-and-text-processing languages will all handle strings more ergonomically and with more built-in utility than Keleusma. Strings in Keleusma exist as a host-boundary type and as a debugging convenience; they are not the surface to optimise for.
Text surface in V0.2.0
V0.2.0 ships only the static-string surface at the script level. String literals ("...") compile to Value::StaticStr constants in the bytecode’s read-only constant pool. The Text primitive type names the surface type for static strings, host-produced dynamic strings (Value::KStr arena handles), and string-typed parameters across the host boundary. The bundled to_string, concat, slice, and length utility natives retired alongside f-string interpolation in the V0.2.0 Phase 3.5 text-composition removal. The runtime still distinguishes static (StaticStr) and dynamic (KStr) variants behind Text; the cross-yield prohibition continues to apply to dynamic strings.
The recommended pattern is to register native Rust functions that perform the string work and expose them to the script. Rust’s standard library handles formatting, splitting, regex, encoding conversion, and Unicode operations far better than anything reasonable to build inside the script. The host writes a small Rust function, registers it with one register_fn call, and the script gets a single use declaration that yields native performance and full Rust ecosystem access.
#![allow(unused)]
fn main() {
// Rust host code.
use keleusma::{Arena, Value, vm::Vm};
let mut vm = Vm::new(module, &arena)?;
// Host-defined string helpers using Rust's standard library.
vm.register_fn("text::upper", |s: String| -> String {
s.to_uppercase()
});
vm.register_fn("text::trim", |s: String| -> String {
s.trim().to_string()
});
vm.register_fn_fallible(
"text::split_first_word",
|s: String| -> Result<String, keleusma::VmError> {
s.split_whitespace()
.next()
.map(|w| w.to_string())
.ok_or_else(|| keleusma::VmError::NativeError("empty input".into()))
},
);
// The script imports each native by name.
//
// use text::upper
// use text::trim
// use text::split_first_word
//
// fn greet(name: Text) -> Text {
// text::upper(text::trim(name))
// }
}
The host owns the string-handling vocabulary; the script consumes it through use declarations. See EMBEDDING.md for the full native-registration surface.
Where text works
- Static string literals. Compiled to
Value::StaticStrand reside in the bytecode’s constant pool. May flow anywhere admissible, including across the yield boundary in the dialogue type. - Arena-resident dynamic strings. Produced by host-registered native functions through the
KString::allocarena boundary. Carried asValue::KStrhandles that resolve through the host-owned arena and become stale on the next arena reset. Subject to the cross-yield prohibition. See TYPE_SYSTEM.md for the full text-type discipline.
The Value::DynStr global-heap variant present in V0.1.x was removed in V0.2.0. All dynamic text is arena-resident.
Escape table for static string literals
| Escape | Result |
|---|---|
\n | newline (U+000A) |
\t | tab (U+0009) |
\r | carriage return (U+000D) |
\\ | literal backslash |
\" | literal double quote |
\0 | null byte |
All other characters appear directly without escaping. Any other backslash sequence is a lex error. V0.2.0 retired the f-string-specific \{ and \} escapes alongside f-string interpolation; { and } are ordinary characters inside "...".
WCMU Coverage
Exponential string concatenation bypasses the WCMU bound
A program like
fn main() -> Text {
let s = "a";
let s = s + s;
let s = s + s;
/* sixty doublings later */
s
}
would in V0.1.x have compiled, been admitted by Vm::new, and exhausted the host process at runtime. V0.2.0 addresses both the allocator and the static-analysis dimensions.
Issue one: string + previously allocated from the global allocator, not the arena. Resolved in V0.2.0. Op::Add on text operands now produces a Value::KStr allocated through KString::alloc in the arena’s top region. Allocation failure surfaces as VmError::OutOfArena rather than exhausting the host process. The Value::DynStr variant has been removed entirely.
Issue two: the WCMU pass did not previously track text sizes statically. Resolved in V0.2.0. The verifier now runs a text-size abstract interpretation pass over each chunk that tracks a per-slot TextSize::{NotText, Known(u32), Unbounded} lattice through the bytecode, evaluating the OpCost::Dynamic cost of Op::Add on text against the operand bounds and accumulating the result into the chunk’s WCMU heap total. Programs that doubly grow a text value cumulatively saturate the bound to u32::MAX, which the safe constructor rejects under the default OverflowPolicy::Reject. The doubling-string example above is now rejected at Vm::new when expressed as a Stream block.
Limitations of the V0.2.0 text-size analysis.
- Loops widen text values to
Unbounded. Text operations inside afororloopbody produce conservativeu32::MAXcontributions because the pass is linear, not iterative. Programs whose text concatenation happens once per stream iteration are handled precisely; programs that mix loops and text are conservative. - Branches widen text values to
Unbounded. Text values written conditionally inside anif/elselose their precise bound. The pass continues to correctly bound text written outside conditionals. - Native return values are
Unbounded. Text returned from a registered native function is tracked as unbounded; any subsequentOp::Addagainst it saturates the contribution. Hosts that need a tighter bound for their natives supply per-native heap attestations throughVm::set_native_bounds. - Atomic-total programs (no Stream block) are not subject to the per-iteration WCMU bound. A
fn main() -> Text { let s = "a"; let s = s + s; ... }compiles and runs because the resource-bounds check applies only to Stream chunks. The arena exhaustion path throughVmError::OutOfArenaprovides the graceful-failure guarantee for these programs.
Recommendation for text-heavy work. Treat heavy text work as host-attested and out-of-band. Register native Rust functions (see the section above) that perform the work in a bounded way and let scripts consume them. Host-side text helpers can be implemented to fail safely on large input rather than allocate unboundedly.
A worked example of the host-attestation pattern for arena-resident allocations lives in examples/wcmu_attestation.rs.
V0.2.0 fail-fast on too-small arenas
The previous releases admitted an Arena::with_capacity(0) through Vm::new and then aborted the host process via handle_alloc_error on the first push. V0.2.0 changes this in two layers.
Construct-time minimum reservation. Vm::new and Vm::new_unchecked pre-reserve a small minimum operand-stack and call-frame allocation in the arena’s bottom region. If the arena cannot hold the minimum, both constructors return the new VmError::OutOfArena variant rather than aborting. The minimum is conservative (four stack slots and one call frame); arenas around five hundred bytes or larger pass.
Run-time push paths return OutOfArena. Every operand-stack and call-frame push in the VM execution loop now routes through internal sp! and fp! macros that call Vec::try_reserve first and return VmError::OutOfArena on allocation failure. Programs whose runtime usage exceeds the arena no longer abort the host process; the host gets a typed error and can decide how to handle it (drop the VM, reset state via Vm::reset_after_error, retry with a larger arena, or surface the error to the user).
The combination means the arena-resident parts of execution — the operand stack and call frames — are now fully arena-bounded with graceful failure.
#![allow(unused)]
fn main() {
let arena = Arena::with_capacity(2 * 1024);
let mut vm = Vm::new(module, &arena)?;
// ...
match vm.call(&[]) {
Ok(state) => /* handle state */,
Err(VmError::OutOfArena(msg)) => {
eprintln!("arena exhausted: {}", msg);
// recover or reconfigure
}
Err(other) => /* handle other errors */,
}
}
For sizing the arena, use auto_arena_capacity_for plus a host-side margin, or the bundled DEFAULT_ARENA_CAPACITY of sixty-four kilobytes for typical embedded scripting.
Other Surprises
Vm::call rejects wrong arg count or type up front
Hosts that drive Keleusma scripts from Rust pass arguments through vm.call(&[Value::Int(1), Value::Int(2)]). The runtime validates the argument count against the entry chunk’s param_count and each argument’s runtime type against the parameter’s declared TypeTag before any bytecode runs. Too few or too many arguments, or a wrong-typed argument, produces a VmError::TypeError at the call boundary rather than a confusing arithmetic error later.
Typical reproduction:
// Script: fn main(a: Word, b: Word) -> Word { a + b }
vm.call(&[Value::Int(1)])
// -> VmError::TypeError("function `main` expected 2 arguments, got 1")
vm.call(&[Value::Int(1), Value::Float(2.5)])
// -> VmError::TypeError("function `main` parameter 1 expected Word, got Float")
Hosts that genuinely want to pass an opaque or composite value receive TypeTag::Composite validation, which accepts any Value. The param_types field of each chunk is the source of truth for what the runtime will accept; the compiler populates it from the function’s declared parameter types.
Vm::resume validates the resume value’s type for Stream blocks
A loop main(x: T) -> R script yields a value of type R and resumes with the next iteration’s value of type T. The host calls vm.resume(value) to drive the next iteration. The runtime validates value against the loop’s parameter type before pushing it into the parameter slot.
#![allow(unused)]
fn main() {
// Script: loop main(x: Word) -> Word { let z = yield x; z }
vm.call(&[Value::Int(11)]) // Ok(Yielded(Int(11)))
vm.resume(Value::Float(1.5)) // VmError::TypeError(
// "loop `main` resume expected Word, got Float")
}
The yield expression’s type and the resume value’s type are the same by language design (the parameter type), so a single tag at the chunk level covers both directions of the dialogue.
Parser rejects deeply nested expressions
The parser is a recursive-descent walker. Deeply nested parens (around a thousand or more) used to overflow the host process’s stack. The parser now bails with a typed ParseError at MAX_PARSE_DEPTH = 32 levels of nesting. The limit applies at the three recursive entry points (parse_expr, parse_type_expr, parse_pattern).
Hosts that produce Keleusma source programmatically (templating, code generation) should keep expression nesting well under thirty-two levels. Realistic hand-written source rarely approaches the limit; the bound exists to prevent a malicious or accidental input from killing the host process.
Local bindings are immutable
let bindings cannot be rebound or mutated. The data segment is the only region of mutable state observable to a script, and it is accessible only from a loop-classified entry point. Accumulation across a loop iteration in an atomic-total fn is therefore not possible without either (a) a loop main script using the data segment, or (b) a host-side fold native. See WHY_REJECTED.md under the recursive-closure entry for examples of both rewrites.
Closures are rejected at the type-checker stage
V0.2.0 Phase 4 retired the closure family: the Op::PushFunc, Op::MakeClosure, Op::MakeRecursiveClosure, and Op::CallIndirect opcodes are gone, the Value::Func runtime variant is gone, and the closure-hoisting compiler pass is gone. The type checker now rejects Expr::Closure with the diagnostic closures are not supported; V0.2.0 admits only direct calls and trait dispatch under the conservative-verification stance. Rewrite as a top-level fn or trait method. First-class function references (e.g. let f = my_func;) are likewise rejected by the compiler. This is the conservative-verification stance documented in LANGUAGE_DESIGN.md. The valid form of unbounded execution is the top-level loop block enforced by the productivity rule.
Pipeline operator requires parentheses
The right-hand side of |> must be a function call with parentheses, even when the function takes no additional arguments. expr |> f is a parse error; expr |> f() is correct.
What does the signed modifier do?
V0.2.0 introduces a signed modifier on the entry function declaration (signed fn main, signed yield main, signed loop main). It sets FLAG_REQUIRES_SIGNATURE in the framing header so the load-time runtime refuses the module unless a cryptographic signature is attached and verifies against the host’s trust matrix.
The signing operation itself is a toolchain step independent of the compiler. keleusma compile script.kel --signing-key seed.bin produces an Ed25519-signed bytecode file; the consumer registers the matching public key on the VM (Vm::register_verifying_key) and loads through Vm::load_signed_bytes or hot-swaps signed updates through Vm::replace_module_from_bytes. Vm::load_bytes refuses signed modules with a diagnostic that names the alternate entry point.
The feature requires the signatures cargo feature, which is off by default and pulls in ed25519-dalek. Builds without the feature accept unsigned modules normally and reject signed modules with LoadError::SignaturesUnsupported. The signed surface keyword still parses without the feature so source files remain portable.
Use case: multi-party module delivery to embedded targets. A signer compiles scripts and signs them; a device flashed with the signer’s public key verifies before loading. See the Distributing signed bytecode cookbook recipe and R42 in RESOLVED.md.
If-else at statement position requires a trailing semicolon
The parser does not auto-insert semicolons. An if-else expression used as a statement (followed by another statement) requires ; even though the expression evaluates to unit.
if state.rem0 == 0 {
/* ... */
} else {
state.rem0 = state.rem0 - 1;
}; // <-- this semicolon is required
state.rem1 = state.rem1 - 1;
Opaque types flow through the native boundary as Value::Opaque
V0.2.0 introduced first-class opaque type support through the HostOpaque trait in the keleusma::opaque module. The host implements the trait for a Rust newtype around the value it wants to expose; native functions produce opaque values through host_arc(...) and consume them by extracting a typed reference through dyn HostOpaque::downcast_ref::<T>(). The script declares the type by name in function signatures, and the type checker resolves the name as Type::Opaque. Opaque values are host-managed through Arc, have a lifetime independent of the arena, may cross the yield boundary, and contribute zero to the script-side WCMU bound. See the “Opaque Host Types” section of EMBEDDING.md and the worked example in examples/opaque_rust_string.rs.
Integer arithmetic wraps to the target word width
Keleusma’s Word is a fixed-width signed integer whose width is declared by the target descriptor. Arithmetic operations mask the result to that width using a sign-extending shift on every step. Overflow does not produce a typed error; the result silently wraps in the modular sense the declared width permits.
fn main() -> Word {
let max = 9223372036854775807;
max + 1
}
// On a sixty-four bit target this returns -9223372036854775808.
This choice is intentional. The Worst-Case Execution Time and Worst-Case Memory Usage bounds the language guarantees depend on every arithmetic operation having a fixed step count. A trapping-overflow semantics would either inflate the worst-case cost of every operation or introduce a control-flow edge that the static analysis would have to enumerate. The wrapping semantics gives a predictable step count and a closed result domain that the analysis can reason about uniformly.
Hosts that need overflow detection register a native that performs the checked operation against a wider Rust integer and surfaces an error through VmError::NativeError. The host owns the checked-arithmetic vocabulary; the script consumes it through use declarations.
Loop-calls-loop is rejected by lexical productivity
The productivity rule that admits loop blocks is enforced by a purely lexical structural check. The verifier walks the syntactic body of each loop and requires that every control-flow path through one iteration contains at least one yield. A loop block whose body’s only yield is inside a function it calls is rejected because the structural pass does not chase the call.
yield helper() -> Word { yield 1 }
loop main() -> Word {
let v = helper(); // <-- structural pass does not see the yield
v
}
This program is rejected with loop body has no yield on at least one path. The rule errs conservative on purpose. A semantic check that chased calls would be unsound for parameter-dependent dispatch or trait method resolution and would also have to handle mutually recursive call graphs. The lexical check is sound, fast, and easy to explain at the cost of forcing the yield to appear at the top level of the loop body.
The recommended pattern is to keep yield at the top of the loop body and call helpers around it.
yield helper() -> Word { yield 1 }
loop main() -> Word {
let v = yield helper(); // direct yield satisfies the rule
v
}
The same constraint applies to if/else and match branches inside the loop body. Every fall-through path must contain a yield, or the branch must break out.
V0.2.0 boundary diagnostics
The construction and call surfaces were tightened in V0.2.0 so that several previously silent or misleading cases now produce typed diagnostics at the appropriate boundary.
- Integer literals that overflow
i64are nowLexError. The previous behaviour silently producedValue::Int(0)for literals such as99999999999999999999999999999. The lexer now reportsinteger literal does not fit in i64with the source span of the literal. - Untyped parameters are inferred when context resolves them. Writing
fn main(x) -> Word { x }previously parsed and registeredxwith no inferred type, then tripped a confusing error later. The type checker now writes inferred primitive types back into the AST. Forfn main(x) -> Word { x }the return-type constraint forcesx: Word, and the chunk’sparam_typescarriesTypeTag::WordsoVm::call(&[Value::Float(1.5)])is rejected at the API boundary. If inference does not resolve the parameter (no constraint), the chunk recordsTypeTag::Compositeand the runtime accepts any value. - Duplicate function heads are rejected, entry point or not. Two function definitions that share the same name whose parameter signatures cannot be disambiguated as multi-headed pattern matching (same shape, no guard) used to keep the first and silently discard the rest. The compiler now reports
function head is dead codeat the second definition. The rule applies to all categories (fn,yield,loop) and to every function, not just the entry point. - Multi-headed entry points are accepted for
fn,yield, andloop. All three function categories admit pattern-matched entry points. Multi-headedloop main(...)Stream blocks compile to a singleOp::Streamand singleOp::Resetenvelope around a dispatch wrapped inOp::Loop/Op::EndLoop; each matched head’s body ends withOp::PopandOp::Breakso the structural verifier’s Stream invariants hold. - Modules without an entry point are now
VmError::VerifyError. A module compiled from source that omitsfn main,yield main, orloop mainpreviously surfaced asVmError::InvalidBytecode("no entry point")at the firstVm::call. The constructorVm::new(andVm::new_unchecked) now rejects the module withmodule has no entry pointat the API boundary. - Premature
Vm::resumeis nowVmError::NotSuspended. Callingvm.resume(value)beforevm.call(args)previously surfaced asVmError::InvalidBytecode("cannot resume: VM not suspended"), which conflated API misuse with corrupt bytecode. The runtime now returns the dedicatedVmError::NotSuspendedvariant. - Structural-verification rejections now carry source spans. Compile-pipeline rejections for
CallIndirectandMakeRecursiveClosureused to attachSpan::default(), which hid the offending source position. Each rejection now points at the originating function or closure declaration so editors can underline the construct.
Where to look for more
- The full language reference is GRAMMAR.md (descriptive; the normative reference is the parser at
src/parser.rs). - The verifier rejection catalog is WHY_REJECTED.md.
- The embedding API surface is EMBEDDING.md.
- The conservative-verification stance is in LANGUAGE_DESIGN.md.
When in doubt about whether a behaviour is intended or a bug, the parser, type checker, and verifier are authoritative; the documentation is descriptive.