Vertep

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 .