lrcsim scenario testbed
Status: NORMATIVE. This is the shipped behavior of lrcsim, sim/, and
tests/scenarios/. The design rationale and forward-looking (Mode B, nightly
seed regression) material lives in
research/SCENARIO_TESTBED.md, which stays a
design doc — read it for why; read this page for what ships.
lrcsim is a unix-native binary that runs whole-network scenarios — topology,
physics, mobility, faults, and assertions — against the real lrc::Node
(the same compiled logic that ships in lrcd and firmware), driven by a
virtual clock over a geometric field model. It extends the test ladder in
TESTING.md one tier above ChaosNet: where ChaosNet answers
“is this behavior correct,” lrcsim answers “how does the network behave”
under geometry, mobility, and asymmetric links a flat-broadcast harness can’t
express.
Build & run
Section titled “Build & run”cmake --build build -j --target lrcsim./build/lrcsim run tests/scenarios/hilltop_asym.scn./build/lrcsim run tests/scenarios/hilltop_asym.scn --trace out.jsonl # JSONL trace./build/lrcsim check tests/scenarios/hilltop_asym.scn # parse-only./build/lrcsim credit-sweep --payload-bytes 55 --class chat # see §5Exit code is 0 iff every assert in the scenario passed. --seed N overrides
the scenario’s own seed line (still deterministic, just a different draw).
1. The field model (sim/field.h)
Section titled “1. The field model (sim/field.h)”sim::Field extends tests/virtual_radio.h’s per-pair SNR override into full
geometry, and is the piece Mode A and a future Mode B share (see
SCENARIO_TESTBED.md §2 — Mode B is not built this wave, but the field model
was written with zero dependency on sockets or lrc::Node specifically so a
future lrcbridge can hold the same Field instance).
- Per-node geometry:
(x, y, z)meters, TX power (dBm), antenna gain (dBi). A node without an explicitset_node()call (or.scnx=/y=/grid=) has no geometry and every link to/from it is an open link (clears every preset floor) — the same defaultVirtualRadiouses, so a minimal scenario with nofield/coordinates still runs. - Log-distance path loss:
snr_db = tx_dbm + gains - noise_floor_dbm - (ref_loss_db + 10 * n * log10(distance)) + height_gain + shadow, wherenis the scenario’spathlossexponent (2.7–3.5 typical) andshadowis an optional seeded log-normal draw. Height gain is a small5*log10(combined height / 3m)term, clamped at 0 (never penalizes below the 1.5m baseline). - Directional overrides are first-class:
set_link_override(from, to, snr_x10)pins an exact SNR (or thekSnrOffsentinel — never clears any floor) independent of the reverse direction. This is the mechanism behindhilltop_asym’s “hears 5, heard by 200” — asymmetry is not an approximation layered on top of a symmetric model, it’s a first-class input. - Mobility:
set_waypoints(i, {(t_ms, x, y), ...})gives a node a piecewise-linear path;position_at()interpolates between the bracketing waypoints and holds at the endpoints outside the range (no teleport, no extrapolation). Setting waypoints implies geometry, even without a separateset_node()call. - Determinism: every shadowing draw is seeded per-
(seed, from, to, t_ms)— not from a shared stream position — so repeated or out-of-order queries at the same link/time always agree. A scenario run is therefore a pure function of(scenario file, seed): the same inputs always produce a byte-identical trace, and a CI failure is reproducible with nothing but the checked-in.scnfile.
Unit tests: tests/test_sim_field.cpp.
1a. Co-channel interference (sim/air.h) — the WS-SPECTRUM host slice
Section titled “1a. Co-channel interference (sim/air.h) — the WS-SPECTRUM host slice”LoRa spreading factors are only quasi-orthogonal: a co-channel
transmission at a different SF is interference attenuated by a finite
rejection figure, not an invisible neighbor. The medium models this with a
parameterized per-preset-pair rejection matrix (SfRejection — keyed by
preset pair, not raw SF pair, because presets fix the whole (SF, BW, CR)
triple and cross-BW same-SF pairs like P5-SF7/250 vs P6-SF7/500 are distinct
chirp rates with their own partial rejection) plus a capture threshold.
Defaults are the planning numbers — 0 dB same-preset, 16 dB cross-preset,
6 dB capture — and they are deliberately parameters: the roadmap calls
multi-SF sharing “a measured power-discipline design, not an assumption”,
and the Wave 4 RF bench campaign is expected to overwrite them with measured
per-pair values (Runner::set_rejection(), or the .scn scalar knobs) and
re-run the scenario suite.
How adjudication works (full detail + documented simplifications in
sim/air.h’s header comment):
- Every radiated frame occupies its real airtime window
(
airtime_ms(preset, bytes)) on its freq_slot — a ~2.5 s anchor frame genuinely spans virtual ticks and interferes with later ticks’ traffic. Different freq_slots are fully isolated. - Reception survives iff
desired − (strongest interferer − rejection[d][i]) ≥ capture. The strongest single post-rejection interferer decides, and the trace names it (sim_dropreason"collision", §6). - Same-preset overlaps between mutually-audible transmitters are skipped: CAD (carrier sense) would have serialized them. CAD cannot detect a different preset’s chirp rate, so cross-preset overlaps get no such protection — which is exactly why the rejection math exists.
- Same-preset hidden pairs collide only when a scenario opts in
(
field co_sf_collisions 1). Off by default: the canonical library’s accelerated gossip cadences (2 s RTRSYNC on multi-second anchor airtimes) deliberately outrun physical airtime and would self-jam; making that honest needs contention-window modeling, which is its own future workstream (SCENARIO_TESTBED.md §4). - Known simplifications, deliberate and documented: one-directional in-tick adjudication (a frame sees interferers recorded before it in the same tick plus everything still on air from earlier ticks — cascade order is a causal order); no half-duplex (a transmitting node can still receive that tick); no adjacent-channel leakage; no power summing across interferers.
The headline numbers, pinned by tests/test_sim_air.cpp on one fixed
4-node co-channel topology (desired signals +12 dB, cross-pair interference
+8 dB, senders hidden — every run identical except one variable):
| Configuration | Pair A | Pair B |
|---|---|---|
| Both pairs same SF (baseline) | 0/1 | 0/1 — SIR 4 dB < 6 dB capture |
| Pair B layered onto SF9 | 1/1 | 1/1 — SIR becomes 20 dB |
| Layered, interferer +30 dB (near-far) | 1/1 | 0/1 — rejection is not orthogonality |
| Layered, interferer at exactly +22 dB | 1/1 | 1/1 — break-even, inclusive |
| Layered, interferer at +23 dB | 1/1 | 0/1 — one dB past break-even |
The break-even formula a deployment’s power discipline must honor: a
co-channel different-SF neighbor may run up to (rejection − capture) =
10 dB hotter than the desired signal; at the same SF it must stay 6 dB
quieter. Layering buys a 16 dB swing — exactly the rejection parameter, no
more. tests/scenarios/multi_sf_share.scn gates CI on both halves: the
disciplined pairs deliver 1/1 (ratio>=1.0) and the near-far victim pair
provably starves (ratio<=0.0).
2. The .scn scenario file format (sim/scn.h)
Section titled “2. The .scn scenario file format (sim/scn.h)”Flat, diff-friendly, hand-rolled parser (no TOML/YAML dependency), in the
spirit of tests/lrctest.h. One construct per line; # starts a trailing
comment only when it begins a new word (so chan=#field is not treated
as a comment — a channel name is itself #-prefixed). Blank lines and
full-line comments are ignored.
seed 42field pathloss 3.0 shadow 2.0node hill role=router x=0 y=0 z=150 tx_dbm=27node plain[1..200] role=client grid=1000x1000 tx_dbm=14link hill->plain[*] snr=+8link plain[6..200]->hill snr=offlink plain[1..5]->hill snr=-14at 60s chanmsg from=plain[137] chan=#field text="can anyone hear me"at 300s kill hillat 420s revive hillmobility plain[42] waypoints (0,0)@0s (900,900)@600sassert converged chan=#field by=900sassert delivered from=plain[137] to=plain[1..200] ratio>=0.95assert duty_legal allassert no_dup_delivery allConstructs
Section titled “Constructs”seed N— required; the u32 seed a run derives from. Missing it is a parse error (“runs would not be reproducible”).field pathloss N shadow N [duty_bp N] [duty_window_ms N] [region NAME] [region_freq_khz N] [region_window_ms N] [sf_rejection N] [capture N] [co_sf_collisions 0|1]— optional; defaults arepathloss=3.0,shadow=0(off), no enforcement,sf_rejection=16dB,capture=6dB,co_sf_collisions=0.duty_bp(basis points, 0–10000, matcheslrc::AirtimePolicy::duty_bpexactly) +duty_window_msapply the medium’s own uniform duty budget;region+region_freq_khz(+ optionalregion_window_ms) drive Node’s real regulatory gate instead — the two are mutually exclusive (§4).sf_rejection/capture/co_sf_collisionsdrive the co-channel interference model — see §1a.fieldparameters are space-separatedkey valuepairs (notkey=value— this is the one construct that differs from the rest of the grammar, matching the spec’s own golden example).node <name-or-range> [role=router|client] [x=N y=N | grid=WxH] [z=N] [tx_dbm=N] [preset=<0..7|NAME>] [freq_slot=N]— declares one or more nodes.name[a..b]expands tonameA … nameB;name[k]is single-index shorthand.grid=WxHplaces the expanded group on a deterministic near-square grid over that box (indexed by position within the group, so adding a different group never reshuffles this one).x=/y=andgrid=are mutually exclusive. Omitting both leaves the node without geometry (open-link default).preset=is the node’s initial lane placement (WS-SPECTRUM: SF layering is lane placement) — an index or a ladder name (ANCHOR…BURST, validated againstlrc::kPresets);freq_slot=the frequency slot (0–255). Both default to the anchor lane, slot 0 — pre-lane-syntax scenarios are unchanged. A reboot (revive) re-adopts the declared placement, not a mid-runlaneretune.rdist=(0–15;ERB.rprogis a 4-bit wire field) is the gradient-descent router-distance the node claims (Node::RoutingDistanceContext) — the v2 credit-routing relay predicate compares against it; default 0 (“co-located with a router”).link <from>-><to> snr=<dB>|off— a directional override. Both sides accept a bare name, a range (plain[1..5]), or[*](every already- declared node whose name has that prefix).snr=offuses thekSnrOffsentinel.at <time> <verb> ...— a timed event. Time acceptsNs/Nms/Nm/Nhsuffixes (bare integers are seconds). Verbs:chanmsg from=<node> chan=<#chan> text="..."/dm from=<node> to=<node> text="..."— drives the node’s local IRC session (PRIVMSG).join from=<node> chan=<#chan>— an explicit JOIN, for scenarios that want to test join timing itself (every node auto-joins every channel the scenario ever references at t=0 by default — see §3 — so this verb is for mid-run joins, not baseline membership).kill <node-or-group>/revive <node-or-group>— tears down/rebuilds the node’slrc::Nodeinstance. Revive keeps the node’s persisted stores (seed, seq, channel records, mailboxes — “reboot with persistence”) but gets a fresh boot-id, exactly like a real reboot.partition <groupA> <groupB>/heal <groupA> <groupB>— cuts/restores every link between two node groups (pairwise, both directions).lane <node-or-group> preset=<0..7|NAME> [freq_slot=N]— a mid-run lane retune (WS-SPECTRUM). Emits thelanetrace kind withreason:"scenario"(§6).inject from=<node> [class=chat|discovery|control] [credit=N] [rprog=N] [bytes=N]— radiates a synthetic v2 DM (AC stamped fromcredit_initial_budget()unlesscredit=overrides;rprogdefaults to the sender’srdist=;dstis a UID no node owns so the frame is never final-local). A laboratory origination seam — Node does not originate v2 frames itself yet; every RELAY decision the injection triggers is the realcredit_forward()gate incore/src/node.cpp.credit_depth.scnis built on it.skew <node> by=<offset>— accepted by the parser (grammar completeness) but not yet wired toNode’s clock seam — see §7.
mobility <node> waypoints (x,y)@Ts (x,y)@Ts ...— see §1. Waypoints must be time-ordered.assert <kind> ...— see §3.
The parser does not stop at the first error: a malformed scenario reports
every problem found in one pass, each with a line number
(sim::ScnError::to_string() → "line N: message").
Unit tests: tests/test_sim_scn.cpp (includes the spec’s own golden example
as a parse vector).
3. Mode A: the runner (sim/runner.h)
Section titled “3. Mode A: the runner (sim/runner.h)”sim::Runner is the N-node generalization of tests/test_chaos.cpp’s
ChaosNet, specialized for .scn files. It wires one real lrc::Node per
scenario node through the field model as a radio-only medium — the exact
pattern ChaosNet::use_virtual_radio() established (out_to_peers/
out_to_peer unwired, out_to_radio → the field-backed medium,
on_radio_frame the only ingest) — so a scenario exercises the same
Node/LaneSchedule/AirtimeLedger code that ships, not a parallel model.
Every node auto-JOINs every channel the scenario ever references (in a
chanmsg event or an assert ... chan= clause) at t=0, so to= addressing
in a delivered assertion has somewhere to land without a redundant join
line per node.
Assertion vocabulary
Section titled “Assertion vocabulary”converged chan=#x by=Ts— every alive node with a non-empty membership view of#xcounts as converged; passes if every joined node converged (or there are none).by=is accepted for readability but not currently a hard deadline check — convergence is evaluated once, at the end of the run (after a 40-tick/1500ms settle pass, mirroringChaosNet::settle()).delivered from=<node> to=<node-group-or-all> ratio<op>N [text="..."]— checks what fraction ofto’s members received a given message.to=accepts a name, a range,[*], or the wordall(every declared node, matchingduty_legal all’s vocabulary); ato=that resolves to zero real receivers fails with a “vacuous assert” detail rather than passing on an empty denominator (a typo’d group name used to green-wash). The ratio operator is part of the claim:ratio>=Nasserts delivery,ratio<=Nasserts NON-delivery (starvation/collision-loss claims —multi_sf_share.scn’s pair C, the region-gate starvation tests),ratio=Npins an exact fraction. Withouttext=, the checked message is the firstchanmsg/dmevent whosefrom=matches (the common case: one message per sender). Withtext=, it checks a specific message explicitly — needed once a sender speaks more than once in the same scenario (seereboot_storm.scn).duty_legal all— every configuredAirtimeLedger(see §4) must never have exceeded its budget; checked viaremaining_duty_bp(), which is never negative by construction ifadmit()/charge()are used correctly.no_dup_delivery all— no node’s local IRC session ever saw the samePRIVMSGline more than once (mirrorstest_chaos.cpp’schaos_duplicate_storm_delivered_oncepattern, at the IRC-observable layer).path_len_max N/path_len_min N— the deepest v2 relay path any single(SRC, MSGID)frame took this run (counted from thecredit_debittap — dedup guarantees a node relays a frame at most once, so per-frame relay events = path depth for a chain, relay-set size for a flood) is ≤ / ≥ N.credit_drop_max N/credit_drop_min N— total credit drops this run (credit.drop_exhausted+credit.drop_progresstelemetry deltas from run start) is ≤ / ≥ N; the detail line reports the two components separately.anchor_occupancy_max— still accepted-but-skipped (no occupancy data source in Mode A yet); the trace makes the skip visible (detailstarts with"skipped: ") rather than silently passing.
Unit tests: tests/test_sim_runner.cpp.
4. Regulatory enforcement (two layers, use exactly one)
Section titled “4. Regulatory enforcement (two layers, use exactly one)”Regulatory enforcement is REAL in core now: WS-REGION wired duty/dwell/LBT
into lrc::Node’s radio TX path (core/src/node.cpp’s emit_radio_frame
gate, active when a RegionTxContext is set) and into the firmware radio
loop. A scenario can exercise either of two enforcement layers — and per
node.h’s double-charge rule, exactly ONE layer per embedding owns
enforcement, so the .scn grammar makes them mutually exclusive:
field region NAME region_freq_khz N [region_window_ms N]— drives Node’s real gate: the runner callsNode::set_region_tx_context()with the namedlrc::kRegionPlansrow (validated at parse time), the carrier frequency (required — the gate resolves the legal sub-band from it, and an unresolvable frequency refuses every TX, never falls back), the node’s current preset (refreshed onlaneretunes — preset is the airtime currency), and the window (default: the regulatory hour). A refused frame never reaches the medium: nopacket_txin the trace, and the refusal lands on the realtx_duty_defercounter. lrcsim is exactly the embedder classnode.hassigns this layer to (synchronousout_to_radio).field duty_bp N duty_window_ms N— the medium’s own ledger: a uniformlrc::AirtimePolicyper node via a standalonelrc::AirtimeLedgerat the sim-medium level, useful when a scenario wants budget pressure without naming a real region (the budget is a free parameter, not a table row). A deferred frame shows assim_dropwithreason:"duty_deferred"in the trace, andremaining_duty_bp()backs theduty_legalassertion.
In BOTH layers a deferred frame is dropped, not queued for retry — there is
no “wait for budget, then send” queue at these layers (recovery of a
starved message, when it happens, is CHANSYNC noticing the gap once a later
frame exposes it). eu868_duty_starve.scn asserts both halves of the real
gate’s contract under g1 pressure: the budget admits while headroom exists
(first message delivered) and never over-admits (duty_legal).
Per-node or per-sub-band budgets in one scenario (a router juggling EU868’s
g/g1/g2/g3 sub-bands independently — AirtimeLedger’s per-(lane, sub_band) keying supports it; a single Node context already resolves the
sub-band from its one carrier) need a per-node grammar extension this wave
doesn’t add.
5. Credit sweeps and the v2 freeze evidence
Section titled “5. Credit sweeps and the v2 freeze evidence”With the v2 wire batch, AC is live on Node’s relay path, and
credit_depth.scn (canonical library, CI-gated) measures reach on REAL
relay traffic via the inject verb + the credit_debit trace tap. Three
harnesses serve different purposes:
lrcsim run tests/scenarios/credit_depth.scn— real-traffic reach per preset throughcore/src/node.cpp’s actualcredit_forward()branch. Headline numbers at the inject reference frame (~87 B): ANCHOR debits 82/hop → 1 relay then exhaustion; REACH 9/hop → 13 relays, the budget binds mid-gradient; TRUNK 1/hop → all 15 gradient-legal relays with 105 units unspent. On fast lanes the binding per-cell constraint is the 4-bitERB.rproggradient (15 levels per re-origination domain), not the budget —AC’s job there is bounding cumulative airtime, not hop count.lrcsim credit-sweep— pure-decision-layer per-preset walk (sim/credit_sweep.h), unconstrained by the 4-bit gradient, for budget-arithmetic questions.lrcsim credit-evidence unit|min-debit|duty|lbt— the deterministic sweep tables committed as the v2 freeze evidence (docs/research/traces/credit-freeze/, cited by PROTOCOL.md’s frozen-constants table). Byte-reproducible with exactly these commands.
6. JSONL trace schema (sim/trace.h)
Section titled “6. JSONL trace schema (sim/trace.h)”Every run can emit a newline-delimited JSON event stream (--trace FILE).
This is a contract: CI attaches it as a failure artifact
(.github/workflows/scenarios.yml), tuning sweeps read distributions off
it, and a future web observatory (CHIRPSCOPE) replays it. The schema is
reconciled with WS-SCOPE’s live daemon SSE feed (docs/OBSERVATORY.md)
so one viewer can replay both a live network and a simulated one:
- Envelope on every line:
v(schema version, currently 1),mono_ms(u64 — the scenario’s virtual clock, not wall time),clock_kind(always"virtual"here — WS-SCOPE’s live feed uses the same field name for its real process-monotonic clock, distinguished by its ownclock_kind),kind(event kind). packet_tx/packet_rx— match WS-SCOPE’s kind names and packet- header field set exactly:type(thePacketTypeC++ enumerator name verbatim —"ChanMsg","Dm","RtrSync", …),flags,ttl,hops,msgid,src/dst(lowercase hex, explicitnull— not omitted — when the packet’s type group doesn’t carry that field),scope/seq24(hex CHAN4 / u32, null for non-group-A types),via_tcp,payload_len, plus sim-specificpreset/freq_slot/snr_x10(rx only) andx/y(null when the node has no geometry).sim_drop— the broadcast medium didn’t deliver a frame (reason:below_floor|wrong_lane|duty_deferred|collision|link_down). Sim-only — a live daemon doesn’t see “the medium refused this,” so this kind is prefixed to guarantee it never collides with a future live-feed kind. Forcollision(co-channel interference, §1a) the line also carriesinterferer(the node whose overlapping transmission won) andinterferer_snr_x10(its post-rejection effective SNR at the listener);snr_x10is the desired signal — the drop means their difference fell under the capture threshold.lane— a node’s active lane changed:preset,freq_slot,reason("scenario"for alane-event retune, today’s only producer). Named without thesim_prefix deliberately: a live daemon’s lane retune is the same observable, so the kind is shared with the future live feed rather than forked.sim_node_state— a scenario-driven up/down transition (kill/revive) or theskewverb’s accepted-but-unwired no-op notice. Sim-only.sim_assert— one assertion’s outcome at run end:assert_kind(notkind, to avoid colliding with the envelope’s ownkind:"sim_assert"),line,pass,detail. Sim-only.credit_debit— one line per v2 relay decision, fed fromNode::Config::on_event’sNodeEvent.credit/debit_unitsfields (the shared observability seam the live SSE feed also reads — PROTOCOL.md §Observability — so lrcsim never re-decodes theACbyte off the wire):node,type,src(hex),msgid,credit_in(AC on arrival),debit_units(this hop’s charge),credit_out.- Reserved, not yet emitted:
dedup;registered/deregistered/router_liveness(WS-SCOPE’s kind names — would be emitted verbatim, notsim_-prefixed, once Mode A surfaces HELLO/WELCOME/BYE/liveness transitions as trace events — a known gap, see §7).
7. Known gaps (read before extending)
Section titled “7. Known gaps (read before extending)”- Mode B (
lrcbridge, reallrcdprocesses over UDP) is not built. The field model (sim/field.h) has zero dependency on sockets orlrc::Nodespecifically, by design, so a futurelrcbridgecan hold the sameFieldinstance — but the broker itself, process spawn/kill, and theSIGKILLmid-write persistence torture are unbuilt. skew(clock skew injection) parses but does nothing. No seam intoNode’s clock exists in the.scn→Runnerpath yet.- No per-node/per-sub-band regulatory budget. See §4 — both enforcement layers are scenario-uniform today.
- Lane placement is scenario-driven, not Node-decided.
node preset=andat .. lane(§2) place and retune lanes, but the runner does not yet driveLaneSchedule’s own promotion ladder — a scenario can’t yet express “this router PROMOTES clients to a faster lane” as emergent behavior, only as a scripted retune. - Same-preset hidden-node collisions are opt-in (
field co_sf_collisions 1— §1a). The default keeps the historical contention-window-managed behavior because the canonical library’s accelerated gossip cadences would self-jam under honest same-preset airtime accounting; contention-window calibration is its own future workstream (SCENARIO_TESTBED.md §4). Cross-preset interference is always on. Also per §1a: in-tick adjudication is one-directional, and half-duplex is not modeled. injectis a laboratory origination seam — Node relays v2 frames (the realcredit_forward()gate) but does not originate them; when v2 origination lands in Node,credit_depth.scnshould switch frominjectto organic traffic. See §5.- The radio medium is O(n²) per transmission round (
Runner:: emit_radioscans every node).metro_500.scnis scaled to 60 clients (from the spec’s literal 500) for CI tractability; see that file’s own header comment for measurements and what optimizing this would take (spatial partitioning, or a proper multicast group instead of an all-pairs scan). - No nightly randomized-seed regression job (SCENARIO_TESTBED.md §6’s
regression-seedslist) — only the fixed seeds baked into each canonical.scnrun in CI today.