ADR-41 — Inference budget design (wiring, on-hit policy, measurement-gated defaults)
Status: Proposed, 2026-06-03. Rigorの推論バジェット(inference
budget)の目標設計を記録する。これは、仕様のbudgets:テーブルがv1にとって
規範的(normative-for-v1)である一方で大部分が未配線(unwired)であり、
大規模アプリで実コストを牽引するカテゴリーがまさにその未配線のものであること
を、ある調査が突き止めたあとに書かれている。ここに記すものはまだ何一つ実装
されていない。作業は以下のLayer 1(ドキュメント・仕様の整備)とLayer
2(負荷を担うバジェットの配線)として順序付けられている。
根拠:
docs/notes/20260603-inference-budget-reality-survey.md
(Rigorの実測)と
docs/notes/20260603-inference-cutoff-prior-art.md
(他の6つのチェッカーがこれをどう解決しているか)。規範的仕様:
docs/type-specification/inference-budgets.md。
Context
Section titled “Context”仕様が約束するものvs. エンジンが実際に行うもの
Section titled “仕様が約束するものvs. エンジンが実際に行うもの”inference-budgets.mdは、
10行から成る設定可能なbudgets:テーブル(recursion_depth、
call_graph_width、operator_ambiguity、union_size、
structural_growth、interface_candidates、hash_erasure_keys、
hash_erasure_values、negative_fact_display)と、static.*の不完全推論
診断ファミリーを定義している。2026-06-03の調査で次が判明した:
budgets:キーは一切パースされず、どの行も強制されていない。実働する カットオフは、ハードコードされ、サイレントで、設定不可な3つのガードだけ である──(receiver, method)の再帰再入ガード(実効深さ1 →Dynamic[top])、100ノードの先祖ウォーク上限、そしてADR-20のHKTリデューサー の64ステップ燃料── これにADR-10のgemごとのbudget_per_gem(唯一の設定可能な バジェットだがオプトイン専用)が加わる。- 配線済みのガードは実際のコストの崖とは直交している。これらは小さな再帰
下降パーサ(haml 421回、jbuilder 126回ヒット)で発火してそれらを高速に
保つ。だがコーパス中で最も遅いプロジェクト(Redmine: 331ファイル、172秒、
1.5 GB)は再帰ガードを71回しか発火させず、3つのガードすべてを上限なしで
通り抜けてしまう。メモリプロファイルはまっすぐに、際限のないユニオン/構造的
成長── 未配線の
union_size/structural_growthカテゴリー── を指している。 - マニュアルは
budget_per_gemを誤って記述している(「ミリ秒単位の時間 バジェット、デフォルト1000」と主張するが、実際はメソッド定義のカウントで デフォルト5000である)。そもそもバジェットについてのユーザー向け説明が全く 存在しない。
先行技術調査が突き止めたこと
Section titled “先行技術調査が突き止めたこと”Rigorはcamp (b)── 必須のシグネチャを伴わない全プログラム/深い推論 (再帰ガードはまさにこのために存在する)── であり、camp-(a)の脱出口 (RBS/インラインRBS/生成された境界シグネチャが深い推論を止める)を備えて いる。調査した6つのツールのうち:
- Sorbet / Steep / mypy(camp a、または戻り値についてcamp-a)は問題を 回避している。境界での必須シグネチャ、ローカルのみの推論である。union-size /反復バジェットは不要。mypyはさらに共通の上位型へjoinでwidenし、精度を 犠牲にして高速な不動点を得ている。
- TypeScript / Pyright(camp b)は密なカウンティングバジェットを使い、
オーバーフロー時には主にエラーを出す(TS2589 / TS2590、Pyrightの
maxCodeComplexity)。 - PHPStan / Psalm(camp b)は値追跡型のwideningを使う(256キーで 配列 → 一般配列、など)。常にサイレントである。PHPStanは定数スカラー ユニオンの上限を削除したことでOOMリグレッションを引き起こした── 上限が 負荷を担っていることの証拠である。
- TypeProf(camp b、最も近い類似物。全プログラム、必須シグネチャなし)は
union_width_limit = 10とtype_depth_limit = 5でuntypedへwidenし、 決してエラーを出さず、v2で不動点をインクリメンタルにしたためバジェットは 編集ごとに再消費される。
決定的な観察は次のとおり: (1) Rigorの設計はTypeProfと双子であり、その
union_width_limit = 10はRigorの仕様のunion_size = 24よりきつい。
(2)サイレントなwideningは主流だが、その不透明さは一様に批判されている。
(3)上限は存在しなければならないが、その値は当て推量ではなく観測された分布
から来るべきである。
Working Decision
Section titled “Working Decision”WD1 — camp-(b)-with-escape-hatchアーキテクチャを維持する。バジェットは付け足しではなく本質的
Section titled “WD1 — camp-(b)-with-escape-hatchアーキテクチャを維持する。バジェットは付け足しではなく本質的”Rigorはユーザーメソッドの戻り値を深く推論し続ける(必須シグネチャなし── それ
がプロダクトである)。したがって推論バジェットはエンジンの恒久的・本質的な
一部であって、一時的な足場ではない。バジェットなしの深い推論は実コードでは停止
しえない。境界の契約(contract。戻り値アノテーション/RBS/sig-genスタブ)は
第一級の脱出口であり続ける── TypeScriptで明示的な戻り値型が果たす役割、
Sorbetで必須のsigが果たす役割と同じである。バジェットヒットは、ユーザーが契約
を供給することで常に解消でき、Rigor専用のつまみを編集することでしか解消できない
ということは決してない。
WD2 — ヒット時のポリシー: widenし、かつ診断する(差別化要因)
Section titled “WD2 — ヒット時のポリシー: widenし、かつ診断する(差別化要因)”すべてのバジェットヒットは保守的な型(Dynamic[top]/宣言された境界/untyped)
へと劣化する── TypeProf / PHPStan / mypyのようにwidenし、TypeScriptのように
決してエラーを出さない。これは偽陽性の規律の価値を保つ。バジェットが動作中の
コードを報告されたエラーに変えてはならない。
しかしRigorはstatic.*の不完全推論診断も発行する(デフォルトは:info)。
これはどのバジェットがどこで発火したかを記録する。これは調査したすべての
wideningツールが開けたままにしているギャップである── mypyのobjectへのサイレント
なjoin、Pyrightのサイレントな「pin」、TypeProfの匿名のuntyped── そして仕様が
すでに求めているものである。プロダクトとしての主張はこうだ: TypeProfのように
widenする。だが他の誰もやらないように、推論がどこで止まったかをユーザーに伝える。
こうすればDynamic[top]が「真の開かれたサーフェス(surface)」なのか「バジェットの
カットオフ」なのか区別できないということは決して起こらない。RIGOR_BUDGET_TRACE
(2026-06-03に出荷)は集計のみのデバッグ用前駆物であり、サイトごとのstatic.*診断
が本当のサーフェスである。
WD3 — デフォルトは実測ゲート式であって、当て推量ではない
Section titled “WD3 — デフォルトは実測ゲート式であって、当て推量ではない”精度を扱いやすさと引き換えにする値を持つすべてのバジェット(union_size、
structural_growth、および将来のあらゆる値/シェイプ上限)について、デフォルトは
原理から推論するのではなく、実プロジェクト上の観測された分布から選ばれなければ
ならない(MUST)。論拠: PHPStanの削除時OOMと偽陽性の規律の価値はいずれも、誤った
上限のコストが非対称であることを示している── 低く設定しすぎると真のA | B | … | N
ユニオンがtopへ潰れ、偽陽性を製造するか、サイレントにチェックを失う。手順:
Redmine + Mastodon上で実際のjoinアリティ(arity)とシェイプメンバーの成長分布を計装
する(BudgetTraceのアプローチを、カウンタからヒストグラムへ拡張する)。そして、
正当なケースをクリップせずに病的なケースを捕捉する、観測された裾のパーセンタイルに
デフォルトを設定する。仕様の現行の24 / 16はこの実測待ちのプレースホルダーであり、
先行技術のアンカー(TypeProf 10 / 5)は真の値が24より低いかもしれないと示唆する。
WD4 — 2つのバジェットスタイルを、カテゴリーごとに割り当てる
Section titled “WD4 — 2つのバジェットスタイルを、カテゴリーごとに割り当てる”- 終端ガードはカウンティング+ハードのまま(正しさのため交渉の余地なし): 再帰再入(深さの下限)、先祖ウォーク(100)、HKT燃料(64)。これらは下限を 下回って無効化することはできない。プロジェクトは非停止をオプトインできない。
- 精度バジェットはwidening+設定可能:
union_size、structural_growth、 およびhash_erasure_*の表示上限は上位型へ一般化され、検証済みの範囲内でユーザー がチューニングできる(仕様はすでに範囲を規定している)。これはPsalmのモデルを 反映している── 精度バジェットを文書化された設定として公開する、調査した中で唯一の ツールである── 「これらをチューニングすると精度は変わるが正しさは変わらない。 驚きはバグではない」というPsalm流の注意書きも含めて。
WD5 — recursion_depth: 終端の下限を精度アンロール深さと分離する
Section titled “WD5 — recursion_depth: 終端の下限を精度アンロール深さと分離する”仕様のrecursion_depth = 5は、エンジンが行わない2つのことを混同している。
次のように再定義する:
- 終端の下限(ハード、≥1): 非停止を防ぐ再入ガード。今日は深さ1で配線 されている。
- 任意の精度アンロール深さ(デフォルト1 = オフ): Rigorがwidenする前に、
戻り値の精度を得るために再帰メソッドを何レベルまでアンロールするか。調査した
camp-(b)ツールのいずれも精度のために一般的な再帰をアンロールしていない
(TypeProfは深さ5で
anyへwidenし、精度の主張をしない)。したがってアンロール は先送りされた需要駆動の改良であり、下限の一部ではない。
文書化されたrecursion_depthのデフォルトを実装の実態(1)に合わせ、2つの意味を
別々に文書化する。
WD6 — 文書化されたテーブルを完成させる
Section titled “WD6 — 文書化されたテーブルを完成させる”実在するが未掲載の2つのガード── ancestor_walk(100)とhkt_fuel(64)── を
バジェットテーブルに追加し、文書化された集合が強制される集合と一致するように
する。どちらもコーパス中のどこでも発火しなかったので、値は寛大である。
プロジェクトがそれらに拘束されることを示すまでは、ハードコード(設定不可)の
ままにしておく。
評価の判定(2026-06-03)
Section titled “評価の判定(2026-06-03)”「現行のしきい値を維持するvs. より良いデフォルトを探すvs. 未配線の仕組みが 配線されるまで評価すらできない」と問われたとき、誠実な答えはこうだ── これは 1つの全体的な選択ではなく、バジェットカテゴリーごとに分割されるものであり、 テーブル全体に単一の判定を当てはめるのは誤りである。
| バジェットグループ | 配線済みか? | 判定 | 理由 |
|---|---|---|---|
| 終端ガード(再帰depth-1、先祖100、HKT燃料64) | yes | 維持 | それらが誤動作するというコーパス証拠はゼロ: 再帰ガードはパーサを高速に保ちFP安全(Dynamic[top]へwiden)、先祖+燃料は一度も発火せず。チューニングに観測された利益はない。 |
budget_per_gem | yes | 値は維持、ドキュメントを修正 | gemごとのデモで5000がテスト対象gemを完全にカバーすることが示された(5000 = 20000でプラトー)。デフォルトは妥当で、誤っているのはマニュアルだけ。 |
union_size / structural_growth | no | 実測まで評価不能 | 何もそれらを強制しないので、24 / 16を判断する観測可能な効果がなく、「より良いデフォルトを探す」ことは接続されていないつまみの上を探すことになる。 |
call_graph_width、overload_candidates、operator_ambiguity、interface_candidates | no | 先送り | どのコーパスプロジェクトも拘束したことがない。 |
支配的な結論。この作業を動機づけた問題(大規模アプリのコストの崖── Redmine 1.5 GB)にとって唯一重要なバジェットについて、「より良いデフォルト」の 問いは現状では答えられず、現状維持は崖を放置したままにする。したがって ゲートとなるアクションはしきい値のチューニングでも現状受容でもない── それは 読み取り専用の分布実測(Slice 2a)である: Redmine / Mastodon上で実際のjoin アリティとシェイプ成長の分布を、まだ上限を強制せずに計装する。これは「評価 不能」を「評価可能」に変える最も安価なステップであり、24がそもそも正しい範囲に あるか(TypeProfの先験値は10)を検証し、いかなるデフォルトが選ばれる前にWD3が 要求する観測された裾を供給する。2aの前に値を選ぶことは、PHPStanの上限削除OOM の誤りを逆向きに繰り返すことになる。
注意。終端ガードについての「維持」は「変更する証拠がない」を意味するので
あって、「最適と証明された」ではない: 再帰ガードはhamlで421回発火したが、それら
のDynamic[top]へのwideningが実際の精度/偽陽性のコストを払ったかどうかは
測られていない(発火回数だけが測られた)。ガードがいつか再検討されるなら、
それもまた当て推量ではなく影響分布の実測を必要とする── Layer 2と同じ規律である。
Slice 2aの結果(2026-06-03)── union_size仮説は反証された
Section titled “Slice 2aの結果(2026-06-03)── union_size仮説は反証された”Slice 2aを実行した(ユニオンアリティのヒストグラム、読み取り専用)。これは
union_sizeがLayer 2のレバーであるという判定の作業仮説を覆した(調査ノートの
Survey 4):
- メモリはユニオン幅と相関しない。 kramdownは124 MBで932メンバーのユニオンを 生成する。Redmineの最も広いユニオンは1.5 GBで37メンバーである。Redmine の膨張は広いユニオンではない。
- 24で上限を設けてもRedmineの254,584個のユニオンのうち20個に触れるだけで、 メモリの恩恵はほぼ0なのに、それらの20個のおそらく正当なjoinに偽陽性のサーフェス を加える── まさにWD3が防ごうとする非対称コストの失敗で、配線前に捕捉された。
- 先行技術の論理はデータによって反転する: TypeProfの10は大規模プロジェクト
ごとに95〜117個のユニオンをクリップするだろう。
union_size = 24はどちらかと いえば低すぎる。配線するとしてもそれは〜40〜64での表示/病理用の弁であり (Redmineの最大37を上回り、kramdown-932 / mastodon-184の裾だけをクリップする)、 メモリの修正ではない。
改訂されたLayer 2。入り口としての「union_sizeを配線する」を取り下げる。
負荷を担う問いは今やこうだ: 実際にRedmineの1.5 GBを何が割り当てているのか?
── 型の宇宙とともにスケールするがユニオン幅とはスケールしない何か
(structural_growth、ファクトストア/ナローイングの蓄積、RBS環境、または保持
されたメソッドごとのスコープ)。次のステップは上限ではなく、Redmineのメモリ
プロファイル(ヒープ割り当ての帰属)である。union_sizeは、配線するとしても
低優先度の高上限の弁になる。これはSlice 2aがその役目を果たしているということだ:
1つの読み取り専用の実測が、有害なデフォルトで誤ったバジェットを配線することから
我々を止めた。
更新 ── その後Slice 2bが崖を完全に解消した(§ Slices, 2bを参照): 1.5 GBは
rigor-activerecord内の1箇所のメモ化されない失敗から生じた420万個の保持された
Stringであり、バジェットなど全くなかった。修正済み。Redmineは217 MB / 84秒へ
低下した。メモリの崖とバジェットテーブルは切り離され、Layer 2のバジェット配線は
今や需要先送りである── どのコーパスプロジェクトもバジェット型のコストを示さない。
Slices
Section titled “Slices”- Layer 1 — ドキュメント・仕様の整備(安価、高確信度、新たな実測不要)。
docs/manual/03-configuration.mdのbudget_per_gemバグを修正する (単位 = メソッド定義数、デフォルト5000)。- WD5に従って
recursion_depthを整合させる(デフォルト1、2つの意味を分離 する)。 ancestor_walk/hkt_fuelをテーブルに追加する(WD6)。- 欠落しているユーザー向けバジェット説明を執筆する(配置はTBD── ハンド ブックの付録vs. マニュアルのセクション)。
- Layer 2 — 実際のコストレバーを見つけて配線する(重大、実測ゲート式)。
- 2a. 完了(2026-06-03)。
BudgetTraceを読み取り専用のユニオンアリティ 分布へ拡張し、Redmine + Mastodonで実行した。結果: ユニオン幅はメモリレバー ではない(上記「Slice 2aの結果」を参照)──union_sizeは格下げ。シェイプ メンバー成長の計装はまだ保留中(構造的成長のサイトが特定され次第の後続作業)。 - 2b(改訂)。完了(2026-06-03)。 Redmineをヒーププロファイルした
(
RIGOR_HEAP_PROFILE/RIGOR_HEAP_TRACE)。結果: 1.5 GBはそもそも バジェットの問題ではなかった── ライブヒープの89 %はStringで、そのうち 98.5 %はrigor-activerecord内の1行に追跡された(schema_table_or_nilは 成功だけをメモ化していたため、欠落したスキーマファイルがARの呼び出しサイト ごとにエラー文字列を再追加していた → 420万個の保持された文字列)。失敗を メモ化することで修正。Redmineは217 MB / 84秒へ低下した(−86 % / −51 %)。 Mastodonはschema.rbを同梱するため一度も影響を受けなかった。調査ノートの Survey 5を参照。 - Layer 2への正味の影響:メモリバジェットの配線を動機づけた大規模アプリ
のコストの崖は解消され、バジェットテーブルとは直交していた。どのコーパス
プロジェクトも現在バジェット型のメモリコストを示さない。したがって
structural_growth/union_sizeの配線は需要先送りである── 配線して 対抗すべき実測されたコストがない。将来のプロジェクトが真に型構造的な膨張を 示すなら、まず2a流の分布プローブを再実行する(WD3)。 - 2c(復活した場合)。将来のプロファイルが関与させたバジェットを、widening
+
static.*診断(WD2)で配線し、デフォルトはその観測された分布から取る。 - 2d(復活した場合)。精度バジェットを
budgets:配下でユーザー設定可能にし、 範囲検証+Psalm流の注意書き(WD4)を付ける。 union_sizeは、配線されることがあるとしても、低優先度の高上限(〜40〜64) の表示/病理用の弁であって、メモリの修正ではない。
- 2a. 完了(2026-06-03)。
- 先送り:残りの未配線の行(
call_graph_width、overload_candidates、operator_ambiguity、interface_candidates)はどのコーパスプロジェクトも 拘束したことがない。需要に応じて配線する。精度アンロール深さ(WD5)は需要 駆動である。
他のADRとの関係
Section titled “他のADRとの関係”- ADR-4(推論エンジン)とADR-5(ロバストネス原則)── バジェットは 「戻り値には厳格、入力には寛容、動作中のコードを決して怖がらせない」の運用 形態である: バジェットにおいてRigorは報告ではなくwidenする。WD2の「診断は するがエラーは出さない」はADR-5の読み方である。
- ADR-10(依存ソース推論)──
budget_per_gemは唯一すでに配線されている バジェットであり、WD4の設定可能+範囲付き+診断発行モデルのテンプレートで ある(dynamic.dependency-source.budget-exceededは、WD2が一般化するstatic.*サーフェスの既存の先例である)。 - ADR-20(軽量HKT)── リデューサーの燃料バジェットは、WD4/WD6がハード コードのまま保つ終端ガードの1つである。
却下/先送りした代替案
Section titled “却下/先送りした代替案”- Rigorをcamp(a)へ移す(シグネチャを必須にし、ローカルで推論する)。 却下: 必須アノテーションなしの深い推論こそがプロダクトである (ロバストネス原則 /「アノテーションのないRubyで動く」という約束)。 camp(a)はバジェット問題を溶かすが、別のツールである (Sorbet / Steepがすでにそこを占めている)。
- バジェットヒット時にエラーを出す(TypeScriptモデル)。デフォルト経路
については却下: バジェットでエラーを出すのは動作中のコードに対する偽陽性の
規律に違反する。
static.*診断は:infoであり、エラーではない。 union_size/structural_growthのデフォルトを今、推論で選ぶ。 実測(WD3)へ先送り── PHPStanの削除時OOMと誤った上限の非対称コストが、当て 推量の値をより高いリスクにする。- すべてのバジェットを設定可能にする。却下: 終端ガード(WD4)は下限を 下回って無効化可能であってはならない。ユーザーがチューニングできるのは精度 バジェットだけである。
Consequences
Section titled “Consequences”- バジェットの明確で本質的な役割: 深い推論エンジンのための恒久的な終端機構で あり、ユーザーが解消できる脱出口と、調査したどの競合も提供しないサイトごとの 診断を備える。
- 即座に着地できる具体的・低リスクなLayer 1(ドキュメント・仕様の修正)と、 偽陽性のリスクを冒さずに実際の大規模アプリのコストの崖を解消する実測ゲート式 のLayer 2。
- 文書化されたバジェットテーブルがついに強制される集合と一致し、
budget_per_gemが正しく記述されるようになる。 - 持ち越される未解決の問い: 正確な
union_size/structural_growthの デフォルトはLayer 2aの分布実測を待つ。先行技術のアンカー(TypeProf 10 / 5) が作業上の先験値である。
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.