14.2 路径与指标的回归

上一节算出了影响面,这一节把「改动前 / 改动后」做成一张回归对照表:分支覆盖率、结局分布熵、张力 MAE、一致性残差各一列,增量各一行,并对着写在 metrics 里的声明阈值逐格判越阈。五处改动全是「变了但未越阈」,表同时落成 JSON 与 CSV,供后续每次演化重跑比对。

站内 《游戏设计》卷一《玩法机制与可执行规格》 做的是玩法规格的消费与演化对机制指标的影响;本章做的是叙事规格演化对路径与曲线的影响。度量对象不同——那边看一局战斗的动力学(回合数、资源、行动密度),这边看整张故事图的路径分布(覆盖率、结局分布、张力曲线)。同一份整数化真源,一边量「这一局打了几个回合」,另一边量「这张图被走成了什么形状」。

14.1 量出了影响面,但影响面是一堆集合差,还不能回答「这次改动该不该合入」。这一节把它压成一张回归对照表:左边是路径级影响,右边是四个指标的绝对值与增量,最后一列对着 metrics 里的声明阈值逐格判越阈。全部数字本机实跑,全部指标都是 proxy。

14.2 路径与指标的回归

回归的口径要先定清楚,否则「表」会变成一堆没法比较的数:

  • 行 = 五处改动各一行,外加一行基线。
  • 列 = 路径级(阻断路径数、终局状态集对称差)与指标级(覆盖率、结局熵、张力 MAE、一致性残差)两组。
  • 判据 = config/story_spec.json 的 metrics 段里已经写死的声明阈值:覆盖率 ≥ 850‰、结局熵 ≥ 1500 毫比特、张力 MAE ≤ 1000 毫。阈值是设计意图的书面表达,不是回归脚本自己拍的。

另外要落一份回归基线:把原规格的四个指标存成 out/nm_14_2_baseline.json,以后每次改规格只需重跑本脚本,就能与基线比。基线文件不存在时脚本会建立它,存在时读回核对——这就是「回归」两个字在工程上的落点。

S 层:把影响面压成对照表

sim/nm_14_2_regression_table.py 只 import 14.1 的内核(apply_mutation / collect / metrics),不重复实现推进语义。它对五处改动各算一行,并把 CSV 与 JSON 一起落盘。

#!/usr/bin/env python3
# file: sim/nm_14_2_regression_table.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/nm_14_2_regression_table.py
"""卷三 14.2 路径与指标的回归(S 层):把改动前后做成一张对照表。

复用 14.1 的影响面内核(只 import sim/nm_14_1_evolution_impact.py,不改它),
对五处小改动各算一列:

  路径级(来自 500 条基线的重放)
    blocked_paths    被阻断的路径数
    state_symdiff    终局状态集(规范串)的对称差大小
  指标级(全部整数,千分位 / 毫)
    coverage_permille           走过的去重边 / 全部去重边
    ending_entropy_milli_bits   500 条采样的结局分布熵
    tension_mae_milli           全 16 个节拍的实际曲线 vs 目标曲线
    consistency_residual        第 9 章同一口径的静态残差 D + K + S + R

同时落一份**回归基线** out/nm_14_2_baseline.json(原规格的四个指标),后续演化
只需再跑一次本脚本即可与基线比对。全部整数,不出现浮点;只读 config/story_spec.json。
产出 out/nm_14_2_regression.json / .csv / out/nm_14_2_baseline.json。
"""
from __future__ import annotations

import csv
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import nm_14_1_evolution_impact as imp1  # noqa: E402  只读 import:apply_mutation / collect / metrics

ROOT = imp1.ROOT
OUT = imp1.OUT
BASELINE = os.path.join(OUT, "nm_14_2_baseline.json")
JSON_PATH = os.path.join(OUT, "nm_14_2_regression.json")
CSV_PATH = os.path.join(OUT, "nm_14_2_regression.csv")

METRIC_KEYS = ["coverage_permille", "ending_entropy_milli_bits",
               "tension_mae_milli", "consistency_residual"]
METRIC_LABEL = {"coverage_permille": "分支覆盖率(‰)",
                "ending_entropy_milli_bits": "结局分布熵(毫比特)",
                "tension_mae_milli": "张力 MAE(毫)",
                "consistency_residual": "一致性残差(计数)"}


def thresholds(spec: dict) -> dict:
    m = spec["metrics"]
    return {"coverage_permille": ("min", m["coverage_target_permille"]),
            "ending_entropy_milli_bits": ("min", m["ending_entropy_min_milli_bits"]),
            "tension_mae_milli": ("max", m["tension_mae_max"] * 1000),
            "consistency_residual": ("ref", 0)}


def crossed(key: str, value: int, thr: dict) -> bool:
    kind, lim = thr[key]
    if kind == "min":
        return value < lim
    if kind == "max":
        return value > lim
    return False


def build_rows(spec: dict, seqs: list[list[str]], cfg: dict, base: dict) -> list[dict]:
    """五处改动各一行:路径级影响 + 四个指标的绝对值与增量。"""
    thr = thresholds(spec)
    rows = []
    for mut in cfg["mutations"]:
        m_spec = imp1.apply_mutation(spec, mut)
        c = imp1.collect(m_spec, seqs)
        mm = imp1.metrics(m_spec, seqs)
        delta = {k: mm[k] - base[k] for k in METRIC_KEYS}
        rows.append({
            "id": mut["id"], "note": mut["note"], "kind": mut["kind"],
            "blocked_paths": c["blocked"],
            "state_symdiff": len(c["canonical"] ^ base["canonical_set"]),
            "dedup_edges_total": mm["dedup_edges_total"],
            "metrics": {k: mm[k] for k in METRIC_KEYS},
            "delta": delta,
            "threshold_crossed": [k for k in METRIC_KEYS if crossed(k, mm[k], thr)],
        })
    return rows


def main() -> int:
    spec = imp1.load_json(os.path.join(ROOT, "config/story_spec.json"))
    cfg = imp1.load_json(imp1.CONFIG)
    base_rows = imp1.load_json(imp1.BASELINE)
    seqs = [r["choices"] for r in base_rows]

    base_collect = imp1.collect(spec, seqs)
    base_metrics = imp1.metrics(spec, seqs)
    base = {**base_metrics, "canonical_set": base_collect["canonical"]}

    # 回归基线文件:不存在则建立,存在则读回核对
    if os.path.exists(BASELINE):
        with open(BASELINE, encoding="utf-8") as fh:
            saved = json.load(fh)
        same = all(saved.get(k) == base[k] for k in METRIC_KEYS)
        print(f"回归基线 {os.path.relpath(BASELINE, ROOT)}({os.path.getsize(BASELINE)} 字节,"
              f"n={saved.get('samples')})与本机现算一致:{same}")
    else:
        os.makedirs(OUT, exist_ok=True)
        with open(BASELINE, "w", encoding="utf-8") as fh:
            json.dump({**base_metrics, "samples": len(seqs), "seed": cfg["baseline"]["seed"]},
                      fh, ensure_ascii=False, indent=2)
            fh.write("\n")
        print(f"回归基线不存在 → 已建立 {os.path.relpath(BASELINE, ROOT)}({os.path.getsize(BASELINE)} 字节)")

    rows = build_rows(spec, seqs, cfg, base)
    thr = thresholds(spec)

    print()
    print(f"基线  覆盖 {base['coverage_permille']}‰  熵 {base['ending_entropy_milli_bits']} 毫比特"
          f"  MAE {base['tension_mae_milli']} 毫  残差 {base['consistency_residual']}"
          f"  (去重边 {base['dedup_edges_total']},采样 {len(seqs)} 条)")
    print()
    head = f"{'改动':<26}{'阻断':>6}{'状态差':>7}"
    for k in METRIC_KEYS:
        head += f"{METRIC_LABEL[k][:6]:>9}"
    print(head)
    base_line = f"{'基线':<26}{0:>6}{0:>7}"
    for k in METRIC_KEYS:
        base_line += f"{base[k]:>9}"
    print(base_line)
    for r in rows:
        line = f"{r['id']:<26}{r['blocked_paths']:>6}{r['state_symdiff']:>7}"
        for k in METRIC_KEYS:
            line += f"{r['metrics'][k]:>9}"
        print(line)

    print()
    print("增量(改动后 − 基线):")
    for r in rows:
        parts = "  ".join(f"{METRIC_LABEL[k][:6]} {r['delta'][k]:+d}" for k in METRIC_KEYS)
        cross = "越过声明阈值: " + (", ".join(METRIC_LABEL[k] for k in r["threshold_crossed"]) or "无")
        print(f"  {r['id']:<26} {parts}   [{cross}]")
    print()
    print(f"声明阈值:覆盖率 ≥ {thr['coverage_permille'][1]}‰ · 结局熵 ≥ "
          f"{thr['ending_entropy_milli_bits'][1]} 毫比特 · 张力 MAE ≤ {thr['tension_mae_milli'][1]} 毫")

    for r in rows:
        assert 0 <= r["metrics"]["coverage_permille"] <= 1000, "覆盖率须在 [0,1000]‰"
        assert r["metrics"]["ending_entropy_milli_bits"] >= 0, "熵非负"
        assert r["metrics"]["tension_mae_milli"] >= 0, "MAE 非负"
        assert r["blocked_paths"] <= len(seqs)

    os.makedirs(OUT, exist_ok=True)
    with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
        w = csv.writer(fh)
        w.writerow(["mutation", "blocked_paths", "state_symdiff", "dedup_edges_total"]
                   + [f"{k}" for k in METRIC_KEYS] + [f"delta_{k}" for k in METRIC_KEYS]
                   + ["threshold_crossed"])
        for r in rows:
            w.writerow([r["id"], r["blocked_paths"], r["state_symdiff"], r["dedup_edges_total"]]
                       + [r["metrics"][k] for k in METRIC_KEYS]
                       + [r["delta"][k] for k in METRIC_KEYS]
                       + ["|".join(r["threshold_crossed"])])
    with open(JSON_PATH, "w", encoding="utf-8") as fh:
        json.dump({"baseline": {k: base[k] for k in METRIC_KEYS} | {"dedup_edges_total": base["dedup_edges_total"]},
                   "thresholds": {k: {"kind": thr[k][0], "limit": thr[k][1]} for k in METRIC_KEYS},
                   "rows": rows}, fh, ensure_ascii=False, indent=1)
        fh.write("\n")
    print("wrote out/nm_14_2_regression.json / .csv")
    print("OK nm_14_2_regression_table:五处改动的路径级与指标级对照表已落盘")
    return 0


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

本机实跑 /opt/homebrew/bin/python3 sim/nm_14_2_regression_table.py(首次运行,基线文件尚不存在):

回归基线不存在 → 已建立 out/nm_14_2_baseline.json(327 字节)

基线  覆盖 1000‰  熵 1747 毫比特  MAE 0 毫  残差 8  (去重边 56,采样 500 条)

改动                            阻断    状态差   分支覆盖率(   结局分布熵(   张力 MAE   一致性残差(
基线                             0      0     1000     1747        0        8
M1_gate_dawn_fallback        127    254     1000     1669        0        8
M2_raise_lit_threshold        30     60     1000     1534        0        8
M3_add_shortcut_goto           0      0      982     1747        0        8
M4_beat_tension_shift          0      0     1000     1747       62        8
M5_gate_lighthouse_entry     123    193     1000     1749        0        7

增量(改动后 − 基线):
  M1_gate_dawn_fallback      分支覆盖率( +0  结局分布熵( -78  张力 MAE +0  一致性残差( +0   [越过声明阈值: 无]
  M2_raise_lit_threshold     分支覆盖率( +0  结局分布熵( -213  张力 MAE +0  一致性残差( +0   [越过声明阈值: 无]
  M3_add_shortcut_goto       分支覆盖率( -18  结局分布熵( +0  张力 MAE +0  一致性残差( +0   [越过声明阈值: 无]
  M4_beat_tension_shift      分支覆盖率( +0  结局分布熵( +0  张力 MAE +62  一致性残差( +0   [越过声明阈值: 无]
  M5_gate_lighthouse_entry   分支覆盖率( +0  结局分布熵( +2  张力 MAE +0  一致性残差( -1   [越过声明阈值: 无]

声明阈值:覆盖率 ≥ 850‰ · 结局熵 ≥ 1500 毫比特 · 张力 MAE ≤ 1000 毫
wrote out/nm_14_2_regression.json / .csv
OK nm_14_2_regression_table:五处改动的路径级与指标级对照表已落盘

(第二次起第一行换成「回归基线 …(327 字节,n=500)与本机现算一致:True」——基线一旦落盘,回归就是读回比对而不是重算。)

判读:五处改动全是「变了但未越阈」

第一,表里没有一格越阈。 声明阈值是覆盖率 ≥ 850‰、结局熵 ≥ 1500 毫比特、张力 MAE ≤ 1000 毫,而五处改动的实际值最差也只是覆盖率 982‰、熵 1534 毫比特、MAE 62 毫——都还在阈值以内。这不是「改动没影响」,而是「影响还没大到违反设计意图」。

第二,覆盖率与熵这两列的方向常常相反。 M1/M2/M5 都是阻断型改动:M1 的熵 1747→1669(−78),M2 的熵 1747→1534(−213),M5 的熵 1747→1749(+2,反而升了一点)。同时 M1/M2/M5 的覆盖率都停在 1000‰——因为被阻断的路径是不再到达某些边,但 500 条基线本来就只覆盖了全部去重边的一部分,阻断没有减少「走过的边种类」。只看一列会得出错误结论。

第三,M3 与 M4 只有一列动,且是两列里不同的那一列。 M3(加 goto)只动覆盖率(1000→982‰,分母 56→57),M4(改节拍张力)只动 MAE(0→62 毫)。其余指标一格没变。「改动打在哪个指标上」是有指纹的——把五行的增量竖着看,每行的非零列就是这次改动的签名。

第四,增量列带符号,但符号不等于好坏。 M3 的 −18‰ 看着像「退化」,可它来自分母变大、行为未变;M5 的 −1 残差看着像「改善」,但那只是删掉了一个 R 类规则失配。符号只说方向,方向的好坏要靠阈值或设计意图来判——这正是下一节「指标变了 ≠ 指标变坏了」要展开的。

改动路径级覆盖率 Δ熵 ΔMAE Δ残差 Δ越阈
M1 加 flag 门阻断 127 / 状态差 2540−7800无
M2 改阈值阻断 30 / 状态差 600−21300无
M3 加 goto全 0−18000无
M4 改节拍张力全 000+620无
M5 加 flag 门阻断 123 / 状态差 1930+20−1无

表落成了 out/nm_14_2_regression.csv(给表格工具)与 out/nm_14_2_regression.json(给脚本),以后每次改规格重跑一次就能比对。所有数值都是 proxy 口径下的结构量,只做相对比较。

自检表

检查项合格标准本节实测
基线落盘原规格四指标写入 baseline.json327 字节,n=500
对照完整5 行改动 × 4 指标20 格齐
阈值来源读 metrics 声明值850‰ / 1500 毫比特 / 1000 毫
增量口径改动后 − 基线,带符号见增量列
落盘产物JSON + CSV 双份均已写出

表能看出一格越没越阈,但它不会替你回答「这次演化要不要告警、告警到什么程度」。下一节把「指标变了」和「指标变坏了」分开:前者是 DRIFT,后者只有越过声明阈值(CROSS)才判得动,而 B 层的扰动扫描只发软告警、始终 exit 0。

阅读导航:上一节:14.1 规格演化的影响面 · 下一节:14.3 演化的告警 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「narrative」更多文章

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