N-Back 训练模块的核心设计选择:不维护”工作记忆窗口”作为独立 state,而是把
trials[]数组本身作为唯一真相,所有派生量(target 判定 / outcome)通过纯函数从数组推导。本页拆解为什么这种”数据-逻辑分离”的状态机抽象比传统的环形 buffer / boolean 组合更适合 React + TypeScript 生态。
概述
N-Back 任务(工作记忆训练)天然让人想到环形 buffer:每来一个刺激就 push、超过 N 就 shift,比较新刺激与 buffer 头部是否相同。在命令式 C/C++ 项目里这是最优解。但在 React + TypeScript 项目里水土不服,原因有三:
- state 分裂:workingMemory 是 stimulusHistory 的另一份拷贝,必须永远同步
- 判定瞬间消失:isTarget 算完即扔,事后无法回溯
- 前 N 题的边界:
boolean isTarget表达不了”不可判定”这第三种状态
MindGym 采用替代设计:训练开始前一次性生成完整 20 元素 trials[],每个 trial 自带预先计算的 isTarget(boolean | null),状态机推进只移动 currentIndex,所有派生量都通过纯函数从 trials[] 推导。
工作方式
一次性生成完整 trials[]
export interface TrialState {
index: number
stimulus: number // 0..9
isTarget: boolean | null // null when index < n
responded: boolean
responseRT: number | null
outcome: Outcome
}
// generateTrials 在训练开始前一次性产出长度 20 的数组
for (let i = 0; i < TRIAL_COUNT; i++) {
const isTarget = i < n ? null : stimuli[i] === stimuli[i - n]
trials.push({ index: i, stimulus: stimuli[i], isTarget, ... })
}
isTarget 在生成时就写死,不依赖运行时状态——因为序列预定、N-back 的 target 关系也就预定。
Discriminated Union 编码所有 6 种状态
export type Outcome =
| 'hit' | 'miss' | 'falseAlarm' | 'correctReject'
| 'pendingResponse' | 'neutral'
为什么不用 boolean × boolean?因为 boolean 组合无法区分”判定无意义”(前 n 题)和”对 non-target 不响应”(correctReject)——这两者数学上都是 (false, false),语义截然不同。Union 让 TypeScript 在编译期能做 exhaustiveness check,filter 时也能天然排除 neutral / pendingResponse。
状态机推进只移动 currentIndex
function advanceTrialImpl() {
const trial = state.trials[state.currentIndex]
const outcome = resolveOutcome(trial) // 纯函数
const updatedTrials = [...state.trials]
updatedTrials[state.currentIndex] = { ...trial, outcome }
set({ trials: updatedTrials, currentIndex: state.currentIndex + 1 })
}
resolveOutcome 是纯函数,从 trial.isTarget × trial.responded 推 outcome。状态机只关心”现在到哪一题了”,纯函数管”这一题对不对”,两者各管一面。
派生量不存 state
工作记忆窗口的内容是 trials.slice(currentIndex - n, currentIndex).map(t => t.stimulus)——要的时候算。已答 trial 数 = currentIndex,剩余 = TRIAL_COUNT - currentIndex - 1,完成率 = currentIndex / TRIAL_COUNT。所有派生量都通过 selector / 纯函数算,不污染 state 真相。
与其他方案对比
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 环形 buffer + index | O(1) 推进 | 命令式,与 React immutable 不合;窗口/历史 state 分裂 | 嵌入式 / 高频音频 DSP |
| trials[] + currentIndex | 单一真相,派生量纯函数,TS 强约束 | trials 数组永驻内存(20 元素可忽略) | React + TS 项目,N≤100 |
| XState 状态机库 | trial-level 状态机可视化、转换可枚举 | 5KB+ bundle、学习曲线 | 复杂业务流程(订单流转等) |
派生 SDT 指标
trials[] 数组直接喂得进 filter,所有 SDT 信号都从同一数组求出:
const hits = trials.filter((t) => t.outcome === 'hit').length
const totalGo = hits + misses
if (totalGo === 0) return zeroMetrics()
const hitRate = correctRate(hits, totalGo)
const dPrime = zScore(hitRate) - zScore(falseAlarmRate)
没有”另一份记账数据”,没有同步问题。
核心判据:state 与派生量的界定
“reset 后会归零的 + 由用户操作直接驱动的”是 state;由 state 推导出来的是派生量。
currentIndex 是 state(reset 归零、tap 推进),“还剩几题”是派生(TRIAL_COUNT - currentIndex - 1)。把派生量塞进 state 会导致”两份数据不同步”的 bug,是 React 项目里最常见的 state 设计错误。
面试问题
- 为什么不用环形 buffer 实现工作记忆窗口?
- discriminated union 比 boolean 组合好在哪里?
- 为什么 isTarget 用
boolean | null而不是boolean | undefined? - 派生量和 state 怎么界定?
- 如果 N 很大(N=100、N=1000)会怎么样?
参见
- discriminated-unions — TypeScript 强约束的工程意义
- zustand-vs-redux — Zustand 的轻量状态机能力
- signal-detection-theory — d′ 与 criterion 的统计原理(同 milestone 产出)
参考
- TypeScript Handbook — Discriminated Unions
- Sandi Metz — Practical Object-Oriented Design(第 4 章 “Creating Flexible Interfaces”)
- Bjarnason & Chiusano — Functional Programming in Scala(第 6 章 “Purely Functional State”)
- Hautus (1995) — “Corrections for extreme proportions and their biasing effects on estimated values of d′”
- 项目源码:
features/nback/logic.ts:32-256+features/nback/store.ts:108-160