站内 《美术制作与视觉规范》 第一卷已经给过一份职责矩阵,回答「谁做什么」;本节要回答的是更细的一层——同一个人,凭什么算到了中级。渲染原理专题 与 《渲染引擎》 里的能力要求是「懂某类原理」,TA 的能力要求是「交付某类产物」,两者不能混着算。
能力项必须挂产出物
一条写不出产出物的能力项,就是一条无法验收的能力项。所以本节的矩阵里,每项能力都强制挂一个 deliverable——它是这项能力的验收凭据,不是举例说明。
| 能力项 | family | 权重‰ | 可验收产出物 |
|---|---|---|---|
| c-tool 工具开发 | tool | 300 | lint 脚本 + 报告 schema |
| c-pipeline 管线集成 | pipeline | 300 | 导出预设 + 导入设置表 |
| c-perf 性能分析 | performance | 250 | 面数 / 骨骼 / 材质槽预算表 |
| c-comm 跨职能沟通 | interface | 150 | 接口矩阵 + 评审记录 |
四项权重之和必须恰为 1000——这不是审美,而是让「加权覆盖率」直接等于千分比,省掉一次除法。真实能力评估需真实团队环境,命令行不可复现(X)。
职级要求:三档,一档一张表
下面这段是 D 层能力矩阵配置:每项能力的权重、每档职级的最低等级要求(L0–L5)、以及某个 TA 的当前等级 profile。它是 S 层覆盖账与 P 层校验器的共同输入。
{
"version": 1,
"project": "stardust-inn",
"note": "TA 能力矩阵与产出物。capabilities = 能力项(family ∈ tool|pipeline|performance|interface,deliverable = 该能力的可验收产出物);tiers = 职级对每项能力的最低等级要求(L0..L5);profile = 某个 TA 的当前等级;weightsPermille = 加权覆盖率权重,三档共用,四项之和必须恰为 1000。等级与权重全整数。真实能力评估需真实团队环境,命令行不可复现(X)。",
"levels": ["L0", "L1", "L2", "L3", "L4", "L5"],
"weightsPermille": { "c-tool": 300, "c-pipeline": 300, "c-perf": 250, "c-comm": 150 },
"capabilities": [
{ "id": "c-tool", "name": "工具开发", "family": "tool", "deliverable": "lint 脚本 + 报告 schema" },
{ "id": "c-pipeline", "name": "管线集成", "family": "pipeline", "deliverable": "导出预设 + 导入设置表" },
{ "id": "c-perf", "name": "性能分析", "family": "performance", "deliverable": "面数 / 骨骼 / 材质槽预算表" },
{ "id": "c-comm", "name": "跨职能沟通", "family": "interface", "deliverable": "接口矩阵 + 评审记录" }
],
"tiers": [
{ "id": "ta-junior", "name": "初级 TA", "requires": { "c-tool": 2, "c-pipeline": 2, "c-perf": 1, "c-comm": 2 } },
{ "id": "ta-mid", "name": "中级 TA", "requires": { "c-tool": 3, "c-pipeline": 3, "c-perf": 3, "c-comm": 3 } },
{ "id": "ta-senior", "name": "高级 TA", "requires": { "c-tool": 4, "c-pipeline": 4, "c-perf": 4, "c-comm": 4 } }
],
"profile": { "c-tool": 3, "c-pipeline": 4, "c-perf": 2, "c-comm": 4 }
}
读这份配置要抓住两点。第一,职级要求是一张表不是一个数:ta-mid 要求四项都到 3 级,而不是「平均 3 级」。第二,profile 是当前等级,不是「期望等级」——它跟要求比出来的差,才是要补的东西。
S 层:加权覆盖账
同一份配置,S 层读它算每档职级的加权覆盖率与硬缺口。本机跑 /opt/homebrew/bin/python3 sim/ta01_capability.py,真实输出:权重和 weightSumPermille=1000,profile 平均 profileAvgLevelX100=325;ta-junior 覆盖率 1000(fullyCovered=true,无缺口),ta-mid 750(缺口 c-perf),ta-senior 450(缺口 c-tool、c-perf),maxTier=ta-junior。平均 3.25 级看着够中级,但性能项只有 2 级、低于中级的 3 级要求,中级覆盖率因此掉到 750‰——这就是「平均分掩盖硬缺口」。真实能力评估需真实团队环境,命令行不可复现(X)。
#!/usr/bin/env python3
# file: sim/ta01_capability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta01_capability.py
"""TA 能力矩阵的加权覆盖账与产出物清单。
读 config/ta01_capability.json:能力项各有权重(之和必须恰为 1000),三档职级对每项
给出最低等级要求,profile 是当前等级。对每一档算:① 硬缺口(profile < require);
② 加权覆盖率(只累加达标项的权重,因权重和为 1000,其和即千分比);③ 是否满分。
核心观点:**平均分会掩盖硬缺口**——本 profile 平均 3.25 级看着够中级,但性能项只有
2 级、低于中级要求 3 级,中级覆盖率只有 750‰。产出 out/ 下 json + csv + svg,另落
canonical 摘要与 sha256 前 16 位供 P 层 TS 侧对照。
真实能力评估需真实团队环境,命令行不可复现(X)。"""
import csv
import hashlib
import json
import os
FILL = ["#3d4a57", "#64748b", "#94a3b8"]
def analyze(cfg):
caps = cfg["capabilities"]
order = [c["id"] for c in caps]
w, prof = cfg["weightsPermille"], cfg["profile"]
wsum = sum(w[c] for c in order)
tiers, cov, gaps = [], {}, {}
for t in cfg["tiers"]:
g = [c for c in order if prof[c] < t["requires"][c]]
cw = sum(w[c] for c in order if prof[c] >= t["requires"][c])
cov[t["id"]], gaps[t["id"]] = cw, g
tiers.append({"id": t["id"], "name": t["name"], "weightedCoveragePermille": cw,
"fullyCovered": cw == wsum, "gaps": g,
"gapDetail": [{"cap": c, "profile": prof[c], "require": t["requires"][c],
"deficit": t["requires"][c] - prof[c]} for c in g]})
fam = {}
for c in caps:
fam.setdefault(c["family"], []).append(c["deliverable"])
canon = json.dumps({"tiers": {k: cov[k] for k in cov}, "gaps": {k: gaps[k] for k in gaps}},
separators=(",", ":"), ensure_ascii=False)
return {"version": cfg["version"], "project": cfg["project"], "weightSumPermille": wsum,
"capabilityCount": len(caps), "deliverableCount": len(caps),
"profileAvgLevelX100": sum(prof[c] for c in order) * 100 // len(order),
"tiers": tiers, "maxTier": next((t["id"] for t in tiers if t["fullyCovered"]), None),
"deliverablesByFamily": fam, "canonical": canon,
"digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
def write_svg(rep, path):
"""三档职级加权覆盖率条形图:条长 = 覆盖率千分比 × 0.22(整数地板除)。"""
parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="360" height="168">']
for i, t in enumerate(rep["tiers"]):
y, pm = 16 + i * 42, t["weightedCoveragePermille"]
parts.append(f'<text x="16" y="{y + 12}" fill="#94a3b8" font-family="monospace" '
f'font-size="11">{t["id"]} {pm}‰</text>'
f'<rect x="112" y="{y}" width="{pm * 220 // 1000}" height="26" rx="4" '
f'fill="{FILL[i]}"/>')
parts.append(f'<text x="16" y="162" fill="#94a3b8" font-family="monospace" font-size="10">'
f'maxTier={rep["maxTier"]} avgX100={rep["profileAvgLevelX100"]}</text></svg>')
open(path, "w", encoding="utf-8").write("".join(parts) + "\n")
def main():
cfg = json.load(open("config/ta01_capability.json", encoding="utf-8"))
rep = analyze(cfg)
os.makedirs("out", exist_ok=True)
json.dump(rep, open("out/ta01_capability.json", "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
with open("out/ta01_capability.csv", "w", encoding="utf-8", newline="") as f:
wr = csv.writer(f)
wr.writerow(["capability", "family", "profile", "require_junior", "require_mid",
"require_senior", "deliverable"])
for c in cfg["capabilities"]:
wr.writerow([c["id"], c["family"], cfg["profile"][c["id"]]] +
[t["requires"][c["id"]] for t in cfg["tiers"]] + [c["deliverable"]])
write_svg(rep, "out/ta01_capability.svg")
print(json.dumps({"weightSumPermille": rep["weightSumPermille"],
"profileAvgLevelX100": rep["profileAvgLevelX100"],
"maxTier": rep["maxTier"],
"tiers": [{k: t[k] for k in ("id", "weightedCoveragePermille",
"fullyCovered", "gaps")}
for t in rep["tiers"]],
"digest": rep["digest"]}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
P 层:能力矩阵内核
下面这段是 P 层 TypeScript 内核 core/src/ta01/capability.ts。gapsFor 列出硬缺口,weightedCoverage 只累加达标项的权重,maxCoveredTier 取覆盖率为满权的最高档,avgLevelX100 则是那个会骗人的平均分。
// file: core/src/ta01/capability.ts
// tier: P
// verify: node --test 'core/test/ta01_*.test.ts'
// TA 能力矩阵内核:把「职级要求 × 当前等级 × 权重」算成一份可断言的覆盖账。
//
// 关键设计:**硬缺口优先于平均分**。加权覆盖率只把「达标项」的权重加进来;
// 只要有一项低于要求,这一项就整块不计入,而不是按差值打折。这样才能拦住
// 「平均分够、但某一项硬性不达标」的假通过。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type CapFamily = "tool" | "pipeline" | "performance" | "interface";
export type Capability = {
id: string;
name: string;
family: CapFamily;
deliverable: string;
};
export type Tier = {
id: string;
name: string;
requires: Record<string, number>;
};
export type Profile = Record<string, number>;
export type Weights = Record<string, number>;
/** 硬缺口:profile < require 的能力项,按 order 顺序返回。 */
export function gapsFor(tier: Tier, profile: Profile, order: string[]): string[] {
return order.filter((c) => profile[c] < tier.requires[c]);
}
/** 加权覆盖率(‰):只累加达标项的权重。权重和恰为 1000,故其和即千分比。 */
export function weightedCoverage(
tier: Tier, profile: Profile, order: string[], weights: Weights,
): number {
let sum = 0;
for (const c of order) {
if (profile[c] >= tier.requires[c]) sum += weights[c];
}
return sum;
}
/** 覆盖率为满权的最高档;没有一档满分则返回 null。 */
export function maxCoveredTier(
tiers: Tier[], profile: Profile, order: string[], weights: Weights,
): string | null {
const full = order.reduce((a, c) => a + weights[c], 0);
let out: string | null = null;
for (const t of tiers) if (weightedCoverage(t, profile, order, weights) === full) out = t.id;
return out;
}
/** 平均等级 ×100(整数地板除)——正是「掩盖硬缺口」的那个数。 */
export function avgLevelX100(profile: Profile, order: string[]): number {
let sum = 0;
for (const c of order) sum += profile[c];
return Math.floor((sum * 100) / order.length);
}
/** canonical 摘要:职级键序、缺口项顺序都与 Python 侧一致。 */
export function canonical(
tierCov: Record<string, number>, tierGaps: Record<string, string[]>, tierOrder: string[],
): string {
const t: Record<string, number> = {};
const g: Record<string, string[]> = {};
for (const id of tierOrder) { t[id] = tierCov[id]; g[id] = tierGaps[id]; }
return JSON.stringify({ tiers: t, gaps: g });
}
负例:平均分够 ≠ 达标
最危险的失败模式是假通过:用平均分判定,一个能力项差两级也能被平均掉。所以这段 P 层测试专门构造了这条负例,并验证达标项是「整块不计入」而不是「按差值打折」。
// file: core/test/ta01_capability.test.ts
// tier: P
// verify: node --test 'core/test/ta01_*.test.ts'
// 能力矩阵断言:权重和必须恰为 1000、每项能力必须有可验收产出物、每档职级要求齐全;
// 再做跨语言对照——TS 侧独立复算 config/ta01_capability.json,与 Python
// (sim/ta01_capability.py)落盘的 out/ta01_capability.json 逐位比对 + digest 对照。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
gapsFor, weightedCoverage, maxCoveredTier, avgLevelX100, canonical,
} from "../src/ta01/capability.ts";
import type { Capability, Tier, Profile, Weights } from "../src/ta01/capability.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta01_capability.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta01_capability.json"), "utf8"));
const caps = cfg.capabilities as Capability[];
const tiers = cfg.tiers as Tier[];
const profile = cfg.profile as Profile;
const weights = cfg.weightsPermille as Weights;
const order = caps.map((c) => c.id);
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
const cov: Record<string, number> = {};
const gp: Record<string, string[]> = {};
for (const t of tiers) {
cov[t.id] = weightedCoverage(t, profile, order, weights);
gp[t.id] = gapsFor(t, profile, order);
}
test("不变量:权重和恰为 1000、每项能力有产出物、每档职级要求齐", () => {
assert.equal(order.reduce((a, c) => a + weights[c], 0), 1000);
for (const c of caps) {
assert.ok(c.deliverable.length > 0, `${c.id} 缺产出物`);
assert.ok(c.family.length > 0, `${c.id} 缺 family`);
}
for (const t of tiers) assert.equal(Object.keys(t.requires).length, order.length, t.id);
assert.ok(order.every((c) => c in profile), "profile 缺能力项");
});
test("跨语言:加权覆盖率与硬缺口逐位一致(整数类)", () => {
for (const t of tiers) {
const row = py.tiers.find((r: { id: string }) => r.id === t.id);
assert.equal(cov[t.id], row.weightedCoveragePermille, t.id);
assert.deepEqual(gp[t.id], row.gaps, t.id);
}
});
test("跨语言:maxTier、平均分、canonical 与 digest 一致", () => {
assert.equal(maxCoveredTier(tiers, profile, order, weights), py.maxTier);
assert.equal(avgLevelX100(profile, order), py.profileAvgLevelX100);
const canon = canonical(cov, gp, tiers.map((t) => t.id));
assert.equal(canon, py.canonical);
assert.equal(sha16(canon), py.digest);
});
test("负例:平均分掩盖硬缺口,加权覆盖率必须拦住", () => {
// 朴素判定:平均等级 >= 3 就算够中级 —— 本 profile 平均 3.25,会被判「够」
assert.equal(avgLevelX100(profile, order) >= 300, true);
const mid = tiers.find((t) => t.id === "ta-mid") as Tier;
assert.deepEqual(gapsFor(mid, profile, order), ["c-perf"], "中级应在性能项上有硬缺口");
assert.ok(cov["ta-mid"] < 1000, "有硬缺口却拿满权");
assert.equal(maxCoveredTier(tiers, profile, order, weights), "ta-junior");
});
test("负例:达标项缺一即整块不计入,而不是按差值打折", () => {
const t: Tier = { id: "x", name: "x", requires: { a: 3, b: 3 } };
const w: Weights = { a: 500, b: 500 };
assert.equal(weightedCoverage(t, { a: 3, b: 2 }, ["a", "b"], w), 500);
assert.equal(weightedCoverage(t, { a: 2, b: 2 }, ["a", "b"], w), 0);
assert.deepEqual(gapsFor(t, { a: 2, b: 2 }, ["a", "b"]), ["a", "b"]);
});
在本机跑 node --test 'core/test/ta01_*.test.ts',真实输出(同机相对量级,耗时不可移植):
✔ 不变量:权重和恰为 1000、每项能力有产出物、每档职级要求齐 (0.524042ms)
✔ 跨语言:加权覆盖率与硬缺口逐位一致(整数类) (0.434083ms)
✔ 跨语言:maxTier、平均分、canonical 与 digest 一致 (0.3835ms)
✔ 负例:平均分掩盖硬缺口,加权覆盖率必须拦住 (0.092208ms)
✔ 负例:达标项缺一即整块不计入,而不是按差值打折 (0.074417ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
两条负例守住同一件事的两面:平均分掩盖硬缺口 守住「判定口径必须按项不按均值」,整块不计入 守住「缺口不能被差值稀释」。只要有一条从「拦下」变成「放行」,能力矩阵就退化成了好看的自评表。
三类工作对应四项能力,但权重并不均分——工具与管线各占 300‰,因为它们的产出物最容易被别人复用;沟通只占 150‰,却决定了另外三项能不能落地。下一节把「能力」变成「接口」:TA 与美术、程序、策划之间,到底有哪些必须写成契约的往返。
阅读导航:上一节:1.1 技术美术的三类工作:工具、管线、性能 · 下一节:1.3 TA 与美术、程序、策划的接口 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。