规则引擎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是用Go规则引擎实现Toulmin论证模型的项目。

需求在进化

来看看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)

同一函数可以在不同图中以不同的defeat关系被复用:

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有什么不同

Regotoulmin
规则编写需要学习Rego DSLGo函数
例外处理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数量比例示例
Strict1568%F1, F2, F3, F4, A1-A3, A6-A16
Defeasible418%Q1, Q2, Q3, C4
Defeater314%F5, F6, 测试文件例外

大部分是strict的——代码结构约定本质上就是要最小化例外。

定量结果

项目文件数(前->后)平均LOC/文件(前->后)SRP违规消除depth违规消除
filefunc–(从一开始就合规)25.100
fullend87->1,260244->25.466->0148->0
whyso12->99147.8->24.412->023->0

fullend从87个文件增加到了1,260个。文件数量爆增,但平均LOC从244降到了25.4。SRP违规66处、depth违规148处全部归零。

理论基础

没有原创理论。全部来自已有研究:

要素原著
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))

用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