15.3 偏差与容差
15.2 同源不同宿主
把三处消费的语言原语差异量清楚了,但还没回答一个更实际的问题:量出来差了一点,算不算错。《引擎实现与体验打磨》
那套在宿主内打磨手感,偏差是它调参的对象;本节恰恰相反——偏差是本节要判定的对象。判定的形式只有一条:|left − right| ≤ tol。本节把这条判据按量的类型分三档,各给一个可跑的判据,并说清为什么浮点那一档必须显式断言 left !== right。
容差判据:一条不等式
三处消费的任何一个量,比对时都写成同一条不等式:
within(left, right, tol) = |left - right| <= tol
差别全在 tol 取什么、left 与 right 是什么类型。tol = 0 时它退化成等式(left === right),这就是机制层;tol > 0 时它是一条区间,这就是派生量与表现层。没有「大概相等」这种东西——要么写得出 tol,要么就不该比。
三档容差
本项目把容差按量的类型分三档声明,每档有名字、有理由、有可执行判据:
| 档 | 量 | 类型 | 判据 | 容差 |
|---|---|---|---|---|
| 整数精确 | dealt / enemyHp / turn | 整数 | left === right | 0 |
| 千分比 | 胜率 / 熵 ×1000 | 整数(‰) | ` | left − right |
| 浮点表现层 | 像素位移 / 动画时长 | 浮点 | ` | left − right |
三档的关键区别不在数值大小,在**「相不相等」这句话本身成不成立**:整数档里「相等」是有意义的、必须成立的;千分比档里两种合法报告口径可以差 1,所以只断言「差不超过 1」;浮点档里「相等」根本不可能成立(浮点加法不满足结合律),所以判据必须换成「落在容差内」,并把「不相等」也写下来。
S 层:三档各跑一遍
脚本把三档各跑一遍:整数档核对 dealt 与「起始敌方血量 − 结束敌方血量」相等;千分比档用同一份胜场计数、两种合法报告口径(地板 vs 四舍五入)算胜率;浮点档用两种求和顺序算同一组位移。
#!/usr/bin/env python3
# file: sim/gd_mech_15_tolerance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_15_tolerance.py
"""15.3 偏差与容差(S 层):容差判据的可执行三档。
「三处消费」里,只有一部分量能要求逐字相同。按量类型分三档,各给一条可执行判据:
① 整数精确(机制层) dealt / enemyHp / turn / cardCount —— tol = 0,必须 `==`;
② 千分比(聚合派生量)胜率、熵×1000 等整数‰ —— tol = 1‰,地板与四舍五入两种
合法报告口径之间最多差 1,故只断言 `|left-right| <= 1`,
而**不**要求相等;
③ 浮点表现层 像素位移、动画时长 —— 只断言 `|left-right| <= tol`,并且
**显式断言 left != right**(浮点加法不满足结合律,它本来
就不可能逐位相等)。
本脚本把三档各跑一遍并落 out/mech15_tolerance.csv 与 out/gd_mech_15_tolerance.json。
"""
from __future__ import annotations
import copy
import json
import os
import ashlib
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
PIXEL_TOL = 1e-6 # 表现层:亚像素差肉眼不可辨
SEGMENTS = [18.4, 6.7, 2.9] # 表现层:三段屏幕位移(浮点,仅只读展示)
WIN_SEEDS = 30
WIN_STRATEGY = ["p0", "p0", "e", "p0", "p0", "e", "p0", "p0"]
def within(left, right, tol) -> bool:
return abs(left - right) <= tol
def win_permille_floor(spec: dict) -> int:
"""地板口径胜率(‰):wins*1000 // N。"""
wins = 0
for i in range(WIN_SEEDS):
sp = copy.deepcopy(spec)
sp["seed"] = spec["seed"] + i
wins += 1 if ashlib.run_spec(sp, WIN_STRATEGY)["won"] else 0
return wins * 1000 // WIN_SEEDS
def win_permille_round(spec: dict) -> int:
"""四舍五入口径胜率(‰):(wins*1000 + N//2) // N。同一份计数,另一种合法报告。"""
wins = 0
for i in range(WIN_SEEDS):
sp = copy.deepcopy(spec)
sp["seed"] = spec["seed"] + i
wins += 1 if ashlib.run_spec(sp, WIN_STRATEGY)["won"] else 0
return (wins * 1000 + WIN_SEEDS // 2) // WIN_SEEDS
def main() -> int:
spec = ashlib.load_spec()
snap = ashlib.run_spec(spec, list(spec["strategy"]))
# ① 整数精确:dealt 与「起始敌方血量 − 结束敌方血量」两条路必须相等(tol = 0)。
dealt = snap["dealt"]
conserved = spec["params"]["enemyHp"] - snap["enemyHp"]
t1 = {"left": dealt, "right": conserved, "tol": 0,
"equal": dealt == conserved, "within": within(dealt, conserved, 0)}
# ② 千分比:同一份胜场计数,地板口径 vs 四舍五入口径,最多差 1‰。
floor_pm = win_permille_floor(spec)
round_pm = win_permille_round(spec)
t2 = {"left": floor_pm, "right": round_pm, "tol": 1,
"equal": floor_pm == round_pm, "within": within(floor_pm, round_pm, 1),
"diff": abs(floor_pm - round_pm)}
# ③ 浮点表现层:同一组位移两种求和顺序,末位不同但落在容差内。
left = (SEGMENTS[0] + SEGMENTS[1]) + SEGMENTS[2]
right = SEGMENTS[0] + (SEGMENTS[1] + SEGMENTS[2])
t3 = {"left": left, "right": right, "tol": PIXEL_TOL,
"notEqual": left != right, "within": within(left, right, PIXEL_TOL)}
ok = t1["within"] and t2["within"] and t3["notEqual"] and t3["within"]
rows = [
["integer_exact", f"{dealt}", f"{conserved}", "0", str(t1["within"]).lower()],
["permille", f"{floor_pm}", f"{round_pm}", "1", str(t2["within"]).lower()],
["perceptual_float", repr(left), repr(right), repr(PIXEL_TOL), str(t3["within"]).lower()],
]
csv_path = os.path.join(ROOT, "out", "mech15_tolerance.csv")
ashlib.write_csv(csv_path, ["tier", "left", "right", "tol", "within"], rows)
report = {
"spec": spec["id"],
"integerExact": t1,
"permille": t2,
"perceptualFloat": t3,
"winSeeds": WIN_SEEDS,
"winStrategy": WIN_STRATEGY,
"ok": ok,
}
out = os.path.join(ROOT, "out", "gd_mech_15_tolerance.json")
with open(out, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"integer_exact left={dealt} right={conserved} tol=0 within={t1['within']}")
print(f"permille left={floor_pm} right={round_pm} tol=1 diff={t2['diff']} "
f"equal={t2['equal']} within={t2['within']}")
print(f"perceptual_float left={left!r} right={right!r} not_equal={t3['notEqual']} "
f"within={t3['within']} tol={PIXEL_TOL!r}")
print(f"all_tiers_ok={ok}")
print(f"wrote out/gd_mech_15_tolerance.json wrote out/mech15_tolerance.csv rows={len(rows)}")
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
实跑 /opt/homebrew/bin/python3 sim/gd_mech_15_tolerance.py:
integer_exact left=50 right=50 tol=0 within=True
permille left=66 right=67 tol=1 diff=1 equal=False within=True
perceptual_float left=27.999999999999996 right=28.0 not_equal=True within=True tol=1e-06
all_tiers_ok=True
wrote out/gd_mech_15_tolerance.json wrote out/mech15_tolerance.csv rows=3
读出来:整数档 left=50 right=50 within=True,tol=0 就是等式;千分比档 left=66 right=67,diff=1、equal=False、within=True——不相等,但在容差内;浮点档 not_equal=True、within=True。三档同时成立,all_tiers_ok=True。
P 层:把判据本身钉成断言
TS 侧把 within 判据与三档各写一遍,并对容差判据本身给一个反面——一个永远返回 true 的容差断言和没有一样。
// file: core/test/gd_mech_15_tolerance.test.ts
// tier: P
// verify: node --test 'core/test/gd_mech_15_tolerance.test.ts'
//
// 15.3 偏差与容差:容差判据的可执行三档。与 Python 侧 sim/gd_mech_15_tolerance.py
// 的实跑输出逐项对应:
// ① 整数精确 tol=0,必须相等;
// ② 千分比 tol=1‰,地板与四舍五入两种合法口径最多差 1,**不**要求相等;
// ③ 浮点表现层 |left-right|<=tol,且**显式断言 left !== right**。
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../src/ashdeck/combat.ts";
import type { Spec } from "../src/ashdeck/types.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const SPEC: Spec = JSON.parse(
readFileSync(join(HERE, "..", "..", "config", "play_spec.json"), "utf8"),
);
// 三档容差必须是有名字的常量,不是散落在断言里的魔法数字。
const TOL_INTEGER = 0;
const TOL_PERMILLE = 1;
const TOL_PIXEL = 1e-6;
function within(left: number, right: number, tol: number): boolean {
return Math.abs(left - right) <= tol;
}
// 千分比:同一份计数,地板 vs 四舍五入两种合法报告口径。
const winPermilleFloor = (wins: number, n: number): number => Math.floor((wins * 1000) / n);
const winPermilleRound = (wins: number, n: number): number =>
Math.floor((wins * 1000 + Math.floor(n / 2)) / n);
test("容差判据本身:tol=0 是等式,超容差必须判失败", () => {
assert.equal(within(50, 50, TOL_INTEGER), true);
assert.equal(within(50, 51, TOL_INTEGER), false);
assert.equal(within(27.999999999999996, 28.0, TOL_PIXEL), true);
assert.equal(within(28.0, 28.01, TOL_PIXEL), false);
});
test("① 整数精确档:dealt 与「起始敌方血量 − 结束敌方血量」相等", () => {
const snap = runSpec(SPEC, SPEC.strategy);
const conserved = SPEC.params.enemyHp - snap.enemyHp;
assert.equal(snap.dealt, 50);
assert.equal(conserved, 50);
assert.equal(snap.dealt, conserved); // tol = 0 就是相等
assert.equal(within(snap.dealt, conserved, TOL_INTEGER), true);
});
test("② 千分比档:地板与四舍五入两种口径最多差 1,且不要求相等", () => {
// 与 Python 侧一致:30 个种子、同一段短策略,胜场 2,66 vs 67。
assert.equal(winPermilleFloor(2, 30), 66);
assert.equal(winPermilleRound(2, 30), 67);
assert.equal(Math.abs(66 - 67), 1);
assert.equal(66 !== 67, true); // 它本来就不逐位相等
assert.equal(within(66, 67, TOL_PERMILLE), true);
// 边界:差值 2 就超出千分比档容差。
assert.equal(within(66, 68, TOL_PERMILLE), false);
});
test("③ 浮点表现层:末位不同,只按容差断言,并显式写下不相等", () => {
const seg = [18.4, 6.7, 2.9];
const left = (seg[0] + seg[1]) + seg[2];
const right = seg[0] + (seg[1] + seg[2]);
assert.notEqual(left, right); // 禁写「逐位相等」:它本来就不相等
assert.equal(within(left, right, TOL_PIXEL), true); // 只断言落在容差内
assert.equal(within(28.0, 28.01, TOL_PIXEL), false); // 反面:超容差判失败
});
test("三档容差是声明的常量,且严格性单调递增", () => {
assert.equal(TOL_INTEGER, 0);
assert.equal(TOL_PERMILLE, 1);
assert.equal(TOL_PIXEL > 0, true);
assert.equal(TOL_INTEGER < TOL_PERMILLE, true);
assert.equal(TOL_PERMILLE > TOL_PIXEL, true); // 不同档单位不同,只表严格程度
});
实跑 node --test '/Users/bingrong.yan/.gdbook/ashdeck/core/test/gd_mech_15_tolerance.test.ts':
✔ 容差判据本身:tol=0 是等式,超容差必须判失败 (0.563833ms)
✔ ① 整数精确档:dealt 与「起始敌方血量 − 结束敌方血量」相等 (0.259833ms)
✔ ② 千分比档:地板与四舍五入两种口径最多差 1,且不要求相等 (0.674625ms)
✔ ③ 浮点表现层:末位不同,只按容差断言,并显式写下不相等 (0.118542ms)
✔ 三档容差是声明的常量,且严格性单调递增 (0.079208ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
为什么浮点档要显式断言 left !== right
浮点档的判据是 |left − right| ≤ tol,但只写这一条会误导读者——他可能以为「容差内」等于「其实相等,只是精度不够」。事实相反:它本来就不可能相等。浮点加法不满足结合律,同一组数换个求和顺序,舍入点不同,末位就不同:
(18.4 + 6.7) + 2.9=27.99999999999999618.4 + (6.7 + 2.9)=28.0
所以断言必须写成两条:assert.notEqual(left, right) 加上 within(left, right, 1e-6)。前者把「不相等」这个事实钉下来,后者才是有意义的判据。这也解释了 11.3 那条纪律——正文与断言都不许写「逐位相等」:对浮点档,那根本不可能成立。
判读:三档各守什么
- 整数档:
dealt == enemyHp_start - enemyHp_end,tol = 0。这是唯一可以写「逐位一致」的档。 - 千分比档:
66vs67,diff = 1、equal = False、within = True。同一份计数、两种合法报告口径,不要求相等——但必须落在一档之内。 - 浮点档:
left !== right且|left − right| ≤ 1e-6。先写下不相等,再判容差。 - 反面:
within(28.0, 28.01, 1e-6) = False。容差断言必须能被超容差判失败。
| 检查项 | 合格标准 |
|---|---|
| 判据形式 | 一律 ` |
| 整数档 | tol = 0,等式,可写「逐位一致」 |
| 千分比档 | tol = 1,不要求相等,但必须落在档内 |
| 浮点档 | 先断言 left !== right,再断言容差 |
| 容差命名 | 三档都是有声明的常量,不是魔法数字 |
到这里,第 15 章把「三处消费」的最后一层收口了:摘要逐字相同(15.1)、宿主差异量清(15.2)、偏差有可执行判据(15.3)。下一章进入整卷的装配——把一份规格在四处消费上联调起来,并把这一卷交付给卷二。
阅读导航:上一节:15.2 同源不同宿主 · 下一节:16.1 规格装配检查表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。