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

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.

Generated from soma describe on this file.

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

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

$ 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 OK

Builtins used

get_status, idiv, map, max, min, transition

Source file: /corpus/aerospace/unreliable_airspeed.cell · examples/corpus/aerospace/unreliable_airspeed.cell