16.3 交付与后续

本卷交付四类东西:真源与内核、度量工具链(S/P/W/B 共九件脚本)、可复算产物、六项代理指标口径。给出接手五步与「本套不做」清单——真人玩家测试、真实配音成本等需真实业务环境,命令行不可复现,逐处标 X,并列出后续可做的三个方向(多规格回归 / 度量反演 / 真人数据接入)。

16.3 交付与后续

独立游戏的叙事设计与分支对话 交的是散文经验——分支怎么写、好感度怎么调,代码不可跑;本卷交的是可算的度量:一份真源、一个确定性内核、一组可复算的产物、六项代理指标的口径。三卷收官,本节把交付什么、怎么接手、不做什么讲清楚。交付不是把文件打包过去,而是把接口说清楚:接手的人要读哪些段、跑哪些命令、拿哪些产物当回归基准、按什么判据验收,以及本卷没有做什么。

交付清单

四类东西:真源与内核(数据 + 代码)、度量工具链(S / P / W / B 九件)、可复算产物(out/ 下的 JSON)、口径声明(六项代理指标的定义与阈值)。每一项都要在磁盘上找得到,缺一即 FAIL 并非零退出:

#!/usr/bin/env python3
# file: sim/nm_16_3_deliverable.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_16_3_deliverable.py
"""卷三 16.3 交付与后续(S 层):把本卷的交付物整理成清单并逐项在磁盘上自检。

三卷收官,交付的是四类东西:**真源与内核**(数据 + 代码)、**度量工具链**(S / P / W 三层
脚本)、**可复算产物**(out/ 下的 JSON)、**口径声明**(六项代理指标的定义与阈值)。
每一项都要在磁盘上找得到,缺一即 FAIL 并非零退出。清单同时给出「接手五步」与
「本套不做的事」——后者逐处标 X(需真实业务环境,命令行不可复现)。
"""
from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)

FOUNDATION = [
    ("真源", "config/story_spec.json"),
    ("内核", "core/src/inkwell/engine.ts"),
    ("打包器", "web/pack.mjs"),
]
TOOLCHAIN = [
    ("S", "sim/nm_16_common.py"),
    ("S", "sim/nm_16_1_checklist.py"),
    ("S", "sim/nm_16_2_fullchain.py"),
    ("S", "sim/nm_16_3_deliverable.py"),
    ("P", "core/src/inkwell/nm_16_2_fullchain.ts"),
    ("P", "core/test/nm_16_2_fullchain.test.ts"),
    ("W", "web/build_nm16.mjs"),
    ("W", "web/verify_nm16.mjs"),
    ("B", "sim/nm_16_3_perturb.py"),
]
ARTIFACTS = [
    ("S", "out/nm_16_1_checklist.json"),
    ("S", "out/nm_16_2_fullchain.json"),
    ("W", "out/nm_16_2_w_fullchain.json"),
    ("B", "out/nm_16_3_perturb.json"),
]
VOL2_INTERFACE = [
    ("联调", "out/ne_16_2_integration.json"),
    ("可达", "out/ne_05_2_reachable.json"),
    ("锚点", "out/ne_01_3_py.json"),
]
SIX_METRICS = [
    ("分支覆盖率", "走过的去重对话边 × 1000 // 去重边总数", "‰", "≥ 850"),
    ("结局分布熵", "-Σ p·log2(p) × 1000 取整", "毫比特", "≥ 1500"),
    ("张力曲线 MAE", "Σ|实际 tension - 目标| × 1000 // 节拍数", "毫", "≤ 1000"),
    ("好感度曲线 MAE", "Σ|夹取后 - 未夹取| × 1000 // 样本数", "毫", "≤ 12000"),
    ("一致性残差", "声明-行为失配计数(D + K + S + R)", "计数", "proxy,只做相对比较"),
    ("整数不变量残差", "好感度越界 + tension 越界计数", "计数", "恒为 0"),
]
NOT_DONE = [
    ("真人玩家测试", "需真实业务环境,命令行不可复现"),
    ("真实译文与配音成本", "需真实业务环境,命令行不可复现"),
    ("真实团队评审流程", "需真实业务环境,命令行不可复现"),
    ("叙事文本创作质量", "划界到《孢子三部曲创作手记》"),
]
HANDOVER = [
    "读 config/story_spec.json,用 engine.ts 的 initState 建初始状态,确认起点 meta.start。",
    "跑 sim/nm_16_1_checklist.py,17 条装配断言全 PASS 再往下。",
    "跑 sim/nm_16_2_fullchain.py 生成 out/nm_16_2_fullchain.json(含锚点摘要与包含关系)。",
    "跑 node --test 'core/test/nm_16_2_fullchain.test.ts' 与 web/verify_nm16.mjs,三处摘要逐位相同。",
    "只有在以上全绿之后,才改规格或节拍标注——改一处要同时重跑三处消费。",
]


def main() -> int:
    spec = json.load(open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8"))
    m = spec["metrics"]

    def exist(rel):
        return os.path.exists(os.path.join(ROOT, rel))

    groups = [("真源与内核", FOUNDATION), ("度量工具链", TOOLCHAIN),
              ("可复算产物", ARTIFACTS), ("卷二接口", VOL2_INTERFACE)]
    fails = []
    print("交付清单(卷三《叙事体验与路径度量》· 三卷收官)")
    total_ok = total = 0
    for name, items in groups:
        ok = [rel for _, rel in items if exist(rel)]
        total_ok += len(ok)
        total += len(items)
        print(f"· {name}  {len(ok)}/{len(items)}")
        for kind, rel in items:
            mark = "OK  " if exist(rel) else "MISS"
            sz = os.path.getsize(os.path.join(ROOT, rel)) if exist(rel) else 0
            print(f"    {mark} [{kind}] {rel:<38} {sz} B")
        miss = [rel for _, rel in items if not exist(rel)]
        if miss:
            fails.append(f"{name}缺 {miss}")

    print(f"· 六项代理指标(口径声明)  {len(SIX_METRICS)}/6")
    for nm, formula, unit, thresh in SIX_METRICS:
        print(f"    {nm:<14} {formula:<34} 单位 {unit:<6} 判据 {thresh}")
    print(f"· 阈值来源 config/story_spec.json#metrics  覆盖率 {m['coverage_target_permille']}‰ · "
          f"熵 {m['ending_entropy_min_milli_bits']} · 好感度 MAE {m['affinity_mae_max']} · 张力 MAE {m['tension_mae_max']}")
    print(f"· 本套不做(标 X)  {len(NOT_DONE)} 项")
    for what, why in NOT_DONE:
        print(f"    X {what:<16} {why}")
    print(f"交付自检 {'PASS' if not fails else 'FAIL'}({total_ok}/{total})")
    for f in fails:
        print(f"  FAIL {f}")

    report = {"foundation": FOUNDATION, "toolchain": TOOLCHAIN, "artifacts": ARTIFACTS,
              "vol2_interface": VOL2_INTERFACE,
              "six_metrics": [{"name": n, "formula": f, "unit": u, "criterion": c}
                              for n, f, u, c in SIX_METRICS],
              "not_done": [{"item": w, "why": y} for w, y in NOT_DONE],
              "handover": HANDOVER, "checked": total, "present": total_ok,
              "thresholds": {"coverage_target_permille": m["coverage_target_permille"],
                             "ending_entropy_min_milli_bits": m["ending_entropy_min_milli_bits"],
                             "affinity_mae_max": m["affinity_mae_max"],
                             "tension_mae_max": m["tension_mae_max"]}}
    with open(os.path.join(ROOT, "out/nm_16_3_deliverable.json"), "w", encoding="utf-8") as fh:
        json.dump(report, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_16_3_deliverable.json")
    print("OK nm_16_3_deliverable:四类交付物 + 六项指标口径 + 不做清单已落盘")
    return 0 if not fails else 1


if __name__ == "__main__":
    sys.exit(main())
$ cd /Users/bingrong.yan/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/nm_16_3_deliverable.py
交付清单(卷三《叙事体验与路径度量》· 三卷收官)
· 真源与内核  3/3
    OK   [真源] config/story_spec.json                 38200 B
    OK   [内核] core/src/inkwell/engine.ts             6286 B
    OK   [打包器] web/pack.mjs                           3663 B
· 度量工具链  9/9
    OK   [S] sim/nm_16_common.py                    10873 B
    OK   [S] sim/nm_16_1_checklist.py               7714 B
    OK   [S] sim/nm_16_2_fullchain.py               4181 B
    OK   [S] sim/nm_16_3_deliverable.py             6099 B
    OK   [P] core/src/inkwell/nm_16_2_fullchain.ts  2586 B
    OK   [P] core/test/nm_16_2_fullchain.test.ts    3290 B
    OK   [W] web/build_nm16.mjs                     4207 B
    OK   [W] web/verify_nm16.mjs                    4238 B
    OK   [B] sim/nm_16_3_perturb.py                 4204 B
· 可复算产物  4/4
    OK   [S] out/nm_16_1_checklist.json             2431 B
    OK   [S] out/nm_16_2_fullchain.json             1322 B
    OK   [W] out/nm_16_2_w_fullchain.json           334 B
    OK   [B] out/nm_16_3_perturb.json               1341 B
· 卷二接口  3/3
    OK   [联调] out/ne_16_2_integration.json           1711 B
    OK   [可达] out/ne_05_2_reachable.json             3112 B
    OK   [锚点] out/ne_01_3_py.json                    2999 B
· 六项代理指标(口径声明)  6/6
    分支覆盖率          走过的去重对话边 × 1000 // 去重边总数           单位 ‰      判据 ≥ 850
    结局分布熵          -Σ p·log2(p) × 1000 取整             单位 毫比特    判据 ≥ 1500
    张力曲线 MAE       Σ|实际 tension - 目标| × 1000 // 节拍数   单位 毫      判据 ≤ 1000
    好感度曲线 MAE      Σ|夹取后 - 未夹取| × 1000 // 样本数         单位 毫      判据 ≤ 12000
    一致性残差          声明-行为失配计数(D + K + S + R)           单位 计数     判据 proxy,只做相对比较
    整数不变量残差        好感度越界 + tension 越界计数               单位 计数     判据 恒为 0
· 阈值来源 config/story_spec.json#metrics  覆盖率 850‰ · 熵 1500 · 好感度 MAE 12 · 张力 MAE 1
· 本套不做(标 X)  4 项
    X 真人玩家测试           需真实业务环境,命令行不可复现
    X 真实译文与配音成本        需真实业务环境,命令行不可复现
    X 真实团队评审流程         需真实业务环境,命令行不可复现
    X 叙事文本创作质量         划界到《孢子三部曲创作手记》
交付自检 PASS(19/19)
wrote out/nm_16_3_deliverable.json
OK nm_16_3_deliverable:四类交付物 + 六项指标口径 + 不做清单已落盘

三卷怎么被人接手用

接手的人只要按顺序跑五步,就能把整套重新立起来:

  1. 读真源:config/story_spec.json 十段,用 engine.ts 的 initState 建初始状态,确认起点 meta.start。
  2. 跑装配:sim/nm_16_1_checklist.py,17 条断言全 PASS 再往下——装配不过就别谈度量。
  3. 跑联调:sim/nm_16_2_fullchain.py 生成 out/nm_16_2_fullchain.json(锚点摘要 + 包含关系)。
  4. 跑 P / W:node --test 'core/test/nm_16_2_fullchain.test.ts' 与 web/verify_nm16.mjs,三处摘要逐位相同。
  5. 才改规格:只有在以上全绿之后才改规格或节拍标注——改一处要同时重跑三处消费,否则三卷立刻不同源。接口也就三个:真源段(读什么)、内核函数(initState / condOk / applyEffects / canonical / digest)、产物基准(拿哪个 JSON 当回归对照)。换人换机器,跑出来还是同一个 4162796975。

哪些事本套不做

交付清单里那 4 条 X 不是谦虚,是划界:真人玩家测试需真实业务环境、命令行不可复现,本套量的是模拟路径的结构量,代理不了真人体验;真实译文与配音成本 / 真实团队评审流程同样需真实业务环境、命令行不可复现;叙事文本创作质量划界到《孢子三部曲创作手记》,本卷只量结构、不评文笔——四处均标 X。

六项指标全部是代理指标(proxy),只做相对比较:覆盖率 1000‰ 说的是边覆盖比例,不声称更好玩;结局熵 1714 毫比特是分布形状,不是「玩家惊喜度」;一致性残差 8 是失配计数,只能横向比。

后续可做的方向

这套东西是可扩展的,往下最自然的三个方向:

  • 多规格回归:把 story_spec.json 扩成一组变体,用 16.1 的检查表批量对账,看哪些规格会掉出阈值。
  • 度量反演:13 章做过「参数 → 曲线」的反演,可以扩成「曲线 → 参数」的搜索,自动找能同时满足覆盖率与熵的节拍标注。
  • 真人数据接入:一旦有真实玩家路径日志,把它喂进同一套采样器,代理指标与真实分布就能并排比——但这一步需真实业务环境,命令行不可复现,仍标 X。

B 层:交付口径的扰动扫描

交付的口径能不能扛住扰动?B 层扫两族:采样规模(覆盖率与熵随 N 收敛,小 N 必然低报)与节拍标注(张力曲线 MAE 随之抬起)。全程只发软告警、始终 exit 0——扰动不该让构建硬失败:

#!/usr/bin/env python3
# file: sim/nm_16_3_perturb.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/nm_16_3_perturb.py
"""卷三 16.3 交付与后续(B 层):交付口径的扰动扫描——采样规模与节拍标注。

两族扰动,全部只产出**软告警**,不硬失败(exit 0):

  采样规模扫描   N ∈ {20, 50, 100, 500, 2000, 10000, 20000}:覆盖率与结局熵随样本量收敛。
                 小 N 必然低报覆盖率与熵——这不是错误,是「分布量需要样本量」的证据。
  节拍标注扰动   把某几拍的 tension 挪动 ±1 / +2,张力曲线 MAE 立刻抬起——交付判据
                 (tension_mae_max)在规格演化时才有分辨力。

软告警口径:越界只记一条 WARN 并计数,脚本始终 exit 0。数字全部来自本机实跑。
"""
from __future__ import annotations

import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import nm_16_common as cm  # noqa: E402

ROOT = cm.ROOT
SEED = 20261016
SWEEP = [20, 50, 100, 500, 2000, 10000, 20000]


def main() -> int:
    spec = cm.load_spec()
    m = spec["metrics"]
    warns, rows = [], []

    print("采样规模扫描(seed=20261016)——覆盖率与结局熵随 N 收敛,小 N 低报是预期")
    print("   N      覆盖率‰   熵(毫比特)   结局数   判据")
    for n in SWEEP:
        r = cm.sample(spec, n, SEED)
        cov, ent, ends = r["coverage_permille"], r["entropy_milli"], len(r["dist"])
        rows.append({"n": n, "coverage_permille": cov,
                     "ending_entropy_milli_bits": ent, "endings_reached": ends})
        w = []
        if cov < m["coverage_target_permille"]:
            w.append(f"覆盖率<{m['coverage_target_permille']}‰")
        if ent < m["ending_entropy_min_milli_bits"]:
            w.append(f"熵<{m['ending_entropy_min_milli_bits']}")
        print(f"   {n:<6} {cov:<9} {ent:<11} {ends:<8} {'WARN ' + '/'.join(w) if w else 'OK'}")
        warns += [f"N={n} {x}" for x in w]

    print("节拍标注扰动——单拍微调 vs 系统性漂移,张力曲线 MAE 随之抬起(判据在演化时才有分辨力)")
    beats, target = spec["beats"], list(m["target_tension_curve"])
    base = sum(abs(b["tension"] - target[i]) for i, b in enumerate(beats)) * 1000 // len(beats)
    print(f"   基线 MAE   {base} 毫(阈值 {m['tension_mae_max'] * 1000} 毫)")
    probes = []
    for label, mutate in (("单拍 0 号 +1", [(0, 1)]), ("单拍 5 号 -1", [(5, -1)]),
                          ("全 16 拍 +1", [(i, 1) for i in range(len(beats))]),
                          ("全 16 拍 +2", [(i, 2) for i in range(len(beats))])):
        tb = list(target)
        for k, delta in mutate:
            tb[k] += delta
        mae = sum(abs(b["tension"] - tb[i]) for i, b in enumerate(beats)) * 1000 // len(beats)
        probes.append({"label": label, "mae_milli": mae})
        over = mae > m["tension_mae_max"] * 1000
        print(f"   {label:<14} MAE {mae:>5} 毫  {'WARN 超阈值' if over else 'OK'}")
        if over:
            warns.append(f"扰动 {label} MAE={mae} 超阈值")

    assert all(0 <= r["coverage_permille"] <= 1000 for r in rows), "覆盖率越界"
    assert all(r["ending_entropy_milli_bits"] >= 0 for r in rows), "熵为负"
    print(f"软告警合计 {len(warns)} 条(B 层不硬失败,exit 0)")

    out = {"seed": SEED, "sweep": rows, "base_mae_milli": base, "probes": probes,
           "warnings": warns, "warning_count": len(warns),
           "thresholds": {"coverage_target_permille": m["coverage_target_permille"],
                          "ending_entropy_min_milli_bits": m["ending_entropy_min_milli_bits"],
                          "tension_mae_max_milli": m["tension_mae_max"] * 1000}}
    with open(os.path.join(ROOT, "out/nm_16_3_perturb.json"), "w", encoding="utf-8") as fh:
        json.dump(out, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_16_3_perturb.json")
    print(f"OK nm_16_3_perturb:采样规模与节拍扰动扫描完成,软告警 {len(warns)} 条已报告")
    return 0


if __name__ == "__main__":
    sys.exit(main())
$ cd /Users/bingrong.yan/.inkwellbook/inkwell && /opt/homebrew/bin/python3 sim/nm_16_3_perturb.py
采样规模扫描(seed=20261016)——覆盖率与结局熵随 N 收敛,小 N 低报是预期
   N      覆盖率‰   熵(毫比特)   结局数   判据
   20     982       1416        4        WARN 熵<1500
   50     982       1756        4        OK
   100    982       1567        4        OK
   500    1000      1747        5        OK
   2000   1000      1721        5        OK
   10000  1000      1716        5        OK
   20000  1000      1714        5        OK
节拍标注扰动——单拍微调 vs 系统性漂移,张力曲线 MAE 随之抬起(判据在演化时才有分辨力)
   基线 MAE   0 毫(阈值 1000 毫)
   单拍 0 号 +1      MAE    62 毫  OK
   单拍 5 号 -1      MAE    62 毫  OK
   全 16 拍 +1      MAE  1000 毫  OK
   全 16 拍 +2      MAE  2000 毫  WARN 超阈值
软告警合计 2 条(B 层不硬失败,exit 0)
wrote out/nm_16_3_perturb.json
OK nm_16_3_perturb:采样规模与节拍扰动扫描完成,软告警 2 条已报告

扫描说明两件事:小 N 的熵 1416 低于阈值 1500,是样本量不足的低报,不是规格坏了(N ≥ 500 后稳定在 1714~1747);把 16 拍整体抬高 2,MAE 2000 越过阈值 1000——交付判据在规格演化时确实有分辨力。两条软告警都只报告、不硬失败。第 16 章至此闭环:16.1 把三卷装成 17 条断言的检查表,16.2 让三种消费跑同一份规格并证明互不矛盾,16.3 把交付物、接手接口与划界讲清楚——三卷收官。

阅读导航:上一节:16.2 三种消费的总联调 · 下一节:回到目录 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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