13.3 验收标准与返工归因

验收不是打勾,是算账。本节把《星屑旅店》的八批交付做成评分卡(整数分 ×10,加权后过线即通过)与返工归因分类账,用一份真跑的 S 层脚本算一次通过率、返工率与各类归因占比,并用 P 层 TS 内核复算到逐位一致;再故意造两份冲突账,证明评分与结论打架、归因不在允许集里都会被拦下。

13.1 管需求怎么写清、13.2 管规格对不对得上;这一节收口到最后一环:交付来了,收还是不收,怎么判。风格指南那一层,《Art Bible 与风格指南》 讲的是指南怎么立;本节不重推指南本身,只做验收评分卡与返工归因的可算账——把「这批货行不行」变成整数分与通过率。

13.3.1 验收不是打勾,是算账

「我觉得差不多了」不是验收结论。验收要能回答三个可数的问题:

  • 这批交付过没过线?(评分卡 → 通过 / 打回)
  • 一批活里一次就过的比例是多少?(一次通过率)
  • 被打回的活,因为什么被打回?(返工归因占比)

13.3.2 D 层:评分卡与批次账

下面这段是 D 层配置 config/sp13_acceptance.json:评分卡四个维度带权重,passScore10 是通过线;ledger 是八批交付记录,badLedger 故意制造两类违例:

{
  "project": "stardust-inn",
  "schemaVersion": 1,
  "specRef": "config/visual_spec.json",
  "note": "验收评分卡 + 返工归因分类账。评分卡每维整数分(×10)带权重;passScore10 是通过线。ledger 是交付批次记录,每条带 scores10(与 scorecard 同序)、pass 布尔与归因类别(通过时 null)。S 层算加权分、一次通过率/返工率与各类归因占比(全部千分比整数地板除)。badLedger 故意制造「评分与结论冲突」与「未知归因」两类违例。",
  "passScore10": 700,
  "scorecard": [
    { "dim": "silhouette", "cn": "剪影可读性", "weight": 3 },
    { "dim": "palette", "cn": "调色板一致", "weight": 2 },
    { "dim": "techspec", "cn": "技术规格达标", "weight": 2 },
    { "dim": "detail", "cn": "细节密度", "weight": 1 }
  ],
  "attributions": ["spec", "art", "tech", "ref"],
  "ledger": [
    { "id": "BATCH-001", "scores10": [880, 820, 900, 760], "pass": true, "attribution": null },
    { "id": "BATCH-002", "scores10": [640, 700, 520, 700], "pass": false, "attribution": "tech" },
    { "id": "BATCH-003", "scores10": [900, 910, 880, 900], "pass": true, "attribution": null },
    { "id": "BATCH-004", "scores10": [700, 660, 720, 650], "pass": false, "attribution": "art" },
    { "id": "BATCH-005", "scores10": [820, 840, 860, 800], "pass": true, "attribution": null },
    { "id": "BATCH-006", "scores10": [600, 620, 640, 610], "pass": false, "attribution": "ref" },
    { "id": "BATCH-007", "scores10": [680, 700, 690, 660], "pass": false, "attribution": "spec" },
    { "id": "BATCH-008", "scores10": [860, 880, 840, 880], "pass": true, "attribution": null }
  ],
  "badLedger": [
    { "id": "BATCH-901", "scores10": [700, 700, 700, 700], "pass": true, "attribution": null },
    { "id": "BATCH-902", "scores10": [500, 500, 500, 500], "pass": true, "attribution": null },
    { "id": "BATCH-903", "scores10": [600, 600, 600, 600], "pass": false, "attribution": "mood" }
  ]
}

四个维度的分都是整数 ×10(880 就是 88.0 分),权重 3/2/2/1 表示剪影最重要、细节最轻。attribution 只有四个允许值:spec(需求不清)、art(画得不行)、tech(技术规格不达标)、ref(参考不到位)。

13.3.3 S 层:加权分、通过率与归因占比

下面这段是 S 层脚本。加权分用整数地板除,通过率与归因占比全部是千分比:

#!/usr/bin/env python3
# file: sim/sp13_acceptance.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp13_acceptance.py
"""13.3 验收标准与返工归因的 S 层账。

读 config/sp13_acceptance.json。对交付批次账(ledger)做两件事:

  1. 评分卡:加权分 = Σ(分 × 权重) // Σ权重(整数地板除),通过 = 加权分 ≥ passScore10;
     评分结论与记录结论不一致记 CONFLICT:<id>。
  2. 归因账:一次通过率 / 返工率 / 各类归因占比,全部千分比整数地板除;
     归因不在允许集里记 ATTR:<id>:<value>。

对正账与 badLedger 各算一遍:正账必须零违例,反账必须被逐条报出。
产出 out/sp13_acceptance.json,供 P 层跨语言对照。全部整数类,逐位一致。
"""

from __future__ import annotations

import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
CFG = os.path.join(ROOT, "config/sp13_acceptance.json")
OUT = os.path.join(ROOT, "out")


def load(p):
    with open(p, encoding="utf-8") as f:
        return json.load(f)


def weighted_score10(scores: list, weights: list) -> int:
    num = sum(s * w for s, w in zip(scores, weights))
    den = sum(weights)
    return num // den


def evaluate(ledger: list, weights: list, pass_score10: int, attributions: list):
    passed = reworked = 0
    conflicts, attr_bad, rows = [], [], []
    attr_count = {a: 0 for a in attributions}
    for e in ledger:
        ws = weighted_score10(e["scores10"], weights)
        pass_by_score = ws >= pass_score10
        if pass_by_score != e["pass"]:
            conflicts.append(f"CONFLICT:{e['id']}")
        if e["pass"]:
            passed += 1
        else:
            reworked += 1
            a = e.get("attribution")
            if a not in attributions:
                attr_bad.append(f"ATTR:{e['id']}:{a}")
            else:
                attr_count[a] += 1
        rows.append({"id": e["id"], "weightedScore10": ws,
                     "passByScore": pass_by_score, "pass": e["pass"]})
    total = len(ledger)
    first_pass_permille = passed * 1000 // total if total else 0
    rework_permille = reworked * 1000 // total if total else 0
    attr_permille = {a: (attr_count[a] * 1000 // reworked if reworked else 0) for a in attributions}
    return {
        "total": total, "passed": passed, "reworked": reworked,
        "firstPassPermille": first_pass_permille, "reworkPermille": rework_permille,
        "attrCount": attr_count, "attrPermille": attr_permille, "rows": rows,
        "violations": sorted(attr_bad + conflicts),
    }


def main() -> int:
    cfg = load(CFG)
    weights = [d["weight"] for d in cfg["scorecard"]]
    good = evaluate(cfg["ledger"], weights, cfg["passScore10"], cfg["attributions"])
    bad = evaluate(cfg["badLedger"], weights, cfg["passScore10"], cfg["attributions"])
    report = {
        "project": cfg["project"],
        "passScore10": cfg["passScore10"],
        "weights": weights,
        "attributions": cfg["attributions"],
        "good": good,
        "bad": bad,
    }

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "sp13_acceptance.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    for label, r in (("good", good), ("bad", bad)):
        print(f"[{label}] total={r['total']} passed={r['passed']} reworked={r['reworked']} "
              f"firstPass={r['firstPassPermille']} rework={r['reworkPermille']} "
              f"violations={r['violations']}")
    print(f"attrCount={good['attrCount']}")
    print(f"attrPermille={good['attrPermille']}")
    print(f"weightedScore10={[r['weightedScore10'] for r in good['rows']]}")
    print("wrote out/sp13_acceptance.json")
    return 0


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

实跑 /opt/homebrew/bin/python3 sim/sp13_acceptance.py:

[good] total=8 passed=4 reworked=4 firstPass=500 rework=500 violations=[]
[bad] total=3 passed=2 reworked=1 firstPass=666 rework=333 violations=['ATTR:BATCH-903:mood', 'CONFLICT:BATCH-902']
attrCount={'spec': 1, 'art': 1, 'tech': 1, 'ref': 1}
attrPermille={'spec': 250, 'art': 250, 'tech': 250, 'ref': 250}
weightedScore10=[855, 632, 897, 688, 832, 616, 685, 862]
wrote out/sp13_acceptance.json

正账八批、四过四返:一次通过率 500‰、返工率 500‰,四类归因各占 250‰(合计正好 1000‰)。八批的加权分依次是 [855, 632, 897, 688, 832, 616, 685, 862]——632 与 688 卡在通过线 700 以下,正是被打回的那两批。反账里 BATCH-902 分数只有 500 却记了 pass: true,与判据打架(CONFLICT);BATCH-903 的归因 mood 不在允许集里(ATTR)。

13.3.4 P 层:同一套账的 TS 内核

下面这段是 P 层 TS 内核 core/src/sp13/acceptance.ts,与 Python 同一套整数规则:

// file: core/src/sp13/acceptance.ts
// tier: P
// verify: node --test 'core/test/sp13_*.test.ts'
// 13.3 验收标准与返工归因的 TS 侧内核。与 sim/sp13_acceptance.py 同一套整数规则:
// 加权分 = Σ(分 × 权重) // Σ权重(整数地板除),通过 = 加权分 ≥ passScore10;
// 一次通过率/返工率/各类归因占比全部千分比整数地板除。整数类逐位一致。

export function weightedScore10(scores: number[], weights: number[]): number {
  let num = 0;
  let den = 0;
  for (let i = 0; i < scores.length; i++) {
    num += scores[i] * weights[i];
    den += weights[i];
  }
  return Math.floor(num / den);
}

export function evaluateLedger(ledger: any[], weights: number[], passScore10: number, attributions: string[]): any {
  let passed = 0;
  let reworked = 0;
  const conflicts: string[] = [];
  const attrBad: string[] = [];
  const attrCount: any = {};
  for (const a of attributions) attrCount[a] = 0;
  const rows: any[] = [];
  for (const e of ledger) {
    const ws = weightedScore10(e.scores10, weights);
    const passByScore = ws >= passScore10;
    if (passByScore !== e.pass) conflicts.push("CONFLICT:" + e.id);
    if (e.pass) {
      passed += 1;
    } else {
      reworked += 1;
      const a = e.attribution;
      if (!attributions.includes(a)) attrBad.push("ATTR:" + e.id + ":" + a);
      else attrCount[a] += 1;
    }
    rows.push({ id: e.id, weightedScore10: ws, passByScore, pass: e.pass });
  }
  const total = ledger.length;
  const firstPassPermille = total ? Math.floor((passed * 1000) / total) : 0;
  const reworkPermille = total ? Math.floor((reworked * 1000) / total) : 0;
  const attrPermille: any = {};
  for (const a of attributions) attrPermille[a] = reworked ? Math.floor((attrCount[a] * 1000) / reworked) : 0;
  return {
    total, passed, reworked, firstPassPermille, reworkPermille,
    attrCount, attrPermille, rows, violations: [...attrBad, ...conflicts].sort(),
  };
}

core/test/sp13_acceptance.test.ts 读 Python 产物做跨语言对照,并用内存样本构造「评分结论冲突」与「未知归因」两个负例:

// file: core/test/sp13_acceptance.test.ts
// tier: P
// verify: node --test 'core/test/sp13_*.test.ts'
// 跨语言对照:读 sim/sp13_acceptance.py 落盘的 out/sp13_acceptance.json,断言 TS 侧的
// 加权分、通过率与归因占比「逐位一致」。负例覆盖评分结论冲突与未知归因两类。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { evaluateLedger, weightedScore10 } from "../src/sp13/acceptance.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp13_acceptance.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp13_acceptance.json"), "utf8"));
const weights: number[] = cfg.scorecard.map((d: any) => d.weight);

test("正账:加权分、通过率与归因占比与 Python 产物逐位一致", () => {
  const mine = evaluateLedger(cfg.ledger, weights, cfg.passScore10, cfg.attributions);
  assert.deepEqual(mine.rows, py.good.rows);
  assert.equal(mine.firstPassPermille, py.good.firstPassPermille);
  assert.equal(mine.reworkPermille, py.good.reworkPermille);
  assert.deepEqual(mine.attrCount, py.good.attrCount);
  assert.deepEqual(mine.attrPermille, py.good.attrPermille);
  assert.deepEqual(mine.violations, py.good.violations);
});

test("正账:8 批一次通过率 500‰、四类归因各 250‰", () => {
  const mine = evaluateLedger(cfg.ledger, weights, cfg.passScore10, cfg.attributions);
  assert.equal(mine.total, 8);
  assert.equal(mine.passed, 4);
  assert.equal(mine.firstPassPermille, 500);
  assert.equal(mine.reworkPermille, 500);
  assert.deepEqual(mine.attrPermille, { spec: 250, art: 250, tech: 250, ref: 250 });
  assert.equal(mine.violations.length, 0);
});

test("badLedger:评分冲突与未知归因都必须被报出", () => {
  const mine = evaluateLedger(cfg.badLedger, weights, cfg.passScore10, cfg.attributions);
  assert.deepEqual(mine.violations, py.bad.violations);
  assert.ok(mine.violations.includes("CONFLICT:BATCH-902"));
  assert.ok(mine.violations.includes("ATTR:BATCH-903:mood"));
});

test("负例:加权分低于通过线却记 pass 必须报冲突", () => {
  const ledger = [{ id: "X", scores10: [100, 100, 100, 100], pass: true, attribution: null }];
  const r = evaluateLedger(ledger, weights, cfg.passScore10, cfg.attributions);
  assert.equal(weightedScore10([100, 100, 100, 100], weights), 100);
  assert.deepEqual(r.violations, ["CONFLICT:X"]);
});

test("负例:归因不在允许集里必须被报出", () => {
  const ledger = [{ id: "Y", scores10: [100, 100, 100, 100], pass: false, attribution: "ghost" }];
  const r = evaluateLedger(ledger, weights, cfg.passScore10, cfg.attributions);
  assert.deepEqual(r.violations, ["ATTR:Y:ghost"]);
});

实跑 node --test 'core/test/sp13_*.test.ts'(此处只列本节相关的 5 条;三节合计 14 条全绿):

✔ 正账:加权分、通过率与归因占比与 Python 产物逐位一致
✔ 正账:8 批一次通过率 500‰、四类归因各 250‰
✔ badLedger:评分冲突与未知归因都必须被报出
✔ 负例:加权分低于通过线却记 pass 必须报冲突
✔ 负例:归因不在允许集里必须被报出
ℹ tests 14
ℹ pass 14
ℹ fail 0

加权分、通过率、占比全是整数(地板除),TS 的 Math.floor 与 Python 的 // 对正数完全同构,逐位一致;本节不涉及色彩派生量,故没有浮点容差问题。

13.3.5 返工归因怎么用

归因账不是拿来追责的,是拿来改流程的:

归因典型症状流程动作
spec多批都栽在同一处回头改 13.1 的 Brief,补字段
art剪影 / 调色板分低换参考、加中期评审
tech通道 / 命名错强化 13.2 的交付前自检

三条纪律:

  1. 归因占比要和通过率一起看。一次通过率 500‰ 只是「一半要返工」;归因占比才告诉你「返工里四成是需求问题」。只看通过率会误判成画师不行。
  2. 通过线是拍的,但加权分是算的。passScore10 = 700 可以调,但一旦定了,评分结论与记录结论必须一致——不一致就是 CONFLICT,门禁会拦。
  3. 归因必须闭集。mood 这种临时造的词会让统计口径崩掉;要新增归因,先改配置里的 attributions,不能随手写。

13.3.6 边界与取舍

  • 真人评审的实际打分(审美、手感)需要真实环境(评审会 / 真人),命令行不可复现,标 X;本节只做评分口径与归因统计的整数账,不预测真实评分。
  • 全部量为整数类,TS 与 Python 逐位一致。

阅读导航:上一节:13.2 技术规格与交付物清单 · 下一节:14.1 评审的时机与参与角色 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则