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.rs は Value::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/1、memoize/2 (関数結果キャッシュ)、exec、execv (シェル実行)、fromcsv、fromtsv 系の 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 コンパイラを書いたその後の話だ。