soma.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.

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.

Generated from soma describe on this file.

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

Run on a fresh copy of the program at build time; the outputs are pasted unedited.

$ 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

Builtins used

get_status, map, transition

Source file: /corpus/state_machines/vending_machine.cell · examples/corpus/state_machines/vending_machine.cell