fullseye

進化型アルゴリズム開発環境 — 拡散・収縮・昇格

op を手で組み合わせる代わりに、機械的に組み合わせ(拡散)、有用なものを絞り (収縮)、通ったものを語彙へ足す(昇格)。足された op は次の探索が 1 段として 選べるので、語彙は自分の発見の上に積み上がる。

  拡散              収縮                   昇格
  chain_mine   →   evolve / promote  →   backends_macro
  (組み合わせ)      (課題で絞る)          (語彙に足す)
      ↑                                        │
      └────────── 次の探索は増えた語彙で ────────┘

司令塔は tools/evolve_loop.py

この環境の本体は「規律」であって自動化ではない

語彙を増やすだけなら合成はいくらでも作れる。しかしそれは探索空間を薄めるだけで、 悪い op が 1 本入ると、それを引く将来の探索すべてを汚染する。だから増やす経路 (拡散)と、通さない規律(ゲート)を同じループに置いてある。

段の責務は混ぜない:

責務 やらないこと
mine 候補を出す。記述子を多次元のまま残す 良し悪しの判定
screen 土俵に乗るかの安い判定(空・長すぎ・非決定的・課題が受け付けない型) 有用性の判定
gate counterfactual utility + 重複排除 + 容量上限 候補の生成
report 通ったものと落ちたものを理由つきで出す 都合の良い要約

screen に有用性の判定を混ぜると、安い判定で本物の発見を捨てたことに誰も 気づけなくなる。だから恒等に近い連鎖でも screen は落とさず gate に渡す。

拡散 — 組み合わせ空間を舐める

tools/chain_mine.py。型付きプールから op をランダムに連鎖させ、成功した連鎖を 振る舞い記述子つきで記録する(tools/chain_fuzz.py は同じ仕掛けで失敗の方を拾う バグ発見器。運用は docs/CHAIN_FUZZ.md)。

記述子を単一スコアに潰さないのが要点。「大きく変える」ことと「有用」は別物で、 潰すとノイズを足すだけの op が勝つ。

収縮 — 課題で絞る

evolve.py / robust.py が problem の fitness でパイプラインを最適化する。 選択は train のみ、holdout は追跡するが選択に使わず、locked holdout は最後に 1 度だけ — この 3 分割が「疑似方程式の罠」に対する正直さの担保 (docs/WAVE0_STABLE_SLOTS.md)。

必ず identity / random / hand-designed の 3 baseline と比べること。過去の実測 では進化 0.041 に対し random search が 0.042 と僅差で、探索空間が小さいと進化の 優位は消える。

昇格 — 語彙へ足す判定

tools/promote_gate.py。3 つの判定を全部通る必要がある。

1. counterfactual utility(反実仮想効用) 問うのは「この op はその課題で強いか」ではなく「この op が語彙にあると、 ワークロード全体がどれだけ改善するか」。実装は候補を 1 段として使ったときの スコアと、既存語彙の最良 1 段を全 problem で比較する。既存語彙で届く分を 差し引くのが要点で、「強い」だけの op ではなく「既存では届かないところに 届く」op を通す。

限界(正直に): 本来は「候補を語彙に入れて進化をやり直したときの改善」を測るべき だが、1 候補あたり数十分かかる。1 段置換はその近似で、utility_method に明記して 出力する。

2. 振る舞いによる重複排除 固定プローブ上の出力が既存 op と一致すれば却下。式の等価性を潰す装置(e-graph / equality saturation)の数値版。プローブ全件で一致したときだけ重複と判定する (1 枚の偶然一致で本物の発見を捨てないため)。プローブ上で区別できないという 意味であって、数学的な等価性の証明ではない。

3. 容量上限(capacity bound) 昇格を無制限に続けると、到達可能なモデル族が capacity-bounded でなくなり 一般化の保証が壊れる。上限(既定 32)に達したら押し出しを要求し、何を落としたかを 必ず記録する

判定は厳しい側へ倒してある — 改善が測定誤差(既存最良比 +0.5%)以内なら却下。

2 つの op 宇宙とその橋(2026-09-01 実測)

着手時、fullseye には op の宇宙が 2 つあり、名前が 3 個しか重なっていなかった

  op 数 誰が使うか 規約
ops.py REGISTRY 742 進化 fn(v, a, b)、sort = image/region/…
型付きカタログ 382 ファザー・facade 宣言された入出力型、多引数

つまり進化は点群 op も 1-D op も数学 op も一度も組み合わせたことがなく、 自己拡張レジストリも狭い側でしか育っていなかった。backends_typed.py がその橋。

安全性の根拠: ops._candidates(sort)in_sort で絞るだけなので、新設 sort だけを足せば既存 sort の候補リスト長は変わらない。長さが変わるとゲノム→op の 写像がずれ、既存 champion を黙って書き換えてしまう(docs/WAVE0_STABLE_SLOTS.md)。 既定は新 sort(points / signal / matrix / cimage)入力の 58 op のみ。既存 sort を 入力に取る op は IMGEVOLVE_WIDE_VOCAB=1 の opt-in で 125 op まで広がる。

ノブ: 最初は DNA op に倣って a, b を凍結したが、実測すると調整余地が 0/58 で、 既定値が恒等な op は文字どおり無意味な枠だった。かといって絶対範囲を決めるのは 出所不明のパラメータの捏造なので、著者自身が書いた既定値の 1/4 〜 2 倍という 相対スケールだけを許した(21/58 が可変に)。

使い方

# 判定だけ回す(既存の採掘結果を使う)
py -3.11 tools/evolve_loop.py --skip-mine --mine-out out/chain_mine_smoke.jsonl

# 拡散から通す。広い語彙で
IMGEVOLVE_WIDE_VOCAB=1 py -3.11 tools/evolve_loop.py --chains 1500 --length 4 --seed 7

# 個別 op の昇格判定
py -3.11 tools/promote_gate.py --op macro_denoise --max-existing 120

# champion を語彙へ(2 段ゲート: hand baseline + counterfactual utility)
py -3.11 champion_to_macro.py --champion out/…/champion_denoise.json \
    --name macro_x --utility-gate

語彙への書き込みは --write を明示したときだけ。既定は「通る候補の一覧を出す ところまで」で、不可逆に近い操作を暗黙に実行しない。

狭い sort — 語彙の量より「同型に留まる比率」が効く

新 sort を開いて最初に分かったのは、語彙を増やしただけでは探索できないという ことでした。決めるのは op の数ではなく、その sort に留まる op の比率です。

sort 候補 同型を返す 6 段すべて留まる確率
points 25 13 (52%) 2.0%
signal 23 17 (74%) 16.3%

6 スロット固定の encoding では、ほとんどの個体が sort から逃げて 0 点になり、 進化は勾配を得られません。実測: 3200 評価を回しても点群の locked は 0.436 で、 何もしない 0.675 を下回りました — 進化が baseline に到達できないので、 「baseline を超えたか」という比較そのものが成立していませんでした。

対処は 2 つで、どちらも実測から出たものです。

1. 既知の baseline から探索を始めるops.genome_for_names(decode の 逆写像)を足し、evolve.run(seed_baselines=True) で trivial と hand を初期集団に 置きます。全 identity に当たる確率は点群で 4e-9 なので、乱数では届きません。 既定は off なので既存の実行はバイト同一で、選択は train のみ・locked は 不可触のまま — honesty guard は緩めていません(種を置くだけです)。

2. 経路の食い違いを潰す — 同じ「何もしないパイプライン」が、ゲノム経路で 0.2016、名前経路で 0.6616 になっていました。identityout_sortANY なので「新 sort はクリップしない」除外をすり抜け、ゲノム経路だけが 6 回 クリップされていたためです。ANY は「入ってきた sort を保つ」意味なので、 _effective_out_sort で解決しました。

結果(locked holdout、実測):

課題 修正前 修正後 trivial hand
points_denoise 0.193 0.7182 0.6748 0.7298
signal_denoise 0.834 0.9736 0.9452 0.9810

点群では進化が自力で 統計的外れ値除去 に到達しました — 点群デノイズの 正攻法そのものです。まだ hand には届いていませんが(同じ op をやや悪い パラメータで見つけた状態)、比較が意味を持つ土俵には乗りました。

端から端までの実証(2026-09-01、実測)

環境が設計どおり働くことを、1 本の通しで確かめました。結論から言うと語彙は 増えませんでした — 何も昇格に値しなかったからです。 それがこの環境の正しい 振る舞いです。

  1. 拡散 — 1500 連鎖を 30 秒で採掘し、988 の完走連鎖から 205 の代表を得た (同じ seed の 2 回走が sha256 でビット一致)。
  2. 収縮 — 点群課題で 3 seed × 60 世代 × 集団 32 を回し、3 回とも同じ解に 収束。進化は自力で 統計的外れ値除去 に到達した(点群デノイズの正攻法)。
  3. 判定 — champion は train で hand を上回った(0.7223 対 0.7163)。しかし 一度も見ていない locked では負けた(0.7182 対 0.7209)。
  4. 昇格ゲート — 却下。beats_hand_on_locked_holdout=False

train で +0.6%、locked で −0.4%。これは「疑似方程式の罠」そのもので、3 分割の honesty guard が捕まえるために存在する現象です。仮に train だけを見ていたら、 汎化しない op が語彙に入り、それを引く将来の探索すべてを汚染していました

昇格ゲートの MIN_RELATIVE_GAIN(既存最良比 +0.5%)も同じ幅を弾くように設定して あり、2 つの独立した基準が同じ判断に至ったことになります。

語彙を広げても、使える仕事が無ければ空回りする(2026-09-01 実測)

新しい op 族(ライトフィールド / 光子計数 / 鏡面分離 / モーション増幅 / 四元数)を 足してカタログは 400 → 475 op になったが、進化はそれを一度も使えなかったevolve_loop を回すと落選理由の筆頭がこれだった:

落選  5  課題が受け付けない入力型(histcube)
落選  4  課題が受け付けない入力型(lightfield)
落選  2  課題が受け付けない入力型(counts)
   ...
不通過  2  進化レジストリに無い op: 'fraunhofer_pattern'

課題 12 件がすべて古い型(image / volume / points / signal)だったからで、 「増やす経路(拡散)と通さない規律(ゲート)を同じループに置く」という設計は、 その語彙を使える仕事が無いと空回りする

判断を 1 つ訂正した

当初 lightfield / counts / histcube を既定語彙から外していた。理由は 「その族の入口 op(lf_from_mla: image→lightfield など)が既存 image sort を 入力に取るので、既定に入れると image の候補リストが動く」。前半は正しいが 結論が過剰だった_candidates は in_sort でしか絞らないので、除外すべきは 入口 op だけで、消費側を足しても動くのは新設 sort の候補リストだけ。実測:

sort
image 523 523
region 130 130
points 33 33
(全既存 sort) 不変 不変
レジストリ全体 809 824

「誰も産まないので死んだ語彙になる」という懸念も画像から始まる探索に限った 話で、Problem.in_sort がその sort なら入力は課題が供給する。

ただし histcube は入れなかった: 消費 op が dtof_cube_depth 1 つだけで 出口が image なので、既定に入れても「1 手で外へ出るだけ」= 進化する余地がゼロ。 使える仕事が無い語彙は足さない

新しい型の課題を 2 つ足した結果

3 seed / 12 世代 / 個体 12 の小さな走行。locked holdout(seed+20000、champion に 対して一度だけ採点する本当に触れていない split)と観測 holdout(seed+10000、 毎世代採点されるが選択には一度も使わない split)を両方載せる — この 2 つは 交換可能ではないし、どちらか一方だけでは勝ち負けが決まらないため。

課題 (入力 sort) split 恒等 手(既存 op 単体の最良) 進化(train 選抜) 手比 進化の seed 間 min / max / std 手超え
photon_denoise (counts) locked 0.4174 0.5536 0.7845 +41.7% 0.6517 / 0.7845 / 0.0608 3/3
  観測 0.3265 0.4433 0.7944 +79.2% 0.5254 / 0.7944 / 0.1263 3/3
vibration_map (video) locked 0.0000 0.7163 0.8941 +24.8% 0.8940 / 0.8953 / 0.0006 3/3
  観測 0.0000 0.6791 0.8783 +29.3% 0.8769 / 0.8870 / 0.0045 3/3
lf_slope (lightfield) locked 0.0000 0.5219 0.5465 +4.7% 0.4930 / 0.5465 / 0.0224 1/3
  観測 0.0000 0.4882 0.5075 +4.0% 0.4489 / 0.5075 / 0.0241 1/3
specular_removal (rgbimage) locked 0.4905 0.8343 0.6277 −24.8% 0.3037 / 0.7549 / 0.1900 0/3
  観測 0.4422 0.8730 0.7761 −11.1% 0.3020 / 0.7761 / 0.2177 0/3

再現(1 行、seed ごとにまっさらな子プロセスで走る):

foreach ($p in 'photon_denoise','vibration_map','lf_slope','specular_removal') { py -3.11 robust.py --problem $p --seeds 3 --gens 12 --pop 12 --isolate --workdir out/rb_2026_09_02_A }

workdir に baseline_<課題>.json を置かないこと(空のディレクトリで走らせる)。 evolve.run はそのファイルがあると ① split config をそこから読み ② 初期集団の 個体 0 をファイル中の乱択最良ゲノムで置き換える。つまり baseline.py を先に 走らせるかどうかで進化の結果が変わる。上の表は「置かない」側の数字。

成果物 out/rb_2026_09_02_A/robust_<課題>.json恒等と手の基準線もその JSON に 入っている(baseline_trivial* / baseline_hand*)ので、上の表は成果物から そのまま読める。各行は測定時の commit / measured_at / split_config を持つ。 2026-09-02 に独立に 3 回(out/rb_2026_09_02_A / _B / A の上書き再実行) 測り、時刻と commit 以外は全フィールドが一致した。その間も別作業の commit が 入り続けていて HEAD は 10 通り以上動いているが、この 12 個の数字はどれも 動いていない。だから各行の commit は「この数字が生きていた時点」の印。

恒等の行を見ると、locked と観測で 0.09 ずれている(photon_denoise: 0.4174 対 0.3265)。op を 1 つも適用しない値が動くのだから、これは抽出そのものの ばらつき(1 split が 8 件しかない)であって手法の差ではない。同じ理由で手比も locked +41.7% / 観測 +79.2% と大きく違う。どちらか一方だけを載せた表は、 選んだ側の抽出運を性能として報告してしまう。

訂正(2026-09-02)

この表の前版(恒等 0.2664 / 手 0.5371 / 進化 0.7760 ほか)は、いま同じコードで 再現しない。 12 個の数字のうち 10 個が合わない(合っている 2 個は vibration_maplf_slope の恒等で、どちらも 0.0000 ―― 相関が定義できず 0 に丸まる値なので一致しても情報が無い)。原因は数字の側ではなく 記録の側だった:

基準線がどこから来たかは特定できた。 4 課題とも生成器は size を使わない (形は 32×32 / 256 bin に固定)ので、動かせるのは件数 n と split seed だけ。 n 1–16 × cfg seed 0–5 × 3 split を総当たりすると、旧表の基準線はいま のコードで 1 通りだけ再現する:

旧表の値 再現できる条件
photon_denoise 恒等 0.2664 / 手 0.5371 n=8・cfg seed 2 の locked (=prob.make(8,64,20002))
vibration_map 手 0.6973 n=8・cfg seed 1 の locked
lf_slope 手 0.5794 n=8・cfg seed 1 の locked
specular_removal 恒等 0.4115 / 手 0.8406 n=8・cfg seed 1 の locked

一方、保存されている champion は cfg seed 0 の走行(out/rb_*/)。つまり 1 行の中で基準線と champion が別の抽出で、しかも課題ごとに cfg seed が 違っていた。実測で裏を取ってある: py -3.11 baseline.py --problem photon_denoise --seed 2robust.py で恒等 0.2664 / 手 0.5371 がそのまま出る (out/rb_probe_cs2/)。lf_slope --seed 1 も手 0.5794 を再現する (out/rb_probe_cs1/)。進化列だけはどの cfg seed でも再現しない ―― cfg seed 1 の lf_slope は 0.557、cfg seed 0 は 0.5465 で、旧表の 0.5907 はどちらでも ない。成果物が無いので、この列がどう作られたかはもう追えない。それが 「基準線を成果物に書かない」ことの代償で、今回の修正が塞いだ穴そのもの。

いまは robust.py進化を始める前に自分で基準線を測って JSON に書くnull の基準線は書き出さずに abort する。ディスク上の baseline_<課題>.json は 突き合わせ用で、食い違えば baseline_file_mismatch に両方の値を残す(どちらかが 黙って勝つことはない)。回帰テスト = tests/test_robust.py::test_floors_are_never_null_even_without_a_baseline_file

測定はまっさらな子プロセスで

--isolate は seed ごとに新しいインタプリタで evolve.py を起動する。連鎖 ファザーの到達数が同じプロセスで先に何を走らせたかに依存する(まっさら 445/515 に対し evolve.run を 20 回回した後 433/515、seed も引数も同一)と 分かっているため、引用する数字を同居プロセスで測らない。分離が結果を変えない ことは tests/test_robust.py::test_isolated_seeds_reproduce_the_in_process_result で固定している(変えてよいのは走る場所だけで、返る値ではない)。

負けた 1 件がいちばん教育的だった。specular_removal の champion は 四元数族を経由する族をまたいだ経路を見つけている:

tb_rgb_to_quaternion -> tb_quat_color_rotate -> tb_quaternion_to_rgb -> ...

観測 holdout では 0.7761 と手(0.8730)に迫って見えたのに、locked では 0.6277 (手 0.8343)まで落ちた。しかも locked の seed 間ばらつきが min 0.3037 / max 0.7549 / std 0.1900 と大きい。train で選んだ champion が汎化しなかった典型で、 観測 holdout だけ見ていたら「惜しい」と読んでいた。勝った vibration_map は逆に locked のばらつきが std 0.0006 と極端に小さい。 ばらつきの開示が効いた実例として残す。

photon_denoise の champion は 光子族だけで閉じた合成だった:

tb_tcspc_irf_convolve -> tb_tcspc_background_subtract -> tb_spad_deadtime_correct

= 新しい族が「単体で使える op」ではなく op を繋いだ手順として価値を出した 最初の例。lf_slope の champion は手と同じ tb_lf_epi_slope で始まり画像 フィルタを 3 段足した形で、伸びは locked で +4.7% に留まる。しかも手を超えた のは 3 seed 中 1 つだけ(seed 間 std 0.0224、手との差 +0.0246)なので、 「勝った」と言うにはばらつきに埋もれている。

ここで自分が踏みかけた罠(記録)

lf_slope を最初 +38% の改善と書きかけた。手の基準線 0.3945 と進化の 0.5465 を比べたためだが、この 2 つは別の抽出で測った数字だった。同じ locked split で測り直すと手は 0.5219 で、実際の差は +4.7%比較は必ず同一の split で取る — 違う抽出どうしを比べると 8 倍近く 盛れてしまう。関連: [[feedback_beat_the_null_before_claiming]]。

(この節自体が同じ罠を二度踏んでいた。上の 0.5794 という「測り直した手」は cfg seed 1 の locked で、champion の 0.5465 は cfg seed 0 の locked。 同じ locked split に揃えると手は 0.5219 になる。訂正の数字までもう一度 別の抽出だった — 「同じ split で測り直した」と書いた本人が、split の名前 だけ合わせて cfg seed を合わせていなかった。基準線を成果物に書かないかぎり、 何度でもこれが起きる。)

もう 1 つ。lf_slope の手の基準線には当初 tb_lf_depth_from_focus を置いて いたが、昇格ゲートが既存 op 単体の最良を全探索して tb_lf_epi_slope が 2 倍以上強いと示した。弱い方を手に置くと課題が実際より易しく見えるので 差し替えた。基準線は「自分が最初に思いついた手」ではなく「既存で最良の手」

既知の限界(次の一手)

関連