6.2 目标曲线与 MAE

把 6.1 的曲线与真源 metrics.target_tension_curve 逐项比,按千分位整数地板除算 MAE(毫),阈值 1000 毫。真源里两条曲线当前逐项相同,故路径口径与全量口径的 MAE 都是 0;本节用反事实探针把目标挪动 1 得到 62 毫,证明这个 0 是自洽证据而非指标失效。

《游戏设计》卷一《玩法机制与可执行规格》 那套给指标定的是整数化 + 声明容差:任何派生量都按声明的容差断言,绝不比浮点。本章接住它的真源做叙事层的曲线度量——本节把 6.1 构造出的张力曲线与设计目标逐项比,得到的 MAE 也走同一条整数容差口径,单位是毫(千分位)。这一节要回答的核心问题是:这个 MAE 现在恒为 0,是指标没用,还是规格此刻自洽?

6.2 目标曲线与 MAE

真源里除了 beats[].tension(作者标注),还有一段 metrics.target_tension_curve(设计目标)——它是 16 个整数 1–5,长度与节拍数一一对应。MAE(平均绝对误差)量的是这两条曲线逐项偏离的平均值,公式是 MAE(毫) = sum(|实际曲线[i] − 目标曲线[i]|) × 1000 // n。

三个细节写死口径:分子先求和再乘 1000,最后一步才做整数地板除——这样单位是千分之一,1000 毫 恰好等于「平均偏离 1 个张力档」;// 是整数地板除,不出现浮点;阈值来自真源 metrics.tension_mae_max = 1,换算成毫就是 1000 毫。注意 1000 毫 这个换算不是随便定的:sum × 1000 // n 的物理含义就是「平均偏离 × 1000」,所以「平均偏离 1 档」正好等于 1000 毫,阈值 tension_mae_max = 1 与它一一对应。

两个口径:路径口径与全量口径

MAE 要算两次,因为「比哪些节拍」有两种合理口径:

口径比什么长度回答的问题
路径口径只比路径命中过的节拍11这一条路的曲线离目标多远
全量口径比全部 16 个节拍16整份规格的标注与目标差多少

路径口径是「这条路径的体验形状」,全量口径是「规格级的一致度」。两个都算,才能区分「路走得少」与「标注本身偏了」——单看路径口径,路走得越短、命中的节拍越少,MAE 越容易被少数几项左右。

S 层:逐项比 + 反事实探针

脚本先按两个口径各算一次 MAE,再做一次反事实探针:把目标曲线的第 1 项挪动 1,看 MAE 会不会抬起来。探针是本节的关键——它回答「这个 0 是不是指标失效」。

#!/usr/bin/env python3
# file: sim/nm_06_2_target_curve_mae.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_06_2_target_curve_mae.py
"""卷三 6.2 目标曲线与 MAE(S 层)。

把 6.1 构造出的「实际曲线」与真源里的设计目标 metrics.target_tension_curve
逐项比,按千分位整数地板除算 MAE(毫)。

两个口径都算:
- 路径口径:只比路径命中过的节拍(长度 = 覆盖节拍数);
- 全量口径:比全部 16 个节拍(规格级的整体一致度)。

真源里 beats[].tension 与 target_tension_curve 当前逐项相同,所以两个口径的
MAE 都是 0——本脚本用「反事实探针」把这一点坐实:只把目标曲线的某一项挪动 1,
MAE 立刻抬起来,证明 MAE 恒 0 是「标注与目标此刻一致」的自洽证据,而不是指标
失效。全程整数,不出现浮点。
"""
from __future__ import annotations

import json
import os
import sys

AFF_LO, AFF_HI = 0, 100
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "../out")


def cond_ok(c, s):
    if c is None:
        return True
    k = c[0]
    if k == "flag":
        return s["flags"].get(c[1], 0) == c[2]
    if k == "item":
        return s["items"].get(c[1], 0) == c[2]
    if k == "aff":
        v, op, n = s["aff"].get(c[1], 0), c[2], c[3]
        return v >= n if op == ">=" else v < n if op == "<" else v == n if op == "==" else v != n
    if k == "and":
        return cond_ok(c[1], s) and cond_ok(c[2], s)
    if k == "or":
        return cond_ok(c[1], s) or cond_ok(c[2], s)
    return not cond_ok(c[1], s)


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


def init_state(spec):
    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 step(spec, s, c):
    eff = c.get("effects") or {}
    aff, flags, items = dict(s["aff"]), dict(s["flags"]), dict(s["items"])
    for k, v in (eff.get("set") or {}).items():
        flags[k] = v
    for k, v in (eff.get("add") or {}).items():
        aff[k] = min(AFF_HI, max(AFF_LO, aff.get(k, 0) + v))
    for k, v in (eff.get("grant") or {}).items():
        items[k] = v
    for k in (eff.get("revoke") or {}):
        items[k] = 0
    return {"node": c["goto"], "aff": aff, "flags": flags, "items": items,
            "path": s["path"] + [c["goto"]]}


def walk(spec):
    s = init_state(spec)
    for _ in range(64):
        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
        s = step(spec, s, opts[0])
    return s


def mae_milli(actual, target):
    """MAE,千分位整数地板除:sum(|a-t|) × 1000 // n。"""
    n = len(actual)
    return sum(abs(a - t) for a, t in zip(actual, target)) * 1000 // n if n else 0


def main() -> int:
    with open(os.path.join(HERE, "../config/story_spec.json"), encoding="utf-8") as fh:
        spec = json.load(fh)

    s = walk(spec)
    visited = {node(spec, nid)["scene"] for nid in s["path"]}
    beats = spec["beats"]
    target = spec["metrics"]["target_tension_curve"]
    thresh = spec["metrics"]["tension_mae_max"]

    # 路径口径:只比路径命中过的节拍
    idx_path = [i for i, b in enumerate(beats) if b["scene"] in visited]
    act_path = [beats[i]["tension"] for i in idx_path]
    tgt_path = [target[i] for i in idx_path]
    # 全量口径:比全部 16 个节拍
    act_all = [b["tension"] for b in beats]
    tgt_all = list(target)

    mae_path = mae_milli(act_path, tgt_path)
    mae_all = mae_milli(act_all, tgt_all)
    diffs = [abs(a - t) for a, t in zip(act_path, tgt_path)]

    print(f"路径口径      命中节拍 {len(idx_path)} / {len(beats)}")
    print(f"实际曲线      {' '.join(str(v) for v in act_path)}")
    print(f"目标曲线      {' '.join(str(v) for v in tgt_path)}")
    print(f"逐项绝对差    {' '.join(str(v) for v in diffs)}")
    print(f"路径 MAE      {mae_path} 毫  ({sum(diffs)} × 1000 // {len(act_path)})")
    print(f"全量 MAE      {mae_all} 毫  ({sum(abs(a-t) for a,t in zip(act_all,tgt_all))} × 1000 // {len(act_all)})")
    print(f"阈值          {thresh * 1000} 毫  (metrics.tension_mae_max = {thresh})")

    # 反事实探针:把目标曲线第 1 个节拍的目标挪动 1,MAE 必须抬起
    probe = list(tgt_all)
    probe[0] = probe[0] + 1
    mae_probe = mae_milli(act_all, probe)
    print(f"反事实探针    目标曲线第 1 项 {tgt_all[0]} → {probe[0]}")
    print(f"探针 MAE      {mae_probe} 毫  (1 × 1000 // {len(act_all)},非 0 证明指标有分辨力)")

    assert mae_path <= thresh * 1000, "路径 MAE 超阈值"
    assert mae_all <= thresh * 1000, "全量 MAE 超阈值"
    assert mae_probe > 0, "反事实探针未抬起,指标疑似失效"

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "nm_06_2_mae.json"), "w", encoding="utf-8") as fh:
        json.dump({"path_beats": len(idx_path), "actual": act_path, "target": tgt_path,
                   "abs_diff": diffs, "mae_path_milli": mae_path, "mae_all_milli": mae_all,
                   "threshold_milli": thresh * 1000, "probe_mae_milli": mae_probe},
                  fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_06_2_mae.json")
    print("OK nm_06_2_target_curve_mae:两条曲线逐项相同 → MAE 恒 0,且探针证明指标非失效")
    return 0


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

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

路径口径      命中节拍 11 / 16
实际曲线      1 2 3 2 3 4 4 3 4 4 2
目标曲线      1 2 3 2 3 4 4 3 4 4 2
逐项绝对差    0 0 0 0 0 0 0 0 0 0 0
路径 MAE      0 毫  (0 × 1000 // 11)
全量 MAE      0 毫  (0 × 1000 // 16)
阈值          1000 毫  (metrics.tension_mae_max = 1)
反事实探针    目标曲线第 1 项 1 → 2
探针 MAE      62 毫  (1 × 1000 // 16,非 0 证明指标有分辨力)
wrote out/nm_06_2_mae.json
OK nm_06_2_target_curve_mae:两条曲线逐项相同 → MAE 恒 0,且探针证明指标非失效

判读:恒 0 是自洽证据,不是指标失效

第一,逐项绝对差全是 0。 路径命中过的 11 个节拍上,实际曲线与目标曲线完全吻合,逐项差 0 0 0 0 0 0 0 0 0 0 0。这不是巧合,是因为真源里 beats[].tension 与 target_tension_curve 逐项相同——作者标注的张力,与设计目标此刻就是一份东西。

第二,两个口径的 MAE 都是 0,且都过阈值。 路径口径 0 毫、全量口径 0 毫,阈值 1000 毫。0 ≤ 1000,通过。 脚本用 assert 把这条钉住。

第三,反事实探针证明指标有分辨力。 只把目标曲线第 1 项从 1 改成 2,探针 MAE 立刻变成 62 毫(1 × 1000 // 16)。如果 MAE 真的退化成恒 0,探针就不可能抬起——它抬起了,说明这个 0 是真实的 0,而不是公式算不动的 0。

观察数字读法
路径 MAE0 毫命中 11 个节拍上标注与目标完全吻合
全量 MAE0 毫全 16 个节拍上标注与目标完全吻合
阈值1000 毫tension_mae_max = 1,即平均偏离 1 档
探针 MAE62 毫挪动 1 项即抬起,指标非失效

所以「当前 MAE 恒 0」的读法只有一种:这份规格的标注与目标此刻是一致的。MAE 真正起作用是在规格演化时(第 14 章)——改了某个节拍的 tension 而没同步 target_tension_curve,它就会从 0 抬起来,成为回归信号。一个恒为 0 的指标不是没用,而是「此刻没有偏离」这一事实的度量。

一个交叉核对:本节算出的路径口径 MAE = 0,与目录篇 nm_00_toc_metric.py 打印的「张力 MAE 0 毫」逐位一致——两条脚本各自独立实现,走同一条确定性路径、读同一段真源,得到同一个 0。这不是巧合,而是「同一条路径的曲线唯一」的直接结果:曲线由路径唯一决定(6.1),目标由真源唯一决定,两个唯一量相减,结果自然唯一。

还要提醒一点:MAE 与覆盖率是两个正交的量。同一条路径可能覆盖率很低(本卷目录篇实测 446‰)而 MAE 为 0——覆盖低说明「这条路没走全」,MAE 为 0 说明「走过的部分没偏」。两者不能互相替代:覆盖看的是广度,MAE 看的是已覆盖部分的贴合度。第 2 章管前者,本章管后者。

自检表

检查项合格标准
单位毫(千分位),sum × 1000 // n
无浮点只用 // 整数地板除
阈值来源metrics.tension_mae_max,换算 × 1000
双口径路径口径 + 全量口径都算
非退化反事实探针 MAE > 0(脚本 assert)

MAE 算出来了,但「这个数说明了什么、不能说明什么」还没写死。下一节把这条边界做成一份可机器检查的口径声明——它明确这个 MAE 是 proxy、只做相对比较、并列出禁声称清单。

阅读导航:上一节:6.1 情感曲线的构造 · 下一节:6.3 代理指标的口径声明 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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