第14章 - 证据画像与徽章
@mmf/evidence 是一个纯函数包:输入是存档与证据事件,输出是四维画像、九枚徽章、思维轨迹与一页式报告。它不读时钟、不碰 DOM、不写存储,连”下一次该推荐哪一关”都是确定性规则。本章拆开它的几个入口,并说明外壳必须补上哪三样东西,纯函数层才能工作。
14.1 证据分层:Draft 与 Event
运行时不是直接写事件,而是产出草稿。草稿缺三样身份信息:谁(profileId)、哪一关(levelId)、什么时候(at)。这些只有外壳知道,所以契约里把它们拆成两个类型:
// packages/game-kit/src/save.ts
/** 运行时只产出草稿;id/profileId/at 由外壳补齐后成为 EvidenceEvent */
export interface EvidenceDraft {
phase: EvidencePhase;
variantId: string;
tool?: ToolId;
action: string;
artifact?: ArtifactSummary;
outcome?: 'info' | 'pass' | 'fail';
failureClass?: string;
metrics?: Record<string, number>;
scaffold?: Scaffold;
}
export interface EvidenceEvent extends EvidenceDraft {
v: 1;
id: string;
at: number;
profileId: string;
levelId: LevelId;
revision?: number;
}
phase 只能取八个值,常量在 packages/content/src/ids.ts:
export const EVIDENCE_PHASES = [
'observe', 'hypothesize', 'construct', 'run', 'result', 'revise', 'generalize', 'argue',
] as const;
运行时侧的调用很轻,只有一行:
// packages/templates/src/pattern/runtime.ts(节选)
function emit(draft: Omit<import('@mmf/game-kit').EvidenceDraft, 'variantId'>): void {
opts.onEvidence?.({ ...draft, variantId: 'base' });
}
onEvidence 由外壳注入,草稿先进队列,再由 WorkshopView 每 2 秒或提交/退出前批量落盘。这里刻意不做”事件立即写盘”:一局会产生 5–30 条事件,逐条写 IndexedDB 既慢,又容易在写失败时丢掉链路的前半段。批量刷新的实现在宿主侧:
// apps/site/src/views/WorkshopView.vue(节选)
function scheduleEvidenceFlush(): void {
if (disposed || evidenceTimer !== null) return;
evidenceTimer = window.setTimeout(() => {
evidenceTimer = null;
void flushEvidence();
}, 2000);
}
disposed 是先手:组件卸载后不再排定时器;evidenceTimer 是”只排一个”的锁。写失败时整批回灌队列并重试三次,超过就丢弃并记一条调试错误——队列另有 400 条上限,写盘长期失败时内存不会无限涨。
14.2 聚合:emptyAggregate 与 aggregate
计数是报告与徽章的基础。packages/evidence/src/index.ts 提供两个纯函数:
// packages/evidence/src/index.ts(节选)
/** 空聚合:6 件工具的计数全部归零,便于 UI 直接读 byTool[tool]。 */
export function emptyAggregate(): EvidenceAggregate {
const byTool = {} as Record<ToolId, number>;
for (const tool of TOOL_IDS) byTool[tool] = 0;
return {
total: 0,
byLevel: {},
byTool,
byPhase: {},
byFailure: {},
lastAggregatedAt: 0,
};
}
export function aggregate(events: EvidenceEvent[]): EvidenceAggregate {
const result = emptyAggregate();
const byPhase: Partial<Record<EvidencePhase, number>> = {};
for (const event of events) {
if (event === null || typeof event !== 'object') continue;
result.total += 1;
// 分别累计 byLevel / byTool / byPhase / byFailure,并取最大 at 作为 lastAggregatedAt
}
result.byPhase = byPhase;
return result;
}
两个细节值得注意:
byTool是穷尽记录:6 件工具的键一定存在,值可以是 0。UI 直接读byTool[tool],不用到处判空。这与 zod 4 的z.record(enum, number)是穷尽记录、部分记录要用z.partialRecord是同一类坑(见 第20章)。lastAggregatedAt取事件里最大的at,不读系统时钟。这样聚合函数进单测时,结果只由输入决定。
写入路径上还有一份实现,在 packages/platform/src/web.ts 的 accumulateAggregate()。为什么不复用 @mmf/evidence?因为依赖方向不允许:platform 是更底层的基础包,不能反过来 import 证据层(依赖图见 docs/02 的架构原则一节)。平台包里的注释写得很直接:”平台包不能依赖 evidence,所以这里保留一份最小实现。”两份实现的字段口径一致,差异只在触发时机:平台那份在证据被裁掉时把计数并入 EvidenceAggregate。
14.3 四维画像:buildEvidenceProfile
buildEvidenceProfile(save, events, summaries, now) 把存档与事件折成四句行为描述。签名里的 now 保留是为了对齐文档,当前实现不读它——画像本身不落时间戳。
画像的输出结构是 EvidenceProfile,核心是四个 DimensionView:
// packages/evidence/src/types.ts(节选)
export interface DimensionSignal {
id: string;
label: string;
value: number;
sample: number;
band: 0 | 1 | 2 | 3;
}
export interface DimensionView {
id: EvidenceDimensionId; // 'structure' | 'rule' | 'model' | 'boundary'
label: string;
band: 0 | 1 | 2 | 3;
summary: string;
signals: DimensionSignal[];
}
export interface EvidenceProfile {
version: 1;
dimensions: DimensionView[];
recommendations: Recommendation[];
toolUses: { tool: ToolId; count: number }[];
eventCount: number;
}
档位不是分数,是句子索引:band 直接取 DIMENSION_TEXT[维度][0..3] 里的一句。以结构维为例:
// packages/evidence/src/profile.ts(节选)
const DIMENSION_TEXT: Record<EvidenceDimensionId, readonly [string, string, string, string]> = {
structure: [
'还没有留下观察记录,先在关卡里多看、多试,再动手也不迟。',
'你会先观察再动手。',
'你会先观察再动手,也能把结构主线走通。',
'你会先观察再动手,也能把规则用到转过方向的地图上。',
],
// rule / model / boundary 同构
};
档位的计算是纯算术,输入只有存档里的完成状态与事件计数:
// packages/evidence/src/profile.ts(节选)
const structureBand: Band = anyTransfer ? 3 : completedStructure > 0 ? 2 : observed.size > 0 ? 1 : 0;
const ruleBand: Band = ruleVariantLevels > 0
? 3
: completedRule > 0 && revisedCount > 0
? 2
: completedRule > 0
? 1
: 0;
const modelBand: Band = multiVariantModel > 0 ? 3 : completedModel > 0 ? 2 : knobProbeEvents > 0 ? 1 : 0;
const boundaryBand: Band = counterexamplePasses > 0 && anyTransfer
? 3
: counterexamplePasses > 0
? 2
: paymentCompleted
? 1
: 0;
每一档都要求能被已发布关卡真实产生,不假设尚未上线的内容;profile.ts 头部注释写的是当时的 6 关口径,后来扩到 20 关只是让同一套规则有更多样本,口径本身没有改。
推荐同样是确定性规则:
- 有未完成关卡时,按摘要顺序取前 3 关;
- 全部完成后,先给「再做一次」,再按当前最薄的一维给「换一种做法」;
- 四维都已经满分时,提示打开报告回看轨迹。
14.4 维度信号:可核对的口径
四维之下各有 2–4 条信号,报告里展示为”值 / 样本量”。信号 id 与口径如下(全部来自实现,不是设计稿):
| 维度 | 信号 id | 口径 |
|---|---|---|
| 结构 | structure-observe-coverage |
有 observe 事件的关卡数 / 关卡总数 |
| 结构 | structure-mainline-completed |
完成的结构主线关卡数 / 结构主线关卡数 |
| 结构 | structure-transfer-stamps |
带 transfer 印章的已通关卡数 / 已通关卡数 |
| 规则 | rule-levels-completed |
完成的规则主线关卡数 / 规则主线关卡数 |
| 规则 | rule-revised-levels |
lastOutcome.revisionCount > 0 的已通关卡数 |
| 规则 | rule-variant-levels |
在多张地图上跑通过程序的规则关数 |
| 规则 | rule-run-events |
证据里 phase === 'run' 的事件数 |
| 模型 | model-knob-probe-events |
工具为 knob 或 probe 的事件数 |
| 模型 | model-levels-completed |
完成的 simulate / experiment 关数 |
| 模型 | model-multi-variant |
completedVariants.length >= 2 的模型关数 |
| 边界 | boundary-payment-completed |
是否完成 g46-algorithm-payment |
| 边界 | boundary-counterexamples |
artifact.kind === 'counterexample' 且 outcome === 'pass' 的事件数 |
| 边界 | boundary-transfer-stamps |
带 transfer 印章的已通关卡数(样本量固定为 2) |
有一条信号是一轮修正的产物,值得单独讲。rule-variant-levels 的注释里写着:
// packages/evidence/src/profile.ts(节选)
// rule 模板不再有迁移门(旋转是刚体变换,恒真,见 docs/12 遗留项 1),
// 所以"带迁移印章的规则关"永远是 0。这里换成规则线真正可观测的信号:
// 在多张地图上跑通过程序的关卡数(变体通行证里出现过的规则关)。
这就是”画像必须能被数据验证”的实际含义:一条永远为 0 的信号会把整维的档位上限封死,发现后不是改文案,而是换口径。
14.5 九枚徽章:evaluateBadges
BADGE_IDS 一共九枚:observer、pattern-spotter、rule-designer、builder、modeler、experimenter、boundary-explorer、reviser、transferer。授予条件全部来自证据,可重算:
// packages/evidence/src/badges.ts(节选)
const OBSERVE_TARGET = 2;
const TRANSFER_TARGET = 2;
/** 完成关卡即授予的徽章:徽章 id → 关卡 id。 */
const LEVEL_BADGES: Partial<Record<BadgeId, LevelId>> = {
'pattern-spotter': 'k-structure-pattern-path',
'rule-designer': 'g13-algorithm-route',
builder: 'g46-algorithm-payment',
modeler: 'g79-modeling-dispatch',
experimenter: 'g1012-modeling-winrate',
};
口径一览:
| 徽章 | 授予条件 | 进度显示 |
|---|---|---|
观察者 observer |
至少 2 个关卡留下 observe 事件 | 有 observe 的关卡数 / 2 |
模式捕手 pattern-spotter |
完成 k-structure-pattern-path |
0 或 1 / 1 |
规则设计师 rule-designer |
完成 g13-algorithm-route |
0 或 1 / 1 |
构造师 builder |
完成 g46-algorithm-payment |
0 或 1 / 1 |
建模师 modeler |
完成 g79-modeling-dispatch |
0 或 1 / 1 |
实验员 experimenter |
完成 g1012-modeling-winrate |
0 或 1 / 1 |
边界探险家 boundary-explorer |
至少 1 条 counterexample 且 outcome 为 pass | 0 或 1 / 1 |
修订者 reviser |
至少 1 个已通关卡 revisionCount 大于 0 | 0 或 1 / 1 |
迁移者 transferer |
至少 2 个已通关卡带 transfer 印章 | 带 transfer 的关卡数 / 2 |
核心判定只有一个表达式:
// packages/evidence/src/badges.ts(节选)
const view: BadgeView = {
id,
earned: Boolean(award) || progress.current >= progress.target,
title: localizedTitle,
criteria: levelTitle
? (locale === 'en' ? 'Complete "' + levelTitle + '"' : '完成「' + levelTitle + '」')
: localizedCriteria,
progress,
};
含义有两层:存档里已授予的一律算已得(award 优先,并带出 awardedAt),哪怕对应的事件已经被裁剪掉;没授予的则按当前进度现算。所以徽章能被”重算”,但不会被”重算掉”。这也解释了 refreshBadges() 为什么要取满 MAX_EVIDENCE_EVENTS(3000 条)而不是只取最近几百条——否则 3000 条上限里的旧证据会被静默忽略,边界探险家这类依赖反例事件的徽章会莫名消失。
ProgressManager.refreshBadges() 负责把”达标但还没落盘”的徽章写进存档:
// packages/progress/src/manager.ts(节选)
const events = await this.evidence.recent(MAX_EVIDENCE_EVENTS);
const views = evaluateBadges(this.current, events, LEVEL_SUMMARIES);
const badges = { ...this.current.badges };
const newly: BadgeId[] = [];
// recent() 按时间倒序(最新在前):引用取最新的 20 条,而非窗口里最旧的 20 条。
const evidenceRefs = events.slice(0, 20).map((event) => event.id);
for (const view of views) {
if (!view.earned || badges[view.id]) continue;
badges[view.id] = { awardedAt: now, evidenceRefs };
newly.push(view.id);
}
注释里的那条修正是真实缺陷:早期实现取的是”窗口里最旧的 20 条”,报告回看时点开的是一堆初学事件;现在 recent() 最新在前,slice(0, 20) 就是最新 20 条。
需要说明一处文档漂移:docs/01 第 6.3 节里的九枚徽章条件(例如”至少 4 个变体”“5 次修订链”)与实现不完全一致,实现按上表的口径。写内容或补测试时以 packages/evidence/src/badges.ts 为准。
14.6 localize:本地化不硬编码在视图里
英文本地化集中在 packages/evidence/src/localize.ts,导出三个函数:
// packages/evidence/src/index.ts(节选)
export { localizeDimensions, localizeReport, localizeTrail } from './localize';
它们的分工:
| 函数 | 输入 | 英文来源 |
|---|---|---|
localizeDimensions |
四维画像 | DIM_LABELS_EN / DIM_TEXT_EN / SIGNAL_LABELS_EN |
localizeTrail |
轨迹条目 | PHASE_LABELS_EN / TOOL_LABELS_EN + 关卡英文标题 |
localizeReport |
一页式报告 | 组合上面两个,另加 PRIVACY_EN 与推荐句 |
一个实现细节:localizeTrail 返回的是新数组,不修改入参。中文界面直接返回原数组(if (locale !== 'en') return items;),因为中文文案在 buildTrail() 里已经拼好;英文界面才逐条重建 label。
中文文案不在 localize 里,而在各自的构建函数里(DIMENSION_TEXT、PHASE_LABELS、TITLES / CRITERIA)。这样中文渲染路径零查表开销,英文路径只多一次数组映射;代价是同一份语义维护两张表,新增徽章时两处都要改。
14.7 外壳侧:ProgressManager 补身份,BadgeGrid 补视觉
证据层不认识 id / profileId / at,外壳把它们补齐。补齐发生在 ProgressManager.recordEvidence():
// packages/progress/src/manager.ts(节选)
async recordEvidence(levelId: LevelId, drafts: EvidenceDraft[]): Promise<void> {
if (!isKnownLevelId(levelId) || !Array.isArray(drafts) || drafts.length === 0) return;
const at = this.clock();
const batch = this.revisionBatches.get(levelId) ?? 0;
const events: EvidenceEvent[] = [];
for (const draft of drafts) {
const event = toEvidenceEvent(draft, {
id: this.nextEvidenceId(at),
at,
levelId,
revision: batch,
});
if (event) events.push(event);
}
// 整批草稿都坏时不占 revision,也不写空数组。
if (events.length === 0) return;
this.revisionBatches.set(levelId, batch + 1);
await this.evidence.append(events);
}
这里有三条边界:
- 未知关卡 id 与空批次直接返回,不写盘。运行时传错 id 不应该污染证据库。
revision是”同一关内第几次证据批次”(0 起),不是”第几次修订”。草稿不带修订号,外壳按批次自增,于是批次序号天然就是该关的修订轨迹;整批都坏时不占用 revision,避免出现空号。id由at加自增计数生成,同一毫秒内多条事件也不会撞 id。
再往里一层是清洗。toEvidenceEvent() 做字段白名单:phase 必须在 EVIDENCE_PHASES 里,tool 必须在 TOOL_IDS 里,outcome / scaffold 同样枚举校验,坏草稿整条丢弃且不占 revision。紧接着是体积控制:
// packages/progress/src/manager.ts(节选)
/** 证据事件单条序列化上限(docs/02 §8.4:≤ 1.5 KB)。 */
const MAX_EVENT_BYTES = 1536;
/** 单条事件的 metrics 键数上限(docs/02 §8.4)。 */
const MAX_METRICS_KEYS = 8;
/** 兜底裁剪:先丢可选字段(metrics → artifact → failureClass),最后才截 action。 */
function fitEventSize(event: EvidenceEvent): EvidenceEvent {
if (eventBytes(event) <= MAX_EVENT_BYTES) return event;
delete event.metrics;
if (eventBytes(event) <= MAX_EVENT_BYTES) return event;
delete event.artifact;
if (eventBytes(event) <= MAX_EVENT_BYTES) return event;
delete event.failureClass;
if (eventBytes(event) <= MAX_EVENT_BYTES) return event;
event.action = truncateChars(event.action, 40);
return event;
}
eventBytes() 统计的是 UTF-8 字节数,不是字符串长度:一条中文事件的 length 与落盘体积能差三倍,按 length 判会漏。metrics 只保留前 8 个有限数值键,键名截到 32 个码点。
视图层的分工也值得记一笔。BadgeGrid.vue 只做”怎么长”的决定,图案与贴纸旋转角都放在外壳:
// apps/site/src/components/BadgeGrid.vue(节选)
/**
* 徽章数据里没有图案字段(证据层只管"授没授"),
* 贴纸图案属于外壳的视觉决定,所以放在这里而不是 @mmf/evidence。
*/
const STICKER_EMOJI: Record<BadgeId, string> = {
observer: '👀',
'pattern-spotter': '🧩',
'rule-designer': '📜',
builder: '🧱',
modeler: '⚙️',
experimenter: '🔬',
'boundary-explorer': '🧭',
reviser: '🔁',
transferer: '🌈',
};
/** 已获得的贴纸微微歪一点,像真的贴上去的;灰度虚线的是还没收集到的空位。 */
const STICKER_TILT = ['-rotate-1', 'rotate-1', 'rotate-2', '-rotate-2', 'rotate-1', '-rotate-1'];
BadgesView.vue 负责取数与总进度环:徽章数组由 evaluateBadges(progress.save, progress.evidence, LEVEL_SUMMARIES, locale) 现算,页面挂载后先 progress.init()、再 loadEvidence(300)、最后 refreshBadges(),保证”看到的就是已落盘的”。进度环用 conic-gradient 画,不引 SVG:
// apps/site/src/views/BadgesView.vue(节选)
const ringStyle = computed(() => ({
background: 'conic-gradient(var(--color-teal) ' + percent.value + '%, var(--color-grid) 0)',
}));
这里的 :style 是组件内的动态绑定,不是文档里的内联样式;站点侧只约束 Markdown 正文不写 style="...",组件代码不受影响。
小结
证据层把”孩子做了什么”变成可重算、可解释、可本地化的数据:
- 运行时只产出
EvidenceDraft,id / profileId / at / revision由ProgressManager补齐,薄契约让运行时与存储解耦; emptyAggregate()/aggregate()是纯函数,byTool是穷尽记录;平台的裁剪路径因为依赖方向另有一份最小实现;buildEvidenceProfile()用确定性算术算出四维档位与信号,档位是句子索引而不是分数;evaluateBadges()的earned是”已授予 或 当前达标”,所以徽章可重算但不会倒退;refreshBadges()取满 3000 条事件、引用最新 20 条;- 英文本地化集中在
localize.ts,中文文案留在构建函数里; - 写入侧的三条边界(未知 id、空批次、整批坏)与 1.5 KB / 8 键限制,是证据库长期可用的前提。
下一章看这些事件的另一面:给孩子的思维日志与轨迹,以及给成人的一页式报告。