应用部分 5. 规范的变异测试
状态:前沿。 针对规范的变异测试(mutation testing)以及免疫力度量向量(immunity score)是一种尚未标准化的实践。"一个变异体对应一个预期失败"属于建议性原则。具体的算子集和阈值需要根据项目进行调优。
在学习路径中,只需运行 examples/stress-mutator/ 并观察一个变异体产生一个预期失败即可。算子选择、阈值设定与 CI 门禁的完整配置属于 production 级别的工作。
让我们先介绍基本概念。变异测试是一种技术,在该技术中参考制品会被可控地"破坏",而测试流程必须捕获这种缺陷。免疫力度量是验证器稳定性的向量度量,由三个分量组成:
strict_reject_rate— 在预期步骤被严格拒绝的用例比例;depth_of_diagnostics— 失败前有效诊断的深度;recovery_time— 恢复到稳定判定结果所需的时间。
形象的说法"验证器疫苗接种"指的就是对规范的常规变异测试。验证器接收可控破坏后的输入,并必须在预期步骤拒绝它们。
与相邻机制的边界如下。在第 2 章中,你创建一个手工缺陷以学习如何识别症状。在本章中,你创建一系列机器生成的变异体以衡量验证器的稳定性。在第 4 章中,验证器寻找针对规则的最小反例,而不是遍历变异算子目录。在第 8 章中,这些检查的结果可作为判定的证据,但文件仲裁本身并不取代变异体生成器。
本章依赖于第一卷第 9 部分中的事实约束纪律。没有它,变异就没有意义。变异体检查的恰好是在预期的 Given/When/Then 步骤上失败这一事实。在学习示例 AgentClinic 中已经出现过该纪律最简单的例子:来自第 12 部分的空评论文本必须被拒绝。这里同样的逻辑被泛化为一组变异算子,绑定到第 20 部分。SDD 反模式中经典错误目录。
阅读前
- 来自第一卷的基础:第 9 部分介绍验证事实,第 20 部分介绍过程错误类别。
- 本地学习案例:
appointment_latency_spike(最小 incident-payload,可运行示例的base/base_spec.json由此构建)。 capstone/的痕迹:seed、算子列表、三项免疫力度量以及validation.md中针对high_memory_usage的判定(作为字符串)。- 第一遍阅读的主要术语:变异测试(本章入口)和免疫力度量(出口 — 向量的三个分量)。其余的——变异算子、变异工厂、"验证器疫苗接种"——属于参考内容,仅在配置 CI 门禁时展开。
- 可以延后的内容:算子选择、阈值校准和变异 CI 门禁。
目标
读完本章后,读者将为事件自动管理项目构建一个退化规范生成器,并配置一个验证器流程,使其完成三件事:以精确的诊断拒绝荒谬用例,将证据链保存到 SDD 中,在合并前计算免疫力度量。验证器不再是语法的守门员,而成为解剖式诊断工具:显示失败事实、字段、Given/When/Then 步骤、JSON Schema 规则、失败路径以及回归风险。这与"规范先行"(spec-first)的做法一致——契约先于规划和代码实现(GitHub Spec Kit)。
最小学习场景
学习案例
生产事件 appointment_latency_spike(源自book/part-11-second-feature-phase.md中的学习特性 /agents):SLA 为 10 分钟,从 appointments_oncall 升级到 sre_lead。变异 Nullify 将 severity 清空。预期——验证器在 When:evaluate_sla_window 之前停下,错误码为 EMPTY_REQUIRED_FIELD,发生在 SLA 计算和负责人选择之前。
准备
book2/examples/stress-mutator/base/base_spec.json— 正确的源文件。book2/examples/stress-mutator/expected/expected_failures.json— 在by_operator键下的预期(diagnostic_code, halt_before),以及thresholds中的免疫力阈值。book2/examples/stress-mutator/scripts/mutate_specs.py、fake_validator.py、immunity_score.py。book2/examples/stress-mutator/manifest.example.json— 确定性基准。
步骤
cd book2/examples/stress-mutator。预期:你位于示例目录中,无额外依赖。python3 scripts/mutate_specs.py --base base/base_spec.json --seed 20260517 --operators Nullify,FutureTime,EscalationCycle,PriorityContradiction --out out/mutations。*预期:已生成out/mutations/manifest.json以及每个变异体对应的 JSON 文件。*- 确定性检查——重复步骤 2。*预期:
mutation_id列表和顺序与上一次运行一致。*
不好的做法: 只运行一次而未重复——无法区分确定性生成器与随机噪声。 好的做法: 连续运行两次,mutation_id 顺序一致,回归基线可重现。
- 通过
diff比较out/mutations/manifest.json与manifest.example.json。预期:差异为 0 行。 python3 scripts/fake_validator.py --mutations out/mutations --out out/validator_results.json。*预期:结果中每个mutation_id都有diagnostic_code+halt_before对。*python3 scripts/immunity_score.py --validator-results out/validator_results.json --expected expected/expected_failures.json。*预期:strict_reject_rate >= 0.98,depth_of_diagnostics >= 3,recovery_time_p95_ms <= 1200。*- 对于学习最小流程,到此为止:可运行示例已证明变异体的确定性、预期失败以及免疫力计算。
如果已安装 Qwen Code 并希望获得额外解释,请执行单独的可选步骤:
qwen -p "阅读 @out/validator_results.json 和 @expected/expected_failures.json。哪些变异体在非预期步骤被拒绝?请勿修改文件。" --approval-mode plan
此请求不能替代可运行检查。其结果可作为评审的注释,但不能作为唯一的就绪事实。
完整 production 路径会增加一个独立的 CI 门禁。在你自己的项目中,通常是 python3 scripts/ci_gate.py --strict-reject-min 0.98 --diag-depth-min 3 --recover-ms-p95 1200 --fail-on-regression ——三个阈值,任何一个违反都会阻止合并。教材中没有专门针对 stress-mutator 的可运行对应物;第 10 部分中介绍的 examples/goodhart-validator/scripts/ci_gate.py 在思想上最为接近。
控制事实
步骤 6 的三项度量同时满足阈值。manifest.json 与 manifest.example.json 逐字节相同。如果执行了可选的 Qwen 请求,其输出不应与可运行事实相矛盾。没有确定性、预期失败以及绿色的免疫力度量,学习流水线不视为通过。
如何进入 capstone/
将 smoke 运行的总结(seed、算子、三项免疫力度量和判定)转入 capstone/validation.md 或简短的 capstone/README.md。不要转入 out/mutations 目录:它应当保持为可重现的本地痕迹,而非可评审的制品。
最小片段:
stress_run:
seed: 20260517
operators: [Nullify, FutureTime, EscalationCycle, PriorityContradiction]
strict_reject_rate: "1.0 >= 0.98"
depth_of_diagnostics: "4.0 >= 3"
recovery_time_p95_ms: "850 <= 1200"
verdict: PASS
可评审痕迹
out/ 目录是本地运行的结果,在 book2/examples/.gitignore 中被忽略。不要将其作为学习制品提交,也不要为了打勾而提交。对于第一遍阅读,capstone/validation.md 中一行内容(seed、算子、三项度量与 verdict)就足够了。
在你自己的 production 仓库中,如果由 CI 生成并参与评审,可以保留简短的报告 outputs/immunity.last-run.json。在学习路径中,事实来源仍然是可重现的命令和上述最小的 capstone 片段。
核心思想
将事件流程的退化场景分为四类。空字段不仅指 null:还包括空字符串、所有者空数组、缺失的 severity、service_id 或 runbook_ref——任何使安全操作无法选择的空缺。时间异常形式上看似正确:ISO 时间戳存在,但 response_timestamp 早于 event_received_at 或晚于约定的 now。可逆的升级循环和递归依赖比一般的遗漏更危险——它们可能使执行流程陷入对所有者、优先级或下一步动作的无限重定义。
再引入一个概念。变异工厂不是随机的噪声生成器,而是建立在正确 base_spec.json 之上的确定性变异器。基础规范被解析成具有显式 Given/When/Then 节点、SLA 矩阵、升级规则和 JSON Schema 片段的抽象语法树(AST)。然后对其应用算子:
Nullify— 清空字段;FutureTime— 将时间戳推向未来;EscalationCycle— 在升级图中添加反向边;
PriorityContradiction— 引入互相矛盾的优先级规则。
在未来的扩展中,将加入 RecursiveDependency 以处理计算字段之间的间接递归。
"一个变异体对应一个预期失败"的原则是变异工厂的主要规则。展示一下对比。
不好的做法:
> 一个变异体同时清空 service_id、反转升级图、颠倒优先级;未指定 expected_failure。
问题:失败时无法定位原因。验证器可能在三个缺陷中的任意一个上停止,回归关联到了组合制品。
好的做法:
> 一个 Nullify 变异器只清空 severity;expected_failure.code = EMPTY_REQUIRED_FIELD,halt_before = When:evaluate_sla_window。
每次运行都使用固定的种子(seed)。相同的输入生成相同顺序的 mutation_id 列表。这一点对验证器与实现者的对决至关重要:争议用例可以重现,交给双方角色,并检查究竟是哪一方违反了契约。
> [runnable] — 该接口的最小实现在 examples/stress-mutator/README.md。
cd book2/examples/stress-mutator
python3 scripts/mutate_specs.py \
--base base/base_spec.json \
--seed 20260517 \
--operators Nullify,FutureTime,EscalationCycle,PriorityContradiction \
--out out/mutations
python3 scripts/fake_validator.py \
--mutations out/mutations \
--out out/validator_results.json
#### CONTROL: 使用相同 seed 重复运行应输出相同的 mutation_id 列表和顺序
在深度 2–3 时组合爆炸已经出现。给生成器设定筛选策略,而不是完全穷举:每个类别至少一个变异体(必填字段、时间窗口、升级图、递归依赖、优先级冲突)。将算子优先级与事件历史关联:如果事后剖析更多显示出错误的时间窗口,就在队列中给 FutureTime 和 NegativeLag 更大的权重。有向模糊测试针对历史上脆弱的契约位置,而不是把令牌预算浪费在均匀的混乱中。
flowchart TD A[文件 base_spec.json] --> B[AST 规范化器] B --> C[变异工厂] C --> C1[Nullify] C --> C2[FutureTime] C --> C3[EscalationCycle] C --> C4[PriorityContradiction] C1 --> D[验证器/实现者对决 绑定 Given/When/Then 步骤] C2 --> D C3 --> D C4 --> D D --> E[诊断与堆栈路径] E --> F[mutation_id 和 validation.md] F --> G[CI 门禁]
将每个变异体绑定到具体的 Given/When/Then 步骤以及具体的 JSON Schema 规则。否则诊断对修复来说过于笼统。绑定关系必须显式:变异 Nullify(service_id) 对应 Given:incident_received 和规则 required.service_id,而变异 FutureTime(response_timestamp) 对应 When:evaluate_sla_window 和约束 format + maximum(now)。
如果变异体破坏了 Then:notify_primary_owner,报告应显示问题的本质。原因不在于通知这个动作本身。原因在于在路径被破坏后无法计算出合法的所有者。这样的追踪缩短了人工调试时间:工程师看到卡点位置,而不仅仅是最终的 VALIDATION_FAILED。
{
"mutation_id": "m_20260517_0009",
"operator": "EscalationCycle",
"target_step": "When:route_escalation",
"json_schema_rule": "$defs.escalation_graph.no_cycles",
"failed_step": "Verifier::GraphCheck::Escalation",
"stack_route": [
"schema.normalize",
"step.when.prepare",
"graph.build",
"graph.detect_cycle",
"halt"
]
}
循环检测需要单独的图遍历。原因在于 JSON Schema 能很好地检查数据形式,但并不总能表达路径的拓扑行为。对于 EscalationCycle,验证器构建所有者或队列的有向图,并运行具有 white/gray/black 状态的深度优先搜索(DFS)。发现 gray 节点则返回最小环,例如 primary_oncall → sre_lead → primary_oncall。
对优先级的可逆转换使用类似控制。如果 P1 按某条规则降级为 P2,然后另一条规则将 P2 升级回 P1 而没有裁决平局的规则(tie_breaker),验证器必须在执行阶段前停止。诊断码必须区分 CYCLE_ESCALATION 和 PRIORITY_REVERSAL。前者通过路径图修复。后者通过冲突解决策略修复。
将时间异常检查放在路由之前。不正确的时间会扭曲 SLA、严重度和反应通道选择。给验证器至少三个锚点 ——event_detected_at、event_received_at、来自受控时间源的约定 now ——以及 max_reaction_lag 策略。相应地,失败会获得以下三个码之一:INVALID_TIME_ANCHOR(如果 response_timestamp 在未来——问题在输入负载中)、NEGATIVE_RESPONSE_LAG(反应延迟为负——问题在时间归一化中)或 STALE_INCIDENT_WINDOW(事件超出允许的窗口——问题在 SLA 规则中)。不同的码对 SDD 日志很重要:它们显示契约具体在哪里被削弱。
递归依赖与循环的不同之处在于它们看起来可能不像图中的短环。典型链:owner 从 priority 计算,priority 依赖 blast_radius,blast_radius 查询 owner_group,而 owner_group 再次需要已经计算出的 owner。
对于这种情况,设定展开限制,例如 max_resolution_depth = 8。保存依赖解析尝试的轨迹。如果超过限制,验证器返回 RECURSION_LIMIT 以及字段链,而不是将问题伪装为超时。这可以保护 LLM 执行者免于无限期地细化条件,并使级联失败可观察。
现在讨论免疫力度量(向量的分量在本章开头)。应将其作为向量引入,而不是单一的总体评分。如果 strict_reject_rate 上升而 depth_of_diagnostics 降到 1,则流程变得严格但更盲目。如果 recovery_time_p95_ms 超出限制,即使正确的验证器也会拖慢 CI 并诱发绕过做法。
在 CI 中基于免疫力阈值和与上一轮的回归比较来构建阻塞。对于学习流程,从以下值开始:
strict_reject_rate >= 0.98,depth_of_diagnostics >= 3,recovery_time_p95_ms <= 1200。
然后根据实际负载和变异体数量校准这些值。
如果新变更满足以下三个条件之一,则合并被阻塞:
- 放过了旧的
mutation_id, - 降低了诊断深度,
- 超过恢复时间限制。
这样的门禁不仅保护 JSON Schema,还保护整个验证器流程:规范化器、图遍历检查、Given/When/Then 规则和报告格式。
> [runnable] — 下面的命令对应 book2/examples/stress-mutator。
cd book2/examples/stress-mutator
python3 scripts/immunity_score.py \
--validator-results out/validator_results.json \
--expected expected/expected_failures.json
在你自己的项目中,这个门禁通常表现为 python3 scripts/ci_gate.py --strict-reject-min 0.98 --diag-depth-min 3 --recover-ms-p95 1200 --fail-on-regression。教材中没有专门为 stress-mutator 准备的就绪脚本;"一个阈值未通过 = 阻塞"的思想保留在形式上接近的 examples/goodhart-validator/scripts/ci_gate.py(第 10 部分)。
将运行结果作为证据链而非一次性测试日志记录到 SDD 中:mutation_id、规范差异(diff)、原始和变异片段、拒绝日志、诊断码、stack_route、JSON Schema 规则引用以及 validation.md 中的最终记录。对于评审,存储 expected_failure 和 actual_failure 尤其有用:如果它们不一致,验证器可能偶然拒绝了用例或拒绝得太晚。这样的结构将变异目录转化为先例目录,其中每条新规则都与具体的盲点和可验证的依据相关联。
完整路径:阈值校准
strict_reject_rate、depth_of_diagnostics、recovery_time_p95_ms 以及每个类别的变异体数量的"低/默认/高"表格、阈值平移练习和重新审视信号都放在附录 D,D.1 节。在第一遍阅读中不需要此节。
示例与应用
示例:正确的规范描述了事件 appointment_latency_spike。SLA 要求 10 分钟内做出反应。升级路径从 appointments_oncall 到 sre_lead。
变异器创建 m_20260517_nullify_855e4297f7。其中 severity 字段被替换为空字符串。变异体绑定到 Given:incident_received 和规则 severity.minLength。预期失败是 EMPTY_REQUIRED_FIELD。流水线必须在 When:evaluate_sla_window 之前停下,发生在 SLA 计算和负责人选择之前。
如果验证器反而走到 Then:notify_owner,则意味着空字段 severity 已经渗透得太深,可能产生对未分类事件的虚假通知。
{
"mutation_id": "m_20260517_nullify_855e4297f7",
"base_case": "appointment_latency_spike",
"operator": "Nullify",
"target_step": "Given:incident_received",
"json_schema_rule": "$.properties.severity.minLength",
"diff_spec": {
"before": { "severity": "P1" },
"after": { "severity": "" }
},
"expected_failure": {
"code": "EMPTY_REQUIRED_FIELD",
"halt_before": "When:evaluate_sla_window"
}
}
第二个示例检查事件 cdn_error_budget_burn 的升级图。负责人 edge_oncall 将 P1 转交给 traffic_sre。变异器添加反向边 traffic_sre → edge_oncall。
验证器应当做什么。返回 CYCLE_ESCALATION,显示最小环,并将失败绑定到 When:route_escalation。同时,实现者不应建议诸如"从列表中选取第一个负责人"之类的绕过。修复 JSON Schema 或添加额外的图规则后,重新运行同一 mutation_id 以证明补丁确实封闭了所发现的缺陷。
validation.md 中的记录应包括差异(diff)、判定、恢复时间以及 CI 中运行的引用。否则在下次修改路径时将无法验证决策。
小结
压力规范生成器将验证器检查转变为可控的工程循环:它分类退化场景,创建可重现的变异,将每次失败与 Given/When/Then 步骤和 JSON Schema 规则关联,通过向量的三个分量测量免疫力,并通过 mutation_id、规范差异、拒绝日志和 validation.md 将证据保存到 SDD。这种循环将荒谬用例转化为针对未来有害需求和隐藏级联失败的回归集。下一章将进入影子规范的竞标。
制品与就绪标准
| 制品 | 就绪条件 |
|---|---|
base/base_spec.json | 描述将用于构建变异的正确事件场景 |
本地 out/mutations/(4 个变异体) | 使用相同 seed 重复运行产生相同顺序的 mutation_id;目录不应被提交 |
out/validator_results.json | 每个变异体都关联 Given/When/Then 步骤和 JSON Schema 规则;具有 diagnostic_code、halt_before、深度(depth) |
| 最小免疫力报告 | 填充向量的三个分量 —— strict_reject_rate、depth_of_diagnostics、recovery_time_p95_ms;可运行示例通过 smoke-pass |
完整路径还会增加 expected/expected_failures.json 作为 CI 的回归基线、简短的可评审报告或 validation.md 中的记录,以及将新运行与旧 mutation_id 比较的 CI 门禁。如果验证器在执行阶段之前停止了循环和时间异常,且 CI 至少基于一个旧 mutation_id 阻止了回归,则认为其就绪。
实践
cd book2/examples/stress-mutator && python3 scripts/mutate_specs.py --base base/base_spec.json --seed 20260517 --out out/mutations——*预期:out/mutations/中恰好有 4 个文件,mutation_id分别为m_20260517_nullify_855e4297f7、m_20260517_futuretime_…、m_20260517_escalationcycle_…、m_20260517_prioritycontradiction_…;diff out/mutations/manifest.json manifest.example.json显示 0 行差异。*python3 scripts/fake_validator.py --mutations out/mutations --out out/validator_results.json && python3 scripts/immunity_score.py --validator-results out/validator_results.json --expected expected/expected_failures.json --out out/immunity.json——*预期:strict_reject_rate >= 0.98,depth_of_diagnostics >= 3,recovery_time_p95_ms <= 1200。*- 在
capstone/validation.md中写入一行:"免疫力(seed=20260517):在预期步骤拒绝了<n>/4个变异体;失败 ——<mutation_id>,需要额外的 guard"。*预期:在下次回归中,比较基于固定的seed而非"全绿"。*
复习题
- 为什么 JSON Schema 不足以检查循环和递归依赖?
strict_reject_rate显示了什么,又隐藏了什么?- 验证器严格性的增长何时会变得有害?
- 验证器在 50 个变异体上通过 smoke 运行并显示
strict_reject_rate=0.95,depth_of_diagnostics=2.4,recovery_time_p95_ms=900。三个标量都在默认阈值之内。至少举出一种应将此运行视为失败的场景,以及应检查manifest.json中的哪些额外字段以使下一个评审者能看到这种失败。