The Seven Runes
Every statement in Futuruna begins with a rune — a single character that declares what the statement is.
| Rune | Question | What it does |
|---|---|---|
# | What exists? | Types, effects, traits, impls |
> | What happens? | Functions, actors, modules |
| | What must be true? | Rules, invariants, handlers, scopes |
= | What is? | Bindings, monadic bind |
~ | What flows? | Reactive streams, subjects |
@ | Where do proofs stop? | IO, imports, dependencies, meta |
? | Prove it. | Verification demands |
# -- What exists
Defines the shape of data: types, algebraic effects, traits, and implementations.
Struct (single-variant product type)
# Point(x: Float, y: Float)
# Weather(city: City, temp: Float, condition: Condition, wind_kph: Float)
Construction uses positional arguments:
= p = Point(1.0, 2.0)
= w = Weather(Copenhagen, 22.0, Sunny, 10.0)
Fields are accessed with dot notation: w.temp, w.condition.
Enum (multi-variant algebraic data type)
# Color = Red | Green | Blue
# Shape = Circle(radius: Float) | Rectangle(width: Float, height: Float)
# Option(a) = None | Some(a)
# Chain(a) = End | Link(head: a, tail: Chain(a))
Option(a) = None | Some(a) and Result(a, e) = Ok(a) | Err(e) are part of
the prelude. Their constructors (None, Some, Ok, Err), Pair, and the
built-in list constructors Nil and Cons belong to those types: another type
may not declare a variant with one of these names. A variant may also not reuse
a built-in type name such as Int or String; Futuruna has no type aliases.
A field of a multi-variant type can be read directly only when every variant declares it with the same type. Otherwise, match on the variant first. Inside an arm whose pattern names the variant, the matched variable is refined to that variant and its fields are readable:
# Income = Salary(amount: Int) | Pension(amount: Int, supplement: Int)
> total(i: Income) -> Int {
match i {
| Salary -> i.amount
| Pension -> i.amount + i.supplement
}
}
Here i.amount is also readable outside a match, because both variants declare
amount: Int; i.supplement is not.
ADT with methods
# Color = Red | Green | Blue {
> name(c) -> String {
match c {
| Red -> "red"
| Green -> "green"
| Blue -> "blue"
}
}
}
A method belongs to its type, not to the global namespace. The first parameter (without type annotation) receives the value the method is called on, so two types may each declare a method with the same name.
Methods declared in a type body and in # impl Trait for Type blocks are
called the same way:
x.m(args)calls the methodmof the type ofx.- A free call
m(x, args)of a method name dispatches on the type of its first argument, exactly likex.m(args). - A declared global function always wins for free calls. With
> fee(x: Int) -> Intdeclared,fee(10)calls that function even when a type has afeemethod;Case(1).fee()still calls the method ofCase. - A free call whose first argument's type has no such method is a runtime error naming the method and the type.
Product types with rule members
# TaxCase(person: Person, rates: Rates) {
| taxable_income() -> person.gross_income
| tax_due() -> taxable_income() * rates.percent / 100
> label() -> String { "tax:" + show(tax_due()) }
}
= tax = TaxCase(Person(1000), Rates(25))
= due = tax.tax_due()
= label = tax.label()
When a product type body contains | entries, those entries are rule members
of the product value. This is the RuleScope model: a pure calculation object
whose constructor inputs are visible inside scoped rules. Rule members can call
sibling rule members, ordinary global functions/rules, and use under /
exception with the same priority semantics as top-level rules. Rule member
names do not leak globally. Scoped dispatch matches both name and arity, so a
same-named member with a different parameter count does not hide an ordinary
global rule. Pure zero-argument rule members are memoized for the duration of
one root member evaluation; repeated dependencies therefore retain value
semantics without repeatedly evaluating the same rule cascade.
The same product body may contain ordinary > methods. Methods share the
product instance and can call rule members with tax_due() or self.tax_due().
Fields are also available in product methods, so person.gross_income works in
both | rule members and > methods. A | rule member and > method cannot
use the same member name.
A rule member or method is called on any expression whose value is the
product: a variable, a nested field (x.p.rate()), a call result
(head(members).rate()), or a lambda or loop parameter
(map(members, |p| p.rate())). When the receiver's type is known, the checker
resolves the member before execution; otherwise the call is resolved from the
runtime value.
RuleScope is different from | scope Name { ... }: | scope owns reactive
lifecycle work such as subjects, streams, subscriptions, and teardown. A
RuleScope has no mutation or lifecycle ownership.
Effect declaration
# effect Console {
> say(msg: String) -> ()
> ask(prompt: String) -> String
}
Defines abstract operations that callers can intercept via | handle.
Trait declaration
# trait Printable {
> display(self) -> String
}
# trait Greetable {
> greet(self) -> String {
"Hello, " + self.display() -- default implementation
}
}
Impl block
# impl Printable for Color {
> display(self) -> String {
match self {
| Red -> "Red"
| Green -> "Green"
| Blue -> "Blue"
}
}
}
An impl names a declared trait and a declared (or built-in) type. It provides
every trait method that has no default body and no other methods. Each method
restates its trait signature: the same number of parameters, self in the
same position, and the parameter and result types the trait declares (Self
stands for the implementing type). Any difference is a type error at the impl.
> -- What happens
Defines transformation: functions, actors, and modules.
Function
> add(a: Int, b: Int) -> Int { a + b }
> greet(name: String) -> String {
"Hello, " + name + "!"
}
Parameters can omit type annotations (inferred). Return type after ->.
An ordinary function name has one declaration in each authored lexical scope,
regardless of parameter count. Duplicate declarations are errors before
execution. Separate modules and nested scopes may use the same name, and a
local declaration may override a name supplied by the prelude or a prefix
import. Use | rules for a family of clauses and cases.
Function with effects
> process(item: String) -> String with Console, Logger {
say("Processing: " + item)
log("info", "processed " + item)
item
}
The with clause declares which effects the function may perform.
Function with inout (mutable value semantics)
> sort_vec(xs: inout List(Int)) -> () {
@ rust { xs.sort(); }
}
inout parameters are passed as &mut T. The caller's value is mutated in place.
Generic function
> map_list(xs: Chain(a), f: a -> b) -> Chain(b) {
match xs {
| End -> End
| Link(h, t) -> Link(f(h), map_list(t, f))
}
}
Lowercase type variables (a, b) become Rust generics.
Actor
> actor counter(state: Int) {
| Increment -> state + 1
| Decrement -> state - 1
| Reset -> 0
}
Actors have a state parameter and message handlers. Each handler returns the new state. An actor handles one message at a time; see Actors for sending, ask, and sharing handles.
Module
> module Math {
> square(x: Int) -> Int { x * x }
> cube(x: Int) -> Int { x * x * x }
}
Modules can be nested. Contents are accessed via Math.square(5).
A module instance owns its initialized bindings. Initializers run once, including private bindings and bindings that are never read. Reading a binding or calling a module function reuses that instance. A module declared inside a function is instantiated each time that declaration executes and captures the surrounding values it uses.
Captures retain their types, including type parameters of the enclosing
function and compound values such as List(a) or Result(a, b). Each call
owns a separate instance. A module function returned as a value keeps that
instance alive:
> remember(value: a) -> Int -> a {
> module Saved {
= item = value
> read(ignored: Int) -> a { item }
}
Saved.read
}
= reader = remember("retained")
@ print(reader(0))
| -- What must be true
Declares rules, invariants, effect handlers, and scopes. The most versatile rune.
Logic rules (Prolog-style)
| taxable(person) -> resident(person), has_income(person)
Commas join goals and || separates alternatives. A lowercase name in a goal
that is neither bound (a head parameter, an earlier goal's binding or a local
value) nor the name of a function or rule is a logic variable; the goal
searches for values that make it true. A function or rule name is passed as a
value, so | any_eligible(items) -> false || any(items, eligible) calls
eligible on each item.
A goal whose arguments contain unbound variables starts a logic query that
searches the matching facts and clauses. Every derived-rule goal inside the
query, including a fully bound one, runs one level deeper than the body that
contains it; the depth carries into rules called from those goals. A query may
nest derived-rule goals up to 10000 levels deep, so a chain succeeds whenever
its derivation nests fewer than 10000 rule goals, such as a path over thousands
of links. A cyclic relation queried for an
unreachable target exceeds that depth and stops with the located error
logic query `edge` exceeded its recursion limit of 10000; evaluation is incomplete. Interpreted and compiled programs apply the same limit.
Default rules with overrides (Catala-style)
| advisory(w) -> "all clear"
| advisory(w) -> "heat warning" under w.temp > 35.0
| exception heatwave advisory(w) -> "danger" under w.temp > 45.0
Rules are evaluated by priority tier: exceptions, guarded defaults, ordinary
clauses, then an unguarded default. Within one tier, source order is
authoritative and the first applicable rule wins. Put the more specific of two
overlapping guards first; runa audit reports clauses that can never fire and
same-tier guards that overlap with different results (see
Auditing clause reachability). under adds a
guard condition. exception <label>
places the rule in the exception tier for the same head. The label (here
heatwave) names the exception for readability and debugging; it does not
affect priority. Write both the label and the named call:
| exception reduced rate(x) -> 10 under x < 10. For a rule with no arguments,
use rate(). Missing labels, parenthesized heads, and heads that are not named
rule calls are parsing errors. An exception overrides an existing rule: the
same scope (or its plain imports) must declare an ordinary clause or default
with the same name and arity. An exception whose head names no such rule, for
example a misspelled rule name, is a type error at that head.
An under guard must return Bool. A known non-Boolean guard is rejected at
its declaration, even if the rule is never called. A dynamically supplied
non-Boolean guard fails evaluation; it is not treated as False and cannot
select a fallback rule. Clauses in one family (same scope, name, and arity)
must have compatible result types. Known conflicting results are diagnosed
before execution. Unresolved generic results do not establish compatibility
or totality.
A rule with safely known Boolean results returns False when no clause
applies, including | eligible(age) -> True under age >= 18 without a
parameter annotation. Invalid guards still stop evaluation. This runtime
fallback does not establish parameter types or authorize a proof: verification
and exploration retain their separate checking requirements.
Named invariants (verification targets)
| name: subject_expr -> predicate_expr
Defines a named predicate that ? can check. The subject expression is the value being tested; the predicate expression must return Bool.
= balance = 1000
= max_supply = 1000000
| balance_bounded: balance -> balance >= 0 && balance <= max_supply
The name before : is the invariant name. The expression between : and -> is the subject (captured by ? name: val). The expression after -> is the predicate.
Effect handlers
= result = | handle Console {
| say(msg) -> { @ print("[console] " + msg); resume(()) }
| ask(prompt) -> { @ print("[console] " + prompt); resume("default") }
} in greet("World")
Intercepts effect operations from the in body. resume(value) continues execution with the given return value.
Scope blocks (lifecycle management)
| scope WeatherStation {
~ readings = subject()
readings <- 42
@ print(show(readings))
}
Scopes own the live subscriptions and derived streams created inside them.
They keep receiving values after the block's statements run, until
@ teardown("WeatherStation"). Named scopes are also the explicit owner
required for live subscriptions started inside ordinary functions. See
docs/stream-lifetimes.md.
Match arms
Inside a match expression, | introduces each arm (see basics.md for match syntax).
= -- What is
Binds a name to a value. Ground truth at a point in time.
Simple binding
= x = 42
= name = "hello"
= result = add(20, 22)
With type annotation
= x: Int = 42
= name: String = "hello"
Top-level initialization order
Unique top-level bindings may refer to bindings declared later, directly or
through functions, rules, and RuleScope members. Futuruna initializes the
required dependencies first in both interpreted and compiled execution.
Declarative @ comptime and @ export annotations remain attached when a
binding moves with its dependencies.
| answer() -> base + 1
= result = answer()
= base = 41
Rebound names keep source-order semantics. A cycle between unique top-level bindings is rejected with the complete initialization path.
Monadic bind (early return)
= value <- parse_int("42")
If the expression returns Ok(v) or Some(v), binds v and continues. If Err(e) or None, returns immediately (early return). Equivalent to Rust's ? operator.
The expression must be a Result or an Option, or a call to an effect
operation, which resumes with a plain value. <- on any other value is a type
error; bind a plain value with = name = expression.
> add_parsed(a_str: String, b_str: String) -> Result(Int, String) {
= a <- parse_int(a_str)
= b <- parse_int(b_str)
Ok(a + b)
}
~ -- What flows
Declares reactive streams and subscribes to them. Values that change over time.
The ~ rune has two forms:
- Binding (
~ name = expr) — creates a stream - Subscription (
~ expr | arms) — consumes a stream with event handling
Stream binding
~ nums = from_list([1, 2, 3, 4, 5])
~ doubled = map(nums, |x| x * 2)
~ big = nums |> filter(|x| x > 3)
Subscription (~ + |)
-- Subscribe to a stream with value handling
~ nums | x -> { @ print(show(x)) }
-- With error handling
~ nums
| x -> { @ print(show(x)) }
| Err(e) -> { @ print("error: " + show(e)) }
-- Full lifecycle (value + error + completion)
~ nums
| x -> { @ print(show(x)) }
| Err(e) -> { @ print("error: " + show(e)) }
| Complete -> { @ print("stream ended") }
-- Pipeline ending in subscription
~ sensor |> filter(valid) |> map(to_celsius)
| t -> { display(t) }
| Err(e) -> { log(e) }
The | arms handle three stream events: values, errors, and completion. Arms on the lines after ~ are indented past it. Use for for lists and ranges; use ~ + | for streams.
See streams.md for the full stream API and subscription reference. For lifetime ownership rules around function-local subscriptions, see docs/stream-lifetimes.md.
Subject creation (push-based streams)
~ clicks = subject() -- keeps every value
~ temp = subject(20.0) -- starts with 20.0, keeps every value
~ history = subject(0, 10) -- starts with 0, keeps the last 10 values
Push values into subjects
clicks <- "click1"
clicks <- "click2"
temp <- 25.0
<- returns after every subscription and derived stream has handled the value.
Subject properties
clicks.count -- number of values the subject has emitted
temp.latest -- most recent value
@ -- Where proofs stop
The boundary between the verified world and effects. Every @ says: formal reasoning cannot reach here.
Print (IO)
@ print("hello")
@ print("value: " + show(x))
Effect invocations use supported builtin names. Unknown names such as
@ println(...) or @ log(...) are errors. Assertions are ordinary calls:
write assert(condition) or assert_with_message(condition, "message")
without @. Declared algebraic operations also use ordinary call syntax, as
shown in the handler example above.
Import (multi-file)
@ import ./utils -- flat import: merge all definitions
@ import Utils from ./utils -- qualified: access via Utils.function()
@ import #a1b2c3 from ./utils -- content-addressed import
Top-level plain imports form one merged declaration scope. They are resolved in import order before the importing file's declarations, regardless of where the directives appear. Imported functions override injected prelude defaults; local functions override imported functions. A local type declaration likewise replaces an imported type with the same name, including its constructors. A constructor name belongs to one sum type: declaring it in two different types visible in one scope is a checker error that names both types. Imported initializers and later calls use that same final function and rule context. Unique bindings across the merged scope follow the dependency order described above. A canonical source is imported once per namespace, including when several dependencies import it. Standalone executable statements in an imported file are not run; its binding initializers are part of the merged program.
Checking an importing file also checks imported function bodies. Errors identify the imported file and source position; editors link the import-site diagnostic to that original location. Qualified modules resolve private helpers and their own dependencies within the module, without inheriting the caller's local names. Qualified bindings and their helper calls use the module's declaration scope; a same-named value in the importer does not replace a module binding. Separate qualified aliases own separate instances. Repeating the same alias and source within one parent namespace reuses its existing instance. Bindings inside an instance follow the dependency order described above.
Use (Rust items)
@ use std::collections::HashMap
@ use std::io::*
Use @ import for Futuruna modules.
Depend (Cargo dependencies)
@ depend "serde" "1" ["derive"]
@ depend "tokio" "1"
A dependency is a crates.io package: a name, a version such as "1" or
"0.10.2", and optionally a list of features. Path, git and inline-table
sources are rejected. runa build and runa run let Cargo download and build
these crates; runa check does so only with --build-deps.
Export (visibility)
@ export
> public_function() -> Int { 42 }
Marks the next definition as public. Without @ export, definitions are private.
Calculate (typed external input)
@ calculate("Danish personal income tax")
| calculate_tax(input: TaxInput) -> TaxResult(annual_tax = annual_tax(input))
Marks one typed rule or function as a discoverable calculation boundary for
runa schema, runa template, and runa call. This annotation does not perform
an effect or change rule semantics. Its optional single string labels the whole
calculation; nested input labels and questions remain field metadata. See
calculations.md.
Comptime (compile-time evaluation)
> generate_lookup(n: Int) -> Int { n * 2 }
@ comptime
= table = generate_lookup(1000)
Put @ comptime on its own line before the binding. The expression is evaluated
at compile time and inlined as a constant.
The compiler can also fold ordinary pure expressions automatically. This speculative evaluation is bounded and cannot perform host effects, including file access, output, randomness, or effects reached through callbacks and module initializers. A value that requires runtime initialization stays at runtime; unavailable values are never substituted with placeholders.
Explicit @ comptime evaluation is pure as well: an expression that reaches
a host effect (output, input, files, the environment, clocks, network or
processes) is a compile-time error at that call. Its dependencies must have
compile-time values too; the compiler reports an error when a required value
is available only at runtime.
Rust escape hatch
@ rust {
fn fast_sort(x: &mut [f64]) {
x.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap());
}
}
Inline raw Rust code. Handles nested braces, strings, and comments correctly.
Embedded Rust runs only in compiled code (runa run, runa build). The
interpreter refuses it: calling a function defined in an @ rust block, or
reaching an @ rust block inside a function body, is a runtime error at that
point.
? -- Prove it
Interrogates what other runes declared. Checks invariants defined with |.
How it works
- Define an invariant with
|:| balance_ok: balance -> balance >= 0 && balance <= max_supply - Check it with
?:? balance_ok
The six forms
Without else, failure halts the program. With else, failure is handled and execution continues.
-- Form 1: Bare check (halt on failure)
? balance_ok
-- Form 2: Pass block (halt on failure)
? balance_ok -> {
@ print("Balance verified")
}
-- Form 3: Capture + pass (halt on failure)
? balance_ok: val -> {
@ print("Balance is " + show(val))
}
-- Form 4: Else block (no halt)
? balance_ok else {
@ print("Balance violated!")
}
-- Form 5: Pass + else (no halt)
? balance_ok -> {
@ print("OK")
} else {
@ print("FAIL")
}
-- Form 6: Full form — capture + pass + else (no halt)
? balance_ok: val -> {
@ print("Verified: " + show(val))
} else {
@ print("Violation: " + show(val))
}
The : val capture binds the subject value (the data being checked), not the boolean result.
Verify all invariants
? all -- check all, halt on any failure
? all -> { @ print("All OK") } -- with pass block
? all -> { @ print("OK") } else { @ print("Some failed") } -- with both
Three assurance levels
The same ? line works at three levels of assurance:
runa run— evaluates the predicate with current values at runtimeruna build— emitsdebug_assert!()in the compiled binaryruna verify— translates supported claims to SMT-LIB2 and invokes Z3 to prove them over their runtime input domain
For Int, verification includes checked i64 arithmetic: a reachable overflow
or zero divisor is a counterexample to the guarantee. Division truncates toward
zero, and remainder has the dividend's sign. Short-circuited operations and
unselected branches do not have to be defined. Unsupported helpers and Float
claims remain explicitly unverified; exact real arithmetic cannot substitute
for floating-point evaluation.
runa verify is Preview. PROVED means that for every value of the invariant's
free variables (Int ranges over the 64-bit values; a variable used as an enum
whose constructors carry no payload ranges over exactly those constructors) the
predicate is true and no Int operation it evaluates overflows or divides by
zero. Claims involving Float are reported as unsupported, never proved.
A free variable ranges over an enum when the invariant passes it to a rule,
function, or constructor parameter declared with that enum, compares it with a
constructor or another value of that enum, or matches it against the enum's
constructors. Every other free variable ranges over Int. A free variable used
as an enum whose constructors carry payloads, as a generic type, or as two
different types makes the claim unsupported with a diagnostic. A
counterexample names each free variable's witness in source syntax, such as
s = Shipped.
Verifying rule dispatch
For CI, runa verify exits 0 only when at least one invariant exists and every
invariant is PROVED. A counterexample, unsupported claim, unknown result,
missing or failed solver, or empty invariant set exits 1.
runa verify can translate pure, total, non-recursive | rule groups directly,
including rules inside a product RuleScope. Conditions and exceptions use the
same precedence as execution; there is no need to restate the rule cascade as a
separate > function.
# TaxCase(income: Int) {
| rate_percent() -> 25
| rate_percent() -> 30 under income > 500000
| exception low_income rate_percent() -> 20 under income < 100000
| tax_due() -> income * rate_percent() / 100
}
= high_income_case = TaxCase(income = 600000)
| high_income_tax: high_income_case.tax_due() -> high_income_case.tax_due() == 180000
Finite state machines verify over every state and event:
# State = Draft | Submitted | Paid | Shipped | Cancelled
# Event = Submit | Pay | Ship | Cancel
| next(s: State, e: Event) -> s
| next(s: State, e: Event) -> Submitted under s == Draft && e == Submit
| next(s: State, e: Event) -> Paid under s == Submitted && e == Pay
| next(s: State, e: Event) -> Shipped under s == Paid && e == Ship
| next(s: State, e: Event) -> Cancelled under e == Cancel && (s == Draft || s == Submitted)
| exception refund_window next(s: State, e: Event) -> Cancelled under s == Paid && e == Cancel
| shipped_is_final: next(Shipped, e) -> next(Shipped, e) == Shipped
| cancel_always_cancels: next(s, Cancel) -> next(s, Cancel) == Cancelled
e ranges over the four events and s over the five states.
shipped_is_final is PROVED; cancel_always_cancels is refuted with the
witness s = Shipped, so runa verify exits 1.
Verification uses the same merged declaration scope and binding dependencies as execution, including when a plain import appears after a local declaration.
Plain imports are resolved recursively for verification. An exception declared by an importing file therefore extends the imported rule group and keeps its normal exception priority. Within one priority tier, imported declarations come before declarations in the importing file and the first applicable rule wins, just as it does during execution.
The solver path fails closed with a diagnostic for partial non-Boolean rules,
recursive dispatch, higher-order parameters, effects, and other expressions
outside its current first-order subset. Rule return sorts are resolved by the
exact RuleScope (when present), name, and arity; same-named overloads never
share a sort, and conflicting or unresolved result sorts fail closed. runa verify remains a Preview surface.
Auditing clause reachability
runa audit (Experimental) uses the same SMT translation of rule groups and
Z3 to check every rule family (same scope, name, and arity) with a clause in the
audited file, imported clauses of that family included:
- Unreachable clause. No input makes the clause fire: whenever its guard
(head patterns,
undercondition, and the body of a Boolean clause) holds, a higher-priority candidate applies first, or the guard cannot hold at all. The report names the clause, file and line, and the earlier clauses that take its inputs. A clause after an unguarded clause of the same tier, a guard repeated further down, and a guard such asx > 5 && x < 3are all unreachable. - Overlapping guards. Two candidates of one tier can both apply to the same
input and select different results, so source order silently picks the first.
The report names both clauses and a witness input. An earlier guard that is a
special case of a later one (a specific head pattern before a general one,
x > 1000beforex > 0) is ordinary first-match refinement and is not reported, and an unreachable clause is reported only as unreachable.
| band(x: Int) -> 0
| band(x: Int) -> 70 under x > 7
| band(x: Int) -> 80 under x > 8 -- unreachable: x > 7 takes every input
| rate(x: Int) -> 1 -- unreachable: the two guards cover every Int
| rate(x: Int) -> 2 under x > 10
| rate(x: Int) -> 3 under x < 20 -- overlaps the guard above, witness x = 11
A clause reported unreachable never fires for any value of the family's
arguments and RuleScope fields (Int ranges over the 64-bit values); top-level
bindings keep their declared values. A Boolean clause whose body is the literal
false only marks a matched head and is not audited. Guards outside the
translatable subset (the same limits as runa verify, plus Float and other
values the encoding does not represent) are listed as not analyzed and counted
in the summary, never guessed. When Z3 is missing the report says the check did
not run.