4.2 涌现的度量
站内的平衡治理流程 关心的是一份榜单:哪些打法太强、哪些太弱、要不要动刀。它默认「打法」这个概念已经存在、可以被数出来。本节补的正是那个前置条件——先把「有多少种打法」写成一个可算的量,也就是 4.1 引出的涌现度:可达模式数除以规则数。有了这个量,榜单才有分母;没有它,讨论强弱就只能凭感觉。
一个指标,三个声明
上一节算出了 2692‰,但这个数不是唯一的。任何形如「模式数 ÷ 分母」的指标都隐含三个没写出来的声明,换掉任何一个,数值就变:
| 声明 | 取值 | 为什么必须写明 |
|---|---|---|
| 粒度 | fine 卡牌 id 多重集 / cost 费用多重集 / size 张数 | 「模式」怎么规范化,直接决定数出几个 |
| 分母 | rules = 13 / alphabet = 6 | 跟规则比还是跟动作符号比,量纲完全不同 |
| 种子集 | 逐种子 / 多种子并集 | 可达集是逐种子的,种子越多并集越大 |
把三个声明都写出来,涌现度才是一个定义清楚的函数;不写,它就是一个可以随便解释的数。本节的 S 层脚本就把这张声明表真的跑一遍。
S 层:3 粒度 × 2 分母
sim/gd_play_04_metric.py 复用 4.1 的 closure / reachable,对并集口径的回合程序按三种粒度各投影一次,再各配两种分母,得到 6 个涌现度。粒度投影只是一个映射:cost 粒度把 ('guard','strike') 压成 (1,1),size 粒度把它压成 2。
#!/usr/bin/env python3
# file: sim/gd_play_04_metric.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_04_metric.py
"""卷二 4.2 涌现的度量(S 层):把「涌现度」写成一个可算但**依赖口径**的代理指标。
涌现度 = 1000 × 可达模式数 // 分母。它同时依赖三个声明:
① 粒度(模式怎么规范化)——fine = 卡牌 id 多重集 / cost = 费用多重集 / size = 张数
② 分母(跟什么比)——rules = 13(卷一规则表)/ alphabet = 6(动作字母表)
③ 种子集(可达集是逐种子的)
脚本对 {3 粒度} × {2 分母} 的 6 个组合各算一遍并集口径的涌现度,另给逐种子的 fine 口径
读数。它全是整数/千分比,无浮点。**所有读数都是代理指标**:只用于相对比较,禁绝对化——
不同口径下的数值不可直接比大小,这正是本节要证明的。
产物 out/play04_metric.json + out/play04_metric.csv + out/play04_metric.svg。
"""
from __future__ import annotations
import copy
import json
import os
import sys
import time
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_04_emergence.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_play_04_emergence as em # noqa: E402
ALPHABET = 6 # 卷一 1.1:handSize 5 个 p 符号 + 1 个 e
DENOMS = {"rules": 13, "alphabet": ALPHABET}
def keys_of(patterns, cost, granularity):
"""把一组回合程序按粒度投影成模式键。"""
if granularity == "fine":
return set(patterns)
if granularity == "cost":
return {tuple(sorted(cost[c] for c in p)) for p in patterns}
if granularity == "size":
return {len(p) for p in patterns}
raise ValueError(granularity)
def main() -> int:
t0 = time.perf_counter_ns()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
ms, mn = cfg["maxSteps"], cfg["maxNodes"]
spec = ashlib.load_spec()
cost = {c["id"]: c["cost"] for c in spec["cards"]}
legal = em.closure(spec)
per_seed, union_fine = [], set()
for sd in cfg["seeds"]:
s = copy.deepcopy(spec)
s["seed"] = sd
progs, nodes, _trunc = em.reachable(s, ms, mn)
union_fine |= progs
per_seed.append({"seed": sd, "fine": len(progs), "states": nodes})
rows, csv = [], []
for gran in ("fine", "cost", "size"):
keys = keys_of(union_fine, cost, gran)
for dname, dval in DENOMS.items():
value = 1000 * len(keys) // dval
rows.append({"granularity": gran, "denominator": dname, "patterns": len(keys),
"emergencePermille": value})
csv.append([gran, dname, len(keys), dval, value])
closure_coarse = len(keys_of(legal, cost, "cost"))
reach_ratio = 1000 * len(union_fine) // len(legal)
seed_vals = [1000 * r["fine"] // DENOMS["rules"] for r in per_seed]
spread = max(seed_vals) - min(seed_vals)
n = ashlib.write_csv(os.path.join(OUT, "play04_metric.csv"),
["granularity", "denominator", "patterns", "denom", "emergencePermille"], csv)
root = ashlib.svg_begin(700, 300)
ashlib.svg_text(root, 20, 28, "涌现度对口径的依赖:3 粒度 × 2 分母", 15)
ashlib.svg_text(root, 20, 48, "全部为代理指标:不同口径之间不可直接比大小", 11, "#94a3b8")
top = max(r["emergencePermille"] for r in rows) or 1
for i, r in enumerate(rows):
y = 66 + i * 34
ashlib.svg_text(root, 20, y + 18, f"{r['granularity']}/{r['denominator']}", 11)
ashlib.svg_bar(root, 180, y, 420, 22, r["emergencePermille"], top, "#38bdf8",
f"{r['emergencePermille']}‰")
size = ashlib.svg_save(root, os.path.join(OUT, "play04_metric.svg"))
report = {"id": "gd_play_04_metric", "seeds": len(cfg["seeds"]),
"closureFine": len(legal), "closureCoarse": closure_coarse,
"unionFine": len(union_fine), "reachPermille": reach_ratio,
"perSeed": per_seed, "seedEmergence": {"min": min(seed_vals), "max": max(seed_vals),
"spread": spread},
"grid": rows, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
with open(os.path.join(OUT, "play04_metric.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"{'粒度/分母':<16}{'模式数':>7}{'分母':>6}{'涌现度‰':>10}")
for r in rows:
print(f"{r['granularity'] + '/' + r['denominator']:<16}{r['patterns']:>7}"
f"{DENOMS[r['denominator']]:>6}{r['emergencePermille']:>10}")
print(f"合法闭包 fine={len(legal)} coarse={closure_coarse} 并集 fine={len(union_fine)} "
f"可达率={reach_ratio}‰")
print(f"逐种子 fine 涌现度 min={min(seed_vals)} max={max(seed_vals)} spread={spread}‰")
print(f"csv={n} 行 svg={size} bytes elapsed={report['elapsedMs']} ms")
print("wrote out/play04_metric.json out/play04_metric.csv out/play04_metric.svg")
ET.parse(os.path.join(OUT, "play04_metric.svg"))
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_04_metric.py:
粒度/分母 模式数 分母 涌现度‰
fine/rules 35 13 2692
fine/alphabet 35 6 5833
cost/rules 17 13 1307
cost/alphabet 17 6 2833
size/rules 5 13 384
size/alphabet 5 6 833
合法闭包 fine=35 coarse=17 并集 fine=35 可达率=1000‰
逐种子 fine 涌现度 min=2307 max=2692 spread=385‰
csv=6 行 svg=2951 bytes elapsed=5378 ms
wrote out/play04_metric.json out/play04_metric.csv out/play04_metric.svg
判读:换口径,数值差 7 倍
六个读数排开看:2692 / 5833 / 1307 / 2833 / 384 / 833(单位 ‰)。同一个内核、同一批种子,只换粒度与分母,最大值 5833 是最小值 384 的 15 倍多;即使固定分母只换粒度,fine 2692 → cost 1307 → size 384 也差 7 倍。
| 粒度 | 模式数 | 涌现度(分母 = 13) | 这个数在说什么 |
|---|---|---|---|
fine | 35 | 2692‰ | 每一张牌算不同,最细 |
cost | 17 | 1307‰ | 只看费用形状,同费率的牌合并 |
size | 5 | 384‰ | 只看一回合打几张,最粗 |
这张表是本节最该记住的东西:「涌现度高不高」这句话,不声明粒度就无从回答。这也解释了为什么不能拿一个游戏的平均回合长度去和另一个游戏比「涌现」——两者的粒度、分母、种子集几乎不可能一致。代理指标的用法只有一个:同一口径内比相对大小。
B 层:换粒度,排序稳不稳
既然绝对数不可比,那排序呢?这才是代理指标能不能用的关键。sim/gd_play_04_metric_bench.py 拿 9 个变体(energyMax × handSize 的 3×3 网格),在 fine 与 cost 两种粒度下各算一遍涌现度,再比对两个排序是否一致。B 档软告警不硬失败,脚本永远 exit 0。
#!/usr/bin/env python3
# file: sim/gd_play_04_metric_bench.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_04_metric_bench.py
"""卷二 4.2 涌现度量基准(B 层):把「粒度口径」当成可扰动参数,看排序稳不稳。
4.2 的涌现度在不同粒度下数值差近 2 倍(fine 2692‰ vs cost 1307‰),所以绝对数不可比。
真正该问的是:**换粒度之后,一组变体的涌现度排序会不会翻盘**——若不翻,说明这个代理
指标至少能做「相对比较」;若翻,则连相对比较都不成立。复用 4.1 的 closure / reachable。
**永远 exit 0**:B 档软告警不硬失败——翻盘只打印 SOFT,不抛异常、不返回非零。
"""
from __future__ import annotations
import copy
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import ashlib # noqa: E402
import gd_play_04_emergence as em # noqa: E402
BENCH_SEEDS = [201, 202, 203, 204]
def keys_of(patterns, cost, granularity):
if granularity == "fine":
return set(patterns)
return {tuple(sorted(cost[c] for c in p)) for p in patterns}
def variant_union(spec, energy, hand, ms, mn):
s = copy.deepcopy(spec)
s["params"]["energyMax"] = energy
s["params"]["handSize"] = hand
union: set = set()
for sd in BENCH_SEEDS:
t = copy.deepcopy(s)
t["seed"] = sd
progs, _nodes, _trunc = em.reachable(t, ms, mn)
union |= progs
return union
def main() -> int:
t0 = time.perf_counter_ns()
with open(em.CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
rules, ms, mn = cfg["rules"], cfg["maxSteps"], cfg["maxNodes"]
spec = ashlib.load_spec()
cost = {c["id"]: c["cost"] for c in spec["cards"]}
rows = []
for energy in cfg["perturb"]["energyMax"]:
for hand in cfg["perturb"]["handSize"]:
union = variant_union(spec, energy, hand, ms, mn)
fine = len(keys_of(union, cost, "fine"))
coarse = len(keys_of(union, cost, "cost"))
rows.append({"energyMax": energy, "handSize": hand, "fine": fine, "coarse": coarse,
"emergenceFine": 1000 * fine // rules, "emergenceCoarse": 1000 * coarse // rules})
order_fine = [(r["energyMax"], r["handSize"]) for r in sorted(rows, key=lambda r: (-r["emergenceFine"], r["energyMax"], r["handSize"]))]
order_coarse = [(r["energyMax"], r["handSize"]) for r in sorted(rows, key=lambda r: (-r["emergenceCoarse"], r["energyMax"], r["handSize"]))]
same = order_fine == order_coarse
ratio_min = min(r["emergenceFine"] * 1000 // r["emergenceCoarse"] for r in rows)
ratio_max = max(r["emergenceFine"] * 1000 // r["emergenceCoarse"] for r in rows)
for r in rows:
print(f"energyMax={r['energyMax']} handSize={r['handSize']} "
f"fine={r['fine']:>3}({r['emergenceFine']:>5}‰) "
f"coarse={r['coarse']:>3}({r['emergenceCoarse']:>5}‰)")
print(f"fine/coarse 比值区间=[{ratio_min}‰, {ratio_max}‰](绝对数不可比)")
print(f"排序一致={same}")
if same:
print("SOFT OK: 换粒度后 9 个变体的涌现度排序完全一致——代理指标可做相对比较(同口径内)")
else:
print(f"SOFT WARN GRANULARITY_FLIP: 换粒度后排序翻盘 fine={order_fine} coarse={order_coarse}")
print(f"SOFT: hardFail=false(B 档软告警不硬失败) elapsed={(time.perf_counter_ns() - t0) // 1000000} ms")
return 0
if __name__ == "__main__":
raise SystemExit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_play_04_metric_bench.py:
energyMax=2 handSize=4 fine= 20( 1538‰) coarse= 11( 846‰)
energyMax=2 handSize=5 fine= 20( 1538‰) coarse= 11( 846‰)
energyMax=2 handSize=6 fine= 20( 1538‰) coarse= 11( 846‰)
energyMax=3 handSize=4 fine= 32( 2461‰) coarse= 16( 1230‰)
energyMax=3 handSize=5 fine= 35( 2692‰) coarse= 17( 1307‰)
energyMax=3 handSize=6 fine= 35( 2692‰) coarse= 17( 1307‰)
energyMax=4 handSize=4 fine= 44( 3384‰) coarse= 22( 1692‰)
energyMax=4 handSize=5 fine= 53( 4076‰) coarse= 25( 1923‰)
energyMax=4 handSize=6 fine= 56( 4307‰) coarse= 26( 2000‰)
fine/coarse 比值区间=[1817‰, 2153‰](绝对数不可比)
排序一致=True
SOFT OK: 换粒度后 9 个变体的涌现度排序完全一致——代理指标可做相对比较(同口径内)
SOFT: hardFail=false(B 档软告警不硬失败) elapsed=30475 ms
结论分两半:绝对数不可比(fine/coarse 比值在 1817‰–2153‰ 之间浮动,同一变体两种粒度差近一倍),排序完全一致(9 个变体的名次一个没变)。这正是代理指标的正确用法——它不该被当成一个绝对分数贴到游戏上,而该被当成一把尺子:同一把尺子量不同变体,名次可信;换一把尺子量同一个变体,读数不可信。粒度、分母、种子集就是那把尺子的刻度。
自检表
| 检查项 | 合格标准 |
|---|---|
| 口径写全 | 粒度 / 分母 / 种子集三个声明都列成表,不藏在代码里 |
| 数值可复现 | 全整数、同种子逐位相同,elapsed 是墙钟采样 |
| 代理指标显式 | 正文声明不同口径的绝对数不可比,只做同口径相对比较 |
| 排序可验 | B 层给出「换粒度排序是否翻盘」的可跑判据,软告警不硬失败 |
有了度量和它的边界,下一节转向反面:当可达模式数不少、但实际打法全挤在一条模式上时,涌现度会骗人——那就是退化解。
阅读导航:上一节:4.1 涌现:从规则到模式 · 下一节:4.3 退化解与退化模式 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。