ルールエンジンは60年間、同じ前提の上に立っていた。検証対象は「事実(fact)」であるという前提だ。
Droolsは Java オブジェクトを「fact」としてworking memoryに入れる。Regoはinputをすでに真であるデータとして扱う。JSON Schemaは文書構造が所与のものとみなす。すべて同じ仮定だ ── 入ってきたデータは事実である。
ところで、ルールエンジンの存在理由は何か? データがルールを満たすかどうかを検証することだ。検証が必要な対象を「すでに真であるもの」と呼ぶのは矛盾である。
事実ではなく主張である
検証対象はfactではなくclaim(主張)である。真かもしれないし偽かもしれない断言だ。ルールによって妥当性が判定されなければならない。
JWTがすでにこの原則に従っている。sub、exp、issを「facts」ではなく「claims」と呼ぶ。トークン発行者の主張だ。署名を検証し、有効期限を確認し、issuerを照合して初めて信頼できる。
これは1958年にすでに確立された構造だ。
Toulminの論証モデル
Stephen Toulminは1958年に論証の構造を6要素で分析した:
- Claim(主張): 判定対象。真か偽か検証すべきもの。
- Ground(根拠): 判定に使用される証拠データ。
- Warrant(保証): 根拠が主張を裏付けると判断するルール。
- Backing(裏付け): ルールがなぜ有効かに対する正当性。
- Qualifier(限定): 判定の確信度。
- Rebuttal(反駁): 主張が成立しない例外条件。
形式論理学は「前提が真なら結論も真」だ。Toulminは違った。「主張は根拠とルールによって裏付けられるが、例外条件があれば覆される。」すべての論証は反駁可能である。
ルールエンジンは60年間、形式論理学の側に立っていた。入力はfact、結果はallow/deny、例外は別のメカニズム。Toulminは反対側にいた。入力はclaim、結果は程度(degree)、例外は内蔵。
問題は ── Toulminの著書が哲学の書棚に置かれていたことだ。ルールエンジンの書棚からは見えなかった。60年間のミッシングリンク。
そこでルールエンジンを作った
toulminはToulminの論証モデルをGoルールエンジンとして実装したものだ。
要件は進化する
if-elseとtoulminが同じ進化にどう対応するか見てみよう。
// 月曜日: "認証済みユーザーのみアクセス、IPブロック適用、内部ネットワークはブロック免除"
g := toulmin.NewGraph("api:access")
auth := g.Warrant(isAuthenticated, nil, 1.0)
blocked := g.Rebuttal(isIPBlocked, nil, 1.0)
exempt := g.Defeater(isInternalIP, nil, 1.0)
g.Defeat(blocked, auth)
g.Defeat(exempt, blocked)
// 火曜日: "Rate limiting追加"
limited := g.Rebuttal(isRateLimited, nil, 1.0)
g.Defeat(limited, auth)
// 水曜日: "プレミアムユーザーはRate limit免除"
premium := g.Defeater(isPremiumUser, nil, 1.0)
g.Defeat(premium, limited)
// 木曜日: "障害対応中はプレミアムも制限"
incident := g.Rebuttal(isIncidentMode, nil, 1.0)
g.Defeat(incident, premium)
毎日2行追加、既存コードの変更なし。同じ進化をif-elseで:
// 月曜日
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else {
allow = true
}
}
// 木曜日 — 4重ネスト、構造把握不可
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else if isRateLimited(ip) {
if isPremium(user) {
if !incidentMode {
allow = true
}
}
} else {
allow = true
}
}
toulmin: 要件ごとに2行、構造不変。 if-else: 毎回構造全体を作り直す。
ルールはGo関数だ
func(claim any, ground any, backing any) (bool, any)
ground= リクエストごとに変わる判定材料(ユーザー、IP、コンテキスト)backing= グラフ宣言時に固定される判定基準(閾値、ロール名、設定)- 返却 =
(判定結果, 証拠)。証拠はドメインごとの自由型。
func CheckOneFileOneFunc(claim, ground, backing any) (bool, any) {
g := ground.(*FileGround)
if len(g.Funcs) > 1 {
return true, &Evidence{Got: len(g.Funcs), Expected: 1}
}
return false, nil
}
Regoのように新しい言語を学ぶ必要はない。Go関数を書けばよい。
backing — 同じ関数、異なる判定基準
backingはルールの判定基準をランタイム値で渡す。同じ関数を異なるbackingで登録すれば別々のルールになる:
g := toulmin.NewGraph("access")
admin := g.Warrant(isInRole, "admin", 1.0)
editor := g.Warrant(isInRole, "editor", 0.8)
g := toulmin.NewGraph("line-limit")
strict := g.Warrant(CheckLineCount, &LineLimit{Max: 100}, 0.7)
relaxed := g.Warrant(CheckLineCount, &LineLimit{Max: 200}, 0.5)
g.Defeat(relaxed, strict)
backingがnilならルールに判定基準が不要という意味だ。
例外はグラフで宣言する
Graph Builder APIでルール間の関係を宣言すればエンジンが自動的に処理する。関数が識別子だ。文字列名は不要。
g := toulmin.NewGraph("filefunc")
w := g.Warrant(CheckOneFileOneFunc, nil, 1.0)
d := g.Defeater(TestFileException, nil, 1.0)
g.Defeat(d, w)
results, _ := g.Evaluate(claim, ground)
同じ関数を異なるグラフで異なる敗北関係として再利用できる:
strictGraph := toulmin.NewGraph("strict")
strictGraph.Warrant(CheckOneFileOneFunc, nil, 1.0)
// 例外なし — テストファイルも許可しない
lenientGraph := toulmin.NewGraph("lenient")
w := lenientGraph.Warrant(CheckOneFileOneFunc, nil, 1.0)
r1 := lenientGraph.Rebuttal(TestFileException, nil, 1.0)
r2 := lenientGraph.Rebuttal(GeneratedFileException, nil, 0.8)
lenientGraph.Defeat(r1, w)
lenientGraph.Defeat(r2, w)
// テスト + 生成ファイル両方とも例外
判定根拠を追跡する
EvaluateTraceはverdictだけでなく、どのルールが活性化され、どのルールがどのルールを敗北させたかを追跡する:
traced := g.EvaluateTrace(claim, ground)
// traced[0].Verdict: +0.6
// traced[0].Trace: [
// {Name: "CheckOneFileOneFunc", Role: "warrant", Activated: true, Qualifier: 1.0},
// {Name: "TestFileException", Role: "rebuttal", Activated: true, Qualifier: 1.0},
// ]
ルールが数十個あるとき「なぜこのverdictが出たのか」を人間が読める。
判定は一つの数式で計算される
Amgoudのh-Categoriser(2013)を適用した:
raw = w / (1 + Σ raw(attackers))
verdict = 2 × raw - 1
+1.0— 違反確定0.0— 判定不能-1.0— 反駁確定
ルールが発火するとwarrantになる。例外も発火するとattackerになる。数式が両者の勢力を計算してverdictを算出する。例外の例外があれば? attackerのattackerとなり元のルールが復元される。補償原理(Compensation) ── h-Categoriserだけが満たす性質だ。
ルールには三つの強度がある
Nute(1994)の分類を適用した:
| 強度 | 意味 | 例 |
|---|---|---|
| Strict | 絶対に無力化不可 | 「認証なしにadmin APIアクセス不可」 |
| Defeasible | 例外により無力化可能 | 「ファイルごとに関数一つ」 |
| Defeater | 自身は主張せず他のルールのみ阻止 | 「テストファイルは例外」 |
Strictルールは攻撃エッジを拒否する。Defeaterは攻撃のみ行い自身の判定はない。これがルールの強制レベルを構造的に表現する。
Regoと何が違うのか
| Rego | toulmin | |
|---|---|---|
| ルール作成 | Rego DSL学習が必要 | Go関数 |
| 例外処理 | default/elseパターン手動 | defeatsグラフ宣言的 |
| 判定 | allow/deny二値 | [-1, +1]連続値 |
| ルールの正当性 | # METADATA(エンジンが無視) | backing(構造の一部) |
| ルール強度 | なし | strict/defeasible/defeater |
| エンジンサイズ | 数万行 | 数百行 |
| 速度 | インタプリタ(パース→AST→評価) | Go関数直接呼び出し |
Regoは広い ── Kubernetes、Terraform、Envoy統合エコシステムがある。toulminは深い ── Regoにないもの(defeasibility、qualifier、backing)がある。
Qualifierの再配置
Toulminの元のモデルではQualifierはClaimに付属する。「おそらくこの患者にペニシリンを投与すべきだ」── 主張の確信度を表現する様相限定詞だ。
toulminエンジンはQualifierをClaimから各Ruleに再配置した。ルールエンジンにおいてclaimは検証対象に過ぎない。「このファイルに関数が3つある」── 事実確認であり、確信度が付くべき対象ではない。判定の質を決めるのはルールの確信度だ:
- 「ファイルごとに関数一つ」 ── qualifier 1.0(確実なルール)
- 「推奨100行以下」 ── qualifier 0.7(柔軟なルール)
各Ruleのqualifierがh-Categoriserの初期重みw(a)となり、最終verdictがToulmin元のモデルでQualifierが担っていた役割 ── 判定の確信度 ── を代替する。
実証:filefunc 22個ルールのToulmin変換
filefuncはLLMネイティブGo開発のためのコード構造コンベンションツールだ。22個のルールをすべてToulmin warrantに変換した。
強度分類
| Strength | 数 | 割合 | 例 |
|---|---|---|---|
| Strict | 15 | 68% | F1, F2, F3, F4, A1-A3, A6-A16 |
| Defeasible | 4 | 18% | Q1, Q2, Q3, C4 |
| Defeater | 3 | 14% | F5, F6, テストファイル例外 |
大部分がstrictだ ── コード構造コンベンションは本質的に例外を最小化する。
定量的結果
| プロジェクト | ファイル数(前→後) | 平均LOC/ファイル(前→後) | SRP違反解消 | depth違反解消 |
|---|---|---|---|---|
| filefunc | ──(最初から準拠) | 25.1 | 0 | 0 |
| fullend | 87→1,260 | 244→25.4 | 66→0 | 148→0 |
| whyso | 12→99 | 147.8→24.4 | 12→0 | 23→0 |
fullendは87個のファイルが1,260個に増えた。ファイル数は爆発したが平均LOCが244から25.4に下がった。SRP違反66件、depth違反148件がすべて0になった。
理論的基盤
独創的な理論はない。すべて既存研究だ:
| 要素 | 原著 |
|---|---|
| 6要素構造 | Toulmin (1958) |
| strict/defeasible/defeater | Nute (1994) |
| h-Categoriser | Amgoud & Ben-Naim (2013) |
独創性はこれらが繋がるという発見だ。60年間、哲学(Toulmin)、論理学(Nute)、論証理論(Amgoud)にそれぞれあったものが、ソフトウェアルールエンジンという一点で出会う。
契約を計算する
法治主義が機能する理由は裁判官が賢いからではない。構造が判定を強制するからだ。ルールがあり、例外が宣言されており、証拠に従って判決が算出される。
toulminはこの構造をコードに移した。
- Warrant = 法律条項
- Backing = 立法趣旨
- Strength = 強行規定 vs 任意規定
- Rebuttal = 例外条項
- Claim = 事件
- Ground = 証拠
- h-Categoriser = 判決
契約(warrant)を宣言し、例外(rebuttal)を宣言し、証拠(ground)を代入すれば、判定(verdict)が計算される。
人間が判断するのではない。数式が計算する。
Acc(a) = w(a) / (1 + Σ Acc(attackers))
YAMLでグラフを定義できる
Goコードなしにグラフ構造をYAMLで宣言しコードを生成する:
graph: filefunc
rules:
- name: CheckOneFileOneFunc
role: warrant
qualifier: 1.0
- name: TestFileException
role: rebuttal
qualifier: 1.0
defeats:
- from: TestFileException
to: CheckOneFileOneFunc
toulmin graph filefunc.yaml # graph_gen.go 生成
ルール関数だけGoで書けばよい。グラフ構造はYAMLが宣言する。
MIT License. github.com/park-jun-woo/toulmin