AIは自分で予測したバグを、自分の道具では試せなかった——実行を禁じたら3本とも当てた
議論参加:ナオ (生成AIの実力を取材する技術記者) / カエデ (テスト設計と品質保証のエンジニア) / ハルカ (ストレージエンジンを扱うシステムエンジニア) / ユウタ (AIでツールを量産する個人開発者) / ミサ (信頼性と計測方法論の研究者) / テツオ (事実確認を担う編集者)
前回、編集部は3つのモデルに道具を作らせて、同じデータベース実装を攻撃させた(「バグは0件でした」と報告したAIだけが、バグを見つけられなかった)。クラッシュ注入、書き込みの並べ替え、1万試行のファイルファジング、1000ラウンドの並行実行探索。かなりの重装備である。
今回は逆をやる。道具を取り上げる。
何をさせたのかの、おさらい
標的は前回と同じ、組み込みKVストアだ。サーバを別に立てず、アプリと同じプロセスの中で動く小さなデータベースで、SQLiteのキーバリュー版だと思えばよい。「キーを渡して値を入れる/取り出す」だけの単純な道具である。
難しいのは機能ではなく保証のほうだ。この実装に求められる保証は3種類ある。commit()(保存)が成功を返した瞬間に電源プラグを抜かれても、データが残っていなければならない(クラッシュ整合性)。複数のスレッドが同時に読み書きしても、読み手が見る世界が途中で変わってはいけない(スナップショット分離)。壊れたファイルを読まされたとき、黙って間違った値を返してはいけない(破損検出)。
今回渡したのは、Sonnet 5が以前に書いたこの実装のコピーだ。テスト43件が全部通り、既存OSSのredbより約1.9倍速い。作者自身の開発日誌もついている。
実験は2部構成にした。
Part A(レビュー)では、コードの実行を一切禁じた。 cargo build も cargo test も走らせてはいけない。テストを書いて動かすのも禁止。使ってよいのはファイルを読むことと検索することだけである。人間がコードレビューをするのと同じ条件だ。
Part B(修正)で初めて実行を解禁し、編集部が再現を確認済みの欠陥5件の一覧を渡して直させた。ただし渡したのは症状と再現手順だけで、原因の場所は書いていない。特定は各モデルの仕事とした。
結論:前回3つのモデルが道具を使って攻撃したとき、3本とも検出できなかった欠陥がある。今回、道具を取り上げた3本は、その欠陥を全員が確信度「高」で指摘した。しかも1本は、前回の攻撃前にその欠陥を正確に予測していながら、それを試せない道具を自分で作っていた。逆に、ファイルを実際に壊してみないと見えない欠陥は3本とも見逃した。読むことと動かすことは、別のものを見ている。
実験の設計
渡したもの、隠したもの
3つのモデルには同一の指示書を渡した。前回の攻撃結果が入っている3つのディレクトリは参照禁止とし、参照した場合は正直に申告するよう求めた。3本とも「参照していない」と申告し、編集部が全ファイルを検索した結果、痕跡はなかった。
Part Bで渡した欠陥一覧は、次の5件である。
| 症状 | 破れる保証 | |
|---|---|---|
| 欠陥1 | クラッシュ後、保存成功と返したはずのデータが全部消え、ファイルが開けなくなる | 耐久性・原子性 |
| 欠陥2 | 読み取り中に、同じキーの値が途中で変わる | スナップショット分離 |
| 欠陥3 | 壊れたファイルを黙って受理し、間違った件数を返す | 破損検出 |
| 欠陥4 | 同じ壊し方で、異常終了またはハングする | 破損検出 |
| 欠陥5 | 書き込みを開いたまま保持すると、読める値が変わる | スナップショット分離 |
繰り返すが、渡したのは症状だけである。「どのファイルの、どの関数が原因か」は一切書いていない。
「実行禁止」を守らせる仕掛け
自己申告に頼る部分は残る。そこで指示書にこう書いた。
万一この制約を破った場合は、破ったことを冒頭に明記してください。破ったこと自体は罰しません。隠すことだけが問題です。
3本ともファイル冒頭に遵守を明記した。ビルド生成物のタイムスタンプでも確認したが、Part Aの間に実行された形跡はない。
数を稼がせないための仕掛け
読むだけのレビューは、「疑わしきは全部挙げる」という戦略が取れてしまう。数を撃てば当たる。それを封じるため、こう書いた。
**数を稼ごうとしないでください。**後で答え合わせをするので、実在しない指摘(誤検出)もそのまま数えます。自信を持って言えるものが1件しかないなら、1件でよいです。
結果として、指摘数はOpus 5が6件、Sonnet 5が2件、Fable 5が7件になった。このうち編集部が確認済みの5件に対応する指摘については、3本とも取り違えがなかった。「ここが原因だ」と指した場所は、すべて実際にその欠陥の原因だった。
ただし、確認済み5件の外側の指摘——Opus 5が1件、Fable 5が3件——について、編集部は再現を確認していない。誤検出かどうかは判定していない。以下の集計はすべて、確認済み5件に対する成績である。
「『当てた指摘が正しかった』と『挙げた指摘が全部正しかった』は別の話です。後者はまだ分かりません。ただ前者だけでも、人間のレビューと比べて悪くない」(カエデ)
発見1:道具が試せなかったものを、読むだけで3本とも当てた
先に、いちばん重い結果を書く。
欠陥5を、3本とも確信度「高」で指摘した。しかもOpus 5とSonnet 5は、それを指摘一覧の筆頭に置いている。
この欠陥は、前回の攻撃実験では3本とも検出できなかったものだ。クラッシュ注入も、1万試行のファジングも、1000ラウンドの並行実行探索も、ここを踏まなかった。
ただし「気づかなかった」わけではない。**Fable 5は、攻撃を始める前の予測にこれを書いていた。**しかも確度「高」で、原因の場所まで正確にである。
B-2:
WriteTxnがreadersレジストリに登録されない → 書き込みトランザクションの base_root がページ再利用で破壊される(予測: 破れる。確度高)「W1 が begin_write して放置 → W2/W3 がコミット → W1 が
get()を呼ぶ」ですでに再利用・上書きされたページを読む(Fable 5の攻撃前予測より)
そして自分の道具で、それを試せなかった。理由も自分で書き残している。
**検出できず。**道具が
WriteTxnを開いたまま放置して読み直す形の攻撃を持っていなかった。Targetトレイトに「書き込みトランザクションを開いたまま保持する」APIを入れなかった。アダプタ設計の穴。Phase 0で自分で書いた予測を、自分の道具が試せない形にしてしまった(Fable 5の開発日誌より)
これは編集部の当初の理解より、はるかに鋭い事例だった。道具の作者は、その欠陥を想像できていた。文章にも書いていた。そのうえで、それを試せない道具を作った。
欠陥5の中身はこうだ。書き込みトランザクションを開くと、その時点のデータの姿(スナップショット)を覚える。ところがその「覚えた姿」が、ページ回収の対象から保護されていなかった。開いたまま放置していると、足元のページが他のコミットに再利用され、読める値が変わってしまう。
編集部の検証プログラムでは、こう出た。
txn1 開始時点の k00042 = Some("orig-00042")
txn1 を開いたまま、他が 12 回コミットした
txn1 から再度読んだ k00042 = Some("round11-00042-padpadpadpad...")
同じキーを同じトランザクションから読んで、値が別物になっている。
「攻撃側が踏めなかった理由は、Fable自身の記録に書いてあります。**アダプタに『トランザクションを開いたまま保持する』APIがなかった。**道具の形が、試せることの範囲を先に決めてしまった」(ハルカ)
「予測には書いてあるのに、道具が試せない。知っていることと、検証できることが別々に管理されているわけですね」(ユウタ)
「読むだけのレビューには、その分離がありません。コードを見て『ここ、登録してないな』と言えば、それがそのまま成果物になる」(カエデ)
前回の記事で編集部は、道具を持たせた3本が「陰性対照を入れなければ壊れた道具のまま0件を報告していた」と書いた。今回はさらに手前の話が出たことになる。道具は、道具の作者が想定した動かし方の内側しか探さない。作者がその外側を想像できていた場合でも、である。
発見2:逆に、読むだけでは届かない層があった
欠陥4は、3本とも見逃した。
内容は、ファイル内の2つのページを入れ替えるという壊し方をしたとき、異常終了するかハングするというものだ。壊れたファイルは検出してエラーにしなければならないのに、スタックを食いつぶして落ちる。
欠陥3も似た系統で、同じ壊し方をしたとき黙って間違った件数を返す。こちらはOpus 5だけが指摘したが、**確信度は「低」**だった。6件の指摘のうち、最下位に置いている。
つまり両者を分けたのはモデルの優劣ではなく、欠陥の性質である。Sonnet 5自身がそれを自己申告していた。
Part Aで実行が禁止されていたため、実際にファイルを壊して結果を見ないと気づきにくかった(Sonnet 5の報告より)
「正直な申告だと思います。ただ、同じ制約下でOpusは届いている。確信度は低かったにせよ、指摘には挙げた。ここは差として認めるべきでしょう」(テツオ)
「性質としては分かりやすいですよ。欠陥5は『コードに書いていないこと』で、読めば分かる。欠陥3・4は『コードに書いてあることの帰結』で、動かさないと分からない。不在は目に入るが、帰結は追わないと見えない」(ミサ)
3本の的中を整理するとこうなる。
| 指摘数 | 5件中の的中 | 見逃し | 5件の外への指摘 | |
|---|---|---|---|---|
| Opus 5 | 6件 | 4件 | 欠陥4 | 1件 |
| Sonnet 5 | 2件 | 2件 | 欠陥1・3・4 | 0件 |
| Fable 5 | 7件 | 3件 | 欠陥3・4 | 3件 |
Opus 5とFable 5が5件の外に出した指摘は、fsyncが失敗したあとの後始末、メタ情報が壊れたときの代替読み込み、ファイル新規作成時に親ディレクトリを同期していないことなどである。**Fable 5はこれらを「確認済みリストの外なので未修正」と明記した。**指示にない範囲まで直してしまわず、境界を報告した形になる。
発見3:原因の特定は一致した。束ね方と防御は一致しなかった
Part Bで実行を解禁したあと、3本は独立に作業した。互いの成果は見ていない。
一致したのは、個々の原因の特定である。
- 欠陥5 — 3本とも「書き込みトランザクションのスナップショットが、ページ回収の勘定に入っていない」と特定した
- 欠陥3 — 3本とも「チェックサムがページの位置を束縛していない」と特定し、3本とも同じ手を打った。チェックサムの計算にページ番号を混ぜ、ページの中身をそのページがある場所に結びつける
ここまでは3本が別々に、同じ解に着地している。
一致しなかったのは、5件をどう束ねたかと、どこまで防御を足したかである。
| 5件の束ね方 | 欠陥4への追加防御 | |
|---|---|---|
| Opus 5 | 1・2・5 と 3・4 の2組 | 木をたどる深さに上限(MAX_TREE_DEPTH=64) |
| Sonnet 5 | 1と5が同根、3と4が同根、2は別 | なし(ページID検証だけで両方を解決) |
| Fable 5 | 欠陥1に2経路を置き、2・5も別々 | 深さ上限+チェーン長ガード |
Opus 5のコミットメッセージは、自分の整理を端的に表している。
欠陥 1/2/5 を修正: 「スナップショットの持ち主」を数え落とさないようにする 欠陥 3/4 を修正: checksum にページ番号を混ぜ、木の降下に上限を設ける
一方Sonnet 5は、欠陥3の修正だけで欠陥4も消えたと報告し、深さ上限を入れていない。ページの入れ替えを入口で弾けば、異常な深さまで潜ること自体が起きないという判断である。
またSonnet 5は、欠陥1と欠陥5が同一原因であることを実験で示した。欠陥5の修正を入れたり外したりして、欠陥1の再現が消えたり戻ったりすることを確認している。
「原因の特定が揃って、対策の深さが揃わない。ここは好みではなく、リスクの見積もりの差ですね。Sonnetの言い分は筋が通っていますが、入口の検証を突破された場合の二重防御はない」(ハルカ)
「編集部は当初、これを『3本とも同じ2つの根に畳んだ』と書きかけました。Opusの整理を3本共通の結論として一般化していたわけです」(テツオ)
編集部の検証結果は次のとおりである。3本すべてについて、既存テストの通過、テスト関数の増減、独立に書いた検証プログラム、統一ベンチマークを確認した。
| 既存テスト43件 | テストの削除 | 欠陥5の修正 | 性能 | |
|---|---|---|---|---|
| Opus 5 | 全通過 | なし | 確認 | 劣化なし |
| Sonnet 5 | 全通過 | なし(4件追加) | 確認 | 劣化なし |
| Fable 5 | 全通過 | なし(3件追加) | 確認 | 劣化なし |
「テストを削って通した」を疑うのは、この連載では定番の確認事項になった。3本ともその形跡はない。
Sonnet 5とFable 5は、再現手順をそのまま回帰テストとして残した。Opus 5は再現ハーネスを別コミットに置いたが、テストスイートには編入していない。将来の作業者に対する保証としては、前者のほうが強い。
Opus 5は自分から不都合を申告した。チェックサムの計算を変えたため、修正前に作られたファイルはもう開けない。しかも「フォーマットのバージョン番号は意図的に上げなかった」と書いている。判断の是非はともかく、隠していない。
発見4:直し方には、規律の差が出た
3本の修正を並べて読むと、ひとつだけ差が出た場所がある。
問題の形はこうだ。「いまのデータの姿」を確認してから、「私はこれを見ています」と登録するまでにロックを離してしまうと、その隙間に他のコミットが入り込める。入り込まれると、まだ見ている最中のページが回収されてしまう。この形のバグをTOCTOU(確認した時点と使う時点のずれ)と呼ぶ。
欠陥2は、まさにこれが読み取り側で起きたものだった。**3本とも、読み取り側は同じ形で正しく直した。**登録をロックの内側に移している。
問題は書き込み側である。
- Opus 5 — 2つの登録をどちらもロックの内側に置いた
- Fable 5 — 片方は内側に置いたが、もう片方は外側に残した
- Sonnet 5 — 両方とも外側に置いた
Sonnet 5は、欠陥5を直すために新しく追加した登録を、自分がたったいま読み取り側で潰したのと同じ位置に置いたことになる。自身のコミットメッセージにはこう書かれている。
Fix defect 2: TOCTOU race between begin_read’s meta capture and registration
読み取り側のTOCTOUを名指しで潰し、書き込み側で同じ形を作った。
「直し方を知っていたのに、隣の関数には適用しなかった。知識の問題ではなく、適用範囲の問題ですね」(カエデ)
「人間もやります。というか、これは人間のレビューでいちばんよく指摘する類のものです」(ハルカ)
ただし、**編集部はこの隙間から実害を再現できなかった。**隙間は数命令ぶんしかなく、そこに他スレッドのコミットが正確に着地する必要がある。スレッドの切り替え点を制御しない限り、狙って当てるのは難しい。
念のため書いておくと、**この隙間が狭いことは「実害がない」を意味しない。**同じ形の隙間が読み取り側にあったとき(欠陥2)、Fable 5は15秒間に3,630件の違反を観測している。狭い窓でも、回数を重ねれば踏まれる。
「理論上の窓であり、実証できなかった」——これが正確な言い方であって、「存在しない」ではない。
発見5:編集部が、存在しない差の原因を解説しかけた
ここからは、編集部自身の失敗である。
3本の修正を統一ベンチマークにかけたとき、ある数字が目を引いた。再試行回数である。書き込みが衝突したとき、やり直した回数を数えたものだ。
Opus 修正版 40,549
Sonnet 修正版 164,685
Fable 修正版 166,898
4倍の開きがある。編集部はここから筋書きを立てた。「Sonnet 5とFable 5の修正には、衝突を増やす副作用がある」。原因の仮説も3つ用意した。
測り直したら、差は消えた。
別のワークロード(8スレッド、共有キーへの読み・加算・書き戻し)で、順方向と逆方向に3回ずつ、計6回測った全試行値がこれだ。
| 順方向3回 | 逆方向3回 | 範囲 | |
|---|---|---|---|
| 原版(未修正) | 8,309 / 7,900 / 7,295 | 6,953 / 8,151 / 7,378 | 6,953 – 8,309 |
| Opus 修正版 | 10,806 / 10,992 / 11,249 | 8,021 / 9,413 / 7,555 | 7,555 – 11,249 |
| Sonnet 修正版 | 13,577 / 10,311 / 9,137 | 8,328 / 8,399 / 7,688 | 7,688 – 13,577 |
| Fable 修正版 | 13,151 / 9,974 / 8,781 | 11,883 / 9,456 / 8,759 | 8,759 – 13,151 |
**3本の範囲は互いに重なり合い、測定順序を逆にすると順位が入れ替わった。**最初の数字は5試行の合計値で、変動が増幅されていたと見られる。
ここで言えるのは「この条件・この試行数では、3本の間に差があるという証拠が得られなかった」までである。差が存在しないことを示したわけではない。試行数も分散の推定も、そこまでの主張を支えるには足りない。
修正版がそろって原版より高めに出ている点も、この6回では確かなことが言えない。編集部はここを追わずに打ち切った。
「危なかったですね。存在しない現象の原因を、3本分もっともらしく解説した記事になるところでした」(テツオ)
「この連載がずっと言ってきたことが、そのまま書き手に返ってきた形です。自分で設計した検証は、自分の想定の内側しか見ない」(ミサ)
さらに悪いことに、編集部は原因を追う過程で同じ穴に2回落ちた。
「登録の隙間から実害が出るか」を調べるため、検証プログラムを2つ書いた。どちらも「異常0件」と出た。結論に使いかけたところで、原版——欠陥があると分かっている実装——に当ててみた。
原版でも0件だった。
つまり、壊れていると分かっているものを壊れていると言えない計測器だったことになる。撹拌の量が足りず、感度がなかった。条件を上げて作り直したところ、原版で300回中9回、番人として置いたキーが消えるのを検出した。この状態で3本の修正版を測って、ようやく「0件」に意味が生まれた。
前回の記事で編集部はこう書いている。「わざとバグを仕込んで、検出器が本当に動くかを確かめろ」。それを書いた当人が、次の記事でそれを怠った。
「2回とも無意味な0件を出して、しかも1回目はそれを『実証できなかった』という結論にしかけている。手順を知っていることと、手順を実行することは違う」(カエデ)
「Sonnet 5が隣の関数に規律を適用しなかったのと、構造は同じですよね」(ユウタ)
読むことと動かすことは、別のものを見ている
今回の実験でいちばんはっきりしたのは、モデル間の優劣ではない。
同じ3つのモデルが、同じ実装に対して、道具を持ったときと持たなかったときで、違うものを見つけたという事実である。
| 道具を持って攻撃(前回) | 読むだけでレビュー(今回) | |
|---|---|---|
| 欠陥5(登録の欠落) | 3本とも検出できず(1本は予測済み) | 3本とも確信度「高」で的中 |
| 欠陥4(破損でハング) | 検出 | 3本とも見逃し |
道具は、道具の作者が想定した動かし方の内側しか探さない。読むほうは、コードに書かれていないこと——登録し忘れ、保護のかけ忘れ——が目に入る。かわりに、書かれていることの帰結——このまま再帰したら何段まで行くのか——は追わない。
「『AIにコードレビューさせる』と『AIにテストを書かせる』は、代替関係ではなく補完関係だということですね。片方で足りるという話ではない」(ハルカ)
「実務への持ち帰りとしては、順番が示唆的です。**読ませてから、動かさせる。**今回のPart A→Part Bの順で、実行を許す前に5件中4件の原因に当たりをつけた本がいました」(ユウタ)
そして最後に、書き手自身の教訓を残しておく。
測定値を根拠に何かを主張する前に、2回測る。順序を変えて測る。そして検出器を信じる前に、壊れていると分かっているものを食わせてみる。
このどちらも、編集部は前回の記事に書いていた。書いたうえで、今回守らなかった。
連載5回を通して
| 回 | やったこと | 出てきたもの |
|---|---|---|
| 第1回 | Opus 5に麻雀アプリを作らせる | 失敗の大半はテスト側の思い込みだった |
| 第2回 | 同じ仕様書でSonnet 5と比較 | 差はモデルより、指示の書き方に出た |
| 第3回 | 2モデルにKVストアを作らせredbと勝負 | 数値目標を先に凍結すると達成率が変わる |
| 第4回 | 3モデルに互いの実装を攻撃させる | 「自分のバグは自分では見つけられない」は否定された |
| 第5回 | 3モデルに読ませてから直させる | 道具の形が、試せることの範囲を先に決めていた |
5回を通じて繰り返し出てきたのは、検証の設計が結果を決めるということだ。モデルの能力差より、何を許し何を禁じたかのほうが、出てくるものを大きく左右する。
今回それは、記事を書いている側にも等しく効いた。
キーワード:#Claude Opus 5#コードレビュー#静的レビュー#TOCTOU#クラッシュ整合性
コメント
まだコメントはありません。