14.2.1 没有数字的评审等于没评
14.1 排好了「谁在什么时候查什么」,但评审现场还差一件事:结论怎么算。如果结论是「我觉得不太行」,那返工的人不知道要改到什么程度才算行,改完还得再被「我觉得」一次。评分卡要解决的就是这件事——把评审结论变成可复算的整数。
一张评分卡由三样东西构成:
- 评分项:风格一致性、剪影可读性、命名合规、面数预算、图集槽位……每项一个整数分(10 分制 ×10,
90就是 9.0 分); - 整数权重:不同项重要性不同,用整数权重加权;
- 两条判定规则:加权分 ≥ 阈值(总体够好)且 无单项否决项低于底线(没有致命短板)。
14.2.2 D 层:评分卡配置
下面这段是 D 层配置 config/sp14_scorecard.json:5 个评分项、整数权重、整数分,阈值 passThreshold 与单项否决底线 vetoFloor。样本里 face-budget 是否决项且只拿了 55(低于底线 60),用来证明「加权够也照样打回」:
{
"project": "stardust-inn",
"schemaVersion": 1,
"specRef": "config/visual_spec.json",
"note": "评审评分卡:每项 score 是 0–100 的整数(即 10 分制 ×10,90 表示 9.0 分),weight 是整数权重。加权分 weighted = Σ(score × weight),满分 maxWeighted = 100 × Σweight。判定:weighted ≥ passThreshold 且无单项否决(veto 项 score ≥ vetoFloor)才通过。全整数,逐位一致。",
"passThreshold": 7500,
"vetoFloor": 60,
"items": [
{ "id": "style-consistency", "weight": 30, "score": 92, "veto": false },
{ "id": "silhouette-readability", "weight": 20, "score": 80, "veto": false },
{ "id": "naming-convention", "weight": 20, "score": 88, "veto": true },
{ "id": "face-budget", "weight": 20, "score": 55, "veto": true },
{ "id": "atlas-slot", "weight": 10, "score": 95, "veto": false }
]
}
注意单位:分数是 0–100 的整数(92 表示 9.2 分),阈值 7500 是在加权分尺度上——权重总和 100,满分就是 100 × 100 = 10000,阈值 7500 相当于「10 分制里的 7.5 分」。所有量都是整数,逐位一致。
14.2.3 S 层:加权分与判定
下面这段是 S 层脚本,算加权分、满分,再判「达阈」与「否决」两条:
#!/usr/bin/env python3
# file: sim/sp14_scorecard.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp14_scorecard.py
"""14.2 评审评分卡与通过/打回标准的 S 层算账。
读 config/visual_spec.json(只读)与 config/sp14_scorecard.json。
每项 score 是 0–100 的整数(10 分制 ×10),weight 是整数权重:
weighted = Σ(score × weight) ← 加权分(整数)
maxWeighted = 100 × Σweight ← 满分(整数)
weightedPass = weighted >= passThreshold
vetoLow = 该项是否决项且 score < vetoFloor
判定 verdict = "pass" 当且仅当 weightedPass 且无 vetoLow。
reasons 形如 "VETO:<项>:<score><<floor>"。全整数,逐位一致。
落 out/sp14_scorecard.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)
SPEC = os.path.join(ROOT, "config/visual_spec.json")
CFG = os.path.join(ROOT, "config/sp14_scorecard.json")
OUT = os.path.join(ROOT, "out")
def load(p):
with open(p, encoding="utf-8") as f:
return json.load(f)
def evaluate(cfg: dict):
floor = cfg["vetoFloor"]
items = []
for it in cfg["items"]:
items.append({"id": it["id"], "weight": it["weight"], "score": it["score"],
"veto": it["veto"], "vetoLow": it["veto"] and it["score"] < floor})
weight_total = sum(it["weight"] for it in cfg["items"])
weighted = sum(it["score"] * it["weight"] for it in cfg["items"])
max_weighted = 100 * weight_total
weighted_pass = weighted >= cfg["passThreshold"]
reasons = [f"VETO:{it['id']}:{it['score']}<{floor}" for it in items if it["vetoLow"]]
verdict = "pass" if (weighted_pass and not reasons) else "reject"
return {
"items": items, "weightTotal": weight_total, "maxWeighted": max_weighted,
"weighted": weighted, "threshold": cfg["passThreshold"], "vetoFloor": floor,
"weightedPass": weighted_pass, "vetoPass": not reasons,
"verdict": verdict, "reasons": reasons,
}
def main() -> int:
spec, cfg = load(SPEC), load(CFG)
r = evaluate(cfg)
summary = {"project": spec["project"], "specVersion": spec["specVersion"], **r}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "sp14_scorecard.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"weightTotal={r['weightTotal']} maxWeighted={r['maxWeighted']} "
f"weighted={r['weighted']} threshold={r['threshold']} "
f"weightedPass={r['weightedPass']} vetoPass={r['vetoPass']}")
for it in r["items"]:
mark = "VETO" if it["veto"] else " "
flag = "LOW" if it["vetoLow"] else "ok"
print(f" {it['id']:<22} w={it['weight']:<3} score={it['score']:<3} {mark} {flag}")
print(f"verdict={r['verdict']} reasons={r['reasons']}")
print("wrote out/sp14_scorecard.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp14_scorecard.py:
weightTotal=100 maxWeighted=10000 weighted=8170 threshold=7500 weightedPass=True vetoPass=False
style-consistency w=30 score=92 ok
silhouette-readability w=20 score=80 ok
naming-convention w=20 score=88 VETO ok
face-budget w=20 score=55 VETO LOW
atlas-slot w=10 score=95 ok
verdict=reject reasons=['VETO:face-budget:55<60']
wrote out/sp14_scorecard.json
读这张表:加权分 8170 ≥ 7500,weightedPass=True——总体是够好的。但 face-budget 只拿 55、低于否决底线 60,于是 vetoPass=False,判定 reject。这正是评分卡要立的规矩:总分不能掩盖致命短板。
14.2.4 P 层:同一套判定的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp14/scorecard.ts,与 Python 同一套整数规则:
// file: core/src/sp14/scorecard.ts
// tier: P
// verify: node --test 'core/test/sp14_*.test.ts'
// 14.2 评审评分卡与通过/打回标准的 TS 侧内核。与 sim/sp14_scorecard.py 同一套整数规则:
// weighted = Σ(score × weight),maxWeighted = 100 × Σweight;
// verdict = "pass" 当且仅当 weighted >= passThreshold 且无否决项低于 vetoFloor。
// 全整数,逐位一致。
export function evaluateScorecard(cfg: any): any {
const floor = cfg.vetoFloor;
const items = cfg.items.map((it: any) => ({
id: it.id, weight: it.weight, score: it.score, veto: it.veto,
vetoLow: it.veto && it.score < floor,
}));
const weightTotal = cfg.items.reduce((s: number, it: any) => s + it.weight, 0);
const weighted = cfg.items.reduce((s: number, it: any) => s + it.score * it.weight, 0);
const maxWeighted = 100 * weightTotal;
const weightedPass = weighted >= cfg.passThreshold;
const reasons = items
.filter((it: any) => it.vetoLow)
.map((it: any) => `VETO:${it.id}:${it.score}<${floor}`);
const verdict = weightedPass && reasons.length === 0 ? "pass" : "reject";
return {
items, weightTotal, maxWeighted, weighted, threshold: cfg.passThreshold, vetoFloor: floor,
weightedPass, vetoPass: reasons.length === 0, verdict, reasons,
};
}
14.2.5 负例:单项否决与刚好达阈
core/test/sp14_scorecard.test.ts 读 Python 产物做跨语言对照,并构造两条边界负例:
// file: core/test/sp14_scorecard.test.ts
// tier: P
// verify: node --test 'core/test/sp14_*.test.ts'
// 跨语言对照:读 out/sp14_scorecard.json,断言加权分与判定逐位一致。
// 负例:单项否决(加权够也打回)、加权刚好达阈(必须通过)。
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 { evaluateScorecard } from "../src/sp14/scorecard.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp14_scorecard.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp14_scorecard.json"), "utf8"));
test("评分卡:TS 复算加权分与判定和 Python 产物逐位一致", () => {
const mine = evaluateScorecard(cfg);
assert.equal(mine.weighted, py.weighted);
assert.equal(mine.maxWeighted, py.maxWeighted);
assert.equal(mine.weightTotal, py.weightTotal);
assert.equal(mine.weightedPass, py.weightedPass);
assert.equal(mine.vetoPass, py.vetoPass);
assert.equal(mine.verdict, py.verdict);
assert.deepEqual(mine.reasons, py.reasons);
assert.deepEqual(mine.items, py.items);
});
test("负例:单项否决必须打回(加权够也拦下)", () => {
const bad = {
passThreshold: 7500,
vetoFloor: 60,
items: [
{ id: "a", weight: 50, score: 100, veto: false },
{ id: "b", weight: 50, score: 55, veto: true },
],
};
const r = evaluateScorecard(bad);
assert.equal(r.weighted, 7750);
assert.equal(r.weightedPass, true);
assert.equal(r.vetoPass, false);
assert.equal(r.verdict, "reject");
assert.deepEqual(r.reasons, ["VETO:b:55<60"]);
});
test("负例:加权刚好达阈必须通过", () => {
const edge = {
passThreshold: 7500,
vetoFloor: 60,
items: [
{ id: "a", weight: 50, score: 75, veto: false },
{ id: "b", weight: 50, score: 75, veto: true },
],
};
const r = evaluateScorecard(edge);
assert.equal(r.weighted, 7500);
assert.equal(r.weightedPass, true);
assert.equal(r.vetoPass, true);
assert.equal(r.verdict, "pass");
});
实跑 node --test 'core/test/sp14_*.test.ts'(此处只列本节相关的 3 条;三节合计 11 条全绿):
✔ 评分卡:TS 复算加权分与判定和 Python 产物逐位一致 (1.22075ms)
✔ 负例:单项否决必须打回(加权够也拦下) (0.116167ms)
✔ 负例:加权刚好达阈必须通过 (0.082208ms)
ℹ tests 11
ℹ pass 11
ℹ fail 0
两条负例各守一条边界:第一条加权 7750 ≥ 7500 却因 b=55 低于否决底线而 reject;第二条加权 7500 恰好等于阈值,>= 判定必须放行——这是最容易写错的一格(写成 > 就会把刚好达阈的资产误打回)。加权分、满分、阈值全为整数,TS 与 Python 逐位一致。
14.2.6 边界与取舍
- 真实评审的审美判断、画师打分、专家主观权重需要真实环境(评审人),命令行不可复现,标 X;本节只做已给分数下的加权与判定,不产生分数本身。
- 权重、阈值、否决项是**《星屑旅店》这一款治愈系休闲游戏**的取值;换项目就换权重与阈值,但「加权达阈 + 单项否决」的双规则不变。
- 评分与权重一律整数(10 分制 ×10),加权分是整数乘法之和,逐位一致;本节不涉及浮点派生量。
阅读导航:上一节:14.1 评审的时机与参与角色 · 下一节:14.3 走查记录与决策留痕 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。