↩️ This repo is Iteration 3 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 | README | Pyramid / library layout |
|---|---|---|---|
| 1 | sdv_simulation_1 |
Overview | — |
| 2 | sdv_simulation_2 |
Overview | — |
| 3 | sdv_simulation_3 (this repo) |
You are here | library-reorg.md |
This README is the operational truth for running and reading the code. Blog posts and blog-inputs/ are supplementary narrative — they may lag or simplify; when in doubt, trust this file and the linked docs/.
Terms used throughout this README. Zone is SDV industry language; BrainTwin, twinlet, tell, cut, etc. are ours unless noted.
| Term | Meaning |
|---|---|
| Zone | SDV: a vehicle concern (lighting, powertrain, …). Code: zone assembly — {Zone}Context in VehicleContext. |
| Twinlet | One zone as a child actor under the BrainTwin (mailbox + timers). |
| BrainTwin | The digital twin overall — parent actor (VirtualCarActor), quiescent commit, ledger, actuation. State: DigitalTwinCar. We say Brain in diagrams. |
HeadlampActor |
The only twinlet in this iteration (headlamp zone). |
| Tell | BrainTwin → twinlet; fire-and-forget (mailbox free until tell-back or timeout). |
| Tell-back | Twinlet → BrainTwin reply (turn_id, tell_attempt; e.g. HeadlampZoneReady). |
| 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. |
| Pyramid | Layered modules in common (L0–L6, physics up to gateway/actuator binaries); acyclic imports. Detail: library-reorg.md. |
BrainTwin (VirtualCarActor)
├── HeadlampActor ← only twinlet today
└── other zones in-process ← powertrain, health, visibility
A Rust workspace that prototypes a software-defined vehicle control path: telemetry and actuation on a shared CAN bus, a gateway that projects wire traffic into twin vocabulary, and a digital twin that maintains vehicle state, decides when to actuate, and closes the loop when the body ECU acknowledges (or fails).
Educational / demonstrator — not a product stack.
Iteration 1 made the control loop work. Iteration 2 made it decomposable, observable, and provable — zone assemblies, a serializable transition ledger, diagnostics, and correct-by-construction twin APIs — while keeping the same three-process CAN demo on the surface. Iteration 3 (this repo) actorifies the first zone twinlet without rewriting the domain core (see Vocabulary).
What Iteration 3 is not (yet):
- Not a full vehicle SDV stack — only
HeadlampActoris a twinlet; other zones stay in-process in the BrainTwin. - 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 (see Demo screenshots).
- Not a replacement for the pyramid/ADR docs — see Pyramid in Vocabulary and
docs/library-reorg.mdfor layer numbers (L0–L6).
| Area | Iteration 1 | Iteration 2 | Iteration 3 (this repo) |
|---|---|---|---|
| Twin context | Flat VehicleContext |
Per-zone assembly contexts (powertrain, health, visibility, headlamp) |
Same aggregate; headlamp zone also runs in a child actor |
FSM step |
Monolithic inline logic | Thin orchestrator; behaviour on assemblies | Unchanged pure core; BrainTwin commit adds quiescence (multi-hop ledger) |
| Concurrency | Single VirtualCarActor |
Same (prep for zones) | BrainTwin + one twinlet (HeadlampActor) |
| BrainTwin ↔ zone I/O | In-process L1 calls | Same on demo path | Enumerated mailboxes (tell / tell-back / spontaneous) on production path |
| Transition ledger | State delta, in-process time | PublishedTransitionRecord, record_seq, as_of_seq |
One row per quiescence hop; optional ledger-only stdout (--print-transitions-only) |
| Invariants | Inline checks | STATE_LAWS oracle (offline) |
Same; detectors feed internal FSM events at commit |
| Twin mutation | Public fields | apply_step only |
Same |
| Diagnostics | NACK/timeout only | Silent-success ACK surfaced; off-hot-path logging | Sink injection at gateway init (default vs ledger-only) |
| Module layout | Ad hoc | Layering started | Pyramid L0–L6 complete; TangleGuard clean (pyramid-m2-complete) |
Future on the actor track: a second zone twinlet (use the Headlamp twinlet as a template), ADR-6 power barrier,
actuation child actor, offline ledger verifier, optional sdv_core crate split.
First twinlet — headlamp zone as a ractor child of the BrainTwin; use it as a template for the next zone. Handoff: docs/milestone-actor-headlamp-scope.md.
Parent actor in the gateway: ingress, tell / wait for tell-back, commit_resolved_turn → run_to_quiescence, ledger/diagnostic sinks, actuation dispatch. Pure fsm::step still owns operational mode transitions.
- Pyramid — see Vocabulary; gateway uses
common::facadeonly (scripts/check-gateway-facade-imports.sh). - Production path: enumerated mailboxes to
HeadlampActor; ACK wait via twinletsend_after, not gatewayTimerTick. - Quiescence / cut / spontaneous — see Vocabulary; ADR:
docs/adr-007-fsm-quiescence-and-cut.md.
Three processes share Linux SocketCAN (vcan0 by default):
| Process | Role |
|---|---|
| emulator | Publishes engine RPM and ambient lux on CAN (~10 Hz). Does not send headlamp CMD. |
| gateway | CAN ingress/egress, projection, BrainTwin + HeadlampActor, actuation CMD TX. |
| front_headlamp_actuator | Body ECU stand-in: CMD → ACK/NACK (~150 ms). |
Outcomes appear on stdout by default (diagnostics) — a stand-in for dashboard / cloud.
Optional ledger-only stdout (see Demo screenshots). VSS-inspired signals; wire layout in
vehicle_device_bus.
Still frames from a live three-process run on vcan0. Emulator and actuator behaviour match
Iteration 2 — same RPM/lux publishing and CMD/ACK loop.
What is new in Iteration 3 is mainly inside the gateway digital twin (BrainTwin + headlamp twinlet, quiescent commit) and an optional ledger-only console mode:
cargo run -p gateway -- --print-transitions-only # coloured transition rows; no diagnostic sinkDaylight (lux ≥ 860): headlamp stays off; no CMD/ACK traffic (same as before).
Tunnel (lux ≤ 840): twin requests ON → CMD on CAN → actuator ACK/NACK/timeout (same as before).
Planned tooling: the ledger stream is shaped for an offline reader — a tool (or small
suite) that ingests PublishedTransitionRecord rows and reports, summarises, or replays what
happened between two time instants (or two record_seq values). Not implemented in this repo
yet; default gateway mode remains human diagnostics on stdout.
Telemetry and commands meet on one virtual CAN interface; the gateway is the only component that speaks both wire and twin.
Diagram (planned): internal layout of the BrainTwin and twinlets inside the digital twin capsule — to be added alongside this overview.
Ingress: CAN → PhysicalCarVocabulary → projector → DigitalTwinCarVocabulary::Fsm →
BrainTwin → tell HeadlampActor → tell-back → commit_resolved_turn.
Egress: DomainAction → DefaultActuationManager → ActuationCommand → CAN CMD → actuator
→ ACK/NACK → ingress again as confirmed / rejected / incomplete facts.
The runtime is message-driven, not one nested call stack (see Tell in Vocabulary). The
BrainTwin handle() returns while pending_turn waits for tell-back. CAN and the actuator add
separate wall-clock paths. Solid arrows = one mailbox delivery; dashed = later delivery.
Example: low lux while driving — two main storylines (happy ACK vs timeout) share the same Turn A lux commit; Turn B is a distinct ingress or spontaneous message.
sequenceDiagram
participant CAN as vcan0
participant GW as Gateway
participant Brain as BrainTwin
participant HL as Headlamp twinlet
participant Act as Actuator
participant Sink as Sinks
rect rgb(235, 245, 255)
Note over CAN,Sink: Turn A — lux ingress (three mailbox passes)
CAN->>GW: AmbientLux frame
GW->>Brain: ① Fsm(UpdateAmbientLux)
Brain->>HL: ② tell Apply (fire-and-forget)
Note over Brain: handle() returns · pending_turn · mailbox free
HL->>HL: on_receiving_message (twinlet handle)
HL-->>Brain: ③ HeadlampZoneReady (tell-back)
Note over Brain: new handle · commit_resolved_turn
Brain->>Brain: run_to_quiescence · apply_step
Brain->>Sink: try_emit ledger hop(s)
Brain->>GW: actuation cmd (async channel)
GW->>CAN: CMD ON
end
rect rgb(245, 255, 235)
Note over CAN,Brain: Turn B (happy) — later CAN round-trip (~150ms+)
CAN->>Act: CMD ON
Act-->>CAN: ACK
CAN->>GW: ACK frame
GW-->>Brain: ④ FrontHeadlampCommandConfirmed
Note over Brain: separate quiescent commit (lamp On)
end
rect rgb(255, 240, 240)
Note over HL,Brain: Turn B (timeout) — alternative to ACK
HL-->>Brain: ④ HeadlampZoneSpontaneous (ACK timer)
Note over Brain: new handle · quiescence (e.g. DrivingDangerously)
Brain->>Sink: further ledger hop(s)
end
Sink wiring is chosen once at gateway startup (default: diagnostics → stdout; ledger-only: transition sink → stdout, no diagnostic sink). See How to run below.
VehicleContext is an aggregate of zone assemblies (SDV zones) — each owns its data and
local rules. Iteration 2 introduced this layout; Iteration 3 makes the headlamp zone the first
twinlet (HeadlampActor).
VehicleContext
├── powertrain : PowertrainContext // WheelRpm, derived speed, mode
├── health : VehicleHealthContext // fuel / oil / tyre
├── visibility : VisibilityContext // ambient lux (from CAN telemetry)
└── headlamp : HeadlampContext // HeadlampState, ACK-wait bookkeeping
L1: {Zone}Context::on_receiving_message → {Zone}ZoneReply. L2 fsm::step runs after
L4 zone_turn merges zone outcomes into the BrainTwin commit.
Powertrain, health, and visibility zones are still in-process inside the BrainTwin; only headlamp is a twinlet on the actor path.
Embed (phase A): after tell-back, the BrainTwin copies
HeadlampZoneReply.ctxintoVehicleContext.headlamp— it does not call L1 in parallel with the child. Toward phase C the embed may shrink to a handle; tests (ledger /GetStatus) surface gaps.
One external ingress or twinlet message triggers one quiescent commit: the BrainTwin runs
run_to_quiescence (0+ hops, each → one ledger row), then apply_step once on the
final cut, then merged actuation.
tell-back / ingress → hop → hop → … → stable → apply_step → actuation
└─ one ledger row per hop ─┘
Example — driving in a tunnel, CMD sent, ACK never arrives (one commit, two hops):
HeadlampZoneSpontaneous ACK timer in Headlamp twinlet
│
▼
Hop 1 event: FrontHeadlampActuationIncomplete
cut: Driving · lamp still Off · lux low
│
▼ detector reads exit cut → Internal(LightingUnsafe)
Hop 2 event: Internal(LightingUnsafe)
cut: DrivingDangerously
│
▼ quiescence stable
apply_step + ledger already emitted → StartBuzzer (actuation)
Lux crossing the ON threshold (earlier CAN event) moves the lamp to OnRequested and emits
CMD; On only after a later ACK ingress — not in the same action phase as the request.
Further reading: docs/adr-007-fsm-quiescence-and-cut.md,
twin_turn.rs,
virtual_car_actor.rs.
transition_tx — fact ledger |
diagnostic_tx — presentation |
|
|---|---|---|
| Delivery | bounded, lossless-or-error | unbounded, best-effort |
| Ordering | total by record_seq |
none guaranteed |
| Cadence | one PublishedTransitionRecord per quiescence hop |
many (init, ticks, meta) |
| Audience | replay, invariant checks | humans / stdout |
Twin emits via TransitionRecordSink and DiagnosticSink traits (not raw channels).
Records carry intended DomainActions; outcomes (ACK, timeout) are separate ingress facts.
GetStatus returns CarSnapshot { as_of_seq } — snapshots are as-of a ledger sequence.
Three separate roles (which pyramid layer owns each — see
docs/library-reorg.md):
- Enforce — illegal operational cuts rejected/clamped in the FSM transition (e.g. speed
frozen while
Off). - Announce — would-be / clamped violations on the diagnostic sink (
LogWarningpath). - Detect — post-hoc
verify_state_laws/STATE_LAWScatalog; oracle for tests, CI, and future offline replay — never a synchronous hot-path gate.
DigitalTwinCar is correct-by-construction: private fields, checked new, single
apply_step mutator after quiescent commit.
Scope honesty: demonstrates that the FSM design avoids reaching dangerous operational cuts in the demo scenarios; not production automotive safety certification.
Operational FSM (FsmState):
| State | Meaning |
|---|---|
Off |
Ignition off; speed frozen 0; lighting cleared. |
Idle |
Powered, RPM ≤ 1000. |
Driving |
RPM > 1000, moving. |
ExtremeOperationWarning |
Speed/RPM stress band; 5 s cooldown to exit. |
DrivingDangerously |
Driving in dark without confirmed headlamp ON (latched); buzzer until recovery. |
Off ──PowerOn──► Idle ◄──stationary── Driving ◄────────────────────────┐
▲ │ │
│ ├── stress ──► ExtremeOperationWarning
│ │ │
│ └── dark + failed lamp ──► DrivingDangerously
│ (Internal LightingUnsafe) │
└──────────────── recovery (lamp ON, bright lux, idle) ─┘
Headlamp (HeadlampState): orthogonal sub-state — Off → OnRequested → On →
OffRequested → … — not extra top-level FSM modes.
| Boundary | Mechanism |
|---|---|
| Lux → headlamp | Value deadband: ON at lux ≤ 840, OFF at lux ≥ 860, hold between |
| Speed → warning | Temporal latch: enter over 160 km/h; exit after ≥ 5 s and hazard cleared (TimerTick) |
| Headlamp ACK | Actor-owned timer in HeadlampActor (not gateway tick on actor path) |
| Crate | Role |
|---|---|
common |
Pyramid L0–L5; BrainTwin + headlamp twinlet; FSM; facade |
vehicle_device_bus |
Headlamp CAN codec; wire protocol reference (IDs, payload kinds) |
emulator |
RPM/lux world model |
gateway |
CAN loop, twin install, timer tick, observability wiring |
front_headlamp_actuator |
CMD/ACK/NACK loop |
| What | Path |
|---|---|
| FSM table | fsm/transition_map.rs |
| Headlamp L1 | vehicle_state/front_headlamp.rs |
| Headlamp twinlet | twin_runtime/headlamp_actor.rs |
| BrainTwin | twin_runtime/controller/virtual_car_actor.rs |
| Quiescence | twin_runtime/twin_turn.rs |
| State laws | digital_twin/car_behaviour_checker.rs |
| CAN / headlamp wire | vehicle_device_bus (e.g. devices/front_headlamp/can.rs) |
cargo test -p common
cargo test -p gateway --lib
cargo test -p vehicle_device_bus
cargo test --workspace
cargo test -p common --features proptest # optionalWith vcan0 up: cargo test -p gateway --test front_headlamp_e2e.
Contract tests: crates/common/src/test/ (quiescence, zone replies, ACK timer, headlamp reply,
operational policy, …).
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan 2>/dev/null || true
sudo ip link set up vcan0Three terminals: cargo run -p emulator, cargo run -p front_headlamp_actuator, cargo run -p gateway.
Gateway flags:
cargo run -p gateway # diagnostics on stdout (default)
cargo run -p gateway -- --print-transitions-only # coloured ledger on stdout; no diagnostic sink
cargo run -p gateway -- --print-timer-tick # disabled in ledger-only mode
cargo run -p gateway -- --trace-actuation-ingress # disabled in ledger-only modeEach flag selects default (diagnostics on stdout) or ledger-only (coloured transition
rows; no diagnostic sink on the twin). There is no mixed mode. Sink wiring, ledger vs
diagnostics, and gateway launch options:
docs/design-notes-runtime-observation.md.
Actuator: FRONT_HEADLAMP_ACTUATOR_DROP_RESPONSE_PROB, FRONT_HEADLAMP_ACTUATOR_ACK_NACK_RESPONSE_PROB.
Emulator: EMULATOR_TUNNEL_PROB.
| Document | Use when |
|---|---|
docs/library-reorg.md |
Pyramid layers (L0–L6), TangleGuard, module map |
docs/design-notes-pyramid-layers.md |
Historical layering Q&A |
docs/design-notes-runtime-observation.md |
Ledger, cut, observation design |
docs/adr-005-assembly-alphabet.md |
Zone alphabets |
docs/adr-006-twin-brain-ingress-coordination.md |
Target brain / power barrier |
docs/adr-007-fsm-quiescence-and-cut.md |
Quiescence, cuts, internal events |
docs/milestone-actor-headlamp-scope.md |
Completed headlamp milestone handoff |
Main TODOs for the next simulation round:
- Second zone twinlet — use the Headlamp twinlet as a template (tell / tell-back / spontaneous).
- ADR-6 power barrier — brain owns ingress;
TwinIngresscoordination across zones. - Offline ledger tool — read transition rows; report or summarise what happened between two times or
record_seqvalues. - Actuation child actor — offload CAN egress (and future connectors) from the BrainTwin hot path.
Update this README when behaviour or layout changes. Blog narrative follows milestones; it does not lead them.


