vatt'ghern jaskier's ballads
本文 4 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

Turso 工程師寫了一個只描述「open → create table → serialize → start reading → call sqlite3_deserialize() during active read transaction」的 Quint 模型,五步驟跑出來,SQLite 直接 crash 而不是回 SQLITE_BUSY——這份被測試了二十年的程式碼,倒在了一個五步的形式化 trace 上。

Quint,從零講起——TLA+ 風格 formal model 怎麼幫 Turso 找出 SQLite 十個 bug

完這篇之後,你會知道一件事:formal methods 在 2026 年並不是學術玩具,也不是 AWS 那種「我們有十個 PhD 在寫 TLA+」的特權。Turso 用 Quint——一個刻意降低門檻的 TLA+ 風格語言——把 SQLite 寫了二十年的 C API 重新「換句話說」一次,跑了一輪 model checker,找出十二個 SQLite 自己 fuzzer 抓不到的 bug。文章會從 state-space 是什麼開始講,到 Quint 的 simulator 與 model checker 各自負責什麼,最後落到一個你可以自己判斷的問題:你家的 storage layer,值不值得花一週寫個 Quint spec。

SQLite 二十年沒抓到的那五步——為什麼這是個值得停下來看的故事

先把案發現場攤開。SQLite 是這個產業裡被測試得最徹底的資料庫,沒有之一。它的 test suite 是程式碼本身的 100 倍——超過一百萬條測試案例。它跑 American Fuzzy Lop、跑 libfuzzer、跑 OSS-Fuzz 已經跑了將近十年,每天有大型雲端廠商的 CI 在用各種詭異的 query 餵它。在這樣的成熟度下,新加入的 contributor 通常只能在邊角案例裡找到問題:char encoding、極端大數值、特定平台的 alignment。

但 Turso 這次找到的不是邊角案例。他們找到的是這種東西:

「sqlite3_deserialize() crashes instead of returning SQLITE_BUSY during an active read transaction.」

翻成白話:你開了一個 read transaction(正在讀),這時候呼叫 sqlite3_deserialize() 想把另一份序列化的 database image 套上去。documented 的合約是回 SQLITE_BUSY 讓你重試。實際行為是 process 整個 crash 掉。

這種 bug 不會被 fuzzer 抓到,原因很具體:fuzzer 餵的是 SQL statement 序列,它不知道「在一個 active read transaction 中間插入 deserialize C API call」這個組合本身是合法的——它也不會去探索 C API 之間的 interleaving。fuzzer 探索的是 statement 空間,這個 bug 住在 C API call 之間的 state 空間。兩個空間的維度不一樣。

那 Turso 怎麼找到的?他們先用 Quint 寫了一份對「sqlite3_deserialize() 的合約」的描述:哪些 state 下可以呼叫、回什麼、不能呼叫時應該如何回應。然後讓 Quint 的 simulator 跑出一條「狀態、動作、狀態、動作」這樣的 trace,再把 trace 翻譯成 C 程式碼 actually 對 SQLite 跑。比對 documented behaviour 與實際行為,發現對不上。

注意這裡的關鍵句:「Traces are just a sequence of states, not SQL statements. Those traces can then be translated into whatever you want to allow execution against the system.」這句話是整套方法的精髓。Quint 產生的不是 SQL,是 state sequence;reproducer 是把 state sequence 翻成你想要的執行單位——SQL statement、C API call、HTTP request 都可以。fuzzer 在 statement 層;formal model 在 state 層。

這篇文章要回答的問題很簡單:state 是什麼?怎麼描述?Quint 是什麼樣子?為什麼它能找到 fuzzer 找不到的東西?跑下來 cost 多少?最後——你的系統適不適合也跑一輪?

在繼續之前先標記一個容易誤會的點。Turso 找到的「12 個 bug」不是 12 個 implementation crash——其中有一條(sqlite3_uri_int64() 的 default 行為)作者明確說「a docs mismatch rather than a real bug」,不是 code 寫錯,是文件描述跟實作對不上。Formal methods 找到的東西,本來就是「documented contract 與 observed behaviour 之間的縫隙」——它不知道哪邊是對的,它只知道兩邊不一致,然後把不一致用 counterexample trace 攤在你面前。

state-space 是什麼——把系統當成一張圖來看

放下 SQLite 一下。先談 state 這個詞,因為下面所有東西都建立在這個詞上。

把任何 stateful 系統想成一張有向圖(directed graph)。圖的每個節點是系統的一個 state——它包含所有「目前 system 知道的東西」。對一個簡單的 counter 來說,state 就是一個整數。對一個有兩個 connection、各自可能在 idle / reading / writing 三個階段的 database server 來說,state 是 (conn1, conn2) 這個 pair,每個分量取自三個值,總共 9 個 state。對 SQLite 的 sqlite3_deserialize() 合約來說,state 包括「目前是否在 read transaction」、「目前是否在 backup」、「目前是否有 active statement」、「memory database 是否已 deserialized」等等——大概十來個 boolean,組合起來可能上百到上千個 state。

圖的每條邊是一個 action——一個會把系統從一個 state 推到另一個 state 的操作。在 SQLite 裡,action 包括 sqlite3_open、sqlite3_exec("BEGIN")、sqlite3_step、sqlite3_deserialize 等等。每個 action 都可以寫成一個「pre-condition → post-condition」:在什麼樣的 state 下這個 action 合法、執行完之後 state 變成什麼。

整套 formal model 的工作就是:

  1. 定義 state 的 shape——通常是一個 record,由幾個欄位組成。
  2. 定義 init——系統的初始 state(圖的起點)。
  3. 定義一組 action——每個 action 是一個 (current state → next state) 的關係。
  4. 定義 invariant——所有 reachable state 都必須滿足的性質。比方說「如果在 active read transaction,那麼 deserialize 必須回 BUSY 而不是 crash」。

有了這四樣東西,你就有了一張完整的 state graph 的描述——雖然你沒有真的把整張圖畫出來。Model checker 的工作就是替你把圖走一遍:從 init 開始,沿著 action 邊往外擴展,每走到一個新 state 就檢查 invariant。如果某個 state violation,它印出從 init 到那個 state 的 trace——一條導致 bug 的 step-by-step path。

注意這跟 unit test 的差別。Unit test 是你手動寫「set up 這個 state,跑這個 action,assert 那個 state」。Formal model 是你描述合約,由工具去「窮舉所有可能 reachable 的 state」並 assert invariant。你不用想到 5-step trace,工具替你想。

另一個常見的混淆:state 不等於 variable。一個 system 裡有 1000 個 variable,不代表它有 1000 個 state——它的 state 是「所有 variable 取值的笛卡爾積之中,reachable 的那個子集」。對 sqlite3_deserialize() 的 model 來說,原文 SQLite 的 internal 變數可能有上萬個,但 model 只挑出影響合約的 4 ~ 7 個 boolean 抽象成 state。這個「挑出哪些 variable 進 state」是寫 spec 的核心藝術,挑得太多 state-space 爆炸跑不完,挑得太少 model 與 reality 差太遠 counterexample 沒意義。

還有一個概念要先放進腦子裡:action 的 non-determinism。Model checker 不假設 action 之間有固定順序,它假設「在每個 state,所有 enabled 的 action 都可能下一個觸發」。所以從同一個 state 出發,可能走出 5 條不同的後續路徑——這 5 條都會被 model checker 探索。Non-determinism 是 formal methods 與 unit test 最深的差異:unit test 一條 path 一個結果,formal model 同時看 N 條 path 的 N 個結果。

從工程意義講,這條 non-determinism 本來就符合真實系統的特性——multi-threaded code、distributed system、async runtime 都是「多個 enabled action 同時可能觸發」。寫單元測試時你被迫做出順序假設,formal model 允許你「不假設順序」並讓工具窮舉所有 interleaving。對 race condition 這類問題,這正是 sweet spot。但「窮舉」這個詞,正是問題所在。

把上面 SQLite 例子推到極端:假設你的模型有 |A| = 10 種 action,每個 action 在某些 state 下適用。從 init 開始走一步,你可能到達 10 個新 state。走兩步,最多到達 100 個。走 N 步,最多到達 10^N 個。這就是 state-space explosion——隨著 trace 長度線性增加,可達 state 數量指數增加。實務上加上對稱性消除、equivalence class、symbolic execution 等技巧可以壓縮一些,但本質的指數性質沒辦法消除。

所以「窮舉所有 state」幾乎永遠是不可能的——對任何有意義的系統來說。Model checker 的真實做法是 bounded exploration:限制 trace 長度 ≤ N,限制 state count ≤ M。在這個有限的盒子裡窮舉。

下面這段是 Quint spec 的最小形狀,先把語法的氛圍放進腦子裡,後面會慢慢補細節:

module sqlite_deserialize {
  // state shape
  var inReadTxn: bool
  var inBackup:  bool
  var memDbDeserialized: bool

  action init = all {
    inReadTxn' = false,
    inBackup'  = false,
    memDbDeserialized' = false
  }

  // contract: in active read txn, deserialize must return BUSY, not crash
  invariant deserializeBusyContract =
    inReadTxn implies not(memDbDeserialized')
}

下面這個 widget 把 state-space 增長的關係畫出來,你可以拖動 N 看一下兩條曲線是怎麼分開的——naive 的指數線與 bounded 線之間那條縫,就是 model checker 能跑完的工程價值。

6 步 / 10^6 個
trace length N (steps) log10(reachable states) 10^0 10^5 10^10 10^15 10^20 naive |A|^N (A=10) bounded(去重後可達 state 數)
橙色是 naive 上界——每一步都產生 |A| 個新 state;綠色是 model checker 實際走的路徑數(簡化為 N · |A| · log N 級的成長,反映 equivalence pruning 與重複合併)。N=6 時 naive 已經 10^6,而 bounded 只在 10^3 量級。Model checker 的工程價值正落在這條縫裡。

橙色是 naive 上界——每一步都產生 |A| 個新 state;綠色是 model checker 實際走的路徑數…

A=10、N=6 時 naive 狀態數 10^6;等價剪枝後 ~10^3;model checker 工作在兩條曲線的缺口以窮舉驗證。

拖到 N=20 你會看到 naive 線條已經衝到 10^20,現代 CPU 一輩子算不完。但 bounded 線條還在 10^3 ~ 10^4 量級。這就是為什麼 model checker 可以「跑完」——它不窮舉真實 state graph,它在一個壓縮過的等價類圖上跑。能不能壓得乾淨,是 model checker 工程品質的核心。

實務上,Quint 的 model checker(Apalache,由 Informal Systems 開發)採用 symbolic model checking——把 state 用 SMT 變數表示而不是具體值,讓 solver 一次推理一整個等價類。這比逐個 state 走(explicit-state model checking)通常快幾個數量級,代價是某些 state 結構(unbounded set、深度遞迴的 record)會讓 SMT solver 卡住。

「窮舉到深度 N」這個條件值得停下來再嚼一下。它有兩個意涵:

第一,model checker 不保證找出所有 bug。它只保證找出長度 ≤ N 的 trace 上的 bug。如果你的 bug 需要 N+1 步才能觸發——例如某個 leak 要連續 100 次 retry 才會 overflow——而你只跑 depth=20,那個 bug model checker 不會回報。所以實務上的 N 是個 cost / coverage 的 tradeoff,沒有「正確」的值。

第二,找不到 counterexample 不等於 spec 正確。可能是 spec 太抽象——你 modeled 的 invariant 太弱,所有 violation 都剛好不違反那個弱條件。所以 spec 設計的另一半是「invariant 要寫得多嚴」——夠嚴才有意義,太嚴又可能無法 model 真實系統。Turso 的做法是:每個 documented contract 都翻成一條 invariant,所以 spec 的 invariant 集合與文件的 contract 集合是對齊的。

Quint 是什麼——TLA+ 的後代,把語法重寫成現代工程師看得懂的樣子

到這裡你已經知道 model checker 在做什麼。Quint 是這套思路在 2026 年的一個落地實作。

Quint 由 Informal Systems 開發,目標是「把 TLA+ 的能力封裝在一個現代工程師願意打開 editor 寫的語言裡」。TLA+ 是 Leslie Lamport 在 1990 年代設計的——數學味很重,語法依賴 ASCII 的 ad-hoc 圖形(`/\` 是 and、`\/` 是 or、`\A` 是 forall),TLA+ 的 editor experience 直到 2020 年才有一個能用的 LSP。新人學 TLA+ 大概花一週才能讀懂第一份 spec。

Quint 的設計選擇是:

  • 類 TypeScript / Rust 的語法。`val`、`def`、`action`、`run` 是 keyword,看起來像一般語言。
  • 強型別系統。state record 的每個欄位、每個 action 的輸入輸出,都有 type,會被 type checker 檢查。TLA+ 是 untyped,寫錯欄位名稱要跑 model checker 才會發現。
  • built-in 的 simulator——隨機跑 trace,不做窮舉,幾秒鐘就回。先用 simulator 撈一些 trace 跑 sanity check,再上 model checker 做窮舉。
  • 後端接 Apalache(symbolic model checker),共享 TLA+ 生態系的工具。Quint 編譯成 TLA+ 給 Apalache 吃。
  • 標準的 dev tooling:LSP、syntax highlighting、`quint test`、`quint run`、`quint verify`。

下面這個 tab widget 把 TLA+ 與 Quint 寫同一個「open / read / deserialize」action 並排放給你看。重點不是哪個語法比較短——是兩者表達能力相同,但 Quint 的 surface 對寫過 TypeScript 的人來說可讀性高一個數量級。

module sqlite_deserialize {
  type DbState = {
    opened: bool,
    inReadTxn: bool,
    inBackup: bool,
    deserialized: bool,
  }

  var s: DbState

  action init = all {
    s' = { opened: false, inReadTxn: false,
           inBackup: false, deserialized: false }
  }

  action open = all {
    not(s.opened),
    s' = { ...s, opened: true }
  }

  action beginRead = all {
    s.opened,
    not(s.inReadTxn),
    s' = { ...s, inReadTxn: true }
  }

  action deserialize = all {
    s.opened,
    // documented:如果 inReadTxn,應該回 BUSY 而不是 crash
    s' = { ...s, deserialized: true }
  }

  val Safety = s.inReadTxn implies not(s.deserialized)

  run sample = init.then(open).then(beginRead).then(deserialize)
}
------------------------------ MODULE sqlite_deserialize ------------------------------
VARIABLE opened, inReadTxn, inBackup, deserialized
vars == <<opened, inReadTxn, inBackup, deserialized>>

Init == /\ opened = FALSE
        /\ inReadTxn = FALSE
        /\ inBackup = FALSE
        /\ deserialized = FALSE

Open == /\ ~opened
        /\ opened' = TRUE
        /\ UNCHANGED <<inReadTxn, inBackup, deserialized>>

BeginRead == /\ opened
             /\ ~inReadTxn
             /\ inReadTxn' = TRUE
             /\ UNCHANGED <<opened, inBackup, deserialized>>

Deserialize == /\ opened
               /\ deserialized' = TRUE
               /\ UNCHANGED <<opened, inReadTxn, inBackup>>

Next == Open \/ BeginRead \/ Deserialize

Safety == inReadTxn => ~deserialized
=======================================================================================
// 兩份 spec 表達的東西完全等價:

// 1. state 由四個 boolean 組成
//    Quint 用 record;TLA+ 用 4 個 VARIABLE。

// 2. action 是 pre-condition + post-state 的 conjunction
//    Quint:all { 條件1, 條件2, s' = ... }
//    TLA+:/\ 條件1 /\ 條件2 /\ 變數' = ...

// 3. 沒變的欄位
//    Quint:用 { ...s, fieldX: value } 自動 carry over
//    TLA+:必須顯式寫 UNCHANGED <<欄位列表>>
//    這條讓 TLA+ 在欄位多的 spec 變得非常吵雜。

// 4. invariant
//    Quint:implies 是中綴 keyword
//    TLA+:=> 是符號
//    語意完全相同。

// 5. type
//    Quint 有 DbState type alias;TLA+ 沒有。
//    對應的代價是 TLA+ 寫錯欄位名 model checker 才會發現。

看完上面三個 tab 你大概抓到了:Quint 不是發明新的語意,它是 reskin TLA+。Apalache 在背後做的事情兩者都一樣。差別在 surface——對於從沒摸過 formal methods 的工程師,Quint 的 onboarding cost 大約是 TLA+ 的 1/3,因為它走的是「你已經會 TypeScript,只是要學一個多了 actions 與 invariants 的 dialect」這條路徑。

Quint 有一個獨有的 keyword:run。它讓你寫固定路徑當 test case(init.then(open).then(beginRead).then(deserialize)),橋接「unit test 思維」與「model checking 思維」——新手從 run 起步,熟練後讓 model checker 自由探索。再加上 record spread 語法 { ...s, opened: true }(默認沒提到的欄位不變,反過來於 TLA+ 的 UNCHANGED),以及編譯期 type checker,這幾個 ergonomic 細節把大型 spec 的維護成本壓得遠低於 TLA+。

simulator 與 model checker 各自負責什麼——兩個工具,兩種 coverage

Quint 提供兩種跑 spec 的方式,差異要分清楚,因為它們找的是不同類別的問題。

simulator:從 init 開始,每一步隨機選一個 enabled action 走下去。產生一條長度為 N 的 trace。檢查 invariant,violation 就回報。重複 K 次。

model checker:從 init 開始,用 BFS(或 symbolic execution)窮舉所有長度 ≤ N 的 reachable state。任何一個 state 違反 invariant 都回報,並印出最短的 counterexample trace。

對一個有 |A| = 10 個 action 的系統,simulator 跑 1000 次 length-10 的 trace,covers 10^4 個 random path。Model checker 跑同樣的 N=10,covers 整整 10^10 個(理論上界)reachable path,但實際透過 symbolic pruning 可能壓到 10^5 ~ 10^7。

關鍵差別不是數量,是「分布」。Simulator 是隨機抽樣——大部分樣本長得很像(都先 open、都先 begin transaction),corner case 很少被選到。Model checker 是窮舉——每一個 reachable corner case 都會被走過,不論 prefix 多奇怪。下面這張圖把這件事畫出來。

REACHABLE STATE SPACE (THEORETICAL) SIMULATOR 隨機 trace × K 次 MODEL CHECKER bounded BFS / symbolic corner-case bug 簡單情境兩者都 cover;只有 model checker 會探到深邏輯角落
橙圈內是 simulator 抽到的少數 path;綠圈內是 model checker 窮舉的稠密 path。bug 就住在「reachable 但 simulator 隨機抽不到」的位置——sqlite3_deserialize() crash 就是其中一個。

橙圈內是 simulator 抽到的少數 path;綠圈內是 model checker 窮舉的稠密 path

simulator 隨機採樣一小角;model checker 窮舉有界可達狀態,SQLite bug 藏在 simulator 幾乎不到的邊角。

Turso 內部本來就有 Deterministic Simulation Testing(DST)——這就是它們公開講過很多次的 turmoil-style 隨機 SQL 生成。原文是這樣比較的:「formal methods explore parts of the specification that the Turso simulator previously could not. The general concepts are similar, but instead of generating a random sequence of SQL statements and enforcing system-level properties, Quint generates a list of traces.」

注意「a list of traces」——不是 random、不是無盡的,是有限、明確、可重複的 trace 集合。每條 trace 都是 model checker 從 spec 推出來的,每條都對應一個「reachable state 配上一條到達它的路徑」。從 DST 的角度看,這像是 randomness 的反義詞——「我不要隨機,我要 systematic」。

用兩者搭配的 workflow 是:

  1. 先用 simulator 跑幾百條 random trace 做 spec 自身的 sanity check——確認 model 至少 syntactically 可跑、沒有 obvious 卡死。
  2. 再用 model checker 跑 bounded exhaustive,找出 invariant violation。這一步通常需要 minutes 到 hours,看 spec 複雜度。
  3. 對找到的 counterexample trace,翻譯成實際 system 的 reproducer(SQL、C API call 序列、HTTP request)。
  4. 在真實 system 上跑這個 reproducer,看是不是真的 violate 預期。

Step 4 很重要:model 找到的 violation 可能是 model 本身寫錯(你描述的合約跟 documented 的對不上)。所以每個 counterexample 都必須在實際 system 上重現過,才算是真的 bug。Turso 找到 12 個 bug,意思就是它們對 12 個 model checker 給的 counterexample 做了 reproducer,每一個都在真實 SQLite 上重現了 documented 與實際行為不一致。

實際操作上 step 3 的 reproducer 通常長這樣——一條 5 步的 trace 翻譯成 C:

// counterexample trace 翻譯成 C reproducer
sqlite3 *db;
sqlite3_open(":memory:", &db);                    // open
sqlite3_exec(db, "CREATE TABLE t(x);", 0, 0, 0);  // create table

unsigned char *serialized;
sqlite3_int64 size;
serialized = sqlite3_serialize(db, "main",
                               &size, 0);          // serialize

sqlite3_stmt *stmt;
sqlite3_prepare_v2(db, "SELECT x FROM t",
                   -1, &stmt, 0);
sqlite3_step(stmt);                               // start reading

// expected: SQLITE_BUSY (documented)
// observed: process crash
int rc = sqlite3_deserialize(db, "main",
                             serialized, size, size,
                             SQLITE_DESERIALIZE_FREEONCLOSE);

這段 C 直接從 Quint 的 trace 來。每一行 C call 對應 trace 上的一個 action。執行起來最後一行不回 SQLITE_BUSY 而是 segfault——文章開頭那個 bug 的真實復現。把 scrubber 拖到不同步,看每一步 model 端 4 個 boolean 怎麼演化、到第 5 步為什麼 invariant 必然被 violation:

0 / 5
STATE FIELDS opened inReadTxn serialized deserialized — init — invariant inReadTxn ⇒ ¬deserialized · 目前 holds S0 S1 S2 S3 S4 S5
5 步從 init 到 violation。每格 ● 表 true、○ 表 false。step 5 的紅框表示 invariant 從 hold 翻成 violated——這正是 model checker 印出 counterexample 的瞬間。

5 步從 init 到 violation

五步反例第 5 步 inReadTxn=true ∧ deserialized=true 同時成立,invariant 違反,對應真實 segfault。

Trace 翻 reproducer 這個步驟在不同 system 有不同形式:

  • 對 SQL database,trace 翻成 SQL statement + connection-level API call。
  • 對 distributed consensus 演算法,trace 翻成 node 之間的 message sequence + 進入特定 phase 的 trigger。
  • 對 HTTP API,trace 翻成 curl 序列 + cookie / session state 設定。
  • 對 protocol implementation(QUIC、TLS),trace 翻成 packet sequence + handshake state。

這層轉譯目前主要是 manual 的(每條 trace 約 10 ~ 30 分鐘),所以 model checker 噴出 100 條 trace 你不會全部翻——你會挑 invariant 最關鍵、看起來最容易重現的幾條。

那十二個 bug——分類看就能猜到 Quint 為什麼擅長這些

下面這張表把 Turso 公布的 12 個 bug 攤開。Bug 的 surface 名稱、所在 subsystem、bug 的本質類別、Quint model 約略需要描述幾個 state field。表是可排序的,按 surface 排可以看到哪個 subsystem 最暴露。

surface / API subsystem bug 類別 state fields ≈
sqlite3_deserialize() crash on active readmemory dbcrash instead of BUSY5
deserialize during backup interleavingmemory db / backupcrash7
EXISTS-to-join LIMIT/OFFSETquery plannerwrong result6
Nested EXISTS-to-join correlation lossquery plannerwrong result8
Quoted constraint with embedded quotesschema / DDLundroppable constraint4
UPDATE one-pass + correlated BETWEENquery plannerwrong result regression7
ALTER ADD CHECK on internal sqlite_ tablesschema / DDLcrash4
sqlite3_mutex 128-byte alignment UBcore / mutexundefined behaviour2
NULL pzErr crash in changegroup_change_finishsession extnull deref3
xfer optimisation bypasses BLOB type via ANYquery planner / xferintegrity_check fail6
xfer optimisation bypasses CHECK constraintsquery planner / xferdb inconsistency6
sqlite3_uri_int64() invalid value defaulturi parsingdocs/code mismatch3
Bug 名稱是 Turso 原文用語,state field 數是大致估計——實際 Quint spec 通常還會加 auxiliary ghost 變數做 invariant 表述。點欄位 header 可排序。

Bug 名稱是 Turso 原文用語,state field 數是大致估計——實際 Quint spec 通常還會加 …

Quint 找到 12 個 SQLite bug;query planner 和 backup 受影響最多;類型:crash、結果錯誤、文件不符。

排一輪會看到一個模式:query planner 與 memory db / backup 各佔三四個,schema / DDL 拿到兩個。這跟「哪個 subsystem 最有 state interleaving」吻合——query planner 的 rewrite optimisation(EXISTS-to-join、xfer、one-pass UPDATE)中間 state 豐富,每個 rewrite 都是一個 state transition;memory db 與 backup 之間有跨 connection 的 mutual exclusion,是天然 state machine。反過來看,alignment UB 與 NULL pzErr 的 state 結構很淺——它們是 unit-test 量級問題,但 Quint trace 用「呼叫順序」找,所以「先呼叫 changegroup_finish 再讀 pzErr」這種 sequence 是免費副產物。

EXISTS-to-join 那條是 query planner 的 rewrite optimization——SELECT * FROM A WHERE EXISTS (SELECT 1 FROM B WHERE B.x = A.x) 被 rewrite 成 join 利用 index。Quint model:state 包含 plan strategy、LIMIT/OFFSET 計數器、join 中間結果;action 包含 apply rewrite、apply LIMIT、emit row;invariant 是「最終 emitted rows = 不 rewrite 時的數量」。Model checker 找到的 trace:rewrite 後 LIMIT 計數器把 join 重複列也算進去,invariant violation。翻成 SQL 餵 SQLite,bug 確認。xfer optimisation 那兩條(BLOB ANY、CHECK constraint)也是同一個套路——把「target 是否啟用 xfer」當 state,invariant「啟用 xfer ⇒ source/target compatible」,model checker 直接展示反例。

把 Quint 帶回自己家——什麼樣的系統值得寫 spec

讀到這裡你應該已經能回答最初那個問題:值不值得花一週寫個 Quint spec?答案是有條件的「值得」——條件可以列成 checklist:

  • 你的系統有明確的 state machine 行為——有 transaction、有 lifecycle、有 lock、有 lease、有 retry budget。如果是 stateless API gateway,formal model 的價值不高。
  • 你有一份 documented 合約——RFC、API doc、internal design doc,描述「在什麼 state 下呼叫什麼 action 應該得到什麼結果」。沒有合約就沒有 invariant 可以 check。
  • state 之間的 interleaving 很豐富——多個 connection、多個 worker、多階段 protocol、optimization 會 rewrite 路徑。狀態夾角越多,fuzzer 越抓不到,formal model 越值得。
  • bug 的後果代價高——資料 corruption、安全漏洞、calibration 失誤。寫 Quint spec 的成本(人天)需要被找到的 bug 的成本(incident、CVE、loss of trust)攤銷。
  • 系統的核心邏輯已經穩定——formal model 是描述「合約」,合約一直在變,spec 也要一直 rewrite。穩定的核心 + 變動的周邊是最佳場景。

反過來,這些情境通常不值得:

  • 純 stateless 的 transformer / parser / formatter——unit test 就夠。
  • UI 邏輯——state space 大但價值密度低。
  • 還在快速迭代的 prototype——spec 跟 code 同時改,兩邊一直對不上。
  • 沒有 documented 合約可參考的內部腳本工具。

實務上怎麼開始?Turso 文章建議的入手法是:「Take one documented SQLite C API contract, then model only the state and properties needed for that.」——挑一個小的、邊界清楚的 API,只 model 跟它有關的 state,不要試圖一次 model 整個系統。整個系統的 spec 通常會大到 model checker 跑不完,最後變成負擔。

一個 typical Quint spec 對單一 API 的長度大概是 100 ~ 300 行——包括 state record、5 ~ 10 個 action、3 ~ 5 個 invariant、若干 helper definition。寫 + 跑 + debug 大概一個工程師 3 ~ 5 天。如果有現成的 TLA+ spec 可以參考,會更快。

還有一個常被忽略的 by-product:寫 Quint spec 強迫你把「documented 但沒寫成可機器讀的形式」的合約逼出來。很多 bug 不是 code 寫錯,是 doc 跟 code 從來沒對齊——sqlite3_uri_int64() 那條「docs mismatch」正是這類,code 沒錯 doc 錯,但這種不一致比 code bug 更危險,因為 downstream 照 doc 寫。Team 至少需要一個人花兩三週成為 Quint 認真 user(讀 tutorial、寫過兩三個 small spec、能讀 Apalache 錯誤訊息),這個人撐起來之後,其他 team 可以用 review 方式 contribute——formal review 比 formal authoring 容易很多。

Quint repo 在 github.com/informalsystems/quint,有 onboarding tutorial、VS Code extension、example library。從 example 裡找一個你看得懂的(counter、bank、two-phase commit),改幾個 action 跑跑看,半小時內你就能看到自己的第一條 counterexample trace 從 terminal 跳出來——那一刻是這套工具最動人的瞬間,因為你會發現你「想到」的合約跟你「寫出來」的 spec 之間,原來有這麼大的縫。

跟 Property-Based Testing 比,PBT 在「input 空間」生成(字串、list),Quint 在「state-action 空間」生成;PBT 通常 stateless,Quint 天然 stateful。對 stateful protocol,Quint 完勝;對純函數,PBT 更輕量——兩者互補不是替代。跟 TLA+ 比,Quint 能力等價(編譯成 TLA+ 給 Apalache 吃),差別是 ergonomic 與型別系統,liveness / fairness 的學術資源 TLA+ 仍佔優。

把整篇收束成一句話:Quint 不是給你「程式正確性的證明」,是給你「對你自己合約的窮舉式 review」。前者是學術理想,後者是工程現實——工程現實已經足夠用來抓 SQLite 二十年沒被抓到的 12 個 bug。

真的開始寫之後,會踩到幾個典型坑——點 tab 切換看每一條的具體陷阱與修法。

把 state 寫太細:把實作裡所有變數搬進 state record。違背 spec 抽象原則——你 model 的是合約,不是實作。內部 cache、buffer、page mapping 都不該進 state。規則:每個欄位都對應一條 documented contract。
把 action 寫太粗:把「open + create table + insert」合併成單一 action。model checker 看不到中間 state,就找不到「table 已建但 row 還沒插」這個 invariant violation。一個 action 對應一次可觀察 API call,不更少也不更多。
invariant 寫成 tautology:寫 opened = openedtrue。永遠不會 violation,跑完報「無問題」但什麼也沒驗證。問自己:哪些 state 是 documented 說「絕對不該出現」的?那個的反面就是 invariant。
忽略 init 的不確定性:真實系統 initial state 通常不固定,db 可能已有資料、connection 可能已 open。spec init 應該是「all valid starting states」,用 any { ... } 表達 non-deterministic。忘掉這層會錯失「特定 init 下才出現」的 bug。
跑太久不知道縮哪:model checker 卡一小時沒回應。要縮的不是 N(trace 深度),是 state space。兇手通常是某個欄位值域太大(integer transaction ID)。改成 symbolic abstraction(只區分 zero / non-zero),從跑不完變秒回。
以為 trace 短就 bug 簡單:counterexample 短不代表修起來容易。Turso 的 deserialize crash 是 5 步 trace,但修正涉及 SQLite 內部好幾個 module 的協調。trace 是「最短反例」,不是「最簡單修法」。

不擅長的場景也要明說:floating-point arithmetic(SMT solver 對浮點支援差一個量級)、外部 timing / wall-clock(Quint 是 logical step,沒有 wall-clock;要用 timed automata)、weak memory ordering(C++/Rust 的 acquire/release/relaxed 用 herd7 / CDSChecker 更合適)、UI 視覺行為(spec 是 logical contract)、ML 模型的 statistical 性質(probabilistic model checking 用 PRISM / Storm)。明確列出 limitation 反而讓 sweet spot 更清楚——multi-step API contract、distributed protocol、state-machine backend、access control rule、replication invariant,這些都是 Quint 的場子。Turso 是 storage 公司,代價高,投資合理;你的系統如果代價沒這麼高,formal methods 是「知道有這條路、但今年不一定要走」——但「不一定要走」跟「不知道有這條路」是兩件事。

Take-away:fuzzer 在 statement 空間裡撒網,Quint 在 state 空間裡推演——當你的 bug 住在「合法的呼叫序列在合法的 state 排列下違反 documented 合約」這個維度,前者永遠抓不到,後者一條 5 步 trace 就能逼出來。