15.3 偏差与容差

容差不是随口写的数字,是可执行判据 |left − right| ≤ tol。本节给出三档容差:整数精确(tol=0,必须相等)、千分比(tol=1‰,地板与四舍五入两种口径最多差 1)、浮点表现层(tol=1e-6,且必须显式断言 left !== right)。

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 === right0
千分比胜率 / 熵 ×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.999999999999996
  • 18.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。这是唯一可以写「逐位一致」的档。
  • 千分比档:66 vs 67,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 规格装配检查表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡