fullseye

連鎖ファザー(chain fuzz)— op を鎖にして揺さぶる第三の品質保証層

tools/chain_fuzz.py。単体テスト・敵対的検証に続く 3 番目の層で、狙う不具合は 「単体では全部通るのに、op を鎖にしたときだけ現れるもの」です。

型契約の嘘、tuple/list 梱包の不一致、NaN の漏出、想定外の例外種、拡大系の 指数増殖 —— どれも単体テストの視野の外にあります。

使い方

# 拡散: 2000 本の連鎖をランダムに張って署名を集める
py -3.11 tools/chain_fuzz.py --chains 2000 --length 8 --seed 1 --out out/chain_fuzz.jsonl

# 収束: 各発見を「その署名を出す最小の op 列」まで削る
py -3.11 tools/chain_fuzz.py --minimize out/chain_fuzz.jsonl
py -3.11 tools/chain_fuzz.py --minimize out/chain_fuzz.jsonl --only compute_fpfh

# 再走: 最小再現をそのまま実行(デバッガを当てる入口)
py -3.11 tools/chain_fuzz.py --replay 5000312 --script random_dropout,compute_fpfh

# カバレッジ内訳: どの op が走り、どの op が一度も走らなかったかを書き出す
py -3.11 tools/chain_fuzz.py --chains 1500 --coverage-out out/coverage.json

# 狙い撃ち拡散の強さ(0=一様、既定 0.5)
py -3.11 tools/chain_fuzz.py --chains 1500 --explore 0.7

分類(署名の種別)

種別 意味 扱い
CONTRACT 文書化された ValueError 。fail-closed が仕事をした
SUSPECT それ以外の例外(TypeError/IndexError/…) 契約の穴。入口検証を足す
TYPEMISS 目録の宣言 out 型と実際の返りが違う 型の嘘。adapter か宣言を直す
NONFINITE 有限入力から NaN/Inf が無言で出た 毒の漏出。ただし契約かを先に疑う
GROWTH 産物が pool 上限超(拡大系の指数増殖) 記録して捨てる(無言で切らない)
SLOW 1 op が閾値超(既定 10s) 性能スメル
OPTIONAL optional 依存の ImportError 白(記録しない)

NONFINITE は「非有限を出した op」を責める前に 出所と契約を追うこと。実例: sdf_subtract の inf は esdf の「全自由なら +inf」という文書化済み契約を min/max 代数が正確に伝播しただけで無実、一方で同family の sdf_smooth_union だけが算術(inf−inf)で全 NaN になる本物のバグでした。契約として正しい非有限は NONFINITE_BY_CONTRACT理由つきで登録します。

設計上の要点(なぜこの形か)

型付きプール

各 op の宣言(in 型 → out 型)に従ってプールから引数を引き、産物を戻します。 型語彙は目録と共有(voxel/points/signal/matrix/table/pairs/roots…)。 TYPE_CHECKS が各語彙の判定関数を持ち、宣言と実際の返りの一致を機械検証します。

連鎖固有 seed

連鎖 iseed * 1_000_003 + i で回ります。共有 rng だと「i 番目だけを 後から再走する」ことができず、最小化が成り立ちません。

引数抽選の乱数を位置から独立させる(最小化の生命線)

候補抽選(どの op を次に引くか)は連鎖 rng、引数抽選は (連鎖 seed, op 名, その op の出現回数) から導いた別の乱数源を使います。 これを分けないと、無関係な op を 1 つ落としただけで以降の抽選が全部ずれ、 最小化の再走が原理的に再現しません。実測: 分離前は再現 48/65(74%)、 分離後は 58/58(100%)。同じ理由でプールの型選択も sorted(pool) に固定 しています(dict の挿入順は op を落とすと変わるため)。

上限とストール自己申告

運用の型(拡散と収束を繰り返す)

  1. 拡散(2000 連鎖)→ 署名一覧を得る
  2. --minimize で各署名を最小 op 列へ
  3. 最小再現を手で実行して実証してから直す(推測でパッチを当てない)
  4. バグ 1 件を直したら、同クラスを兄弟コードで一掃する (例: float32 桁あふれは 20 箇所の同種キャストに 1e39 を実際に流して、 該当した 4 op だけを直した — 実測で該当しないものは触らない)
  5. 同じ seed で再走し、署名が消えたことを確認

これまでの戦果(実測)

署名数 主な発見
第 3 波 型の嘘 22 件、RESULT_ADAPTERS+call() を一級機能化
wave-4 103 TYPEMISS 7 op、SUSPECT 9 種、第 6 家系「小さい入力→巨大な内部割当」(TPS 12GB / PSF 64GB / CPD)
wave-5 4 float32 桁あふれの無言 NaN、sdf_smooth_union の inf→NaN、入口契約の穴 2 系統
wave-6 非 CONTRACT 0 収束(白 63 種のみ)
math 追加 2 mat_svd/mat_eigh の型の嘘を初走行で即検出
wave-7 172(NONFINITE 2) 引数抽選の乱数を位置独立にした = 探索経路が変わり新しい発見poly_eval の float64 オーバーフロー無言 inf、chamfer_distance の空点群無言 NaN(→ metrics3d の 5 兄弟を一掃)
optics 追加 18(全 CONTRACT) 光学 18 op を登録(opsoptics、新語彙 jones/stokes)。非 CONTRACT 0 — バグは登録前の敵対監査で 6 件取り切っていた(文字列が長さとして通る / MTF の無言 NaN / determinant の無言 inf / 高次 Zernike 基底の桁落ち / 108GB の内部確保 / 未報告の求積誤差)。行列の形が決まっている abcd_trace/jones_apply/mueller_apply は一様抽選だと実経路を一度も通らないため(実測 800 連鎖で 0 回)、OP_ARG_BUILDERS で半分は妥当な行列を合成する
ライトフィールド + 光子計数 追加(2026-09-01) 40 新 34 op を登録。非 CONTRACT 0(バグは登録前の敵対監査で 8 件取り切り済み)。この波の主眼は op でなくファザー自身の穴 3 件(下記)
未実行の掘り起こし(2026-09-01) 62 「必須引数を束縛できず黙ってスキップ」を解消したところ、これまで一度も実行されていなかった 3d op から非 CONTRACT 5 件が出た(型宣言と実装の食い違い)

「発見ゼロ」を頑健さと取り違えない ― ファザー自身の穴 4 件(2026-09-01)

この層自体が嘘をつきうる、という実測の記録です。4 件とも「バグが無い」と 「そもそも実行されていない」を区別できない形をしていました。

  1. 既定値つきの引数を上書きできなかった。 既定値がプールの寸法と噛み合わない op は毎回 ValueError で弾かれ、一度も実行されないまま「発見ゼロ」に数えられる (lf_from_mla の既定 angular=(5,5) は 32x32 を割り切れず 1200 連鎖で 0 回)。 → OP_PARAM_HINTS(op 名で狙い撃ち)だけ既定値も上書きできるようにした。 名前レベルの PARAM_HINTS を既定値に効かせると既存 op の挙動が一斉に変わる ので、そちらは据え置き。
  2. カバレッジが数しか出なかった。 「304/417」では残る 113 が頑健なのか到達 不能なのか分からない。→ --coverage-out と族ごとの内訳。出した瞬間に photon 族 10/17 =「fail-closed が効きすぎて 7 op が一度も実行されない」が判明。
  3. 署名が数値で分裂していた。 良いエラーメッセージほど実行固有の数を含むので、 素の文字列で同一視すると同じ 1 件が毎回別署名になる(署名 99 → 238 の増分が ほぼ全部これ)。→ 署名を作る前に数値を伏せる。238 → 40 に収束。
  4. 必須引数を束縛できず黙ってスキップしていた。 未到達 100 op の内訳を実測 したら 70 件がこれで、記録も残っていなかった。不足していたのはカメラ内部 行列 K、姿勢 R/t、RANSAC 閾値 thresh、voxel 化の bounds/res など。 → ヒントを追加。カバレッジ 321 → 336、そして隠れていた非 CONTRACT が 5 件

型の到達可能性は不動点で解ける(初期プールの型から、入力が揃う op の出力型を 足していく)。実測では 434 op 中 refine_peak_newton 1 件だけが score 型の 生産者不在で構造的に到達不能だった → 種を追加。tests/test_chain_fuzz_minimize.py がこの不変量を固定している。

狙い撃ち拡散の効果(内訳を混ぜない)

候補が数百ある中で特定の op が長さ 6 の枠に入る確率は低い。そこで連鎖ごとに 目標 op を 1 つ決めて寄せる方式を入れた(目標は連鎖固有 seed から引くので --minimize/--replay の前提を壊さない)。同一コード・1500 連鎖・長さ 6 の実測:

explore カバレッジ 署名数
0.0(一様) 336/434 55
0.3 340/434 68
0.7 341/434 62

狙い撃ちの寄与は +5 op です。321 → 336 の伸びは上記 4 番(引数ヒント)の効果で あって狙い撃ちの効果ではないので、混ぜて報告しないこと。なお小規模では狙い撃ち の方が悪くなります(60 連鎖 × 長さ 5 で 67 op vs 一様 167 op)—— 1 連鎖の幅を捨てて 希少 op への到達を買う取引なので、集計値の大小は走行規模で反転します。

先に試した「まだプールに無い型を産む op を優先する」型空間バイアスは効きません でした(1500 連鎖で 321 → 322)。プールが最初から全生成器型で埋まっているため、 優先対象がすぐ尽きるからです。効かなかった案も消さずに残しておきます。

wave-7 の 2 件はどちらも --minimize が 5→2 / 4→2 op に削り、その最小再現を 手で実行して初めて原因が確定しました(前者は「長い信号を係数に取り違え」、 後者は「上流の outlier 除去が雲を空にした」)。削り込みが無ければ 8 op の 連鎖から原因を読み解く必要があった — 収束フェーズの実利はここにあります。

拡張するとき