0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「落ちない」は安全ではない —— 自動化が黙って嘘をつく5つの型と、それを捕まえるテスト

0
Last updated at Posted at 2026-09-08

自作の測定スクリプトに嘘をつかれた。エラーは出ていない。落ちてもいない。
正しい形をした、間違った答えを返しただけだった。

この記事は、そういう「黙って通る」壊れ方を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件のとき 5ExitCode.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層目(すでに出ている指示の取り消し)を呼ぶコードは書いてあったのに、
呼ばれる側のメソッドが存在しなかった。異常系だけを確認していたので、
「接続できないときに正しく失敗する」ことは確かめてあった。
メソッドが無い場合も、出力が一字一句同じだった。

異常系だけを見て正常系を一度も通していないと、この種の確認は偽陽性で通る。


チェックリスト

  1. 「取れなかった」を空や0で返している経路を全部つぶす。例外にする
  2. 安全装置をわざと壊して、赤くなるのを見る。見たことがないなら動いていないと思う
  3. 取れなかった値を 0 / false に丸めていないか探す。分母を出力に含める
  4. プロンプトで禁止している箇所を、構造(固定辞書)に置き換える
  5. 上限の向きを確認する。減らす操作に掛かっていたら外す
  6. フォールバックの確認は、正常系を一度通してからやる

上から順に効く。1と2だけでも、黙って嘘をつく経路はかなり減る。

おわりに

「AIに任せる」と「任せっぱなしにする」の間には、検品がある。

AIは実装を速くした。設計も、テストを書くのも速くなった。
だが黙って間違う経路を数えて塞ぐ仕事は、まったく減っていない。
むしろ実装が速くなったぶん、そこだけが残る。

最後に、いちばん効いた問いを置いておく。

「これが間違った答えを返したとき、自分はどうやって気づくんだろう?」

答えが出てこなければ、まだ検品が終わっていない。


note でも書いています

このシリーズ以外に、AIエージェントの運用から広がった政治・経済・メディア構造の話を
note で書いています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?