判定内核
判定规则是决策柱的地基:用语义模型自己的词汇声明「什么算有问题」, 由确定性引擎定时求值,零 LLM。怎么配规则是判定规则能力页的事; 这页讲的是内核里五个刻意的设计决定——为什么条件语言必须是闭集的、 为什么它的逻辑必须是三值的、「判不了」为什么必须响亮地失败、 为什么每条判定是一条不可编辑的证据记录,以及一次判定最多花多少条查询、花在哪。 每一个决定都对应源码里一个具体的失败模式:它们不是风格,是对策。
要解决什么问题
一个「每天定时判指标有没有问题」的系统,听起来只差一条 if。
真正难的是下面四个失败模式——它们的共同点是从外面全都看不出来。
| 失败模式 | 从外面看是什么样 | 根因 |
|---|---|---|
| 规则文件是不可信输入 | 什么都看不出来——直到出事 | 任何能写 KB 的人都能改 YAML。条件若靠 eval() 求值,那是注入面;若变量集是开放的,拼错的名字静默取到「未知」,规则永远不响——「从没响过」与「每天都没事」长得一模一样 |
| 两值逻辑在缺数据时撒谎 | 在无数据的组上误报 | 基线没取到时 not (x > y) 会翻成真。误报比漏报更难发现——它看起来像一次正常触发 |
| 「判不了」被折成「没事」 | 运行历史一片绿色 | 窗口解析失败、指标没声明、没有时间字段、连不上库——这些若静默变成「本次无触发」,一条死掉的任务与健康任务在外部无法区分 |
| 判定依据不可复算 | 历史判定无法解释 | 阈值改过之后,「当时为什么触发」失去了上下文;更隐蔽的是,整份文件共用一把字节指纹时,编辑无关规则会让所有规则的历史分桶错位 |
内核的立场由此推出:判定是关于某一时刻的事实记录,不是可随手改的配置。 下面五个设计决定,分别把上面五个根因堵死。
条件语言:安全由构造
规则条件住在 decisions.yml 里,是不可信输入,所以它永远不会遇到
eval()。取而代之的是一个刻意做小的语言:手写词法分析器
expr.py:346 _tokenize
加递归下降解析器
expr.py:369 _Parser,
文法只有七条产生式(写在模块 docstring 里),求值阶段只能从调用方给的 scope 字典里读值——
能力上限 = 能读几个变量,没有别的。
更关键的是两套闭集:标识符闭集
expr.py:70 VARIABLES
和函数闭集
expr.py:104 FUNCTIONS。
写了一个闭集外的名字——哪怕只是拼错——是解析错误,写入时就被 lint 拒掉,
而不是一个静默的「未知值」让规则永远不响。未注册的 trend() 保持保留状态:
半成品函数比没有函数更危险。
| 变量 | 含义 | 为什么需要它(或何时为 Unknown) |
|---|---|---|
current | 规则窗口内的指标值 | 判定的主语 |
baseline | 基线窗口内的指标值 | 基线取不到时是 Unknown,不是 0 |
delta / delta_pct | 差值 / 相对变化 | 基数为 0 时 delta_pct 为 Unknown(与编译器的 NULLIF(…,0) 对齐) |
contribution | 该组在 Σ|Δ| 中的带符号份额 | 只在按维分组的规则里存在——聚合规则引用它会在写入时被拒 |
row_count | 当期查询返回的行数 | 「一行都没有」本身可以是一种判据 |
dim | 被求值组的维度标签 | 同上,仅分组规则有值;也是字符串比较(dim == "北区")的载体 |
confidence | 超出噪声带的位置分数(非概率) | 只在规则声明了 significance 之后有值——未声明时恒 Unknown,所以 lint 在写入时直接拒掉引用它的条件,见统计与噪声带 |
解析结果按条件文本缓存(AST 不可变,调度器每个 tick 都会重解析),但解析错误不缓存——
一条正在被修的规则要保持可修。解析入口收一个 variables 参数,
每个域带自己的一套闭集:结果域的 validator 检查用
expr.py:92 VALIDATOR_VARIABLES,
SQL 域的 guard 断言用
guards.py:55 GUARD_VARIABLES
——同一台解析器,多套词,缓存键里带着词表,各域之间永远不会共享一份缓存。
Kleene 三值:判不了是第三种答案
条件求值的结果不是 True / False,而是 True / False / Unknown。
这是对更早的告警 DSL(jobs/alerts.py)的刻意修正:那份契约「未知 → False,绝不抛异常」
对一条裸比较是对的,但 not 一进语言就错了——缺基线时
not (x > y) 会被两值逻辑判成真,在无数据上误报。
| 表达式 | True | False | Unknown |
|---|---|---|---|
not A | False | True | Unknown |
A and B | 两边都 True | 任一边 False | 其余情况 |
A or B | 任一边 True | 两边都 False | 其余情况 |
实现里有两个值得看的细节。其一,Unknown 是单例,它的
__bool__ 会抛异常——任何一处不小心的 if value:
都是一个响亮的 bug,而不是把三值静默塌成二值。其二,跨类型比较(数字与字符串)返回
Unknown 而不是 False,理由与上面同源:False 会在 not 下翻成误报,而「类型对不上」
的诚实答案就是「判不了」。
对外的触发契约收敛为一个窄口: expr.py:520 evaluate_condition 只有在条件确定为真时才返回 True,Unknown 一律不触发——没有假阳性。 而 validator 与 guard 两档不走这个函数:它们直接取三值,因为「判不了」与「违反」 对断言型检查是两件事——判不了绝不拦。
未声明 significance 的规则,confidence 恒为 Unknown——
一条引用它的条件永远不响。这种「静默 no-op」从外面看与「检查通过」一模一样,
所以它在写入时被直接拒绝(_SIGNIFICANCE_ONLY 守卫),而不是留给运行时。
一条判定的完整管线
flowchart TB
Y["decisions.yml · 规则
(语义模型的词汇)"]:::entry --> W["窗口解析
自然语言窗口 → 当前期 + 基期"]:::entry
W --> C["语义编译 build_and_compile
权威 SQL · 零 LLM"]:::gen
C --> X["只读执行
connectors.execute · 单条超时"]:::exec
X --> J["judge 纯函数
逐组行卡 · 三值求值"]:::gate
J --> G{"触发?"}:::gate
G -->|"否"| OK["记录证据
status = ok"]:::entry
G -->|"是"| APP["附录逐层叠加(按声明)
显著性门 → 因果梯 → 分析桥"]:::entry
APP --> V["消息 + 证据 → 判定记录
不可编辑 · 相邻 diff"]:::entry
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef gen fill:#6366f114,stroke:#6366f199,stroke-width:1.5px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
linkStyle 6 stroke:#f59e0b,stroke-width:1.5px
窗口解析复用提问链路的同一入口(parse_time_range):「本月」在用户提问和规则触发时
必须是同一个跨度,两处各写一套解析,迟早不一致。基线按 baseline.kind 由同一份窗口推出
(上期 / 同比 / 字面值),推不出就硬错,绝不猜。
service.py:449 _resolve_window
编译把 subject(metric / dimension / filter——与 /v1/semantic/query 同一种形状)
编到语义模型上:先编一遍拿锚定数据集,再按指标的 agg_time_dimension 解析时间字段,带上窗口条件编第二遍。
时间条件是半开区间(< 结束日 + 1 天),与归因引擎共用同一个生成函数——闭区间会在 timestamp 列上
丢掉窗口最后一天,而触发后的分析附录必须与判定看到同一批天。
service.py:484 _compile
执行走连接器的只读通道(ConnectorRegistry.execute 拒绝任何非 SELECT),
每条 SQL 有各自的超时预算——一次卡死不该把另一次的额度也吃掉。方言(dialect)解析不出来是硬错:
「猜一个方言」编译出的错 SQL,在没人读的定时任务里是最坏结果。
service.py:1205 _run
求值是一个模块级纯函数
service.py:155 judge:
逐组生成行卡(current / baseline / delta / delta_pct / contribution / triggered / matched),
条件在每组作用域里三值求值。emit 决定规则上报哪些触发的组
(any / all / top_k),但行卡保持原样——对 all 规则,你恰恰需要看到哪些组没触发,
改写它们会掩盖这个信息。这个纯函数边界还有一个直接回报:what-if 回放(因果与模拟)
就是在假想的数字上重跑同一条内核——「模拟与判定不漂移」由此不是承诺,是构造。
判定的产物是一条带完整证据的记录:
| 证据字段 | 装着什么 | 为什么要它 |
|---|---|---|
sql_current / sql_baseline | 两期的权威 SQL | 「这个数是怎么算出来的」的最终答案 |
rows + truncated | 当期原始行样本(上限 200 行)+ 截断旗 | 没有原始行,「据以判定的数字」只是转述;截断必须可见 |
periods / times.anchor_date | 两期区间、基准日期 | 窗口是判定的自变量之一,必须留痕——不同日期跑出不同结果是对的,前提是知道跑在哪天 |
rule_digest / rule_rev | 文件字节指纹 / 单条规则内容版本 | 「当时生效的是哪版规则」——见下一节的两把尺 |
budget | 本次判定的查询账目(限额 / 实花 / 分阶段) | 成本可解释;让路有记录 |
失败必响:error 是判定历史的一等公民
与告警系统有意不同的一条纪律:判定层所有「判不了」都以错误示人, 绝不静默变成「本次无触发」。原因写在模块 docstring 里——一条静默不触发的定时任务, 与一条健康的任务从外面看没有区别。
| 判不了的情形 | 为什么不降级为「无触发」 |
|---|---|
窗口文本解析不出 / prev_period 基线却没有窗口 | 判定的自变量缺失——判的不是「没事」,是「没判」 |
| 数据源没有语义模型 / 指标未声明 | 编译不出权威 SQL;对裸表瞎猜正是这套架构要消灭的失败模式 |
| 时间字段解析不出 | 没有窗口条件的查询会把「历史全部数据」当成「本期」 |
| 方言无法确定 / 数据源不可达 / SQL 失败 / 超时 | 环境坏了要说环境坏了,而不是假装今天数据干净 |
scope: aggregate 却写了 subject 维度(绕过 lint 手改文件) | 查询会分组、聚合规则找单行——所有变量恒 Unknown,规则静默死掉;执行前的最后一道防线在此拦截 |
| 组织扩展总开关停用 | 停用中的任务必须报「已停用」——静默的绿色 run 与健康运行无法区分(开关每次评估现读,热生效) |
入口
service.py:1286 evaluate
把上述每一类异常折成一条 error 判定:运行记录里 status=error,
证据里带着错误原因与当次规则指纹——「哪一版规则开始报错」是排障的第一个问题,所以失败也记 digest。
失败永远不是通知,也永远不是一行空记录。
不可编辑的记录,相邻的 diff
判定记录 verdicts.py:46 VerdictRecord 没有编辑、没有回滚:判定是关于某一时刻的陈述(「2026-10-01,聚合口径说 X」), 编辑它就是编辑历史;存证据的全部意义在于,后来的分歧是对着记录争论,而不是对着一行可改的数据。 相邻 diff verdicts.py:180 diff_verdicts 是两条记录之上的派生视图,从不落库;旧记录缺字段时,每一处比较都退化为「说不出」, 而不是发明一次变化(比如「幅度突变」是展示阈值 10 个百分点,源码里明说它不是统计主张)。
这里有两把尺,各管一层。整份 decisions.yml 的字节指纹(digest)对文件敏感;
单条规则的内容版本
rules.py:868 rule_rev
是这条规则规范序列化的前 16 位,对其他规则的编辑无关。
渲染「规则被改过」时以 rev 为准——这是修过的真实教训:编辑 B 规则会让 A 规则的相邻两条也亮起
「digest 变了」,两把尺混用时审计线会集体误报。两个字段都缺时不报变化:
「没记录版本」不是「版本变了」。
判定记录的证据行上限 50 行(运行记录 200 行),切掉的部分正是之后 diff 的输入。
所以裁切不静默:证据里带着 truncated 旗,diff 对缺失字段说「说不出」。
一份被悄悄裁剪的证据,比一份明确标注不完整的证据更危险。
查询预算:顺序即优先级
一次判定的查询有硬上限,而且账是公开的:
budget.py:34 DecisionBudget
把预算分成 judge(2) > significance(2) > causal(4) > bridge(4),总额 12。
阶段按这个顺序执行、先到先得:判定本身永远排第一,预算不足时让路的永远是往后阶段,绝不反过来。
让路不是静默的——yield_ 记账进 evidence.budget,
用源码里的话说:「过严的护栏会被绕过,那连观测都没有了」。
还有一个逐字节的兼容纪律:只有显式声明才花钱——没有声明显著性 / 因果 / 桥的规则, 连账本都不进证据,输出与引入预算之前逐字节相同。老规则的老行为,是升级不翻车的先决条件。
怎么用
规则住在 .trove/kb/<datasource>/decisions.yml——KB 树本身就是 git 仓库,
所以每次改规则都是一次可评审的提交。一条完整的规则长这样:
version: 4
rules:
- id: loan-drop
name: 贷款余额异常下降
severity: warning
window: 本月 # 与提问链路同一个时间解析器
baseline: {kind: prev_period} # 由窗口推出上期
subject: # 与 /v1/semantic/query 同一种形状
metrics: [loan_balance]
filters: [{field: region, op: "=", value: North}]
conditions: ["delta_pct < -0.1"] # 闭集变量 + 三值逻辑
recommendation: 联系区域负责人核对口径
priority: 2
写入路径上有三重门:lint 的结构检查(单 metric、闭集取值、静默死形状)、 试编译(subject 真的编一次——lint 的硬门就是这个测试)、 以及文件版本上界守卫:比当前内核更新的 schema 版本拒绝读取, 因为管理端整份文件保存会把读不懂的字段悄悄删掉——拒绝读,好过静默读少了。
| 入口 | 做什么 |
|---|---|
GET /v1/admin/decisions | 规则列表(每条附最新判定与相邻 diff 摘要、被哪些任务引用) |
PUT /v1/admin/decisions | 整份文件保存(过 lint + 试编译) |
GET /v1/admin/decisions/{rule_id}/verdicts | 判定历史(含相邻 diff) |
POST /v1/admin/decisions/{rule_id}/simulate | what-if 回放——在假想数字上重跑同一条内核,见因果与模拟 |
GET /v1/admin/decisions/quality | 按规则版本回评的质量,见闭环与契约 |
定时触发走任务系统:Job 指向规则 id,runner 里的决策腿
runner.py:174 _run_decision
从不进入 NL 管线——判定的产出不依赖任何一次模型调用,这正是它能被质问的底气。
语义模型按任务的 datasource 现取现用:启动时绑定默认源的做法,会让一个任务静默地用错模型编译 SQL。
取舍
| 选择 | 放弃了什么 | 为什么值得 |
|---|---|---|
| 闭集表达式语言 | 表达力——写不了跨行计算、任意聚合、趋势检测(trend 刻意保留不建) |
拼错即拒、无注入面、lint 能静态判死形状。半建的能力比没有能力更危险:它会诱惑人去写一条永远不响的规则 |
| 三值逻辑 + Unknown 不触发 | 「缺数据也算数」做不到——要显式声明;条件作者要理解第三种值 | 没有假阳性。「判不了」的诚实出口不是静默,而是被 require 的显著性门升级成 error run(见统计页) |
| 判定记录不可编辑 | 写错规则后不能修正历史;纠错只能追加新的判定 | 审计线可信——对着记录争论,而不是对着可变数据 |
| 每条规则恰一个 metric | 多指标联动要拆成多条规则 | 条件词汇是单数的(current / delta),第二个指标无处绑定;拒绝好过静默只判第一个 |
| 判定独立于 NL 管线 | 不能复用会话、记忆、Skills 等问数侧能力 | verdict 不依赖模型 = 可复算。一次判定与一次提问是两种承诺,混线的代价是两边都不可信 |
| 预算硬上限(12 条/次) | 极端复合的规则会撞到额度,靠后的附录被让路 | 让路永远记账、永远先砍附录。定时任务的成本有上界,是它能被放心开着的理由 |
已知边界,也如实列在这里:判定永远读当前的语义模型——模型被改,下一次判定就换了尺子; 历史记录靠 rev 保住可解释性,但不会用旧模型重算。证据行有上限(截断有旗)。 规则条件不做跨行的事——这是上面「闭集」的代价,也是下一步能力(如果做)要先回答的问题。
优势在哪
- 可复算。输入(规则版本、窗口、语义模型)确定后,输出就是同一批行——证据里连 SQL 都在,争议可以回到数字本身。
- 可审计。不可编辑的记录 + 相邻 diff + 按规则版本分桶的质量回评;「规则被改过」有精确的一把尺(rev),而不是含混的一把(digest)。
- 不会静默死。写入时有 lint 拒掉静默形状,运行时有「失败必响」——所有「判不了」都是 error run。一条死掉的任务不会伪装成健康任务。
- 安全面为零。无
eval、闭集语法、只读执行通道、零 LLM——判定层没有可以被注入的输入面,也没有可以被模型带偏的环节。 - 成本有界且可解释。12 条查询的硬上限、顺序即优先级、让路有账——开着一队定时判定,不需要担心成本失控。
一句话总结内核的取向:判定层是能被质问的——每一次触发都能拉出它据以判断的 SQL 与行, 每一次「没触发」都有记录,每一次「判不了」都以错误示人。