13.3 偏差与容差

什么必须逐位相同、什么允许不同,本节写成一张容差表:整数状态(好感度、flag、item、节点 id、路径)必须逐位;浮点派生量、对象键序、文本渲染、时间戳允许不同。负例把好感度钳位上限从 100 改成 99,实测 12 / 500 条路径摘要改变,首个分歧点定位到路径 #13 第 20 步 c_fg_1a 的 shen。

《游戏设计》卷一《玩法机制与可执行规格》 用声明容差断言机制派生量;本章把同一套纪律用到叙事状态上。13.1 与 13.2 证明了三侧「相同」,本节处理更常见的一半:当三侧不完全相同时,哪些差异是偏差、哪些是容差。答案要写成一张可核对的表——而不是一句「差不多就行」。

13.3 偏差与容差

把状态里的量分成两类:必须逐位相同的与允许不同的。前者是判据,后者是纪律——不是「可以不管」,而是「按声明的方式断言」。

量类判据必须逐位实测残差
整数状态(好感度 / flag / item / 节点 id / 路径)canonical 串逐位比对是0(500 / 500)
对象键序canonical 按键名排序后比对否0(排序后等价)
浮点派生量(示例:调和和)声明容差 1e-9否7.532e-13(升序 vs 降序)
文本渲染不进摘要、不比对否不适用(canonical 全 ASCII)
时间戳不进状态、不比对否不适用

第一行是硬判据:节点 id、好感度、flag、物品、路径必须逐位相同——13.1 的 500 / 500 就是这一行。下面四行是容差:它们要么被 canonical 的排序规则消解(键序),要么根本不进摘要(文本、时间戳),要么只能用浮点算、必须声明一个可接受的误差上界(浮点派生量)。唯一不能做的是把浮点量也当成逐位判据——下面那条负例就展示了「差一个整数」会怎样。

S 层:故意引入偏差的负例

负例只改一个数:把好感度钳位上限从规格值 100 改成 99。其余一字不动。脚本重放 500 条路径,断言摘要必然改变,并报出首个分歧点。

#!/usr/bin/env python3
# file: sim/ne_13_3_tolerance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_13_3_tolerance.py
"""卷二 13.3 偏差与容差(S 层)。
把「什么必须逐位相同、什么允许不同」落成一张容差表,并给一个故意引入偏差的负例:
把好感度钳位上限从 100 改成 99,断言摘要必然改变,并报出首个分歧点(路径 / 步 / 选项 / 值)。
容差表里「浮点派生量」一栏给一个真跑残差:同一批值的调和和按升序 / 降序累加,
双精度结果在末几位不同——这就是「浮点派生量按声明容差断言、不逐位」的实证。
"""
from __future__ import annotations

import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ne_11_common import OUT, canonical, cond_ok, fnv1a, init_state, load_spec  # noqa: E402

JSON_PATH = os.path.join(OUT, "ne_13_3_tolerance.json")
SEED, N, HI_OK, HI_BAD = 20261016, 500, 100, 99


def node(spec: dict, nid: str) -> dict:
    return next(n for n in spec["dialogue"] if n["id"] == nid)


def apply(spec: dict, s: dict, c: dict, hi: int) -> dict:
    """唯一的偏差来源:好感度钳位上限 hi。hi=100 是规格值,hi=99 是负例扰动。"""
    eff = c.get("effects") or {}
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k in sorted((eff.get("set") or {})):
        flags[k] = eff["set"][k]
    for k in sorted((eff.get("add") or {})):
        aff[k] = min(hi, max(0, aff.get(k, 0) + eff["add"][k]))
    for k in sorted((eff.get("grant") or {})):
        items[k] = eff["grant"][k]
    for k in sorted((eff.get("revoke") or {})):
        items[k] = 0
    return {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
            "path": s["path"] + [c["goto"]]}


def choice_of(spec: dict, s: dict, cid: str) -> dict:
    return next(x for x in (node(spec, s["node"]).get("choices") or []) if x["id"] == cid)


def sample_paths(spec: dict, n: int, seed: int) -> list[list[str]]:
    rng, out = seed, []
    for _ in range(n):
        s, trail = init_state(spec), []
        for _step in range(200):
            cur = node(spec, s["node"])
            if cur.get("ending"):
                break
            opts = [c for c in (cur.get("choices") or []) if cond_ok(c.get("when"), s)]
            if not opts:
                break
            rng = (rng * 48271) % 2147483647
            c = opts[rng % len(opts)]
            trail.append(c["id"])
            s = apply(spec, s, c, HI_OK)
        out.append(trail)
    return out


def replay(spec: dict, path: list[str], hi: int) -> dict:
    s = init_state(spec)
    for cid in path:
        s = apply(spec, s, choice_of(spec, s, cid), hi)
    return s


def first_divergence(spec: dict, paths: list[list[str]]) -> dict | None:
    for i, p in enumerate(paths):
        a, b = init_state(spec), init_state(spec)
        for j, cid in enumerate(p):
            a = apply(spec, a, choice_of(spec, a, cid), HI_OK)
            b = apply(spec, b, choice_of(spec, b, cid), HI_BAD)
            if canonical(a) != canonical(b):
                field = next(k for k in sorted(a["aff"]) if a["aff"][k] != b["aff"][k])
                return {"path": i, "step": j, "choice": cid, "field": field,
                        "hi100": a["aff"][field], "hi99": b["aff"][field],
                        "digest100": fnv1a(canonical(a).encode()), "digest99": fnv1a(canonical(b).encode())}
    return None


def main() -> int:
    spec = load_spec()
    paths = sample_paths(spec, N, SEED)
    diverged = sum(1 for p in paths
                   if fnv1a(canonical(replay(spec, p, HI_OK)).encode())
                   != fnv1a(canonical(replay(spec, p, HI_BAD)).encode()))
    first = first_divergence(spec, paths)

    vals = [v for p in paths for v in replay(spec, p, HI_OK)["aff"].values()]
    asc = desc = 0.0
    for v in sorted(vals):
        asc += 1.0 / (v + 1)
    for v in reversed(sorted(vals)):
        desc += 1.0 / (v + 1)
    float_residual = abs(asc - desc)

    table = [
        {"class": "整数状态(好感度 / flag / item / 节点 id / 路径)", "criterion": "canonical 串逐位比对",
         "bitwise": True, "residual": 0},
        {"class": "对象键序", "criterion": "canonical 按键名排序后比对", "bitwise": False, "residual": 0},
        {"class": "浮点派生量(示例:调和和)", "criterion": "声明容差 1e-9", "bitwise": False,
         "residual": float_residual},
        {"class": "文本渲染", "criterion": "不进摘要、不比对", "bitwise": False, "residual": None},
        {"class": "时间戳", "criterion": "不进状态、不比对", "bitwise": False, "residual": None},
    ]

    print(f"spec={spec['meta']['title']}  采样 n={N}  seed={SEED}")
    print(f"负例 · 钳位上限 {HI_OK} → {HI_BAD}:{N} 条路径里 {diverged} 条终局摘要改变(其余落在未触顶路径上)")
    if first:
        print(f"首个分歧点:路径 #{first['path']} 第 {first['step']} 步 选项 {first['choice']}  "
              f"{first['field']}={first['hi100']}(上限 100) vs {first['field']}={first['hi99']}(上限 99)")
        print(f"          该步摘要 {first['digest100']} → {first['digest99']}(必须不同)")
    print(f"容差实证 · 浮点派生量(调和和,升序 vs 降序):{asc!r} vs {desc!r}  残差 {float_residual:.3e}")
    print("容差表:")
    for t in table:
        r = "不适用" if t["residual"] is None else f"{t['residual']:.3e}" if isinstance(t["residual"], float) else str(t["residual"])
        print(f"  {t['class']:<32} 必须逐位={'是' if t['bitwise'] else '否':<3} 残差 {r}")

    assert diverged > 0 and first is not None, "负例未造成任何偏差"
    assert float_residual > 0, "浮点残差应为非零"
    os.makedirs(OUT, exist_ok=True)
    with open(JSON_PATH, "w", encoding="utf-8") as fh:
        json.dump({"n": N, "seed": SEED, "hi_ok": HI_OK, "hi_bad": HI_BAD,
                   "diverged_paths": diverged, "first_divergence": first,
                   "float_residual": float_residual, "tolerance": table}, fh,
                  ensure_ascii=False, indent=2)
        fh.write("\n")
    print("wrote out/ne_13_3_tolerance.json")
    print("OK ne_13_3_tolerance:负例造成偏差,首个分歧点已定位,容差表已落盘")
    return 0


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

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

spec=雾港夜航  采样 n=500  seed=20261016
负例 · 钳位上限 100 → 99:500 条路径里 12 条终局摘要改变(其余落在未触顶路径上)
首个分歧点:路径 #13 第 20 步 选项 c_fg_1a  shen=100(上限 100) vs shen=99(上限 99)
          该步摘要 2034325278 → 3825501945(必须不同)
容差实证 · 浮点派生量(调和和,升序 vs 降序):46.86073654770573 vs 46.860736547704974  残差 7.532e-13
容差表:
  整数状态(好感度 / flag / item / 节点 id / 路径) 必须逐位=是   残差 0
  对象键序                             必须逐位=否   残差 0
  浮点派生量(示例:调和和)                    必须逐位=否   残差 7.532e-13
  文本渲染                             必须逐位=否   残差 不适用
  时间戳                              必须逐位=否   残差 不适用
wrote out/ne_13_3_tolerance.json
OK ne_13_3_tolerance:负例造成偏差,首个分歧点已定位,容差表已落盘

P 层:TS 侧重放负例并定位同一分歧点

门禁在 TS 侧用同样的钳位扰动重放 500 条路径:正向对照(100 对 100)必须零分歧,负例(100 对 99)必须命中同一批 12 条路径、同一个首个分歧点。

// file: core/test/ne_13_3_tolerance.test.ts
// tier: P
// verify: node --test 'core/test/ne_13_3_tolerance.test.ts'
// 卷二 13.3:故意引入偏差(钳位上限 99),断言摘要必须改变,并定位首个分歧点;
// 正向对照(hi=100 对 hi=100)必须零分歧。只对整数状态写逐位一致。

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 { digest, samplePaths } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { firstDivergence, replayClamped } from "../src/inkwell/ne_13_3_tolerance.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 paths = samplePaths(spec, 500, 20261016);
const py = JSON.parse(readFileSync(join(ROOT, "out/ne_13_3_tolerance.json"), "utf8")) as {
  diverged_paths: number;
  first_divergence: { path: number; step: number; choice: string; field: string; hi100: number; hi99: number };
  float_residual: number;
};

test("负例 · 钳位上限 99:12 / 500 条路径的终局摘要改变", () => {
  let diverged = 0;
  for (const p of paths) {
    if (digest(replayClamped(spec, p, 100)) !== digest(replayClamped(spec, p, 99))) diverged += 1;
  }
  assert.equal(diverged, 12);
  assert.equal(diverged, py.diverged_paths);
  console.log(`钳位 100 → 99:${diverged} / ${paths.length} 条摘要改变`);
});

test("正向对照 · 钳位 100 对 100:零分歧(残差恒为 0)", () => {
  let diverged = 0;
  for (const p of paths) {
    if (digest(replayClamped(spec, p, 100)) !== digest(replayClamped(spec, p, 100))) diverged += 1;
  }
  assert.equal(diverged, 0);
});

test("首个分歧点:路径 #13 第 20 步 c_fg_1a,shen 100 vs 99,摘要必须不同", () => {
  const d = firstDivergence(spec, paths, 99);
  assert.ok(d);
  assert.equal(d.path, 13);
  assert.equal(d.step, 20);
  assert.equal(d.choice, "c_fg_1a");
  assert.equal(d.field, "shen");
  assert.equal(d.hi100, 100);
  assert.equal(d.hiBad, 99);
  assert.notEqual(d.digest100, d.digestBad);
  assert.equal(d.path, py.first_divergence.path);
  assert.equal(d.step, py.first_divergence.step);
  assert.equal(d.choice, py.first_divergence.choice);
  console.log(`首个分歧点 #${d.path}/${d.step} ${d.choice} ${d.field} ${d.hi100}→${d.hiBad}:摘要 ${d.digest100} → ${d.digestBad}`);
});

test("容差表 · 浮点派生量按声明容差断言(残差 7.5e-13 < 1e-9)", () => {
  assert.ok(py.float_residual > 0);
  assert.ok(py.float_residual < 1e-9);
  console.log(`浮点派生量残差 ${py.float_residual.toExponential(3)} < 声明容差 1e-9`);
});

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

钳位 100 → 99:12 / 500 条摘要改变
首个分歧点 #13/20 c_fg_1a shen 100→99:摘要 2034325278 → 3825501945
浮点派生量残差 7.532e-13 < 声明容差 1e-9
✔ 负例 · 钳位上限 99:12 / 500 条路径的终局摘要改变 (24.161958ms)
✔ 正向对照 · 钳位 100 对 100:零分歧(残差恒为 0) (14.452125ms)
✔ 首个分歧点:路径 #13 第 20 步 c_fg_1a,shen 100 vs 99,摘要必须不同 (2.251041ms)
✔ 容差表 · 浮点派生量按声明容差断言(残差 7.5e-13 < 1e-9) (0.09875ms)
ℹ tests 4
ℹ pass 4
ℹ fail 0

判读:偏差有大小,容差有上界

「12 / 500 条摘要改变」:一个整数(钳位上限 100 → 99)就让 12 条路径的终局摘要变了。其余 488 条没变,是因为那些路径的好感度从未触顶——偏差只在触顶的地方显形。这说明摘要不是「一改就全变」的敏感噪声,而是精确地指出哪些路径受影响。

「首个分歧点 #13 第 20 步 c_fg_1a,shen 100 vs 99」:第 13 条路径走到第 20 步时,shen 被钳到 100;改成 99 后,这一步的 canonical 串第一次不同,摘要从 2034325278 变成 3825501945。首个分歧点给出了偏差的坐标——哪条路径、哪一步、哪个字段、差多少。

「正向对照零分歧」:把两侧都设成 100,0 条路径有差异。这条对照排除了「摘要本来就是随机噪声」的可能——差异只来自那个被改动的整数。

「浮点残差 7.532e-13 < 1e-9」:同一批好感度值的调和和,升序累加与降序累加在双精度下末几位不同。这不是缺陷,是浮点的本性;容差表因此不把浮点量列为逐位判据,而是声明一个上界(1e-9),实测残差 7.532e-13 落在界内。

观察数字读法
负例受影响的路径12 / 500偏差只在触顶处显形
正向对照分歧0 / 500差异只来自被改动的整数
首个分歧点#13 / 第 20 步 / shen偏差有精确坐标
浮点派生量残差7.532e-13落在声明容差 1e-9 内

自检表

检查项合格标准
两类量分明整数状态逐位;浮点 / 键序 / 文本 / 时间戳按声明方式
负例必给钳位 100 → 99,摘要必须改变
分歧点可定位报出路径 / 步 / 选项 / 字段 / 值
正向对照100 对 100 零分歧
容差有上界浮点派生量声明 1e-9,实测落在界内
口径纪律只对整数状态写「逐位一致」

第 13 章到此收束:摘要立住了(13.1)、三宿主对齐了(13.2)、偏差与容差的边界写死了(13.3)。下一章把这条一致性链变成可回归的门禁——属性测试、随机路径模糊测试与告警阈值。

阅读导航:上一节:13.2 同源不同宿主 · 下一节:14.1 属性测试与不变量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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