13.3 工具的采纳率与维护成本

采纳率和维护成本最容易被写成口号。本节把它们变成可算的整数账:采纳率 = 活跃用户×1000 // 目标用户,节省人天×100 = 活跃用户×次数×分钟×100 // 一人天分钟数,工具有效率 = 节省人天×100×1000 // 维护人天×100,两轴各带下限落在四象限里,给出 promote / keep / observe / retire 四类决策,并用负例证明只看一个轴一定会误砍。

上一节把工具链排成了层,也证明了「分层不是文档、是门禁」;但层排得再整齐,也回答不了最后一个问题:这些工具各自被用得多不多、养起来贵不贵。站内 game/industry 的工业化与外包短文讲的是组织与流程的道理;本节只算「这个工具值不值得养」这笔整数账——DevOps / CI 平台本身不是本节内容。

13.3 工具的采纳率与维护成本

13.3.1 两个轴,缺一不可

一个工具值不值得养,要同时看两件事:采纳率(有多少目标用户真的在用)与工具有效率(每投入 1 人天维护,换回多少人天节省)。两个轴各带一条下限,就落进四个象限:

采纳率 \ 有效率≥ 下限< 下限
≥ 下限promote 推广keep 保留,先压维护成本
< 下限observe 观察,先做推广retire 下架

三个量全是整数(地板除):adoptionPermille = activeUsers×1000 // targetUsers;savedPersonDaysX100 = activeUsers×sessions×minutes×100 // minutesPerPersonDay;efficiencyPermille = savedPersonDaysX100×1000 // maintainPersonDaysX100(×1000 表示倍率,1000 = 1.0 倍)。真实使用数据需真实团队环境,命令行不可复现(X);本节全部为构造的示例参数。

13.3.2 D 层:采纳与维护参数

{
  "version": 1,
  "project": "stardust-inn",
  "note": "13.3 工具采纳率与维护成本账。targetUsers = 目标用户数;minutesPerPersonDay = 一人天分钟数(480)。每个工具:activeUsers = 年活跃使用人数;sessionsPerUserYear = 每人每年使用次数;minutesSavedPerSession = 每次节省分钟;maintainPersonDaysX100 = 年维护人天×100(修复 + 适配 + 支持)。全部为本套构造的示例参数,真实使用数据需真实团队环境,命令行不可复现(X)。判据(全整数地板除):adoptionPermille = activeUsers×1000 // targetUsers;savedPersonDaysX100 = activeUsers×sessions×minutes×100 // 480;efficiencyPermille = savedPersonDaysX100×1000 // maintainPersonDaysX100(工具有效率,×1000 表示倍率)。决策:promote(高采纳高效率) / keep(高采纳低效率) / observe(低采纳高效率) / retire(低采纳低效率)。",
  "targetUsers": 20,
  "minutesPerPersonDay": 480,
  "adoptionFloorPermille": 500,
  "efficiencyFloorPermille": 1000,
  "tools": [
    { "id": "t-naming", "name": "命名校验", "activeUsers": 18, "sessionsPerUserYear": 40, "minutesSavedPerSession": 15, "maintainPersonDaysX100": 800 },
    { "id": "t-atlas", "name": "图集校验", "activeUsers": 12, "sessionsPerUserYear": 30, "minutesSavedPerSession": 20, "maintainPersonDaysX100": 600 },
    { "id": "t-dashboard", "name": "生产看板", "activeUsers": 16, "sessionsPerUserYear": 200, "minutesSavedPerSession": 2, "maintainPersonDaysX100": 2400 },
    { "id": "t-vfx", "name": "特效检查", "activeUsers": 4, "sessionsPerUserYear": 20, "minutesSavedPerSession": 25, "maintainPersonDaysX100": 300 },
    { "id": "t-color", "name": "取色助手", "activeUsers": 3, "sessionsPerUserYear": 10, "minutesSavedPerSession": 8, "maintainPersonDaysX100": 500 },
    { "id": "t-legacy", "name": "旧导出脚本", "activeUsers": 5, "sessionsPerUserYear": 15, "minutesSavedPerSession": 6, "maintainPersonDaysX100": 900 }
  ]
}

13.3.3 S 层:采纳账、节省账、有效率账

#!/usr/bin/env python3
# file: sim/ta13_adoption.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/ta13_adoption.py
"""13.3 工具的采纳率与维护成本:把「这个工具值不值得养」算成一笔整数账。

读 config/ta13_adoption.json。对每个工具算(全部整数地板除):
  ① 采纳率 adoptionPermille = activeUsers×1000 // targetUsers;
  ② 年节省人天×100 savedPersonDaysX100 = activeUsers×sessions×minutes×100 // minutesPerPersonDay;
  ③ **工具有效率 efficiencyPermille = savedPersonDaysX100×1000 // maintainPersonDaysX100**(倍率×1000);
  ④ 决策:promote / keep / observe / retire(采纳率与有效率两轴,各带下限)。
核心观点:**只看一个轴一定会误判**。只看采纳率会把高效但没人知道的 t-vfx 砍掉(它该观察推广);
只看有效率会把高采纳的 t-dashboard 砍掉(它该保留、优化维护成本)。两轴一起才成立。
产出 out/ta13_adoption.json,另落 canonical 摘要与 sha256 前 16 位供 P 层对照。
真实使用数据需真实团队环境,命令行不可复现(X)。"""
import hashlib
import json
import os

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


def analyze(cfg):
    mppd, target = cfg["minutesPerPersonDay"], cfg["targetUsers"]
    af, ef = cfg["adoptionFloorPermille"], cfg["efficiencyFloorPermille"]
    rows = []
    for t in cfg["tools"]:
        ad = t["activeUsers"] * 1000 // target
        sd = t["activeUsers"] * t["sessionsPerUserYear"] * t["minutesSavedPerSession"] * 100 // mppd
        eff = sd * 1000 // t["maintainPersonDaysX100"]      # 有效率 = 节省人天×100 ×1000 // 维护人天×100
        v = ("promote" if ad >= af else "observe") if eff >= ef else ("keep" if ad >= af else "retire")
        rows.append({"id": t["id"], "name": t["name"], "adoptionPermille": ad,
                     "savedPersonDaysX100": sd, "maintainPersonDaysX100": t["maintainPersonDaysX100"],
                     "efficiencyPermille": eff, "verdict": v})
    kept = [r for r in rows if r["verdict"] != "retire"]
    canon = json.dumps(
        [{"id": r["id"], "adoptionPermille": r["adoptionPermille"],
          "savedPersonDaysX100": r["savedPersonDaysX100"],
          "efficiencyPermille": r["efficiencyPermille"], "verdict": r["verdict"]} for r in rows],
        separators=(",", ":"), ensure_ascii=False)
    ts = sum(r["savedPersonDaysX100"] for r in rows)
    tm = sum(r["maintainPersonDaysX100"] for r in rows)
    ks = sum(r["savedPersonDaysX100"] for r in kept)
    km = sum(r["maintainPersonDaysX100"] for r in kept)
    return {
        "version": cfg["version"], "project": cfg["project"], "targetUsers": target,
        "minutesPerPersonDay": mppd, "adoptionFloorPermille": af, "efficiencyFloorPermille": ef,
        "toolCount": len(rows), "rows": rows,
        "verdictCounts": {v: sum(1 for r in rows if r["verdict"] == v)
                          for v in ("promote", "keep", "observe", "retire")},
        "totalSavedPersonDaysX100": ts, "totalMaintainPersonDaysX100": tm,
        "portfolioEfficiencyPermille": ts * 1000 // tm,
        "retainedSavedPersonDaysX100": ks, "retainedMaintainPersonDaysX100": km,
        "retainedEfficiencyPermille": ks * 1000 // km,
        "retireIds": [r["id"] for r in rows if r["verdict"] == "retire"],
        "canonical": canon, "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16],
    }


def main():
    rep = analyze(json.load(open(CONFIG, encoding="utf-8")))
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "ta13_adoption.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
        f.write("\n")
    print(f"tools={rep['toolCount']} target={rep['targetUsers']} "
          f"adoptionFloor={rep['adoptionFloorPermille']} efficiencyFloor={rep['efficiencyFloorPermille']}")
    for r in rep["rows"]:
        print(f"  {r['id']:<11} adoption={r['adoptionPermille']:<5} savedX100={r['savedPersonDaysX100']:<5} "
              f"maintX100={r['maintainPersonDaysX100']:<5} eff={r['efficiencyPermille']:<5} {r['verdict']}")
    print(f"verdicts={rep['verdictCounts']} retire={rep['retireIds']}")
    print(f"totalSavedX100={rep['totalSavedPersonDaysX100']} totalMaintX100={rep['totalMaintainPersonDaysX100']}")
    print(f"portfolioEfficiency={rep['portfolioEfficiencyPermille']} "
          f"retainedEfficiency={rep['retainedEfficiencyPermille']}")
    print(f"digest={rep['digest']}")
    print("wrote out/ta13_adoption.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

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

tools=6 target=20 adoptionFloor=500 efficiencyFloor=1000
  t-naming    adoption=900   savedX100=2250  maintX100=800   eff=2812  promote
  t-atlas     adoption=600   savedX100=1500  maintX100=600   eff=2500  promote
  t-dashboard adoption=800   savedX100=1333  maintX100=2400  eff=555   keep
  t-vfx       adoption=200   savedX100=416   maintX100=300   eff=1386  observe
  t-color     adoption=150   savedX100=50    maintX100=500   eff=100   retire
  t-legacy    adoption=250   savedX100=93    maintX100=900   eff=103   retire
verdicts={'promote': 2, 'keep': 1, 'observe': 1, 'retire': 2} retire=['t-color', 't-legacy']
totalSavedX100=5642 totalMaintX100=5500
portfolioEfficiency=1025 retainedEfficiency=1341
digest=c2ea30a827afb891
wrote out/ta13_adoption.json

整条组合一年节省 5642(56.42 人天),维护投入 5500(55 人天),组合有效率 1025‰——刚好过 1.0 倍。砍掉两个 retire 工具后,留下四个工具的有效率升到 1341‰:分母少了 500 + 900 = 1400 维护人天,分子只少了 50 + 93 = 143 节省人天,于是整条账立刻从「勉强保本」变成「赚三成」。这就是维护成本的性质:它摊在所有人头上,但只有少数人受益的工具最贵。

13.3.4 P 层内核

// file: core/src/ta13/adoption.ts
// tier: P
// verify: node --test 'core/test/ta13_*.test.ts'
// 13.3 工具采纳率与维护成本内核:把「值不值得养」算成一笔整数账。
//
// 关键设计:**判定必须两轴(采纳率 × 有效率)**。只看采纳率会砍掉高效但没人知道的工具;
// 只看有效率会砍掉高采纳但维护昂贵的工具。两轴各带下限,落在四个象限里。
// 全部整数:人天×100、千分比、倍率×1000,除法一律整数地板除。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export type ToolUsage = {
  id: string;
  name: string;
  activeUsers: number;
  sessionsPerUserYear: number;
  minutesSavedPerSession: number;
  maintainPersonDaysX100: number;
};

export type Verdict = "promote" | "keep" | "observe" | "retire";

/** 采纳率(千分比):活跃用户 × 1000 // 目标用户。 */
export function adoptionPermille(t: ToolUsage, targetUsers: number): number {
  return Math.floor((t.activeUsers * 1000) / targetUsers);
}

/** 年节省人天×100 = 活跃用户×次数×分钟×100 // 一人天分钟数。 */
export function savedPersonDaysX100(t: ToolUsage, minutesPerPersonDay: number): number {
  const minutes = t.activeUsers * t.sessionsPerUserYear * t.minutesSavedPerSession;
  return Math.floor((minutes * 100) / minutesPerPersonDay);
}

/** 工具有效率(倍率×1000)= 节省人天×100 × 1000 // 维护人天×100。 */
export function efficiencyPermille(t: ToolUsage, minutesPerPersonDay: number): number {
  return Math.floor((savedPersonDaysX100(t, minutesPerPersonDay) * 1000) / t.maintainPersonDaysX100);
}

/** 两轴四象限决策。 */
export function verdict(
  adoption: number, efficiency: number, adoptionFloor: number, efficiencyFloor: number,
): Verdict {
  const hiA = adoption >= adoptionFloor;
  const hiE = efficiency >= efficiencyFloor;
  if (hiA && hiE) return "promote";
  if (hiA && !hiE) return "keep";
  if (!hiA && hiE) return "observe";
  return "retire";
}

/** canonical 摘要:键序与 Python 侧一致,供 digest 对拍。 */
export function canonical(rows: {
  id: string; adoptionPermille: number; savedPersonDaysX100: number;
  efficiencyPermille: number; verdict: Verdict;
}[]): string {
  return JSON.stringify(rows.map((r) => ({
    id: r.id, adoptionPermille: r.adoptionPermille, savedPersonDaysX100: r.savedPersonDaysX100,
    efficiencyPermille: r.efficiencyPermille, verdict: r.verdict,
  })));
}

13.3.5 P 层测试:负例是核心

// file: core/test/ta13_adoption.test.ts
// tier: P
// verify: node --test 'core/test/ta13_*.test.ts'
// 采纳率与维护成本断言:TS 侧独立复算 config/ta13_adoption.json,与 Python(sim/ta13_adoption.py)
// 落盘的 out/ta13_adoption.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 { adoptionPermille, savedPersonDaysX100, efficiencyPermille, verdict, canonical } from "../src/ta13/adoption.ts";
import type { ToolUsage, Verdict } from "../src/ta13/adoption.ts";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/ta13_adoption.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/ta13_adoption.json"), "utf8"));
const tools = cfg.tools as ToolUsage[];
const mppd = cfg.minutesPerPersonDay as number;
const target = cfg.targetUsers as number;
const af = cfg.adoptionFloorPermille as number;
const ef = cfg.efficiencyFloorPermille as number;
const sha16 = (s: string): string => createHash("sha256").update(s, "utf8").digest("hex").slice(0, 16);
const rows = tools.map((t) => {
  const ad = adoptionPermille(t, target);
  const eff = efficiencyPermille(t, mppd);
  return { id: t.id, adoptionPermille: ad, savedPersonDaysX100: savedPersonDaysX100(t, mppd),
           efficiencyPermille: eff, verdict: verdict(ad, eff, af, ef) };
});
const row = (id: string) => rows.find((r) => r.id === id) as {
  adoptionPermille: number; efficiencyPermille: number; verdict: Verdict;
};

test("跨语言:采纳率 / 节省 / 有效率 / 决策逐位一致,canonical 与 digest 一致", () => {
  assert.equal(rows.length, py.toolCount);
  for (const r of rows) {
    const t = py.rows.find((x: { id: string }) => x.id === r.id);
    assert.equal(r.adoptionPermille, t.adoptionPermille, r.id);
    assert.equal(r.savedPersonDaysX100, t.savedPersonDaysX100, r.id);
    assert.equal(r.efficiencyPermille, t.efficiencyPermille, r.id);
    assert.equal(r.verdict, t.verdict, r.id);
  }
  const canon = canonical(rows);
  assert.equal(canon, py.canonical);
  assert.equal(sha16(canon), py.digest);
  assert.equal(py.digest, "c2ea30a827afb891");
});

test("组合账:砍掉两个低效工具后有效率从 1025‰ 升到 1341‰", () => {
  assert.equal(py.portfolioEfficiencyPermille, 1025);
  assert.deepEqual(py.retireIds, ["t-color", "t-legacy"]);
  assert.equal(py.retainedEfficiencyPermille, 1341);
  assert.ok(py.retainedEfficiencyPermille > py.portfolioEfficiencyPermille);
});

test("负例:只看采纳率会把高效但没人用的 t-vfx 砍掉", () => {
  const vfx = row("t-vfx");
  assert.ok(vfx.adoptionPermille < af);
  assert.ok(rows.filter((r) => r.adoptionPermille < af).map((r) => r.id).includes("t-vfx"));
  assert.equal(vfx.verdict, "observe"); // 有效率 1386 ≥ 1000,该观察推广
});

test("负例:只看有效率会把高采纳的 t-dashboard 砍掉", () => {
  const dash = row("t-dashboard");
  assert.ok(dash.efficiencyPermille < ef);
  assert.ok(rows.filter((r) => r.efficiencyPermille < ef).map((r) => r.id).includes("t-dashboard"));
  assert.equal(dash.verdict, "keep"); // 采纳率 800 ≥ 500,该保留
});

test("边界:两轴都在下限之上才 promote;都在之下才 retire", () => {
  assert.equal(verdict(af, ef, af, ef), "promote");
  assert.equal(verdict(af - 1, ef - 1, af, ef), "retire");
  assert.equal(verdict(af, ef - 1, af, ef), "keep");
  assert.equal(verdict(af - 1, ef, af, ef), "observe");
});

实跑 node --test 'core/test/ta13_adoption.test.ts':

✔ 跨语言:采纳率 / 节省 / 有效率 / 决策逐位一致,canonical 与 digest 一致
✔ 组合账:砍掉两个低效工具后有效率从 1025‰ 升到 1341‰
✔ 负例:只看采纳率会把高效但没人用的 t-vfx 砍掉
✔ 负例:只看有效率会把高采纳的 t-dashboard 砍掉
✔ 边界:两轴都在下限之上才 promote;都在之下才 retire
ℹ tests 5
ℹ pass 5
ℹ fail 0

两条负例才是这一节的重点:t-vfx 采纳率只有 200‰(低于 500 的下限),但有效率 1386‰——它高效,只是没人知道,该 observe 推广而不是 retire;t-dashboard 采纳率 800‰(高于下限),但有效率只有 555‰——它人人都在用,只是维护太贵,该 keep 并压维护成本而不是下架。只看采纳率会把前者误砍,只看有效率会把后者误砍;两轴一起,digest=c2ea30a827afb891 与 Python 侧逐位一致,结论才立得住。

阅读导航:上一节:13.2 工具链的分层与复用 · 下一节:14.1 产能度量:人天与吞吐 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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