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/15 | 18.5m | 0.0% | 7.2m | 26.0 | 18.5m |
| static(inputs glob + 依存伝播) | 15/15 | 12.8m | 30.9% | 6.2m | 14.7 | 13.1m |
| glob だけ(伝播なし) | 14/15 | 8.6m | 53.4% | 4.7m | 10.3 | 6.4m |
Jev(diff arm、t=1.0) | 45/45 | 5.8m | 68.6% | 4.1m | 7.5 | 2.2m |
| 何も走らせない | 0/15 | 0.0m | 100.0% | 0.0m | 0.0 | 0.0m |
そして一番効いたのは 17 §5 の反転だった。
17 §5 は
「リポジトリの文脈は 1 件も動かさなかった」で終わっている。ここでは
ブランチ名とコミット題名を外すと、diff の中身を読める arm だけが生き残る。
packages/shared/src/money.ts の JSDoc の誤字 1 行に対して:
| arm | 意図つき | 意図なし |
|---|---|---|
| static | 17.1m | 17.1m |
| paths(パスだけ) | 0.0m | 7.6m |
| stat(+行数) | 0.0m | 2.3m |
| diff(hunk まで) | 0.0m | 0.0m |
つまり 17 の「文脈は払い損」は「意図が 1 文で書かれているとき」の話で、 意図が書かれていないときは文脈が唯一の情報源になる。 17 §8 の宿題 1 はこれで閉じた(§4)。
他に確定したこと:
- glob だけでは不健全。 依存伝播を外すと 14/15 に落ちる(
a11yの@inputsはweb/**で、変更はpackages/uiにある)。グラフは要る(§2) - グラフはプロンプトではなくコードに置く。 グラフの事実を質問に載せた
diff_grapharm は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 つの情報があり、それぞれ読む主体が違います:
| 由来 | 何を持つか | 誰が読むか |
|---|---|---|
dependencies | just 自身の辺 | コード(閉包・順序・critical path) |
| 最後のコメント行 | just の doc。1 行の散文 | Jev(17 が 90% → 100% を測った分) |
# @inputs: / # @cost: | 入力 glob と CI 秒数 | 静的ベースラインとコスト計算 |
just は直前の 1 行だけを doc にするので、@inputs / @cost を上に置くと
just からは見えず、justfile のテキストからは読めます。
27 レシピ、うちゴール 18 個(非 meta のどのレシピからも依存されていないもの)。
install や build-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/15 | 18.5m | 0.0% | 26.0 |
| static(glob + 依存伝播) | 15/15 | 12.8m | 30.9% | 14.7 |
| glob だけ | 14/15 | 8.6m | 53.4% | 10.3 |
ui_aria_removed を glob だけが落とします。 packages/ui/src/IconButton.tsx から
aria-label を外す変更で、これを捕まえられるのは a11y だけ。
ところが a11y の @inputs は web/** なので字面では当たらない。
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 |
|---|---|---|---|---|---|---|
paths | 45/45 | 6.2m | 66.3% | 4.2m | 8.4 | 2.6m |
stat | 45/45 | 6.1m | 67.1% | 4.2m | 8.1 | 2.3m |
diff | 45/45 | 5.8m | 68.6% | 4.1m | 7.5 | 2.2m |
diff_graph | 45/45 | 6.2m | 66.3% | 4.2m | 8.6 | 2.7m |
見逃しは 240 リクエスト中 0 件。 static の 12.8m に対して 5.8m なので、 グラフが健全に残した分の半分以上がさらに落ちます。
欠陥ごとに見ると、どこに時間が行っているかが全部見えます(diff、t=1.25):
| ブランチ | static | Jev | 検出 | 赤くなるタスク |
|---|---|---|---|---|
lockfile_vuln | 17.5m | 0.9m | 3/3 | audit |
lockfile_licence | 17.5m | 1.0m | 3/3 | license-check |
lint_console | 13.7m | 1.2m | 3/3 | lint |
migration_drops_column | 12.2m | 3.2m | 3/3 | test-api-integration |
unformatted_edit | 13.0m | 3.8m | 3/3 | fmt-check |
bundle_moment | 13.7m | 4.6m | 3/3 | bundle-size |
ui_aria_removed | 13.2m | 5.2m | 3/3 | a11y |
shared_rounding_bug | 17.1m | 7.9m | 3/3 | test-shared |
e2e_testid_rename | 13.7m | 8.3m | 3/3 | e2e-web |
web_type_break | 13.7m | 9.2m | 3/3 | build-web typecheck-web |
web_unit_offbyone | 13.7m | 9.8m | 3/3 | test-web-unit |
schema_nonnull | 16.1m | 10.1m | 3/3 | build-web typecheck-web |
auth_cookie_regression | 13.0m | 12.2m | 3/3 | e2e-auth |
docs_dead_link | 1.2m | 1.1m | 3/3 | docs-links |
infra_instance_size | 0.9m | 0.9m | 3/3 | infra-plan infra-validate |
削減が大きいのは「狭いチェックしか要らない変更」 —— lockfile は audit だけ、
console.log は lint だけ。逆に auth_cookie_regression は 12.2m かかりますが、
これは正しい: e2e-auth(140s)を走らせるには build-web と build-api が要るので、
閉包の値段はグラフが決めていて、Jev はそこを安くできません。
コストと検出のトレードオフ(diff arm)
| 閾値 | 検出 | machine | 削減 | wall | タスク数 |
|---|---|---|---|---|---|
| 0.00 | 45/45 | 18.5m | 0.0% | 7.2m | 26.0 |
| 0.50 | 45/45 | 7.2m | 61.0% | 4.7m | 9.6 |
| 1.00 | 45/45 | 5.8m | 68.6% | 4.1m | 7.5 |
| 1.25 | 45/45 | 4.5m | 75.8% | 3.4m | 6.2 |
| 1.50 | 39/45 | 3.0m | 83.6% | 2.4m | 4.7 |
| 1.75 | 28/45 | 1.9m | 90.0% | 1.5m | 3.0 |
| 2.00 | 0/45 | 0.0m | 100.0% | 0.0m | 0.0 |
1.25 が崖の手前で、75.8% 削って 45/45。1.5 で 39/45 に落ちます。
score が 2.00 ちょうどを返すことは実際にはほぼ無いので、t=2.0 が 0/45 なのは当然です
(上限を閾値にしてはいけない、という 04
の再現)。
4. 文脈は効いた —— 17 との違いは「意図が書かれているかどうか」
17 §5 は
changed_files も直前の終了コードも 1 件も動かさなかったと報告しています。
ここでは逆に、diff の中身が効く場面がはっきり出ます。
--no-intent でブランチ名とコミット題名を落とす(patch だけが残る):
| arm | 意図つき machine | 意図なし machine | 意図つき 無害 diff | 意図なし 無害 diff |
|---|---|---|---|---|
paths | 6.2m | 6.4m | 2.6m | 3.9m |
stat | 6.1m | 5.7m | 2.3m | 3.1m |
diff | 5.8m | 5.5m | 2.2m | 2.3m |
diff_graph | 6.2m | 6.0m | 2.7m | 3.0m |
集計だと差は小さい。1 ブランチに降りると構造が見えます ——
comment_typo(モノレポで一番下流から依存されているファイルの JSDoc 誤字 1 行):
| 意図つき | 意図なし | |
|---|---|---|
| static | 17.1m | 17.1m |
paths | 0.0m | 7.6m |
stat | 0.0m | 2.3m |
diff | 0.0m | 0.0m |
そして「この変更は振る舞いを変えうるか」の確率が、なぜそうなるかを説明します:
| arm | 壊れる diff | 壊れない diff |
|---|---|---|
paths(意図つき) | 0.784 | 0.182 |
paths(意図なし) | 0.756 | 0.482 |
diff(意図つき) | 0.781 | 0.180 |
diff(意図なし) | 0.784 | 0.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 リクエストのトークン |
|---|---|---|---|---|
diff | 45/45 | 5.8m | 68.6% | ~2.9k |
diff_graph | 45/45 | 6.2m | 66.3% | ~3.8k |
悪化します(削減 2.3 ポイント減、トークン 1.3 倍)。理由は構造的で、
前提の解決は既にコードがやっているからです。e2e-auth を選べば
build-web は閉包が足すので、Jev が知る必要はない。
@cost を見せると「高いから避ける/高いから重要」のどちらにも転びうるノイズが増えるだけでした。
同じ情報でも、置き場所で符号が変わる。 依存グラフは コードに置けば無料で正確、プロンプトに置けば有料で不正確。 05 の 「繰り返させたくない選択はコードで消す」と同じ形。
ただし 1 つ例外がありました。§8 の dev_script_tweak は
diff_graph だけが t=1.25 で 0.0m まで落とせています(他は 7.7m)。
コスト情報が効くのは「安い判断で高い suite を避ける」場面らしいが、
1 シナリオなので一般化はできません。
6. 「何も走らせない」は別の noul にすると安全なゲートになる
17 §3 の
「逃げ道は選択肢ではなく別の問い」を、フィルタの形で再現します。
_all_waste(「どのチェックも走らせずに通る」)を単独のゲートに使うと:
| 閾値 | 無害な diff に発火 | 壊れている diff に発火 |
|---|---|---|
| p ≥ 0.30 | 9/15 | 0/45 |
| p ≥ 0.40 | 9/15 | 0/45 |
| p ≥ 0.50 | 6/15 | 0/45 |
| p ≥ 0.60 | 6/15 | 0/45 |
壊れている 45 判定で 1 件も発火しません。 平均は壊れる側 0.161・無害側 0.479 で、
17 の applicable(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 machine | t=1.5 検出 | t=2.0 検出 | |
|---|---|---|---|---|
| 床なし(意図つき) | 45/45 | 4.5m (75.8%) | 39/45 | 0/45 |
| 床 10s(意図つき) | 45/45 | 5.0m (72.7%) | 42/45 | 12/45 |
| 床なし(意図なし) | 44/45 | 4.2m (77.5%) | 39/45 | 0/45 |
| 床 10s(意図なし) | 45/45 | 4.7m (74.5%) | 42/45 | 12/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.json の dev スクリプトのポートを 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 に入っているか」を規則で判定しているだけで、
実際に
vitestやplaywrightを実行して落ちたかは見ていません。@costの秒数もこちらが書いた数字です。削減率はこの表に完全に依存しています。 - オラクルは規則だが、規則はこちらが書いた。 17 §6 の
「同じ人間がラベルを付けた」は薄めましたが消えていません。消えたのは
「1 件ずつ主観で付けた」ところだけで、
CATCHESの catches / scope 自体は設計物です。 e2eとe2e_authを別の kind にしたのはこちらの都合。 これでauth_cookie_regressionはe2e-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. この実験で確定したこと
- グラフと判断の分業が成立する。 ゴールだけスコアリングさせて前提は閉包が足す形なら、 順序も前提も Jev が間違えようがない。17 §6 の 「1 タスクしか選べない」は、グラフを外に置くと消える(§1)。
- 検出を落とさず machine time を 68.6%(t=1.25 で 75.8%)削れる。 1 ブランチ 20 問 1 リクエスト・0.17 秒・$0.000131。 CI 1 本あたり 12.7 分を 0.17 秒の判断で買っている(§3)。
- 静的解析だけでは健全さと安さが両立しない。 glob だけは 14/15 で取りこぼし、 依存伝播を足すと 15/15 だが 30.9% しか削れない(§2)。
- 文脈が効くのは意図が書かれていないときだけ。 17 §5 の 「文脈は 1 件も動かさない」は、ゴール文が意図を明示していたから。 diff だけが入力なら hunk が唯一の情報源で、パスだけでは 無害な変更の判別が 0.482(コイン投げ)まで落ちる(§4)。
- 同じ依存グラフを、コードに置くと効きプロンプトに置くと逆効果。
diff_graphは 68.6% → 66.3%、トークン 1.3 倍(§5)。 - 逃げ道を別 noul にすると、片側 0 誤りのゲートになる。 p(all_waste) ≥ 0.4 は無害 9/15 に発火し、壊れている 45 件に 0 件。 ただし閾値は 0.5 ではなく 0.4(分布を見ないと引けない)(§6)。
- 閾値はコストで重み付けする。 唯一の見逃しは 4 秒のタスクが 1.24 対 1.25 で落ちたもの。「10 秒以下は無条件」で 45/45 に戻り、代償は 0.5 分(§7)。
- 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.tsはgit diffからそのまま走るので、保存ごとに 0.2 秒で「今どのテストを回すか」が出せる。 06 の I(レイテンシを UX に使う)の一形態で、 ここではフィードバックが速い側に倒す使い方になる。 ★検証済み → 25 §7 同じ diff を 10 回投げて、タスクごとの標準偏差を出す。 境界付近の不安定さが定量化できれば、床の代わりに 「信頼区間の下限が閾値を越えたら skip」にできる。scoreの分散。追記: 作らなくてよかった。 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(+ conformance は blocked) |
report_signature(places : Int → Int?) | 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-jevdslとtest-jevlangが落ちた。 規則で書いていたらこの 2 つは書き漏らしていた —— これが 「走らせて分かる」の実物です。moba_counter_typeはconformanceを 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/6 | 40.8s | 0% | 20.0 | 40.8s |
| static(inputs glob + 依存伝播) | 6/6 | 14.8s | 63.8% | 3.7 | 13.6s |
| glob だけ(伝播なし) | 6/6 | 8.1s | 80.2% | 2.9 | 9.9s |
| Jev(t=1.0) | 18/18 | 13.9s | 65.9% | 4.3 | 11.4s |
| Jev(t=1.25) | 18/18 | 10.8s | 73.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-build → conformance だけ)。
@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/45 | 53.3% | 18/18 |
| 変更後(何を守るか) | 18/45 | 65.9% | 18/18 |
1 行の散文で 12.6 ポイント。 編集ごとに見ると効き方がはっきり出ます:
| 編集 | 変更前 | 変更後 |
|---|---|---|
lib_choice_ceiling | 27.6s | 10.9s |
moba_counter_type | 21.4s | 8.2s |
hooks_policy_threshold | 22.6s | 7.1s |
task_filter_glob | 18.0s | 2.9s |
17 の 「精度への投資先は質問文ではなく項目名と 1 行の説明」が、 合成ロスターではなく実在の justfile で再現した。 しかも直し方が具体的に言える: doc には「何をするか」ではなく「何を守るか」を書く。
12.5 「テストが落ちない」と「何も変わらない」は別物
逃げ道の noul は合成と同じ挙動でした —— p ≥ 0.4 で無害 9/27 に発火、壊れている 18 件に 0 件。 片側 0 誤りは再現します。
一方 p(振る舞いを変える) は分離しません:
| 合成(§4) | 実測(§12) | |
|---|---|---|
| 壊れる diff | 0.781 | 0.772 |
| 壊れない diff | 0.180 | 0.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%) |
| 8 | hunk が要るのはパスが内容を決めないファイル | 未再測 |
新しく出たもの:
- 正解ラベルを実測にすると、測っているものが変わる。 「ありそうな編集」15 個のうち 9 個はスイートに見えない。フィルタの精度を測るつもりで スイートの穴を測ることになった(§12.2)。
- フィルタが効くのはグラフの形次第。 共有パッケージから伝播する monorepo では効き、 独立したパッケージが並ぶリポジトリでは無料の glob に負ける。 入れる前にグラフの扇形を見る(§12.3)。
- doc には「何をするか」ではなく「何を守るか」を書く。 1 行変えて削減 53.3% → 65.9%、過剰選択 31/45 → 18/45(§12.4)。
- ビルドキャッシュがあるとタスクの値段は順序に依存する。 cold で測らないと、先に走ったレシピが全員のコンパイルを払って 残りが 0.0 秒に見える(§12.1)。
- 「テストが落ちない」と「何も変わらない」を同じ質問で聞いてはいけない。
実測ラベルは前者、
_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 --checkとtruth.mjsを graph に入れていない。 前者は HEAD で赤く(このリポジトリは moon-fmt clean ではない)、 後者は正常終了が 1 なので、どちらも pass/fail のグラフに載せられませんでした。 20/20 green は「載せられたものが全部通る」という意味です。