15.2 从规格到校验的流水线

从改规格到产物核对是一条五环流水线:结构校验与语义校验、规格解析、跨语言摘要、内核门禁、产物核对。本节给出逐条本机真跑过的命令,用一个 S 层驱动把五环串成一次执行,再用一个 P 层门禁断言这份报告不是过期快照,并给出各环的时间账与取舍口径。

15.2 从规格到校验的流水线

《游戏设计》卷一《玩法机制与可执行规格》 的流水线终点是守恒残差归零:机制仿真跑完,断言整数不变量没被破坏。本卷的流水线形状相同——真源进、产物出、最后一环核对——但终点换成了可达与覆盖:跑完之后要断言的是分支覆盖率满分、不可达节点为零、卡死路径为零。两套共享「命令可复现、数字来自实跑」的纪律,判据不通用。

五环流水线

一条改动要走到「可以提交」,必须依次穿过五环。每一环都有明确的输入、动作与唯一的失败信号:

环输入动作产物失败信号
1 结构 + 语义校验config/story_spec.json孤儿 / 断链 / 死条件 / 命名空间 / 可达性 BFSout/spec_check.jsonexit 非 0
2 规格解析与 SVG同一份规格十段字段解析 + 纯 xml.etree 出图out/tension_curve.svg 等回读断言失败
3 跨语言摘要(Python 侧)同一份规格采样 500 条路径,逐条算状态摘要out/digest_py.json好感度越界
4 内核门禁(TS 侧)规格 + 第 3 环产物用内核重放同 500 条路径,逐位比对无(断言即产物)node --test 有 fail
5 产物核对第 1、3 环产物复核覆盖率 / 不可达 / 卡死 / 行数out/pipeline_arch.json任一判据不成立

顺序不能颠倒。第 4 环依赖第 3 环的产物,第 5 环依赖第 1 与第 3 环的产物;把它们合成一次执行,才能保证「报告里的数字」与「产物里的数字」出自同一趟运行。

逐条命令

五环各自一条命令,末条是串起来一次跑完:

# snippet: 五环流水线逐条命令(在 $HOME/.inkwellbook/inkwell 下执行)
/opt/homebrew/bin/python3 sim/na_00_spec_check.py     # 1 结构 + 语义校验
/opt/homebrew/bin/python3 sim/na_01_spec_parse.py     # 2 规格解析与 SVG
/opt/homebrew/bin/python3 sim/na_02_digest.py         # 3 跨语言摘要(Python 侧)
node --test 'core/test/na_02_digest.test.ts'          # 4 内核门禁(TS 侧)
/opt/homebrew/bin/python3 sim/na_15_2_pipeline.py     # 1–5 一次跑完并核对产物

第 4 环必须写带引号的 glob。node --test <目录> 不会发现 .ts 文件,写目录等于零个测试通过——这是本套踩过两次的坑。第 3、4 环的顺序也不能换:内核门禁读的是第 3 环落下的 out/digest_py.json,文件不存在时它会直接报「先跑 Python 侧」。

S 层:把五环串成一次执行

下面这个驱动把四环当子进程跑一遍、记录退出码与耗时,再在进程内做第 5 环的产物核对,把整趟结果落成 out/pipeline_arch.json。它是流水线唯一的「一次执行」入口——五条命令手工跑一遍和跑它一遍,得到的产物应当相同。

#!/usr/bin/env python3
# file: sim/na_15_2_pipeline.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_15_2_pipeline.py
"""卷一 15.2(S 层):把五环流水线真跑一遍,落 out/pipeline_arch.json 供 P 层门禁断言。

1 结构校验 + 语义校验   sim/na_00_spec_check.py
2 规格解析与 SVG        sim/na_01_spec_parse.py
3 跨语言摘要(Python 侧)sim/na_02_digest.py
4 内核门禁(TS 侧)      node --test core/test/na_02_digest.test.ts
5 产物核对              读 out/spec_check.json 与 out/digest_py.json
任一环非零退出即整体非零退出。
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
PY = "/opt/homebrew/bin/python3"
STAGES = [
    ("结构校验 + 语义校验", [PY, "sim/na_00_spec_check.py"]),
    ("规格解析与 SVG", [PY, "sim/na_01_spec_parse.py"]),
    ("跨语言摘要(Python 侧)", [PY, "sim/na_02_digest.py"]),
    ("内核门禁(TS 侧)", ["node", "--test", "core/test/na_02_digest.test.ts"]),
]


def load(rel: str) -> dict | list:
    with open(os.path.join(ROOT, rel), encoding="utf-8") as fh:
        return json.load(fh)


def main() -> int:
    stages: list[dict] = []
    failed = False
    total = len(STAGES) + 1  # 末环是进程内产物核对
    for i, (name, cmd) in enumerate(STAGES, 1):
        t0 = time.time()
        p = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True)
        dt = time.time() - t0
        lines = [ln for ln in (p.stdout or "").splitlines() if ln.strip()]
        stages.append({"n": i, "name": name, "cmd": " ".join(cmd), "exit": p.returncode,
                       "seconds": round(dt, 1), "last": lines[-1] if lines else ""})
        print(f"[{i}/{total}] {name:<22} exit={p.returncode}  {dt:5.1f}s  {(lines[-1] if lines else '')[:64]}")
        failed = failed or p.returncode != 0

    sc, dp = load("out/spec_check.json"), load("out/digest_py.json")
    checks = {
        "coverage_permille": sc["coverage_permille"],
        "visited_edges": sc["visited_edges"],
        "all_edges": sc["all_edges"],
        "unreachable_nodes": len(sc["unreachable_nodes"]),
        "unreachable_choices": len(sc["unreachable_choices"]),
        "dead_ends": sc["dead_ends"],
        "sample_n": sc["sample_n"],
        "digest_rows": len(dp),
        "digest_first3": [r["digest"] for r in dp[:3]],
    }
    print(f"[{total}/{total}] 产物核对")
    print(f"  分支覆盖率 {checks['coverage_permille']}‰({checks['visited_edges']}/{checks['all_edges']} 条对话边)")
    print(f"  不可达节点 {checks['unreachable_nodes']}、不可达选项 {checks['unreachable_choices']}、卡死 {checks['dead_ends']}")
    print(f"  digest_py 行数 {checks['digest_rows']},前 3 条摘要 {checks['digest_first3']}")

    ok = (not failed and checks["coverage_permille"] == 1000 and checks["unreachable_nodes"] == 0
          and checks["unreachable_choices"] == 0 and checks["dead_ends"] == 0)
    out = os.path.join(ROOT, "out/pipeline_arch.json")
    os.makedirs(os.path.dirname(out), exist_ok=True)
    with open(out, "w", encoding="utf-8") as fh:
        json.dump({"ok": ok, "stages": stages, "checks": checks}, fh, ensure_ascii=False, indent=2)
        fh.write("\n")
    if not ok:
        print("FAIL 流水线未全绿")
        return 1
    print("OK na_15_2_pipeline:5 个环节全部 exit 0,产物核对通过")
    return 0


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

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

[1/5] 结构校验 + 语义校验            exit=0   94.6s  OK na_00_spec_check:规格自洽
[2/5] 规格解析与 SVG              exit=0    0.3s  OK na_01_spec_parse:九段字段全部解析,2 张 SVG 回读通过,整数不变量通过
[3/5] 跨语言摘要(Python 侧)        exit=0    0.4s  OK na_02_digest.py:500 条路径,不变量通过,写出 out/digest_py.json
[4/5] 内核门禁(TS 侧)             exit=0    0.7s  ℹ duration_ms 482.055834
[5/5] 产物核对
  分支覆盖率 1000‰(115/115 条对话边)
  不可达节点 0、不可达选项 0、卡死 0
  digest_py 行数 500,前 3 条摘要 [3976226298, 3848604931, 1198029437]
OK na_15_2_pipeline:5 个环节全部 exit 0,产物核对通过

时间账值得看一眼:第 1 环 94.6 秒,其余三环合计 1.4 秒。整条流水线的成本几乎全在 BFS 可达性枚举上——这是「可穷举但非平凡」的代价,也是为什么本案例的规模是硬约束(放大一档,第 1 环从分钟级变成小时级)。这个秒数会随机器负载波动,但量级关系稳定。

P 层:把最后一环写成门禁

第 5 环的核对目前是驱动脚本里的几行断言。它有一个薄弱处:只要有人手工改过 out/spec_check.json,或者忘了重跑流水线,这份报告就会变成过期快照。下面的 node --test 门禁把「报告是不是过期」变成可判定的:它不信任报告,而是重新从原始产物派生一遍数字再比对。

// file: core/test/na_15_2_pipeline_gate.test.ts
// tier: P
// verify: node --test 'core/test/na_15_2_pipeline_gate.test.ts'
// 流水线最后一环的门禁:断言 out/pipeline_arch.json 不是一份过期报告——
// 把 manifest 里声称的数字,重新从原始产物派生一遍再比对。

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync, existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";

import { replay, digest, samplePaths } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const readJson = (rel: string) => JSON.parse(readFileSync(join(ROOT, rel), "utf8"));

test("流水线报告存在,且四个环节全部 exit 0", () => {
  assert.ok(existsSync(join(ROOT, "out/pipeline_arch.json")), "先跑 /opt/homebrew/bin/python3 sim/na_15_2_pipeline.py");
  const m = readJson("out/pipeline_arch.json") as { ok: boolean; stages: { n: number; exit: number }[] };
  assert.equal(m.stages.length, 4);
  for (const s of m.stages) assert.equal(s.exit, 0, `第 ${s.n} 环退出码非 0`);
  assert.equal(m.ok, true);
});

test("报告不是过期快照:与原始产物逐项一致", () => {
  const m = readJson("out/pipeline_arch.json") as { checks: Record<string, number> };
  const sc = readJson("out/spec_check.json") as Record<string, unknown> & { unreachable_nodes: string[]; unreachable_choices: string[] };
  assert.equal(m.checks.coverage_permille, sc.coverage_permille);
  assert.equal(m.checks.visited_edges, sc.visited_edges);
  assert.equal(m.checks.all_edges, sc.all_edges);
  assert.equal(m.checks.unreachable_nodes, sc.unreachable_nodes.length);
  assert.equal(m.checks.unreachable_choices, sc.unreachable_choices.length);
  assert.equal(m.checks.dead_ends, sc.dead_ends);
  assert.equal(m.checks.sample_n, sc.sample_n);
});

test("门禁判据:覆盖率满分、零不可达、零卡死", () => {
  const sc = readJson("out/spec_check.json") as Record<string, unknown>;
  assert.equal(sc.coverage_permille, 1000);
  assert.equal(sc.visited_edges, sc.all_edges);
  assert.deepEqual(sc.unreachable_nodes, []);
  assert.deepEqual(sc.unreachable_choices, []);
  assert.equal(sc.dead_ends, 0);
});

test("最后一环:内核重放前 3 条路径,摘要与 Python 侧产物一致", () => {
  const m = readJson("out/pipeline_arch.json") as { checks: { digest_rows: number; digest_first3: number[] } };
  const dp = readJson("out/digest_py.json") as { choices: string[]; digest: number }[];
  assert.equal(m.checks.digest_rows, dp.length);
  assert.deepEqual(m.checks.digest_first3, dp.slice(0, 3).map((r) => r.digest));
  const spec = readJson("config/story_spec.json") as Spec;
  for (let i = 0; i < 3; i += 1) assert.equal(digest(replay(spec, dp[i].choices)), dp[i].digest, `第 ${i} 条摘要不一致`);
  // 采样器也要与 Python 侧同序:前 3 条选择序列一致。
  const ts = samplePaths(spec, dp.length, 20261016);
  for (let i = 0; i < 3; i += 1) assert.deepEqual(ts[i], dp[i].choices, `第 ${i} 条选择序列不一致`);
});

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

✔ 流水线报告存在,且四个环节全部 exit 0 (2.477416ms)
✔ 报告不是过期快照:与原始产物逐项一致 (1.171417ms)
✔ 门禁判据:覆盖率满分、零不可达、零卡死 (0.889083ms)
✔ 最后一环:内核重放前 3 条路径,摘要与 Python 侧产物一致 (24.5255ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 401.844042

判读:三条读数

第一,最后一环是唯一能抓「忘了重跑」的地方。 前四环各自只保证自己那一步对;只有第 5 环同时读两份产物,才能发现「规格改了、报告没更新」。第 4 条测试更进一步:它不信报告,直接用内核重放前 3 条路径,拿摘要和 Python 侧产物比。摘要一致说明两条独立实现的推进结果相同——这是整数不变量之外,本套唯一有意义的跨语言判据。

第二,覆盖率满分不代表分支设计得好。 流水线跑出 115/115 条对话边全部可达、不可达节点为零。这个读数只说明这张图没有孤儿,不说明分支有趣、平衡或值得玩。覆盖率是结构量,不是质量量;把它当质量指标用,就是把「没有断头路」当成「剧情写得好」。

第三,耗时集中在第 1 环,因此流水线要按环取舍。 改一句台词只需要跑第 1 环;改一个 add 的增量必须跑到第 4 环(状态变了,摘要就变了);改一个 when 条件则必须五环全跑。判断该跑几环,靠的是 15.1 那张影响面表——影响面越宽,要跑的环越多。

自检表

检查项合格标准
命令可复制每条命令都能原样贴进终端跑通
glob 带引号node --test 一律写全路径或带引号的 glob
顺序有依赖内核门禁读 Python 侧产物,先 3 后 4
最后一环不信任报告从原始产物重新派生数字再比对
秒数不硬编码时间账说明量级关系,不把秒数当验收阈值

流水线保证「规格改了、产物跟着变」。但规格的改动从哪来、谁批、什么时候允许合入,是流程问题,不是脚本问题。下一节处理这部分,并把本机跑不了的那一半明确标出来。

阅读导航:上一节:15.1 规格、仿真、内核、原型的目录分工 · 下一节:15.3 协作与版本管理 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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