14.2 随机路径的模糊测试

把随机路径当模糊测试的输入:用自实现可播种 PRNG 跑 20000 条路径,断言全部终止于 5 个结局之一、无卡死、无越界。实测分布为离港 10074、沉没 5507、缄默 3223、灯守 1018、真相 178,最稀有的真相仅 178 条。Python 与 TypeScript 两侧对同一批路径的终局状态摘要逐位一致。

14.2 随机路径的模糊测试

站内服务端的叙事分支状态架构 关心的是状态在并发与重连下的一致性,那是服务端架构层的正确性。本节是引擎层的另一件事:把随机路径本身当成模糊测试(fuzzing)的输入——不挑「精心设计」的路径,而是随机走两万条,看有没有哪一条走进死角、走出区间、或者根本不终止。它同样不涉及网络、数据库、并发,只在一份规格与一个内核里跑。

随机路径就是模糊测试的输入

模糊测试的经典形态是「拿随机字节喂解析器,看它崩不崩」。叙事引擎的输入是选择序列,所以模糊测试的形态是:从起点出发,每一步在当前状态下均匀随机挑一个可用选项,直到走进结局。一次运行 = 一条随机路径 = 一个随机输入。断言不是「输出等于某个期望值」(随机输入没有固定期望),而是三条不变量:

#断言违反即
①全部终止于 5 个结局之一有卡死 / 不终止
②全程好感度恒 ∈ [0,100] 整数有越界
③每一步 goto 的目标节点都存在有悬空 goto

只要有一条被违反,模糊测试就 exit 1。它不证明规格完美,但它能在两万次随机尝试里证伪——这是模糊测试与穷举的分工:穷举(第 5 章)证明「全部可达状态」,模糊测试快速探「有没有明显坏掉」。

为什么必须自实现可播种 PRNG

全书有一条纪律:随机一律自实现可播种 PRNG,禁用语言内置 random / Math.random()。理由是跨语言对照——Python 与 TypeScript 必须能对同一批路径算出逐位相同的摘要。内置随机数发生器没有跨语言的位级契约,用它就永远对不上。

本节用 Lehmer MINSTD:rng = rng * 48271 % 2147483647,取模得到下标。Python 侧在 sim/ne_14_2_fuzz.py,TypeScript 侧复用内核 engine.ts 的 samplePaths——两处是同算法同种子,所以两万条路径逐条相同。

S 层:20000 条路径

S 层脚本从起点走 N=20000 条路径(seed 固定),统计结局分布与步数区间,并把前 500 条终局状态摘要与整批汇总摘要写进 out/ne_14_2_fuzz.json,供 P 层逐位比对:

#!/usr/bin/env python3
# file: sim/ne_14_2_fuzz.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_14_2_fuzz.py
"""卷二 14.2 随机路径的模糊测试(S 层)。

把随机路径当成模糊测试的输入:用自实现可播种 PRNG(Lehmer MINSTD:
rng = rng * 48271 % 2147483647,禁用语言内置 random)从起点走 N 条路径,
每一步在当前状态下**均匀随机**挑一个可用选项,直到走进结局节点。

三条断言(任何一条不成立即 exit 1):
  ① 全部终止于 5 个结局之一(没有卡死、没有步数超预算);
  ② 全程好感度恒 ∈ [0,100] 整数(无越界);
  ③ 每步节点都存在(无悬空 goto)。

统计结局分布计数与步数区间;并把前 500 条路径的终局状态摘要(FNV-1a 32 位,
只对状态做、不对文本做)与整批的汇总摘要写进 out/ne_14_2_fuzz.json,
供 P 层同种子同批路径逐位比对。整数口径:全部 int,禁浮点。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import na_00_spec_check as base  # noqa: E402  复用 load_spec / eval_cond / apply_effects

ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
N = 20000
SEED = 20261016
STEP_BUDGET = 200
DIGEST_PREFIX = 500


def init_state(spec: dict) -> dict:
    aff = {v: next(c for c in spec["characters"] if c["id"] == v)["affinity"]["init"]
           for v in sorted(spec["state"]["vars"])}
    return {"node": spec["meta"]["start"], "aff": aff,
            "flags": {f: 0 for f in sorted(spec["state"]["flags"])},
            "items": {i: 0 for i in sorted(spec["state"]["items"])},
            "path": [spec["meta"]["start"]]}


def canonical(s: dict) -> str:
    aff = ",".join(f"{k}={s['aff'][k]}" for k in sorted(s["aff"]))
    fl = ",".join(k for k in sorted(s["flags"]) if s["flags"][k])
    it = ",".join(k for k in sorted(s["items"]) if s["items"][k])
    return f"{s['node']}|{aff}|{fl}|{it}|{'>'.join(s['path'])}"


def digest(s: dict) -> int:
    h = 2166136261
    for ch in canonical(s):
        h = ((h ^ ord(ch)) * 16777619) & 0xFFFFFFFF
    return h


def main() -> int:
    spec = base.load_spec()
    by = {n["id"]: n for n in spec["dialogue"]}
    name_of = {e["id"]: e["name"] for e in spec["endings"]}
    start = spec["meta"]["start"]
    rng = SEED

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

    dist: dict = {e["name"]: 0 for e in spec["endings"]}
    stuck = 0
    over_budget = 0
    oob = 0
    dangling = 0
    steps: list = []
    digests: list = []
    for _ in range(N):
        s = init_state(spec)
        n_step = 0
        while True:
            cur = by.get(s["node"])
            if cur is None:
                dangling += 1
                break
            if cur.get("ending"):
                dist[cur["ending"]] += 1
                steps.append(n_step)
                digests.append(digest(s))
                break
            if n_step >= STEP_BUDGET:
                over_budget += 1
                break
            opts = [c for c in (cur.get("choices") or []) if base.eval_cond(c.get("when"), s)]
            if not opts:
                stuck += 1
                break
            c = opts[rnd(len(opts))]
            eff = c.get("effects") or {}
            s = {"node": c["goto"], "aff": dict(s["aff"]), "flags": dict(s["flags"]),
                 "items": dict(s["items"]), "path": s["path"]}
            ns = base.apply_effects(eff, {"aff": s["aff"], "flags": s["flags"], "items": s["items"]})
            s["aff"], s["flags"], s["items"] = ns["aff"], ns["flags"], ns["items"]
            s["path"] = [*s["path"], c["goto"]]
            for v in spec["state"]["vars"]:
                if not (0 <= s["aff"][v] <= 100):
                    oob += 1
            n_step += 1

    ended = sum(dist.values())
    order = [e["name"] for e in spec["endings"]]
    summary = 2166136261
    for d in digests:
        for ch in str(d):
            summary = ((summary ^ ord(ch)) * 16777619) & 0xFFFFFFFF

    out = {
        "n": N, "seed": SEED, "step_budget": STEP_BUDGET,
        "ended": ended, "stuck": stuck, "over_budget": over_budget,
        "out_of_range": oob, "dangling_goto": dangling,
        "ending_distribution": {k: dist[k] for k in order},
        "step_min": min(steps), "step_max": max(steps),
        "step_sum": sum(steps),
        "summary_digest": summary,
        "digest_prefix_len": DIGEST_PREFIX,
        "digest_prefix": digests[:DIGEST_PREFIX],
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ne_14_2_fuzz.json"), "w", encoding="utf-8") as fh:
        json.dump(out, fh, ensure_ascii=False, indent=2)
        fh.write("\n")

    print(f"模糊测试 {N} 条路径(seed={SEED},Lehmer MINSTD,步数预算 {STEP_BUDGET})")
    print(f"终止于结局 {ended} 条:卡死 {stuck} / 超预算 {over_budget} / 悬空 goto {dangling} / 好感度越界 {oob}")
    print("结局分布:" + " · ".join(f"{k} {dist[k]}" for k in order))
    rarest = min(order, key=lambda k: dist[k])
    print(f"最稀有结局:{rarest} {dist[rarest]} 条(占比 {dist[rarest] * 1000 // ended}‰)")
    print(f"步数区间 [{min(steps)}, {max(steps)}],均值 {sum(steps) // len(steps)}")
    print(f"前 {DIGEST_PREFIX} 条终局状态摘要前缀已写出,整批汇总摘要 {summary}")
    if stuck or over_budget or oob or dangling or ended != N:
        print("FAIL 模糊测试存在不终止 / 越界 / 悬空路径")
        return 1
    print("OK ne_14_2_fuzz:全部路径终止于 5 个结局之一,无卡死、无越界")
    return 0


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

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

模糊测试 20000 条路径(seed=20261016,Lehmer MINSTD,步数预算 200)
终止于结局 20000 条:卡死 0 / 超预算 0 / 悬空 goto 0 / 好感度越界 0
结局分布:灯守 1018 · 离港 10074 · 真相 178 · 缄默 3223 · 沉没 5507
最稀有结局:真相 178 条(占比 8‰)
步数区间 [22, 31],均值 25
前 500 条终局状态摘要前缀已写出,整批汇总摘要 2695131274
OK ne_14_2_fuzz:全部路径终止于 5 个结局之一,无卡死、无越界

20000 条全部终止于结局之一:卡死 0、超预算 0、悬空 goto 0、越界 0。

P 层:同一批路径的摘要逐位一致

P 层用 TypeScript 复用内核的 samplePaths(同算法同种子)跑同一批 20000 条路径,再读 Python 侧产物逐位比对——结局分布、摘要前缀、汇总摘要、步数区间四项全部对齐:

// file: core/test/ne_14_2_fuzz.test.ts
// tier: P
// verify: node --test 'core/test/ne_14_2_fuzz.test.ts'
// 14.2 随机路径模糊测试:20000 条同种子路径全部终止于 5 个结局之一,
// 并与 Python 侧(out/ne_14_2_fuzz.json)逐位比对终局状态摘要。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

import type { Spec } from "../src/inkwell/engine.ts";
import { fuzz } from "../src/inkwell/ne_14_2_fuzz.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const spec = JSON.parse(readFileSync(join(ROOT, "config/story_spec.json"), "utf8")) as Spec;
const SEED = 20261016;
const N = 20000;

const py = JSON.parse(readFileSync(join(ROOT, "out/ne_14_2_fuzz.json"), "utf8")) as {
  ending_distribution: Record<string, number>;
  digest_prefix: number[];
  summary_digest: number;
  step_min: number;
  step_max: number;
};

test("全部 20000 条路径终止于结局之一:卡死 0、超预算 0", () => {
  const r = fuzz(spec, N, SEED);
  assert.equal(r.ended, N);
  assert.equal(r.stuck, 0);
  assert.equal(r.overBudget, 0);
  console.log(`路径 ${r.n},终止 ${r.ended},卡死 ${r.stuck},超预算 ${r.overBudget}`);
});

test("结局分布与 Python 侧逐项相同(同种子同批路径)", () => {
  const r = fuzz(spec, N, SEED);
  assert.deepEqual(r.distribution, py.ending_distribution);
  console.log(`结局分布 ${JSON.stringify(r.distribution)}`);
});

test("前 500 条终局状态摘要与 Python 侧逐位一致", () => {
  const r = fuzz(spec, N, SEED);
  assert.deepEqual(r.digests.slice(0, py.digest_prefix.length), py.digest_prefix);
  console.log(`摘要前缀 ${py.digest_prefix.length} 条逐位一致,首条 ${py.digest_prefix[0]}`);
});

test("整批汇总摘要与 Python 侧相同;步数区间与 Python 侧相同", () => {
  const r = fuzz(spec, N, SEED);
  assert.equal(r.summaryDigest, py.summary_digest);
  assert.equal(r.stepMin, py.step_min);
  assert.equal(r.stepMax, py.step_max);
  console.log(`汇总摘要 ${r.summaryDigest},步数区间 [${r.stepMin}, ${r.stepMax}]`);
});

本机实跑 node --test 'core/test/ne_14_2_fuzz.test.ts':

路径 20000,终止 20000,卡死 0,超预算 0
结局分布 {"灯守":1018,"离港":10074,"真相":178,"缄默":3223,"沉没":5507}
摘要前缀 500 条逐位一致,首条 3976226298
汇总摘要 2695131274,步数区间 [22, 31]
✔ 全部 20000 条路径终止于结局之一:卡死 0、超预算 0 (571.656875ms)
✔ 结局分布与 Python 侧逐项相同(同种子同批路径) (561.876417ms)
✔ 前 500 条终局状态摘要与 Python 侧逐位一致 (558.089458ms)
✔ 整批汇总摘要与 Python 侧相同;步数区间与 Python 侧相同 (646.970875ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 2558.799292

两侧的汇总摘要都是 2695131274,前 500 条摘要逐位一致。这就是「同算法同种子的自实现 PRNG」换来的:两万条路径、每一条的终局状态、每一个摘要,Python 与 TypeScript 完全对得上。摘要只对整数状态做,不对文本做——这一点是全书口径。

分布与步数怎么读

结局计数占比
离港10074504‰
沉没5507275‰
缄默3223161‰
灯守101851‰
真相1789‰

均匀随机下分布是偏斜的,不是均匀的。原因在最后枢纽 d_dq_2:五个结局各由一条带条件选项门控(c_dq_2a–c_dq_2e),而 c_dq_2e(离港)无条件,永远可用;其余四条要满足各自的条件才出现。随机挑选是在当时可用的选项里等概率取一个,所以「可用的结局选项越多,每个被选中概率越低」——离港常驻,自然最高。

最稀有的 真相 178 条(9‰) 需要 bell_rung 为真,这是一条窄路径。这个数字本身不判好坏:分布是结构量,不是「平衡度」的绝对标尺。它的用途是回归基线——把这一批计数存下来,规格一改,分布一动,就能立刻看到(下一节)。

步数区间 [22, 31]、均值 25,说明每条路径都要穿过若干枢纽与场景才到结局,没有「三步速通」的短路径,也没有「走不完」的长路径。它同样是结构量:区间塌到个位数通常意味着某个枢纽塌了,区间拉长通常意味着某处出现了环。

自检表

检查项合格标准
随机可复现自实现 Lehmer MINSTD,同种子同序列,禁内置 random
断言是命题三条断言都是「对所有路径成立」的不变量,不是固定期望值
两侧逐位一致同批路径的终局摘要跨语言逐位相同
摘要只对状态只对整数状态做 digest,不对文本做
分布是结构量不声称「平衡」「更好」,只作回归基线

两万条随机路径证明「怎么走都不会坏」。但它只说明当前这份规格没问题——规格一旦被改动,随机路径与分布就会变。下一节把摘要、分布与残差落成回归基线,让改动可见、可告警。

阅读导航:上一节:14.1 属性测试与不变量 · 下一节:14.3 回归与告警 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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