2.3 覆盖的缺口与告警

覆盖率给一个总数,缺口给一张名单。本节对随机采样 N=100 条路径逐边计数,定位那条从未被走过的边——门条件为 bell_rung 的真相结局入口,源节点访问了 100 次却没选中它;再用采样规模与种子的参数扰动给出软告警口径,低于 850‰ 只记 WARN、恒 exit 0。

2.3 覆盖的缺口与告警

独立游戏的叙事设计与分支对话 里,作者会说「这条支线好像没人走到」——那是一句感觉。本节把这句话变成一个可定位的对象:哪条边、从哪个节点、门条件是什么、为什么采样没走到它。覆盖率给一个总数(982‰),缺口给一张名单(1 条边);告警则是这张名单低于目标时的软提示。

缺口是什么

上一节的覆盖率 1000‰ 是「这次采样恰好走遍了」;缺口要回答的是「没走遍时,差的是哪几条」。一条边没被覆盖,只有两种可能:路径到不了它的起点(结构不可达),或路径到了起点却没选中它(门条件太苛刻)。前者是结构问题,后者是采样问题——定位时要把两者分开,否则会把「采样不够」误判成「设计断了」。本节对随机采样 N=100 条路径逐边计数,把访问次数为 0 的边叫缺口、为 1 的边叫难边(擦边而过,换个种子就可能掉出去);缺口按门条件的逻辑节点数(and / or / not 的嵌套深度)排序——条件越深越难被随机撞到。

S 层:把缺口定位成一张名单

下面这段 S 层脚本 sim/nm_02_3_gap.py 逐边计数后输出缺口、难边与未到达的结局。它复用 sim/nm_02_1_sampling.py 的 eval_cond / apply_effects / init_state(只读),同种子同序列,故可复现。

#!/usr/bin/env python3
# file: sim/nm_02_3_gap.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_02_3_gap.py
"""卷三 2.3 覆盖的缺口与告警(S 层):定位「从未被任何采样路径走过的边」。

随机采样 N=100 条路径逐边计数,输出未覆盖边(访问次数 = 0)、难边(= 1)与未到达的
结局,缺口按门条件深度排序。缺口是采样的**正常产物**,这里只**定位**;告警是软告警
(B 层见 nm_02_3_perturb.py)。只读 config/story_spec.json。产出 out/nm_02_3_gap.json。
"""
from __future__ import annotations

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")
SEED, N, STEP_CAP = 20261016, 100, 64


def cond_depth(c) -> int:
    """门条件的逻辑节点数——越深越难被随机满足。"""
    if not c:
        return 0
    if c[0] in ("and", "or"):
        return 1 + cond_depth(c[1]) + cond_depth(c[2])
    if c[0] == "not":
        return 1 + cond_depth(c[1])
    return 1


def main() -> int:
    spec = s1.load_spec()
    by = {n["id"]: n for n in spec["dialogue"]}
    E = {(n["id"], c["goto"]) for n in spec["dialogue"] for c in (n.get("choices") or [])}
    rng, visits, node_visits, ends = SEED, {}, {}, set()

    def rnd(mod: int) -> int:
        nonlocal rng
        rng = (rng * 48271) % 2147483647
        return rng % mod

    for _ in range(N):
        node, st = spec["meta"]["start"], s1.init_state(spec)
        for _s in range(STEP_CAP):
            if by[node].get("ending"):
                ends.add(by[node]["ending"])
                break
            opts = [c for c in (by[node].get("choices") or []) if s1.eval_cond(c.get("when"), st)]
            if not opts:
                break
            c = opts[rnd(len(opts))]
            visits[(node, c["goto"])] = visits.get((node, c["goto"]), 0) + 1
            node_visits[node] = node_visits.get(node, 0) + 1
            st = s1.apply_effects(c.get("effects") or {}, st)
            node = c["goto"]

    def describe(e: tuple) -> dict:
        a, b = e
        ch = next(c for c in by[a]["choices"] if c["goto"] == b)
        return {"from": a, "to": b, "choice": ch["id"], "when": ch["when"],
                "depth": cond_depth(ch["when"]), "source_visits": node_visits.get(a, 0)}

    uncovered = sorted((e for e in E if visits.get(e, 0) == 0),
                       key=lambda e: (-cond_depth(next(c["when"] for c in by[e[0]]["choices"]
                                                        if c["goto"] == e[1])), e[0]))
    thin = sorted((e for e in E if visits.get(e, 0) == 1), key=lambda e: e[0])
    covered = sum(1 for e in E if visits.get(e, 0) > 0)
    missed = [nm["name"] for nm in spec["endings"] if nm["name"] not in ends]

    os.makedirs(OUT, exist_ok=True)
    report = {"seed": SEED, "n": N, "edges_total": len(E), "covered": covered,
              "permille": covered * 1000 // len(E),
              "uncovered": [describe(e) for e in uncovered],
              "thin_edges": [describe(e) for e in thin],
              "endings_reached": sorted(ends), "endings_missed": missed}
    with open(os.path.join(OUT, "nm_02_3_gap.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print(f"随机采样 N={N}(seed={SEED}):覆盖 {covered}/{len(E)} = {covered * 1000 // len(E)}‰")
    print(f"未覆盖边 {len(uncovered)} 条:")
    for u in report["uncovered"]:
        print(f"   {u['from']} → {u['to']}  选项 {u['choice']}  门条件 {u['when']}  "
              f"深度 {u['depth']}  源节点访问 {u['source_visits']}")
    print(f"难边(只被 1 条路径走过){len(thin)} 条:")
    for t in report["thin_edges"]:
        print(f"   {t['from']} → {t['to']}  选项 {t['choice']}  门条件 {t['when']}")
    print(f"到达的结局 {report['endings_reached']}")
    print(f"未到达的结局 {missed}")
    print(f"判读:缺口 {len(uncovered)} 条边 = 覆盖率缺口 {1000 - covered * 1000 // len(E)}‰;"
          f"源节点访问 {report['uncovered'][0]['source_visits'] if report['uncovered'] else 0} 次"
          f"说明「路径到了但没选它」")

    assert covered + len(uncovered) == len(E), "覆盖 + 未覆盖应等于全量"
    print("wrote out/nm_02_3_gap.json")
    print(f"OK nm_02_3_gap:缺口已定位({len(uncovered)} 条未覆盖边,{len(thin)} 条难边),"
          f"缺 {len(missed)} 个结局")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/nm_02_3_gap.py:

随机采样 N=100(seed=20261016):覆盖 55/56 = 982‰
未覆盖边 1 条:
   d_dq_2 → e_truth  选项 c_dq_2b  门条件 ['flag', 'bell_rung', 1]  深度 1  源节点访问 100
难边(只被 1 条路径走过)0 条:
到达的结局 ['沉没', '灯守', '离港', '缄默']
未到达的结局 ['真相']
判读:缺口 1 条边 = 覆盖率缺口 18‰;源节点访问 100 次说明「路径到了但没选它」
wrote out/nm_02_3_gap.json
OK nm_02_3_gap:缺口已定位(1 条未覆盖边,0 条难边),缺 1 个结局

判读:缺口是「到了没选它」,不是「到不了」

第一,缺口只有 1 条边,但它正好是第 5 个结局的入口。 未覆盖的边是 d_dq_2 → e_truth,门条件 ['flag', 'bell_rung', 1]——没走过它,就少一个结局(真相)。覆盖率缺 18‰ 看起来很小,后果却是一个结局整块没被触达。这就是「总数会骗人、名单不会」:982‰ 听起来接近满分,名单却指着唯一一条通往真相的路。

第二,源节点访问 100 次,说明不是「到不了」。 d_dq_2 是路径的终点前节点,100 条路径全部走到了它(访问次数 = 100),却一次都没满足 bell_rung = 1。所以缺口是门条件太苛刻,不是结构断了——bell_rung 只由两个选项置位,而这两个选项又各需先持有 bell_key 线索,是一道合取门。随机玩在 100 条路径里撞不到它。

第三,难边为 0 说明缺口「干净」。 没有「只被走过 1 次」的擦边边,意味着这次的 982‰ 是稳的:缺口要么是 0、要么是这 1 条,不会因为换种子而大起大落。第四,定位不等于修复。 脚本不改任何东西——它只把缺口指出来;要补上这条边,要么放宽门条件、要么提高采样规模、要么换成覆盖导向的贪心策略(2.1 里贪心在 N=25 就覆盖了它)。改哪一处是设计决策,不是脚本的结论。

B 层:缺口告警是软告警

缺口要不要报警?这里定死一条口径:覆盖率低于目标只记软告警,不硬失败。因为「采样没走遍」是采样的正常产物,把它当错误会让每一次采样都「失败」。下面这段 B 层脚本 sim/nm_02_3_perturb.py 对采样规模与采样种子两条轴做扰动,低于 850‰ 只打印 WARN,恒 exit 0;断言写的是「告警条数已报告」,不是「必须无告警」。

#!/usr/bin/env python3
# file: sim/nm_02_3_perturb.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_02_3_perturb.py
"""卷三 2.3 覆盖缺口的告警(B 层,软告警不硬失败)。

两条**参数扰动**,只打印软告警:① 采样规模 N 扫 1…100,看覆盖率何时越过目标 850‰;
② 采样种子(固定 N=3)扫 6 个,看随机覆盖率在目标线上下抖动。**全部 exit 0**——低于
目标只记 WARN,断言写「告警条数已报告」,不写「必须无告警」。扰动只改内存里的采样
参数,不写回 config/story_spec.json。复用 sim/nm_02_1_sampling.py 的
eval_cond / apply_effects / init_state(只读)。产出 out/nm_02_3_perturb.json。
"""
from __future__ import annotations

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")
NS = [1, 2, 3, 5, 10, 25, 50, 100]
SEEDS = [20261016, 1, 7, 42, 100, 999983]
SEED_N, STEP_CAP = 3, 64


def sample_seed(spec: dict, by: dict, strategy: str, n: int, seed: int) -> set:
    """带种子的采样:返回覆盖的去重边集合。"""
    rng, visits, es = seed, {}, set()

    def rnd(mod: int) -> int:
        nonlocal rng
        rng = (rng * 48271) % 2147483647
        return rng % mod

    for _ in range(n):
        node, st = spec["meta"]["start"], s1.init_state(spec)
        for _s in range(STEP_CAP):
            if by[node].get("ending"):
                break
            opts = [c for c in (by[node].get("choices") or []) if s1.eval_cond(c.get("when"), st)]
            if not opts:
                break
            if strategy == "greedy":
                m = min(visits.get((node, x["id"]), 0) for x in opts)
                cand = [x for x in opts if visits.get((node, x["id"]), 0) == m]
                c = cand[rnd(len(cand))]
            else:
                c = opts[rnd(len(opts))]
            visits[(node, c["id"])] = visits.get((node, c["id"]), 0) + 1
            es.add((node, c["goto"]))
            st = s1.apply_effects(c.get("effects") or {}, st)
            node = c["goto"]
    return es


def main() -> int:
    spec = s1.load_spec()
    by = {n["id"]: n for n in spec["dialogue"]}
    E = len({(n["id"], c["goto"]) for n in spec["dialogue"] for c in (n.get("choices") or [])})
    target = spec["metrics"]["coverage_target_permille"]
    warns: list[str] = []

    print(f"去重对话边 {E}  目标覆盖率 {target}‰  软告警阈值 = 覆盖率 < {target}‰")
    print("全部 exit 0(B 层口径):低覆盖只记 WARN,不硬失败。")
    print("\n① 采样规模 N → 覆盖率(千分比)")
    print(f"   {'策略':<8}{'N':>5}{'边':>5}{'‰':>6}{'判定':>8}")
    scale_rows = []
    for strat in ("random", "greedy"):
        for n in NS:
            es = sample_seed(spec, by, strat, n, 20261016)
            pm = len(es) * 1000 // E
            ok = pm >= target
            scale_rows.append({"strategy": strat, "n": n, "edges": len(es),
                               "permille": pm, "meets_target": ok})
            print(f"   {strat:<8}{n:>5}{len(es):>5}{pm:>6}{'OK' if ok else 'WARN':>8}")
            if not ok:
                warns.append(f"规模 {strat} N={n}:覆盖率 {pm}‰ < 目标 {target}‰")

    print("\n② 采样种子(random, N=3)→ 覆盖率抖动")
    print(f"   {'seed':>10}{'边':>5}{'‰':>6}{'判定':>8}")
    seed_rows = []
    for sd in SEEDS:
        es = sample_seed(spec, by, "random", SEED_N, sd)
        pm = len(es) * 1000 // E
        ok = pm >= target
        seed_rows.append({"seed": sd, "edges": len(es), "permille": pm, "meets_target": ok})
        print(f"   {sd:>10}{len(es):>5}{pm:>6}{'OK' if ok else 'WARN':>8}")
        if not ok:
            warns.append(f"种子 {sd}:覆盖率 {pm}‰ < 目标 {target}‰")

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_02_3_perturb.json"), "w", encoding="utf-8") as fh:
        json.dump({"edges_total": E, "coverage_target_permille": target,
                   "scale_axis": scale_rows, "seed_axis": seed_rows,
                   "warning_count": len(warns), "warnings": warns}, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    print()
    print("\n".join(f"WARN {w}" for w in warns) if warns else "无软告警:所有点均达目标")
    print("wrote out/nm_02_3_perturb.json")
    print(f"OK nm_02_3_perturb:告警条数已报告 = {len(warns)} 条,exit 0(B 层口径)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/nm_02_3_perturb.py:

去重对话边 56  目标覆盖率 850‰  软告警阈值 = 覆盖率 < 850‰
全部 exit 0(B 层口径):低覆盖只记 WARN,不硬失败。

① 采样规模 N → 覆盖率(千分比)
   策略          N    边     ‰      判定
   random      1   22   392    WARN
   random      2   32   571    WARN
   random      3   43   767    WARN
   random      5   51   910      OK
   random     10   54   964      OK
   random     25   55   982      OK
   random     50   55   982      OK
   random    100   55   982      OK
   greedy      1   22   392    WARN
   greedy      2   49   875      OK
   greedy      3   51   910      OK
   greedy      5   53   946      OK
   greedy     10   55   982      OK
   greedy     25   56  1000      OK
   greedy     50   56  1000      OK
   greedy    100   56  1000      OK

② 采样种子(random, N=3)→ 覆盖率抖动
         seed    边     ‰      判定
     20261016   43   767    WARN
            1   38   678    WARN
            7   51   910      OK
           42   46   821    WARN
          100   46   821    WARN
       999983   48   857      OK

WARN 规模 random N=1:覆盖率 392‰ < 目标 850‰
WARN 规模 random N=2:覆盖率 571‰ < 目标 850‰
WARN 规模 random N=3:覆盖率 767‰ < 目标 850‰
WARN 规模 greedy N=1:覆盖率 392‰ < 目标 850‰
WARN 种子 20261016:覆盖率 767‰ < 目标 850‰
WARN 种子 1:覆盖率 678‰ < 目标 850‰
WARN 种子 42:覆盖率 821‰ < 目标 850‰
WARN 种子 100:覆盖率 821‰ < 目标 850‰
wrote out/nm_02_3_perturb.json
OK nm_02_3_perturb:告警条数已报告 = 8 条,exit 0(B 层口径)

判读:告警的阈值与相变点

第一,相变点在随机 N=5、贪心 N=2。 随机采样要到 5 条路径才越过高 850‰ 的目标线,贪心 2 条就够——贪心用更少的路径把缺口补上,这与 2.1 的结论一致。相变点不是「采样够不够」的绝对答案,而是「这条策略在这张图上越过目标线的最小规模」。

第二,种子扰动说明小规模采样不可信。 固定 N=3、换 6 个种子,覆盖率在 678‰–910‰ 之间抖——同一个 N,有的种子过线、有的不过。所以「N=3 的覆盖率」这个数本身没有意义;要么把 N 抬到相变点以上,要么报告「多个种子的区间」。第三,8 条告警全部是软告警,脚本仍 exit 0。 这是 B 层口径的要点:告警是信息不是失败。如果改成硬失败,那么任何一次小规模采样都会让流水线变红,而真正该看的信号(缺口名单)反而被淹没。「告警条数已报告 = 8」才是这条断言该写的东西。

缺口与告警都是代理指标:缺口名单度量「这张图还有哪几条边没被采样触达」,告警度量「这次采样离目标线多远」。它们不声称缺口越大叙事越差,也不声称越过目标线就等于覆盖充分。所有结论都只在相对比较(不同策略、不同规模、不同种子)里成立。

自检表

检查项合格标准
缺口可定位输出未覆盖边的节点 / 选项 / 门条件 / 源节点访问次数
缺口可归因区分「到不了」与「到了没选它」(本例为后者)
软告警口径低于目标只记 WARN,脚本恒 exit 0;断言写「告警条数已报告」
相变点真跑随机 N=5、贪心 N=2 越线,来自实跑非估计

缺口名单把「采样没走到哪」说清楚了,但覆盖率高不高、缺口大不大,都还只回答了「这张图被走遍了多少」。下一个问题是:走出来的结局,分布得均不均匀——这是第 3 章要量的结局分布熵与分支平衡。

阅读导航:上一节:2.2 分支覆盖率的度量 · 下一节:3.1 结局分布的熵 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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