Initial framework: command language, flow layer, host build

A C framework for commanding a Pico WH robot over USB, WiFi or BLE with a
small text DSL. No hardware attached yet, so the whole stack runs on the
host against a simulated HAL and is covered by tests.

Two specifications drive the code:

  docs/grammar.md  the language — one namespace of callables (native,
                   firmware-resident, and defined over the wire), procedures
                   with named arguments and defaults, REPEAT, and the rules
                   that keep it safe on a microcontroller.
  docs/flow.md     transport, bracket-balance framing, the envelope,
                   admission, execution, abort and failsafe.

Three properties the design leans on:

  - Bodies may only reference callables that already exist, so the call
    graph is acyclic by construction, recursion is unrepresentable, and
    interpreter depth is checked at definition time rather than discovered
    at runtime.
  - Nothing blocks. The interpreter is a resumable state machine over an
    explicit frame stack, so ABORT is honoured within one tick even in the
    middle of a long move, and no program can overflow the MCU stack.
  - No queue. One outstanding statement at a time, which keeps abort to a
    single unambiguous victim and avoids inventing an answer to "the
    running program failed, does the queued one still go?".

Named arguments are resolved to the callee's parameter order at definition
time, so they cost nothing at execution.

rubo_core is target-independent. Porting to the Pico means providing a
non-blocking rubo_transport_t and a rubo_hal_t, and nothing else.

Not built yet: the Pico transport and HAL, the camera DAT/DEND bulk path,
and IF/ELIF, which is reserved in the grammar and lands with the sensors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfu1EFVbpee92zvzQunsKb
This commit is contained in:
khannurien
2026-08-21 14:49:29 +00:00
commit 51c4fb1089
27 changed files with 4081 additions and 0 deletions

429
docs/flow.md Normal file
View File

@@ -0,0 +1,429 @@
# Rubo Command Flow — Transport, Framing and Execution
Status: **draft, v1**. Companion to `docs/grammar.md`, which specifies the
language. This document specifies everything around it: how bytes become
statements, how statements become motion, and how the robot is stopped.
## 1. Principles
1. **Nothing blocks.** The interpreter is a resumable state machine driven from
the main loop. A `MOVE` lasting ten seconds must not prevent `ABORT` from
being received in second three. This constraint shapes the entire design.
2. **The envelope is not the language.** Correlation tags and control words are
handled by the session layer and never reach the parser. `docs/grammar.md`
stays a specification of a language, not of a wire protocol.
3. **One framer for every transport.** USB, WiFi, BLE and the host test harness
all reduce to a byte stream. Only MTU and connection semantics differ.
4. **A dropped link stops the robot.** Every path that engages a motor is
covered by a deadman timer. This is the one requirement that is not
negotiable for convenience.
## 2. Layer stack
```
┌──────────────────────────────────────────────┐
│ transport usb_cdc │ wifi_tcp │ ble_nus │ host_stdio
│ byte stream in/out, MTU-aware
├──────────────────────────────────────────────┤
│ framer bracket-balance statement extraction (§4)
├──────────────────────────────────────────────┤
│ session tags, control words, ACK/DONE/ERR, backpressure (§5§7)
├──────────────────────────────────────────────┤
│ parser → validator → arena (docs/grammar.md)
├──────────────────────────────────────────────┤
│ scheduler resumable interpreter, explicit stack, ticks (§8)
├──────────────────────────────────────────────┤
│ HAL motor │ camera │ battery sim | pico
└──────────────────────────────────────────────┘
```
Layers below the parser are shared; only `transport` and `HAL` have per-target
implementations. The host build swaps exactly those two and runs everything
else unmodified, which is what makes the language testable with no hardware.
## 3. Transport interface
```c
typedef struct {
int (*open)(void *cfg);
int (*read)(uint8_t *buf, size_t len); /* non-blocking; 0 = no data */
int (*write)(const uint8_t *buf, size_t len);
bool (*connected)(void);
void (*close)(void);
size_t mtu; /* max bytes per write */
} rubo_transport_t;
```
`read` must never block. Everything above this line is polled from the main
loop; a blocking read anywhere forfeits §1.1.
| Transport | Implementation | MTU | Notes |
|-------------|----------------|-----|-------|
| `usb_cdc` | TinyUSB CDC (pico-sdk) | 64 | the development default; enumerate as ACM |
| `wifi_tcp` | lwIP listener, one client | ~1460 | CYW43; port configurable, default 3141 |
| `ble_nus` | Nordic UART Service | 20244 | notify for TX, write-without-response for RX; chunking mandatory |
| `host_stdio`| stdin/stdout | — | host build; drives the whole stack under a test harness |
| `host_tcp` | POSIX socket | — | host build; lets a real client speak to a simulated robot |
BLE's small MTU is not a special case: the framer is byte-oriented, so a
statement split across five notifications reassembles exactly as one arriving
in a single USB read.
## 4. Framing
`docs/grammar.md` §3.3 established that statements end at **bracket balance**,
not at newlines. The framer is therefore a small state machine, not a line
reader.
### 4.1 Rule
> A top-level statement is complete at the first newline encountered while
> bracket depth is zero and the buffer is non-empty.
Inside brackets, newlines are ordinary whitespace. A multi-line `DEF` pasted
into a terminal arrives as one statement; a one-liner arrives as one statement.
Interactive typing and bulk paste need no mode switch.
### 4.2 State machine
| State | Input | Action |
|-----------|------------------|--------|
| `STMT` | `#` | → `COMMENT` |
| `STMT` | `[` | `depth++`; append |
| `STMT` | `]` | `depth--`; append; if `depth < 0` → error `E_SYNTAX`, → `DISCARD` |
| `STMT` | newline | if `depth == 0 && len > 0`**emit**; else treat as space |
| `STMT` | other | append |
| `COMMENT` | newline | → `STMT` (apply the newline rule above) |
| `COMMENT` | other | discard |
| `DISCARD` | newline | reset `depth`/`len`, → `STMT` |
| `DISCARD` | other | discard |
The framer must be **comment-aware**: a `[` inside a comment must not affect
depth, or a single `#` in a comment would desynchronise the stream permanently.
There are no string literals in the language, so no quote handling is needed.
### 4.3 Failure and resynchronisation
| Condition | Response | Recovery |
|-----------|----------|----------|
| `len > RUBO_MAX_STMT` | `ERR 10 statement too long` | → `DISCARD` |
| unbalanced `]` | `ERR 1 unexpected ]` | → `DISCARD` |
| partial statement idle > `RUBO_FRAME_TIMEOUT_MS` | `ERR 1 incomplete statement` | reset |
| transport disconnect mid-statement | — | reset buffer; §10 deadman applies |
The idle timeout matters most on BLE, where a client can vanish mid-frame
leaving an unterminated `[` that would otherwise wedge the framer forever.
### 4.4 Top-level `;`
A bare `;` at depth zero is `E_SYNTAX`. One frame carries one statement; to
submit several as a unit, wrap them: `[ A; B; C ]`. This keeps "one statement,
one tag, one ACK, one DONE" exact.
## 5. Envelope
Two things wrap a statement and are stripped by the session layer before
parsing. Neither appears in `docs/grammar.md`, because neither is part of the
language.
### 5.1 Correlation tags
An optional `@<uint16>` prefix. Responses echo it.
```
> @7 SQUARE side=1000
< @7 ACK
< @7 DONE
```
Untagged statements are legal and produce untagged responses — convenient when
a human is typing. Tags are the client's to allocate; the robot never
interprets them beyond echoing.
### 5.2 Control words
Recognised before parsing, executed immediately, never queued, never subject to
`E_BUSY`. They bypass the interpreter entirely.
| Word | Effect |
|---------|--------|
| `ABORT` | unwind the interpreter stack, `hal_stop_all()`, end the running program (§10) |
| `PING` | `PONG` — liveness, and it feeds the deadman timer |
| `STAT` | one-line status: running callable, tag, depth, arena free, link age |
Control words are deliberately few. `ABORT` in particular cannot be a native
callable, because a callable can only run when the interpreter is free to run
it — which is precisely when you do not need it.
## 6. Response vocabulary
```
@7 ACK statement admitted, parsed, validated; now executing
@7 DONE execution finished normally
@7 ERR <code> <message> rejected or failed; codes in grammar.md §10
EVT <name> [args...] asynchronous, untagged, broadcast
DAT <id> <seq> <total> <payload> bulk data chunk (§11)
DEND <id> bulk data complete
PONG reply to PING
STAT <k>=<v> ... reply to STAT
```
**ACK and DONE are always both sent**, including for instantaneous statements
like `DEF` and `DEL`. Collapsing them for non-executing statements would save a
few bytes and cost every client a special case; a terminal client is free to
hide `ACK` when `DONE` follows immediately.
`ERR` may arrive instead of `ACK` (rejected at parse or validation) or after
`ACK` (failed during execution). A client waits for exactly one of `DONE` or
`ERR` per tag.
## 7. Statement lifecycle
```
bytes ─► RECEIVING ─► ADMISSION ─► PARSING ─► VALIDATING ─► RUNNING ─► DONE
│ │ │ │ │
│ │ │ │ ├─► ERR (runtime: E_RANGE, E_FAULT)
│ │ │ │ └─► ABORTED (E_ABORT)
│ │ │ └─► ERR (E_UNKNOWN, E_ARITY, E_ARGNAME,
│ │ │ E_UNDEFINED, E_CYCLE, E_NOMEM…)
│ │ └─► ERR (E_SYNTAX, E_LIMIT)
│ └─► ERR 12 busy
└─► discarded (§4.3)
```
**Admission** comes before parsing: if a program is already running, the
statement is rejected immediately without being parsed at all (§9). There is
no point spending cycles validating something that cannot run.
Validation is the last point at which rejection is free. Past `ACK`, a failure
means the robot has already moved, so the majority of `docs/grammar.md` §10 is
deliberately checkable before execution begins.
## 8. Execution model
### 8.1 Explicit stack, no C recursion
The interpreter holds a statically allocated array of frames:
```c
typedef struct {
const rubo_node_t *node; /* current statement in the body */
uint16_t pc; /* index within the enclosing sequence */
uint16_t iter; /* REPEAT counter */
rubo_value_t args[RUBO_MAX_PARAMS];
uint32_t deadline; /* native scratch: ms, 0 = none */
} rubo_frame_t;
static rubo_frame_t stack[RUBO_MAX_DEPTH];
```
`docs/grammar.md` §5.1 guarantees the call graph is acyclic, so `RUBO_MAX_DEPTH`
frames are provably sufficient and depth is checked at definition time. Nothing
here recurses in C, so the MCU stack is not at risk regardless of what the user
sends.
The explicit stack buys three things beyond safety: `ABORT` is a matter of
resetting an index, `STAT` can report exactly what is executing, and the whole
interpreter is trivially unit-testable on the host.
### 8.2 Natives are resumable
```c
typedef enum { RUBO_STEP_DONE, RUBO_STEP_PENDING, RUBO_STEP_ERR } rubo_step_t;
typedef rubo_step_t (*rubo_native_fn)(rubo_frame_t *f, uint32_t now_ms);
```
A native with a duration sets `f->deadline` on first entry and returns
`RUBO_STEP_PENDING` until `now_ms >= f->deadline`. `MOVE dir=FWD speed=50
dur=1000` engages the motors, returns immediately, and is re-entered each tick
until its deadline passes — during which the transport is polled normally and
`ABORT` is honoured within one tick.
### 8.3 Main loop
```c
for (;;) {
uint32_t now = rubo_millis();
rubo_transport_poll(now); /* read bytes, feed framers */
rubo_session_poll(now); /* frame → envelope → admit → parse → run */
rubo_exec_tick(now); /* advance the interpreter, bounded work */
rubo_hal_poll(now); /* sensors, deadman, event generation */
}
```
Cooperative and single-core; no RTOS, and the second RP2040 core stays free for
the camera pipeline later. `rubo_exec_tick` runs at most `RUBO_STEPS_PER_TICK`
interpreter steps before yielding, so a deeply nested `REPEAT` of instantaneous
statements cannot starve the transport.
## 9. Concurrency and backpressure
**One interpreter, one running program, no queue.**
| Situation | Behaviour |
|-----------|-----------|
| statement arrives, nothing running | `ACK`, execute |
| statement arrives, program running | `ERR 12 busy` — not parsed, not stored |
| `DEF`/`DEL` arrives while a program is running | `ERR 12 busy` |
| `ABORT` arrives | immediate, always (§5.2) |
The client contract is therefore exactly one sentence: **one outstanding
statement at a time — wait for `DONE` or `ERR` before sending the next.**
### 9.1 Why no queue
Queueing looks friendlier and is not, for three reasons.
**The language already solves it.** Chaining work is what sequences are for:
`[ SQUARE side=1000; ZIGZAG deg=30; STOP ]` is one statement, one tag, one
`ACK`, one `DONE`. It is validated as a whole before anything moves, runs with
no inter-statement gaps, and aborts as a whole. A queue is a worse duplicate of
a feature that already exists, reachable only by giving up whole-program
validation.
**A queue invents a question with no good answer.** If `A` is running, `B` is
queued, and `A` fails with `E_FAULT` — does `B` run? Yes means the robot keeps
driving after a fault. No means an `ACK` was a promise the robot silently
broke. With `[A; B]` the question never arises: the sequence stops. Every
queued design has to pick a side and surprise somebody.
**A queue is wrong for the case people actually want it for.** Live driving —
arrow keys, a joystick app — wants *preemption*: press left while moving
forward and left should happen now. A queue delivers the opposite, turning
steering input into a laggy backlog. Batch work wants sequences; interactive
work wants preemption; the queue serves neither.
`DEF` and `DEL` are refused during execution for a separate reason: mutating
the arena while a callable may be live on the interpreter stack is not worth
making safe for a convenience nobody needs.
### 9.2 If preemption is needed later
Should live driving materialise, the addition is explicit and small: a `!`
prefix on the envelope meaning *abort whatever is running and execute this
instead* — `!DRIVE left=40 right=-40`. It reuses the `ABORT` path wholesale and
requires no queue, no reordering, and no change to §9.1's guarantees. It is
deliberately not in v1 because a preempting command is a loaded gun and should
be designed against a real teleop client rather than an imagined one.
## 10. Abort and failsafe
`ABORT` unwinds the stack, calls `hal_stop_all()`, and emits `EVT ABORT`
followed by `ERR 13` for the tag of the program it killed. With no queue there
is nothing else to flush — which is the second benefit of §9: abort has exactly
one victim, and its identity is never in doubt.
Three independent guards cover the case where nobody is left to send `ABORT`:
| Guard | Trigger | Action |
|-------|---------|--------|
| **Deadman** | no inbound bytes on the owning session for `RUBO_LINK_TIMEOUT_MS` **while motors are engaged** | abort + stop, `EVT LINKLOST` |
| **Disconnect** | transport reports `!connected()` while motors are engaged | abort + stop |
| **Motor cap** | any single motor-engaging native exceeds `RUBO_MOTOR_MAX_MS` | stop that native, `ERR 6` |
The deadman is armed only while motors are engaged, so an idle robot on a quiet
link is not perpetually resetting itself. `PING` is sufficient to feed it, which
is why it is a control word rather than a native.
### 10.1 A client must ping while it waits
This falls out of §9 and is worth stating plainly, because it is the one place
where two rules interact in a way that surprises people:
> A program that engages the motors for longer than `RUBO_LINK_TIMEOUT_MS`
> will be killed by the deadman unless the client sends something during it.
`SQUARE side=1000` runs for about six seconds. A client that submits it and
then waits quietly for `DONE` — the obvious reading of §9's "wait for `DONE`
or `ERR`" — gets `EVT LINKLOST` instead. That is the deadman working
correctly: it cannot distinguish an attentive operator from a crashed one, and
guessing in the robot's favour is how robots drive into walls.
So the client contract has a second sentence: **`PING` on an interval
(≈500 ms) whenever a program is running.** The reference client in
`host/main.c --script` does exactly this.
## 11. Bulk data
The protocol is text; camera frames are not. Frames leave as `DAT` records,
chunked to the transport MTU, base64-encoded:
```
< DAT 3 0 8 <base64…>
< DAT 3 1 8 <base64…>
< DEND 3
```
`<id>` correlates chunks to the `CAM action=SNAP` that produced them; `<seq>`
and `<total>` let a client detect loss and reassemble. Base64 costs 33%
overhead, which for a B&W QQVGA snapshot (160×120×1bpp ≈ 2.4 KB → ≈ 3.2 KB) is
acceptable for stills and firmly not acceptable for video. A binary side
channel can be added later without disturbing the text protocol; streaming
video was never in scope for the command path.
## 12. Multiple transports
All configured transports may be open simultaneously. Each holds its own framer
and buffer. Statements from any transport compete for the same single execution
slot, `E_BUSY` is global, and `EVT`/`DAT` are broadcast to every connected
transport.
This is honest rather than clever: two clients can interleave commands and
confuse each other. Exclusive session ownership — first-come with explicit
release or timeout takeover — is the obvious v2 refinement, but it needs a
policy decision about who may seize control of a moving robot, and that
decision is better made with the hardware in front of us.
The deadman (§10) tracks the session that submitted the running program, not
the aggregate of all links.
## 13. Constants
| Constant | Suggested | Meaning |
|----------------------------|-----------|---------|
| `RUBO_FRAME_TIMEOUT_MS` | 2000 | partial statement idle timeout |
| `RUBO_LINK_TIMEOUT_MS` | 2000 | deadman, while motors engaged |
| `RUBO_MOTOR_MAX_MS` | 10000 | hard cap on one motor-engaging native |
| `RUBO_STEPS_PER_TICK` | 32 | interpreter steps before yielding |
| `RUBO_RX_BUF` | 1088 | ≥ `RUBO_MAX_STMT` + envelope |
| `RUBO_TCP_PORT` | 3141 | `wifi_tcp` default |
## 14. Worked exchange
```
> @1 DEF ZIGZAG $deg=45 $reps=3 [
> REPEAT $reps [
> TURN dir=LEFT deg=$deg; MOVE dir=FWD speed=40 dur=300;
> TURN dir=RIGHT deg=$deg; MOVE dir=FWD speed=40 dur=300
> ]
> ]
< @1 ACK
< @1 DONE
> @2 [ SQUARE side=1000; ZIGZAG deg=30 reps=5 ]
< @2 ACK
> @3 SQUARE side=200
< @3 ERR 12 busy # one outstanding statement at a time
> @4 DEF FOO [ STOP ]
< @4 ERR 12 busy # definitions are not an exception
> ABORT
< EVT ABORT
< @2 ERR 13 aborted # exactly one victim
> @3 SQUARE side=200
< @3 ACK
< @3 DONE
```
Note that the multi-line `DEF` at `@1` is a single statement: the framer emits
it only at the newline following the closing bracket.
## 15. Open questions
1. **Should `EVT` be subscribable?** Broadcasting every event to every
transport is fine at this scale and wasteful over BLE if telemetry gets
chatty. An `EVT` mask per session is cheap to add later.
2. **`STAT` output format.** Currently ad-hoc `k=v` pairs. If it grows, it
wants a schema.
3. **Exclusive session ownership** (§12) — deferred pending hardware.

343
docs/grammar.md Normal file
View File

@@ -0,0 +1,343 @@
# Rubo Command Language — Grammar
Status: **draft, v1**. Covers the language only. Wire framing, session flow,
acknowledgements and events are specified separately in `docs/flow.md` (TBD).
## 1. Design principles
1. **One namespace of callables.** A call site cannot tell whether it is
invoking a C function, a procedure baked into firmware, or a procedure
defined over the wire five seconds ago. Primitives are simply callables
with a native body.
2. **Bounded execution.** Every program's call depth and iteration count are
statically bounded. Recursion is impossible by construction (§5.1), and the
only loop takes an explicit, capped count.
3. **No dynamic allocation at execution time.** Definitions are parsed once
into a compact form in a fixed arena. Execution allocates nothing.
4. **One parser.** Firmware-resident procedures are written in this same
language and parsed at boot, so there is no second representation to keep in
sync and the standard library is testable on the host.
5. **Two value types only:** integers and words. No strings, no floats, no
collections.
## 2. Kinds of callable
| Kind | Body lives in | Defined at | Deletable | Shadowable |
|-----------|---------------|-------------------|-----------|------------|
| `NATIVE` | C function | compile time | no | no |
| `STATIC` | flash (const) | compile time, in this language | no | no |
| `DYNAMIC` | RAM arena | runtime, over the wire | yes | yes (redefinition) |
`STATIC` procedures exist so a useful vocabulary survives a power cycle without
a filesystem. A `DYNAMIC` procedure is promoted to `STATIC` by pasting its text
into the firmware's standard library — no translation step.
## 3. Lexical structure
### 3.1 Tokens
| Token | Form | Notes |
|-------------|---------------------------------------|-------|
| `IDENT` | `letter (letter \| digit \| "_")*` | ≤ 15 chars, canonicalised to uppercase |
| `NUMBER` | `["-"] digit+` | signed 32-bit |
| `LBRACKET` | `[` | |
| `RBRACKET` | `]` | |
| `SEMI` | `;` | statement separator |
| `EQ` | `=` | named argument / default value |
| `DOLLAR` | `$` | parameter reference |
| `RELOP` | `<` `>` `<=` `>=` `==` `!=` | reserved, §7 |
### 3.2 Keywords
`DEF` `DEL` `REPEAT` `IF` `ELIF` `ELSE`
Keywords are case-insensitive and reserved: they may not be used as callable
or parameter names. `IF`/`ELIF`/`ELSE` are reserved but not yet implemented.
### 3.3 Whitespace and comments
Whitespace (including newlines) separates tokens and is otherwise
insignificant — a definition may span as many lines as it likes. `#` begins a
comment that runs to the end of the line.
Statement boundaries are therefore determined by **bracket balance**, not by
newlines. This has a direct consequence for the wire protocol, addressed in
`docs/flow.md`.
### 3.4 Case
All identifiers — callable names, parameter names, and word values — are
case-insensitive and canonicalised to uppercase on ingest. `move fwd`,
`MOVE FWD` and `Move Fwd` are the same statement.
## 4. Syntax
```ebnf
statement ::= def | del | repeat | if | sequence | call
def ::= "DEF" name paramdecl* sequence
paramdecl ::= "$" name [ "=" literal ]
del ::= "DEL" name
repeat ::= "REPEAT" arg sequence
(* reserved see §7, not implemented in v1 *)
if ::= "IF" cond sequence { "ELIF" cond sequence } [ "ELSE" sequence ]
cond ::= operand relop operand
operand ::= literal | param | query
relop ::= "<" | ">" | "<=" | ">=" | "==" | "!="
sequence ::= "[" [ statement { ";" statement } [";"] ] "]"
call ::= name { positional } { named }
positional ::= arg
named ::= name "=" arg
arg ::= literal | param
literal ::= number | word
param ::= "$" name
name ::= IDENT
word ::= IDENT
number ::= NUMBER
```
The grammar is LL(1): one token of lookahead distinguishes every production,
including `positional` from `named`. A hand-written recursive-descent parser is
sufficient; no parser generator is needed.
### 4.1 The `$` invariant
`$name` means exactly one thing everywhere it appears: **the value of a
parameter of the enclosing procedure**. It never appears at a call site as a
binding target.
```
DEF WIGGLE $deg [ TURN dir=LEFT deg=$deg; TURN dir=RIGHT deg=$deg ]
# ^^^^ binding target: bare
# ^^^^ parameter read: $
```
Parameter scope is strictly the body of the procedure that declares it. There
are no globals, no closures, and no inheritance of a caller's parameters.
### 4.2 Arguments
Arguments may be given positionally, by name, or both — with all positional
arguments preceding all named ones, which is the familiar rule. Named form is
the documented style; positional is shorthand for short, frequent commands.
```
MOVE FWD 50 1000 # positional
MOVE dir=FWD speed=50 dur=1000 # named
MOVE FWD speed=50 dur=1000 # mixed
MOVE dur=1000 speed=50 dir=FWD # named args are order-independent
```
This requires every callable — natives included — to declare parameter *names*,
not just an arity. See §8.
### 4.3 Defaults
A parameter may declare a default, which must be a literal. Parameters with
defaults are optional at the call site; those without are required.
```
DEF SQUARE $side $speed=50 [ ... ]
SQUARE side=1000 # speed defaults to 50
SQUARE side=1000 speed=80
SQUARE # E_ARITY: required parameter 'side' missing
```
Defaults are evaluated at definition time and stored as literals; they cannot
reference other parameters.
**Parameters with defaults must come last.** Without that rule
`DEF F $a=1 $b` would accept `F 5`, bind `a`, and then fail on the missing
`b` — behaviour nobody can predict from reading the signature. Violation is
`E_SYNTAX`.
## 5. Static semantics
Checks performed at **definition** time, so that a malformed procedure is
rejected once at `DEF` rather than repeatedly at each call.
### 5.1 Forward references are forbidden
> A body may only reference callables that already exist.
This single rule is what makes the language safe to run on a microcontroller
with no supervision. Because a callable can only reference callables defined
strictly before it, the call graph is acyclic by construction, recursion is
unrepresentable, and maximum interpreter stack depth is computable at
definition time (and checked against `RUBO_MAX_DEPTH`).
Violation: `E_UNDEFINED`.
### 5.2 Redefinition
Redefining a `DYNAMIC` name is allowed and may change its arity and parameter
names. It is rejected if the new body transitively references the name being
redefined, which is the only way a cycle could otherwise reappear:
```
DEF A [ MOVE FWD 50 100 ]
DEF B [ A ]
DEF A [ B ] # E_CYCLE
```
The check is a depth-first walk of the new body's reference set. On success,
every existing caller is re-validated against the new signature; if any caller
would break, the redefinition is rejected atomically (`E_ARITY` / `E_ARGNAME`)
and the old body is retained.
`NATIVE` and `STATIC` names cannot be redefined or deleted: `E_READONLY`.
### 5.3 Argument checking
Resolved at definition time for calls inside a body, and at parse time for
top-level calls: unknown parameter name (`E_ARGNAME`), duplicate parameter name
(`E_ARGNAME`), a named argument colliding with one already given positionally
(`E_ARGNAME`), missing required parameter (`E_ARITY`), too many positionals
(`E_ARITY`), and value type mismatch where the callable declares a type
(`E_ARGTYPE`).
Range checks that depend on a runtime value (`E_RANGE`) are necessarily
deferred to execution. A word argument is one of these: `MOVE dir=SIDEWAYS`
is well-typed — `dir` takes a word and `SIDEWAYS` is a word — and is caught
as `E_RANGE` when `MOVE` runs and finds it is neither `FWD` nor `BACK`.
### 5.4 Deletion
`DEL` is refused with `E_READONLY` if any other callable references the name,
because deleting it would leave a dangling callee in a body that already
passed validation. Deletion is refused rather than cascaded: removing
procedures the operator did not ask about is a worse surprise than being told
to delete the caller first.
## 6. `REPEAT`
```
REPEAT 4 [ MOVE FWD 50 500; TURN RIGHT 90 ]
REPEAT $n [ ... ]
```
The count is an integer literal or parameter. `n <= 0` executes the body zero
times and is not an error; `n < 0` is `E_RANGE`. The count is clamped to
`RUBO_MAX_ITER`, exceeding which is `E_RANGE` rather than a silent truncation.
`REPEAT` is the only loop. There is deliberately no `WHILE`: an unbounded loop
would forfeit the termination guarantee of §5.1, and the failure mode of a
non-terminating program on a robot with motors is a physical one.
## 7. Reserved: conditionals
Not implemented in v1. Specified here so that adding it later is not a breaking
change to the grammar.
```
IF DIST < 30 [ STOP ]
ELIF DIST < 100 [ MOVE FWD 20 500 ]
ELSE [ MOVE FWD 50 500 ]
```
A condition is a **flat triple**`operand relop operand`. There is no
nesting, no parenthesisation, and no `AND`/`OR`. This keeps it a fixed-size
struct rather than an expression tree, consistent with the decision to omit
arithmetic entirely.
`IF` is bounded and so does not threaten §5.1 — it selects among branches and
never loops.
The blocking dependency is not syntactic. Conditions require **queries**:
callables that yield a value rather than a status. Introducing them changes the
value model, the dispatch table and the HAL contract, and there is nothing
meaningful to branch on until real sensors exist. `IF` therefore lands with the
sensor layer.
## 8. Native signatures
Illustrative, to be finalised alongside the HAL. Listed here because named
arguments require natives to declare parameter names.
| Callable | Parameters | Notes |
|----------|-----------------------------------------|-------|
| `MOVE` | `$dir` `$speed` `$dur=0` | `dir``FWD`,`BACK`; `speed` 0..100; `dur` ms, 0 = until countermanded |
| `TURN` | `$dir` `$deg` | `dir``LEFT`,`RIGHT`. Open-loop until odometry exists |
| `DRIVE` | `$left` `$right` `$dur=0` | tank-style, 100..100 per side |
| `STOP` | — | |
| `WAIT` | `$dur` | ms |
| `CAM` | `$action` | `action``SNAP` |
Units are open until the motion semantics are settled: `TURN deg=90` implies
odometry or an IMU we do not have. v1 is open-loop and the unit slot is
reserved for when encoders arrive.
## 9. Limits
All limits are compile-time constants. Exceeding any is `E_LIMIT` unless a more
specific code applies.
| Constant | Suggested | Meaning |
|----------------------|-----------|---------|
| `RUBO_MAX_NAME` | 15 | identifier length, excluding terminator |
| `RUBO_MAX_PARAMS` | 4 | parameters per callable |
| `RUBO_MAX_DEPTH` | 8 | interpreter call depth |
| `RUBO_MAX_NEST` | 8 | syntactic nesting of sequences |
| `RUBO_MAX_ITER` | 65535 | `REPEAT` count |
| `RUBO_MAX_CALLABLES` | 32 | `DYNAMIC` procedures resident |
| `RUBO_ARENA_INSTRS` | 256 | `STATIC` + `DYNAMIC` procedure storage, in fixed-width instructions |
| `RUBO_MAX_STMT` | 1024 | source bytes in one top-level statement |
## 10. Error codes
| Code | Name | Meaning |
|-----:|---------------|---------|
| 0 | `OK` | success |
| 1 | `E_SYNTAX` | malformed input |
| 2 | `E_UNKNOWN` | no such callable |
| 3 | `E_ARITY` | missing required argument, or too many positionals |
| 4 | `E_ARGNAME` | unknown, duplicate, or conflicting parameter name |
| 5 | `E_ARGTYPE` | wrong value type |
| 6 | `E_RANGE` | value outside accepted range |
| 7 | `E_UNDEFINED` | body references a callable that does not exist |
| 8 | `E_CYCLE` | redefinition would create a cycle |
| 9 | `E_NOMEM` | procedure arena exhausted |
| 10 | `E_LIMIT` | a static limit exceeded (§9) |
| 11 | `E_READONLY` | attempt to delete or redefine a `NATIVE`/`STATIC` callable |
| 12 | `E_BUSY` | reserved — flow layer |
| 13 | `E_ABORT` | execution interrupted |
| 14 | `E_FAULT` | HAL or hardware error |
## 11. Worked example
```
# --- firmware standard library, parsed at boot -------------------
DEF SQUARE $side $speed=50 [
REPEAT 4 [
MOVE dir=FWD speed=$speed dur=$side;
TURN dir=RIGHT deg=90
]
]
DEF PATROL [
SQUARE side=1000;
STOP
]
# --- sent over the wire at runtime -------------------------------
DEF ZIGZAG $deg=45 $reps=3 [
REPEAT $reps [
TURN dir=LEFT deg=$deg;
MOVE dir=FWD speed=40 dur=300;
TURN dir=RIGHT deg=$deg;
MOVE dir=FWD speed=40 dur=300
]
]
[ PATROL; ZIGZAG deg=30 reps=5; SQUARE side=500 speed=30; STOP ]
```
The last line is a top-level sequence: it is submitted, validated and executed
as a single abortable unit.