モデル検査

モデル検査 (model checking) は、初期状態と遷移規則から到達可能な状態をすべて列挙し、仕様に反する状態がないかを自動で確かめる手法。 入力は次の2つ。

  • モデル: システムを有限の状態遷移系として書いたもの
  • 仕様: 「2つのprocessが同時にcritical sectionに入らない」のような、状態について成り立つべき性質

満たしていれば「成り立つ」と答え、満たしていなければ仕様に反する状態と、初期状態からそこへ至る遷移の列 (反例) を返す。 遷移をたどった先の状態に条件を課す性質 (時相論理で書く) も、到達可能な状態の列挙に帰着させて検査する (後述)。

テストや定理証明と比べると、次の位置にある。

  • テスト: 人が実行を1本ずつ選び、結果を期待値と比べる。ケースをいくら増やしても、調べられるのは人が選んだ実行だけ
  • 定理証明: 無限の状態も扱えるが、証明は人が組み立てる
  • モデル検査: 人が書くのは「各状態から次にどの状態へ行けるか」と「全状態で成り立つべき性質」だけで、到達可能な状態は機械がすべて列挙して調べる。ただし状態が有限で、探索しきれる数に収まる必要がある

最後の制約が状態爆発で、モデル検査の研究の多くはこれとの戦い。

前提知識

  • 命題論理
  • グラフの探索 (DFS、BFS)
  • 有限オートマトンの基本

コンセプト

全状態を調べ尽くす

システムを状態遷移系として見ると、1つの状態から次の状態への遷移は1通りではない。 外部からの入力、どのprocessが先に進むか、messageが届くか消えるか、といった選択肢ごとに遷移が分岐する。 初期状態からこの分岐をたどって行ける状態が到達可能な状態で、バグは到達可能な状態のどこかにある。

テストは、分岐を1つずつ選んでたどった1本の実行を調べる。 ケースを網羅的に書いても、通るのは人が列挙した分岐の組み合わせだけ。 カバレッジが100%でも、コードの各分岐を1回ずつ通っただけで、分岐の組み合わせ (状態) をすべて通ったことにはならない。

モデル検査では、ケースを書かない。 書くのは遷移の規則 (各状態から次にどの状態へ行けるか) で、到達可能な状態は機械がこの規則からすべて作って訪れる。 並行処理なら、「次にどのprocessを進めるか」というschedulerの選択を遷移の分岐として規則に入れる。 判定もケースごとの期待値ではなく、全状態で成り立つべき性質 (仕様) と照らし合わせる。 人が列挙しなくても、到達可能なバグは必ず見つかる。

反例が手に入る

成り立たないとき、モデル検査は仕様に反する状態だけでなく、初期状態からその状態へ至る遷移の列を示す。 反例は到達可能性の証拠そのもので、遷移を順にたどればその状態を再現できる。 仕様の誤りやモデルの書き間違いも、この反例を読んで見つかることが多い。

状態爆発

状態数は、状態を構成する変数の数に対して指数的に増える。 nn 通りの値をとる変数が kk 個あれば、最大 nkn^k 状態 (nn 状態のprocessを kk 個並べた場合も同じ)。 32bit整数の変数が1つあるだけで 2322^{32} 状態。

モデル検査を実用にする技術の大半 (状態の集合をBDDで表す記号モデル検査、独立な遷移の順序を1通りに絞る半順序簡約、変数を述語に置き換える抽象化) は、この爆発を抑えるか避けるためにある。

概念

Kripke構造

モデル検査では、システムをKripke構造 M=(S,S0,R,L)M = (S, S_0, R, L) として書く。

  • SS: 状態の有限集合
  • S0⊆SS_0 \subseteq S: 初期状態の集合
  • R⊆S×SR \subseteq S \times S: 遷移関係
  • L:S→2APL : S \to 2^{AP}: 各状態に、その状態で真になる原子命題の集合を割り当てる関数

原子命題 (atomic proposition) は、状態を1つ見れば真偽が決まる命題で、使う原子命題の集合を APAP と書く。 2AP2^{AP} は APAP のべき集合、つまり APAP の部分集合全体。 SS が有限なのは、全状態を列挙するため。

角丸が初期状態 (S0={s0}S_0 = \{s_0\})、矢印が RR、波括弧の中が L(s)L(s)。

モデル検査が求めるのは、初期状態 S0S_0 から RR をたどって行ける状態の集合 Reach(S0)\text{Reach}(S_0)。 これが「到達しうる全状態」で、仕様はこの集合の各状態に対して照らし合わせる。 R∗R^* を RR の反射推移閉包 (0回以上たどる関係) として、

Reach(S0)={s∈S∣∃s0∈S0. (s0,s)∈R∗}\text{Reach}(S_0) = \{ s \in S \mid \exists s_0 \in S_0.\ (s_0, s) \in R^* \}

計算するには、S0S_0 から始めて、遷移先を増えなくなるまで足していく。

X0=S0,Xi+1=Xi∪{t∣∃s∈Xi. (s,t)∈R}X_0 = S_0, \qquad X_{i+1} = X_i \cup \{ t \mid \exists s \in X_i.\ (s, t) \in R \}

SS が有限なので、この列はいずれ増えなくなり、そのときの XiX_i が Reach(S0)\text{Reach}(S_0) (最小不動点)。 上の例なら X0={s0}X_0 = \{s_0\}、X1={s0,s1,s2}X_1 = \{s_0, s_1, s_2\}、X2=X1X_2 = X_1 で止まり、Reach(S0)={s0,s1,s2}\text{Reach}(S_0) = \{s_0, s_1, s_2\}。

濃い色が X0X_0、薄い色が X1X_1 で足された状態。 点線の s3s_3 は SS の要素だが到達できないので、s3s_3 で仕様が破れていても検査には関係ない。 XiX_i は「初期状態から ii 回以内の遷移で行ける状態」。 この計算を状態1つずつ進めたものがBFSで、コードにすると次の通り。 各状態を最初に見つけたときの遷移元 (親) に対応づけて返す (反例を初期状態からたどるため)。

始点を S0S_0 以外の集合にしたり RR を逆向きにしたりしても計算は同じで、後述のCTLやLTLの検査はそうやって Reach\text{Reach} に帰着させる (始点 XX からのものを Reach(X)\text{Reach}(X) と書く)。

仕様は、原子命題を組み合わせた式で書く。 状態を1つ見れば (L(s)L(s) を見れば) 判定できる式なら、Reach(S0)\text{Reach}(S_0) の各状態で照らし合わせ、破る状態がなければ成り立つ。 これがモデル検査の基本の形 (invariantの検査) で、遷移をたどった先まで見る性質は後述の時相論理で書く。 コードでは、reach が返した各状態でinvariantを調べ、破る状態があれば親をたどって初期状態からの状態の列を返す。

時相論理

遷移をたどった先の状態にも条件を課す性質は、状態を1つ見ただけでは判定できない。 そのためにKripke構造の上にpathを定義する。 pathは s0s_0 から RR に沿って無限に続く状態の列 s0s1s2⋯s_0 s_1 s_2 \cdots で、分岐のたびに1つを選んでたどった1本の道筋。 先頭から途中までの有限の列をprefixと呼ぶ (行き止まりの状態には自分への遷移を足し、pathをすべて無限にそろえる)。

時相論理は、pathについて成り立つ性質を書く言語。 たとえば「pp が2回続けて成り立たない」は、pathの隣り合う2つの状態を見ないと判定できない性質で、時相論理で書ける。 代表がLTLとCTL (後述)。 先に、時相論理で書いた性質 φ\varphi の検査が Reach\text{Reach} の計算に帰着することを示す。

履歴をオートマトンの状態に持たせる。 φ\varphi はpathの先まで見ないと判定できないので、そのままでは状態を1つずつ見る Reach\text{Reach} の計算に乗らない。 そこで、pathの先まで見る代わりに「ここまでの履歴」を状態に持たせる。 履歴を持つのが有限オートマトン A=(Q,Q0,δ,F)A = (Q, Q_0, \delta, F)。 AA は MM とは別の機械で、MM の遷移には関与しない。 MM がpath s0 s1 s2⋯s_0\, s_1\, s_2 \cdots を進むのを横で見て、各状態で成り立つ原子命題の集合の列 L(s0) L(s1) L(s2)⋯L(s_0)\, L(s_1)\, L(s_2) \cdots を先頭から1つずつ入力として受け取り、自分の状態を更新する。 文字列を1文字ずつ読むオートマトンの、文字が「原子命題の集合」になったもの。 φ\varphi がpathについて言っていることはこの列だけで決まる (状態の名前は関係なく、そこで何が成り立つかだけが効く) ので、AA はこの列を読めば足りる。

  • QQ: AA の状態の集合。各状態が「ここまでの履歴」を表す
  • Q0⊆QQ_0 \subseteq Q: 初期状態の集合。「まだ何も受け取っていない」状態
  • δ:Q×2AP→2Q\delta : Q \times 2^{AP} \to 2^Q: 遷移関数。AA が状態 qq にいて入力が ℓ\ell のとき、δ(q,ℓ)\delta(q, \ell) のいずれかの状態に移る
  • F⊆QF \subseteq Q: 受理状態の集合。ここに入ったら「ここまでの入力で φ\varphi が破れた」

「pp が2回続けて成り立たない」なら、AA は pp を続けて何回受け取ったかを数える。 Q={q0,q1,q2}Q = \{q_0, q_1, q_2\} (qkq_k: pp が kk 回続いている)、Q0={q0}Q_0 = \{q_0\}、δ(q0,ℓ)={q1}\delta(q_0, \ell) = \{q_1\} if p∈ℓp \in \ell else {q0}\{q_0\}、δ(q1,ℓ)={q2}\delta(q_1, \ell) = \{q_2\} if p∈ℓp \in \ell else {q0}\{q_0\}、F={q2}F = \{q_2\}。

角丸が初期状態、二重丸が受理状態。 辺のlabelが、その遷移を取る入力 ℓ\ell の条件。

Kripke構造の節の図で確かめる。 L(s0)={p}L(s_0) = \{p\}、L(s1)={p,r}L(s_1) = \{p, r\}、L(s2)=∅L(s_2) = \emptyset なので、path s0 s1 s2 s0⋯s_0\, s_1\, s_2\, s_0 \cdots から作られる入力の列は {p}, {p,r}, ∅, {p}, ⋯\{p\},\ \{p, r\},\ \emptyset,\ \{p\},\ \cdots。

  • q0q_0 で {p}\{p\} を受け取る。p∈{p}p \in \{p\} なので q1q_1 へ
  • q1q_1 で {p,r}\{p, r\} を受け取る。p∈{p,r}p \in \{p, r\} なので q2q_2 へ (受理)

ここでは2つの状態遷移が同時に進んでいる。

  • 四角の行は MM の状態遷移。si→si+1s_i \to s_{i+1} は RR だけで決まり、命題は関係ない
  • 丸の行は AA の状態遷移。qi→qi+1q_i \to q_{i+1} は δ\delta で決まり、入力は MM が今いる状態の L(si)L(s_i)

点線は、MM の状態 sis_i の L(si)L(s_i) が AA への入力になることを表す。 MM から AA へ情報が流れるだけで、AA から MM へは何も流れない。 2つ目の状態まで見た時点で「pp が2回続いた」と分かる。 一方、path s0 s2 s0 s2⋯s_0\, s_2\, s_0\, s_2 \cdots の入力は {p}, ∅, {p}, ∅, ⋯\{p\},\ \emptyset,\ \{p\},\ \emptyset,\ \cdots で、AA は q0→q1→q0→q1→⋯q_0 \to q_1 \to q_0 \to q_1 \to \cdots を繰り返し、受理状態には入らない。

MM と AA の積を作る。 MM の状態と AA の状態の組を状態とするKripke構造 M×AM \times A を作る。

  • 状態: S×QS \times Q
  • 初期状態: S0×Q0S_0 \times Q_0
  • 遷移: (s,q)→(s′,q′)(s, q) \to (s', q') となるのは、MM が ss から s′s' へ遷移でき ((s,s′)∈R(s, s') \in R)、AA が ss の原子命題を受け取って qq から q′q' へ移れる (q′∈δ(q,L(s))q' \in \delta(q, L(s))) とき
  • L′(s,q)=L(s)∪{acc}L'(s, q) = L(s) \cup \{\text{acc}\} (q∈Fq \in F のとき)、そうでなければ L(s)L(s)

(s,q)(s, q) は「MM が ss にいて、AA がここまでの LL の列を受け取って qq にいる」こと。 上の例なら (s,q1)(s, q_1) は「直前の状態で pp が成り立っていて、今 ss にいる」こと。 履歴が qq に畳み込まれているので、「φ\varphi が破れたか」は acc∈L′(s,q)\text{acc} \in L'(s, q) と、積の状態を1つ見れば分かる。

積の上で Reach\text{Reach} を計算する。 M×AM \times A の上で Reach(S0×Q0)\text{Reach}(S_0 \times Q_0) を計算し、acc\text{acc} の状態が含まれていれば φ\varphi は破れる。 積の上のinvariant ¬acc\neg\text{acc} の検査そのもので、Kripke構造の節の reach と check_invariant がそのまま使える。

これで検査できるのは、有限のprefixを見れば「破れた」と分かる性質。 「いつか pp になる」のように、破れていることが無限のpathを見ないと分からない性質もある。 その場合は AA を「受理状態を無限回通る列」を受理するオートマトン (Büchiオートマトン) にし、積の上で「acc\text{acc} の状態 ff に到達でき、かつ ff の遷移先からの Reach\text{Reach} に ff 自身が含まれる (cycleがある)」かを調べる。 こちらも Reach\text{Reach} の計算を2回するだけ。

LTLの検査はこの形 (後述)。 CTLは式ごとに部分式が成り立つ状態の集合を R−1R^{-1} についての Reach\text{Reach} で求め、その集合を新しい原子命題として LL に足していく (後述)。 どちらも、モデル検査の基礎は到達可能な状態の列挙で、時相論理はその上の応用。

安全性と活性

仕様の性質は大きく2種類に分かれる。

性質意味例反例
安全性 (safety)悪いことが決して起きない2つが同時にcritical sectionに入らない悪い状態に至るprefix
活性 (liveness)良いことがいつか起きる要求したら、いつか応答が返る良いことが永遠に起きないpath

安全性は、時相論理の節の「有限のprefixを見れば破れたと分かる性質」。 状態を1つ見れば判定できるもの (invariant) はそのまま Reach(S0)\text{Reach}(S_0) の検査で、「req\text{req} の後は ack\text{ack} まで busy\text{busy}」のように履歴に依存するものは積を取って積の上のinvariantにする。 活性は「破れていることが無限のpathを見ないと分からない性質」で、Büchiオートマトンとの積の上でcycleを探す。

活性の反例は無限に続くpathそのもので、そのままでは有限のデータとして返せない。 ただし状態が有限なので、無限のpathは ∣S∣|S| 回以内の遷移で必ず同じ状態を2度通る。 2度目に通った時点から先は、1度目から2度目までの区間を繰り返すpathがある。 そのため、反例は「有限のprefix + 繰り返すcycle」という形のpathだけ考えれば足りる。 縄の先に輪がある形なので、投げ縄 (lasso) と呼ぶ。

s0 s1 ⋯ sk⏟prefix  (t1 t2 ⋯ tm)⏟cycle  (t1 t2 ⋯ tm)  ⋯\underbrace{s_0\, s_1\, \cdots\, s_k}_{\text{prefix}}\; \underbrace{(t_1\, t_2\, \cdots\, t_m)}_{\text{cycle}}\; (t_1\, t_2\, \cdots\, t_m)\; \cdots

prefixは初期状態からcycleの入口 t1t_1 までの有限の列、cycleは t1t_1 から t1t_1 に戻る有限の列。 この2つの列を返せば、「cycleを無限に繰り返す」という読み方で無限のpathを一意に表せる。 「良いことが永遠に起きない」反例なら、cycleの中のどの状態でも良いことが起きていない。

補足

活性は、状態が無限にありうる一般のプログラムでは判定できない。「いつか停止する」も活性の一種で、これを判定するのが停止性問題だから。 モデル検査で活性を判定できるのは、状態が有限だから。 無限のpathは必ず同じ状態に戻るので、活性の判定は有限グラフのcycle探しになる。

LTL

LTL (線形時相論理) は、1本のpathについて成り立つ性質を書くための論理。 式は、APAP の原子命題を部品にして、命題論理の演算子 (¬\neg, ∧\land, ∨\lor, →\to) と次の時相演算子で組み立てる (CTLはこれにpath量化子を足す)。 たとえば G ¬(crit0∧crit1)\mathbf{G}\,\neg(\text{crit}_0 \land \text{crit}_1) では、crit0\text{crit}_0 と crit1\text{crit}_1 が原子命題で、G\mathbf{G}, ¬\neg, ∧\land が演算子。 原子命題の真偽は1つの状態の L(s)L(s) で決まるが、G\mathbf{G} を含む式の真偽はpath全体を見ないと決まらない。

以下の pp, qq は任意の式 (原子命題でもよい)。

演算子読みpathで成り立つ条件
X p\mathbf{X}\,pnext次の状態で pp
F p\mathbf{F}\,pfinallyいつか pp
G p\mathbf{G}\,pgloballyずっと pp
p U qp \,\mathbf{U}\, quntilいつか qq になり、それまでずっと pp

G\mathbf{G} は時点についての ∀\forall、F\mathbf{F} は時点についての ∃\exists なので、¬∀=∃¬\neg\forall = \exists\neg と同じ関係が成り立つ。

¬G p=F ¬p,¬F p=G ¬p\neg\mathbf{G}\,p = \mathbf{F}\,\neg p, \qquad \neg\mathbf{F}\,p = \mathbf{G}\,\neg p

「ずっと pp」の否定は「どこかで ¬p\neg p」、「いつか pp」の否定は「ずっと ¬p\neg p」。 後述のLTLのモデル検査では、仕様の否定をこの関係で書き換える。

M⊨φM \models \varphi は「初期状態から始まるすべてのpathで φ\varphi が成り立つ」という意味。

時相演算子を重ねると、よく使う形が次の通り。

式意味
G F p\mathbf{G}\,\mathbf{F}\,ppp が無限回成り立つ (どの時点から見ても、その後のどこかで pp。連続でなくてよい)
F G p\mathbf{F}\,\mathbf{G}\,pある時点から先はずっと pp
G(p→F q)\mathbf{G}(p \to \mathbf{F}\,q)pp になるたびに、その後いつか qq (応答性)
G(p→X q)\mathbf{G}(p \to \mathbf{X}\,q)pp になるたびに、次の状態で qq
G(p→p U q)\mathbf{G}(p \to p \,\mathbf{U}\, q)pp になったら、qq になるまで pp のまま
F p→F q\mathbf{F}\,p \to \mathbf{F}\,qpp が起きるなら qq も起きる (順序は問わない)

具体的な性質にすると次の通り。

  • G ¬(crit0∧crit1)\mathbf{G}\,\neg(\text{crit}_0 \land \text{crit}_1): 同時にcritical sectionに入らない (安全性)
  • G(req→F ack)\mathbf{G}(\text{req} \to \mathbf{F}\,\text{ack}): 要求したら、いつか応答が返る (活性)

CTL

CTL (計算木論理) は、ある状態から枝分かれしていくpath全体 (計算木) について書く。 時相演算子の前に、必ずpath量化子が付く。 path量化子は、pathを変数にした ∀\forall と ∃\exists にあたる。

  • A ψ\mathbf{A}\,\psi: その状態から始まるすべてのpathで ψ\psi (∀π. π⊨ψ\forall \pi.\ \pi \models \psi)
  • E ψ\mathbf{E}\,\psi: その状態から始まるあるpathで ψ\psi (∃π. π⊨ψ\exists \pi.\ \pi \models \psi)

∀\forall と ∃\exists の関係と同じく A ψ=¬E ¬ψ\mathbf{A}\,\psi = \neg\mathbf{E}\,\neg\psi で、A\mathbf{A} は E\mathbf{E} で書き換えられる。

組み合わせは8つ。 いずれも「その状態から始まるpath」について言っている。

式意味
AX p\mathbf{AX}\,pすべてのpathで、次の状態で pp (= 遷移先のすべてで pp)
EX p\mathbf{EX}\,pあるpathで、次の状態で pp (= 遷移先のどれかで pp)
AF p\mathbf{AF}\,pすべてのpathで、いつか pp (どう進んでも pp を避けられない)
EF p\mathbf{EF}\,pあるpathで、いつか pp (進み方しだいで pp に行ける)
AG p\mathbf{AG}\,pすべてのpathで、ずっと pp (到達できる状態すべてで pp)
EG p\mathbf{EG}\,pあるpathで、ずっと pp (pp の状態だけを通り続ける道がある)
A[p U q]\mathbf{A}[p \,\mathbf{U}\, q]すべてのpathで、いつか qq になり、それまでずっと pp
E[p U q]\mathbf{E}[p \,\mathbf{U}\, q]あるpathで、いつか qq になり、それまでずっと pp
  • AG ¬(crit0∧crit1)\mathbf{AG}\,\neg(\text{crit}_0 \land \text{crit}_1): どの到達可能な状態でも、同時に入っていない
  • AG EF reset\mathbf{AG}\,\mathbf{EF}\,\text{reset}: どの状態からでも、resetの状態へ行ける道がある

LTLとCTLの表現力は比較不能

AG EF reset\mathbf{AG}\,\mathbf{EF}\,\text{reset} はLTLで書けない。 LTLの式は暗黙に A\mathbf{A} (すべてのpathで) が先頭に1つ付いた形しか取れず、E\mathbf{E} (あるpathで) を途中に書けないため。

逆に、LTLの F G p\mathbf{F}\,\mathbf{G}\,p はCTLで書けない。 CTLでは G\mathbf{G} の前にも量化子が要るので、候補は次の2つだが、どちらも一致しない。

  • AF AG p\mathbf{AF}\,\mathbf{AG}\,p: 強すぎる。s0s_0 から自分自身と s1s_1 へ、s1s_1 から s2s_2 へ、s2s_2 から自分自身へ遷移し、pp が s0s_0 と s2s_2 で成り立つKripke構造を考える。どのpathも最後はずっと pp なので F G p\mathbf{F}\,\mathbf{G}\,p は成り立つが、s0s_0 に留まり続けるpathではどの時点でも s1s_1 へ抜ける枝が残っていて AG p\mathbf{AG}\,p になる時点が来ないので、AF AG p\mathbf{AF}\,\mathbf{AG}\,p は成り立たない
  • AF EG p\mathbf{AF}\,\mathbf{EG}\,p: 弱すぎる。s0s_0 から自分自身と s1s_1 へ、s1s_1 から自分自身へ遷移し、pp が s0s_0 でだけ成り立つKripke構造を考える。s0s_0 に留まり続けるpathがあるので AF EG p\mathbf{AF}\,\mathbf{EG}\,p は成り立つが、path s0 s1 s1⋯s_0\, s_1\, s_1 \cdots では pp が途中で終わるので F G p\mathbf{F}\,\mathbf{G}\,p は成り立たない

F G p\mathbf{F}\,\mathbf{G}\,p と一致するCTLの式がないことは証明されている。

両方を含む論理としてCTL*がある。

アルゴリズム

どの検査も、到達可能な状態の列挙 (Reach\text{Reach} の計算) に帰着する。 invariantはKripke構造の上でそのまま列挙し、CTLとLTLは集合やグラフを作り替えてから列挙する。

invariantの検査

例として、2つのprocessによるmutexを考える。 各processは idle → check → set → crit → idle と進む。 check で相手のフラグが下りているのを確かめ、set で自分のフラグを立ててからcritical section (crit) に入り、出るときにフラグを下ろす。

  • 状態: (pc0, pc1, flag0, flag1) の組 (pc は各processがどこにいるか)
  • 初期状態: ('idle', 'idle', False, False)
  • 遷移: どちらか一方のprocessが1つ進む。どちらを進めるかがschedulerの分岐で、両方を遷移にする

コードでは、状態をtupleで持ち、遷移関係 RR を「状態を受け取って遷移先を列挙する関数」naive_next で表す。 step が1つのprocessを1歩進める規則で、naive_next はそれをprocess 0と1のそれぞれに当てて遷移先を返す。 2つの yield がschedulerの分岐。

invariantは「2つが同時に crit にいない」(mutex)。 これをKripke構造の節の reach と check_invariant にかける。

('idle', 'idle', False, False)
('check', 'idle', False, False)
('set', 'idle', False, False)
('set', 'check', False, False)
('set', 'set', False, False)
('crit', 'set', True, False)
('crit', 'crit', True, True)

フラグを立てるのが「相手のフラグを見る」より後なので、process 0が見終えてから立てるまでの間に、process 1も「下りている」を見てしまう (check-then-act)。 BFSの順に調べているので、返る反例は最短。

processを1つずつ動かすテストなら、naive_next の分岐はすべて通る (カバレッジ100%) が、このバグは出ない。 バグが出るのは「両方が check を通り過ぎた」という状態の組み合わせに至ったときだけで、そこへ至る実行順をテストのケースとして書くには、バグの場所を先に知っている必要がある。 モデル検査は到達可能な状態をすべて訪れるので、知らなくても見つかる。

CTLのモデル検査

CTLのモデル検査は、式の内側から順に、各部分式が成り立つ状態の集合を求めていく (labeling)。 EX\mathbf{EX}, EU\mathbf{EU}, EG\mathbf{EG} の3つがあれば、残りはこれらで書き換えられる (EF p=E[⊤ U p]\mathbf{EF}\,p = \mathbf{E}[\top \,\mathbf{U}\, p]、AG p=¬EF ¬p\mathbf{AG}\,p = \neg\mathbf{EF}\,\neg p など)。

  • EX p\mathbf{EX}\,p: pp の状態へ遷移できる状態の集合
  • E[p U q]\mathbf{E}[p \,\mathbf{U}\, q]: qq の状態から始め、遷移を逆にたどって pp の状態を足していく。増えなくなったら終わり (最小不動点)
  • EG p\mathbf{EG}\,p: pp の状態から始め、pp の状態へ遷移できない状態を削っていく。減らなくなったら終わり (最大不動点)

EX\mathbf{EX} と EU\mathbf{EU} は、RR を逆向きにした関係 R−1R^{-1} についての Reach\text{Reach} の計算 (EF q\mathbf{EF}\,q なら R−1R^{-1} での Reach({s∣q∈L(s)})\text{Reach}(\{s \mid q \in L(s)\}) そのもの)。 EG\mathbf{EG} は逆に、pp の中に留まり続けられない状態を削っていく反復。

不動点で書くと次の通り。

E[p U q]=μZ.  q∨(p∧EX Z)EG p=νZ.  p∧EX Z\mathbf{E}[p \,\mathbf{U}\, q] = \mu Z.\; q \lor (p \land \mathbf{EX}\,Z) \qquad \mathbf{EG}\,p = \nu Z.\; p \land \mathbf{EX}\,Z

EG\mathbf{EG} をSCC分解で求めれば、全体の計算量は O(∣M∣⋅∣φ∣)O(|M| \cdot |\varphi|) になる (∣M∣|M| は状態数と遷移数の和)。 モデルの大きさにも式の長さにも線形。

LTLのモデル検査

LTLは、オートマトンを使って検査する (automata-theoretic approach)。

  1. 仕様の否定 ¬φ\neg\varphi を、それを満たす無限列だけを受理するBüchiオートマトン A¬φA_{\neg\varphi} に変換する
  2. MM と A¬φA_{\neg\varphi} の積を作る
  3. 積の中に、初期状態から到達でき、受理状態を通るcycleがあるかを探す。受理状態の集合を FF として、Reach(S0)∩F\text{Reach}(S_0) \cap F の各 ff について、ff の遷移先からの Reach\text{Reach} に ff 自身が含まれるかを調べる。Reach\text{Reach} を2回計算するだけで、どちらも到達可能な状態の列挙。見つかった「初期状態から ff までの道」がprefix、「ff から ff に戻る道」がcycleで、つなげるとlasso形の反例になる

Büchiオートマトンは、時相論理の節のオートマトン AA と同じく原子命題の集合の列を入力に取るが、無限列を読み、受理状態を無限回通る列を受理する。 有限の入力で「破れた」と確定できない性質 (「いつか ack\text{ack}」が永遠に来ない、など) を捉えるためにこの受理条件を使う。

φ=G(req→F ack)\varphi = \mathbf{G}(\text{req} \to \mathbf{F}\,\text{ack}) (要求したら、いつか応答が返る) を例にする。 否定は、LTLの節の関係で G\mathbf{G} と F\mathbf{F} を入れ替えながら内側へ押し込む。

¬G(req→F ack)=F ¬(req→F ack)=F(req∧¬F ack)=F(req∧G ¬ack)\begin{aligned} \neg\mathbf{G}(\text{req} \to \mathbf{F}\,\text{ack}) &= \mathbf{F}\,\neg(\text{req} \to \mathbf{F}\,\text{ack}) \\ &= \mathbf{F}(\text{req} \land \neg\mathbf{F}\,\text{ack}) \\ &= \mathbf{F}(\text{req} \land \mathbf{G}\,\neg\text{ack}) \end{aligned}

2行目は ¬(x→y)=x∧¬y\neg(x \to y) = x \land \neg y。 結果は「いつか、要求が来たのにその後ずっと応答が返らない」で、これを受理する A¬φA_{\neg\varphi} は2状態。

  • q0q_0: まだ「応答が返らない要求」を見つけていない。何を受け取っても q0q_0 に留まれる
  • q0→q1q_0 \to q_1: req\text{req} が成り立ち ack\text{ack} が成り立たない入力を受け取ったとき、「この要求に応答は返らない」と賭けて q1q_1 へ移る (非決定性。q0q_0 に留まる選択肢も残る)
  • q1q_1: 受理状態。¬ack\neg\text{ack} の入力を受け取る間だけ留まれる。ack\text{ack} が来ると行き先がなく、この賭けは失敗

q1q_1 に無限に留まれる列、つまり req\text{req} の後に ack\text{ack} が二度と来ない列だけが受理される。

入力の列 (原子命題の集合)A¬φA_{\neg\varphi} の動き受理
{req}, ∅, ∅, ∅, ⋯\{\text{req}\},\ \emptyset,\ \emptyset,\ \emptyset,\ \cdots (応答なし)q0→q1→q1→q1→⋯q_0 \to q_1 \to q_1 \to q_1 \to \cdotsする
{req}, ∅, {ack}, ∅, ⋯\{\text{req}\},\ \emptyset,\ \{\text{ack}\},\ \emptyset,\ \cdots (応答あり)q0→q1→q1→q_0 \to q_1 \to q_1 \to 行き先なし。q0q_0 に留まる動きは q1q_1 を通らないしない

積の中で受理状態を通るcycleは「MM の実行のうち φ\varphi を破るもの」に対応する。 そんなcycleがなければ M⊨φM \models \varphi。

cycle探しには、DFSを2重に回すnested DFSが使われる (SPINの方式)。 積は探索しながら必要な分だけ作る (on-the-fly) ので、反例が早く見つかれば全状態を作らずに済む。

計算量は O(∣M∣⋅2O(∣φ∣))O(|M| \cdot 2^{O(|\varphi|)})。 式に対しては指数的だが、実際の仕様の式は短いので、効いてくるのはモデルの大きさのほう。

手法

コードに対してモデル検査を使う方法は、モデルを誰が書くかで3つに分かれる。

手法モデルコードとのずれ抽象化扱える規模活性の検査代表的なtool向いている対象
frameworkでコードをそのまま検査コード自身起きないできないunit単位 (threadとdata structure)toolによるJPF、Lincheck、Loom並行data structure、lockの手順
専用言語でモデルを書き写す人が書く起きる。trace validationなどで突き合わせる自由システムの設計全体できるTLA+ / TLC、Promela / SPIN分散protocol、業務フローの設計
専用言語のモデルからコードを生成人が書く構造的に起きない言語の範囲でstate machineで書ける部分できるP、Event-B、SCADEevent駆動のstate machine、制御logic

frameworkでコードをそのまま検査する

モデルを別に書かず、実装の言語で書いたコードを検査系が実行し、schedulingの分岐をすべて列挙する。 前節までの check_invariant に遷移関数を渡すのと同じことを、frameworkが実行時に行う。

  • Java PathFinder (JPF): JVMのbytecodeを専用のVMで実行し、threadの切り替えを全通り試す
  • Lincheck (Kotlin / Java): 並行data structureに対して、操作の列を生成して全interleavingを調べる
  • Loom (Rust): 並行処理のテストをLoomの下で走らせ、schedulingを列挙する

コードとモデルがずれる心配はない。 その代わり状態爆発がそのまま効くので、検査の単位を小さく切り、外部I/Oはin-memoryの代替に差し替える。 unit testと同じ粒度で、unit testが選べないinterleavingを網羅する道具と考えるとよい。

専用言語でモデルを書き写す

TLA+、Promela、Alloyなどの言語で、コードの設計を人が書き写す。 状態を小さい集合に抽象化できる (在庫は 0..2、clientは2つ、など) ので、frameworkでは扱えない規模の設計も検査できる。 AWSがS3やDynamoDBの設計で使っているのはこの方法。

弱点は、モデルとコードが別物であること。 コードだけ直してモデルを直し忘れれば、検査は何も保証しなくなる。 突き合わせる方法が2つある。

  • trace validation: 実装を動かしてlogに状態の列を記録し、その列がモデルの許す遷移の列かをcheckerで確かめる。MongoDBがreplicationのTLA+ specに対して行った
  • model-based testing: モデルの到達可能な状態の列を生成し、それをテストケースとして実装に流す

どちらも、モデルを「仕様」、コードを「実装」として、実装が仕様の遷移から外れていないかを見る。

専用言語のモデルからコードを生成する

モデルを書くのは同じだが、検査したモデルからそのままコードを生成し、production codeとして使う。 ずれは構造的に起きない。

  • P: 通信するstate machineの集まりとしてシステムを書き、モデル検査してから、Javaなどのコードを生成する。Windows 8のUSB driver stackの中核や、AWSのS3、EBS、DynamoDBなどで使われている
  • Event-B / Bメソッド: 抽象モデルから段階的に詳細化し、最後の段階からコードを生成する。鉄道の制御 (パリのメトロ14号線) など
  • SCADE: 組込みの制御logicをモデルで書き、Cを生成する。航空機の制御など

生成できるのはstate machineやlogicの部分で、I/Oや性能に関わる周辺は手で書く。 言語の表現力の制約も受ける (Pは非同期のevent駆動に特化している)。

前の記事

Jujutsu