自作の測定スクリプトに嘘をつかれた。エラーは出ていない。落ちてもいない。
正しい形をした、間違った答えを返しただけだった。
この記事は、そういう「黙って通る」壊れ方を5つの型に分けて、直し方と、それを捕まえる
テストをセットで置いていく。直し方はどれも数行で済む。難しいのは直すことではなく、
在ることに気づくほうなので、テストの書き方に重心を置く。
発端:語をひとつ替えたら、答えが100倍変わった
YouTubeの検索需要を測るスクリプトを書いた。検索語を渡すと上位の再生数の中央値を返す。
主観で発信ネタを選ばないための道具だ。
最初に「Claude Code 失敗」で引いたら、上位が軒並み1,000回未満だった。
「この話題は誰にも見られていない」と結論した。
間違いだった。同じ話題を「Claude Code トラブル」で引き直したら 13.4万回。
語をひとつ替えただけで100倍以上ちがった。
原因は検索語ではなく、実装だった。YouTubeはときどき同意ページやbot判定ページを返す。
そのとき埋め込みデータ(ytInitialData)が無い。そして当時の実装は——
def extract(html: str) -> list[dict]:
m = re.search(r"var ytInitialData = (\{.+?\});", html)
if not m:
return [] # ← これ
...
取れなかったときに空リストを返していた。 呼び出し側から見ると
「0件ヒットした」と区別がつかない。だから「需要が無い」と読めてしまった。
エラーログは出ない。終了コードは0。そのまま判断の材料になる。
型① 「取れなかった」を「無かった」として返す
直し方
class FetchError(RuntimeError):
pass
def extract(html: str) -> list[dict]:
m = re.search(r"var ytInitialData = (\{.+?\});", html)
if not m:
raise FetchError("ytInitialData が見つからない(書式変更・bot判定を疑う)")
...
「取れなかった」と「無かった」は別のことだ。同じ値で返してはいけない。
それを捕まえるテスト
「正常系で0件が返る」ことと「異常系で例外が飛ぶ」ことの両方を固定する。
片方だけだと、また空返しに戻したときに気づけない。
def test_ヒット0件なら空リスト():
html = _make_html(items=[]) # データはあるが中身が0件
assert extract(html) == []
def test_データが取れなければ例外():
"""空リストと区別がつかなくなる。ここが崩れると『需要が無い』と誤読する。"""
with pytest.raises(FetchError):
extract("<html>同意ページ</html>")
型② テストが0件でも緑になる
これは9日空けて2回踏んだ。まったく同じ形で。
触ったファイルに対応するテストを自動実行し、落ちたら差し戻す仕組みを作ってある。
起動方法は python テストファイル だ。
pytestで書いたテストファイルには、末尾にこれが要る。
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))
書き忘れると、そのファイルは何もせず終了コード0で終わる。
テストは1件も走っていない。それでも緑になる。
安全装置がいちばん危ないのは、この壊れ方だ。外れているのに、外れたことを教えてくれない。
それを捕まえるテスト
「テストがある」ではなく「テストが実際に走った」を確認する。
pytest の終了コードは、収集0件のとき 5(ExitCode.NO_TESTS_COLLECTED)になる。
import subprocess, sys, pathlib
def test_全テストファイルが単体で走る():
"""`python <file>` で起動したとき、0件で緑にならないこと。"""
for f in pathlib.Path("tests").glob("test_*.py"):
r = subprocess.run([sys.executable, str(f)],
capture_output=True, text=True, timeout=120)
assert r.returncode == 0, f"{f} が失敗: {r.stdout[-400:]}"
assert "no tests ran" not in r.stdout, f"{f} は0件で緑になっている"
安全装置を作ったら、わざと壊して赤くなるのを一度見ておく。
見ていないなら、それは「動いている」ではなく「動いていることにしている」だ。
型③ 取れなかった値を 0 や false に丸める
同じスクリプトで、再生数がパースできない動画がある(ライブ配信中など)。
最初は 0 にしていた。
0 にすると「再生数0の動画」と区別がつかない。 中央値が下に引っ張られる。
同じことが「説明文に商材への誘導があるか」の判定でも起きた。
説明文が取れなかった動画を「誘導なし」に丸めると、誘導率が実際より低く出る。
「無い」のではなく「判定していない」のに。
直し方
None を第三の値として持ち、集計から外す。分母も一緒に出す。
@dataclass
class Video:
views: int | None # None = 取れなかった(0ではない)
lure: bool | None # None = 判定していない
def summarize(videos: list[Video]) -> dict:
known = [v.views for v in videos if v.views is not None]
judged = [v.lure for v in videos if v.lure is not None]
return {
"median_views": statistics.median(known) if known else None,
"sampled": len(known), "total": len(videos), # ← 分母を隠さない
"lure": sum(judged), "lure_judged": len(judged), # ← 「4/10」と出す
}
出力も 誘導 4/10 の形にする。誘導 40% にすると、
判定できなかった6本が「誘導なし」に化けたのか分からない。
それを捕まえるテスト
def test_取れなかった値は集計に混ぜない():
vs = [Video(views=100, lure=True), Video(views=None, lure=None)]
s = summarize(vs)
assert s["median_views"] == 100 # 0 が混ざれば 50 になる
assert s["sampled"] == 1 and s["total"] == 2
assert s["lure_judged"] == 1 # 分母が2になっていたら丸めている
0 も false も、それ自体が意味を持つ値だ。空き地に置いてはいけない。
型④ 壊れた状態ファイルを黙って初期化する
自動売買の実験をしている。前回どういう状態だったかをファイルに書いてある。
「ファイルが壊れていたら、空とみなして初期化する」は、いちばん書きたくなる分岐だ。
書けばプログラムは止まらなくなる。そしていちばん危ない。
空とみなした瞬間、「もう持っている」「もう発注済み」といった制約が全部消える。
安全装置が、誰にも知られずに外れる。
直し方
class StateFileError(RuntimeError):
pass
def load_state(path: Path) -> dict:
if not path.exists():
return {} # 無いのは正常(初回)
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, OSError) as e:
# **壊れているのは異常。空と同じ扱いにしない。**
raise StateFileError(f"{path} が壊れています: {e}") from e
「無い」と「壊れている」を分ける。 前者は初回、後者は事故だ。
書き込み側も対にする。途中で落ちたファイルを読むことになるので、原子的に置き換える。
def save_state(path: Path, data: dict) -> None:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False))
os.replace(tmp, path) # 同一FS上ならアトミック
それを捕まえるテスト
def test_壊れた状態ファイルは黙って初期化しない(tmp_path):
p = tmp_path / "state.json"
p.write_text("{壊れたJSON")
with pytest.raises(StateFileError):
load_state(p)
def test_無いのは正常(tmp_path):
assert load_state(tmp_path / "none.json") == {}
止まったプログラムは困るが、気づける。黙って初期化したプログラムは困らないが、気づけない。
型⑤ 「〜するな」と書いて、禁止したつもりになる
自分専用の通知botを作って、外部のチャットから指示を受け取れるようにしてある。
最初は、受け取った文字列から実行する関数名を組み立てる作りだった。
プロンプトには「危険な操作はするな」と書いてあった。
それは禁止になっていない。
# 悪い例:文字列から呼ぶものを決めている
handler = globals().get(f"cmd_{text.split()[0]}")
if handler:
handler()
文字列から関数を組み立てられる構造がある以上、組み立てられる文字列が来たら実行される。
「するな」と書いてあっても関係ない。書いてあるのはお願いであって、鍵ではない。
直し方
固定の辞書で受ける。辞書に載っていないものは、何をどう書かれても実行されない。
COMMANDS = { # ここに無いものは実行されない
"/mute": cmd_mute,
"/unmute": cmd_unmute,
"/status": cmd_status,
}
def handle(text: str) -> str:
fn = COMMANDS.get(text.split()[0] if text.split() else "")
return fn(text) if fn else "知らないコマンドです"
そのうえで、いま外から叩けるのは「通知をミュートする」だけにしてある。
受け口を破られても、被害は「通知が止まる」で終わる。
口座を触るコマンドは、まだ一つも足していない。意図的にだ。
それを捕まえるテスト
性質をテストする。「危ないコマンドを1つずつ試す」だと、次に増えた危ないものを取り逃す。
def test_辞書に無い入力は何も実行しない():
for text in ["cmd_halt", "__import__('os').system('ls')", "/halt", "eval"]:
assert handle(text) == "知らないコマンドです"
def test_受け口は増えていない():
"""足すときに気づくための番人。増やすなら、ここも意図して直す。"""
assert set(COMMANDS) == {"/mute", "/unmute", "/status"}
禁止はプロンプトではなく、構造で書く。
止め方は、検品と対で要る
検品は「間違いに気づく」ための仕組みだ。気づいたあと止められなければ意味がない。
止め方について3つ。
上限を決めるのは運用者で、AIではない
「上限に引っかかった」と相談すると、たいてい上限を上げる提案が返ってくる。筋は通っている。
だが上限は結論であって、変数ではない。引っかかったときに疑うのは上限ではなく中身のほうだ。
上限を掛ける向きを間違えない
「1回で動かせる量の上限」を作るとき、増やす側と減らす側の両方に掛けたくなる。
やってはいけない。何かの拍子に上限を超えた状態になったとき、
それを解消する操作まで上限に引っかかる。危険な状態から出られなくなる。
def check(order, limits) -> bool:
# 絶対額の上限は「増やす向き」にだけ掛ける。
# 減らす向きに掛けると、上限超過の状態を解消できなくなる。
if order.side == "INCREASE" and order.notional > limits.max_notional:
return False
return True
def test_上限を超えていても減らす操作は通る():
over = Order(side="DECREASE", notional=999_999)
assert check(over, Limits(max_notional=1000)) is True
この不変条件は、本番の設定ファイルを読んでテストする。
テスト用の値だけで確かめると、本番の設定が変わった瞬間に成立しなくなる。
緊急停止は多層にする
「停止フラグのファイルを置く」だけでは、すでに外に出た指示は止まらない。
出す前・出す瞬間・出したあと。それぞれに止め口が要る。
そして——これは最近やらかしたのだが——多層にしたつもりで、層が実装されていないことがある。
2層目(すでに出ている指示の取り消し)を呼ぶコードは書いてあったのに、
呼ばれる側のメソッドが存在しなかった。異常系だけを確認していたので、
「接続できないときに正しく失敗する」ことは確かめてあった。
メソッドが無い場合も、出力が一字一句同じだった。
異常系だけを見て正常系を一度も通していないと、この種の確認は偽陽性で通る。
チェックリスト
- 「取れなかった」を空や0で返している経路を全部つぶす。例外にする
- 安全装置をわざと壊して、赤くなるのを見る。見たことがないなら動いていないと思う
- 取れなかった値を 0 / false に丸めていないか探す。分母を出力に含める
- プロンプトで禁止している箇所を、構造(固定辞書)に置き換える
- 上限の向きを確認する。減らす操作に掛かっていたら外す
- フォールバックの確認は、正常系を一度通してからやる
上から順に効く。1と2だけでも、黙って嘘をつく経路はかなり減る。
おわりに
「AIに任せる」と「任せっぱなしにする」の間には、検品がある。
AIは実装を速くした。設計も、テストを書くのも速くなった。
だが黙って間違う経路を数えて塞ぐ仕事は、まったく減っていない。
むしろ実装が速くなったぶん、そこだけが残る。
最後に、いちばん効いた問いを置いておく。
「これが間違った答えを返したとき、自分はどうやって気づくんだろう?」
答えが出てこなければ、まだ検品が終わっていない。
note でも書いています
このシリーズ以外に、AIエージェントの運用から広がった政治・経済・メディア構造の話を
note で書いています。