この記事で扱うこと
AI に同じ作業を二回頼んでみると、面白いことが起きます。依頼は同じなのに、コードが毎回違うのです。昨日は通っていたやり方が今日は微妙に違う形で出てきますし、同じ会話の中でさえ、さっき交わした約束と違うコードを出してくることがあります。
これを 非決定論的(non-deterministic) と呼びます。同じ入力を入れても出力が毎回同じである保証がない、という意味です。逆に同じ入力なら必ず同じ出力になるものを 決定論的(deterministic) と言います。
問題は検査です。毎回変わるものを人の目だけで検査すると、人も毎回違うものを見落とします。そこで私は逆の戦略をとります。
非決定論的な生産者(AI)はそのままにして、検査する側をすべて決定論的な装置に置き換えます。 そして検査がうまく働くように、作業を細かく分けます。
この記事では、私が使っている frontend-oracle-design というワークフローがこの戦略をどう実装しているのかを、段階ごとにひとつずつほどいていきます。哲学的な背景は 実装より先に正解の基準を固定しましょう で扱ったので、この記事は 実際の手順がどう回るのか に集中します。
まず: 料理人は毎回違うが、温度計はいつも同じ
ひとつの比喩から始めます。
腕のいい料理人を雇ったのですが、この料理人は 同じ注文でも毎回少しずつ違うもの を作ります。ある日は素晴らしく、ある日は味付けがおかしい。横で調理過程を見ていても制御はできません。過程が毎回違うからです。
ではどうするかというと、厨房に計器を設置します。
- 肉の中心温度は温度計が測る — 毎回同じ基準で
- 焼き時間はタイマーが測る — 毎回同じ基準で
- 材料の量は秤が測る — 毎回同じ基準で
料理人がどんな順番で何をしようと、皿が出る前に計器をすべて通過しなければならない。計器には機嫌がありません。同じ皿を二度測れば二度とも同じ値が出ます。これが決定論的な検証です。
コードに戻ると、計器はこんな形をしています。
| 計器 | コードでは | 何を捕まえるか |
|---|---|---|
| 温度計 | 型チェック (tsc) | 型の合わないコード、存在しないフィールドへのアクセス |
| 秤 | リント (eslint) | 禁止されたパターン、危険なイディオム |
| タイマー | テスト (vitest, Playwright) | 要件と異なる挙動 |
| 検印 | 構造チェックのスクリプト | 正解表の形式違反、順序違反 |
| 封印シール | ハッシュ(SHA-256)ロック | 合意した基準がこっそり変わること |
ここで大事な点がひとつあります。計器ひとつでは足りません。 温度計は塩辛さを捕まえられませんし、秤は生焼けを捕まえられません。検証器ごとに捕まえる欠陥の種類が違うので、何層も重ねてはじめて穴が埋まります。安全工学ではこれを スイスチーズモデル と呼びます — チーズ一枚には穴がありますが、何枚も重ねると穴が貫通する確率がぐっと下がる、という話です。
ワークフロー全体の地図
まず手順を一望します。大きく 設計(Design) と 提供(Delivery) の二区間です。
[設計区間]
1. 調査 — 正解を決められる出典を固定する
2. 質問 — 曖昧な境界を先に聞く (BVA)
3. 正解表 — Oracle Card で要件を行単位に書く
4. ロック — スクリプトで検査し、ハッシュで封印する
[提供区間]
5. 赤 — テストを先に書き「意図した理由で」失敗させる (VALID_RED)
6. 緑 — 最小実装で通す (GREEN)
7. 訓練 — わざとバグを仕込み、テストが捕まえるか確認する (ミューテーションテスト)
8. 再審 — 独立したレビュアーがカードだけを持って再検査する
そして全区間を貫くルールが二つあります。観察ごとに原因をひとつだけ分類するルーティングと、再試行回数の上限(予算) です。ひとつずつ見ていきます。
段階 1 — 調査: 裁判の前に証拠を採用する
最初の段階はコードを読むことではなく、「正解を決める権限がどこにあるか」 を固定することです。
裁判を思い浮かべてください。本格的な攻防の前に どの証拠を採用するか を決めますよね。採用されなかった資料は、どれだけもっともらしくても判決には使えません。ここでも同じことをします。
- 採用されるもの: ユーザーの明示的な回答、承認された企画書・仕様・デザインの正確な位置
- 採用されないもの: 今動いているプロダクトコード、既存のテスト、「最近はみんなこうしてますよ」的なベストプラクティス
今動いているコードは「今何が起きているか」を教えてくれるだけで、「何が正しいか」は教えてくれません。AI はリポジトリを全部読めるので、現在の挙動を要件だと勘違いする事故 をごく自然に起こします。バグが仕様に昇格する瞬間です。
出典どうしが矛盾したり、必須の資料にアクセスできない場合は? もっともらしい方を選ばず、NEEDS_DECISION(要判断)の状態で止まって人に聞きます。 この「止まる」がワークフロー全体で何度も出てきます。
段階 2 — 質問: 保険の特約のように境界を先に書く
出典が決まったら、次は 曖昧な境界 を探して先に聞きます。
保険契約に似ています。「怪我をしたら保障します」という文は聞こえがいいですが、実際の紛争はすべて境界で起きます。通勤中は対象ですか? 自転車は? だから良い契約書は 特約として境界事例を先に文章化 します。
要件も同じです。「一覧を表示してください」は簡単ですが、事故は境界で起きます。
- 一覧が 0件 なら? 1件 なら? 1万件 なら?
- リクエストが 失敗 したら? 5秒かかったら?
- 同じボタンを 連打 したら?
このように値の境界を中心にケースを取り出す手法を 境界値分析(BVA, Boundary Value Analysis) と言います。バグが値の範囲の真ん中より 端で圧倒的に多く出る という古くからの観察に基づいた手法です。
この段階の質問にも上限があります — 最大2ラウンド。 無限に聞いていては進まないので、2ラウンドの中で結果を変える質問だけを選んで聞きます。
段階 3 — 正解表: 採点基準表を先に作る
答えが集まったら Oracle Card という表にまとめます。試験でいえば答案ではなく、採点基準表(ルーブリック) を先に作るということです。
| # | この状況で (Given) | こうすると (When) | こうなるべき (Then) | 絶対に起きてはいけない (Never) | 出典 |
| --- | ------------------- | ----------------- | ------------------- | ------------------------------ | ------------ |
| O1 | 一覧が空 | ページに入る | 案内文言を表示 | スピナーが回り続ける状態 | 企画書 3.2節 |
| O2 | リクエストが5秒超 | 自動キャンセル | 再試行ボタンを表示 | 無限待機 | ユーザー回答 |
| O3 | 同じ項目を連続click | 二回目以降は無視 | リクエストは1回だけ | 重複リクエスト | ユーザー回答 |
各行が後でテストひとつになります。そして 出典欄が空の行は進められません。 出典のない行を「普通はこうするから」で埋めた瞬間、検証は推測に変わります。
表を書き終えたら、自分で一度攻撃してみます。「この表を満たしたまま、それでも間違っている実装はあり得るか?」を問う 敵対的セルフレビュー です。あり得るなら行が足りていないので、追加します。
段階 4 — ロック: 契約書に公証をもらう
ここから決定論的な装置が本格的に登場します。人が書いた表を 機械が検査して封印 します。
- 構造チェック — スクリプト(
oracle-verify)が表の形式を検査します。出典のない行、検証不可能な文などを機械的に弾きます。人が「まあ大丈夫そう」で流すものを、機械は流しません。 - ハッシュ封印 — 表ファイルの SHA-256 ハッシュを記録します。ハッシュは内容が一文字変わっただけで全く別物になる指紋のようなものなので、以後 各段階の開始前に指紋を照合 すれば、表がこっそり変わったかどうかが即座にわかります。
紙の契約書で 公証を受けて契印を押すこと と同じです。契印(ページにまたがって押す印)があれば、後からページをすり替えるのが不可能になりますよね。
指紋が合わなかったらどうするか — ロックし直して通すことはしません。 それまで集めた証拠をすべて破棄して段階1に戻ります。検証を通すために検証基準を変えたら、それはもう検証ではないからです。
なぜここまでするかというと、非決定論的な作業者は 長い作業の途中で基準そのものを微妙に再解釈する ことが多いからです。人間もそうです。ハッシュはその再解釈を物理的に遮断します。
段階 5 — 赤: 火災報知器からテストする
ここでようやくテストを書きます。順番が大事です — プロダクトコードよりテストが先 です。
表の各行をテストに移し、実行して 意図した理由で失敗するか をまず確認します。この状態を VALID_RED と呼びます。
新しい火災報知器を取り付けたとしましょう。取り付けた直後にすることは テストボタンを押すこと ですよね。火事のときに鳴るか確認もしていない報知器を信じて眠るわけにはいきません。VALID_RED がまさにそのテストボタンです。まだ実装がないのでテストは当然失敗すべきで、失敗の理由が「機能がないから」 であることを確認します。タイプミスや設定の問題で赤いなら、報知器自体が壊れているわけです。
この段階で決定論を守る細かいルールがいくつかあります。
- 実行の記録は台帳(ledger)に残ります。 テスト実行は専用スクリプトを経由し、結果が追記専用(append-only)の台帳に書かれます。報告するときは「通りました」ではなく 台帳の実行番号を引用 します。台帳にない実行は通過と認めません。言葉は非決定論的ですが、台帳は決定論的だからです。
- 状態遷移もスクリプトが審判です。 「テストなしで実装から始めていないか」「失敗するテストをこっそり skip に切り替えていないか」といった順序違反を遷移スクリプトが検査し、違反なら遷移そのものを拒否します。サッカーの審判が記録用紙を持っているのと同じです — 選手が「さっき点を入れました」と言い張っても、記録用紙になければゴールではありません。
- ネットワークは MSW で塞ぎます。 本物のサーバーは応答が毎回変わりうる非決定論の塊なので、リクエストを途中で捕まえて決まった応答を返す道具(MSW)に置き換えます。検証対象ではない場所の非決定論を取り除くわけです。
段階 6 — 緑: 最小限だけ実装する
赤を確認したら実装します。目標は ロックされた表を通す最小限のコード です。表にない機能を足したり、「後のための」抽象を作ったりはしません。
通らなければ直して再試行しますが、ここにも予算があります — 実装改善は最大3ラウンド。 そしてこの回数を頭の中で数えず、スクリプトが数えます。 「今何回目だったっけ?」を非決定論的な作業者に任せると、必ず甘く数えられるからです。
予算を使い切っても赤なら、最後の失敗内容とともに 正直に失敗として報告して止まります。 「通るまでやってみます」よりこの方が速いですし、何より 失敗がデータとして残ります。
段階 7 — 訓練: わざと火を出してみる
リスクの高い作業(決済、権限など)にはもう一層重ねます。ミューテーションテスト(mutation testing) です。
実装コードに わざと小さなバグを仕込みます。 < を <= に変えるとか、条件をひとつ反転させるとか。そしてテストを走らせて 赤が点くか を見ます。点けばテストがその種のバグを捕まえられる証拠であり(変異を「殺した」と表現します)、点かなければテストに穴があるということです。確認後はバグを元に戻し、再び緑を確認します。
消防避難訓練と同じです。本当の火事の前に 偽の状況を作って、警報と避難手順が機能するかを確認する わけです。「報知器を付けたから大丈夫」と「実際に鳴るのを確認した」は、まったく違うレベルの確信です。
段階 8 — 再審: 別の審判にカードだけを渡す
最後に 独立したレビュアー を付けます。ポイントは「独立」です。
自分の書いたコードを自分でレビューすると、同じ思い込みを繰り返します。自分の文章の誤字を自分では見つけられないのと同じです。だからメイン作業と 分離されたセッション のレビュアーに、正解表と成果物だけ を渡します。「これを作るのに苦労した経緯」は渡しません。経緯を聞くと、レビュアーも一緒に説得されてしまうからです。
レビュアーの指摘は、そのまま直すのではなく次節のルーティング表でまず分類します。そして有効な指摘を反映したうえで テスト全体を再実行して 緑を再確認します。この再検証まで終わった状態(REVIEW_VERIFIED)が、このワークフローの「完了」です。テストが通ることは完了ではありません — 完了をレビューの後ろに置けば、テストを緩めて得をする理由がなくなります。
全区間共通 — 救急のトリアージ表と目覚まし時計
上の段階の合間で何かおかしいものが見つかったとき、反射的にコードを直さず まず分類 します。救急外来のトリアージ(重症度分類)と同じです。来た順に治療するのではなく、症状を見て決められた経路に送ります。
| 分類 | どういう意味か | 進む経路 |
|---|---|---|
POLICY_GAP | 正解そのものが未定 | 止まって人に質問 |
EVIDENCE_GAP | 正解はあるが確認していない | ロック範囲内でテストだけ追加 |
HARNESS_DEFECT | テスト道具の問題 | 道具だけ補正 (予算2回) |
PRODUCT_DEFECT | 本物のバグ | 赤を確認してから実装を修正 (予算3回) |
ENVIRONMENT_DEFECT | 環境の問題 | コードに触れず失敗を報告 |
NON_ORACLE_OPINION | 根拠のない好み | 記録のみ、完了を妨げない |
この分類がないと「テストが赤くなったから、とりあえずプロダクトコードを直す」という反射行動が出ます。赤の理由が実は道具の問題だったのに、正常なコードを直してしまう事故は、思ったより頻繁に起きます。
そして各経路の予算(質問2ラウンド、道具補正2回、実装3ラウンド)は 互いに融通できず、スクリプトが数えます。 目覚まし時計と同じです。「あと5分」を自分の意志に任せると必ず寝坊するので、機械に任せるわけです。
モジュール化 — 検問所が働くには荷物が小さくなければならない
ここまでが検問所の話でした。次は なぜ作業を細かく分けるのか です。結論から言うと、検問所は荷物が小さいときだけまともに働きます。
通関検査を思い浮かべてください
ひとつのコンテナに電子機器も食品も衣類も全部混ざって入ってきたら、検査官は何をどう検査すればいいでしょうか。開けているうちに疲れて「まあ大丈夫そうです」になります。逆に 品目ごとに箱を分けて 送れば、箱ごとにその品目の検査基準を正確に適用できます。
コードも同じです。500行の変更(diff)はレビュアー(人であれ機械であれ)を疲れさせ、テストが落ちても どの部分のせいか 特定できません。50行の変更が10個あれば、それぞれが明確に判定されます。先ほど作った Oracle Card がまさにこの「箱」です。カード1枚 = 小さな契約1つ = 検査可能な単位1つ。
すべてのモジュールを最初から作るわけではない
ここに本当に大事な原則が出てきます。細かく分けるということは、すべての断片を毎回新しく作り、毎回最初から検査するという意味ではありません。
レゴを思い浮かべてください。宇宙船を作るのに、ブロックの射出成形から始める人はいませんよね。すでに規格が検証されたブロック を持ってきて組み立て、新しく設計するのは今回の作品にだけ必要な特殊パーツ数個だけです。検査もその新しいパーツに集中すればよく、既存のブロックは規格(公開 API)が合うか確認すれば終わりです。
ワークフローでも同じことをします。
- 既存のコンポーネント・ユーティリティ・パターンをまず探します。 リポジトリにすでにあるものを作り直すのが AI コードの最も多い失敗なので、新しく書く前に検索から始めます。すでにあるコードは すでに検査を通ったブロック です。
- 検証パイプラインも再利用します。 型チェック、リント、テストランナー、CI — すでに設置されている検問所をそのまま使います。作業ごとに新しい検証ツールを発明しません。新しく作るのは、今回の作業の正解表とそのテストだけです。
- 今解くべき問題にだけ集中します。 「後で使うかもしれない」抽象や設定は作りません。それは検査する契約(カード)のないコードなので、検問所をそのまま素通りしてしまう荷物だからです。
分けた断片は所有境界と一緒に生きる
分けた断片をどこに置くかにもルールがあります。テストと偽サーバー応答(MSW ハンドラ)は その機能を所有するフォルダの隣 に置きます。ルートに e2e/ や mocks/ フォルダを作って全部集めると楽に見えますが、後で機能を消すときにテストが一緒に消えず、誰のものかわからない死んだ検査 が溜まります。機能とテストが 一緒に生まれて一緒に死ぬ ようにしておくことが、モジュール化の完成です。
まとめ — 戦略は一文です
出力が毎回違う生産者を制御するには、生産者を直そうとせず、判定する側をすべて機械に置き換え、判定できる大きさに仕事を分ける。
段階ごとに縮めるとこうなります。
| 段階 | やること | 決定論的な装置 |
|---|---|---|
| 1. 調査 | 正解の出典を固定 | 出典のない正解を禁じるルール |
| 2. 質問 | 境界値を先に文章化 | BVA、質問予算2ラウンド |
| 3. 正解表 | 行単位の採点基準表 | Given/When/Then/Never + 出典 |
| 4. ロック | 表を機械検査・封印 | 構造チェックスクリプト、SHA-256 |
| 5. 赤 | テスト先行、失敗を確認 | VALID_RED、実行台帳 |
| 6. 緑 | 最小実装 | 遷移スクリプト、予算3ラウンド |
| 7. 訓練 | 偽バグでテストを検証 | ミューテーションテスト |
| 8. 再審 | 独立レビュー + 再検証 | カードだけ渡す分離セッション |
そしてモジュール化は、この表全体が機能するための前提条件です。検査単位が小さいほど各装置の判定が鮮明になり、すでに検証されたブロックを再利用するほど 新たに検査すべき面積 が減るからです。
AI がもっと良くなれば、こうした手順は不要になるでしょうか。私は逆だと思っています。生産速度が上がるほど、間違ったものも速く、たくさん 作られます。そうなるほど、機嫌のない計器がより必要になります。