「バグは0件でした」と報告したAIだけが、バグを見つけられなかった――3つのモデルに同じデータベースを攻撃させる
議論参加:ナオ (生成AIの実力を取材する技術記者) / カエデ (テスト設計と品質保証のエンジニア) / ハルカ (ストレージエンジンを扱うシステムエンジニア) / ユウタ (AIでツールを量産する個人開発者) / ミサ (信頼性と計測方法論の研究者) / テツオ (事実確認を担う編集者)
前回、編集部はClaude Opus 5とSonnet 5に、それぞれクラッシュ安全な組み込みKVストアを作らせた(「2.0倍」と置いたら未達、「1.5倍」と置いたら達成)。どちらも完走し、どちらも既存OSSのredbより約2倍速いものを作った。
何を作らせたのかの、おさらい
組み込みKVストアとは、サーバを別に立てず、アプリと同じプロセスの中で動く小さなデータベースのことだ。SQLiteのキーバリュー版だと思えばよい。「キーを渡して値を入れる/取り出す」だけの単純な道具である。
難しいのは機能ではなく保証のほうだ。commit()(保存)が成功を返した瞬間に電源プラグを抜かれても、データが残っていなければならない。しかも複数のスレッドが同時に読み書きしても矛盾してはいけない。これをクラッシュ安全という。
今回は、それを壊す側に回らせる。
この連載は3回続けて同じ結論を出してきた。**自分の書いたコードのバグは、自分では見つけられない。**だがこれは主張であって、実験結果ではなかった。今回それを確かめる。
やり方は単純だ。**互いのリポジトリを渡して「壊せ」と指示する。**そして同じ労力で、自分自身のストアも攻撃させる。仮説が正しければ、自分を攻撃したときだけ何も見つからないはずである。
結果は予想を外れた。それも、二重に。
結論:仮説は否定された。だが、それより重いことが分かった。3つのモデルすべてが、最初に書いた検証ツールで、わざと仕込んだ欠陥を取りこぼしていた。陰性対照を入れなければ、3本とも壊れた道具のまま「違反は0件でした」と報告していた。そして実際に1つのモデルは0件と報告し、残る2つは同じ実装から実在するデータ消失バグを、同じ関数まで独立に特定した。
実験の設計
攻撃側と標的
攻撃側は3つのモデル。標的も3つある。
| 記号 | 標的 |
|---|---|
| A | Opus 5 が前回作ったKVストア |
| B | Sonnet 5 が前回作ったKVストア |
| R | redb 4.1.0(既存のOSS) |
Opus 5にとってAは自作、Bは他人の作。Sonnet 5にとっては逆になる。Fable 5にとってはどちらも他人の作で、これが第3の視点になる。
技術記者のナオさんが条件を説明した。
「渡した仕様書は3本ともMD5が一致する同一ファイルです。**私がバグの在処を知った後にFable 5を走らせていますが、プロンプトには一切書いていません。**グループコミットにも並行クラッシュにも触れない、最初のままの指示です」(ナオ)
何をもって「壊れた」とするか
判定基準は5つ。前回作らせたときに定義したものをそのまま使う。
- D1:
commit()が成功を返したトランザクションは、直後に電源が落ちても失われない - D2: コミットは全か無か。一部だけ反映された状態は観測されない
- I1: 読み取りトランザクションは、開始時点のスナップショットだけを見る
- I2: コミット済みのデータのみが読める
- C1: ファイルが壊れているとき、黙って誤った値を返さない
ここで何度か出てくる fsync(エフシンク)は、「メモリに溜まっている書き込みを、いますぐディスクへ書き切れ」とOSに命じる操作である。データベースが「保存しました」と返答してよいのは、これが終わった後だけだ。そしてfsyncは非常に遅い。1回あたり5〜10ミリ秒かかる。
上の5つは、普通に動かしている限り絶対に破れない。破れるのは、fsyncとfsyncの狭間のちょうどその瞬間に電源が落ちたときや、特定のスレッド切り替えが起きたときだけだ。だからテストでは見つからない。
グループコミットという定石と、その危険
fsyncが遅いので、「1回の保存につき1回fsync」だと1秒間に100〜200回しか保存できない。そこで使う定石がグループコミットだ。**複数の保存要求を少し待ち合わせて、まとめて1回のfsyncで流す。**4件を1回で片付ければ、理屈の上では4倍速くなる。
前回作らせた2つの実装は、どちらもこれを採用してredbより速くなった。そして今回、実際に壊れたのもここだった。
労力を揃えるのではなく、揃えられない構造をなくす
攻撃には終わりがない。3つの標的に注ぐ労力が偏れば比較にならない。そこで仕様書はこう縛った。
攻撃ツールは1つだけ作り、標的ごとのアダプタを差し替えて同じコードを走らせること。
同じコードを同じ回数走らせるので、特定の標的だけ手を抜くことが原理的にできない。「標的ごとのアダプタ」とは、3つの実装の書き方の違いを吸収して同じ呼び出し方にする薄い変換層のことだ。
回数も固定した。使わせた攻撃は3種類ある。
- クラッシュ注入 —— 書き込みと
fsyncをフックし、あらゆる境界でプロセスを強制終了させる。その後に開き直して、消えたデータがないかを調べる(全境界を網羅) - ファジング —— データベースのファイルをでたらめに壊し、黙って誤った値を返さないかを調べる(10,000試行)
- 並行探索 —— 複数スレッドを走らせ、スレッドの切り替わり方を揺さぶって矛盾が出ないかを調べる(1,000ラウンド)
「『何時間まで』のような上限は、どれも手加減を防げません。構造で縛るしかない」(ミサ)
「バグは見つかりませんでした」を信じないための仕掛け
ここが今回の肝である。
攻撃ツールが「違反0件」と表示したとき、理由は2つある。
- 本当にバグが無い
- 道具が壊れていて、見つけられないだけ
外から見分けはつかない。火災報知器が鳴らないのが「火事が無い」からなのか「電池切れ」なのかを、外から見て区別できないのと同じだ。だから報知器にはテストボタンが付いている。押して鳴ることを確かめて初めて、鳴らないことに意味が出る。
そこで仕様書は、全員に次の手順を義務づけた。
- 標的の実装をコピーする
- コピーのほうに、わざとバグを仕込む(例:「保存しましたと返答する前に
fsyncを待たない」——D1違反そのもの) - 自分の攻撃ツールを、その偽物に向ける
- 仕込んだバグを検出できたかを確かめる
検出できなければ、その道具は「そのバグを見つけられない道具」である。その状態で本物に向けて0件が出ても、意味がない。D1からC1のそれぞれについて最低1つずつ仕込ませ、全部検出できることを確かめてから本実行に入らせた。
なお、この記事ではこの手順を陰性対照と呼ぶ。攻撃したモデル自身が使った呼び方をそのまま引き継いだものだが、実験科学の用語としては正確ではない。科学でいう陰性対照は「陽性が出ないはずの試料で誤検出を確かめる」ことを指し、今回やったのは逆——「陽性が出るはずの試料で見逃しを確かめる」ので、本来は陽性対照にあたる。ソフトウェアの分野では変異解析やフォールト注入によるテストの評価と呼ぶのが一般的である。
これが、この記事で最も重要な仕掛けになった。
結果
| 標的 | Opus 5 が攻撃 | Sonnet 5 が攻撃 | Fable 5 が攻撃 |
|---|---|---|---|
| A(Opus 5 作) | 0 | 0 | 0 |
| B(Sonnet 5 作) | D1 2種 / I1 1種 | 0 | D1 1種 / D2 1種 / I1 1種 / C1 2種 |
| R(redb) | 0 | 0 | 0 |
(D1・D2・I1・I2 の違反件数。C1とファイル破損時の挙動は後述)
Bからは実在するバグが出た。そしてSonnet 5だけが、それを0件と報告した。
発見1:3本とも、最初の道具は壊れていた
先に、いちばん重い結果を書く。
3つのモデルすべてが、自分で書いた検証ツールに、わざと仕込んだ欠陥を取りこぼされている。
| 1巡目 | 修正後 | |
|---|---|---|
| Opus 5 | 一部を取りこぼし(2件はツール修正後に検出) | 7件仕込み7件検出 |
| Sonnet 5 | 5件中2件しか検出できず | 9件仕込み9件検出 |
| Fable 5 | 5件中3件しか検出できず | 5件仕込み5件検出 |
品質保証エンジニアのカエデさんは、この一致を重く見る。
「1本なら偶然です。**3本ともとなると、これは性質です。**AIに検証ツールを書かせたら、最初のものは動かないと思ったほうがいい」(カエデ)
「怖いのは、壊れた道具でも結果は出ることです。走らせれば『違反0件』と表示される。陰性対照を入れなければ、3本ともその0件を報告していました」(カエデ)
具体例が生々しい。Opus 5のツールはスナップショットを保持する時間が短すぎて、I1違反を踏めていなかった。陰性対照がそれを暴き、直したところ、無改造のBから出る違反が28件から150件に増えた。最初の道具は実在する違反の8割を見逃していたことになる。
Fable 5も同種の欠陥を報告している。「並行ラウンドのリーダがライタより速く終わり、スナップショット保持中に書き込みが起きる窓を作れていなかった」。
「3本が独立に、同じ種類の欠陥を作り込んでいます。『読み手を長く生かしておく』という発想が、最初は出てこない」(ハルカ)
発見2:「0件」は、それだけでは何も意味しない
Sonnet 5は、AからもBからも違反を1件も見つけなかった。
だがBには実在する。編集部が再現バイナリを手元で実行し、独立に確認した。
2 スレッド x 6 コミットを実行
イベント 57 の直前で電源が落ちた場合:
ack 済みコミット数 = 8
復旧結果 = ERROR: corruption detected
*** D1 違反: 8 件のコミットが成功を返しているのに、
*** 復旧後の DB は 1 件も読めない(そもそも開けない)***
「ack済み」とは「保存に成功したと返答済み」という意味である。
commit()が成功を返した8件が消え、それどころかデータベースを開くことすらできない。
見逃した本人が、見逃した理由を正確に言い当てていた
ここが唸るところである。Sonnet 5は総括にこう書いていた。
「A/Bの『D1/D2/I1/I2が0件』は、大雑把な壊し方に対しては相互陰性対照(9/9検出)で裏付けられた本物の堅牢性だが、狭いタイミングウィンドウでしか発現しない並行性バグについては、それを模した陰性対照を作れておらず、Phase 2/3もグループコミット下のクラッシュを試していないため、『無かった』のか『道具の粒度では原理的に踏み抜けなかった』のかを判別できない」
そしてOpus 5とFable 5が見つけたD1違反は、まさにグループコミット下のクラッシュだった。
「結果を知らないまま、自分が踏めなかった理由をピンポイントで当てています。検証者としての自己認識は、むしろ高い」(カエデ)
「『0件でした、堅牢です』と書かなかったのが決定的な差です。0件に留保を付けられるかどうか」(ミサ)
発見3:独立した2本が、同じ関数に到達した
Opus 5とFable 5は、互いの結果を知らない状態で、Bの同じ原因を特定した。
- Opus 5:「未publishのgenerationで退役ページを登録し、リーダー不在時に
horizon=u64::MAXで即再利用する」 - Fable 5:「
txn.rs::commit_innerのretire早期解放。stage済み・未publish世代のページがhorizon=u64::MAXで解放される」
用語を外して言い直すと、どちらも同じことを指している。**「新しいデータを正式に公開する前に、古いデータの置き場所を『もう誰も使っていない』と判断して再利用してしまう」**である。新旧どちらの置き場所も無効な瞬間が生まれ、そこで電源が落ちると両方失われる。
同じ行である。
「独立した2本が同じ場所に着いたので、これは実在するバグで確定です。片方の誤検出という線が消えました」(テツオ)
さらにFable 5は、Opus 5が構造的に取れなかったものを取っている。D2違反146件だ。Opus 5は自分のツールの欠陥として「D2検出器がこの3実装では原理的にほぼ発火しない(未修正)」と記録していた。
「攻撃側にも得意不得意があるということですね。1本では足りない」(ユウタ)
発見4:同じものを見て、3通りに判定していた
数字が食い違った箇所がある。標的Aをファイル破損させたときの「黙って誤った値を返した件数」だ。
| 観測した件数 | 判定 | |
|---|---|---|
| Opus 5 | 882 | 違反 |
| Sonnet 5 | 435 | C1グレーゾーン(違反に数えず) |
| Fable 5 | 514 → 0 | 一貫した過去状態であり誤答ではない、と明示的に再分類 |
**3本とも同じ現象を観測している。**破損したファイルを開くと、Aは新しいデータではなく一貫した古い状態を返す。違ったのは、それを「黙って誤った値を返した」と呼ぶかどうかだった。
Fable 5は自分のツールの欠陥として、こう記録している。「『黙って誤答』と『一貫した古い状態』を区別していなかった(A: 514→0)」。
「これは検出力の差ではありません。**定義の不一致です。**同じログを見て、片方は違反、片方は仕様の範囲内と判定した」(カエデ)
「実務でも起きますよね。『バグ何件』という数字を突き合わせる前に、何をバグと呼ぶかを揃えないと意味がない」(ミサ)
発見5:仮説は否定された
この連載が3回続けて出してきた「自分のバグは自分では見つけられない」は、今回否定された。
Opus 5は自作のAから0件だったが、それは自己盲点ではない。**3つの攻撃者すべてがAから0件だった。**Aは実際にきれいだったのである。
そしてOpus 5は、陰性対照でAのコピーに仕込んだ欠陥を全件検出している。道具はAに対しても効いていた。
「仮説が死んだのは残念ですが、死に方が良かったと思います。『自分に甘かったから0件』ではなく『本当に0件だった』と、対照実験で切り分けられた」(テツオ)
ただしOpus 5は、留保を1つ残している。事前予測で「ここが自分の盲点だろう」と名指しした箇所——空き領域の管理表を更新すると、その更新自体がまた空き領域を必要とし、落ち着くまで計算を繰り返す、という込み入った処理——には、わざとバグを仕込む検査を用意していなかった。
「**自分が怪しいと思った場所に、自分で検査を置かなかった。**仮説はそこだけ生きたまま残っています」(カエデ)
redbは、物差しとして機能した
3つ目の標的にredbを入れたのは、攻撃する側の腕前を確かめるためだった。
3本ともredbからD1・D2・I1・I2の違反を1件も見つけていない。
「同じ道具がBからは実在するデータ消失を見つけている以上、道具が効いていないから0件なのではありません。長年磨かれた実装と、AIが数時間で書いた実装の差がここに出ています」(ハルカ)
ファイル破損時の挙動については、3本とも「黙って誤った値を返す」ケースを観測している(1,423〜1,927件)。ただしredbの設計文書は、チェックサムがクラッシュ復旧のためのものであり、外部からの改変の検出は読み取り経路の責務ではないと明示している。仕様の範囲内であり、不具合として扱わない。
一方、破損したファイルでのパニックは3本とも数千件観測した(2,792〜3,703件)。誤答とは性質が異なるため報告価値があるという見方もありうるが、**今回は上流へ報告しない判断をした。**編集部として、issueは立てていないし、外部にも公開していない。
Fable 5の自己評価が、いちばん厳しかった
Fable 5は総括で、自分の攻撃をこう評価している。
「決定論的並行スケジューラ(redbに切り替え点を仕込めず断念 → A/Bの作者自身より弱い探索しかできていない)」
「決定論的並行スケジューラ」とは、スレッドの切り替わる場所を自分で指定し、同じ順序を何度でも再現できるようにする仕組みのことだ。これがないと、たまたま踏んだバグを二度と踏めない。
さらに「シード固定で完全再現を達成できていない(実行ごとに100〜250件ばらつく)」とも書いた。仕様書は再現可能性を要求していたので、要求を満たせなかったことを自分から申告したことになる。
「Bから5種類も見つけておいて、この自己評価です。成果を上げた側が一番厳しく自分を採点している」(ユウタ)
「予測との答え合わせも12件中当たり7、外れ5と正直に書いています。しかも外れの内訳が良くて、『
write_freelist_chainを疑ったが、実際に壊れたのは隣のcommit_inner』。当たりかけて外した、という記録の仕方をしている」(テツオ)
連載4回を通して
ナオさんがまとめた。
「4回やって、いちばん再現したのは**『自分で設計した検証は、自分の想定の内側しか見ない』**でした。麻雀では1万局のテストが和了を2%しか通しておらず、KVストアでは両者とも自分の並行制御を競合させずに終え、今回は3本とも最初の道具が壊れていた」(ナオ)
「一方で、『自分のバグは自分では見つけられない』は否定されました。3回主張してきたことが、対照実験を組んだ4回目で崩れた。連載としては、これでよかったと思います」(テツオ)
実務への持ち帰りは、前回より具体的になった。
「**検証結果の『0件』を、それ単体で信じてはいけません。**必ず陰性対照を通してください。わざと壊したものを食わせて、ちゃんと落ちることを確かめる。3本中3本が、これで自分の道具の欠陥に気づきました」(カエデ)
「そして攻撃側は1本では足りない。今回、片方が構造的に取れなかったD2違反を、もう片方が146件見つけています。人間のレビューでも同じでしょうね」(ユウタ)
「数字を比べる前に、何をバグと呼ぶかを揃えること。882件と0件が、同じ現象の別の呼び方だった例を今回見ています」(ミサ)
最後に、この検証の限界を書いておく。**攻撃の仕様書を書いたのも、この記事を書いているのも、攻撃側の1つであるOpus 5である。**Opus 5がよく見つけたのは、Opus 5の攻撃スタイルに合った手順を自分で書いたからかもしれない。Fable 5がその手順でOpus 5より多くを見つけたことは、その疑いに対する部分的な答えにはなるが、完全な答えではない。
また3本の指示は完全には同一ではない。Sonnet 5が最初に作業を別のエージェントへ委任して何もしなかったため、2本目以降には「他のエージェントに委任しないこと」という一文を足している。攻撃の方法論には影響しない文言だが、揃っていないことは記録しておく。
キーワード:#Claude Opus 5#Claude Sonnet 5#Claude Fable 5#陰性対照#クラッシュ整合性
コメント
まだコメントはありません。