第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 关只是让同一套规则有更多样本,口径本身没有改。

推荐同样是确定性规则:

  1. 有未完成关卡时,按摘要顺序取前 3 关;
  2. 全部完成后,先给「再做一次」,再按当前最薄的一维给「换一种做法」;
  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 键限制,是证据库长期可用的前提。

下一章看这些事件的另一面:给孩子的思维日志与轨迹,以及给成人的一页式报告。