SLG 模块拆分
关心的是系统模块怎么切、依赖怎么连;本节关心的是状态图通不通——有没有走不出去的死角、有没有怎么打都赢不了的开局。这是状态空间语义的最后一问。4.3 已经交付了 core/src/ashdeck/solvability.ts:广度优先穷举 + digest 去重 + 三重上限,并把结论拆成 solvable / exhausted / truncated 三个布尔。本节不重复那套实现,而是在它之上做两件事:把「卡住」拆成一张判据表,并说清每条结论的可靠性边界。
10.3 死锁与可解性检测
「卡住」不是一个概念,是四种。它们的检测方式、可达性、结论强度都不同,混为一谈是这一节最容易犯的错:
| 判据 | 定义 | 检测方式 | 本规则下可达 | 结论强度 |
|---|---|---|---|---|
| 死锁 | 未结束且合法动作集为空 | legalActions(s).length === 0 | 摘掉 e 的动作集变体下可达 | 状态级事实 |
| 停滞 | 未结束、有合法动作,但每个都不改变状态 | 比较每个动作后快照的 digest | 不可达(e 必然推进 turn) | 状态级事实 |
| 不可解 | 不存在任何取胜序列 | 穷尽前沿后仍无 won | 可达(换一组 params) | 仅 exhausted 时是证明 |
| 截断 | 撞上 maxSteps / maxNodes / maxBranch | 搜索器如实置位 | 极易触发 | 只是「没搜到」 |
前两类是状态级判据,后两类是整图级判据。searchSolvable 在搜索时顺带计数 deadlocks 与 stalls,于是四种形态能在一次搜索里同时报出来。四条判据在 4.3 的模块里各有落点,本节脚本一一对应地复刻:
| 判据 | solvability.ts 里的落地 | 本节脚本里的落地 |
|---|---|---|
| 合法动作集 | legalActions(spec, s, allowEndTurn) | acts_of(spec, s, allow_e) |
| 死锁 | isDeadlock(spec, s, allowEndTurn) | not acts_of(...) 与 deadlocks 计数 |
| 停滞 | isStall(spec, seq, s) | is_stall(spec, seq, s, allow_e) |
| 可解 / 不可解 / 截断 | searchSolvable(spec, limits) | search(spec, max_steps, ...) |
函数名一一对应不是巧合——本节的 Python 脚本刻意与 solvability.ts 同口径,才能拿它复算同一批夹具、得到一致的数字(baseline 1517 个状态、8 步解,与 4.3 逐字相同)。跨语言对照的意义正在于此:同一套判据、两种宿主,结论必须对得上;对不上就说明有一侧实现漂移了。第 15 章会把这种对照做成常设的偏差检查。
下面这段 S 层脚本用与 solvability.ts 同口径的 Python BFS,把四个夹具各推一类:
#!/usr/bin/env python3
# file: sim/gd_mech_10_detection.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_10_detection.py
"""10.3 死锁与可解性检测(S 层)。
4.3 已交付 core/src/ashdeck/solvability.ts(BFS + digest 去重 + 三重上限)。本节在它
**之上**给一张判据表,把四种「卡住」分门别类检测出来,并说清可靠性边界:
可解 找到取胜序列 → solvable=True,恒可靠(有证据)
不可解证明 前沿被穷尽(exhausted=True)→ solvable=False 是证明
截断 撞上某个上限(truncated=True)→ 只能说「限深内没搜到」
死锁 未结束且无合法动作(摘掉 e 的动作集变体下才可达)
用与 solvability.ts 同口径的 Python BFS 逐 fixture 复算,输出 CSV 与判据表。
全整数,无浮点,无 random。
"""
from __future__ import annotations
import copy
import os
import sys
import ashlib
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def cost(spec, cid):
return next((c["cost"] for c in spec["cards"] if c["id"] == cid), 999999)
def acts_of(spec, s, allow_e=True):
if s["over"]:
return []
a = ["p" + str(i) for i, cid in enumerate(s["hand"]) if cost(spec, cid) <= s["energy"]]
if allow_e:
a.append("e")
return a
def is_stall(spec, seq, s, allow_e=True):
if s["over"]:
return False
a = acts_of(spec, s, allow_e)
if not a:
return False
base = ashlib.digest(s)
return all(ashlib.digest(ashlib.run_spec(spec, seq + [x])) == base for x in a)
def search(spec, max_steps=12, max_nodes=200000, max_branch=6, allow_e=True):
"""与 core/src/ashdeck/solvability.ts 的 searchSolvable 同口径。"""
seen = {ashlib.digest(ashlib.run_spec(spec, []))}
frontier = [[]]
nodes, expanded, deadlocks, stalls, max_seen = 1, 0, 0, 0, 0
solution, exhausted, truncated = None, False, False
for _ in range(1, max_steps + 1):
nxt = []
for seq in frontier:
s = ashlib.run_spec(spec, seq)
expanded += 1
a = acts_of(spec, s, allow_e)
max_seen = max(max_seen, len(a))
if not s["over"] and not a:
deadlocks += 1
if is_stall(spec, seq, s, allow_e):
stalls += 1
if len(a) > max_branch:
truncated, a = True, a[:max_branch]
for x in a:
child = seq + [x]
snap = ashlib.run_spec(spec, child)
if snap["won"]:
solution = child
break
g = ashlib.digest(snap)
if g in seen:
continue
if nodes >= max_nodes:
truncated = True
break
seen.add(g)
nodes += 1
nxt.append(child)
if solution is not None or truncated:
break
if solution is not None or truncated:
break
if not nxt:
exhausted = True
break
frontier = nxt
if solution is None and not exhausted:
truncated = True
return {"solvable": solution is not None, "solution": solution or [], "nodes": nodes,
"expanded": expanded, "deadlocks": deadlocks, "stalls": stalls,
"maxBranchSeen": max_seen, "exhausted": exhausted, "truncated": truncated}
def verdict(r):
"""四类判据,按特异性从高到低判定。"""
if r["solvable"]:
return "可解(证据)"
if r["deadlocks"] > 0:
return "死锁"
if r["exhausted"] and not r["truncated"]:
return "不可解(证明)"
return "截断(未搜到)"
def main():
spec = ashlib.load_spec()
deep = copy.deepcopy(spec)
deep["params"]["enemyHp"] = 999
deep["params"]["turnLimit"] = 2
fixtures = [("baseline", spec, 12, True), ("unsolvable", deep, 12, True),
("truncated", deep, 8, True), ("deadlock", spec, 12, False)]
header = ["fixture", "solvable", "solLen", "nodes", "expanded", "deadlocks",
"stalls", "exhausted", "truncated", "verdict"]
rows = []
for name, fx, steps, allow_e in fixtures:
r = search(fx, max_steps=steps, allow_e=allow_e)
rows.append([name, str(r["solvable"]).lower(), len(r["solution"]), r["nodes"],
r["expanded"], r["deadlocks"], r["stalls"], str(r["exhausted"]).lower(),
str(r["truncated"]).lower(), verdict(r)])
n = ashlib.write_csv(os.path.join(ROOT, "out", "mech10_detection.csv"), header, rows)
print(" ".join(header))
for r in rows:
print(" ".join(str(x) for x in r))
print("wrote out/mech10_detection.csv rows=%d" % n)
kinds = {r[0]: r[9] for r in rows}
ok = (kinds["baseline"] == "可解(证据)" and kinds["unsolvable"] == "不可解(证明)"
and kinds["truncated"] == "截断(未搜到)" and kinds["deadlock"] == "死锁")
print("verdicts=%s" % kinds)
print("allFourDistinct=%s" % ok)
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/gd_mech_10_detection.py:
fixture solvable solLen nodes expanded deadlocks stalls exhausted truncated verdict
baseline true 8 1517 912 0 0 false false 可解(证据)
unsolvable false 0 420 420 0 0 true false 不可解(证明)
truncated false 0 408 382 0 0 false true 截断(未搜到)
deadlock false 0 18 18 3 0 true false 死锁
wrote out/mech10_detection.csv rows=4
verdicts={'baseline': '可解(证据)', 'unsolvable': '不可解(证明)', 'truncated': '截断(未搜到)', 'deadlock': '死锁'}
allFourDistinct=True
四个夹具各命中一类,allFourDistinct=True。baseline 1517 个状态找到 8 步取胜序列;unsolvable(敌方 999 血 + 2 回合上限)穷尽 420 个状态后判不可解,exhausted=true;同一个夹具把深度压到 8 步,exhausted 翻成 false、truncated 翻成 true,结论降级为「没搜到」;deadlock 夹具摘掉 e,18 个状态里撞上 3 个真死锁。这四个数字并列,就是判据表存在的意义——同一个 solvable=false,配上不同的布尔组合,说的完全是两回事。
P 层把判据钉死在 solvability.ts 上,关键是「边界」那一条:
// snippet: core/test/gd_mech_10_detection.test.ts 的关键断言
const r = searchSolvable(unsolvable()); // 敌方 999 血 + 2 回合上限
assert.equal(r.solvable, false);
assert.equal(r.exhausted, true); // 前沿被穷尽 → 这是证明
assert.equal(r.truncated, false);
const base = searchSolvable(SPEC);
assert.equal(base.solvable, true);
assert.equal(runSpec(SPEC, base.solution).won, true); // true 有证据,可重放
const short = searchSolvable(unsolvable(), { ...DEFAULT_LIMITS, maxSteps: 8 });
assert.equal(short.exhausted, false);
assert.equal(short.truncated, true); // 撞上限 → 只能说「没搜到」
实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_10_detection.test.ts',本节 5 条的真实输出:
✔ 判据一:无合法动作即死锁,但本规则下 e 恒合法故默认不可达 (0.752417ms)
✔ 判据二:无进展循环即停滞——e 必然推进 turn,故基线恒 false (0.243833ms)
✔ 判据三:资源不可达目标即不可解,穷尽前沿后是证明 (5.0865ms)
✔ 证明边界:true 有证据恒可靠,false 撞上限时只能说没搜到 (16.138125ms)
✔ 基线分类:可解、死锁 0、停滞 0——判据一与判据二在本规则下不可达 (11.409458ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 241.324542
证明边界是这一节最不能含糊的一条:solvable === true 由一条取胜序列作证,无论上限多紧都成立,证据不受搜索预算影响;solvable === false 只有在 exhausted === true(前沿被穷尽)时才是证明,一旦 truncated === true,false 只意味着「限深内没搜到」。把这两个布尔分开返回,而不是合成一个 solvable,是 4.3 那个模块唯一不能省的设计——本节只是把它用起来。
死锁判据还要自证会响。本规则下 e 恒合法,所以死锁不可达;deadlock 夹具用的是动作集变体:acts_of(spec, s, allow_e=False) 把 e 摘掉,空手牌 + 0 能量就成了真死锁。这不是给规则开后门,而是给判据配一个能判 true 的负例——与 4.2、10.2 一脉相承:没有反面的判据等于没说。
判据的顺序也有讲究。verdict() 先判 solvable、再判 deadlocks、最后才判 exhausted,依据是特异性从高到低:一条取胜序列是最强证据,压过一切;「撞上了死锁状态」比「整体不可解」更具体,先报;只有前两者都不成立,才轮到「前沿穷尽」这种全局结论。顺序错了,deadlock 夹具会被误报成「不可解」,判据表就退化回一个布尔。这条规则值得写进任何检测器的设计里:先报最强的证据,再报最具体的发现,最后才是全局兜底。
四种判据的分工
| 判据 | 报什么 | 不该报什么 |
|---|---|---|
| 死锁 | 某个状态走不动了 | 不要报成「整局不可解」 |
| 停滞 | 有动作但都是空操作 | 不要与死锁混为一谈 |
| 不可解 | 穷尽后无解(证明) | 不要把「没搜到」报成不可解 |
| 截断 | 撞了上限 | 不要报成任何确定性结论 |
| 检查项 | 合格标准 |
|---|---|
| 判据分得清 | 死锁 / 停滞 / 不可解 / 截断四种形态各有定义与检测方式 |
| 结论分级 | solvable / exhausted / truncated 三个布尔分开返回 |
| 边界写明 | true 有证据恒可靠;false 仅 exhausted 时是证明 |
| 判据会响 | 死锁用动作集变体造出真负例,不停留在「不可达」 |
| 上限诚实 | 撞上限即标 truncated,绝不把「没搜到」报成「不可解」 |
到这里,状态空间的三问就答完了:能走到哪(10.1 的可达集)、哪些状态不允许存在(10.2 的清单)、整张图通不通(10.3 的判据表)。三者都跑在命令行里,数字都是实跑的。下一章离开状态语义,回到数值本身:同一份规格要让 Python 与 TypeScript 给出逐位一致的结果,得先把所有预算与阈值整数化。
阅读导航:上一节:10.2 不变量清单 · 下一节:11.1 整数化与跨语言一致 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。