Unreliable airspeed — AF447: the autopilot never flies on bad data
On 1 June 2009, Air France 447's pitot tubes iced over in a storm over the Atlantic. The three airspeed channels disagreed; the autopilot dropped out and the flight control law degraded to alternate — with stall protection gone. In the confusion of the handover the crew held the A330 in a stall from 38,000 feet to the ocean. 228 people died. Two structural properties matter, and this controller makes both machine-shaped rather than flag-shaped: 1. There is NO state with the autopilot engaged and degraded data. The instant the channels split, AP-off and alternate law arrive as ONE transition — never a half-handover. 2. Alternate law is LATCHED for the remainder of the flight (as on the real A330): when the pitots thaw and the speeds agree again, the law does NOT spring back. soma verify proves the latch — the state is terminal.
A Soma program from the aerospace part of the verified corpus: 105 lines, cells AirDataLaw, LawTests. It passes soma check with soma 3.2.4.
How it is built
The cell AirDataLaw declares 4 signals in its face: engage_ap(id: String) -> Map, ap_off(id: String) -> Map, airspeed_vote(id: String, a_kt: Int, b_kt: Int, c_kt: Int) -> Map and law_state(id: String) -> String; keeps 1 memory slot: agreed_kt (Map<String, Int>, persistent and consistent) under the invariant agreed_kt >= 0 && agreed_kt <= 400. Its state machine control_law starts in normal_manual and allows 4 transitions: normal_manual -> normal_ap, normal_ap -> normal_manual, normal_ap -> alternate_law_manual and normal_manual -> alternate_law_manual. alternate_law_manual is terminal. Handlers: engage_ap (line 47), ap_off (line 53), airspeed_vote (line 59) and law_state (line 75).
The file also holds 1 test cell (LawTests) with 12 assertions, run by soma test.
The source
unreliable_airspeed.cell
// ============================================================================
// UNRELIABLE AIRSPEED — AF447: the autopilot never flies on bad data
// ============================================================================
//
// On 1 June 2009, Air France 447's pitot tubes iced over in a storm over
// the Atlantic. The three airspeed channels disagreed; the autopilot
// dropped out and the flight control law degraded to alternate — with
// stall protection gone. In the confusion of the handover the crew held
// the A330 in a stall from 38,000 feet to the ocean. 228 people died.
//
// Two structural properties matter, and this controller makes both
// machine-shaped rather than flag-shaped:
//
// 1. There is NO state with the autopilot engaged and degraded data.
// The instant the channels split, AP-off and alternate law arrive
// as ONE transition — never a half-handover.
// 2. Alternate law is LATCHED for the remainder of the flight (as on
// the real A330): when the pitots thaw and the speeds agree again,
// the law does NOT spring back. soma verify proves the latch — the
// state is terminal.
// ============================================================================
cell AirDataLaw {
face {
signal engage_ap(id: String) -> Map
signal ap_off(id: String) -> Map
signal airspeed_vote(id: String, a_kt: Int, b_kt: Int, c_kt: Int) -> Map
signal law_state(id: String) -> String
}
memory {
// last agreed airspeed, knots — the airframe envelope
agreed_kt: Map<String, Int> [persistent, consistent]
invariant agreed_kt >= 0 && agreed_kt <= 400
}
// ABSENT: any state pairing "engaged" with degraded air data
// ABSENT: any edge out of alternate_law_manual (the latch)
state control_law {
initial: normal_manual
normal_manual -> normal_ap
normal_ap -> normal_manual // crew disengages, data still good
normal_ap -> alternate_law_manual // disagree: AP drop + law degrade, ONE step
normal_manual -> alternate_law_manual
}
on engage_ap(id: String) {
let r = try { transition(id, "normal_ap") }
if r.error != () { return map("ok", false, "reason", "{r.error}") }
return map("ok", true, "law", get_status(id))
}
on ap_off(id: String) {
let r = try { transition(id, "normal_manual") }
if r.error != () { return map("ok", false, "reason", "{r.error}") }
return map("ok", true, "law", get_status(id))
}
on airspeed_vote(id: String, a_kt: Int, b_kt: Int, c_kt: Int) {
let hi = max(a_kt, max(b_kt, c_kt))
let lo = min(a_kt, min(b_kt, c_kt))
if hi - lo > 20 {
// channels split: one transition hands over AND degrades.
// If already latched, it simply stays latched.
if get_status(id) != "alternate_law_manual" {
transition(id, "alternate_law_manual")
}
return map("ok", true, "law", get_status(id), "disagree_kt", hi - lo)
}
let w = try { agreed_kt.set(id, idiv(a_kt + b_kt + c_kt, 3)) }
if w.error != () { return map("ok", false, "reason", "voted speed outside envelope") }
return map("ok", true, "law", get_status(id), "agreed_kt", agreed_kt.get(id))
}
on law_state(id: String) { return get_status(id) }
}
cell test LawTests {
rules {
// normal cruise: AP engaged, channels agree
assert engage_ap("f1").ok == true
assert airspeed_vote("f1", 272, 274, 273).law == "normal_ap"
// THE AF447 moment: pitots ice, channels split by 80 kt —
// AP-off and alternate law arrive as ONE transition
assert airspeed_vote("f1", 272, 190, 268).law == "alternate_law_manual"
// the latch: speeds agree again minutes later, but the law
// does NOT spring back and the AP CANNOT re-engage
assert airspeed_vote("f1", 270, 271, 270).law == "alternate_law_manual"
assert_fails transition("f1", "normal_ap")
assert_fails transition("f1", "normal_manual")
assert engage_ap("f1").ok == false
// a clean handover on good data is still a normal thing
assert engage_ap("f2").ok == true
assert ap_off("f2").ok == true
assert law_state("f2") == "normal_manual"
// the envelope ledger: an impossible voted speed is unwritable
assert_fails agreed_kt.set("f2", 999)
assert_fails agreed_kt.set("f2", -40)
}
}What the toolchain says
$ soma check unreliable_airspeed.cell
✓ All checks passed.$ soma test unreliable_airspeed.cell
test LawTests ...
✓ assert engage_ap("f1").ok == true
✓ assert airspeed_vote("f1", 272, 274, 273).law == "normal_ap"
✓ assert airspeed_vote("f1", 272, 190, 268).law == "alternate_law_manual"
✓ assert airspeed_vote("f1", 270, 271, 270).law == "alternate_law_manual"
✓ assert_fails transition("f1", "normal_ap") — raised invalid transition: alternate_law_manual → normal_ap. Current state: 'alternate_law_manual'. Valid targets: []
✓ assert_fails transition("f1", "normal_manual") — raised invalid transition: alternate_law_manual → normal_manual. Current state: 'alternate_law_manual'. Valid targets: []
✓ assert engage_ap("f1").ok == false
✓ assert engage_ap("f2").ok == true
✓ assert ap_off("f2").ok == true
✓ assert law_state("f2") == "normal_manual"
✓ assert_fails agreed_kt.set("f2", 999) — raised memory invariant violated on 'agreed_kt': agreed_kt >= 0 && agreed_kt <= 400 — rejected write of 999 (key "f2"); the slot is unchanged
✓ assert_fails agreed_kt.set("f2", -40) — raised memory invariant violated on 'agreed_kt': agreed_kt >= 0 && agreed_kt <= 400 — rejected write of -40 (key "f2"); the slot is unchanged
12 tests: 12 passed, 0 failed$ soma verify unreliable_airspeed.cell
Verifying unreliable_airspeed.cell...
State machine 'control_law': 3 states, initial 'normal_manual'
States: [alternate_law_manual, normal_ap, normal_manual]
✓ 3 states, 4 transitions
✓ all states reachable from 'normal_manual'
✓ terminal states: [alternate_law_manual]
✓ no deadlocks
✓ liveness: every state can eventually reach a terminal state
✓ think-isolated: CTL safety properties hold regardless of LLM output (3 handlers, 3 literal transitions, 0 dynamic)
✓ termination: all 4 handlers structurally terminate
✓ refinement: handler `engage_ap` ⟶ {normal_ap}
✓ refinement: handler `ap_off` ⟶ {normal_manual}
✓ refinement: handler `airspeed_vote` ⟶ {alternate_law_manual [if hi - lo > 20 ∧ if get_status(id) != "alternate_law_manual"]}
· transition sources: `engage_ap`, `ap_off`, `airspeed_vote` 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)
AirDataLaw/invariants:
⚠ invariant agreed_kt >= 0 && agreed_kt <= 400 — runtime-checked (computed values): airspeed_vote → agreed_kt because `a_kt` is a parameter (narrow it: `require idiv(a_kt + b_kt + c_kt, 3) >= 0 && idiv(a_kt + b_kt + c_kt, 3) <= 400 else …`); `b_kt` is a parameter (narrow it: `require idiv(a_kt + b_kt + c_kt, 3) >= 0 && idiv(a_kt + b_kt + c_kt, 3) <= 400 else …`); `c_kt` is a parameter (narrow it: `require idiv(a_kt + b_kt + c_kt, 3) >= 0 && idiv(a_kt + b_kt + c_kt, 3) <= 400 else …`)
10 passed, 1 warnings, 0 failures
Temporal properties for 'control_law':
✓ deadlock_free — no deadlocks in any reachable state
Temporal: 1 passed, 0 failed
VERIFY OKBuiltins used
get_status, idiv, map, max, min, transition