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 的交付前自检 |
三条纪律:
- 归因占比要和通过率一起看。一次通过率 500‰ 只是「一半要返工」;归因占比才告诉你「返工里四成是需求问题」。只看通过率会误判成画师不行。
- 通过线是拍的,但加权分是算的。
passScore10 = 700可以调,但一旦定了,评分结论与记录结论必须一致——不一致就是CONFLICT,门禁会拦。 - 归因必须闭集。
mood这种临时造的词会让统计口径崩掉;要新增归因,先改配置里的attributions,不能随手写。
13.3.6 边界与取舍
- 真人评审的实际打分(审美、手感)需要真实环境(评审会 / 真人),命令行不可复现,标 X;本节只做评分口径与归因统计的整数账,不预测真实评分。
- 全部量为整数类,TS 与 Python 逐位一致。
阅读导航:上一节:13.2 技术规格与交付物清单 · 下一节:14.1 评审的时机与参与角色 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。