3.2 确定性与幂等

确定性是同一输入同一输出,幂等是重复应用结果不变,两者常被混为一谈。本节把状态推进拆成三条可断言判据:函数性、set 类算子幂等、add 故意不幂等,并说明全书那句「同输入确定性违例 0」指的是同一状态到同一后继状态,而不是「重复 add 不累加」。

3.2 确定性与幂等

Phaser 选择后果日志 记录的是「玩家选过什么、后果是什么」这条日志。本节问的是这条日志能被重放的前提:同一次写入,跑两遍会不会得到同一个状态?如果不会,日志就只能当描述,不能当判据。本节把「确定性」与「幂等」这两个常被混用的词拆开,各自给出可断言的整数判据。

这一节是本卷最容易被写糊的一节,因为它踩在两个近义词上。确定性(determinism)说「同一输入必得同一输出」;幂等(idempotence)说「同一操作重复应用,结果不变」。它们不是一回事,也不该用同一把尺子量。把这两件事混起来,轻则把一个正确实现当成 bug,重则把「同输入确定性违例 0」这句全书结论理解反。

旧稿曾把同输入确定性比较称为“状态推进幂等”,这一命名已在本节正文与代码注释中纠正;旧快照保留用于追溯,不作为数学上的幂等定义。真正的幂等判据是 apply(apply(s,e),e) == apply(s,e),仅对这里的 set/grant/revoke 类检验;add 保持累加,不能按幂等处理。

两个词,两件事

先把定义钉死:

词说的事本节判据本案例结果
确定性 / 函数性同一 (状态, 效果) 两次 → 同一后继状态两次应用的规范串逐位相同115/115 一致,违例 0
幂等(set 类)apply(apply(s,e),e) == apply(s,e)重复应用后规范串不变49 条,违例 0
幂等(add 类)同上,但故意不成立重复应用后规范串改变66 条全部累加

最关键的一句话:全书反复出现的「同输入确定性违例 0」,指的是第一行——同一状态 → 同一后继状态(函数性)。它不是在说「重复 add 不累加」。如果把它读成后者,就会以为 add 非幂等是个待修的缺陷;恰恰相反,add 非幂等是设计意图。下面三条判据逐一给证据。

判据①:函数性(同一输入 → 同一输出)

对全规格 115 条选项,各取其 effects,从同一个初始状态应用两次,比较两次结果的规范串。规范串由「节点 | 好感度 | 置位 flag | 持有物品 | 路径」拼成(与 engine.ts 的 canonical 同算法),所以「逐位相同」是可断言的字面相等,不是模糊的「看起来一样」。

结果 115/115 一致,违例 0。这条之所以成立,是因为效果应用是纯函数:applyEffects 返回一个新状态对象,不改动入参。本例的效果变换返回新状态、不改动入参;测试还固定规格和输入,才能对照同输入的后继状态。纯函数不等于幂等,也不能只凭“不改入参”就推断没有其他外部依赖。

判据②:set 类算子幂等

把不含 add 的选项挑出来,对每条做 apply(apply(s,e),e) 与 apply(s,e) 的比对。这类选项共 49 条,违例 0。原因在算子的语义:

  • set 是赋值:flags[k] = v。对同一个 k 赋同一个 v,赋多少次都一样。
  • grant 是置位:items[k] = v(本案例恒为 1)。重复置位不变。
  • revoke 是清零:items[k] = 0。重复清零不变。

这三者都是「把某格设成某个定值」,与「当前值」无关,所以天然幂等。注意本案例 revoke 一次未用(3.1 节已报告),49 条里实际只出现 set 与 grant。

判据③:add 故意不幂等

含 add 的选项共 66 条,重复应用后规范串全部改变(66/66)。这不是缺陷,是累积量的本义:好感度是一个「越送礼越高」的数,重复一次写入就该再涨一次。举个最小例子:选项 c_pier_1b 的 add.hui = 2,hui 初值 50,应用一次得 52,应用两次得 54——每次恰好多 2。

如果硬把 add 改成幂等(比如把累加改成赋值),好感度就退化成「最后一次写入的值」,整条关系曲线、所有阈值门都会随之改变。所以「add 非幂等」不是要修的 bug,而是要守住的语义。

三条判据为什么必须分开

混用会带来两类具体错误:

  • 把「add 累加」误判成幂等违例 → 去改实现 → 好感度失去累积语义,全书的好感度章节全部失效。
  • 把「函数性」当成「幂等」→ 不敢在重放里重复应用效果 → 而确定性回放(第 4 章)恰恰依赖同一序列重复推进得到同一状态。

一句话:函数性管「输入定输出」,幂等管「重复定不变」。状态推进要的是前者(必须成立),外加后者对 set 类成立、对 add 明确不成立。

S 层:三条判据的静态验证

下面这段是 S 层脚本 sim/ne_03_2_determinism.py。它把三条判据各算成一个整数违例数,add 的非幂等则如实报成「改变条数」。

#!/usr/bin/env python3
# file: sim/ne_03_2_determinism.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ne_03_2_determinism.py
"""卷二 3.2 确定性与幂等(S 层,纯静态,不跑路径)。

把 3.1 的效果应用函数摊成三条可断言的整数判据:

  ① 函数性(同输入确定性):同一 (状态, 效果) 应用两次,
     得到的**规范串逐位相同**——即 apply 是函数,不是过程。违例恒为 0。
  ② set 类算子幂等:效果只含 set / grant / revoke(不含 add)的选项,
     `apply(apply(s,e),e) == apply(s,e)`。违例恒为 0。
  ③ add 故意不幂等:含 add 的选项重复应用会**累加**(好感度是累积量),
     所以同输入确定性不能理解成「重复 add 不累加」——这两件事必须分开。

canonical / digest 与 core/src/inkwell/engine.ts、sim/na_02_digest.py 同算法
(键名排序、k=v 逗号连接、只列置位 flag 与持有物品)。产出
out/ne_03_2_determinism.csv。整数口径:全部 int,禁浮点。
"""
from __future__ import annotations

import csv
import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CSV_PATH = os.path.join(OUT, "ne_03_2_determinism.csv")


def clamp(v: int) -> int:
    return AFF_LO if v < AFF_LO else AFF_HI if v > AFF_HI else v


def load_spec() -> dict:
    with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
        return json.load(fh)


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 apply_effects(eff: dict, s: dict) -> dict:
    """与 engine.ts applyEffects 逐句同构:纯函数,返回新状态,键序按字典序。"""
    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] = clamp(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": s["node"], "aff": aff, "flags": flags, "items": items, "path": s["path"]}


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 = load_spec()
    s0 = init_state(spec)
    choices = [c for n in spec["dialogue"] for c in (n.get("choices") or [])]
    print(f"spec={spec['meta']['title']}  选项 {len(choices)}")

    # ① 函数性:同一 (状态, 效果) 应用两次,规范串逐位相同
    func_bad = []
    for c in choices:
        e = c.get("effects") or {}
        a, b = apply_effects(e, s0), apply_effects(e, s0)
        if canonical(a) != canonical(b) or digest(a) != digest(b):
            func_bad.append(c["id"])
    print(f"① 函数性(同一 (状态, 效果) 两次 → 同一规范串):{len(choices)}/{len(choices)} 一致,违例 {len(func_bad)}")
    if func_bad:
        print(f"FAIL 函数性违例 {func_bad}")
        return 1

    # ② set 类算子幂等:只含 set / grant / revoke 的选项
    set_only = [c for c in choices if "add" not in (c.get("effects") or {})]
    idem_bad = []
    for c in set_only:
        e = c.get("effects") or {}
        once = apply_effects(e, s0)
        twice = apply_effects(e, once)
        if canonical(once) != canonical(twice):
            idem_bad.append(c["id"])
    print(f"② set/grant/revoke 类算子幂等(apply(apply(s,e),e)==apply(s,e)):"
          f"含 add 的选项 {len(choices) - len(set_only)} 条,其余 {len(set_only)} 条,违例 {len(idem_bad)}")
    if idem_bad:
        print(f"FAIL set 类算子幂等违例 {idem_bad}")
        return 1

    # ③ add 故意不幂等:含 add 的选项重复应用会累加
    add_choices = [c for c in choices if (c.get("effects") or {}).get("add")]
    non_idem = 0
    for c in add_choices:
        e = c.get("effects") or {}
        if canonical(apply_effects(e, s0)) != canonical(apply_effects(e, apply_effects(e, s0))):
            non_idem += 1
    print(f"③ add 故意不幂等:含 add 的选项 {len(add_choices)} 条,其中重复应用后规范串改变 {non_idem} 条"
          f"(好感度是累积量,非缺陷)")
    sample = add_choices[0]
    se = sample.get("effects") or {}
    once, twice = apply_effects(se, s0), apply_effects(se, apply_effects(se, s0))
    key = sorted(se.get("add"))[0]
    print(f"   例:选项 {sample['id']} 的 add={se.get('add')};一次后 {key}={once['aff'][key]},"
          f"两次后 {key}={twice['aff'][key]}(差值 {se['add'][key]})")

    # 整数不变量:好感度越界 0
    res = sum(1 for c in choices for k, v in ((c.get("effects") or {}).get("add") or {}).items()
              if not (AFF_LO <= clamp(s0["aff"].get(k, 0) + v) <= AFF_HI))
    print(f"I1 好感度越界 {res}")

    rows = [{"check": "functionality", "total": len(choices), "violations": len(func_bad)},
            {"check": "idempotent_set_ops", "total": len(set_only), "violations": len(idem_bad)},
            {"check": "add_non_idempotent", "total": len(add_choices), "violations": non_idem}]
    os.makedirs(OUT, exist_ok=True)
    with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["check", "total", "violations"])
        w.writeheader()
        w.writerows(rows)
    print(f"wrote out/ne_03_2_determinism.csv rows={len(rows)}")
    print("OK ne_03_2_determinism:函数性违例 0、set 类幂等违例 0、add 故意不幂等")
    return 0


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

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

spec=雾港夜航  选项 115
① 函数性(同一 (状态, 效果) 两次 → 同一规范串):115/115 一致,违例 0
② set/grant/revoke 类算子幂等(apply(apply(s,e),e)==apply(s,e)):含 add 的选项 66 条,其余 49 条,违例 0
③ add 故意不幂等:含 add 的选项 66 条,其中重复应用后规范串改变 66 条(好感度是累积量,非缺陷)
   例:选项 c_pier_1b 的 add={'hui': 2};一次后 hui=52,两次后 hui=54(差值 2)
I1 好感度越界 0
wrote out/ne_03_2_determinism.csv rows=3
OK ne_03_2_determinism:函数性违例 0、set 类幂等违例 0、add 故意不幂等

同一趟写出的 out/ne_03_2_determinism.csv 三行,列依次是 check、total、violations:functionality 115/0、idempotent_set_ops 49/0、add_non_idempotent 66/66。注意第三行的 violations=66 是预期值,不是错误——它正是「add 重复应用会累加」的条数。

P 层:把三条判据写成测试

P 层把同样的三条判据写成 node --test。分析逻辑在 core/src/inkwell/ne_03_2_determinism.ts 的 analyze,测试在 core/test/ne_03_2_determinism.test.ts——它直接复用 engine.ts 的 applyEffects / canonical / replay,不改动内核。

// file: core/test/ne_03_2_determinism.test.ts
// tier: P
// verify: node --test 'core/test/ne_03_2_determinism.test.ts'
// 3.2 确定性与幂等:三条判据分开断言,别把「函数性」与「add 累加」混为一谈。

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 { applyEffects, canonical, digest, initState, replay } from "../src/inkwell/engine.ts";
import type { Spec } from "../src/inkwell/engine.ts";
import { analyze } from "../src/inkwell/ne_03_2_determinism.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;

test("① 函数性:同一 (状态, 效果) 应用两次,规范串逐位相同,违例 0", () => {
  const r = analyze(spec);
  assert.deepEqual(r.functionalityViolations, []);
});

test("② set 类算子幂等:apply(apply(s,e),e) == apply(s,e),49 条违例 0", () => {
  const r = analyze(spec);
  assert.equal(r.setOpTotal, 49);
  assert.deepEqual(r.setOpViolations, []);
});

test("③ add 故意不幂等:66 条含 add 的选项重复应用全部累加", () => {
  const r = analyze(spec);
  assert.equal(r.addTotal, 66);
  assert.equal(r.addChanged, 66);
});

test("add 累加的具体证据:c_pier_1b 的 add.hui=2,一次后 52、两次后 54", () => {
  const start = spec.dialogue.find((n) => n.id === spec.meta.start)!;
  const c = start.choices.find((x) => x.id === "c_pier_1b")!;
  assert.equal(c.effects.add!.hui, 2);
  const s0 = initState(spec);
  assert.equal(s0.aff.hui, 50);
  const once = applyEffects(c.effects, s0);
  const twice = applyEffects(c.effects, once);
  assert.equal(once.aff.hui, 52);
  assert.equal(twice.aff.hui, 54);
});

test("函数性在真实推进上成立:同一选择序列两次 replay 摘要相同", () => {
  const seq = ["c_pier_1a", "c_pier_2a", "c_tavern_1a", "c_tavern_2a"];
  const a = replay(spec, seq);
  const b = replay(spec, seq);
  assert.equal(canonical(a), canonical(b));
  assert.equal(digest(a), digest(b));
  console.log(`replay ${seq.length} 步:摘要 ${digest(a)},两次一致`);
});

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

replay 4 步:摘要 649771119,两次一致
✔ ① 函数性:同一 (状态, 效果) 应用两次,规范串逐位相同,违例 0 (2.507042ms)
✔ ② set 类算子幂等:apply(apply(s,e),e) == apply(s,e),49 条违例 0 (1.376041ms)
✔ ③ add 故意不幂等:66 条含 add 的选项重复应用全部累加 (0.990792ms)
✔ add 累加的具体证据:c_pier_1b 的 add.hui=2,一次后 52、两次后 54 (0.141041ms)
✔ 函数性在真实推进上成立:同一选择序列两次 replay 摘要相同 (0.684542ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0

五条全过,fail 0。最后一条把「函数性」从单步推广到整条选择序列:同一序列 replay 两次,规范串与摘要都相同(本例 4 步摘要 649771119)。这正是第 4 章「确定性回放」要建立的地基——如果单步是纯函数,整条路径就是确定性的。

自检表

检查项合格标准
函数性同一 (状态, 效果) 两次 → 同一规范串,违例 0
set 类幂等apply(apply(s,e),e) == apply(s,e),违例 0
add 非幂等明确报成「预期改变」,不判 FAIL
口径不混用「同输入确定性违例 0」= 函数性,非「重复 add 不累加」
纯函数推进返回新对象,入参不被改写

确定性说清了单步与整条序列。但「状态变成了什么」与「相对上一步变了什么」是两件事——下一节把快照与差量分开算,并检查哪些字段写了却从没人读。

阅读导航:上一节:3.1 效果的应用 · 下一节:3.3 状态快照与差量 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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