首页/设计深潜/判定内核

判定内核

判定规则是决策柱的地基:用语义模型自己的词汇声明「什么算有问题」, 由确定性引擎定时求值,零 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) 会被两值逻辑判成真,在无数据上误报。

表达式TrueFalseUnknown
not AFalseTrueUnknown
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 两档不走这个函数:它们直接取三值,因为「判不了」与「违反」 对断言型检查是两件事——判不了绝不拦。

为什么 lint 要拒掉「引用 confidence 但没声明 significance」

未声明 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
图 1 · 一次判定的完整路径:判定本身永远是前五步;附录(显著性 / 因果 / 分析桥)只在触发后、且被声明时才叠加,失败只记账、不改判定。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

窗口解析复用提问链路的同一入口(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}/simulatewhat-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 与行, 每一次「没触发」都有记录,每一次「判不了」都以错误示人。

继续读

想了解去哪
噪声带、稳健统计、显著性门如何判「超出历史波动」统计与噪声带
因果升级梯与 what-if 回放(同一内核的重放)因果与模拟
质量回评、效果验收、输出契约闭环与契约
触发后分析桥的贡献分解与残差分解数学与驱动器树
规则写法全量(字段表、管理端操作、扫描、what-if)判定规则
规则编译所依赖的语义模型语义编译边界
本页源码锚点的基准提交:anchor-base: 211ae86 GitHub 仓库 文档首页