2.2 分支覆盖率的度量
独立游戏的叙事设计与分支对话 里,「分支覆盖率」是一个经验判断——作者凭手感说「这次分支写得比较密」。本节把它变成一个可复现的整数:分母是多少、分子怎么数、同一个数在两种口径下为什么会不一样。这不是又一个「覆盖率」,而是一个口径声明:任何一个百分比,先把分母说清楚。
覆盖率是一个整数千分比
定义只有一行:
覆盖率(‰) = 走过的对话边 × 1000 // 全部对话边 (整数地板除,无浮点)
用千分比而不是百分比,是因为 56 条边的粒度下,百分比会把 25/56 和 26/56 都压成同一个整数;千分位能保住区分度。地板除保证它是整数——与全书「派生量整数化」的纪律一致,也保证 Python 与 TypeScript 两侧算出的值逐位一致(第 15 章会回到这个口径)。
分母必须显式声明,否则两个数会打架。 本案例有两层口径:
| 口径 | 分母 | 定义 | 全量覆盖时 |
|---|---|---|---|
| 去重边口径(本节默认) | 56 | 指向同一目标的并行选项合并成一条图边 | 1000‰ |
| 选项口径(对照) | 115 | 规格 meta.scale.choices 声明的选项数 | 1000‰ |
同一个「走完全部」,在两种口径下都恰好是 1000‰——全量覆盖与口径无关。但中间值不同:一条确定性路径走过的 25 步,在去重边口径下是 25/56 = 446‰,在选项口径下是 25/115 = 217‰。目录篇的 446‰ 用的是去重边口径,本节沿用同一个分母,让全章的数可比。
S 层:三个来源放进同一张表
下面这段 S 层脚本 sim/nm_02_2_coverage.py 把三个来源——单条确定性路径、采样 N=200 条、BFS 穷举基准——放进同一张表,各算边、节点、场景三层覆盖。分母一律是去重对话边 56;节点覆盖把起点与终点都算进已访问(故一条 26 节点的路径覆盖 26/48 个节点);场景覆盖取已访问节点所属的场景。
#!/usr/bin/env python3
# file: sim/nm_02_2_coverage.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_02_2_coverage.py
"""卷三 2.2 分支覆盖率的度量(S 层)。
分支覆盖率是一个**整数千分比**:
覆盖率(‰) = 走过的对话边 × 1000 // 全部对话边 (整数地板除,无浮点)
本节的**分母口径**有两层,必须分开声明,否则两个数会打架:
去重边口径(默认,分母 56) 把指向同一目标的并行选项合并成一条图边。
选项口径(对照,分母 115) 规格声明的选项数(meta.scale.choices)。
同一条路径在两种口径下得到不同的千分比,**但「全量覆盖」在两种口径下都恰好是
1000‰**。目录篇的 446‰ 用的是去重边口径(25/56)。本节把三个来源并列:单条确定性
路径、采样 N 条、BFS 穷举基准,并给出节点覆盖与场景覆盖作旁证。只读
config/story_spec.json。产出 out/nm_02_2_coverage.json / .csv。
"""
from __future__ import annotations
import csv
import json
import os
import sys
import nm_02_1_sampling as s1
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
N = 200
def main() -> int:
spec = s1.load_spec()
nodes = spec["dialogue"]
by = {n["id"]: n for n in nodes}
m = spec["metrics"]
E_dedup = len({(n["id"], c["goto"]) for n in nodes for c in (n.get("choices") or [])})
E_option = len({(n["id"], c["id"]) for n in nodes for c in (n.get("choices") or [])})
n_nodes = len(nodes)
n_scenes = len(spec["scene_graph"]["nodes"])
det, _ = s1.first_path(spec, by)
rand = s1.sample(spec, by, "random", N)
greedy = s1.sample(spec, by, "greedy", N)
bfs = s1.reachable_edges(spec, by)
def node_cover(edges: set) -> int:
return len({a for a, _ in edges} | {b for _, b in edges})
def scene_cover(edges: set) -> int:
return len({by[x]["scene"] for x in ({a for a, _ in edges} | {b for _, b in edges})})
sources = [
("deterministic", det, 1),
("random", rand["edges"], N),
("greedy", greedy["edges"], N),
("exhaustive", bfs, 0),
]
rows = []
for name, es, n in sources:
rows.append({
"source": name, "paths": n,
"edges_dedup": len(es), "dedup_permille": len(es) * 1000 // E_dedup,
"nodes": node_cover(es), "node_permille": node_cover(es) * 1000 // n_nodes,
"scenes": scene_cover(es), "scene_permille": scene_cover(es) * 1000 // n_scenes,
})
# 选项口径:确定性路径的 25 条边在选项里对应 25 个不同选项 id(无重复)
det_option = 25
option_rows = [
{"source": "deterministic", "edges_option": det_option,
"option_permille": det_option * 1000 // E_option},
{"source": "exhaustive", "edges_option": E_option, "option_permille": 1000},
]
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "nm_02_2_coverage.csv"), "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=list(rows[0]))
w.writeheader()
w.writerows(rows)
with open(os.path.join(OUT, "nm_02_2_coverage.json"), "w", encoding="utf-8") as fh:
json.dump({"edges_dedup_total": E_dedup, "edges_option_total": E_option,
"nodes_total": n_nodes, "scenes_total": n_scenes,
"coverage_target_permille": m["coverage_target_permille"],
"sources": rows, "option_caliber": option_rows}, fh, ensure_ascii=False, indent=2)
fh.write("\n")
print(f"分母:去重对话边 {E_dedup} 选项 {E_option} 节点 {n_nodes} 场景 {n_scenes}"
f" 目标覆盖率 {m['coverage_target_permille']}‰")
print(f" {'来源':<14}{'路径':>5}{'边':>5}{'边‰':>7}{'节点':>5}{'节点‰':>7}{'场景':>5}{'场景‰':>7}")
for r in rows:
print(f" {r['source']:<14}{r['paths']:>5}{r['edges_dedup']:>5}{r['dedup_permille']:>7}"
f"{r['nodes']:>5}{r['node_permille']:>7}{r['scenes']:>5}{r['scene_permille']:>7}")
print(f"选项口径对照:单条路径 {det_option}/{E_option} = {det_option * 1000 // E_option}‰;"
f"穷举 {E_option}/{E_option} = 1000‰")
print(f"判定:单条路径 {rows[0]['dedup_permille']}‰ < 目标 {m['coverage_target_permille']}‰ → 需采样;"
f"穷举 {rows[3]['dedup_permille']}‰ = 定义上界")
for r in rows:
assert 0 <= r["dedup_permille"] <= 1000 and 0 <= r["node_permille"] <= 1000, "覆盖率须在 [0,1000]‰"
assert 0 <= r["scene_permille"] <= 1000, "场景覆盖率须在 [0,1000]‰"
assert rows[3]["dedup_permille"] == 1000, "穷举应为 1000‰"
print("wrote out/nm_02_2_coverage.csv / .json")
print(f"OK nm_02_2_coverage:去重边口径下,单条 {rows[0]['dedup_permille']}‰,"
f"采样 {rows[1]['dedup_permille']}‰,穷举 {rows[3]['dedup_permille']}‰")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/nm_02_2_coverage.py:
分母:去重对话边 56 选项 115 节点 48 场景 14 目标覆盖率 850‰
来源 路径 边 边‰ 节点 节点‰ 场景 场景‰
deterministic 1 25 446 26 541 9 642
random 200 56 1000 48 1000 14 1000
greedy 200 56 1000 48 1000 14 1000
exhaustive 0 56 1000 48 1000 14 1000
选项口径对照:单条路径 25/115 = 217‰;穷举 115/115 = 1000‰
判定:单条路径 446‰ < 目标 850‰ → 需采样;穷举 1000‰ = 定义上界
wrote out/nm_02_2_coverage.csv / .json
OK nm_02_2_coverage:去重边口径下,单条 446‰,采样 1000‰,穷举 1000‰
D 层:把口径固化成一张定义表
S 层算出的数是「这一次」的;口径是「每一次」都要一样的。把口径写成一份数据而不是散在正文里,是为了让校验脚本能读它、让下一章的度量能复用同一份分母。下面这份 config/nm_02_2_metric.json 把公式、两层分母、目标线与参照值固定下来——它是声明,不是又算一遍。
{
"metric": "branch_coverage",
"unit": "permille",
"formula": "covered_edges * 1000 // total_edges",
"rounding": "floor",
"float_forbidden": true,
"calibers": [
{
"name": "dedup_edge",
"denominator": 56,
"definition": "指向同一目标的并行选项合并成一条图边",
"default": true
},
{
"name": "option",
"denominator": 115,
"definition": "规格 meta.scale.choices 声明的选项数",
"default": false
}
],
"target_permille": 850,
"reference": {
"deterministic_single_path": 446,
"sampled_random_200": 1000,
"exhaustive_bfs": 1000
},
"invariants": [
"coverage_permille in [0, 1000]",
"full_coverage == 1000 in both calibers"
],
"proxy": true,
"note": "只做相对比较;不声称覆盖率越高体验越完整"
}
这份表里有三处是硬约束:rounding = floor(跨语言才逐位一致)、float_forbidden = true(与全书整数纪律一致)、invariants 里的两条不变量(coverage_permille in [0, 1000] 与「两种口径的全量覆盖都等于 1000」)。校验脚本读它、断言它;正文里的每个覆盖率数字,都能在这份表里找到对应的分母。
判读:三层覆盖与一条判定线
第一,单条路径 446‰ < 目标 850‰,这是「必须采样」的判定线。 脚本不打印 FAIL、也不返回非零——它只给出一个判定:单条路径达不到目标,所以要采样。目标 850‰ 来自规格 metrics.coverage_target_permille,是作者对「这张图至少要被走遍多少」的声明。
第二,节点覆盖与场景覆盖是旁证,不是替代。 单条路径覆盖 26/48 个节点(541‰)、9/14 个场景(642‰)——都比边覆盖 446‰ 高。原因是节点数远少于边数:走 25 步经过 26 个节点,节点层的「碰巧路过」比边层的「恰好选对」容易。所以「节点都到过了」不等于「分支都走遍了」——只有边覆盖能说明分支被走遍。
第三,采样的 1000‰ 与穷举的 1000‰ 是同一个数,但含义不同。 采样 N=200 条随机路径恰好也覆盖了全部 56 条边(下一节会看到,这个「恰好」并不稳定);穷举则是定义上覆盖全部可达边。两个 1000‰ 不能混为一谈:前者是「这次采样运气好」,后者是「基准线」。第 2.3 节正是要用告警把这两种 1000‰ 区分开。
第四,选项口径的 217‰ 与去重边口径的 446‰ 都对,取决于分母。 规格声明 115 个选项,但其中 59 个是「并行选项」——同一个节点上多个选项指向同一个目标节点。它们是不同的选项,却是同一条图边。选哪个口径取决于你要回答的问题:问「玩家看到多少种选择」用选项口径,问「这张图有多少条边被走过」用去重边口径。本节统一用后者,并在每个数字旁标注分母。
分支覆盖率是代理指标:它度量「这张图的结构被走遍了多少」,不声称走遍全部边就等于叙事体验完整,也不声称覆盖率越高越好。它只做相对比较(单条 vs 采样 vs 基准),不做绝对判断。
自检表
| 检查项 | 合格标准 |
|---|---|
| 分母显式 | 每个覆盖率数字旁标出分母(去重边 56 / 选项 115) |
| 整数口径 | 千分比地板除,无浮点 |
| 三来源真跑 | 单条 / 采样 / 穷举都来自实跑,非估计 |
| 判定可复核 | 446‰ < 850‰ 目标线,穷举 = 1000‰ 定义上界 |
| 整数不变量 | 边 / 节点 / 场景覆盖率均 ∈ [0,1000]‰ |
知道了覆盖率怎么算、单条路径离目标多远,下一步是把「采样没走到的那些边」列成名单——缺口在哪、为什么没走到、什么时候该告警。这是下一节的事。
阅读导航:上一节:2.1 采样策略:随机、贪心、穷举 · 下一节:2.3 覆盖的缺口与告警 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。