Traffic light
A traffic light with a pedestrian crossing, run through the core interpreter by a host of about forty lines. It shows what it means for step to be pure: the chart asks for timers, and the host keeps them.
Files: examples/traffic-light/traffic-light.scxml, examples/traffic-light/main.rs, examples/traffic-light/expected-output.txt, examples/traffic-light/vt/. All commands run from the repository root.
The chart
operating is a parallel state. Its light region cycles red, green and yellow on delayed <send>s named light-timer, and cancels the pending one on exit. Its crossing region follows the light with eventless In('red') transitions. A pedestrian button cuts green short, and fault leaves the whole parallel state for flashing.
<?xml version="1.0" encoding="UTF-8"?>
<!--
A traffic light with a pedestrian crossing, on the `null` datamodel.
`operating` is a parallel state with two regions that run side by side:
- `light` cycles red -> green -> yellow -> red. Each colour schedules its own end with a
delayed <send> of `tick`, and cancels it on exit, so leaving a colour early (a pedestrian
pressing the button) never lets a stale timer fire later.
- `crossing` shows walk / don't walk. It has no events of its own: eventless transitions
follow the light with the `In()` predicate, the one condition the null datamodel supports.
`fault` leaves the whole parallel state for `flashing`; `reset` brings the light back to red.
Illustrates the scxml-interpretation capability (parallel states, eventless transitions,
delayed send and cancel) and scxml-format (reading an SCXML 1.0 document).
-->
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" datamodel="null"
name="traffic-light" initial="operating">
<parallel id="operating">
<transition event="fault" target="flashing"/>
<state id="light" initial="red">
<state id="red">
<onentry><send id="light-timer" event="tick" delay="30s"/></onentry>
<onexit><cancel sendid="light-timer"/></onexit>
<transition event="tick" target="green"/>
</state>
<state id="green">
<onentry><send id="light-timer" event="tick" delay="25s"/></onentry>
<onexit><cancel sendid="light-timer"/></onexit>
<transition event="tick" target="yellow"/>
<!-- a pedestrian cuts the green phase short -->
<transition event="button" target="yellow"/>
</state>
<state id="yellow">
<onentry><send id="light-timer" event="tick" delay="5s"/></onentry>
<onexit><cancel sendid="light-timer"/></onexit>
<transition event="tick" target="red"/>
</state>
</state>
<state id="crossing" initial="dont_walk">
<state id="dont_walk">
<transition cond="In('red')" target="walk"/>
</state>
<state id="walk">
<transition cond="In('green')" target="dont_walk"/>
</state>
</state>
</parallel>
<state id="flashing">
<transition event="reset" target="operating"/>
</state>
</scxml>
The host
session::start and session::step return the next session and a list of effects such as “deliver tick in 30 s” or “cancel light-timer”. They never sleep or read a clock. The program keeps the timers on a virtual clock and feeds them back as events, so the run is the same every time.
//! Traffic light: load an SCXML chart and drive it through the pure core interpreter.
//!
//! The core never reads a clock or sleeps. `session::start` and `session::step` take the
//! session as a value plus one input and return the next session and a list of *effects*:
//! "deliver `tick` in 30 s", "cancel `light-timer`". This program is the smallest possible host
//! for those effects: a virtual clock, a list of pending timers and a script of external events.
//! It prints the configuration (the active states) after every macrostep.
//!
//! Illustrates the `scxml-interpretation` capability (Pure Step Function, Macrostep Execution,
//! Send And Cancel Effects) on a chart read by `scxml-format`.
//!
//! Run with `cargo run -p vertep-examples --bin traffic-light`.
use vertep::core::compile::CompiledChart;
use vertep::core::datamodel::{Environment, Event, EventType, SessionInfo};
use vertep::core::null::NullDatamodel;
use vertep::core::session::{self, Effect, Input, Outcome, StartInput};
/// The chart, next to this file.
const CHART: &str = include_str!("traffic-light.scxml");
/// External events, by the virtual time (in seconds) at which they arrive.
const SCRIPT: &[(u64, &str)] = &[(40, "button"), (50, "fault"), (60, "reset")];
/// The virtual time at which the program stops.
const END_S: u64 = 120;
/// A delayed `<send>` the host must deliver later.
struct Timer {
due_ms: u64,
sendid: String,
event: Event,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Read the SCXML document into the chart IR, then validate and compile it.
let parsed = vertep::scxml::read_str(CHART)?;
let chart = CompiledChart::new(parsed.chart).map_err(|v| format!("invalid chart: {v:?}"))?;
// 2. Start a session. The chart names the `null` datamodel (no data, only `In()` guards).
let mut dm = NullDatamodel::new();
let mut timers: Vec<Timer> = Vec::new();
let mut now_ms = 0;
let start = StartInput {
info: SessionInfo {
sessionid: "light-1".into(),
..SessionInfo::default()
},
..StartInput::default()
};
let outcome = session::start(&chart, start, &mut dm).map_err(|e| format!("{e:?}"))?;
let mut session = report(now_ms, "(start)", outcome, &mut timers);
// 3. The event loop: deliver whichever comes first, a due timer or a scripted event.
let mut script = SCRIPT.iter().peekable();
loop {
let next_timer = timers.iter().map(|t| t.due_ms).min();
let next_script = script.peek().map(|(at, _)| at * 1000);
let timer_first = match (next_timer, next_script) {
(Some(t), Some(s)) => t <= s,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => break,
};
let at = if timer_first { next_timer } else { next_script }.expect("a next input");
if at > END_S * 1000 {
break;
}
let event = if timer_first {
let i = timers
.iter()
.position(|t| t.due_ms == at)
.expect("a due timer");
timers.remove(i).event
} else {
let (_, name) = script.next().expect("a scripted event");
Event::named(*name, EventType::External)
};
now_ms = at;
let name = event.name.clone();
// The time travels inside the input: the core sees it, but never asks for it.
let env = Environment {
now_ms: now_ms as i64,
seed: 0,
};
let outcome = session::step(&chart, session, Input::event(event, env), &mut dm)
.map_err(|e| format!("{e:?}"))?;
session = report(now_ms, &name, outcome, &mut timers);
}
for t in &timers {
println!(
"stopped at {END_S}s; still pending: {} due at {}s",
t.event.name,
t.due_ms / 1000
);
}
Ok(())
}
/// Prints one macrostep and applies its effects to the timer list; returns the new session.
fn report(now_ms: u64, input: &str, outcome: Outcome, timers: &mut Vec<Timer>) -> session::Session {
println!(
"[{:>4}s] {:<8} -> {}",
now_ms / 1000,
input,
outcome.trace.configuration.join(" ")
);
for effect in outcome.effects {
match effect {
// No target: the event goes back to this session's external queue after the delay.
Effect::Send(send) if send.target.is_none() => {
println!(
" schedule {} in {}s",
send.event.name,
send.delay_ms / 1000
);
timers.push(Timer {
due_ms: now_ms + send.delay_ms,
sendid: send.sendid,
event: send.event,
});
}
Effect::Cancel { sendid } => {
let before = timers.len();
timers.retain(|t| t.sendid != sendid);
if timers.len() < before {
println!(" cancel {sendid}");
}
}
other => println!(" (ignored effect {other:?})"),
}
}
outcome.session
}
Run it
cargo run -p vertep-examples --bin traffic-light
[ 0s] (start) -> operating light red crossing walk
schedule tick in 30s
[ 30s] tick -> operating light green crossing dont_walk
schedule tick in 25s
[ 40s] button -> operating light yellow crossing dont_walk
cancel light-timer
schedule tick in 5s
[ 45s] tick -> operating light red crossing walk
schedule tick in 30s
[ 50s] fault -> flashing
cancel light-timer
[ 60s] reset -> operating light red crossing walk
schedule tick in 30s
[ 90s] tick -> operating light green crossing dont_walk
schedule tick in 25s
[ 115s] tick -> operating light yellow crossing dont_walk
schedule tick in 5s
[ 120s] tick -> operating light red crossing walk
schedule tick in 30s
stopped at 120s; still pending: tick due at 150s
The same chart through vt
Every surface of a chart has the same content-addressed version. The YAML and Turtle that vt convert writes hash to the same IRI as the SCXML, and a test checks it.
vt validate examples/traffic-light/traffic-light.scxml
vt hash examples/traffic-light/traffic-light.scxml
examples/traffic-light/traffic-light.scxml: ok: urn:vertep:chart:sha256:46431c540d155bee9016c61ee7a8f734890a92342ed9f91188aea25a602f5731
urn:vertep:chart:sha256:46431c540d155bee9016c61ee7a8f734890a92342ed9f91188aea25a602f5731
vt convert --to yaml examples/traffic-light/traffic-light.scxml
vertep: 1
name: traffic-light
datamodel: "null"
initial: operating
states:
operating:
type: parallel
on:
fault: flashing
states:
light:
initial: red
states:
red:
entry:
- send:
event: tick
id: light-timer
delay: "30s"
exit:
- cancel:
sendid: light-timer
on:
tick: green
green:
entry:
- send:
event: tick
id: light-timer
delay: "25s"
vt convert --to turtle examples/traffic-light/traffic-light.scxml
@prefix c: <urn:vertep:chart:sha256:46431c540d155bee9016c61ee7a8f734890a92342ed9f91188aea25a602f5731#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
@prefix vt: <https://w3id.org/vertep/vt#> .
c:chart:ia0 a vt:TargetRef ;
vt:order 0 ;
vt:scope c:chart ;
vt:state c:s.operating .
c:chart:ia:sel:chart vt:choice c:s.operating .
c:chart:ia:sel:s.operating vt:choice c:chart:ia0 .
c:chart a vt:Chart ;
vt:binding "early" ;
vt:bindingExplicit false ;
vt:child c:s.flashing , c:s.operating ;
vt:datamodel "null" ;
vt:datamodelExplicit true ;
vt:initial c:chart:ia0 ;
vt:name "traffic-light" .
c:s.crossing:ia0 a vt:TargetRef ;
vt:order 0 ;
vt:scope c:s.crossing ;
vt:state c:s.dont_walk .
c:s.crossing:ia:sel:chart vt:choice c:s.operating .
- Delays accept
sandms. - Every state, transition and target is a node minted under the version IRI, with no blank nodes.