Railway block signaling — two trains never share a block
Ladbroke Grove (1999, 31 dead) and a century of telescoped carriages before it happen when a second train enters an occupied block. Here the collision state is unrepresentable: a block is a state machine with exactly `clear` and `occupied`, and `occupied -> occupied` does not exist — the second grant RAISES instead of being a flag you forgot to check. A train-count ledger is the independent second wall: the write of a 2 is refused by the memory itself.
A Soma program from the safety_interlocks part of the verified corpus: 80 lines, cells BlockSignal, BlockTests. It passes soma check with soma 3.2.4.
How it is built
The cell BlockSignal declares 3 signals in its face: request_entry(block: String, train: String) -> Map, exit_block(block: String, train: String) -> Map and occupant_of(block: String) -> String; keeps 2 memory slots: train_count (Map<String, Int>, persistent and consistent) under the invariant train_count >= 0 && train_count <= 1 and holder (Map<String, String>, persistent and consistent). Its state machine block starts in clear and allows 2 transitions: clear -> occupied and occupied -> clear. Handlers: request_entry (line 31), exit_block (line 43) and occupant_of (line 53).
The file also holds 1 test cell (BlockTests) with 9 assertions, run by soma test.
The source
railway_block.cell
// RAILWAY BLOCK SIGNALING — two trains never share a block.
// Ladbroke Grove (1999, 31 dead) and a century of telescoped carriages
// before it happen when a second train enters an occupied block. Here the
// collision state is unrepresentable: a block is a state machine with
// exactly `clear` and `occupied`, and `occupied -> occupied` does not
// exist — the second grant RAISES instead of being a flag you forgot to
// check. A train-count ledger is the independent second wall: the write
// of a 2 is refused by the memory itself.
cell BlockSignal {
face {
signal request_entry(block: String, train: String) -> Map
signal exit_block(block: String, train: String) -> Map
signal occupant_of(block: String) -> String
}
memory {
// trains physically inside each block — never 0..1 violated
train_count: Map<String, Int> [persistent, consistent]
invariant train_count >= 0 && train_count <= 1
holder: Map<String, String> [persistent, consistent]
}
state block {
initial: clear
clear -> occupied
occupied -> clear
}
on request_entry(block: String, train: String) {
// the interlock IS the transition: a held block raises here
let r = try { transition(block, "occupied") }
if r.error != () {
let who = occupant_of(block)
return map("granted", false, "reason", "block {block} held by {who}")
}
train_count.set(block, (train_count.get(block) ?? 0) + 1)
holder.set(block, train)
return map("granted", true, "block", block, "train", train)
}
on exit_block(block: String, train: String) {
if occupant_of(block) != train {
return map("ok", false, "reason", "{train} does not hold {block}")
}
transition(block, "clear")
train_count.set(block, train_count.get(block) - 1)
holder.delete(block)
return map("ok", true, "block", block, "state", get_status(block))
}
on occupant_of(block: String) {
return holder.get(block) ?? "nobody"
}
}
cell test BlockTests {
rules {
// first train is granted the block
assert request_entry("b7", "IC-100").granted == true
assert occupant_of("b7") == "IC-100"
// THE collision: a second train into the same block. The signal
// logic refuses it, and the raw transition itself does not exist.
assert request_entry("b7", "EXP-9").granted == false
assert_fails transition("b7", "occupied")
// the ledger is the second wall: writing 2 trains is refused
// even if every signaling check above were deleted
assert_fails train_count.set("b7", 2)
assert_fails train_count.set("b7", -1)
// only the holder can release; then the block cycles normally
assert exit_block("b7", "EXP-9").ok == false
assert exit_block("b7", "IC-100").ok == true
assert request_entry("b7", "EXP-9").granted == true
}
}What the toolchain says
$ soma check railway_block.cell
✓ All checks passed.$ soma test railway_block.cell
test BlockTests ...
✓ assert request_entry("b7", "IC-100").granted == true
✓ assert occupant_of("b7") == "IC-100"
✓ assert request_entry("b7", "EXP-9").granted == false
✓ assert_fails transition("b7", "occupied") — raised invalid transition: occupied → occupied. Current state: 'occupied'. Valid targets: [clear]
✓ assert_fails train_count.set("b7", 2) — raised memory invariant violated on 'train_count': train_count >= 0 && train_count <= 1 — rejected write of 2 (key "b7"); the slot is unchanged
✓ assert_fails train_count.set("b7", -1) — raised memory invariant violated on 'train_count': train_count >= 0 && train_count <= 1 — rejected write of -1 (key "b7"); the slot is unchanged
✓ assert exit_block("b7", "EXP-9").ok == false
✓ assert exit_block("b7", "IC-100").ok == true
✓ assert request_entry("b7", "EXP-9").granted == true
9 tests: 9 passed, 0 failed$ soma verify railway_block.cell
Verifying railway_block.cell...
State machine 'block': 2 states, initial 'clear'
States: [clear, occupied]
✓ 2 states, 2 transitions
✓ all states reachable from 'clear'
✓ reactive machine: no terminal state, and every reachable state can return to 'clear'
✓ no deadlocks
✓ liveness: every reachable state can return to 'clear' (reactive machine)
✓ think-isolated: CTL safety properties hold regardless of LLM output (2 handlers, 2 literal transitions, 0 dynamic)
✓ termination: all 3 handlers structurally terminate
✓ refinement: handler `request_entry` ⟶ {occupied}
✓ refinement: handler `exit_block` ⟶ {clear}
· transition sources: `request_entry`, `exit_block` 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)
BlockSignal/invariants:
✓ invariant train_count <= 1 — writer 'exit_block' proven by induction (writes train_count.get(block) - 1)
✓ invariant train_count >= 0 — writer 'request_entry' proven by induction (writes (train_count.get(block) ?? 0) + 1)
⚠ invariant train_count >= 0 && train_count <= 1 — runtime-checked (computed values): exit_block → train_count [train_count >= 0] because `train_count` has no known range, request_entry → train_count [train_count <= 1] because `train_count` has no known range
11 passed, 1 warnings, 0 failures
Temporal properties for 'block':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OK