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?

Goに既知バグを16件入れて、Opus 5とOpus 4.8に20回レビューさせた

0
Posted at

レビュー担当のモデルをOpus 5に上げるか、4.8のままにするか。
それを決めたくて測りました。
結論だけ先に置くと、上げたほうが得です。
検出は増えて、実費は43%下がりました。

普段はプロジェクトリーダーなので、自分で書くより人のコードを読んでいる時間のほうが長い。
3日前にはこのツールの入力オーバーヘッドを実測した記事を書いたばかりです。

発端は、比較相手がずれている数字

2026-07-24、Opus 5のリリース当日に、コードレビューSaaSのCodeRabbitが自社基盤での計測を出しました。
既知不具合のカバー率がOpus 5のx-highで55.2%、同社の現行本番構成が61.1%。
nitpickは92件対23件で約4倍、入力トークンは約50%増、出力は65%増。
結論は「書き手としては強いが、レビュアーとしては専門特化型」。

この数字が「Opus 5はレビューが弱い」として広まり始めました。
ただ、本文をたどると比較相手は"our current production model mix"であって、Opus 4.8ではありません。
GPT-5.6系のレーンにも言及があります。
世代間の比較ではなく、別系統のモデル構成との比較です。

上げるか据え置くかを決めるなら、同じコーパスに同じプロンプトで両方を並べるしかない。
見当たらなかったので、自分で作りました。

何を入れたか

公開リポジトリにバグを入れる方式はやめました。
修正済みのコードが学習データに入っていたら、レビュー能力ではなく修正の記憶を測ることになります。

在庫突合バッチをGoで新しく書きました。
519行、7ファイル。
HTTPクライアント、ワーカープール、キャッシュ、リポジトリ層と、業務でよく見る並びにしてあります。

ここに欠陥を16件入れ、正しいまま置いたコードを6件混ぜました。
後者は誤検知を数えるための対照コードです。
欠陥の分類は、CodeRabbitが「Opus 5が弱い」と名指ししたlogic errors、race conditions、API misuseに寄せました。

欠陥はどれも、docコメントに書いた仕様を実装が破る形にしています。
わかりやすいのは税額の丸めのあたりです。

package main

import "fmt"

const TaxRateBP = 1000 // 消費税率をベーシスポイントで持つ

type Line struct {
	UnitPriceJPY int64 // 単価
	Quantity     int   // 数量
	DiscountBP   int   // 値引き率(ベーシスポイント)
}

func Subtotal(l Line) int64 {
	gross := l.UnitPriceJPY * int64(l.Quantity)    // 値引き前の金額
	return gross - gross*int64(l.DiscountBP)/10000 // 値引きを引いた税抜金額
}

// Total は明細群の税込合計を返す。
//
// 仕様: 税額は明細ごとではなく税抜合計に対して一度だけ計算し、
// 円未満は四捨五入する。経理システムとの日次突合がこの丸めに依存している。
func Total(lines []Line) int64 {
	var sum int64             // 税抜合計を貯める
	for _, l := range lines { // 明細を1行ずつ足す
		sum += Subtotal(l) // 値引き適用後の税抜金額
	}
	tax := sum * TaxRateBP / 10000 // 整数除算なので切り捨て。仕様は四捨五入
	return sum + tax               // 税込金額
}

func main() {
	// 税抜5円 → 税額0.5円 → 四捨五入なら6円になるはず
	fmt.Println(Total([]Line{{UnitPriceJPY: 5, Quantity: 1}})) // 5 と表示される
}

いちばん難しくしたのは、ファイルをまたぐ契約違反です。
repo.Listはdocで「SKUの昇順で返す。呼び出し側はこの順序に依存してよい」と約束しているのに、SQLにORDER BYがない。
そして別ファイルのreconcile.lookupが、その順序を前提にsort.Searchで二分探索している。
片方だけ読んでも見つかりません。

16件が本当に欠陥かどうかは、実行して確かめました。
6件はテストが落ちること、2件はレースディテクタが検出すること、2件はgo vetが指摘すること、残りは読解で判定しています。

--- FAIL: TestTotal_RoundsHalfUp
    Total = 5, want 6(仕様: 税額の円未満は四捨五入)
--- FAIL: TestBackoff_CapsAt2s
    backoff(10) = 1m42.4s, want 2s(仕様: 上限2秒)

ワーカープールのwg.Addをゴルーチンの内側に置いた件だけは、5回に1回しか落ちませんでした。
毎回落ちないバグを毎回落ちるテストで固定できないので、この件は5回まわした記録のほうを残しています。

準備でいちばん時間を持っていかれたのは、レースディテクタが起動しなかったことです。
relocation target __popcountdi2 not definedが出てビルドが通らない。
この環境のgccは実体がzig-bootstrap由来のclang 18で、libgccを持っていませんでした。
-ldflags=-linkmode=externalとclang_rtのbuiltins指定で通しています。

測り方

全ソースを行番号付きでプロンプトに埋め込み、JSONで指摘を返させました。
ファイル読み取りもコマンド実行も全部止めています。
go vetを実行できてしまうと、読解力ではなくツールの使い方を測ることになるので。

claude -p "$(cat review_prompt.txt)" \
  --model claude-opus-5 \
  --effort xhigh \
  --output-format json \
  --disallowed-tools Bash Read Write Edit MultiEdit Glob Grep WebSearch WebFetch Task NotebookEdit

各モデル10回ずつ、計20ラン。
同じモデルに同じプロンプトを投げても結果は毎回変わるので、1回や3回では話になりません。
実際この後の結果でも、検出件数は片方が12〜15件、もう片方が13〜16件に散っています。
採点は、指摘のファイルと行番号が正解表から±3行以内なら検出とみなす機械判定です。

検証環境はWSL2上のUbuntu 24.04、Go 1.23.4、Claude Code 2.1.219。
effortは両モデルともxhighに固定しました。

正解表をレビュー側に渡さないところだけは、手順として気を使いました。
*_test.goはどれも「仕様どおりなら通るテスト」なので、混ざればそのまま答えになります。

先に無料の下限を出しておきます。
go vetが拾えたのは16件中2件でした。

測る前の予想は「Opus 5のほうが少し落ちる」でした。
CodeRabbitの55.2%対61.1%を読んだ後なので、素直にその方向へ引っ張られています。
誤検知もOpus 5のほうが増えると踏んでいました。
nitpick4倍という数字が頭に残っていたからです。

結果

who-found-what.png

Opus 4.8 Opus 5
用意した16件のうち指摘できた数(中央値) 13件 / 81.2% 15件 / 93.8%
同(最小-最大) 12-15件 13-16件
指摘の総数(中央値) 14.5件 21件
用意していなかった正しい指摘(中央値) 1件 4.5件
誤検知(20ラン合計) 0件 0件
入力トークン(中央値) 25,742 26,713
出力トークン(中央値) 13,031 6,736
1ランの実費(中央値) $0.3466 $0.1970
1ランの所要(中央値) 178.7秒 94.7秒

外しました。
Opus 5のほうが多く見つけて、速くて、安い。
私が張った方向とはまるきり逆でした。

差がついた欠陥は16件中2件に集中しています。

detection-gap.png

同じキーを同時に引いたときキャッシュが二重に読みに行く件が、Opus 4.8で0/10、Opus 5で7/10。
2ファイルにまたがる並び順の約束違反が、3/10対9/10。
残り11件はどちらも全ランで検出していて、差がありません。

上位モデルに払っている差額は、この難しい2件ぶんです。
簡単な欠陥はどちらも取りこぼしません。

両モデルが揃って落としたのも1件ありました。
値引き額を先に切り捨てるせいで税抜金額が仕様どおりの切り捨てにならない件は、4.8で2/10、Opus 5で1/10。
仕様コメントと実装の差が1円ぶんしかない欠陥なので、これは人間のレビューでも落ちます。

出力トークンが半分になっている点だけ補足します。
Opus 5はthinkingがデフォルトでONになり、その思考ぶんも出力として課金される。
それでも4.8の13,031に対して6,736でした。
公式が「法務タスクでOpus 4.8比 平均26%のトークン削減」と説明していた向きとは一致しますが、幅はこの用途のほうがずっと大きい。
CodeRabbitの「出力65%増」とは逆です。

誤検知を数えるつもりが、自分のバグを数えていた

この記事でいちばん時間を使ったのはここです。

最初の集計では、Opus 5が対照コードのSplitEvenlyを10/10で、Upsertを8/10で指摘していました。
誤検知が多い、という数字がそのまま出ます。
CodeRabbitのnitpick4倍とも符合する。
予想が当たった、と一度は思いました。

指摘の文面を読んで、手が止まりました。
「SplitEvenlyはtotalが負のとき余りを配分せず、合計がtotalに一致するという仕様不変条件を破る」。

書いたのは自分です。
負の値を渡す想定を、そもそもしていませんでした。

package main

import "fmt"

// SplitEvenly は金額を人数で等分し、余りを先頭から1円ずつ配る。
//
// 仕様: 戻り値の合計は必ず total に一致する。
func SplitEvenly(total int64, n int) []int64 {
	if n <= 0 {
		return nil
	}
	base := total / int64(n)     // Goの整数除算は0方向に丸める
	rem := total - base*int64(n) // total が負なら rem も負になる
	out := make([]int64, n)
	for i := range out {
		out[i] = base
		if int64(i) < rem { // rem が負だと一度も成立しない
			out[i]++
		}
	}
	return out
}

func main() {
	for _, total := range []int64{1001, -10} { // 正の値と負の値で挙動を比べる
		parts := SplitEvenly(total, 3)
		var sum int64
		for _, p := range parts { // 戻り値を合計して不変条件を確認する
			sum += p
		}
		fmt.Printf("SplitEvenly(%d,3) = %v  合計=%d  一致=%v\n", total, parts, sum, sum == total)
	}
}

この関数だけ切り出して動かしてみました。

SplitEvenly(1001,3) = [334 334 333]  合計=1001  一致=true
SplitEvenly(-10,3) = [-3 -3 -3]  合計=-9  一致=false

対照ではなく、欠陥でした。
Upsertも同じで、UPDATEしかせず対象が無くても0件更新のままCommitが成功します。
リトライの最終試行後にもbackoffぶんだけ待ってから抜ける件も、指摘のほうが正しい。
対照として登録した6件のうち3件が、本物のバグだったわけです。

そこから20ラン分の指摘を1件ずつコードと突き合わせました。
正解表に載っていない指摘は16種類。
判定の結果、14種類が実害のある欠陥、2種類が正しいが軽微な助言、誤りは0種類

findings-breakdown.png

20ラン合計353件の指摘を、この3つに振り分けた結果です。
つまり両モデルとも、誤検知を1件も出していません。
Opus 5の指摘数が4.8の1.4倍だったのは、ノイズが1.4倍だったからではなく、見つけた欠陥が多かったからです。
増えたぶんの中身は、用意していなかった正しい指摘が12件から51件へ増えた差です。

裁定する前の数字だけ見ていたら、私はOpus 5を「誤検知が多いモデル」として報告していました。
予想が当たったことにして、そのまま出していました。
nitpickの件数は、1件ずつ人が判定して初めて意味を持つ。
ベンダーが公表するnitpick数を、判定手続き抜きで品質の指標として読むことはできません。

どう使うか

一番得なのは、go vetとレースディテクタを通した残りをOpus 5に投げることです。
無料で取れる2件を有料で取りに行く理由がない。
519行に対して1回$0.20、95秒。
検出した欠陥1件あたり$0.0131で、4.8の$0.0267の半分以下です。

一番損なのは、検出率だけ見て人の確認を減らすことです。
中央値は15/16でも、最小のランは13/16でした。
同じコードに同じプロンプトを投げて、1回で13件しか出ないことがあります。
1回のレビューを合否判定に使うつもりなら、この振れ幅ぶんは人が持つしかありません。

Opus 4.8に据え置く理由は、この計測の範囲では見つかりませんでした。
速度も費用も検出も負けています。

AIレビューは、見落としを拾う網としてはもう十分だと思っています。
ただ、人が読む量は減りません。
指摘が増えれば裁定の手間も増えるので、レビューを任せたというより、レビュー対象が一段増えたという感覚のほうが近い。

所感

自分で入れたバグを自分で数えるつもりが、入れていないバグを3件返されました。
測る側のコードが測られる側より雑だった、という話ではあります。
ただ、正しいコードを混ぜて誤検知を数えるという設計そのものが、その「正しいコード」を書く人間の正しさに寄りかかっていることは、やってみるまで実感がなかった。

この計測で言えるのは、519行のGoコードに、xhighのeffortで、10回ずつ投げた範囲の話です。
実PRの差分に対する検出率はまた別で、注入したバグでF1が0.847、実PRで0.066まで落ちたという報告もあります(arXiv:2606.15689)。
それでも、同じ条件を2つのモデルに当てた結果としては十分だと判断しました。

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?