↩️ This repo is Iteration 4 of a software-defined-vehicle (#SDV) prototype. Each iteration grows on its predecessor — reusing and refactoring what still fits, and changing structure where the next goal requires it.
| Iteration | Repository | Focus |
|---|---|---|
| 1 | sdv_simulation_1 |
First working CAN control loop |
| 2 | sdv_simulation_2 |
Assembly-based zone contexts, transition ledger, diagnostics |
| 3 | sdv_simulation_3 |
Headlamp twinlet (first actorified assembly), quiescent commit |
| 4 | sdv_simulation_4 (this repo) |
Brain FSM Redesign — start/stop barriers, generic assembly envelopes, ROB turn queue, second assembly (Wiper) |
Iteration 4 completes the Brain FSM Redesign. The central goal was to replace the
Iteration-3 monolithic handle() — which had 5 match arms and a fragile
Option<PendingBrainTurn> — with a clean VecDeque<BarrierEntry> drain loop, a
re-order buffer (ROB) pattern that decouples assembly tell-back arrival order from event
ingress order.
The Brain actor's mailbox receives both of:
- External events, coming from Controller, viz.,
PowerOn,RainsStartedetc. - Conversational events, coming from the Assemblies, viz.,
ZoneReady(Headlamp)
In Iteration 3, each CAN ingress produced a single pending turn. If two events - say,
HeadLampOn arrived while the first was still awaiting a headlamp tell-back, the second was stuffed into a
VecDeque backlog. When replies arrived out of order (e.g. event B's reply arrived before
event A's), the ledger recorded them in arrival order, violating causal consistency.
TurnBarrier solves this: every ingress event gets its own barrier pushed onto a
VecDeque<BarrierEntry>. Each barrier tracks which assemblies (BTreeSet<AssemblyId>) must
reply before it can commit. The drain loop inspects only the front entry: if its
pending set is non-empty, the loop stalls; once empty (or if the entry is Passthrough),
it pops and commits. This guarantees that the ledger records events in ingress order
regardless of reply arrival order.
Iteration 3 had no formal power-cycle protocol: IgnitionOffReset was handled speculatively
in the actor code. The new FSM introduces two explicit preparing states:
PreparingToStart(BTreeSet<AssemblyId>)— entered onPowerOn. The Brain emitsStartAssembliesdomain actions (→BecomeOntells to each assembly) and counts downAssemblyZoneReadyacknowledgements. When the set empties, the FSM transitions toIdle.PreparingToStop(BTreeSet<AssemblyId>)— entered onPowerOfffromIdle. Symmetric shutdown: emitStopAssemblies(→BecomeOfftells to each assembly), count down acknowledgements, then transition toOff.
The BTreeSet<AssemblyId> embedded in the state variant is the sole authoritative
countdown — no duplicate state in VehicleContext.
- ROB ordering: The
VecDeque<BarrierEntry>drain loop guarantees causal consistency regardless of assembly reply arrival order. Event ingress order is the ledger order. - Generic assembly envelopes: Mailbox variants
ZoneReady { zone_id, reply }andZoneSpontaneous { zone_id, event }replace headlamp-specificHeadlampZoneReady/HeadlampZoneSpontaneous. Adding a new assembly (Wiper) requires zero newhandle()arms. - FSM-driven lifecycle:
StartAssemblies/StopAssembliesdomain actions are emitted byoutput()on entry to preparing states, replacing the old manualBecomeOn/BecomeOffwiring in tests. - Per-assembly retry:
ZoneTellBackTimeout { zone_id, turn_id, tell_attempt }retries each unresponsive assembly independently, rather than a singleTellBackTimeout { turn_id }that retried all zones at once. - Second assembly (Wiper):
AssemblyId::Wiperis now defined alongsideHeadlampinALL_ASSEMBLIES. Wiper is a fully actorified twinlet with immediate transitions (no ACK protocol). PendingBrainTurndeleted: The old enum and its special-case handling are fully absorbed into the ROB pattern.- Brain Actor's
handle()arms: Exactly 4 arms —Fsm,ZoneReady,ZoneTellBackTimeout,GetStatus. Zero new arms per new assembly.
Terms used throughout this README.
| Term | Meaning |
|---|---|
| Assembly | A managed device group identified by AssemblyId (Headlamp, Wiper). Each assembly has a lifecycle (<BecomeOn> → Ready → … → <BecomeOff> → Off) and a {Assembly}Context in VehicleContext. |
| Twinlet | One assembly as a child actor under the BrainTwin (mailbox + timers). |
| Brain | The digital twin overall — parent actor (VirtualCarActor), quiescent commit, ledger, actuation. State: DigitalTwinCar. |
| Zone | SDV industry term for a vehicle concern. The mailbox vocabulary uses ZoneReady / ZoneReply / ZoneSpontaneousEvent as generic envelopes — these are not the internal tracking type (AssemblyId is). |
| Tell | Brain → twinlet; fire-and-forget (mailbox free until tell-back or timeout). |
| Tell-back | Twinlet → Brain reply (turn_id, tell_attempt; e.g. ZoneReady { zone_id: Headlamp, … }). |
| Spontaneous | Twinlet tell-back on its own deadline (e.g. ACK timer), not a new CAN ingress. |
| Cut | Snapshot (FsmState, VehicleContext) at one instant. |
| Quiescence | Multi-hop resolve to a stable cut; one ledger row per hop; one apply_step. |
| ROB | Re-order buffer (VecDeque<BarrierEntry>) — preserves event ingress order regardless of assembly reply arrival order. |
| TurnBarrier | One per ingress event: tracks which assemblies have replied, holds per-assembly timers and replies. |
| Pyramid | Layered modules in common (L0–L6, physics up to gateway/actuator binaries); acyclic imports. |
The Brain's operational FSM governs the Digital Twin's mode. All 7 states:
Off ──PowerOn──► PreparingToStart({Headlamp, Wiper})
│ each AssemblyZoneReady(id) removes id
▼
PreparingToStart({Wiper})
│ AssemblyZoneReady(Wiper) → set empty
▼
Idle ──UpdateRpm > threshold──► Driving
│ │
PowerOff stationary
│ │
▼ ▼
PreparingToStop({Headlamp, Wiper}) Idle
│ symmetric to start
▼
Off
Driving ──Internal(LightingUnsafe)──► DrivingDangerously
DrivingDangerously ──headlamp On or lux high or stationary──► Driving or Idle
Driving ──speed > 160 km/h──► ExtremeOperationWarning(now) ──TimerTick + cooldown──► Driving
Full transition table: diagrams/brain_transitions.md
transition()is a pure function — no I/O, no actor, no heap allocation or discovery of runtime resources. It receives only(old_state, event, ctx)and returns(new_state, maybe_error). All side effects (timers, tells, ledger writes) are driven by the caller from the returned state.output()is a pure function — maps(old_state, new_state, ctx)toVec<FsmAction>.- Both are called exactly once per event, in strict order, from the actor's
handle()thread. - Execution of
tansition()andoutput()is always single-threaded. - The
BTreeSet<AssemblyId>insidePreparingToStart/PreparingToStopis the sole authoritative countdown while waiting for responses from all the assemblies that the brain has. EachAssemblyZoneReady(id)event removes thatidfrom the set. When the set becomes empty, the transition function maps toIdle(start) orOff(stop). No separateremaining_assembliesfield exists inVehicleContext. - External events that arrive during a preparing state are recorded in the ledger with
applied: falseand discarded — they are never replayed. Events that arrive after the FSM has reachedIdleare processed according to their semantic meaning (e.g.UpdateRpmmay transition toDriving). PowerOffis rejected (logged) inDriving,DrivingDangerously,ExtremeOperationWarning, orOff.- Every power cycle goes through a preparing state — never
Off ↔ Idledirectly.
Each assembly transitions independently through its own internal FSM, driven by
BecomeOn / BecomeOff tells from the Brain during PreparingToStart /
PreparingToStop. The assembly acknowledges readiness via
AssemblyZoneReady(AssemblyId) tell-backs. Internal states differ by assembly —
headlamp and wiper are documented separately below.
Lux thresholding and hardware AckOn / AckOff intermediates:
Off ──BecomeOn──► Ready ──lux≤threshold──► OnRequested ──AckOn──► On
▲ ▲ ◄──ActIncompl(On)/Timeout── │
│ │ │ BecomeOff
│ │ ▼
│ │◄──────────── AckOff ────────────────── OffRequested
│ │ │
│ └──────── BecomeOff ────────────────────────────┘
└────────────────────────── BecomeOff ───────────────────────────────┘
Events (messages from Brain):
BecomeOn— Brain tell: power up the assembly lifecycle.BecomeOff— Brain tell: power down the assembly lifecycle.
States:
- Off — Initial; no actuation. Ignores all zone messages except
BecomeOn/BecomeOff. - Ready — Idle but powered; awaiting lux threshold command.
- OnRequested — ON actuation sent to hardware; awaiting
AckOn. - On — Physical lamp confirmed ON by hardware ACK.
- OffRequested — OFF actuation sent to hardware; awaiting
AckOff.
Full diagram: diagrams/headlamp_assembly_state_transition.md
Wiper uses a simpler three-state model (Off / Ready / Running) with no lux gate and
no ACK intermediates — see diagrams/wiper_assembly_state_transition.md.
The common crate follows an acyclic layer pyramid. The critical invariant:
L2 (fsm) must not import L3 or above.
L0 vehicle_physics Constants, pure kinematics (no FSM, no I/O)
L1 vehicle_state Assemblies: powertrain, health, visibility, headlamp, wiper
L2 fsm FsmState, FsmEvent, step(), transition_map — pure decision core
L3 digital_twin, published Twin capsule (DigitalTwinCar), serializable ledger projection
L4 twin_runtime, sinks VirtualCarActor, HeadlampActor, WiperActor, turn_barrier, detectors
L5 facade Public surface — the only module gateway binaries may import
L6 gateway, emulator, Application binaries — never import internal modules
front_headlamp_actuator, wiper_actuator
Detail: docs/library-reorg.md
DigitalTwinCarVocabulary (mailbox)
├── Fsm(FsmEvent) ← CAN ingress / detector events
├── ZoneReady { zone_id, turn_id, reply } ← twinlet tell-back (correlated)
├── ZoneSpontaneous { zone_id, event } ← twinlet tell-back (unsolicited)
├── ZoneTellBackTimeout { zone_id, … } ← per-assembly retry deadline
└── GetStatus(RpcReplyPort<CarSnapshot>) ← synchronous snapshot query
VirtualCarActor (Brain)
├── barrier_queue: VecDeque<BarrierEntry> ← ROB (Waiting / Passthrough variants)
│ └── TurnBarrier ← per-assembly pending set + timers + replies
├── on_zone_ready() ← matches barrier by turn_id
├── on_zone_timeout() ← per-assembly retry or synthetic reply
├── on_zone_spontaneous() ← unsolicited twinlet event
├── begin_fsm_turn() ← pushes barrier, tells assembly(ies)
└── try_drain_barrier_queue() ← commits completed barriers front-to-back
---
config:
theme: default
themeVariables:
signalColor: "#111111"
signalTextColor: "#111111"
actorLineColor: "#111111"
actorTextColor: "#111111"
noteBkgColor: "#fff9e6"
noteTextColor: "#111111"
---
sequenceDiagram
participant CAN as vcan0
participant GW as Gateway
participant Brain as Brain
participant HL as Headlamp twinlet
participant Act as Actuator
participant Sink as Sinks
%% --- TURN A: LUX INGRESS (Light Blue Background) ---
rect rgb(215, 230, 255)
Note over CAN,Sink: Turn A - lux ingress
CAN->>GW: AmbientLux frame
GW->>Brain: 1 Fsm - UpdateAmbientLux
Brain->>Brain: begin_fsm_turn push BarrierEntry::Waiting( pending: Headlamp )
Brain->>HL: 2 tell Apply - fire-and-forget
Note over Brain: handle returns - mailbox free
HL->>HL: on_receiving_message - twinlet handle
HL-->>Brain: 3 ZoneReady( zone_id: Headlamp, reply, ... )
Note over Brain: on_zone_ready moves Headlamp from pending to zone_replies
Brain->>Brain: try_drain_barrier_queue commit
Brain->>Sink: ledger hop(s)
Brain->>GW: actuation cmd - async channel
GW->>CAN: CMD ON
end
%% --- CAN ROUND-TRIP (Light Green Background) ---
rect rgb(220, 250, 220)
Note over CAN,Brain: Later - CAN round-trip
CAN->>Act: CMD ON
Act-->>CAN: ACK
CAN->>GW: ACK frame
GW-->>Brain: 4 Fsm - FrontHeadlampOnAck
Note over Brain: separate turn, headlamp state becomes On
end
%% --- TIMEOUT ALTERNATIVE (Light Red Background) ---
rect rgb(255, 220, 220)
Note over HL,Brain: Timeout alternative
HL-->>Brain: 4 ZoneSpontaneous( zone_id: Headlamp, ... )
Note over Brain: on_zone_spontaneous yields synthetic FSM event
end
Both managed assemblies run as child actors under the Brain: HeadlampActor and
WiperActor. They share the same tell / tell-back envelope (ZoneReady) but differ in
whether operational transitions wait for hardware ACK.
Headlamp models a full actuation round-trip: intermediate OnRequested / OffRequested
states, an ACK timer, and spontaneous ZoneSpontaneous events on deadline expiry.
HeadlampActorMsg (mailbox)
├── Apply(BrainTurn) ← Brain tell: apply new state
├── BecomeOn ← Brain tell: power up lifecycle
├── BecomeOff ← Brain tell: power down lifecycle
└── Internal(FrontHeadlampOnAck) ← actuator ACK (spontaneous)
HeadlampActor
├── ctx: HeadlampContext ← domain state
├── state_machine: AssemblyStateMachine ← assembly lifecycle FSM
├── ack_timer: Option<JoinHandle<...>> ← ACK deadline timer
├── on_receiving_message() ← match on Apply / BecomeOn / BecomeOff
└── send_to_brain() ← tell-back ZoneReady / ZoneSpontaneous
---
config:
theme: default
themeVariables:
signalColor: "#111111"
signalTextColor: "#111111"
actorLineColor: "#111111"
actorTextColor: "#111111"
noteBkgColor: "#fff9e6"
noteTextColor: "#111111"
---
sequenceDiagram
participant Brain as Brain
participant HL as HeadlampActor
participant HW as Hardware
%% --- POWER-ON SEQUENCE (Light Blue Background) ---
rect rgb(215, 230, 255)
Note over Brain,HW: Power-on sequence
Brain->>HL: BecomeOn
HL->>HL: transition: Off to BecomeOn
HL->>HW: actuate power circuit
HW-->>HL: ACK
HL->>HL: transition: BecomeOn to Ready
HL-->>Brain: ZoneReady( zone_id: Headlamp, state: Ready )
end
%% --- OPERATIONAL COMMAND (Light Green Background) ---
rect rgb(220, 250, 220)
Note over Brain,HW: Operational command
Brain->>HL: Apply( target_state: On )
HL->>HL: transition: Ready to OnRequested
HL->>HW: actuate lamp
HW-->>HL: ACK
HL->>HL: transition: OnRequested to On
HL-->>Brain: ZoneReady( zone_id: Headlamp, state: On, reply: ... )
end
%% --- POWER-OFF SEQUENCE (Light Red Background) ---
rect rgb(255, 220, 220)
Note over Brain,HW: Power-off sequence
Brain->>HL: BecomeOff
HL->>HL: transition: On to OffRequested
HL->>HW: de-actuate
HW-->>HL: ACK
HL->>HL: transition: OffRequested to BecomeOff to Off
HL-->>Brain: ZoneReady( zone_id: Headlamp, state: Off )
end
Wiper is a fully actorified twinlet with immediate transitions and no ACK protocol.
Rain ingress (RainsStarted / RainsStopped, projected from VssSignal::RainDetected on
CAN) is routed to the wiper zone while the Brain is in Idle or Driving. A successful
Start / Stop tell emits StartWiping / StopWiping outcomes that traverse the
actuation path to ActuationCommand::StartWiper / StopWiper on CAN.
WiperActorMsg (mailbox)
└── Apply(WiperActorVocabulary) ← Brain tell: one WiperMessage per turn
WiperActor
├── ctx: WiperContext ← domain state (Off / Ready / Running)
├── on_receiving_message() ← pure L1 handler; immediate ZoneReady reply
└── tell-back ZoneReady ← no spontaneous events, no ACK timer
- Three states only —
Off,Ready,Running; noOnRequested-style pending states. - Tell-back timeout still applies (Brain↔twinlet coordination). Exhaustion yields a
synthetic
LogWarningoutcome on the diagnostic stream (unlike headlamp, there is noZoneSpontaneouspath). - L6 path — gateway wiper command publisher →
vcan0→wiper_actuatorbinary (fire-and-forget motor log; optionalWIPER_ACTUATOR_DROP_RESPONSE_PROBto ignore CMDs).
Full diagram: diagrams/wiper_assembly_state_transition.md
Each external ingress or twinlet spontaneous event creates one TurnBarrier wrapped in
BarrierEntry and pushed onto barrier_queue. The drain loop commits barriers strictly in
ingress order, regardless of which assembly replies arrive first.
pub(crate) struct TurnBarrier {
turn_id: u64,
event: FsmEvent,
now: Instant,
pending: BTreeSet<AssemblyId>, // assemblies not yet replied
zone_waits: HashMap<AssemblyId, TellBackWait>,
zone_timers: HashMap<AssemblyId, TellBackTimer>,
zone_replies: HashMap<AssemblyId, ZoneReply>,
zone_messages: HashMap<AssemblyId, ZoneMessage>,
}
pub(crate) enum BarrierEntry {
Waiting(TurnBarrier), // awaiting one or more assembly tell-backs
Passthrough(PassthroughBarrier), // no zone tell needed; committable immediately
}Drain invariant: while let Some(front) = barrier_queue.front() — examine front entry;
if Waiting with non-empty pending set, stop; if Passthrough or Waiting with empty
pending, pop and commit. Only the front entry may be committed.
This solves the ordering problem from Iteration 3: when two assembly-directed events arrive, then assembly replies arrive in reverse order (e.g. Wiper replies before Headlamp replies to event A), the ledger still records event A before event B.
The routing function zone_message_for_event returns None for all external events during
PreparingToStart / PreparingToStop. No tell is sent. The event is recorded in the ledger
with applied: false and discarded. Events that arrive after the FSM reaches Idle are
routed normally according to their semantic meaning.
One external ingress or twinlet message triggers one quiescent commit: the Brain runs
0+ hops (each → one ledger row), then apply_step once on the final stable cut, then
merged actuation.
tell-back / ingress -> hop -> hop -> ... -> stable -> apply_step -> actuation
|-- one ledger row per hop --|
Detectors (e.g. LightingUnsafe) are pure functions in twin_runtime/detectors/. They
propose FsmEvent::Internal(...) events; the transition table decides. The loop terminates
when no detector fires or MAX_QUIESCENCE_HOPS is reached.
Four processes share Linux SocketCAN (vcan0 by default). Start the actuators before
or alongside the gateway so CMD frames have a listener on the bus.
# One-time setup (per boot)
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan 2>/dev/null || true
sudo ip link set up vcan0
# Terminal 1 — CAN emulator (RPM + ambient lux + rain sensor)
cargo run -p emulator
# Terminal 2 — Headlamp actuator (CMD in → ACK/NACK out)
cargo run -p front_headlamp_actuator
# Terminal 3 — Wiper actuator (CMD in → motor log out; no ACK/NACK)
cargo run -p wiper_actuator
# Terminal 4 — Gateway (Brain + HeadlampActor + WiperActor)
cargo run -p gateway
# Optional: coloured transition ledger only (no diagnostics)
cargo run -p gateway -- --print-transitions-only| Variable | Process | Effect |
|---|---|---|
EMULATOR_TUNNEL_PROB |
emulator | Per-tick probability of entering a low-lux tunnel (headlamp demo) |
EMULATOR_RAIN_PROB |
emulator | Per-tick probability of rain starting when dry (0.0 disables rain) |
FRONT_HEADLAMP_ACTUATOR_DROP_RESPONSE_PROB |
front_headlamp_actuator | Probability of sending no ACK/NACK after a CMD |
WIPER_ACTUATOR_DROP_RESPONSE_PROB |
wiper_actuator | Probability of ignoring a wiper CMD (no motor actuation) |
Example:
EMULATOR_RAIN_PROB=0.05 cargo run -p emulator
WIPER_ACTUATOR_DROP_RESPONSE_PROB=0.3 cargo run -p wiper_actuator- Linux with
vcan0set up (see one-time setup above) - Rust toolchain (MSRV: latest stable)
Detail: docs/project-structure.md
# Run all tests
cargo test -p common
# Run specific contract suites
cargo test -p common -- test::quiescence_actor_contract
cargo test -p common -- test::zone_tell_back_contract
cargo test -p common -- test::turn_barrier_contract
cargo test -p common -- test::headlamp_ack_timer_contract
cargo test -p common -- test::headlamp_lifecycle_contract
cargo test -p common -- test::wiper_zone_contract
cargo test -p common -- test::wiper_actuation_contract
cargo test -p common -- test::wiper_signal_contract
cargo test -p common -- test::wiper_startup_failure_contract
# Wiper CAN codec (vehicle_device_bus)
cargo test -p vehicle_device_bus --test wiper_can_codecContract test reference: docs/contract-tests.md
Detail: docs/design-documents.md
- Not a full vehicle SDV stack — two managed assemblies (
Headlamp,Wiper) with L6 CAN slices; many other vehicle domains are absent. - Not production safety certification — the correctness model is a teaching scaffold.
- Not the offline ledger analyser — the gateway can emit a machine-oriented transition stream; the reader/report tool is designed but unbuilt.
- Not a replacement for the pyramid/ADR docs — see
docs/library-reorg.md.
Known gaps carried forward to Iteration 5: CAN emulation for PowerOn/PowerOff, non-blocking
actuation (child actor), HeadlampActor isolation tests, ActuationIncomplete(Off) coverage.
