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?

その安全装置、一度も作動したところを見ていないのでは —— わざと壊して確かめる

0
Last updated at Posted at 2026-09-08

今日、自分のコードで緊急停止の2層目が丸ごと機能していないのを見つけた。
呼び出す側は書いてあった。呼ばれる側のメソッドが存在しなかった

しかもドキュメントには「実地確認済み」と書いてあった。嘘ではない。
接続を切って試して、期待どおりのエラーが出るのを確かめてある。
メソッドが無い場合も、出力が一字一句同じだったというだけだ。

この記事は、そういう「作ってあるが働いていない安全装置」を、
わざと壊して確かめる話を、動くコードで書く。

前回(「落ちない」は安全ではない)が
「黙って間違う経路を塞ぐ」なら、今回は「塞いだものが本当に働くか」。

まず:なぜ「実地確認済み」が嘘になったのか

緊急停止は3層で作ってあった。

何を止めるか
① 新規の指示 停止フラグのファイルを置き、以降を全部却下する
すでに出ている指示 取り消す
③ 定時実行そのもの プロセスを起動しない

②のコードはこうなっていた。

try:
    with Broker(...) as b:
        for order in b.pending_orders():
            b.cancel_order(order.id)      # ← このメソッドが存在しなかった
except Exception as e:
    print(f"★ 取り消しに失敗しました: {type(e).__name__}: {e}")
    print("  **停止は成立しています**(フラグは作成済み)")
    print("  すでに出ている指示は手で取り消してください")
    return 1

外側の except Exception が、接続断も AttributeError も同じ文言で受ける。
だから「接続できないときに正しく失敗する」を確かめても、
「そもそも実装が無い」ことは検出できない。異常系の確認が偽陽性で通っていた。

フォールバックの確認は、正常系を一度通してからやる。
「失敗したときに正しく失敗する」は、成功経路が存在することの証明にならない。

さらに悪いことに、この取り消しループは main() の中に直接書かれていて、
テストから到達できなかった。テストは全部緑のままだった。

① 「発火実績ゼロ」を、データとして持つ

安全装置には「作った日」ではなく「最後に作動した日」を持たせる。
持たせると数えられる。数えられると減らせる。

from dataclasses import dataclass
from datetime import date

@dataclass
class Guard:
    name: str
    last_fired: date | None = None      # None = 一度も作動していない

GUARDS = {
    "max_order_size":  Guard("1回の発注量の上限"),
    "stale_state":     Guard("状態が古いときに止める"),
    "halt_file":       Guard("緊急停止フラグ"),
    "cancel_pending":  Guard("未約定注文の取り消し"),
}

def unverified() -> list[str]:
    """**一度も作動していないもの=未検証。**「たぶん動く」ではなく「分からない」。"""
    return [g.name for g in GUARDS.values() if g.last_fired is None]

これをそのままテストにする。増えたら気づくための番人だ。

def test_未検証の安全装置を数える():
    # 減らす前提の数字。増えたらこのテストが落ちて気づく。
    assert len(unverified()) <= 2, f"未検証: {unverified()}"

「テストでは動く」と「本番の経路で作動した」は別のことだ。
その間には、テスト用に作った抜け道と、本番にしかない前提が挟まっている。

② わざと壊す、3つのやり方

(a) テストを1件わざと落として、赤くなるのを見る

いちばん軽い。1分で終わる。捕まえられるのは
「テストが走っていない」「差し戻しが効いていない」「そのファイルが対象に入っていない」。

手でやると忘れるので、スクリプトにする。期待値を機械的に反転させて、
赤くならなかったテストを報告する
(軽いミューテーションテスト)。

import pathlib
import subprocess
import sys

FLIP = [("assert ", "assert not "), ("==", "!="), ("is True", "is False")]

def survives_mutation(test_file: pathlib.Path) -> list[str]:
    """1箇所ずつ壊して pytest を回し、**それでも緑のままだった変異**を返す。"""
    src = test_file.read_text()
    survived = []
    for old, new in FLIP:
        if old not in src:
            continue
        broken = src.replace(old, new, 1)          # 最初の1箇所だけ壊す
        backup = src
        try:
            test_file.write_text(broken)
            r = subprocess.run([sys.executable, "-m", "pytest", str(test_file), "-q"],
                               capture_output=True, text=True, timeout=120)
            if r.returncode == 0:                  # 壊したのに緑 = 何も守っていない
                survived.append(f"{old}{new}")
        finally:
            test_file.write_text(backup)           # **必ず戻す**
    return survived

finally で必ず戻すのが肝心で、ここを忘れると壊したまま放置される。
「戻す」までを含めて1回という原則は、この後のドリルでも同じだ。

(b) 依存を抜く

ネットワークを切る。状態ファイルを壊す。設定を空にする。
見たいのは「エラーになるか」ではなく、「何と言って止まるか」

def test_状態ファイルが壊れていたら止まる(tmp_path):
    p = tmp_path / "state.json"
    p.write_text("{壊れたJSON")
    with pytest.raises(StateFileError) as e:
        load_state(p)
    # **「無い」と「壊れている」が区別されていること**まで見る
    assert "壊れて" in str(e.value)

def test_無いのは正常(tmp_path):
    assert load_state(tmp_path / "none.json") == {}

def test_接続できないときの文言(capsys, monkeypatch):
    def boom(*a, **kw):
        raise ConnectionRefusedError("接続できません")
    monkeypatch.setattr(broker, "connect", boom)
    assert emergency_stop() == 1
    out = capsys.readouterr().out
    assert "停止は成立しています" in out      # ①は成功していると明示すること

黙って空を返したら負け。「取れませんでした」と言って止まれば勝ち。

そして冒頭の教訓: この (b) だけをやっても、実装が無いことは見つからない。
(c) の正常系とセットで初めて意味を持つ。

def test_取り消しが呼ばれ板から消えるまで見る():
    """異常系だけでなく、成功経路を1本通す。ここが無くて実装漏れを見逃した。"""
    b = fake_broker(pending=["o1", "o2"])
    failed = cancel_pending(b)
    assert b.cancelled == ["o1", "o2"]
    assert failed == []

(c) 上限に、わざと当てにいく

上限は「超えないように運用する」ものなので、普段は当たらない。
当たらないものは、当たったときの挙動が確かめられていない。
当てにいく側が、上限を下げて近づく。

def test_上限に当たったら止まる():
    tight = Limits(max_notional=1)          # 普段の操作が必ず引っかかる値にする
    assert check(Order(side="INCREASE", notional=1000), tight) is False

def test_止まったあとに中途半端な状態が残らない(tmp_path):
    """止まることだけでなく、**止まったあと何が残るか**を見る。"""
    gate = Gate(limits=Limits(max_notional=1), journal=tmp_path / "j.json")
    gate.execute(Order(side="INCREASE", notional=1000))
    assert load_state(tmp_path / "j.json") == {}      # 予約が残っていたら二重実行の元

③ 検証の周期を、対象の周期から切り離す

安全装置が作動する機会は、そう来ない。私の場合、判断は月1回だった。
つまり本番経路を通すのは年12回。そのうち安全装置に当たる回はもっと少ない。

そこで、判定と無関係に経路を一巡させるドリルを作った。

いまの状態 → 少しだけ動かす → 元に戻す

これで検証が年12回から年52回になった。もっと大事なのは、
回数を自分で決められるようになったことだ。

検証したいものの周期と、検証の周期は、別々に決められる。
「本番が来ないと確かめられない」と思っている時点で、たいてい設計を分けていない。
月次バッチでも、リリースでも、バックアップでも同じ。

ドリルの骨格(門番つき)

def drill(qty: int = 2, dry_run: bool = False) -> int:
    # --- 門番。**やらない条件を先に書く** ---
    if not cfg.sandbox:
        return fail("本番設定では動かさない")
    if is_scheduled_run_day(today()):
        return fail("本番の実行日はドリルと取り合うので走らせない")
    if not market_is_open():
        return ok("市場が閉じている。異常ではない")   # ← 正常終了で返す

    start = current_position()          # **戻す先を先に控える**
    steps = [start + qty, 0, start]     # 増やす → 全部戻す → 元に戻す
    for target in steps:
        if execute(target, dry_run=dry_run) != 0:
            return fail(f"失敗。**建玉が元に戻っていません**。開始時は {start}")
        if reconcile() != 0:            # 台帳と実物の突合。ここが通らなければ進まない
            return fail(f"突合が不一致。開始時は {start}")

    end = current_position()
    if abs(end - start) > tolerance:
        return fail(f"戻っていない: {start}{end}")
    return ok(f"完了。{start} に戻っています")

外せない設計が3つある。

本番と同じ経路を通す。
ドリル用の抜け道を作った瞬間、ドリルは意味を失う。
「ドリルのときだけこの判定を飛ばす」と書きたくなったら、
それは確かめたかったものそのものだ。

元に戻すところまでが1回。
戻せないドリルは1回しか実行できない。1回しか実行できないものは習慣にならない。
習慣にならないものは、いざというとき最新ではない。

失敗を記録する。
ドリルが失敗したときに出てくるものが、この作業の成果物だ。
成功したドリルは何も教えてくれない。私は失敗の記録を1か所に貯めていて、
関係しそうな話題が出たときに自動で見出しが差し込まれるようにしてある。
覚えておこうとしないためだ。人は覚えておけない。9日後にまったく同じ形で踏む。

門番もテストで固定する

ドリル自体が事故の元にならないよう、やらない条件を落とす。

@pytest.mark.parametrize("cond,expect", [
    ({"sandbox": False},        "本番設定"),
    ({"scheduled_today": True}, "取り合う"),
])
def test_ドリルが走ってはいけない条件(cond, expect, monkeypatch):
    setup(monkeypatch, **cond)
    assert expect in drill_reason()

def test_市場が閉じているのは異常ではない(monkeypatch):
    setup(monkeypatch, market_open=False)
    assert drill() == 0          # 失敗ではなく正常終了にする

④ やってはいけないこと

ドリルを回すために、安全装置を緩めない。

「上限に引っかかってドリルが通らない」となったとき、上限を上げれば通る。
通るが、それは何も確かめていない。

引っかかったのなら、引っかかったこと自体が結果だ。
上限は結論であって、変数ではない。動かすのは中身のほうだ。

順序を間違えると、安全装置を外す練習をしていることになる。

チェックリスト

  1. 安全装置に「最後に作動した日」を持たせる。None は未検証として数える
  2. 新しく足した日に、テストを1件わざと落として赤くなるのを見る
  3. 依存を抜いて、何と言って止まるかを見る(黙って空を返したら負け)
  4. 異常系だけで満足しない。 正常系を1本通す。実装漏れはそこでしか見つからない
  5. 上限は当てにいく。止まることと、止まったあと何が残るかの両方を見る
  6. ドリルは「戻す」までで1回。本番と同じ経路を通す
  7. ドリルのために上限を緩めない

おわりに

安全装置を作るのは楽しい。設計に筋が通るし、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?