toulmin — 契約を計算するルールエンジン Image: AI generated

ルールエンジンは60年間、同じ前提の上に立っていた。検証対象は「事実(fact)」であるという前提だ。

Droolsは Java オブジェクトを「fact」としてworking memoryに入れる。Regoはinputをすでに真であるデータとして扱う。JSON Schemaは文書構造が所与のものとみなす。すべて同じ仮定だ ── 入ってきたデータは事実である。

ところで、ルールエンジンの存在理由は何か? データがルールを満たすかどうかを検証することだ。検証が必要な対象を「すでに真であるもの」と呼ぶのは矛盾である。

事実ではなく主張である

検証対象はfactではなくclaim(主張)である。真かもしれないし偽かもしれない断言だ。ルールによって妥当性が判定されなければならない

JWTがすでにこの原則に従っている。subexpissを「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.Rule(isAuthenticated)
blocked := g.Counter(isIPBlocked)
exempt  := g.Except(isInternalIP)
blocked.Attacks(auth)
exempt.Attacks(blocked)

// 火曜日: "Rate limiting追加"
limited := g.Counter(isRateLimited)
limited.Attacks(auth)

// 水曜日: "プレミアムユーザーはRate limit免除"
premium := g.Except(isPremiumUser)
premium.Attacks(limited)

// 木曜日: "障害対応中はプレミアムも制限"
incident := g.Counter(isIncidentMode)
incident.Attacks(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(ctx Context, specs Specs) (bool, any)
  • ctx = リクエストごとに変わる判定材料(ユーザー、IP、コンテキスト)。Get/Setでアクセスする。
  • specs = グラフ宣言時に.With()で固定される判定基準(閾値、ロール名、設定)
  • 返却 = (判定結果, 証拠)。証拠はドメインごとの自由型。
func CheckOneFileOneFunc(ctx toulmin.Context, specs toulmin.Specs) (bool, any) {
    gf, _ := ctx.Get("file")
    f := gf.(*FileGround)
    if len(f.Funcs) > 1 {
        return true, &Evidence{Got: len(f.Funcs), Expected: 1}
    }
    return false, nil
}

Regoのように新しい言語を学ぶ必要はない。Go関数を書けばよい。(TypeScriptポーティングrulecatも同じシグネチャを使う ── npm install rulecat。)

spec — 同じ関数、異なる判定基準

specはルールの判定基準を宣言時点でビルダーに渡す。同じ関数を異なるspecで登録すれば別々のルールになる ── クロージャファクトリは不要だ:

g := toulmin.NewGraph("access")
admin  := g.Rule(isInRole).With(&RoleSpec{Role: "admin"})            // ruleID = "isInRole#admin"
editor := g.Rule(isInRole).With(&RoleSpec{Role: "editor"}).Qualifier(0.8)
g := toulmin.NewGraph("line-limit")
strict  := g.Rule(CheckLineCount).With(&LineLimit{Max: 100}).Qualifier(0.7)
relaxed := g.Rule(CheckLineCount).With(&LineLimit{Max: 200}).Qualifier(0.5)
relaxed.Attacks(strict)

spec値はSpecインターフェース(SpecName() stringValidate() error)を実装しなければならず、登録時点で検証される。specが不要なルールは.With()を省略する(nil spec)。

例外はグラフで宣言する

Graph Builder APIでルール間の関係を宣言すればエンジンが自動的に処理する。関数が識別子だ。文字列名は不要。

g := toulmin.NewGraph("filefunc")
w := g.Rule(CheckOneFileOneFunc)
d := g.Except(TestFileException)
d.Attacks(w)

ctx := toulmin.NewContext()
ctx.Set("file", file)
results, _ := g.Evaluate(ctx)

同じ関数を異なるグラフで異なる敗北関係として再利用できる:

strictGraph := toulmin.NewGraph("strict")
strictGraph.Rule(CheckOneFileOneFunc)
// 例外なし — テストファイルも許可しない

lenientGraph := toulmin.NewGraph("lenient")
w := lenientGraph.Rule(CheckOneFileOneFunc)
r1 := lenientGraph.Except(TestFileException)
r2 := lenientGraph.Except(GeneratedFileException).Qualifier(0.8)
r1.Attacks(w)
r2.Attacks(w)
// テスト + 生成ファイル両方とも例外

判定根拠を追跡する

EvalOption{Trace: true}を渡すと、verdictだけでなく、どのルールが活性化され、どのルールがどのルールを敗北させたかを追跡する。各TraceEntryはToulmin要素をそのまま含む ── Name(Claim)・Ground(ctx)・Specs(Backing)・Verdict

results, _ := g.Evaluate(ctx, toulmin.EvalOption{Trace: true})
// results[0].Verdict: +0.6
// results[0].Trace: [
//   {Name: "CheckOneFileOneFunc", Role: "rule",   Activated: true, Qualifier: 1.0},
//   {Name: "TestFileException",   Role: "except", Activated: true, Qualifier: 1.0},
// ]

ルールが数十個あるとき「なぜこのverdictが出たのか」を人間が読める。Duration: trueを渡すとルールごとの実行時間も計測する。監査ログ・デバッグがエンジンに組み込まれているわけだ ── 別途のロギングは不要だ。

判定は一つの数式で計算される

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と何が違うのか

Regotoulmin
ルール作成Rego DSL学習が必要Go関数
例外処理default/elseパターン手動defeatsグラフ宣言的
判定allow/deny二値[-1, +1]連続値
ルールの正当性# METADATA(エンジンが無視)spec(構造の一部)
ルール強度なし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割合
Strict1568%F1, F2, F3, F4, A1-A3, A6-A16
Defeasible418%Q1, Q2, Q3, C4
Defeater314%F5, F6, テストファイル例外

大部分がstrictだ ── コード構造コンベンションは本質的に例外を最小化する。

定量的結果

プロジェクトファイル数(前→後)平均LOC/ファイル(前→後)SRP違反解消depth違反解消
filefunc──(最初から準拠)25.100
yongol87→1,260244→25.466→0148→0
whyso12→99147.8→24.412→023→0

yongolは87個のファイルが1,260個に増えた。ファイル数は爆発したが平均LOCが244から25.4に下がった。SRP違反66件、depth違反148件がすべて0になった。

理論的基盤

独創的な理論はない。すべて既存研究だ:

要素原著
6要素構造Toulmin (1958)
strict/defeasible/defeaterNute (1994)
h-CategoriserAmgoud & 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))

MIT License. github.com/park-jun-woo/toulmin

変更履歴

  • 2026-06-18: API更新 ── ビルダーパターングラフ(Rule/Counter/Except.With().Attacks())、ルールシグネチャfunc(ctx Context, specs Specs)Evaluate(ctx, EvalOption{Trace})、backing→spec、TypeScriptポーティング(rulecat)を反映
  • 2026-03-22: 初版