23. タスク/テストランナーの filter

September 21, 2026 · View on GitHub

17 は「大量のタスクから正しい 1 つを選ぶ」だった。 こちらはフィルタで、問いの形が違う:

  • 事前に just で依存グラフを作っておく。 前提と順序はグラフの仕事にする
  • state は diff そのもの。 ゴール文を渡さない(17 §6 の 「ゴール文が丁寧すぎる」をやめる)
  • 全ゴールタスクに score を付けさせる。 1 つ選ぶのではなく、閾値で切って集合にする
  • 選んだゴールの前提はグラフが足す(17 §6 の 「1 タスクしか選べない」の解消)

再現:

cd experiments/task-filter && npm install
npm test                                              # 28 件(API 不要・just 不要)
npx tsx src/run.ts --replay                           # このレポートの数字を再集計(API 不要)
npx tsx src/run.ts --replay transcripts/raw-no-intent.jsonl --threshold 1.25
TYPESAFEAI_API_KEY=... npx tsx src/run.ts --repeat 3   # 実測しなおす(240 リクエスト、\$0.031)
TYPESAFEAI_API_KEY=... npx tsx src/run.ts --repeat 3 --no-intent
TYPESAFEAI_API_KEY=... npx tsx src/cli.ts --base main  # 手元の diff に対して使う
npm run graph                                          # justfile から graph.json を作り直す(要 just)

**下の数字は全部 transcripts/*.jsonl$(240 行 \times 2)から再集計できます。** 収集(\text{API} を叩く)と集計($analyse())を分けてあるので、 閾値や戦略を変えた再分析にリクエストは要りません。 グラフも graph.json にキャッシュしてあるので just の用意も要りません。


結論(先に)

15/15 の検出を保ったまま machine time を 68.6% 削った。1 ブランチ 0.17 秒・$0.000131。

戦略検出machine削減wallタスク数無害 diff
全部走らせる(ci)15/1518.5m0.0%7.2m26.018.5m
static(inputs glob + 依存伝播)15/1512.8m30.9%6.2m14.713.1m
glob だけ(伝播なし)14/158.6m53.4%4.7m10.36.4m
Jev(diff arm、t=1.0)45/455.8m68.6%4.1m7.52.2m
何も走らせない0/150.0m100.0%0.0m0.00.0m

そして一番効いたのは 17 §5 の反転だった

17 §5 は 「リポジトリの文脈は 1 件も動かさなかった」で終わっている。ここでは ブランチ名とコミット題名を外すと、diff の中身を読める arm だけが生き残るpackages/shared/src/money.ts の JSDoc の誤字 1 行に対して:

arm意図つき意図なし
static17.1m17.1m
paths(パスだけ)0.0m7.6m
stat(+行数)0.0m2.3m
diff(hunk まで)0.0m0.0m

つまり 17 の「文脈は払い損」は「意図が 1 文で書かれているとき」の話で、 意図が書かれていないときは文脈が唯一の情報源になる。 17 §8 の宿題 1 はこれで閉じた(§4)。

他に確定したこと:

  • glob だけでは不健全。 依存伝播を外すと 14/15 に落ちる(a11y@inputsweb/** で、変更は packages/ui にある)。グラフは要る(§2)
  • グラフはプロンプトではなくコードに置く。 グラフの事実を質問に載せた diff_graph arm は diff より悪い(66.3% 対 68.6%、トークンは 1.3 倍)(§5)
  • 「何も走らせなくていい」は別の noul で聞くと安全なゲートになる。 p ≥ 0.4 で無害 9/15 に発火し、壊れている 45 件には 0 件(§6)
  • 安いタスクに同じ閾値を使ってはいけない。 唯一の見逃しは 4 秒の fmt-check が 1.24 対 1.25 で落ちたもの。10 秒以下を無条件に走らせると 45/45 に戻り、代償は 0.5 machine 分(§7)

1. しくみ

グラフは just から取る

experiments/task-filter/justfile は pnpm モノレポ風の CI を実際の just レシピで書いたもので、 グラフは just --dump --dump-format json の出力そのままです(手で書いた表ではない)。 レシピ 1 つにつき 3 つの情報があり、それぞれ読む主体が違います:

由来何を持つか誰が読むか
dependenciesjust 自身の辺コード(閉包・順序・critical path)
最後のコメント行just の doc。1 行の散文Jev(17 が 90% → 100% を測った分)
# @inputs: / # @cost:入力 glob と CI 秒数静的ベースラインとコスト計算

just直前の 1 行だけを doc にするので、@inputs / @cost を上に置くと just からは見えず、justfile のテキストからは読めます。

27 レシピ、うちゴール 18 個(非 meta のどのレシピからも依存されていないもの)。 installbuild-web はゴールではありません —— 誰も「install を走らせるべきか」とは 聞かないし、聞く必要もない(閉包が足す)。ci[group('meta')] で、 「全部走らせる」ベースラインはこのレシピの閉包そのものです(手で並べたリストではない)。

install(35s) ── codegen(8s) ─┬─ build-api(24s) ─┬─ test-api-integration(95s)
             └─ build-shared(12s) ─ build-ui(18s) ─ build-web(46s) ─┬─ e2e-web(320s)
                                                                    ├─ e2e-auth(140s)
                                                                    ├─ a11y(85s)
                                                                    └─ bundle-size(40s)

全部で 26 タスク・machine 18.5 分、critical path は 7.2 分e2e-web 1 本で 320 秒あるので、削る価値があるのはここです。

質問は「ゴール 1 個につき score 1 問」

18 ゴール + diff 全体への noul 2 問 = 20 問を 1 リクエスト00 の fan-out が 20 問で 「21x 速く 8x 安い」を測った規模そのままなので、タスクごとにスコアを出すのが成立します

score にしたのは「走らせるべきか」ではなく「どれくらい走らせるべきか」だからで、 17 の最大の効果が「答えの形を問題の形に合わせる」だったのを踏襲しています。 3 段の criteria:

0 Waste:     nothing this task checks can be affected by this change.
1 Insurance: this task covers the area the change touches, but a failure would be a surprise.
2 Required:  this change can plausibly break exactly what this task checks.

diff 全体への 2 問(glob には原理的に答えられないもの):

質問内容
_changes_behaviourこの変更は実行時の振る舞いを変えうるか
_all_wasteどのチェックも走らせずに通るか(= 逃げ道。17 §3 の別 noul)

正解は「赤くなるタスク」で、こちらが付けたラベルではない

17 §6 の最大の限界は 「ロスターもシナリオも同じ人間が書いている」でした。ここはそこを潰しています:

  • シナリオは種類 kind と surfacing パス at を持つ欠陥を 1 つ植える
  • src/oracle.ts各レシピの catches / scope を持つ(Jev には一切見せない)
  • 失敗集合は 2 つの積で決まる: kind ∈ catches && at ⊆ scope

なので測っているのは「Jev がこちらの意見と一致したか」ではなく:

run set の中に、実際に赤くなるタスクが 1 つでも入っているか。

at(失敗が表に出る場所)は、diff が触ったパスとは限りません。 api/schema.graphql を非 null にした変更は web/src/pages/Profile.tsx 型エラーになり、migration の削除は api/src/repo/orders.ts で落ちます。 これがグラフが要る理由そのもので、同時にオラクルを規則のままにします。

コーパスは 20 ブランチ(欠陥あり 15・壊れないもの 5)。 npm test が「どのレシピでも捕まえられない欠陥」と 「static ベースラインの取りこぼし」を先に弾くので、 比較相手が壊れていないことは測る前に確認済みです。


2. グラフは要る —— glob だけでは不健全

まず API なしの 3 つ。ここが Jev の比較相手になります。

戦略検出machine削減タスク数
全部(ci の閉包)15/1518.5m0.0%26.0
static(glob + 依存伝播)15/1512.8m30.9%14.7
glob だけ14/158.6m53.4%10.3

ui_aria_removed を glob だけが落とします。 packages/ui/src/IconButton.tsx から aria-label を外す変更で、これを捕まえられるのは a11y だけ。 ところが a11y@inputsweb/** なので字面では当たらないbuild-ui → build-web → a11y という依存辺だけが両者を繋いでいます。

だから turborepo / bazel 系は依存伝播をやるのですが、その代償が 30.9% しか削れないことです。 packages/shared の 1 ファイルは下流全部の入力なので、誤字 1 文字でも 17.1 分走ります

健全さと安さは静的解析の中では両立しない。 glob だけは安いが取りこぼし、 伝播すると取りこぼさないが悲観的すぎる。フィルタが入る余地はここ。


3. 結果 —— 検出を落とさず 68.6% 削る

20 ブランチ × 3 回。欠陥ありは 15 × 3 = 45 判定:

arm検出machine削減wallタスク数無害 diff
paths45/456.2m66.3%4.2m8.42.6m
stat45/456.1m67.1%4.2m8.12.3m
diff45/455.8m68.6%4.1m7.52.2m
diff_graph45/456.2m66.3%4.2m8.62.7m

見逃しは 240 リクエスト中 0 件。 static の 12.8m に対して 5.8m なので、 グラフが健全に残した分の半分以上がさらに落ちます

欠陥ごとに見ると、どこに時間が行っているかが全部見えます(diff、t=1.25):

ブランチstaticJev検出赤くなるタスク
lockfile_vuln17.5m0.9m3/3audit
lockfile_licence17.5m1.0m3/3license-check
lint_console13.7m1.2m3/3lint
migration_drops_column12.2m3.2m3/3test-api-integration
unformatted_edit13.0m3.8m3/3fmt-check
bundle_moment13.7m4.6m3/3bundle-size
ui_aria_removed13.2m5.2m3/3a11y
shared_rounding_bug17.1m7.9m3/3test-shared
e2e_testid_rename13.7m8.3m3/3e2e-web
web_type_break13.7m9.2m3/3build-web typecheck-web
web_unit_offbyone13.7m9.8m3/3test-web-unit
schema_nonnull16.1m10.1m3/3build-web typecheck-web
auth_cookie_regression13.0m12.2m3/3e2e-auth
docs_dead_link1.2m1.1m3/3docs-links
infra_instance_size0.9m0.9m3/3infra-plan infra-validate

削減が大きいのは「狭いチェックしか要らない変更」 —— lockfile は audit だけ、 console.loglint だけ。逆に auth_cookie_regression は 12.2m かかりますが、 これは正しい: e2e-auth(140s)を走らせるには build-webbuild-api が要るので、 閉包の値段はグラフが決めていて、Jev はそこを安くできません

コストと検出のトレードオフ(diff arm)

閾値検出machine削減wallタスク数
0.0045/4518.5m0.0%7.2m26.0
0.5045/457.2m61.0%4.7m9.6
1.0045/455.8m68.6%4.1m7.5
1.2545/454.5m75.8%3.4m6.2
1.5039/453.0m83.6%2.4m4.7
1.7528/451.9m90.0%1.5m3.0
2.000/450.0m100.0%0.0m0.0

1.25 が崖の手前で、75.8% 削って 45/45。1.5 で 39/45 に落ちます。 score が 2.00 ちょうどを返すことは実際にはほぼ無いので、t=2.0 が 0/45 なのは当然です (上限を閾値にしてはいけない、という 04 の再現)。


4. 文脈は効いた —— 17 との違いは「意図が書かれているかどうか」

17 §5changed_files も直前の終了コードも 1 件も動かさなかったと報告しています。 ここでは逆に、diff の中身が効く場面がはっきり出ます

--no-intent でブランチ名とコミット題名を落とす(patch だけが残る):

arm意図つき machine意図なし machine意図つき 無害 diff意図なし 無害 diff
paths6.2m6.4m2.6m3.9m
stat6.1m5.7m2.3m3.1m
diff5.8m5.5m2.2m2.3m
diff_graph6.2m6.0m2.7m3.0m

集計だと差は小さい。1 ブランチに降りると構造が見えます —— comment_typo(モノレポで一番下流から依存されているファイルの JSDoc 誤字 1 行):

意図つき意図なし
static17.1m17.1m
paths0.0m7.6m
stat0.0m2.3m
diff0.0m0.0m

そして「この変更は振る舞いを変えうるか」の確率が、なぜそうなるかを説明します:

arm壊れる diff壊れない diff
paths(意図つき)0.7840.182
paths(意図なし)0.7560.482
diff(意図つき)0.7810.180
diff(意図なし)0.7840.183

パスだけの arm は、意図を外されると無害な変更をコイン投げ(0.482)でしか判別できません。 packages/shared/src/money.ts というパスからは、そこで何が起きたか読めないので当然です。 docs/fix-money-comment というブランチ名か、-Convertions +Conversions という hunk の どちらかが必要で、両方外すと判別が消える。

17 の結論は間違っていない。適用条件が付く。 「文脈は払い損」は意図が 1 文で明示されているときの話で、 diff だけが入力のときは文脈が意図の代わりになる。 17 §8 の宿題 1(曖昧なゴールで測り直す)は、 これで閉じたことにします。

実務上の含意は素直です: フィルタに渡すのは hunk まで。 コミット題名は無料で効くが、それに頼るとメッセージの質に精度が乗ってしまう。


5. グラフはプロンプトではなくコードに置く

diff_graph arm は各質問にそのタスクの前提・@inputs@cost を載せたものです。 素直に思いつく改善で、結果は:

arm検出machine削減1 リクエストのトークン
diff45/455.8m68.6%~2.9k
diff_graph45/456.2m66.3%~3.8k

悪化します(削減 2.3 ポイント減、トークン 1.3 倍)。理由は構造的で、 前提の解決は既にコードがやっているからです。e2e-auth を選べば build-web は閉包が足すので、Jev が知る必要はない。 @cost を見せると「高いから避ける/高いから重要」のどちらにも転びうるノイズが増えるだけでした。

同じ情報でも、置き場所で符号が変わる。 依存グラフは コードに置けば無料で正確、プロンプトに置けば有料で不正確05 の 「繰り返させたくない選択はコードで消す」と同じ形。

ただし 1 つ例外がありました。§8 の dev_script_tweakdiff_graph だけが t=1.25 で 0.0m まで落とせています(他は 7.7m)。 コスト情報が効くのは「安い判断で高い suite を避ける」場面らしいが、 1 シナリオなので一般化はできません。


6. 「何も走らせない」は別の noul にすると安全なゲートになる

17 §3 の 「逃げ道は選択肢ではなく別の問い」を、フィルタの形で再現します。 _all_waste(「どのチェックも走らせずに通る」)を単独のゲートに使うと:

閾値無害な diff に発火壊れている diff に発火
p ≥ 0.309/150/45
p ≥ 0.409/150/45
p ≥ 0.506/150/45
p ≥ 0.606/150/45

壊れている 45 判定で 1 件も発火しません。 平均は壊れる側 0.161・無害側 0.479 で、 17applicable(0.113 対 0.741)ほど綺麗に分かれてはいないものの、 片側の誤りが 0 なので「CI を丸ごと飛ばす」判断には使えます

注意点も 17 と同じ向き: 無害側の平均が 0.479 なので、閾値 0.5 は谷の中ではなく山の上です。 0.4 に置くと検出率が上がって(6/15 → 9/15)副作用は増えません。 この閾値は分布を見ないと引けない(04)。


7. 安いタスクに同じ閾値を使ってはいけない

--no-intent の t=1.25 に唯一の見逃しがありました:

unformatted_edit   run 1   needed one of {fmt-check}
                           highest-scored of those: fmt-check 1.24

4 秒のタスクが 1.24 対 1.25 で落ちた。 09 の 「閾値の境界に乗った決定をそのまま採る」がそのまま出ています。

fmt-check を飛ばして節約できるのは 4 秒で、失うのは その変更で赤くなる唯一のチェックです。つまり閾値をコストで重み付けするべきで、 「10 秒以下は無条件に走らせる」という 1 行の床を入れると:

t=1.25 検出t=1.25 machinet=1.5 検出t=2.0 検出
床なし(意図つき)45/454.5m (75.8%)39/450/45
床 10s(意図つき)45/455.0m (72.7%)42/4512/45
床なし(意図なし)44/454.2m (77.5%)39/450/45
床 10s(意図なし)45/454.7m (74.5%)42/4512/45

見逃しを 0 に戻す代償は 0.5 machine 分(削減 77.5% → 74.5%)。 曲線の攻撃的な側も全体に頑丈になります。

推奨運用点: diff arm・閾値 1.25・10 秒以下は無条件。 両条件で 45/45、削減 72.7〜74.5%。


8. 弱いところ —— 「パスから何が変わったか読めない」ファイル

削減が一番効かなかったのは dev_script_tweak (web/package.jsondev スクリプトのポートを 5173 → 5174)。7.7m 使っています。

diff arm が何を選んでいるか:

意図つき   e2e-web 1.31,  e2e-auth 0.89
意図なし   e2e-web 1.51,  e2e-auth 0.77

e2e-web(320 秒)を選んでいる。 そして理屈は通っていて、Playwright の 設定が dev サーバの URL を指していれば実際に壊れます。 このフィクスチャではビルド済み preview に対して走るので無駄ですが、 hunk からそれは読めない(justfile にもそこは書いていない)。

paths arm を意図なしで走らせると、別の壊れ方をします:

license-check 1.89,  audit 1.86,  bundle-size 1.42,  typecheck-web 1.29, ...

package.json が変わったなら依存が増えたのだろうと読んでいます。 これも推論としては妥当で、パスだけでは 「スクリプトを直した」と「依存を足した」が同じ web/package.json です。

hunk が効くのは「パスが内容を決めないファイル」—— package.json・lockfile・設定ファイル。 逆に web/src/hooks/useCart.ts はパスだけでほぼ足りる。 §4 の集計差が小さく、1 ブランチの差が大きいのはこれが理由。


9. 正直な限界

  • モノレポとコーパスは合成。 justfile も 20 ブランチもこちらが書いたもので、 実在のリポジトリではありません。欠陥の種類の分布(15 件中 e2e だけが要るもの 2 件)は 実際の PR の分布ではない。
  • 走らせていない。 「赤くなるタスクが run set に入っているか」を規則で判定しているだけで、 実際に vitestplaywright を実行して落ちたかは見ていません。 @cost の秒数もこちらが書いた数字です。削減率はこの表に完全に依存しています。
  • オラクルは規則だが、規則はこちらが書いた。 17 §6 の 「同じ人間がラベルを付けた」は薄めましたが消えていません。消えたのは 「1 件ずつ主観で付けた」ところだけで、CATCHES の catches / scope 自体は設計物です。
  • e2ee2e_auth を別の kind にしたのはこちらの都合。 これで auth_cookie_regressione2e-auth しか正解にならず、 140 秒のタスクを 1 つ選ばせる問題になっています。実際には e2e-web が サインインを踏んで落ちることも多く、そのぶんこの問題は実際より難しく作ってある
  • 1 リポジトリ 1 グラフ。 ゴール 18・タスク 27 で、17 §4 が 測ったような規模での劣化を測っていませんscore は 1 タスク 1 問なので、 ゴールが 100 あれば 100 問になる。fan-out の実測は 20 問までしか無い (00)ので、そこは未知です。
  • score の分散を測っていない。 3 回の繰り返しで検出は全 arm 45/45 でしたが、 個々のスコアのばらつき(同じ diff で 1.24 と 1.31 に振れる幅)は集計していません。 §7 の見逃しが示すとおり境界付近では再現しないはずで、 これは床を入れる理由をもう 1 つ増やします。
  • critical path は無限並列の仮定。 wall の数字は 「依存関係以外の制約が無い」ときの値で、実際の CI の runner 数は入っていません。

10. この実験で確定したこと

  1. グラフと判断の分業が成立する。 ゴールだけスコアリングさせて前提は閉包が足す形なら、 順序も前提も Jev が間違えようがない17 §6 の 「1 タスクしか選べない」は、グラフを外に置くと消える(§1)。
  2. 検出を落とさず machine time を 68.6%(t=1.25 で 75.8%)削れる。 1 ブランチ 20 問 1 リクエスト・0.17 秒・$0.000131。 CI 1 本あたり 12.7 分を 0.17 秒の判断で買っている(§3)。
  3. 静的解析だけでは健全さと安さが両立しない。 glob だけは 14/15 で取りこぼし、 依存伝播を足すと 15/15 だが 30.9% しか削れない(§2)。
  4. 文脈が効くのは意図が書かれていないときだけ。 17 §5 の 「文脈は 1 件も動かさない」は、ゴール文が意図を明示していたから。 diff だけが入力なら hunk が唯一の情報源で、パスだけでは 無害な変更の判別が 0.482(コイン投げ)まで落ちる(§4)。
  5. 同じ依存グラフを、コードに置くと効きプロンプトに置くと逆効果。 diff_graph は 68.6% → 66.3%、トークン 1.3 倍(§5)。
  6. 逃げ道を別 noul にすると、片側 0 誤りのゲートになる。 p(all_waste) ≥ 0.4 は無害 9/15 に発火し、壊れている 45 件に 0 件。 ただし閾値は 0.5 ではなく 0.4(分布を見ないと引けない)(§6)。
  7. 閾値はコストで重み付けする。 唯一の見逃しは 4 秒のタスクが 1.24 対 1.25 で落ちたもの。「10 秒以下は無条件」で 45/45 に戻り、代償は 0.5 分(§7)。
  8. hunk が要るのはパスが内容を決めないファイル。 package.json や lockfile では パスだけの arm が「依存が増えた」と読んで audit / license-check を 1.9 で選ぶ。ソースファイルではパスだけでほぼ足りる(§8)。

11. 次に試すこと

  • 実際に走らせて正解を作る。 ★検証済み → §12(このリポジトリ自身で測った) §9 の一番大きい穴。手元のリポジトリで just のタスクを全部走らせて終了コードを記録し、 「フィルタが選んだ集合の中に、実際に落ちたタスクが入っていたか」で測る。 @cost も実測値になるので、削減率が仮定から実測に変わる。

    追記: やったら結論が 2 つ反転した。 検出は 18/18 で見逃し 0 のまま、 しかしこのリポジトリでは glob だけの静的判定(無料)に負ける(80.2% 対 65.9%)。 そして 15 個の「ありそうな変更」のうち 9 個は、何も壊さない —— 正解ラベルを実測にした一番の収穫は、フィルタの精度ではなく テストスイートの穴だった。詳細は §12。

  • ゴール数のスケール。 17 §4 の形で ゴールを 18 → 60 → 120 に増やす。score は 1 タスク 1 問なので fan-out の未測定領域(20 問超)に直接入る。劣化するなら @inputs で候補を絞ってからスコアリングする二段構えになる。
  • 床の高さをデータで決める。 §7 の 10 秒は思いつきの値。 「そのタスクを飛ばして節約できる秒数」と「それが唯一の検出者である確率」の積で 引けるはずで、引ければ閾値が 1 つではなくタスクごとの関数になる。
  • PR ではなくローカルの pre-commit で測る。 src/cli.tsgit diff からそのまま走るので、保存ごとに 0.2 秒で「今どのテストを回すか」が出せる。 06 の I(レイテンシを UX に使う)の一形態で、 ここではフィードバックが速い側に倒す使い方になる。
  • score の分散。 ★検証済み → 25 §7 同じ diff を 10 回投げて、タスクごとの標準偏差を出す。 境界付近の不安定さが定量化できれば、床の代わりに 「信頼区間の下限が閾値を越えたら skip」にできる。

    追記: 作らなくてよかった。 10 draw(150 リクエスト・$0.0179)で平均 sd は 0.030、 閾値 1.25 をまたぐのは 240 組中 7 組で、そのどれも実際には赤くならないタスク。 10 draw の平均で決めても 1 draw で決めても、検出(60/60)も machine 秒数(10.8s)も同じ。 揺れは決定に届いていない。


12. 追記 — このリポジトリ自身で、正解ラベルを実測した

§9 の一番大きい穴は「実際に走らせていない」だった。赤くなるタスクの集合は src/oracle.ts の規則が決めていて、その規則を書いたのもこちら17 §6 の「同じ人間がラベルを付けた」を 薄めただけで、消してはいなかった。

そこでこのリポジトリを対象にやり直した

再現:

# 前提: moon 0.1.20260915+ と just が PATH にあること
npx tsx experiments/task-filter/real/build.ts             # 根の justfile → real/graph.json
npx tsx experiments/task-filter/src/observe.ts --dir . --isolate --out real/costs.json
npx tsx experiments/task-filter/real/observe-mutations.ts  # ラベルを実測(15 x スイート)
npx tsx experiments/task-filter/real/run.ts --repeat 3     # 45 リクエスト、\$0.005
npx tsx experiments/task-filter/real/run.ts --replay       # 再集計(API 不要)

12.1 何が変わったか — ラベルが「規則」から「終了コード」になった

§1〜§8(合成)§12(実測)
グラフ合成した monorepo の justfileこのリポジトリの根の justfile(20 レシピ・ゴール 15)
@costこちらが書いた秒数実測(--isolate で各レシピを cold で計測)
diffこちらが書いた unified diff実際の git diff(15 個の実編集)
正解kind ∈ catches && at ⊆ scope という規則全レシピを走らせた終了コード
「何が落ちるか」こちらが決める走らせて分かる(expected フィールドが存在しない)

real/mutations.ts にあるのは編集だけで、期待する失敗は書いていません。 何が赤くなるかは src/observe.ts が 20 レシピ全部を走らせて記録します。 だから「思っていたものが壊れない」「思っていなかったものが壊れる」が バグではなく結果になる。

ベースラインは 20/20 green・machine 40.8 秒・wall 20.5 秒(レシピ 21・ゴール 16)。 合成の 18.5 分に対して桁が 2 つ違うので、以下は割合で読んでください。

cold で測る必要があった。 最初に測ったとき、MoonBit のテストは どれも 0.0 秒でした —— 先に走った moon-build が全部コンパイルを 済ませていたから。ビルドキャッシュがあるとタスクの値段は「何が先に走ったか」に依存する# @reset: moon clean を注釈として足して各レシピを cold で測り直したら、 合計 19 秒 → 40.1 秒になりました。

12.2 一番の収穫 — 15 個のうち 9 個は、何も壊さない

実測したラベル(real/runs.json):

編集実際に落ちたもの
lib_wire_key(ワイヤ形式のキーを単数形に)test-lib
jevdsl_confidence(confidence から abs を外す)test-jevdsl
task_filter_glob(*/ を跨ぐように)test-task-filter
eslint_plugin_report_at(閾値 1.5 → 1.2)test-eslint-plugin
moba_counter_type(カウンタを Double に)moon-build moon-check(+ conformanceblocked)
report_signature(places : IntInt?)moon-build moon-check test-jevdsl test-jevlang
lib_choice_ceiling(255 → 256)何も
jevlang_js_rounding(Math.round に)何も
jevlang_mbt_rounding(同じ編集を MoonBit 側に)何も
jevlang_threshold_strictness(>=>)何も
hooks_policy_threshold(0.5 → 0.65)何も
hooks_gate_timeout(2500ms → 250ms)何も
comment_typo / readme_wording / docs_report_link何も(意図どおり)

6/15 しか検出できない。 そして中身が効いている:

  • 丸めの 2 件はどちらも通る。 formatNumberコメント自身がMath.round は +∞ 側に、MoonBit の to_int は 0 側に丸めるので 負の半分で食い違う。この関数の仕事は一致すること」と書いてあり、 さらに conformance という 2 実装の一致を見るレシピが存在するのに、 両側とも赤くならない。理由は単純で、examples/ の 3 本が負の値を通らない。 つまり コメントが警告している場所に、テストが無い
  • max_choices = 255 は誰も固定していない。 00 が 実 API に対して測った定数なのに、256 にしても全部 green。
  • hooks/policy.jev の閾値も固定されていない。 18 §6b は 「閾値を差し替え可能にした」と書いていて、実際に差し替えても何も言われない
  • report_signature は予想しなかったものを壊した。 report/fmt.mbt の シグネチャを変えたら test-jevdsltest-jevlang が落ちた。 規則で書いていたらこの 2 つは書き漏らしていた —— これが 「走らせて分かる」の実物です。
  • moba_counter_typeconformance を blocked にした。 グラフが conformance: moon-build と宣言しているので、moon-build が落ちると conformance は走れない。グラフの予測が実行で確認された唯一の例。

ラベルは 2 回測って同じでした。 この表は一度 main を取り込む前に測り、 main が eslint-plugin のテスト・judge・warm を書き換えたあとに測り直しています。 スイートは 19 → 20 レシピ・32.3 → 40.1 秒に変わったのに、 落ちるレシピの集合は 15 件すべてで一致しました。 実測ラベルは少なくともこの程度には安定しています。

フィルタの評価より先に、リポジトリの評価が出てしまった。 「ありそうな編集」を 15 個入れて 9 個が無反応なら、 それはフィルタの話ではなくスイートの穴の話です。 22 §11 が 「保留セットを作り直したら全体の数字が下がった」のと同じ向きで、 正直な測り方に変えると数字は下がる

12.3 検出は 18/18。ただし glob だけの静的判定に負ける

6 個の壊れる編集 × 3 回 = 18 判定:

戦略検出machine削減タスク数無害な編集での machine
全部走らせる6/640.8s0%20.040.8s
static(inputs glob + 依存伝播)6/614.8s63.8%3.713.6s
glob だけ(伝播なし)6/68.1s80.2%2.99.9s
Jev(t=1.0)18/1813.9s65.9%4.311.4s
Jev(t=1.25)18/1810.8s73.5%3.0

見逃しは 45 リクエスト中 0 件(t=1.25 まで)。そこは合成と同じ。 しかし §2 の結論が反転します:

  • 合成では glob だけが 14/15 で取りこぼし、static が 30.9% しか削れなかった
  • ここでは glob だけが 6/6 で 80.2% 削る —— 無料で、Jev より良い

理由はグラフの形です。合成の monorepo は packages/shared が 下流全部の入力だったので、依存伝播が爆発しました。このリポジトリは 扇形がほとんど無い(moon-deps → 各テスト、moon-buildconformance だけ)。 @inputs も 1 レシピ 1 ディレクトリでほぼ正確。 静的解析が既に十分正確なら、判断を足す余地は無い。

フィルタが効くのは「静的解析が悲観的になる形のグラフ」だけ。 共有パッケージから下流に伝播する monorepo では効き(§3)、 独立したパッケージが並んでいるだけのリポジトリでは効かない。 グラフの形を見てから入れるかを決めるべきで、 §10 の「68.6% 削減」を一般則として持ち出してはいけない。

そして絶対値でも割に合いません。スイート全体が 40.8 秒なので、 フィルタが 0.17 秒・$0.00012 で買えるのは最大でも 27 秒です。 globs only が無料で 32.7 秒削るなら、このリポジトリでは答えは「使わない」

12.4 効いたのは 1 行のコメントだった

診断は明確でした。Jev は conformance を選びすぎていた:

conformance を選んだ回数     31/45      平均スコア 1.15   ← 唯一 1.0 を超えるゴール

conformance閉包が一番深いゴール(自身 3.8s + 前提 14.0s)なので、 コスト向けのフィルタとしては最悪の偏りです。lib の定数を変えただけ、 hooks/policy.jev の閾値を動かしただけの diff でも選ばれていました。

doc コメントを見たら、何をするかは書いてあって、何を守るかが書いていない:

-# Replay every example through both jevlang implementations and diff them
+# Check the MoonBit and JS jevlang implementations still agree on examples/*.jev

これだけ変えて測り直すと:

conformance を選んだ回数削減検出
変更前(何をするか)31/4553.3%18/18
変更後(何を守るか)18/4565.9%18/18

1 行の散文で 12.6 ポイント。 編集ごとに見ると効き方がはっきり出ます:

編集変更前変更後
lib_choice_ceiling27.6s10.9s
moba_counter_type21.4s8.2s
hooks_policy_threshold22.6s7.1s
task_filter_glob18.0s2.9s

17 の 「精度への投資先は質問文ではなく項目名と 1 行の説明」が、 合成ロスターではなく実在の justfile で再現した。 しかも直し方が具体的に言える: doc には「何をするか」ではなく「何を守るか」を書く。

12.5 「テストが落ちない」と「何も変わらない」は別物

逃げ道の noul は合成と同じ挙動でした —— p ≥ 0.4 で無害 9/27 に発火、壊れている 18 件に 0 件。 片側 0 誤りは再現します。

一方 p(振る舞いを変える)分離しません:

合成(§4)実測(§12)
壊れる diff0.7810.772
壊れない diff0.1800.580

これは Jev が間違えているのではなく、ラベルの意味が違うからです。 hooks_gate_timeout(2500ms → 250ms)は確かに振る舞いを変えます。 変えるのに、それを見るテストが無いだけ。つまり実測ラベルの 「壊れない」は**「無害」ではなく「このスイートには見えない」**です。

合成コーパスではこの 2 つが構造的に一致していました(欠陥を植えたか植えなかったか)。 実在のリポジトリでは乖離して、その乖離こそがスイートの穴です。 §6 の _all_waste が生き残ったのは、あれが「どのチェックも走らせずに通るか」—— つまりスイート基準で聞いているからで、 _changes_behaviour が壊れたのは真実基準で聞いているから。 質問をどちらの基準で書いたかを意識しないと、ラベルと噛み合いません。

12.6 §10 のどれが生き残ったか

§主張実測後
1グラフと判断の分業が成立する生きている(conformance の blocked が実行で確認された)
2検出を落とさず machine time を削れる条件つき。削れるが静的解析に負ける形のグラフがある
3静的解析だけでは健全さと安さが両立しない反転。扇形の無いグラフでは glob だけで 6/6・80.2%
4文脈が効くのは意図が書かれていないときだけ未再測(実測側は arm を振っていない)
5グラフはコードに、プロンプトに置くと逆効果未再測
6逃げ道を別 noul にすると片側 0 誤り生きている(9/27 対 0/18)
7閾値はコストで重み付けする生きているが、このスイートでは床が高すぎて逆効果(1 秒床で 65.9% → 48.6%)
8hunk が要るのはパスが内容を決めないファイル未再測

新しく出たもの:

  1. 正解ラベルを実測にすると、測っているものが変わる。 「ありそうな編集」15 個のうち 9 個はスイートに見えない。フィルタの精度を測るつもりで スイートの穴を測ることになった(§12.2)。
  2. フィルタが効くのはグラフの形次第。 共有パッケージから伝播する monorepo では効き、 独立したパッケージが並ぶリポジトリでは無料の glob に負ける。 入れる前にグラフの扇形を見る(§12.3)。
  3. doc には「何をするか」ではなく「何を守るか」を書く。 1 行変えて削減 53.3% → 65.9%、過剰選択 31/45 → 18/45(§12.4)。
  4. ビルドキャッシュがあるとタスクの値段は順序に依存する。 cold で測らないと、先に走ったレシピが全員のコンパイルを払って 残りが 0.0 秒に見える(§12.1)。
  5. 「テストが落ちない」と「何も変わらない」を同じ質問で聞いてはいけない。 実測ラベルは前者、_changes_behaviour は後者を聞いていて、 合成コーパスでは一致していたものが実リポジトリでは 0.180 対 0.580 に開く(§12.5)。

12.7 §12 の限界

  • 編集はこちらが書いた。 実測になったのはラベルだけで、 15 個の編集そのものは選んだもの。実際の PR の分布ではありません (ただし「どれが検出されるか」は選べていない —— 9 個が無反応だったのがその証拠)。
  • 壊れる編集が 6 個しかない。 18 判定は 17 §3 の 18 件と同規模ですが、 合成の 45 件より薄い。検出できる場所を狙って編集を足せば増やせますが、 それは「検出可能性で選んだ」ことになるので、 ここでは無選別の 15 個をそのまま出しています。
  • スイートが 40.8 秒しかない。 削減率は意味を持ちますが、 絶対値では 27 秒。フィルタの経済性を測るには小さすぎる題材で、 §3 の 18.5 分のような規模での実測は別のリポジトリが必要です。
  • arm を振っていない。 §4・§5・§8(paths/stat/diff/diff_graph--no-intent)は実測側では測り直していません。表の「未再測」はそのままの意味です。
  • 1 回の計測。 @cost は cold で 1 回ずつ測った値で、分散を見ていません。 32 秒のスイートでは数百ミリ秒のばらつきが削減率に乗ります。
  • moon fmt --checktruth.mjs を graph に入れていない。 前者は HEAD で赤く(このリポジトリは moon-fmt clean ではない)、 後者は正常終了が 1 なので、どちらも pass/fail のグラフに載せられませんでした。 20/20 green は「載せられたものが全部通る」という意味です。