言語 / Language: 日本語 · English
テストが全部緑でも、門は嘘をつける ―― 画像計測ライブラリで門が見逃したものと、見逃さない門の作り方
画像処理と計測の関数(以下 op)を約 2,200 本持つ自作ライブラリ Fullseye を育てています。op が正しいかを確かめる検査を、ここでは門と呼びます。pytest のテストも、生成物が最新かを見る検査も、CI も、全部「通さないものを通さない」ための門です。
門は 7,000 を超える関数で 1 万 5 千件ほど走り、全部緑です。それでも、門が緑のまま結果が嘘だったことが何度もありました。この記事は、その嘘を 4 つの型に分けて、それぞれ実例と「どう門を作り直したか」を置いたものです。型は Fullseye に固有ではなく、テストを書く人なら誰でも踏みます。
先に結論
| 型 | 何が起きるか | 作り直し方 |
|---|---|---|
| 門は在るのに、事故の場所で走っていない | 開発機の checkout では緑、配布物や Linux では赤 | 事故の起きる場所(wheel・Linux・CI)で数える |
| 緑なのに中身が空 | 空の出力・空のリスト・捨てられた判定を「一致」と数える | 中身の量を別に数える。門自身を壊して発火を確かめる |
| 全数を回したのに覆っていない | 入力 1 枚・パラメータ 1 組で「全部見た」と思う | 構造の違う入力を足す。免除台帳が長ければまず門を疑う |
| 自分の仕様と合っているだけ | 自分で書いた式で答え合わせをしている | 独立な真値(公表値・第 2 実装・変換に対する不変性)を外から持ち込む |
用語(先に読むと楽)
- 門 —— 結果を通すか止めるかの検査。テスト関数・生成物の差分検査・CI のジョブ。
- 真値 —— 門が答え合わせに使う、正しいと分かっている値。定理から出る閉じた式、規格に印刷された計算例、別の言語で書いた第 2 の実装の出力など。
- fail-closed —— 検査に失敗したら通さず、はっきり拒む設計。直せないものを黙って直さない。
- drift(ずれ) —— 生成物(索引・ヘルプ・翻訳)が元のソースと食い違うこと。
- 探針(probe) —— 全 op を一斉に呼ぶための試験入力。
型 1: 門は在るのに、事故の起きる場所で走っていない
wheel から op の 26 % が消えていた。 配布用の wheel を作り直したとき、pyproject.toml の py-modules にモジュールを書き忘れ、wheel を入れた環境では 224 本の op が消えていました。「読み込みに失敗した backend はゼロ」を確かめる門は在りました。ただしその門は、開発用に checkout したソースの上でしか走っていませんでした。checkout には全部のファイルが在るので、原理的にこの事故を再現できません。
作り直しは「wheel を実際に作り、別の仮想環境に入れ、checkout と wheel の op の集合を突き合わせる」ジョブを CI に置くことでした。後日、同じ書き忘れ(backends_texture)をしたときは、このジョブが即座に赤になり、1 行で直りました。
同じ型をあと 3 つ踏んでいます。
-
.gitignoreに当たった生成物: 台帳のカテゴリ名buildが.gitignoreのbuild/に当たり、ノート 3 枚が commit されていませんでした。手元は全部緑で、CI だけ 10 件が赤。「ファイルが在るか」を見る門は、そのファイルが git に載っているかを見ていませんでした。→ 生成物のパスが ignore に当たらないことを検査する門を足しました。 -
ディレクトリを歩く順番:
highpass.mdが 2 次元用と 1 次元用の両方に在り、Windows は 2 次元を、Linux は 1 次元を先に見つけて、CI でだけ門が落ちました。os.walkの順番はファイルシステム次第です。同じ探し方を 4 つの門と 1 つの道具がコピーしていました。 - 1 件も走っていないのに exit 0: テストファイルの読み込み時に assert が落ちると、pytest はその時点で全体を止めます。37 分かかるはずのスイートが 21 秒で終わり、終了コードは 0 でした。
教訓: 門を書いたら「この門は、事故が起きる環境で走っているか」を確かめる。開発機で緑なのは、直った証拠になりません。
型 2: 緑なのに中身が空
空の出力は、どんな比較にも「一致」する。 生成物が最新かを見る門(全部作り直して、作業ツリーが変わらないことを確かめる)は、生成器が何も出さなくなっても緑のままです。実際、索引が「op ノート 0 本」と書いたまま 6 言語で公開され、ヘルプは画像 0 枚・Markdown が生のまま 60 ページ、という状態を門は通しました。→ 中身の量(例の付いたノートの数、本文の文字数、空のセルがゼロであること)を別に数える門を足しました。
同じ型の 3 例です。
-
判定を計算して捨てていた: PoC を実行する門は「終了コードは 0 だが PASS と印字していない」を正しく判定していました。ところがその判定を返り値にせず、直後の
assert code == 0が必ず通るので、assert を 1 つも持たない PoC が 3 本放置されていました。 -
空振りする assert:
assert all(x for x in xs)とfor x in xs: assert ...は、xsが空なら無条件に通ります。探って締めても 126 件(84 ファイル)が残ったので、件数を台帳に固定し、増えたら赤にしました(減らす方向にだけ動くラチェット)。 - 同じ図: 展示館の 318 枚の図のうち、中身が違うのは 317 枚でした。「有理」と「無理」という正反対の主張の GIF が、バイト単位で同一でした(投影の仕方で回転成分がちょうど 0 になっていた)。
教訓: 門には「この門は空を通さないか」を確かめる門が要る。いちばん確実なのは、門をわざと壊して、赤になるのを見ることです。Fullseye には「門が本当に全部の PoC を見ているか」「公開名の門が空でないか」を確かめるテストが、門ごとに付いています。
型 3: 全数を回したのに、覆っていない
探針 1 枚は、全数ではない。 全 op を試験入力で一斉に呼ぶ門は、正しい場所に立っていました。それでも、しきい値の op xsitk_huang_thresh は、sin/cos の縞の探針では 4 回中 4 回成功し、勾配・円・市松の探針では 4 回中 4 回失敗(出力が全部ゼロ)していました。探針を構造の違う 3 枚に増やすと、「この op は退化入力なので免除」とした台帳が 27 本から 12 本に縮みました。免除の多くは op の性質ではなく、探針 1 枚の盲点だったわけです。
パラメータにも同じことが起きます。画像をタイルに分けて処理しても結果が変わらない、と宣言した op 168 本を、門は tile=64, halo=16 の 1 組だけで検査していました。組を変えると 18 本が割れていました。
いちばん痛かったのは、差分ファザーが 60,000 ケースで 0 件を出したことです。ファザーは「物体がいくつ見つかったか」は比べていましたが、「どの順番で並んでいるか」を観測していませんでした。わざと並びを逆にする変異を入れても 3,000 ケースで 1 度も捕まえられず、観測を 1 行足すと 2 ケース目で捕まえました。0 件は頑健さではなく、見ていないことの印でした。
教訓: 「全部に対して走った」は「全部を覆った」ではない。免除の台帳が長いときは、op より先に探針と観測を疑う。ファザーの 0 件は、変異を入れて殺せるか確かめてから信じる。
型 4: 自分の仕様と合っているだけ
合成の門 52 件が緑のまま、規格の計算例が 5 件の欠陥を出した。 測定システム解析(ゲージ R&R)と不確かさ(GUM)の op 9 本を、合成データの門 52 件で確かめていました。全部緑です。そこへ規格に印刷されている計算例(worked example)を真値として足すと、5 件の欠陥が出ました。交互作用をプールする経路が無くて EV が 7.3 % ずれる、有効自由度の切り捨てが無くて包含係数が 2.1132(規格は 2.12)になる、など。5 件とも「仕様に書いていないことが無い」型で、自分の仕様から作った合成データでは原理的に見つかりません。
第 2 実装は、単一実装のテストの外を出す。 画像処理の C ABI を Rust で書き直したら、最初の 8 件は全部一致しました(これを「合格」でなく「探針が甘い」と読んだ)。仕様が黙っている所を狙った 9 件で、連結成分の op が numpy の経路では 4 連結、OpenCV の経路では 8 連結になっていることが分かりました。8×8 の市松模様で 32 個 対 1 個です。どちらの経路もそれぞれのテストには通っていました。
変換に対する不変性は、真値が無くても門になる。 「回転しても変わらない」と主張する点群の記述子は、下請けの法線の符号が任意なせいで、90 度回すと 42.4 % の点で符号が反転していました。主張ごとに「12 通りのランダム回転で変わらないか」の門を立てると、真値の値を 1 つも知らなくても嘘が出ます。
教訓: 自分の書いた式で答え合わせをしない。真値は外から持ち込む —— 公表値、別言語の第 2 実装、変換に対する不変性やスケール則(k 倍すると k の p 乗で変わる、など)。
この回で門が捕まえたもの(2026-09-27)
この記事を書いている日に、線虫のコネクトーム(神経の配線図)を測る op と PoC を足しました。その 1 日で門が捕まえたものを、型と並べて置きます。
- 回転不変の門が 1 ulp の境界を捕まえた(型 4 の門が、型 3 の穴を塞いだ)。 神経の木の Sholl 解析(中心から同心球を広げ、枝と交わる数を数える)は、3 次元では回転で整数が 1 つも動かないはずです。ところがテストが赤になりました。既定の半径の上端を「いちばん遠い節点までの距離」ちょうどにしていたため、その節点が殻の上に乗り、回転で浮動小数が 1 ulp 動いただけで「内側/外側」が反転して、交点が 1 から 0 になっていました。既定の殻を区間の中点に置いて直しました。
- 訳が黙って読まれていなかった(型 2)。 英語の説明しかない op に日本語の要約を足しました。指紋も合っているのに、日本語のヘルプが生成されません。訳を置く場所が、決められた鍵の 1 段下ではなく最上位だったので、読み手が見つけられなかったのです。再生成は終了コード 0 で、何も言いませんでした。
- 論文との 9 ポイントの差を、分類表と 1 本ずつ突き合わせて全部分けた(型 4)。 同じ遺伝子の線虫 8 匹で「7 匹以上に在る結合」を数えると成虫の結合の 34 % で、論文の約 43 % と合いませんでした。合わせにいかず、著者が公開している結合ごとの分類表と照合しました。論文は「左右の対でまとめた結合が 7 匹以上に在れば、対に属する結合 1 本 1 本に stable の札を付ける」数え方をしていて、それだけで論文の stable 792 本のうち 789 本を再現しました。どちらも間違いではなく、何を 1 本と数えたかの違いでした。
-
図のギザギザは丸めの跡だった。 2 匹の平均シナプス数を丸めて度数分布にすると、奇数と偶数で交互に上下しました。numpy の
roundは偶数丸め(2.5 → 2)なので、半整数が偶数の階級に寄ります。生物の周期性に見えるので危ないところでした。合計(整数)のまま数えると消えました。 - 著者の欄をそのまま真値にしかけた(型 4)。 線虫 8 匹の神経の骨格で、fullseye の op が出したケーブル長を、著者がファイルに書いた「長さ」の欄と突き合わせる門を立てかけました。ところが 1,586 本中 1,360 本が合いません。その欄は線分の長さの合計ではなく(中央値 0.89 倍)、書き出し元の定義で計算されたもので、確かめようがありません。代わりに、節点ごとの「根からの距離」の欄を使いました。ただしこの欄には座標の無い節点も混ざっていたので、座標のある節点だけで比べると、1,586 本すべてで相対 1.75e-9 まで一致しました。外から持ち込んだ真値も、その定義を確かめるまでは真値ではありません。
- 第 2 実装の側の名前が入れ替わっていた(型 4)。 分割の採点量(CREMI の adapted Rand error)を scikit-image と突き合わせると、誤差の値は一致したのに、precision と recall が入れ替わっていました。scikit-image のコードは precision を正解側の対の数で割っていて、docstring の「候補の画像の対の数で割る」と逆です。F 値は対称なので誤差そのものは正しく、precision と recall を別々に使うときだけ食い違います。第 2 実装は自分の欠陥だけでなく、相手の欠陥も出します。
- 採点の前処理ひとつで結論が逆になった(型 2)。 電子顕微鏡の神経の切り出しを VOI の「分けすぎ」と「まとめすぎ」で採点すると、最初はどのしきい値でも「まとめすぎ」でした。原因は、膜の画素を背景(ラベル 0)のまま残していたことです。背景全体が 1 つの巨大な領域として数えられ、まとめすぎが 2.4 倍に膨らんでいました。膜の画素を最寄りの細胞へ割り振ると、しきい値 65 % と 70 % の間で「分けすぎ」から「まとめすぎ」へきれいに入れ替わりました。
門の種類(Fullseye で使っているもの)
| 種類 | 真値 | 例(テスト) |
|---|---|---|
| 閉形式・恒等式 | 定理・解析解 | 次数の和 = 辺の数 / 極小曲面の平均曲率 = 0 |
| 第 2 実装 | 別言語・別 backend・総当たり | Rust と Python の一致 / 3 サイクルの総当たり / 生成した C とビット一致 |
| 公表値 | 規格の計算例・規格の図 | ゲージ R&R の worked example / GenICam の画素形式 59 種 |
| fail-closed | 契約外の入力は拒む | 未知の型名・非正の σ・位置合わせ失敗を黙って単位行列にしない |
| 不変性・スケール則 | 変換で変わらない/決まった指数で変わる | 12 回転で不変 / k 倍で k^p |
| 対照(ヌル) | 次数を保って組み替えた偶然 | 各ヌル標本で次数列が厳密に保存 |
| 退化・一様入力 | 空・定数・1 画素 | 一様な画像が全面検出にならない |
| 探針 | 全 op を構造の違う入力で呼ぶ | fallback ゼロ / つまみが効く |
| drift | 全部作り直して作業ツリーが変わらない | regen_all --check |
| 登録面の数え上げ | 実物から数えた数 | README・索引・__all__ の件数 |
| 配布物の側で数える | wheel・py-modules | 公開経路の無い関数が増えない |
| 翻訳の指紋 | 原文の指紋 | 原文を直したら訳が古いと分かる |
| 成果物を実行する | 別プロセスで exit 0 + PASS | PoC 150 本以上・docs のコード例 |
| 環境・git | tracked・ローカルパス無し・順番 | ignore に当たらない / 私的パスが公開物に無い |
| 門の門 | 門自身が発火する | 空を通さない / 壊すと赤になる / 空振りする assert の台帳 |
規模の数字は、見出しではなく参考として置きます。テストファイル 420 本、テスト関数 7,192 個、実行件数は約 1 万 5 千件で、全体スイートは約 35 分です。CI は Python 3.10 / 3.11 / 3.12 × 4 分割の 12 ジョブに、wheel を実際に作って数えるジョブ、生成物の drift 検査、週 1 回の順序シャッフルを足した構成で、リリースはその commit の CI が緑でないと出せません。
門を足すときの手順
- 真値をどこから持ってくるかを先に決める。自分の書いた式で答え合わせをしない。
- 事故が起きる場所で走るかを確かめる(配布物・Linux・CI)。
- 門を壊して、赤になるのを見る。赤にならない門は門ではない。
- 空を通さないかを別に数える(出力の量・見た件数)。
- 入力とパラメータを 1 つにしない。構造の違う入力を最低 2 つ。
- 免除は理由つきの台帳に書き、減らす方向にだけ動くラチェットにする。
- 合否だけでなく中身を残す。長い検査の全出力はファイルへ。合否だけだと、次にもう 1 回同じ時間を払う。
向かないこと・限界
- 門を増やすと時間がかかります。全体スイートの 80 % の時間は上位 80 段(PoC を別プロセスで走らせる門など)が使っていました。速くするときは、測ってから直します。
- 門は「知っている嘘」しか止めません。この記事の実例の多くは、門が在ったのに通したものです。新しい型の嘘は、また別の門を足すまで通ります。
- 公表値は規格や論文が在る分野でしか使えません。無い所では、第 2 実装と不変性が頼りです。
作り手について
問いと方向は私が決め、実装・門の追加・敵対的な見直しは Claude Code に回しました。この記事の実例の多くは、その見直しの記録です。