An HVAC thermostat control loop
A single machine cycles between idle, heating, and cooling based on the measured temperature versus a target band. This is a reactive (cyclic) system with no terminal state — the proof obligation is deadlock-freedom: the controller always has a legal next action.
A Soma program from the state_machines part of the verified corpus: 75 lines, cells Thermostat, ThermostatTests. It passes soma check with soma 3.2.4.
How it is built
The cell Thermostat declares 3 signals in its face: set_target(lo: Int, hi: Int) -> Map, reading(id: String, temp: Int) -> Map and mode(id: String) -> String; keeps 1 memory slot: band (Map<String, Int>, persistent and consistent). Its state machine hvac starts in idle and allows 4 transitions: idle -> heating, idle -> cooling, heating -> idle and cooling -> idle. Handlers: set_target (line 26), reading (line 33) and mode (line 54).
The file also holds 1 test cell (ThermostatTests) with 6 assertions, run by soma test.
The source
thermostat.cell
// An HVAC thermostat control loop.
// A single machine cycles between idle, heating, and cooling based on the
// measured temperature versus a target band. This is a reactive (cyclic)
// system with no terminal state — the proof obligation is deadlock-freedom:
// the controller always has a legal next action.
cell Thermostat {
face {
signal set_target(lo: Int, hi: Int) -> Map
signal reading(id: String, temp: Int) -> Map
signal mode(id: String) -> String
}
memory {
band: Map<String, Int> [persistent, consistent]
}
state hvac {
initial: idle
idle -> heating
idle -> cooling
heating -> idle
cooling -> idle
}
on set_target(lo: Int, hi: Int) {
require lo < hi else bad_band
band.set("lo", lo)
band.set("hi", hi)
return map("lo", lo, "hi", hi)
}
on reading(id: String, temp: Int) {
let lo = band.get("lo") ?? 18
let hi = band.get("hi") ?? 24
let cur = get_status(id)
// decide the target mode for this temperature
let want = if temp < lo { "heating" } else if temp > hi { "cooling" } else { "idle" }
// return to idle first if we're actively conditioning and the
// desired mode differs (the machine only allows idle<->active)
if cur != "idle" && cur != want {
transition(id, "idle")
cur = "idle"
}
if cur == "idle" && want != "idle" {
transition(id, want)
}
if cur != "idle" && want == "idle" {
transition(id, "idle")
}
return map("temp", temp, "mode", get_status(id))
}
on mode(id: String) { return get_status(id) }
}
cell test ThermostatTests {
rules {
// configure a comfortable band
assert set_target(18, 24).lo == 18
// cold reading -> heating
assert reading("h1", 15).mode == "heating"
// warming into the band -> idle
assert reading("h1", 21).mode == "idle"
// hot reading -> cooling
assert reading("h1", 28).mode == "cooling"
// back into band -> idle
assert reading("h1", 22).mode == "idle"
// an inverted band is rejected
assert_fails set_target(30, 10)
}
}What the toolchain says
$ soma check thermostat.cell
✓ All checks passed.$ soma test thermostat.cell
test ThermostatTests ...
✓ assert set_target(18, 24).lo == 18
✓ assert reading("h1", 15).mode == "heating"
✓ assert reading("h1", 21).mode == "idle"
✓ assert reading("h1", 28).mode == "cooling"
✓ assert reading("h1", 22).mode == "idle"
✓ assert_fails set_target(30, 10) — raised require failed: bad_band: constraint violated
6 tests: 6 passed, 0 failed$ soma verify thermostat.cell
Verifying thermostat.cell...
State machine 'hvac': 3 states, initial 'idle'
States: [cooling, heating, idle]
✓ 3 states, 4 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)
⚠ dynamic transition targets: handler `reading` uses a dynamic (computed) transition target — the state these handlers move to is computed at run time (from input or an LLM answer), so the machine's safety is checked at run time for them, not proven
✓ termination: all 3 handlers structurally terminate
⚠ refinement (partial — dynamic targets fall back to runtime check): handler `reading` calls transition() with a computed target — which edge it takes is checked at run time (invalid_transition), not proven; write the target as a literal state to have it verified
✓ refinement (partial — dynamic targets fall back to runtime check): handler `reading` ⟶ {idle [if cur != "idle" && cur != want], idle [if cur != "idle" && want == "idle"]} + <dynamic>
· transition sources: `reading` calls 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)
7 passed, 2 warnings, 0 failures
Temporal properties for 'hvac':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK