规则引擎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是用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有什么不同
| 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处全部归零。
理论基础
没有原创理论。全部来自已有研究:
| 要素 | 原著 |
|---|---|
| 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