マイナスゼロ:仕様とテストで回すローカルコード生成ループ

780mlocal-llmcoding-agents

黒い球体を琥珀色と緑色の検証ブロックへ運ぶ円形のテストループ

小さなローカルモデルに、きつめの契約、仕様書と実際のテストファイルだけを渡したとき、正しいコードを安定して書けるのか。そしてうまくいかなかったときに、より大きなローカルモデルへ引き上げることに意味があるのか、それとも単なるエンジニアリング上の迷信なのか。知りたくなりました。

実行するハーネスはloop.pyというスクリプトで、Radeon 780M(画像生成のためにチューニングしてきたのと同じGPU)上でローカルに動かし、クラウドのAPIは一度も呼びません。タスクの説明、仕様ファイル、テストファイル、対象となるソースファイルを渡すと、モデル(デフォルトはqwen3:8b。速くて反復しやすいので)に実装を書かせます。その実装はプロジェクトの実際のゲート、型チェック・リント・テストを通します。ゲートで落ちれば、モデルはその失敗の内容をそのまま見せられ、もう一度挑戦できます。上限回数まで挑戦しても落ち続けたら、ハーネスはqwen3.6:27bにエスカレーションして、そこからやり直します。人間が出したコントリビューションと同じゲートを通らない限り、何も出荷されません。

対象にしたのはCraymel Ballという、古いJRPGのミニゲームをブラウザで再現した小さなプロジェクトで、すでに開発が進んでいて実際のVitestのテストスイートもありました。これがあることで、仕様とテストによる本物の、単純ではない契約と、生成物をすぐ使える実際の呼び出し元の両方が揃っていました。同じゲームのスプライトは、のちに別の3Dメッシュ化パイプラインの題材にもなりました。

ベースラインの再現

ゲーム内の4つの純粋関数、壁の反射物理、対戦結果のスコアリング、円の重なり判定、磁力バーストのインパルスは、このプロジェクトの中で同じループがすでに書いたものでした。新しい作業を頼む前に、そのうち3つを消して、既存の仕様とテストファイルだけから、元の実装の記憶なしに再生成できるかを確かめました。

3つとも、出荷済みのコードとバイト単位で同一か、挙動として同じものが返ってきました。これで、新しいことを頼む前に、このハーネス自体を信頼できることが確認できました。

壁にぶつかる

4つ目の関数、プレイヤーからある角度でボールを押し引きする磁力バーストのインパルスには、qwen3:8bが5回試しても見抜けないバグがありました。何度やっても同じアサーションで落ち続けました。

FAIL  computeBurstImpulse › applies a pure push straight down
  expected { x: -5, y: -0 } to deeply equal { x: -5, y: +0 }

モデルは算術的には正しいインパルスの計算を何度も返してきましたが、それでも落ち続けました。失敗の原因が算術とは関係なかったからです。IEEE-754では-0と0は==では等しくても、同一性では等しくありません。Object.is(-0, 0)はfalseですし、VitestのtoEqualは各フィールドを同一性で比較します。真下への押し出しは水平成分がちょうどゼロになり、三角関数の計算がどちらの方向から近づいたかによって、そのゼロに符号が付くことがあります。普段の開発者がまず意識しない、実在する、地味な境界条件です。

5回試して5回とも失敗しました。毎回、失敗した差分をそのまま見せてもです。この一貫性そのものが、エスカレーションすべきだという合図でした。

モデル結果
qwen3:8b5回中5回失敗
qwen3.6:27b2回目で通過

27bのモデルは符号の問題を直接診断し、2回目の挑戦で修正しました。1回のイテレーションには、小さいモデルの数秒に対しておよそ200秒かかりました。

// original hand-written fix
return {
  x: ix + 0,
  y: iy + 0,
};
// qwen3.6:27b's fix
if (impulseX === 0) impulseX = 0;
if (impulseY === 0) impulseY = 0;

どちらの修正も、リテラルの0を代入し直して符号ビットを正にしています。違いは、それをreturn文の中でやるか、事前のガード付き再代入としてやるかだけです。この修正の形からは、大きいモデルが符号がなぜ間違っていたのかを理解していたことがうかがえます。

エスカレーションの試行自体も、実行できるようになるまでに2回つまずきましたが、どちらもモデルとは関係ない理由でした。同じGPU上で動いていた別のゲームが、ローカルサーバーに必要なVRAMの余裕をすでに使い切っていました。それを閉じた後も、Vulkanのバックエンドはクラッシュで断片化したメモリを引きずったままで、何も読み込めなくなっていました。原因のプロセスを落とし、GPUドライバをリセットする用意済みのスケジュールタスクを起動することで、両方とも直りました。

新しい領域へ

ハーネスの検証が済んだところで、残りの作業は新規のものでした。実際に動いているゲームからそのまま持ってきたリファクタリング対象です。render.tsとai.tsには、それぞれテストと仕様を付けた別モジュールに切り出す価値のあるロジックが4つありました。画面表示用の時刻フォーマッター、スプライト選択のための向き判定、CPU側とボール追跡の両方が必要としていた最近傍点の探索、そして2つの別々のアニメーションシステムがそれぞれ少しずつ違う書き方をしていた共通のフレームインデックスの計算式です。それぞれに新しくspec.mdとtest.tsを書き起こし、ループに渡しました。

この回はエスカレーションが不要で、qwen3:8bだけで1〜2回のイテレーションで済みました。途中の失敗のうち2つは、私自身のミスでした。

あるテストは、2つの斜め方向が同じ向きとして分類されるはずだという前提で書いていて、その根拠は特定の角度におけるMath.cosとMath.sinがビット単位で完全に一致するという想定でした。浮動小数点演算では、三角関数としては同じであるはずの場所でも1ULPずれることがあり、モデルの実装はその挙動をそのまま反映していましたが、私が仕様に書いた計算例のほうはその想定を誤っていました。

仕様の中の計算例には、数値そのものが間違っているものもありました。フレーム数を掛けるべきところを掛け忘れたフレームサイクルの周期です。こちらはループを実行する前に見つかりました。

組み込む

4つの新しいモジュールは、切り出し元だったインラインのロジックを実際に置き換えて初めて意味を持ちます。render.tsとai.tsの中で、壁時計のタイマー、スプライトの向き判定、CPU側のターゲット追跡、そしてゲーム内の2箇所の別々のフレームサイクル処理と置き換わりました。後者は、以前は少しずつ違う2つの式だったものが、いまは1つの式を共有しています。ゲート全体を通しても問題ありませんでした。

npx vitest run

 Test Files  9 passed (9)
      Tests  63 passed (63)

それでもゲーム自体をブラウザで確認する必要はありました。新規ゲームの開始、CPUが正しくボールを追跡すること、動きに合わせてスプライトの向きが変わること、タイマーが正しくカウントダウンすること。テストスイートが緑になることは、コードが契約に対して正しいことを保証します。

2つのローカルモデルと8つの出荷済み関数を通して、実際の仕様と実際のテストファイルだけを渡された小さなモデルは、安定して正しいコードを書けました。自分の過去の出力を記憶なしに再現できたことも、初めて見る切り出し作業をこなせたことも、その一部です。失敗するときは、同じ理由で同じ間違った答えが、イテレーションのたびに返ってきました。これが、エスカレーションの仕組みをエンジニアリングのパターンとして成立させている理由です。大きいモデルは、繰り返し現れるシグネチャに対して呼び出されます。その分ずっと遅いイテレーションは、小さいモデルが一度も気づけなかったことを見つけることで元を取っていました。

契約は双方向に効くこともわかりました。6行の関数の中の符号付きゼロのバグを見つけられるほど厳密なテストスイートは、その関数を書かせた仕様側の誤った前提も同じように暴きます。このループが見つけた間違いのうち2つは、仕様を書いた人間のものでした。テストスイートは、どちらの側の間違いも同じように検出しました。

(追記: 同じ780Mで、今度は別種のローカルハーネスを試しました。このループのエスカレーション方式の代わりに、DeepSeek Harnessのラウンド方式のcreate_goalツールを使うものです。その記録はこちら。)