soma.cell

Gas burner ignition — purge before spark, lockout is forever

NFPA 86 exists because furnaces that spark into a fuel-laden chamber detonate: the prepurge (4+ air changes) is the single step operators skip when restarts are slow, and skipped purges are a recurring CSB explosion class. Here `off -> pilot_spark` simply does not exist: the only road to a spark runs through `purging` and `purge_done`. A failed ignition or flame loss lands in `lockout` — a TERMINAL state soma verify proves has no exit. Re-light means a human walks to the burner; software cannot talk its way out. The retry ledger caps trial-for- ignition attempts at 3 in memory itself.

A Soma program from the safety_interlocks part of the verified corpus: 82 lines, cells BurnerControl, BurnerTests. It passes soma check with soma 3.2.4.

How it is built

The cell BurnerControl declares 5 signals in its face: start_purge(id: String) -> String, purge_complete(id: String) -> String, try_ignition(id: String, flame_proved: Bool) -> Map, flame_failure(id: String) -> String and shut_off(id: String) -> String; keeps 1 memory slot: ignition_tries (Map<String, Int>, persistent and consistent) under the invariant ignition_tries >= 0 && ignition_tries <= 3. Its state machine burner starts in off and allows 8 transitions: off -> purging, purging -> purge_done, purge_done -> pilot_spark, pilot_spark -> burning, pilot_spark -> lockout, pilot_spark -> purge_done, burning -> off and burning -> lockout. lockout is terminal. Handlers: start_purge (line 38), purge_complete (line 39), shut_off (line 40), flame_failure (line 41) and try_ignition (line 43).

The file also holds 1 test cell (BurnerTests) with 10 assertions, run by soma test.

Generated from soma describe on this file.

The source

gas_burner_purge.cell

// GAS BURNER IGNITION — purge before spark, lockout is forever.
// NFPA 86 exists because furnaces that spark into a fuel-laden chamber
// detonate: the prepurge (4+ air changes) is the single step operators
// skip when restarts are slow, and skipped purges are a recurring CSB
// explosion class. Here `off -> pilot_spark` simply does not exist: the
// only road to a spark runs through `purging` and `purge_done`. A failed
// ignition or flame loss lands in `lockout` — a TERMINAL state soma
// verify proves has no exit. Re-light means a human walks to the burner;
// software cannot talk its way out. The retry ledger caps trial-for-
// ignition attempts at 3 in memory itself.

cell BurnerControl {
    face {
        signal start_purge(id: String) -> String
        signal purge_complete(id: String) -> String
        signal try_ignition(id: String, flame_proved: Bool) -> Map
        signal flame_failure(id: String) -> String
        signal shut_off(id: String) -> String
    }

    memory {
        ignition_tries: Map<String, Int> [persistent, consistent]
        invariant ignition_tries >= 0 && ignition_tries <= 3
    }

    state burner {
        initial: off
        off -> purging
        purging -> purge_done
        purge_done -> pilot_spark
        pilot_spark -> burning      // flame proved within trial time
        pilot_spark -> lockout      // no flame: fuel valve slams shut
        pilot_spark -> purge_done   // retry, chamber still proven clear
        burning -> off              // controlled shutdown
        burning -> lockout          // flame failure mid-run
    }

    on start_purge(id: String)    { transition(id, "purging")  return get_status(id) }
    on purge_complete(id: String) { transition(id, "purge_done")  return get_status(id) }
    on shut_off(id: String)       { transition(id, "off")  return get_status(id) }
    on flame_failure(id: String)  { transition(id, "lockout")  return get_status(id) }

    on try_ignition(id: String, flame_proved: Bool) {
        let tries = (ignition_tries.get(id) ?? 0) + 1
        let w = try { ignition_tries.set(id, tries) }
        if w.error != () {
            transition(id, "lockout")   // 4th try is refused by the ledger
            return map("ok", false, "state", get_status(id), "reason", "retry budget spent")
        }
        transition(id, "pilot_spark")
        if flame_proved {
            transition(id, "burning")
            ignition_tries.set(id, 0)
            return map("ok", true, "state", get_status(id), "tries", tries)
        }
        transition(id, "purge_done")
        return map("ok", false, "state", get_status(id), "tries", tries)
    }
}

cell test BurnerTests {
    rules {
        // THE explosion: spark with no purge. The transition is absent.
        assert_fails transition("b1", "pilot_spark")
        assert_fails transition("b1", "burning")

        // the only road: purge, prove it, then spark
        assert start_purge("b1") == "purging"
        assert_fails transition("b1", "pilot_spark")    // purge not done yet
        assert purge_complete("b1") == "purge_done"
        assert try_ignition("b1", true).state == "burning"

        // flame failure mid-run -> lockout, and lockout is TERMINAL
        assert flame_failure("b1") == "lockout"
        assert_fails transition("b1", "off")
        assert_fails transition("b1", "purging")

        // the retry ledger refuses a 4th trial-for-ignition
        assert_fails ignition_tries.set("b2", 4)
    }
}

What the toolchain says

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

$ soma check gas_burner_purge.cell
✓ All checks passed.
$ soma test gas_burner_purge.cell
test BurnerTests ...
  ✓ assert_fails transition("b1", "pilot_spark") — raised invalid transition: off → pilot_spark. Current state: 'off'. Valid targets: [purging]
  ✓ assert_fails transition("b1", "burning") — raised invalid transition: off → burning. Current state: 'off'. Valid targets: [purging]
  ✓ assert start_purge("b1") == "purging"
  ✓ assert_fails transition("b1", "pilot_spark") — raised invalid transition: purging → pilot_spark. Current state: 'purging'. Valid targets: [purge_done]
  ✓ assert purge_complete("b1") == "purge_done"
  ✓ assert try_ignition("b1", true).state == "burning"
  ✓ assert flame_failure("b1") == "lockout"
  ✓ assert_fails transition("b1", "off") — raised invalid transition: lockout → off. Current state: 'lockout'. Valid targets: []
  ✓ assert_fails transition("b1", "purging") — raised invalid transition: lockout → purging. Current state: 'lockout'. Valid targets: []
  ✓ assert_fails ignition_tries.set("b2", 4) — raised memory invariant violated on 'ignition_tries': ignition_tries >= 0 && ignition_tries <= 3 — rejected write of 4 (key "b2"); the slot is unchanged

10 tests: 10 passed, 0 failed
$ soma verify gas_burner_purge.cell
Verifying gas_burner_purge.cell...

State machine 'burner': 6 states, initial 'off'
  States: [burning, lockout, off, pilot_spark, purge_done, purging]

  ✓ 6 states, 8 transitions
  ✓ all states reachable from 'off'
  ✓ terminal states: [lockout]
  ✓ no deadlocks
  ✓ liveness: every state can eventually reach a terminal state
  ✓ think-isolated: CTL safety properties hold regardless of LLM output (5 handlers, 8 literal transitions, 0 dynamic)
  ✓ termination: all 5 handlers structurally terminate
  ✓ refinement: handler `start_purge` ⟶ {purging}
  ✓ refinement: handler `purge_complete` ⟶ {purge_done}
  ✓ refinement: handler `shut_off` ⟶ {off}
  ✓ refinement: handler `flame_failure` ⟶ {lockout}
  ✓ refinement: handler `try_ignition` ⟶ {lockout [if w.error != ()], pilot_spark, pilot_spark → burning [if flame_proved], pilot_spark → purge_done}
  · transition sources: `start_purge`, `purge_complete`, `shut_off`, `flame_failure`, `try_ignition` 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)

BurnerControl/invariants:

  ✓ invariant ignition_tries >= 0 — writer 'try_ignition' proven by induction (writes tries)
  ✓ invariant ignition_tries >= 0 && ignition_tries <= 3 — writer 'try_ignition' proven (writes 0)
  ⚠ invariant ignition_tries >= 0 && ignition_tries <= 3 — runtime-checked (computed values): try_ignition → ignition_tries [ignition_tries <= 3] because `tries` is only known to lie in [1, 4] — narrow it: `require tries <= 3 else …`

14 passed, 1 warnings, 0 failures

  Temporal properties for 'burner':
  ✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK

Builtins used

get_status, map, transition

Source file: /corpus/safety_interlocks/gas_burner_purge.cell · examples/corpus/safety_interlocks/gas_burner_purge.cell