言語 / Language: 日本語 · English
画像処理と計測の関数(以下 op)を約 2,200 本持つ自作ライブラリ Fullseye を育てています。op が正しいかを確かめる検査を、ここでは門と呼びます。pytest のテストも、生成物が最新かを見る検査も、CI も、全部「通さないものを通さない」ための門です。
門は 7,000 を超える関数で 1 万 5 千件ほど走り、全部緑です。それでも、門が緑のまま結果が嘘だったことが何度もありました。この記事は、その嘘を 4 つの型に分けて、それぞれ実例と「どう門を作り直したか」を置いたものです。型は Fullseye に固有ではなく、テストを書く人なら誰でも踏みます。
| 型 | 何が起きるか | 作り直し方 |
|---|---|---|
| 門は在るのに、事故の場所で走っていない | 開発機の checkout では緑、配布物や Linux では赤 | 事故の起きる場所(wheel・Linux・CI)で数える |
| 緑なのに中身が空 | 空の出力・空のリスト・捨てられた判定を「一致」と数える | 中身の量を別に数える。門自身を壊して発火を確かめる |
| 全数を回したのに覆っていない | 入力 1 枚・パラメータ 1 組で「全部見た」と思う | 構造の違う入力を足す。免除台帳が長ければまず門を疑う |
| 自分の仕様と合っているだけ | 自分で書いた式で答え合わせをしている | 独立な真値(公表値・第 2 実装・変換に対する不変性)を外から持ち込む |
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 つの道具がコピーしていました。教訓: 門を書いたら「この門は、事故が起きる環境で走っているか」を確かめる。開発機で緑なのは、直った証拠になりません。
空の出力は、どんな比較にも「一致」する。 生成物が最新かを見る門(全部作り直して、作業ツリーが変わらないことを確かめる)は、生成器が何も出さなくなっても緑のままです。実際、索引が「op ノート 0 本」と書いたまま 6 言語で公開され、ヘルプは画像 0 枚・Markdown が生のまま 60 ページ、という状態を門は通しました。→ 中身の量(例の付いたノートの数、本文の文字数、空のセルがゼロであること)を別に数える門を足しました。
同じ型の 3 例です。
assert code == 0 が必ず通るので、assert を 1 つも持たない PoC が 3 本放置されていました。assert all(x for x in xs) と for x in xs: assert ... は、xs が空なら無条件に通ります。探って締めても 126 件(84 ファイル)が残ったので、件数を台帳に固定し、増えたら赤にしました(減らす方向にだけ動くラチェット)。教訓: 門には「この門は空を通さないか」を確かめる門が要る。いちばん確実なのは、門をわざと壊して、赤になるのを見ることです。Fullseye には「門が本当に全部の PoC を見ているか」「公開名の門が空でないか」を確かめるテストが、門ごとに付いています。
探針 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 件は、変異を入れて殺せるか確かめてから信じる。
合成の門 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 乗で変わる、など)。
この記事を書いている日に、線虫のコネクトーム(神経の配線図)を測る op と PoC を足しました。その 1 日で門が捕まえたものを、型と並べて置きます。
round は偶数丸め(2.5 → 2)なので、半整数が偶数の階級に寄ります。生物の周期性に見えるので危ないところでした。合計(整数)のまま数えると消えました。| 種類 | 真値 | 例(テスト) |
|---|---|---|
| 閉形式・恒等式 | 定理・解析解 | 次数の和 = 辺の数 / 極小曲面の平均曲率 = 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 が緑でないと出せません。
問いと方向は私が決め、実装・門の追加・敵対的な見直しは Claude Code に回しました。この記事の実例の多くは、その見直しの記録です。