計測ツール自身が七回測り間違え、そのどれもが自分に不利な方向だった
対話型 TUI の A/B harness には七つの計測誤りがあり、さらにコードのコメントにだけ書かれた二つがありました。どれも本物の pi の上で見つかり、どれも数字を実際より良く見せる方向でした——捏造された結果が 8 件、一度も送られなかったプロンプトが 5 セル。
この記事は、計測ツール自身の誤りの記録です。
commit b0eb0680 の一文目は、はっきりこう書いています。
これは体裁の手直しではない。どれも本物の pi 0.87.1 の TUI の上で見つけたもので、どれも harness を自分に不利に、または agent に有利にする方向だった。つまりこれらの数字は本来、実際より良く見えるはずだった。(原文:「這不是排版修飾。每一條都在真 pi 0.87.1 的 TUI 上抓到,而且每一條的方向都是讓 harness 對自己不利或對 agent 有利,也就是說這些數字原本會長得比實際好看。」)
「方向がそろっている」ことが鍵です。七つの誤りの方向がランダムなら、それはノイズです。すべて同じ方向なら、それは系統的なものです——この harness に構造的な問題があることを示しています。
#なぜ対話型 TUI は特に測りにくいのか
対話型インターフェースには、叩く API がありません。測りたいもの(ゲートのバナーがどこに出るか、モデルが何ターン目まで進んだか、settle したかどうか)は、すべて画面からかき出す必要があります。
測り方は tmux の疑似端末です。pane を一つ開いて agent を走らせ、止まるのを待ち、capture-pane で画面を取り込み、正規表現でその中から判定を探します。
これが七つの誤りの出どころです——どれも「画面からどう真実を読み取るか」という層にあります。
#誤り 1:arm A が別のモデルで走っていた
「Vacant を入れていない agent の成績」を測るには、まったく入れていない arm が一つ必要です。問題は、モデルの接続先が起動引数ではなく設定から来ることです。
やり方は、models.json をテスト環境に一部コピーすることでした。しかしそれで pi がその provider を選ぶわけではありません。 起動時の固定指定がないと、pi は自分のデフォルトを使います。
実測では openrouter/moonshotai/kimi-k2.6 に落ちていました——違うモデル、違うホストで、しかもこの実行の proxy を 1 byte も通っていませんでした。
その結果、arm A は「モデルの失敗」を「agent の失敗」として記録しました。 A アームのすべてのセルが、間違ったものを測っていたのです。
コードのコメントにはこうあります。
⚠ THIS IS THE WHOLE A/B, AND I GOT IT WRONG THE FIRST TIME.
修正:各セルで seen_model を記録し、報告側で異なるモデルのセルの比較を拒否できるようにしました(INVALID 欄)。
興味深いのは、repo 自身が 2026-09-22 に同じ穴にはまっていたことです。gateshim.py:578-582 に書いてあります。新しい問題ではなく、誰も両者を突き合わせなかったのです。
#誤り 2:settle のシグナルを取り違えた
元のルール:B/C アームはゲートのバナーが見えた時点で settled と報告する。
問題は、arm C だけが複数ラウンドを走るアームだったことです。第一ラウンドのバナーが出た途端に報告し、すぐに session を殺していました。スナップショットが捉えたのは、まだ考えている第二ラウンドでした——token は ↑5.9k ↓79 で止まり、第一ラウンドの判定はすでに可視領域から押し出されていました。
そのセルは、settled: true と gate_accepted: null の組として読み取られました。
コードのコメント:
両方の半分とも、嘘をついているのは harness だ。 そのアームが嘘をついているのではない。
修正:完全な scrollback を取り込み、ポーリングで回数を数え、TUI がアイドルに戻ってから初めて settle とする。
#誤り 3:可視の高さしか読んでいなかった
capture-pane はデフォルトでは、見えている約 50 行しか返しません。-S -4000 に変えて、完全な scrollback を取ります。
なぜこれで数字が良く見えるのか:複数ラウンドの session では早い時点の判定が画面の外へスクロールしてしまい、それこそが測りたい変数だからです。 可視の高さだけを読むことは、系統的に最後の判定だけを見ることに等しく——そして最後の判定はたいてい有利なほうです。
#誤り 4:前置きを判定と取り違えた
ゲートはまずこの行を出力します。
Vacant 閘門:驗收來源 dir:tests_visible(…);快照 sha256 …
本当の判定はこうです。
✓ 可見驗收全過(2/2)
✗ 拒交裁決(visible_fail,可見驗收 0/2)——互動模式不擋檔案:…
どちらも「Vacant 閘門:」で始まり、先頭はまったく同じですが、前者には判定がありません。
元の正規表現は先頭しか見ていなかったので、saw_gate が真になり、arm C は前置きの後で settled を宣言しました。どのセルも、返ってくるのは gate_accepted: null でした。
これはバッチ全体で最も深刻な誤りでした。全か無かだからです。あるセルを読み違えたのではなく、すべてのセルが読み取れなかったのです。
修正:二か所の正規表現の両方で、マーク(✓ / ✗ / ⚠)を必須にしました。
ついでに、括弧の形の落とし穴もあります。納品拒否のバナーに書かれているのは 可見驗收 0/2) です——コンマ+空白で、左括弧がありません(左括弧は stop reason のほうで使われている)。合格のバナーだけが 可見驗收全過(2/2) です。合格のほうの形だけに合わせて正規表現を書くと、すべての納品拒否が None として parse されます。
#誤り 5:infra_void が納品拒否として記録された
bool(None) は Python では False です。したがって:
gate_accepted = None (ゲートに判定がない)
↓
False (「判定:拒否」として記録)
これは repo 自身の infra_void ルールが明文で禁じていることです:void を、数えられる失敗に変えること。 何も起きなかったセルと、納品拒否されたセルとでは、意味がまったく違います。
コードのコメント:
Noneis a real value here, not a missing one.
#誤り 6:同じ記録の中で二つのフィールドが矛盾していた
まず「最後の合格」で visible を設定し、その後「最後の拒否」で上書きしていました。
その結果:最終ラウンドが 2/2 全合格のセルが、1/2 と報告されました。
修正:最後のバナー一つで一度に決める。
これは比較的小さな誤りですが、方向は同じです——より悲観的な結果を報告していました。
#誤り 7:停滞が agent の失敗として数えられた
実測のシグネチャによれば、モデルは存在しないツール ls_r を呼び、shell に退き、それ以降何も出力しなくなりました。一方で backend 自体は健全でした(直後に 2 秒で say ok を返した)。
これは失敗ではなく void です。モデル呼び出しの失敗は、正しい答えでも誤った答えでもありません。
コードのコメント:
A failed model call is neither a right answer nor a wrong one; it is a void, and a void is re-run, not counted.
修正:クリーンな workspace で再実行し、決して採点せず、attempts/voids を記録する。MAX_ATTEMPTS = 3。
#コードのコメントにだけある二つ
この二つは commit message には挙がっておらず、私が ab_harness/tui_ab.py のコメントを読んでいて見つけたものです。
#arm A の 8 セルはすべて捏造された結果だった
pi のステータスバーは、送信前は 0.0%/262k で、始まってから ↑N ↓M が出ます。
24 セルのバッチで、arm A の 8 セルはすべて、ラウンドの間ずっと 0.0% のままでした。プロンプトは一度も送られず、TUI はただ座っていただけです。そして古い arm-A の settle ルール(「idle 4 polls」——即座に成立)は、それらをすべて settled + hidden_pass: false として記録しました。
コードのコメント:
それは 8 個の捏造された結果だ。 The only thing that would have caught it is this check.
arm A のすべてのセルが間違っていました——数字の偏りではなく、セルそのものが存在しなかったのです。
#Enter が 6 セルで落ち、しかも私は二度捕まえ方を間違えた
pi は起動時にこう出力します。
Warning: tmux extended-keys is off. Modified Enter keys may not work.
最初の 24 セルのバッチでは 6 セルで Enter が落ちていました。形はどれも同じです。プロンプトが入力欄に残り、ステータスバーは 0.0%/262k、ターンもゲートも判定もない。それらのセルは timeout として記録されました。
これが、arm B で 8 セルのうち 5 セルが void になったように見えた理由です。実際には、8 セルのうち 5 セルでプロンプトが一度も送られていなかったのです。
作者は、自分が捕まえ方を二度間違えたことを書き残しています。
grep -F "$prompt"で確認した——pi はプロンプトを会話の記録にエコーするので、文字列は常に画面上にあり、この検査は決して通りません。そこで彼は Enter を十回送り直し、すべてのセルでexit 2していました——画面に↑2.8k ↓34と正しい provider がはっきり表示されているセルも含めて。これでは arm A が 8/8submit_failedと報告されてしまいますが、arm A は実際には毎回送信していました。tmux set -g extended-keys on——測ってみるとオフで 3/3 送信、オンで 3/3 送信で、最初から問題ではなかったうえに、Enter のエンコーディングを変えてしまいます。
コードのコメント:
both recorded because the second one is worse than having no check at all
二つ目の間違い方は、検査がないよりも悪いものです。正しい答えを誤りとして報告してしまうからです。
#修正した後
read_verdict には、五種類の実際の pane の形に対するテストがあります。
- 複数ラウンドで最後に合格
- 単一ラウンドで拒否
- 複数ラウンドで最後に拒否
infra_void- ゲートなし(arm A)
ruff はクリーンです。
しかしそのバッチの数字は使えません——A アームの 8 セルは捏造で、B アームの 5 セルはプロンプトが一度も送られていません。直した後で再実行が必要です。
リモートの vacant-dev は、arm-A のセル 3 つの後でマシンごと到達不能になりました(port 22、ICMP、tailscale ping のすべてに応答なし、同じネットワークセグメントの他のマシンは正常)。「それらのセルにはバックアップがなく、復旧したらまず引き戻す必要がある。」
#この七つから私が学んだこと
この節は私の判断です。
1. 同じ方向の誤りこそが要点です。
七つの方向がランダムなら、それはノイズです。しかし「どれもが数字を実際より良く見せる」なら、それは構造的な問題を示しています——具体的には、この harness が系統的に「何も起きていないように見える」より「何かが起きたように見える」ほうを好むということです。
これはクローラーによくある病です。何も取れなかったとき、人(とプログラム)は「起きなかった」ではなく「失敗した」と記録しがちです。後者のほうが、データがあるように感じられるからです。
2. 「静かな無効」は「効果が小さい」よりも、誤りとして報告されるべきです。
もし W2(opencode に stop イベントがない)が「トレースは opencode では効果が小さい」と読まれたら、それは受け入れられる結論であり、誰も直そうとはしないでしょう。
しかし実際には、37 step の完全な署名付きカルテがあり、判定はゼロ、delivery.md もありません。見た目はまったく正常です。
37 step の完全な署名付きカルテ、判定はゼロ。これは何もしないより危険です。receipt が正しく見えてしまうからです。
3. 計測ツールのバグは、製品のバグと同じ基準で管理すべきです。
この七つは commit message では「eval(tui)」で、「feat(eval)」と同じ種類に見えます。しかし方法の上では、製品の欠陥と何の違いもありません——どちらも「成立していると思わせて、実際には成立していない」ものです。

