闭环与契约
判定触发了、行动也外送了——然后呢? 审计线如果止于「已发出」,产品就只是告警器。这页讲回来的路: 送出 N 天之后把那条度量测一次(效果验收),按规则版本分桶回评 「判得准不准」,出一份字节稳定的质量报告;以及主动扫描怎样把 「没人报的异常」变回判定规则草稿——让环闭回起点。全程复用同一条 噪声带与同一个 2×2—— 「判定说超带」与「验收说超带」,是同一句话。
闭环缺的是最后一段
没有验收的告警系统,连自己准不准都不知道:触发率 100% 的规则可能在报噪音, 一条从未触发过的规则可能口径早就烂了。回评需要三样以前没有的东西—— 一次诚实的测量(不是「跟以前不一样」,而是「超出噪声带了吗」)、 一个时间的时钟(行动当天就测,测到的是行动前的数)、和一个 版本账本(规则被改过之后,旧判定不能算到新口径头上)。 这页的四个模块各自解决一样,全部纯派生、零 LLM。
flowchart LR
V["判定 verdict
(含触发日与规则版本)"]:::gate --> P["提案 → 审批 → 外送"]:::act
P --> W["N 天后的小时级 sweep
measure_due"]:::entry
W --> M["verifier(包外执行面)
ITS + 可选 DiD 净效应"]:::exec
M --> O["outcome 行
超带 / 无变化 / 判不了"]:::entry
O --> S["回评分桶
(rule_id, rule_rev)"]:::entry
S --> R["质量报告 / 全局面"]:::entry
D["主动扫描
扫描发现 → 规则草稿"]:::entry --> V
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
classDef act fill:#8b5cf614,stroke:#8b5cf699,stroke-width:1.5px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
linkStyle 6 stroke:#8b5cf6,stroke-width:1.5px
验收:三层结局,没有第四种
效果测量的核心是一个纯函数 effect.py:131 measure_effect: 把行动前历史块与行动后水平放在一起,给出 ITS(中断时间序列)核心量—— 中心量一律取 median(与噪声带 center 同口径:「行动后水平」恰恰是要抗单点的那一侧), 跨零/超带判定用与判定侧同一个稳健 z 阈值 effect.py:46 EFFECT_K。
结局是三层的,没有第四种:outside_band = True(行动后超出行动前噪声带)/
False(无可辨识变化)/ None(判不了,原因单列 degraded)。
False 不是失败——它是最诚实也最有价值的一档:「这次行动没动到数」是一个结论;
而「可能动了、但 n 不够/带退化」绝不冒充 False,与判定侧「算不出 ≠ 在带内」是同一条纪律。
还有一条安静但决定成败的选择:带只由行动前的块构成
effect.py:76 interrupted_design。
把行动后的点混进分布会自我稀释——那正是「行动有效」的假阳性来源,与扫描侧的历史块纪律同款。
规则声明了 causal.control 且对照帧取得到时,净效应按同一个 2×2 附加
effect.py:161 attribute_effect:
method 升为 "its+did"——方法名描述的是最强的那层主张,
delta/pct 仍来自 ITS,两者不是同一件事,方法名把她们分开;
对照取不到 → causal: "unavailable" + 原因,ITS 结论原样保留(对照不可用是验收侧最常见的形态,不是错误)。
verifier 契约:能力从外面给
测量要读业务库,但行动包(行动安全架构)的姿态是结构性的——
构造签名不收连接器、整包不 import 数据源。所以执行面留在包外:
ActionService 以可注入 callable 的形式接收 verifier
outcome.py:98 make_verifier,
与 ActionDispatcher 的 transport 是同款姿态——能力从外面给,包本身够不到。
| 契约 | 含义 | 为什么这样定 |
|---|---|---|
async (proposal) -> dict | None |
None = 「还不能测」:规则的测量期还没滚过行动日,跳过、下次再试、不写行 |
与「测了」严格区分——安静跳过与安静失败长得一样,是最危险的混淆 |
dict 带 error 键 |
一次响亮的失败:规则被删/被停用、窗口不可解析、编译 MISS、查询失败、组不可解析……落成 error outcome,一行,不重试 | 闭环断了必须看得见,不能像「还没到期」一样安静 |
| 异常 | 未预期故障,同样被接成 error outcome | 一次测量失败 = 一行 error,不是崩 |
| 窗口按规则的时钟重算 | 与触发它的判定用同一套 period 语义(grain 对齐 / same_phase / 过滤口径),复用 DecisionService 的同一批解析 helper |
测量与判定读到不同口径的数字,正是闭环验收最不能出的错——同一批 helper 是结构性保证 |
| 期必须滚过行动日 | window_end > 行动日 才测;否则返回 None |
行动当天就在期内的测量,测的是「行动前的数」,不是效果 |
| 只测点名的那一组 | 提案创建时把消息点名的那一组钉进 evidence_refs["group"],验收读它不重算;per_dimension 规则取不到组 → group_unresolved error |
重算会读到现在的证据,而验收要的是当时点的那一组;拿别的总体冒充被点名的组,是这一层最不能出的错 |
| 一次测量,一个结局 | 不是重试循环;verifier 恢复后不回填 | 回填时窗口已经滚走,填出来的会是另一份测量(测的是另一个期间)——拿它冒充当初的验收结果,是这一层最不能出的错 |
测量记录里还带着两个给审计看的数:exposure_days
outcome.py:77 _exposure_days
(测量期对行动的实际暴露天数——一个 10 月 5 日发出的行动,在「上个月」这种窗口下最早 10 月 31 日之后才被覆盖:
31 天里 26 天在行动之后,读者看得见这个数,就看得见测量滞后有多少是「期间还没滚完」);
以及 rule_rev 与 rule_rev_judged 两个版本——
前者是测量所依据的规则内容,后者是判定当时那一版,两者不同说明规则在丈量之前被改过。
驱动:四道门,各有各的语义
到期测量由 measure_due
service.py:605 measure_due
驱动。默认档全关(outcome_after_days <= 0 立即返回 0、不查库——未配置的部署逐字节不变);
刻度是天(测量是 N 天语义),所以挂在小时级的 sweep 腿上,不是 30 秒的 job tick。四道门:
| 门 | 行为 | 语义 |
|---|---|---|
未到 outcome_after_days | 跳过,不算已测 | 到期后那次 sweep 会再看到它——「还没到时候」≠「测了」 |
verifier 返回 None | 跳过、不写行 | 测量期没滚过行动日(verifier 的语义,不是失败) |
| verifier 缺席或抛异常 | 落一行 error outcome,不重试 | 闭环断了必须看得见——不能像「还没到期」一样安静 |
enabled | 不是门 | 测量是读(只读查询 + 自己的账本),不是外送——关掉外送之后,已经出去的那批更需要验收 |
结果落在 outcomes 表
store.py:151 _OUTCOMES_SQL
(store 迁移 v2 加的;outside_band 列用 0/1/NULL 三态存储——
0 绝不是「没测到」,读侧对此有严格落位约定)。
回评:按规则版本分桶
质量回评是一个纯派生报告
score.py:109 score_history,
分桶键 = (rule_id, rule_rev)
score.py:40 bucket_key。
这是 digest 与 rev 之分 的延续:digest 是整份 decisions.yml 的字节 sha256,
编辑 B 规则会让 A 规则的历史整段错位;rev 是单条规则的内容版本——改 A 才开 A 的新桶。
更老的 verdict 没有 rev → 落 rev_unknown 桶(不猜、不丢:没有版本信息的判定,仍然是一段可以被看的历史)。
三个口径上的坚持:
- 纯派生,绝不写回。质量是读出来的视图,不是一张会漂移的表—— 判定记录不可变,把质量算好存回去的设计会让同一段历史在不同时刻给出不同的数。
- 两类健康分开计。判定健康(ok/alert/error 计数)与行动效果(effective/no_effect/unverifiable/errors) 分开——一条全是 error 的规则(判定「判不了」)与一条判了但从没动到数的规则, 是两种完全不同的问题,把比率混在一起会把两者都盖住。
- 比率只在分母够时给。
effective_rate的分母是判得出来的测量 (有效 + 无变化),unverifiable不进分母;decided < MIN_EFFECTS(3)→ 比率None+insufficient原因(few_verdicts/few_effects/no_effects)。 3 次行动里的 2 次有效不是「67% 有效率」,它是「样本不足,别读比率」;总览 score.py:178 rollup 走同一道门——总览行不该比桶更敢说话。
报告:同一份数字,逐字节可复算
呈现层是三个纯函数
quality_report.py:48 build_report:
build_report(单源)/ fleet_report(全局面)/ render_markdown(markdown 呈现)。
口径的唯一权威在 score.py——呈现层不重写任何指标,只做包装与措辞;
两个出口各算一遍的后果不是「重复」,是漂移:同一段历史在两个地方给出两个有效率,
比没有报告更糟(与离线回归门 eval/gate.py 的教训同款)。
generated_at 由调用方给、缺省空串,同一输入 → 逐字节同一输出(dict 键序固定)。
两处措辞纪律:跨源汇总只给总量——同名规则在两个数据源是两条规则,
把两个源的 revenue_drop 拼成一条「历史」会造出一段从未存在过的历史(per-rule 比率只在各自桶里);
比率算不出时渲染成「—」而不是 0%——「样本不足」不许被折成任何百分比。
admin 面上:GET /v1/admin/decisions/quality
decisions.py:552 decision_quality
给单源分桶明细,GET /v1/admin/quality/overview
quality.py:435 admin_quality_overview
给逐源 fleet 报告,提案级效果在 GET /v1/admin/actions/proposals/{id}/outcomes
actions.py:198 get_action_outcomes。
主动扫描:把发现变回规则
环的另一半起点是没人报的异常。主动扫描按 ScanSpec(metric × dim 单元列表)
定期跑一遍:每个单元至多一条 SQL
scanner.py:117 scan_unit
——一条查询同时取回「历史块 + 当期窗口」,标签切分决定谁进带、谁是当期;
判据与判定侧完全同款(history-only 的带、稳健 z、k 默认 3.5)。
发现分两类 FINDING_KINDS = ("anomaly", "unverifiable"):
「超带」是发现,「判不了」也是发现——算不出的单元恰好一行 unverifiable,从不静默少一行;
截断(top_k)只截 anomaly
scanner.py:249 cap_findings——
截掉的是「还不值得人看」的,不是「说不清」的。
一条容易写错的边界在这里被写死:缺失 ≠ 0——当期无行可能是数据未到/未落,
不是「掉到零」的证据(no_current_block 记账,不产发现)。
发现的出口是判定规则草稿(人确认后成为规则):扫描→草稿→规则→判定→行动→验收,
环从这里闭回起点。规格在写时校验
scanner.py:89 spec_issues:
度量/维度/时间字段/锚定数据集逐条解析——没有时间字段的度量从来给不出块,
注定每次 tick 都只产 unverifiable 的规格不该被写进任务(错误在创建时说得清,在运行记录里说不清)。
扫描的 hypotheses 档(默认关)多了第二层:让 LLM 就发现起草候选假设
(「假设华东下滑 ≥10% 由车贷逾期引起」这类可检验陈述),但裁决是纯函数
hypotheses.py:266 _adjudicate:
取当期与基期两个数,按声明方向与最小幅度判 supported / refuted /
unverifiable(判不了说判不了——基期为 0 且声明了最小幅度时,方向还能看但幅度不可比,
不硬判)/ no_data。起草是 LLM 的活(parse_candidates 解析 + 引用校验门槛),
裁决是确定性的活——每条假设恰好一行 outcome,输入序保持不变。
怎么用
# conf/agent.yml(或管理端设置)
action:
outcome_after_days: 14 # 外送满 14 天才测;0 = 关闭(默认)
scan:
hypotheses: false # 假设起草档(默认关)
max_queries: 12 # 扫描每轮的查询预算
k: 3.5 # 与判定同一条带
扫描作为定时任务运行(job 带 scan_spec 段;任务类型优先级
decision_rule > scan_spec > 自然语言管线),报告落在 run 证据里
(ScanReport:发现 / 判不了 / 截断数 / 草稿 / 预算 / 证据 SQL 全文)。
验收不需要新配置以外的操作——到期自动测、自动回评分桶;出错自动进 admin 待办。
管理端操作与判定历史界面见判定规则与
行动提案。
取舍
| 选择 | 放弃了什么 | 为什么值得 |
|---|---|---|
| 一次测量,一个结局,不回填 | verifier 修好后把历史缺口补上 | 回填时窗口已滚走,填出来的是另一个期间的测量——冒充当初的验收结果是这一层最不能出的错 |
| error 行不重试 | 自动恢复的测量 | 测量不是重试循环;一行 error 让断链看得见,比悄悄重试到「成功」更诚实 |
按 rule_rev 分桶(非 digest) |
「一个规则一个桶」的简单分桶 | 编辑 B 规则不该让 A 的判定史错位;改过口径的规则,旧判定不能算到新账上 |
MIN_EFFECTS = 3 比率门 |
样本很少时也给一个「有效率」 | 1/1 不是 100%;伪精度比没有数字更糟。不足时给原因(few_effects…)而不是给 0 |
| 报告纯派生、不写回 | 预计算好的质量表和它的查询速度 | 同一段历史在任何时刻重算都逐字节相同——可复算是这层的意义本身 |
| enabled 不是测量门 | 「关了外送就一切停」的直觉 | 测量是读不是写;关掉外送之后,已经出去的那批更需要验收 |
| 截断只截 anomaly | 发现列表的整齐(全部 top_k) | 「判不了」的数量由扫描单元数(metric × dim)封顶,保留它才是「判不了要看得见」的兑现 |
优势在哪
- 同一把尺子量两次。验收的超带判定与判定/扫描共用同一条带与同一个稳健 z 阈值;净效应用同一个 2×2——「判定说超带」与「验收说超带」不可能对不上。
- 三层结局全诚实。超带 / 无变化 / 判不了,三态严格区分;「没动到数」是有价值的结论,「判不了」有原因清单,绝不互相冒充。
- 闭环断裂可见。verifier 缺席、异常、组解析不了——全部落 error outcome 并进待办;回填不存在,测量滞后有
exposure_days记账。 - 版本账本干净。回评按
rule_rev分桶、测量记rule_rev_judged——规则改过之后,新旧口径的数字不会混进同一个比率。 - 可复算、零 LLM。验收、评分、报告全是纯函数;报告的每个字节都能从存储的判定与测量记录重算出来。
- 扫描与判定同源。主动发现的判据就是判定的判据——发现可以直接起草成规则,环闭回起点,不需要两套口径之间的翻译。