0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude が JIT コンパイラを書いたその後 #ClaudeCode

0
Posted at

jq で Project Euler が遅くて困っていたから、JIT を作らせた。
2 ヶ月後、私は Project Euler を解かなくなった。


元記事 で「Claude に jq の JIT コンパイラを作らせたら 9〜17 倍速くなった」と書いたのが今年 3 月。あれから 2 ヶ月、jq-jit を開発し続けた。数字だけ並べるとこうなる。

元記事 (2026-03-07) 現在 (v1.6.1, 2026-05-19)
commit 数 142 1,421
Rust コード 19,772 行 60,243 行
jq 比速度 9〜17 倍 100 倍以上
公式テストスイート 509/509 509/509

「もっと速くなりました」「もっとテストが通りました」という素直な続報を期待されるかもしれない。実際に起きたことは、もう少し別の方向だった。バグが大量に見つかったし、Claude は自分でテスト工学のフルセットを組み上げ、私は Project Euler を解かなくなった。順番に書いていく。

509/509 ≠ 完成だった

元記事で「公式 jq テストスイート 509 件を 100% パスした」と誇らしげに書いた。その時点では本当に通っていた。今でも通っている。問題は、それが「完成」を意味しなかったということだ。

3 月以降、jq-jit には不具合報告が積み上がった。とくに recursive な def を絡めたパターンで JIT が壊れる系統が多い。

  • 同じフィールドを両辺に置いた .f OP .f の RHS が null を返す (#634)
  • 再帰呼び出しを挟むと as $x の binding が後段で壊れる (#635, #642, #653, #655, #679)
  • reduce (gen | gen | recursive(...)) as $x ($init; ...) で stack overflow (#648)
  • 二段ネスト sieve reduce ... ($init; reduce ... (.; .[$m] += 1)) が interpreter より 8〜22 倍遅くなる (#647)
  • ネスト reduce で in-place RMW の fast path から外れる (#664)
  • v1.6.0 で reduce/foreach/NDJSON 系が 5〜10% 退行 (#673)

これらは公式テストには出ない。公式テストは「合成された最小ケース」の集合で、再帰 def を深くネストして実用ワークロードを走らせるシナリオが想定されていないからだ。テストスイートの limits は、AI が書いた実装でも人間が書いた実装でも変わらない。

「公式テストが通る」と「実用で壊れない」の間には、想像していたよりずっと広い距離があった。これが続編のスタート地点になる。

人間の指示は 3 段階で抽象を上げた

ここから少しスタイルを変える。私 (作者) の視点で書く。

バグ発見側の Claude に向けて出した指示は、2 ヶ月で 3 段階に進化した。それぞれは前段で取り逃したものを補うために生まれた。

フェーズ ①: ブラックボックス

最初は単純だった。

"jq-jit を適当に動かしてバグを見つけて issue をあげて"

これでもかなりの数を見つけた。表層の挙動バグはランダム生成された入力でもよく出る。ただ、しばらくするとパターンが偏ってきた。同じ系統のバグばかり再発見している。広く探そうとしてはいるのだが、ランダム生成の射程には限界がある。

フェーズ ②: ホワイトボックス MECE

"コードを読んで、builtin や機能や case などをベースに MECE にテストして"

これで一気に網羅性が出た。Claude は自分でソースを読み、機能カテゴリを列挙し、それぞれに対して未カバーの組み合わせを探した。バグの種類が一気に多様になった。

ただ、これも構造ベースのテストでしかないから、合成ケースの域は出ない。「実ワークロードでだけ顔を出すバグ」は依然として漏れる。

フェーズ ③: 実用ワークロード

"Project Euler を順番に解いて"

これで recursive def 系の binding 汚染、in-place fast path の取りこぼし、perf 回帰が連鎖的に取れ始めた。「実際に複雑なプログラムを書いて走らせる」ことが、何より強い検出器だった。それでもまだバグはある。それは仕方ない。

ソフトウェアテスト論では「ランダム → 構造 → 実用」の順で深さを上げるとよく言うが、AI に指示する側でも同じ進化が起きた。違うのは、各段階の 実装 を考えるのは Claude 側で、私は 抽象度 を上げ続けるだけだったことだ。

念のため書いておくと、フェーズを切り替えた動機は技術的な気づきだけではない。② をやりきった後の「一通りやった」という肯定感がなければ、③ には進めなかったと思う。テストが進むほど、私は「もう十分やったかもしれない」と納得していった。「やった感」の管理は、判断を任せる側のコストとして見えにくいが、確実に存在している。

Rust 側ではテスト工学のフルセットが組まれていた

ここで Claude 側の話に戻す。前章の曖昧な 3 段階の指示は、Rust の実装側でどう翻訳されたか。

tests/ を覗くと、5 つのロールに整理された 12 のテストファイルが並んでいた。

ロール 役割
compat compat_official.rs / compat_regression.rs 公式 jq.test 509 + issue 駆動 regression
differential diff_corpus.rs / diff_scenarios.rs jq-1.8.1 と value-level 差分
fuzz fuzz_restricted.rs / fuzz_full.rs / fuzz_error_wrap.rs proptest 生成 (filter, input) × 3 段階
self-diff selfdiff_jit_interp.rs JIT 経路と interpreter 経路の出力一致 assert
contract / coverage / enforce contract_fast_path.rs ほか fast path 単体 424 個 + ソース scrape のメタテスト

これらは私が指示したものではない。「バグを見つけて」「MECE で」「PE を解いて」と言っただけで、その先のテストインフラの設計は全部 Claude が組んだ。とくに唸ったのが 3 つある。

ひとつ目: self-diff の存在

JQJIT_FORCE_INTERPRETER=1 という環境変数を立てると、同じ Rust バイナリが JIT と fast path をすべて殺し、純粋な tree-walking interpreter として動く。jq-jit 自身に二つの実行経路を持たせ、両者の出力一致を assert する。外部の jq-1.8.x に依存しない内部 oracle だ。

「外部の oracle にも限界がある」と判断して、自分の中にもう一つ oracle を作った形になる。これは私の指示には無かった発想だった。

ふたつ目: fuzz_restricted の deliberately conservative

proptest の generator が try/catch..、入力の float リテラルを意図的に除外している。理由はファイル冒頭のコメントに長文で残されていて、要約すると「これらの shape が含まれると、まだ修正されていない既知のバグ class でずっと shrinker が止まらず、ノイズになる」。

「同じバグばっかり見つけない?」という私の素朴な疑問は、Rust 側では「generator の distribution を保守的に設計する」という具体的な実装になっていた。

みっつ目: メタテスト層

coverage_fast_path.rs はソースを正規表現で scrape して「全 detect_* fast path がコーパスの少なくとも 1 ケースで叩かれている」ことを assert する。enforce_value_factories.rsValue::Obj(Rc::new(...)) の直接構築を allowlist 外で禁ずる。テストの抜けと書き方の抜けを、別レイヤで塞ぐ。

つまり 2 章の指示は、こう翻訳されていた。

私が出した指示 Claude が組んだ実装
"適当に動かして" proptest による差分 fuzzing
"MECE で" contract_fast_path 424 個 + coverage_fast_path のメタ強制
"PE を解いて" diff_scenarios の実 script シナリオ corpus

私の抽象度の高い指示が、Claude の手で動くテストインフラに翻訳されている。その痕跡が tests/README.md に残っている。

並走の物理学: tmux × auto-merge × ポーリング

ここから運用の話をする。前章までで見えてきたのは、「指示を出す私」と「テストインフラを組む Claude」の役割分担だった。これが 2 つのセッションに分かれて並走することで、私は 1 日単位で席を離れられるようになった。

tmux で 2 つの pane を並べる。片方は「発見側」、もう片方は「消化側」。

pane A (発見側)                       pane B (消化側)
─────────────────────                 ─────────────────────
PE を解く / MECE で動かす             "issues を順番に消化して"
   ↓                                     ↓
バグ再現 → gh issue create              1 件 pick → fix + regression test
                                         ↓
                                         PR を上げる
                                         ↓
                                         gh で checks をポーリング ← 待機点
                                         ↓
                                         pass → auto-merge → git pull
                                         ↓
                                         次の issue へ

肝心なのは消化側の最終段、gh CLI で CI の checks をポーリングしている部分。session は token をほぼ食わない待機状態に入り、CI が確定したら起きて merge に進む。Claude を寝かせるのではなく、CI を Claude の腕時計にした。

消化側を駆動するプロンプトは "issues を順番に消化して" だけ。その先の手順は CLAUDE.md の Issue Fix Workflow に静置してある。fix → regression test 追加 → bench 比較 → conventional commit → PR → checks 待ち → merge → 次へ、を 1 文で起動できる。

元記事 (11 日, 2026-03 上旬) 続編 (2 ヶ月, 2026-03-12 〜 05-19)
人間が送ったメッセージ数 20 通 (メインセッション) 起動と一文プロンプトのみ
session 構成 1 セッションで最適化ループ 2 セッション並走 (発見 × 消化)
CI の役割 (補助) Claude の腕時計

元記事に「20 通のメッセージで JIT が生まれた」と書いた。続編は「待ち時間を CI に外注した」と書ける。

Rust が書けない作者

ここまで来て一つ正直に書く。私は Rust を書けない。jq-jit のオーナーを名乗っていながら、コードを読んで実装の善し悪しを判断することができない。Cranelift IR の解釈もできない。パフォーマンス手法の妥当性も判断できない。

私が Claude に出せる指示は、3 つしかなかった。

  • テスト戦略の抽象指示 (前章までの 3 段階)
  • issue としての症状報告 (PE で踏んだ再現手順を貼る)
  • merge と push の物理アクション

逆に出せないのは、実装に踏み込んだあらゆる判断だ。「ここはこう書いた方がいい」「この最適化は怪しい」「IR をこう変えるべき」が全部出てこない。

そこに残るのは「信じるしかない」という感情だった。

その感情を運用に落とすために、テスト群が機能していた。509/509 公式テスト、jq-1.8.1 との differential、JIT と interpreter の self-diff、proptest による広域 fuzz。これらが緑で揃ったとき、私は「動いている」と信じることができる。動いているかどうかを私が判断することはできないが、「動いていない場合に検出される」ことは複数経路で担保されている。

元記事の hook で私はこう書いた。「人間が書いた行数はゼロ」。続編で同じ調子で書くなら、こうなる。

人間が判断した内容もほぼゼロだった。

「やった感」の管理という見えないコストの話を 2 章の末尾に書いた。あれは事実上、自分の不安をどう手懐けるかの話だった。判断できない領域を Claude に預け続けるには、自分の側に「やった」という納得が必要で、その納得をテスト戦略の進化で作っていた。

ついでに、オレオレ言語になった

ここで少し空気を変えて、副産物の話をする。

jq-jit には、jq の標準にはない拡張が積み上がってきた。memoize/1memoize/2 (関数結果キャッシュ)、execexecv (シェル実行)、fromcsvfromtsv 系の CSV/TSV パーサ。それぞれ、私が手元で「あったら便利だな」と思ったタイミングで issue を立てて、消化側が実装した。

結果として jq-jit は、jq の互換実装でありながら、私専用の方言を持つ JSON 処理言語になっている。今は claude-statusline (Claude Code のステータスバーを描画する小道具) が jq-jit を使って書かれている。jq + bash で書こうと思えば書けることを、わざわざ jq-jit で書いている。完全にオレオレ言語だ。

ついでに、同じ 2 ヶ月で依存ライブラリの剥がしも進んだ。libc の時間関数を chrono に置き換え、fast-float を stdlib に置き換え、Windows x86_64 のリリースが追加された。AI に書かせると依存が膨らむという偏見への、ささやかな反論くらいにはなる。

おわりに — 私は Project Euler を解かなくなった

冒頭に書いたオチを回収する。

jq で Project Euler が遅くて困っていた人間がいた。そこから jq-jit ができて、PE は速く解けるようになった。これで目的は達成された。

ところが、PE は jq-jit のバグ検出器としても優秀だった。だから今は Claude が PE を順番に解き続けて、バグと perf 回帰を見つけてくれる。その間、私が PE を解く理由は、ひとつも残らなかった。

目的を達成したら、目的を失った。

これが、Claude が JIT コンパイラを書いたその後の話だ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?