- ブラウザURL入力
- DNS名前→IP
- ルータ経路選択
- ロードバランサ振り分け
- Nginx受け口
- アプリ処理今ここ
- DB保存
前回は、時々落ちるテストには書かれていない入力があることを見ました。今回比べる相手は、直す前に用意する判定手順そのものです。
まず結論
「壊れていない」は何と比べたかでしか言えません。「直った」も同じで、比べる相手は、直す前に「壊れている」と答えた判定手順です。それが無いまま直すと、直ったかどうかを何とも比べられません。
その判定手順は、答えを3つ持つ必要があります。追っている失敗が出た・出なかった・別の理由で判定できなかった。3つ目を「壊れている」に混ぜると、道具はもっともらしい別の答えを返してきます。
`git bisect run` に渡した判定スクリプトの中でコマンド名を打ち間違え、シェルが終了コード127(command not found)で終わりました。git はそのコミットをどう扱うでしょうか。
当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。
「直った」は、何と比べて言うのか
このコースの芯は「『壊れていない』は、何と比べたかでしか言えない」です。直すときも同じで、「直った」と言うには比べる相手が要ります。直す前のコードで「壊れている」と答え、直した後で「壊れていない」と答える判定手順です。直す前に一度も「壊れている」と答えたことのない手順が、直した後に「壊れていない」と答えても、何も言っていません。
この回では、失敗を意図して起こすことを不具合の再現と呼びます。他の回で使っている「実測の再現性」(同じ測定で同じ値が出ること)とは別の意味です。
debuggingbook の入門章「Introduction to Debugging」は、デバッグを仮説・予測・実験の手順として書いています。ただし、この章に reproduce(再現)の語は出てきません。
再現を手順として書いているのは、同じ本の入力を縮める章(DeltaDebugger)です。縮め始める前に check_reproducibility で同じ呼び出しをもう一度実行し、結果が UNRESOLVED なら FailureNotReproducedError、PASS なら NotFailingError を投げます。FAIL が出なければ先へ進みません。
その判定は3値です。
Return * PASS if no exception occurred * FAIL if the collected exception occurred * UNRESOLVED if some other exception occurred.
実装は、例外の型とメッセージが両方元と一致したときだけ FAIL にします。落ちたかどうかではなく、追っている失敗と同じ落ち方をしたかを比べています。
- ・3つ目を FAIL に混ぜると、別の失敗を「再現した」と数える
- ・3つ目をどう扱うかは道具ごとに違う(後述)
2値の判定で縮めると、追っていた失敗が消える
設定文字列を読んで単価を返す関数に、不具合が1つあります。rate=0 でゼロ除算になります。一方で、= の無い部分があると ValueError で落ちます。こちらは今追っている失敗ではありません。
判定手順を2つ用意しました。debuggingbook の規則に従った3値版と、何であれ落ちたら FAIL にする2値版です。
def judge3(inp):
try:
price_per_unit(inp)
return PASS
except Exception as e:
if type(e) is type(ORIGINAL) and str(e) == str(ORIGINAL):
return FAIL
return UNRESOLVED
どちらを使っても、入力から1文字ずつ消して、判定が FAIL のままなら採用する、という同じ素朴な手順で縮めます(ddmin ではありません)。Python 3.14.6 で2回実行し、2回とも同じ出力でした。
judge3('a=1;rate=0;b=2') = FAIL judge2 = FAILjudge3('a=1;b=2') = PASS judge2 = PASSjudge3('a=1;rate') = UNRESOLVED judge2 = FAILjudge3('rate=0') = FAIL judge2 = FAILjudge3('x') = UNRESOLVED judge2 = FAILshrink with judge2: 'a=1;rate=0;b=2' -> '2' (ValueError: not enough values to unpack (expected 2, got 1))shrink with judge3: 'a=1;rate=0;b=2' -> '=1;rate=0;=2' (ZeroDivisionError: division by zero)
3行目の 'a=1;rate' は落ちますが、ValueError です。2値版はこれを FAIL、つまり「再現した」と数えます。その結果、2値版で縮めると '2' に行き着き、追っていたゼロ除算が消えています。 3値版で縮めた結果にはゼロ除算が残ります。
3値版の結果 '=1;rate=0;=2' も最小ではありません(1文字ずつ消す手順の限界)。見たいのは縮め方ではなく、縮める手順が「再現した」を判定に任せきっていることです。
2値の判定(何であれ例外が出たら FAIL)で入力を縮めると `'2'` になり、ValueError で落ちました。何が起きたのでしょうか。
3つ目の答えを、道具はどう扱うか
「判定できない」という答えを受け取った道具が次に何をするかは、道具ごとに違います。
debuggingbook はこう書いています。
Such an outcome that is different from the original failure causes our DeltaDebugger not treating this as a failure, but rather as a UNRESOLVED outcome, indicating that the test cannot determine whether it passed or failed. The ddmin algorithm treats such unresolved outcomes as if they were passing; hence, the algorithm treats its minimization attempt as unsuccessful.
ddmin は UNRESOLVED を通ったものと同じに扱い、その縮め方を採用しません。
git bisect run は判定スクリプトの終了コードを読みます。
Note that <cmd> run with <arg> should exit with code 0 if the current source code is good/old, and exit with a code between 1 and 127 (inclusive), except 125, if the current source code is bad/new.
The special exit code 125 should be used when the current source code cannot be tested. If the script exits with this code, the current revision will be skipped (see git bisect skip above).
0 が good、1〜127 のうち125以外が bad、125 が skip(飛ばす)、0〜127 の外は Any other exit code will abort the bisect process. で中断です。予測問題で見たとおり、126 と 127 も bad です。
同じ3値でも、ddmin は「通った」扱い、git は「飛ばす」扱いです。等号で結んではいけません。 共通しているのは1点、どちらも判定できない答えを**「再現した」には数えない**ことです。判定手順を書く側の仕事は、判定できない場合を見分けて、その道具が求める形で返すことです。
読み込めない版を bad と答えると、別の境目が返る
10個のコミットを作りました。c5 で構文エラーを入れて c6 で直し、c8 で本物の不具合(合計が1ずれる)を入れます。判定スクリプトは2つです。素朴な版は、何であれ失敗したら終了コード1で終わります。3値版は、読み込めなければ125、合計がずれたら1、合っていれば0で終わります。
node -e 'import("./sum.mjs").then(m=>{ process.exit(m.sum([1,2,3])===6 ? 0 : 1) }, e=>{ console.error("UNRESOLVED:", e.name); process.exit(125) })'
各コミットで2つの判定を走らせた終了コードです(git 2.50.1、Node v24.21.0)。
c1 naive=0 judge3=0c2 naive=0 judge3=0c3 naive=0 judge3=0c4 naive=0 judge3=0c5 naive=1 judge3=125c6 naive=0 judge3=0c7 naive=0 judge3=0c8 naive=1 judge3=1c9 naive=1 judge3=1c10 naive=1 judge3=1
どちらも c1 を good、c10 を bad として git bisect run をかけました。git の記録(git bisect log)はこうです。
### git bisect run judge-naive.sh (good=c1, bad=c10)--- bisect loggit bisect start 'main' '45efe7fe4f8e5c99015a1e3909b2365002c4f758'git bisect bad 74107334d39a6fd76d3602d860a746eb25522792git bisect good 17761a98bebce4f1f07ec728ef8d6b0af53af13egit bisect good 73905f9bd36842f8d3189210e3364b1168b43e74### git bisect run judge3.sh (good=c1, bad=c10)--- bisect loggit bisect start 'main' '45efe7fe4f8e5c99015a1e3909b2365002c4f758'git bisect skip 74107334d39a6fd76d3602d860a746eb25522792git bisect good cc3fb00657e20ec26c402e45dad311e9d77d521cgit bisect bad 04be1ea592b4b960fd55c3df743e9a098da6bcb6git bisect good 479e58ecb3ffbdb2d7fe92407878a74cf7a11972
7410733 が c5、04be1ea が c8 です。素朴な判定では c5 に bad が付き、bisect は c5 を first bad commit として返しました。本物の不具合 c8 には届いていません。3値版では c5 に skip が付き、c8 を返しました。2回実行して、2回とも同じ結論です。
素朴な判定の答えは、判定にとっては正しい境目です。c4 で good、c5 で bad が返ったのですから。『半分にすると、なぜ20回で済むのか』で見たとおり、bisect は境目が1つという前提を検査せず、もっともらしい答えを1つ返します。誤っているのは探索ではなく、比べる相手として渡した判定のほうです。
素朴な判定で `git bisect run` をかけると、構文エラーを入れた c5 が first bad commit として返りました。この出力だけを見て、誤りだと分かりますか。
別の失敗を「再現した」と数える判定
何であれ落ちたら FAIL にする判定です。入力を縮めると ValueError の '2' に行き着き、bisect は構文エラーの c5 を返しました。FAIL は出ていますが、追っている失敗とは比べていません。
競合するコードが正しい値を返した1回
『同じ値を2人が書く』の実測です。2スレッドで排他制御なしに200万回加算し、3回のうち3回目がちょうど 2000000 になりました。この1回だけを判定に使えば PASS ですが、コードは壊れたままです。
=== plain RMW vs atomic_fetch_add (3 runs) ===expected=2000000 plain=1081150 atomic=2000000expected=2000000 plain=1643779 atomic=2000000expected=2000000 plain=2000000 atomic=2000000
片方は落ちすぎる判定、片方は通りすぎる判定です。どちらも判定手順は答えを返しています。何が足りないのでしょうか。
それぞれの答えを「追っている不具合が、この版にあるか」という問いに言い換えられるかを考えてみてください。
この章のまとめ
- 「直った」と言うには比べる相手が要る。直す前に「壊れている」と答えた判定手順がそれにあたる。
- debuggingbook の判定は3値。元と同じ型・同じメッセージの例外だけ FAIL、別の例外は UNRESOLVED。縮める前に再実行して FAIL を確かめる。
- 3つ目の扱いは道具で違う。ddmin は通った扱い、git bisect run は 125 で skip。125 を除く 1〜127(126・127 を含む)は bad、0〜127 の外は中断。等号で結ばない。
- 判定できない版を bad と答えると、bisect は構文エラーの c5 を返し、本物の c8 に届かなかった。誤っていたのは判定。
- 1回の PASS は直った証拠にならない。競合するコードも3回中1回は正しい値を返した。
自分の現場で確かめる
読んだだけでは身につきません。手元の環境で実際に見てみてください。
理解度チェック
読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。
debuggingbook の DeltaDebugger の実装で、試した入力が FAIL(再現した)と判定されるのはどんなときですか。
用語集(4語)
- 不具合の再現
- 追っている失敗を意図して起こすこと。本コースの他の回で使う「実測の再現性」(同じ測定で同じ値が出ること)とは別の意味。
- 判定手順
- ある版や入力について「追っている失敗が出たか」を答える手順。直す前に用意し、直した後の比較にも使う。
- PASS / FAIL / UNRESOLVED
- debuggingbook の DeltaDebugger が使う3値。例外なし・元と同じ例外・別の例外に対応する。ddmin は UNRESOLVED を PASS と同様に扱う。
- git bisect skip
- 判定できないコミットを飛ばす操作。`git bisect run` では終了コード125がこれにあたる。境目の隣を飛ばすと、どちらが最初の bad か特定できなくなることがある。
出典(一次ソース)
- Andreas Zeller, The Debugging Book — Reducing Failure-Inducing Inputs(DeltaDebugger。2026-09-24 取得) ↗
- The Debugging Book — Introduction to Debugging(The Scientific Method。2026-09-24 取得) ↗
- git-bisect Documentation(ページ表記 2.55.0。ローカル 2.50.1 の man は冒頭の主語が the script (my_script in the above example) だが、終了コードの規則(0 / 125 を除く 1〜127 / 125 は skip / 126・127 の注記 / それ以外は中断)は同一) ↗