fullseye

言語 / Language: 日本語 · English

テストが全部緑でも、門は嘘をつける ―― 画像計測ライブラリで門が見逃したものと、見逃さない門の作り方

画像処理と計測の関数(以下 op)を約 2,200 本持つ自作ライブラリ Fullseye を育てています。op が正しいかを確かめる検査を、ここでは門と呼びます。pytest のテストも、生成物が最新かを見る検査も、CI も、全部「通さないものを通さない」ための門です。

門は 7,000 を超える関数で 1 万 5 千件ほど走り、全部緑です。それでも、門が緑のまま結果が嘘だったことが何度もありました。この記事は、その嘘を 4 つの型に分けて、それぞれ実例と「どう門を作り直したか」を置いたものです。型は Fullseye に固有ではなく、テストを書く人なら誰でも踏みます。

先に結論

型 何が起きるか 作り直し方
門は在るのに、事故の場所で走っていない 開発機の checkout では緑、配布物や Linux では赤 事故の起きる場所(wheel・Linux・CI)で数える
緑なのに中身が空 空の出力・空のリスト・捨てられた判定を「一致」と数える 中身の量を別に数える。門自身を壊して発火を確かめる
全数を回したのに覆っていない 入力 1 枚・パラメータ 1 組で「全部見た」と思う 構造の違う入力を足す。免除台帳が長ければまず門を疑う
自分の仕様と合っているだけ 自分で書いた式で答え合わせをしている 独立な真値(公表値・第 2 実装・変換に対する不変性)を外から持ち込む

用語(先に読むと楽)

型 1: 門は在るのに、事故の起きる場所で走っていない

wheel から op の 26 % が消えていた。 配布用の wheel を作り直したとき、pyproject.toml の py-modules にモジュールを書き忘れ、wheel を入れた環境では 224 本の op が消えていました。「読み込みに失敗した backend はゼロ」を確かめる門は在りました。ただしその門は、開発用に checkout したソースの上でしか走っていませんでした。checkout には全部のファイルが在るので、原理的にこの事故を再現できません。

作り直しは「wheel を実際に作り、別の仮想環境に入れ、checkout と wheel の op の集合を突き合わせる」ジョブを CI に置くことでした。後日、同じ書き忘れ(backends_texture)をしたときは、このジョブが即座に赤になり、1 行で直りました。

同じ型をあと 3 つ踏んでいます。

教訓: 門を書いたら「この門は、事故が起きる環境で走っているか」を確かめる。開発機で緑なのは、直った証拠になりません。

型 2: 緑なのに中身が空

空の出力は、どんな比較にも「一致」する。 生成物が最新かを見る門(全部作り直して、作業ツリーが変わらないことを確かめる)は、生成器が何も出さなくなっても緑のままです。実際、索引が「op ノート 0 本」と書いたまま 6 言語で公開され、ヘルプは画像 0 枚・Markdown が生のまま 60 ページ、という状態を門は通しました。→ 中身の量(例の付いたノートの数、本文の文字数、空のセルがゼロであること)を別に数える門を足しました。

同じ型の 3 例です。

教訓: 門には「この門は空を通さないか」を確かめる門が要る。いちばん確実なのは、門をわざと壊して、赤になるのを見ることです。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 日で門が捕まえたものを、型と並べて置きます。

門の種類(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 が緑でないと出せません。

門を足すときの手順

  1. 真値をどこから持ってくるかを先に決める。自分の書いた式で答え合わせをしない。
  2. 事故が起きる場所で走るかを確かめる(配布物・Linux・CI)。
  3. 門を壊して、赤になるのを見る。赤にならない門は門ではない。
  4. 空を通さないかを別に数える(出力の量・見た件数)。
  5. 入力とパラメータを 1 つにしない。構造の違う入力を最低 2 つ。
  6. 免除は理由つきの台帳に書き、減らす方向にだけ動くラチェットにする。
  7. 合否だけでなく中身を残す。長い検査の全出力はファイルへ。合否だけだと、次にもう 1 回同じ時間を払う。

向かないこと・限界

作り手について

問いと方向は私が決め、実装・門の追加・敵対的な見直しは Claude Code に回しました。この記事の実例の多くは、その見直しの記録です。