A vending machine state machine
idle -> collecting (first coin) -> (more coins) -> dispensing -> idle. A refund from collecting returns to idle. The machine tracks the inserted balance in memory and only dispenses once the price is met.
A Soma program from the state_machines part of the verified corpus: 89 lines, cells VendingMachine, VendingTests. It passes soma check with soma 3.2.4.
How it is built
The cell VendingMachine declares 4 signals in its face: insert(id: String, cents: Int) -> Map, vend(id: String) -> Map, refund(id: String) -> Map and state_of(id: String) -> String; keeps 2 memory slots: balance (Map<String, Int>, persistent and consistent) under the invariant balance >= 0 and price (Map<String, Int>, persistent and consistent). Its state machine machine starts in idle and allows 5 transitions: idle -> collecting, collecting -> collecting, collecting -> dispensing, collecting -> idle and dispensing -> idle. Handlers: insert (line 30), vend (line 40), refund (line 53) and state_of (line 60).
The file also holds 1 test cell (VendingTests) with 11 assertions, run by soma test.
The source
vending_machine.cell
// A vending machine state machine.
// idle -> collecting (first coin) -> (more coins) -> dispensing -> idle.
// A refund from collecting returns to idle. The machine tracks the
// inserted balance in memory and only dispenses once the price is met.
cell VendingMachine {
face {
signal insert(id: String, cents: Int) -> Map
signal vend(id: String) -> Map
signal refund(id: String) -> Map
signal state_of(id: String) -> String
promise "every session returns to idle (vend or refund)"
}
memory {
balance: Map<String, Int> [persistent, consistent]
price: Map<String, Int> [persistent, consistent]
invariant balance >= 0
}
state machine {
initial: idle
idle -> collecting
collecting -> collecting
collecting -> dispensing
collecting -> idle
dispensing -> idle
}
on insert(id: String, cents: Int) {
require cents > 0 else bad_coin
let cur = balance.get(id) ?? 0
balance.set(id, cur + cents)
if get_status(id) == "idle" {
transition(id, "collecting")
}
return map("state", get_status(id), "balance", balance.get(id))
}
on vend(id: String) {
let bal = balance.get(id) ?? 0
let p = price.get("default") ?? 150
if bal < p {
return map("ok", false, "need", p - bal)
}
transition(id, "dispensing")
let change = bal - p
balance.set(id, 0)
transition(id, "idle")
return map("ok", true, "change", change)
}
on refund(id: String) {
let bal = balance.get(id) ?? 0
transition(id, "idle")
balance.set(id, 0)
return map("ok", true, "refunded", bal)
}
on state_of(id: String) { return get_status(id) }
}
cell test VendingTests {
rules {
// insert moves idle -> collecting and accumulates balance
assert insert("m1", 100).state == "collecting"
assert insert("m1", 100).balance == 200
// with 200 inserted against a 150 price, vend succeeds with 50 change
assert vend("m1").change == 50
// and the machine is back at idle
assert state_of("m1") == "idle"
// an under-funded vend is refused (still collecting, no dispense)
assert insert("m2", 100).state == "collecting"
assert vend("m2").ok == false
// refund path returns the inserted coins and resets to idle
assert insert("m3", 75).state == "collecting"
assert refund("m3").refunded == 75
assert state_of("m3") == "idle"
// a zero/negative coin is rejected before any state change
assert_fails insert("m4", 0)
// cannot dispense straight from idle
assert_fails transition("m5", "dispensing")
}
}What the toolchain says
$ soma check vending_machine.cell
note: promise on 'VendingMachine' is documentation (not machine-verifiable): "every session returns to idle (vend or refund)"
--> vending_machine.cell:12:17
|
12 | promise "every session returns to idle (vend or refund)"
| ^
✓ All checks passed (1 note above).$ soma test vending_machine.cell
test VendingTests ...
✓ assert insert("m1", 100).state == "collecting"
✓ assert insert("m1", 100).balance == 200
✓ assert vend("m1").change == 50
✓ assert state_of("m1") == "idle"
✓ assert insert("m2", 100).state == "collecting"
✓ assert vend("m2").ok == false
✓ assert insert("m3", 75).state == "collecting"
✓ assert refund("m3").refunded == 75
✓ assert state_of("m3") == "idle"
✓ assert_fails insert("m4", 0) — raised require failed: bad_coin: constraint violated
✓ assert_fails transition("m5", "dispensing") — raised invalid transition: idle → dispensing. Current state: 'idle'. Valid targets: [collecting]
11 tests: 11 passed, 0 failed$ soma verify vending_machine.cell
Verifying vending_machine.cell...
State machine 'machine': 3 states, initial 'idle'
States: [collecting, dispensing, idle]
✓ 3 states, 5 transitions
✓ all states reachable from 'idle'
✓ reactive machine: no terminal state, and every reachable state can return to 'idle'
✓ no deadlocks
✓ liveness: every reachable state can return to 'idle' (reactive machine)
✓ think-isolated: CTL safety properties hold regardless of LLM output (3 handlers, 4 literal transitions, 0 dynamic)
✓ termination: all 4 handlers structurally terminate
⚠ refinement: declared transition `collecting → collecting` is never reached by any handler — spec may be aspirational or stale
✓ refinement: handler `insert` ⟶ {idle → collecting [if get_status(id) == "idle"]}
✓ refinement: handler `vend` ⟶ {dispensing, dispensing → idle}
✓ refinement: handler `refund` ⟶ {idle}
· transition sources: `vend`, `refund` call transition(id, target) without declaring the source — the runtime refuses a move the graph forbids, but the proof does not see where the handler starts; `transition(id, from, to)` names the edge (checked by `soma check`, refused at run time from any other state)
VendingMachine/invariants:
✓ invariant balance >= 0 — writer 'insert' proven by induction (writes cur + cents)
✓ invariant balance >= 0 — writer 'refund' proven (writes 0)
✓ invariant balance >= 0 — writer 'vend' proven (writes 0)
13 passed, 1 warnings, 0 failures
Temporal properties for 'machine':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK