この記事は、連載「確かめない技術」の第 2 回です。うまくいかない症状と、その対処を一つずつ並べています。各回はファイルやスクリプトを一つ置けば完結します。その仕組みがなぜ要るのかは、置いたあとに解説を読めば分かります。連載の全体像と各回の一覧は序論にあります。

今回は、宣言の話です。宣言というのは、撮影やスモーク(AIがブラウザを自動で動かして、スクリーンショットを撮ったり、ページを一通り操作したりして、返ってきた画面で確かめること)を実行する前に、何を検証したいのかと、なぜ安い手段(grepや単体テスト)では済まないのかの 2 問への答えを、AIに文章で書かせることです。前回見たとおり、AIは、安い手段で済むことまで、1 枚に 30〜100 秒かかる撮影で確かめていました。2 問を書かせるのは、撮影のたびに、安い手段で済むかを比べる工程を入れるためです。私は、AIに宣言を書かせる指示文を置いたあと、AIが宣言を書かずに走らせた実行が何回あったのかを、一度も数えることができませんでした。

まず一つだけ、探してみてください。

直近のセッションで、AIが撮影やスモークを走らせた実行を 1 つ選びます。

その実行の直前に、AIがなぜその実行が要るのかを書いた文は、どこかに残っているでしょうか。

前回置いたのは、撮影の前に、Q1(検証したい問題)とQ2(なぜ安い手段で済まないか)をAIに書かせる指示文でした。前回の実例では、AIはQ2を書くことができずに、撮影の手前で止まりました。残る課題は一つです。前回書いたとおり、AIが読んだルールも、従われるとは限りません。AIが宣言を書く工程を飛ばして撮影を実行すると、その実行はそのまま走ります。こうして走った実行は記録に残らないので、それが何回起きているのかは、私の推測のままでした。

あなたも、撮影の前に宣言を書かせる指示文を置いていたとします。直近のセッションでAIが走らせた撮影のうち、宣言を書かずに走った撮影が何回あったのかは、分かりませんでした。このとき、あなたなら次のどちらを選びますか。

  • 撮影のたびに、AIが書いた宣言を読んで確かめる
  • 宣言を書いた形跡が無ければ、撮影の前に割り込む仕組みで実行を止め、その判定を 1 回ずつ記録に残す

答えは、止めて記録に残すほうです。回数が分からなかったのは、AIが宣言を書く工程を飛ばしても撮影がそのまま走り、その撮影がどこにも記録されなかったからです。宣言を読んで確かめることができるのは、AIが宣言を書いた撮影だけで、宣言を書かずに走った撮影には、読む宣言が残っていません。形跡が無い実行を止めれば、仕組みが拾った撮影は、宣言を書く工程を飛ばしては走りません。判定を 1 回ずつ記録すれば、宣言を書かずに撮影しようとした回数を、止めた記録から数えることができます。手元の記録では、4 日間で仕組みが判定した 22 回のうち、宣言を書かずに撮影しようとして止まった実行は 0 回でした。

理解したことは一文にできます。

宣言は、AIが書いたときにだけ働く。だから、AIが宣言を書いた形跡を、撮影やスモークを実行する前提にする。

CC BY 4.0

仕組み: 宣言を書いた形跡を、実行の前提にする

置いたのは、PreToolUseのhook(AIがコマンドを実行する直前に割り込む仕組み)1 本です(手元の運用からの簡約版)。

# 撮影・スモークの入口だけを拾う(SKIP_VISUAL_GUARD=1 は明示の逃げ道として通す)
case "$command" in *SKIP_VISUAL_GUARD=*) exit 0 ;; esac
printf '%s' "$command" | grep -qE 'screenshot\.sh|smoke_[a-z_]+\.js' || exit 0

# 1. 宣言ファイルが無ければ動けない
[ -f tmp/visual_verification.md ] || { guide; exit 2; }

# 2. 禁句は機械的に弾く(プログラムに決められるのはここだけ。禁句は自分が書く言語のものに置き換える)
grep -qE '念のため|とりあえず|確認のため' tmp/visual_verification.md && exit 2

# 3. Q1 / Q2 が 1 文ずつ埋まっているか(空欄・テンプレのままを弾く)
# 4. 鮮度 30 分(前の検証の宣言の使い回しを弾く)

hookに任せる判断の線引きは、前回と同じです。hookに任せるのは、プログラムが真偽を決めることのできる判断だけです。「単体テストで済むか」の真偽は、プログラムには決めることができないので、hookは宣言の中身の妥当性を測りません。hookは、AIが実行より先に宣言を書いた形跡だけを測ります。前回の言い方では、宣言は、ルール(安い手段で済む検証を、撮影で確かめない)を読んだAIに、画面で確かめる直前に、ルールに照らした理由を書かせる打ち手で、hookはルールそのものをなくす打ち手でした。hookでルールを破る操作を止めれば、AIがそのルールを読む必要も、覚えておく必要もなくなるからです。このhookでなくしたルールは、「宣言を書いてから実行する」の 1 行だけです。宣言を書かせる打ち手は、そのまま続けています。前回に書いたとおり、弾くことができるのは、禁句(「念のため」「確認のため」のように、どの実行の前にも書くことのできる理由の言葉)として一覧に載せた言葉だけです。AIが別の言葉に言い換えた理由は、hookの禁句の判定も通ってしまいます。

このhookが拾うのは、拾う対象としてhookに書いた入口(撮影・スモークを始めるスクリプト)の名前だけです。手元では、私がhookに書いたのは、ラッパーの内側で動くスクリプトのファイル名でした。

私がふだん使っているのは、そのスクリプトを呼び出す 14 本のラッパーのほうです。hookは、置いた日から、この 14 本のラッパーをすべて判定なしで通しています。撮影やスモークの入口を新しく足したら、その名前をhookの側にも足します。hookは、名前の取り違えも、足し忘れも、何も言わずに通すからです。

実行を止めるときは、置き換え先を案内文に書く

hookを作るときに、私は、撮影の実行を止めるだけでは足りないと考えました。hookが実行を止めたとき、AIのTODO(作業の予定の一覧)には「スクリーンショットで確認」が積まれています。止めた理由だけを返すと、そのTODOをどう片付けるかは、AIがその場で決めることになります。

だから私は、hookが実行を止めたときに出す案内文に、置き換え先を書きました。「TODO の『撮影で確認』を『grep / 単体テストで確認』に書き換えて消化する。本当に画面が要るなら Q1/Q2 を書いて実行し直す」。1 作目の第 8 回では、セッションの始めに読ませた方針があっても、AIは失敗した直後に同じやり方でもう一度試しました。AIが方針に従ったのは、もう一度試そうとしたところで止めて、その場で方針を読ませたときでした。止めたときに出す案内文は、AIが次の操作を決める前に届きます。だから、実行を止める仕組みを置くなら、その案内文に、AIが次にすることを書いておきます。

実測: 導入した日に、hookの問題が 2 つ見つかった

1 件目は、自動テストが見つけた、hook自身のバグです。bashの ${#var} は、ロケールによっては、文字数の代わりにバイト数を返します。「見たい」は 3 文字ですが、9 バイトです。hookは「見たい」とだけ書いた宣言を 9 と数えたので、この 3 文字の手抜きの宣言が、「8 文字未満は空欄扱い」の判定を通っていました。このバグが見つかったのは、空欄の判定を確かめる項目を、自動テストに書いていたからです。私は、hookが文字数をpython3で数えるように直しました。

2 件目は、hookが、導入した私自身を止めたことです。私がhookの動作を確かめるために打ったコマンドが、screenshot.sh という文字列を含んでいただけで止まりました。撮影を実行するコマンドではないので、偽陽性です。私は、案内文に書いておいた逃げ道の環境変数を付けて、hookを通ることができました。大事なのは、こうして止まったその場で読む案内文に、逃げ道が書いてあることです。

hookは、実行を止める仕組みであり、実行を数える仕組みでもあります。hookは、判定のたびに、記録ファイル(日時・止めた理由・コマンドの先頭 120 字)へ 1 行を書き足します。これで、前回まで推測のままだった「宣言なしの実行が何回あるか」を、hookが拾う入口については、私は記録から数えることができるようになりました。4 日間で、hookがコマンドを判定したのは 22 回で、そのうち止めたのは 7 回です。7 回とも、2 件目と同じ偽陽性でした。撮影以外のコマンドが、文字列に入口の名前を含んでいただけです。そのうち 2 件は、宣言ファイルを書くコマンドでした。この 2 件の記録には、止めた理由として「前の宣言の使い回し」が残っています。これは、止めた判定(鮮度)の名前です。この 4 日間の記録では、宣言なしの撮影も、禁句を書いた宣言も、0 回でした。

CC BY 4.0 はここまで

読まなくなったもの

宣言の中身です。AIが書いた「なぜ確かめるか」を、私は一度も読まずに済ませています。

宣言が働くのは、AIが宣言を書くときです。書かせると、安い手段で済むかを比べる工程が、撮影の前に入ります。私が書かれた宣言を読まなくても、この工程は変わりません。

hookは宣言の中身の妥当性を測らない、と決めたときに、宣言を読んで良し悪しを決める役も、私はなくしました。

私が読むのは、記録のうち、hookがコマンドを止めたときの行(いつ・何を止めたか)だけです。それでも、hookが拾う入口から走った撮影やスモークには、どれも、AIが書いた理由が先にあります。AIが宣言を書いていなければ、hookが実行を止めるからです。

CC BY 4.0

注意: hookが測るのは、宣言を書いた形跡だけです

このhookは、正しくない宣言も通します。hookが宣言を通す条件は、Q1とQ2にそれらしい文が 1 文ずつ入っていること、禁句が無いこと、30 分以内に書かれていることの 3 つだけです。たとえば、Q2に書いた「単体テストからは呼ぶことのできない画面なので」という 1 文も、この条件を満たします。それが本当かどうかは、プログラムには決めることができません。

私は、この抜け穴を残しています。塞ぐには、宣言の中身の妥当性を判定する仕組みを、もう一段作ることになります。その判定も、結局は誰かの判断です。hookで手に入れたのは、AIが実行より先に宣言を書いたという形跡だけです。宣言に書いた判断そのものの質は、前回と同じく、AIの側に残っています。

検証手順: 止まるべきものが止まり、通るべきものが通る

前提

  • このhookを登録し、宣言ファイルの置き場と禁句の一覧を決め終えていること
  • 撮影のコマンドが手元にあること

所要時間: 20 分(下の 6 通りに、撮影でないコマンドが通ること、スモークも止まること、禁句の判定を消すと 2 が通ることの 3 つを足して、9 項目の自動テストにするなら、もう少しかかります)

手順

下の 6 通りを 1 つずつAIに渡して、hookの反応を控えます。止まるはずの 4 つと、通るはずの 2 つを、一度に確かめます。

#何を渡すか期待する結果
1宣言ファイルを置かずに撮影のコマンドを実行させる止まる。案内に「Q1 と Q2 を書け」と、書き換え先(撮影 → grep / 単体テスト)が出る
2Q2 に禁句だけを書いた宣言(「念のため」)止まる。どの語が禁句かが出る
3Q1 か Q2 が空の宣言止まる
4古い宣言(前の作業のものを残したまま)止まる。いつの宣言かが出る
5Q1 と Q2 が具体的に埋まった宣言通る(撮影が走る)
6案内文に書いておいた逃げ道(環境変数など)通る

合格条件(すべて満たすこと)

  • 1〜4 が、期待する結果のとおりに止まる
  • 5 と 6 が、止まらずに通る
  • 禁句の判定の 1 行をhookから消すと、2 が通ってしまい、2 を確かめている自動テストの項目が失敗する

合格しなかったとき

  • 5 と 6 が止まった —— このhookは「撮影を全部止めるだけのもの」になっています。止まることだけを確かめる自動テストは、hookがすべてを止める壊れ方をしても合格します。1 作目の第 6 回で見た、一度も落ちたことのない自動テストと同じ落とし穴です
  • 判定を 1 行消しても、自動テストが合格したまま —— その項目は、置いた日から、判定があってもなくても合格していたことになります

後始末

  • 消した判定の 1 行を戻し、置いた宣言ファイルを片付けます

CC BY 4.0 はここまで

次回は「テストを再実行しない技術」。私は、確認のためにテストを走らせ直すのをやめました。 それでも、いま通っているかどうかは毎回わかります。