2.2 分支覆盖率的度量

分支覆盖率是一个整数千分比:走过的对话边 × 1000 整除全部对话边。本节先声明两层分母口径(去重边 56 与选项 115),再把单条确定性路径、采样 N 条、BFS 穷举三个来源放进同一张表,量出边、节点、场景三层覆盖,并给出 446‰ 与 1000‰ 的口径来源。

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 覆盖的缺口与告警 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长