RCE language specification, draft 0.5
6 (continued). Dynamic semantics: steps, loops and concurrency
This section defines the step function δ (delta), which takes a state and
an event and returns the next state and an outcome, then errors, hashing,
loop bounds, concurrency and how examples are checked. A trap is a defined
failure that refuses the event and leaves state unchanged. Example: a loop
with bound 10 that starts an 11th iteration traps with "loop bound".
6.4 The step function δ
For event e = (Eₗ, v):
- For each region Rᵢ in declaration order, take the steps
step Rᵢ on p if gin source order whose pattern p matches e and whose guard g evaluates to true in (Rᵢ's pre-state fields, bindings of p). The first such step is the step of Rᵢ for e. If there is none, Rᵢ is unchanged (frame). A guard that traps (overflow, division by zero, …) does not fall through to the next step: the event is refused with that trap (change S1; this is what makes δ a total function rather than leaving the case undefined). - Every selected step runs on the pre-state s (ASM semantics, Form law 2): no step sees another region's update for the same event.
- The body of the step of Rᵢ is evaluated with fields of Rᵢ bound as
vars to rᵢ, the pattern bindings aslets. Its result isok(σ′): rᵢ′ = the field values in σ′ (each checked against its type range and VALUE_LIMIT), outputs oᵢ = the values emitted, in order;fail(X, w)ortrap(m): the step is refused.
- If any selected step is refused, the whole event is refused: δ(s, e) = (s, [Refused(X or trap(m), e)]), where X is the refusal of the first refused region in declaration order. Atomicity: an event either applies to every region or to none.
- Otherwise δ(s, e) = ((r₁′, …, rₖ′), o₁ ++ … ++ oₖ).
The arrow form step R on p -> e is sugar for the block
f = e where f is R's only field.
Order independence. Since each step reads only its own region and the event, the result of steps 2–5 does not depend on the order regions are evaluated except through "first refused region", which is fixed by declaration order. Outputs are concatenated in declaration order.
6.5 Errors, traps and refusals
A fail X(…) or a trap inside a step refuses the event: the state is
unchanged, the log records the event with outcome refused(X) and λ outputs
Refused. Nothing is thrown past a step; no event is dropped silently. In a
pure function called from comptime, refusal is a compile error. In main, a
trap or unhandled failure ends the command with the refusal as its output.
6.6 Determinism of identity: name-free hashing
Channel ids, actor ids and task ids are not counters. Each is the SHA-256 of the canonical JSON of (creating construct's name-free key, creating task id, per-task creation index), where the name-free key replaces identifiers by their De Bruijn position (Form law 2). Renaming every identifier in a program therefore leaves every id, every log hash and every select decision unchanged (T7).
6.7 Loops and fuel
for p in e iterates over a finite value (list, set, map in canonical order,
text by scalar value, or an integer range a..b / a..=b); its length is
fixed when the loop starts, and mutating the iterated collection inside the
loop is refused statically (it is borrowed). while c bound n and
loop bound n run at most n iterations, n a comptime-or-runtime u64
evaluated once; the default bound is LOOP_LIMIT = 1,000,000. A program may
set its own default with a module-level const LOOP_LIMIT: u64 = n, n a
positive literal (draft 0.5; any other declaration of that name is refused,
W-LOOP-1). Starting iteration n + 1 is trap("loop bound"). Comptime
evaluation uses the same bounds. for x in f(…) over an iterator function
(5.14) counts yields against LOOP_LIMIT in the same way; Lean:
RCE/Iter.lean (drive_total, drive_break).
Structural recursion (draft 0.4). A function may call itself when
every self-call passes, in one parameter position i fixed for the
function, a name bound inside a constructor pattern of a match whose
scrutinee is parameter i (or a name already known to be such a
component). The argument is then a strict component of the value
received, so along any chain of calls the value at position i strictly
shrinks and the depth of recursion is at most the number of constructor
nodes in that value, which N9 bounds. A name that is rebound by let,
var, assignment or a for pattern loses this status. The checkers of
drafts 0.4 to 0.7 let the position differ from call to call; that admits
a function that never returns (f(x, y) whose one self-call takes a
component of x and a fixed value for y, and whose other takes a fixed
value for x and a component of y, runs forever from f(Leaf, Node(…))),
so such a function is refused (W-REC-1; an erratum, C.15). Every function
this paragraph admits is admitted by the rule below with one layer.
Recursion by layered measures. Every cycle of the call graph (a
strongly connected component: one function that calls itself, or two or
more functions; trait methods excluded) is admitted when its calls can be
done in layers. A layer gives each function f of the cycle a set P f of
parameter positions and the summed measure μ_f(ā) = Σ_{i ∈ P f}
size(aᵢ), with size the number of constructor nodes. A call g(b̄)
written in f keeps the measure when the positions P g map one to one
into P f so that the argument at each j ∈ P g is the parameter at its
image i (unchanged, the name not rebound) or a component of it in the
sense of structural recursion above; it shrinks the measure when at
least one of them is a component. Layer 1 must be kept by every call of
the cycle and shrunk by at least one; the calls it shrinks are done.
Layer 2 must be kept by every call not yet done and shrunk by at least
one of them, and so on. The cycle is admitted when the calls left after
some layer k form no cycle among the functions. Then the tuple (μ₁, …,
μ_k, rank of the function) falls lexicographically at every call: a call
done at layer j keeps μ₁ … μ_{j−1} and shrinks μ_j, and a call left over
keeps every μ and lowers the rank. The order is well founded, so every
chain of calls is finite; with one layer a chain inside the cycle has at
most (μ + 1)·k calls, with μ the measure of the first call and k the
number of functions in the cycle, and N9 bounds μ. The reference checker
tries every choice of the sets of a layer, in a fixed order, for a cycle
with at most 16 parameters in total, takes the first choice that does
the most calls, and refuses a larger cycle that needs a layer. Lean:
RCE/Mutual.lean (call_dec, call_keep, layer_dec, wf_total,
wf_fuel_irrelevant; one layer: lex_dec, mutual_total,
mutual_fuel_irrelevant, mutual_depth). Examples: even(xs) and
odd(xs) that each call the other on the tail of a matched Cons
(one layer); h(m, n) that calls h(m', n) and h(m, n') on
components m' of m and n' of n (layer {m} does the first call, layer {n}
the second).
Declared bounds. fn f(…) -> τ bound n, n a positive integer
literal, allows at most n active calls of f at once: a call of f, once
its arguments are evaluated and fitted to the parameter types, traps
with trap("recursion bound") when n calls of f are already active, and
otherwise counts as active until it returns or fails. A cycle that no
layers admit is admitted when every cycle of calls inside it passes
through a function declared with bound (the calls between functions
without one form no cycle). Then the slack Σ (n_g − active calls of g)
over the bounded functions falls at every call of a bounded function and
the rank falls at every other call, so every run of the cycle ends, by
its value or by the trap, within (slack + 1)·k nested calls. This covers
recursion on integers or indices, which has no constructor measure
(gcd(a, b) calling gcd(b, a % b), a parser that recurses on a
position). The counts belong to the task (6.8) that makes the calls, so
par and for … par inside a function of a cycle with a bounded
function are refused (W-REC-1): the branches could otherwise count calls
differently on one thread and on several. bound on an iterator
function (5.14), a foreign function or a trait signature is W-REC-1. The
check costs nothing (C.6.4 is unchanged). Lean: RCE/Mutual.lean
(bump_slack, bound_dec, with lex_dec, mutual_total,
mutual_depth); tests: spec/checker/tests/bound.rs.
Any other cycle in the call graph is refused (W-REC-1).
This is what makes δ total (T2): every construct terminates in a number of steps bounded by a function of the program and the event.
6.8 Concurrency: tasks, channels, select, actors
Concurrency is defined by a small-step semantics over configurations C = (T, K, A, L): a finite map T of tasks (id ↦ continuation), channels K (id ↦ capacity, FIFO buffer, closed flag, held-send queue), actors A (id ↦ region state, mailbox), and the log L.
Scheduling is a tick: tasks are ordered by id (6.6) and each runnable task
takes exactly one communication step: it runs pure code until its next
channel operation, spawn, select, emit or termination, and then performs
that operation. All operations of a tick are staged and delivered after every
task has moved (Form law 2: every update reads the pre-state). A tick is
logged as Tick{n} plus one entry per communication action, with payloads
(Form law 4).
c has space ─→ v appended to c.buffer; task continues
c full ─→ (c, v) held in c.held in arrival order;
task blocked; Backpressure logged (no drop, law 3)
<- c, buffer nonempty ─→ first value; held send released in order
<- c, empty and closed ─→ none
<- c, empty and open ─→ task blocked
spawn f(ā) ─→ new task id h(…); args moved in
select: ready arms R ≠ ∅ ─→ execution k of this select statement in
this task scans the arms cyclically from
arm k mod n (n arms, source order) and
takes the first ready one (draft 0.5)
select: R = ∅, default present ─→ default arm
select: R = ∅, `after n` ─→ blocked; after n ticks with R still ∅, after-arm
An actor is a region with a mailbox; a <- M(v) is an event M(v) admitted
to a's region at the next tick; the actor runs its handlers by 6.4 with its
own region as S. Each actor processes at most one message per tick, in
mailbox order.
Fairness (draft 0.5). The counter k is per task and per select
statement, starts at 0 and grows by one per execution. An arm that is ready
at each of n consecutive executions of its select is taken by one of them
(bounded wait). Draft 0.4 always took the minimal arm, so a busy first
channel could starve the others, the problem Go solves with a random choice;
the rotation gives the same guarantee without randomness. Lean:
RCE/Select.lean (select_bounded_wait).
Because the order of every choice is a function of ids, payloads and per-select counters, and ids are content hashes, the entire run of a program is a function of its inputs: replaying the log reproduces it (T1, T3). The host may run tasks in parallel provided the observable result equals the tick semantics.
Deadlock: a tick in which no task can move and no input is pending ends the
run with outcome Deadlock in λ (a defined outcome, not a hang).
6.9 Examples and checks
example ē -> o denotes the proposition: folding the events ē from s₀ gives
a final step whose outcome is o, where for -> refused: X the last event is
refused with X (earlier events must be applied), and for -> e the event list
is fully applied and then
- if the program has exactly one region with exactly one field f, the value of f equals ⟦e⟧;
- otherwise e must be a
boolexpression over the final state, with fields in scope by name (orR.fwhen ambiguous), and must be true.
check e (in an intent or at comptime) is the proposition ⟦e⟧ = true,
evaluated at comptime. A program is admitted only if every example and check
holds (W-INT-1): the gate is part of the language, not of a test harness.
Built from spec commit 49fa9af. Code cells run the reference checker rce-check, written in Rust and compiled to WebAssembly, inside your browser; nothing is sent to a server. Read-aloud uses the Kokoro-82M model (Apache-2.0), served from this site and run in your browser.