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%)以内なら却下。
着手時、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 を開いて最初に分かったのは、語彙を増やしただけでは探索できないという ことでした。決めるのは 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 になっていました。identity の out_sort が ANY
なので「新 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 をやや悪い パラメータで見つけた状態)、比較が意味を持つ土俵には乗りました。
環境が設計どおり働くことを、1 本の通しで確かめました。結論から言うと語彙は 増えませんでした — 何も昇格に値しなかったからです。 それがこの環境の正しい 振る舞いです。
beats_hand_on_locked_holdout=False。train で +0.6%、locked で −0.4%。これは「疑似方程式の罠」そのもので、3 分割の honesty guard が捕まえるために存在する現象です。仮に train だけを見ていたら、 汎化しない op が語彙に入り、それを引く将来の探索すべてを汚染していました。
昇格ゲートの MIN_RELATIVE_GAIN(既存最良比 +0.5%)も同じ幅を弾くように設定して
あり、2 つの独立した基準が同じ判断に至ったことになります。
新しい op 族(ライトフィールド / 光子計数 / 鏡面分離 / モーション増幅 / 四元数)を
足してカタログは 400 → 475 op になったが、進化はそれを一度も使えなかった。
evolve_loop を回すと落選理由の筆頭がこれだった:
落選 5 課題が受け付けない入力型(histcube)
落選 4 課題が受け付けない入力型(lightfield)
落選 2 課題が受け付けない入力型(counts)
...
不通過 2 進化レジストリに無い op: 'fraunhofer_pattern'
課題 12 件がすべて古い型(image / volume / points / signal)だったからで、 「増やす経路(拡散)と通さない規律(ゲート)を同じループに置く」という設計は、 その語彙を使える仕事が無いと空回りする。
当初 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 手で外へ出るだけ」= 進化する余地がゼロ。
使える仕事が無い語彙は足さない。
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% と大きく違う。どちらか一方だけを載せた表は、
選んだ側の抽出運を性能として報告してしまう。
この表の前版(恒等 0.2664 / 手 0.5371 / 進化 0.7760 ほか)は、いま同じコードで
再現しない。 12 個の数字のうち 10 個が合わない(合っている 2 個は
vibration_map と lf_slope の恒等で、どちらも 0.0000 ―― 相関が定義できず
0 に丸まる値なので一致しても情報が無い)。原因は数字の側ではなく
記録の側だった:
out/rb_*/robust_*.json は baseline_hand / baseline_trivial /
baseline_hand_locked / baseline_trivial_locked がすべて null。
robust.py は baseline_<課題>.json が無ければ基準線を null のまま書いて
先へ進む作りで、その走行では誰も baseline.py をその workdir に走らせて
いなかった。表の基準線列は成果物のどこにも記録されていない。photon_denoise の locked を 0.7845、
specular_removal を 0.6277 と記録しており、これは今日の再測定と 4 桁まで
一致する。表だけが別の計算から来ていた(記事本文が別途 0.628 と書いていて
表が 0.6039 になっている食い違いも同じ原因)。基準線がどこから来たかは特定できた。 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 2 → robust.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 倍以上強いと示した。弱い方を手に置くと課題が実際より易しく見えるので
差し替えた。基準線は「自分が最初に思いついた手」ではなく「既存で最良の手」。
counts / lightfield / rgbimage / video を足したが、
qimage / beatcube 始まりの課題はまだ無い(語彙には入れてある)。
polsweep は消費 3・自己ループ 0 で「1 手で外へ出るだけ」に近いため
histcube と同じ理由で語彙に入れていない。denom = abs(best) + 1e-12
は例外を出さずに rel = +724476067514.28 を返し、判定は PROMOTE だった
(実測 2026-09-02: vibration_map の既存 video op は tb_temporal_bandpass の
1 個だけで locked スコアがちょうど 0.0000)。いまは比を None にして
「比が定義できない」と印を付け、その課題自身の尺度(手 / 恒等の基準線)に対する
絶対改善で判定し、相対の集計からは除外する。非退化ケースの相対値は 1 つも
動かない。回帰テスト = tests/test_promote_gate.py の
test_zero_baseline_no_longer_explodes_the_relative_gain ほか 5 件。docs/CHAIN_FUZZ.md — 同じ拡散の仕掛けをバグ発見に使う側docs/WAVE0_STABLE_SLOTS.md — レジストリ拡張の危険と name-pinned championbackends_macro.py — 昇格した op が語彙に入る読み取り側