Skip to content
雲里
里雾

工作记忆的状态机抽象 — trials[] 派生量 + discriminated union

mindgym 开发 更新于 2026/5/19

N-Back 训练模块的核心设计选择:不维护”工作记忆窗口”作为独立 state,而是把 trials[] 数组本身作为唯一真相,所有派生量(target 判定 / outcome)通过纯函数从数组推导。本页拆解为什么这种”数据-逻辑分离”的状态机抽象比传统的环形 buffer / boolean 组合更适合 React + TypeScript 生态。


概述

N-Back 任务(工作记忆训练)天然让人想到环形 buffer:每来一个刺激就 push、超过 N 就 shift,比较新刺激与 buffer 头部是否相同。在命令式 C/C++ 项目里这是最优解。但在 React + TypeScript 项目里水土不服,原因有三:

  1. state 分裂:workingMemory 是 stimulusHistory 的另一份拷贝,必须永远同步
  2. 判定瞬间消失:isTarget 算完即扔,事后无法回溯
  3. 前 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.respondedoutcome。状态机只关心”现在到哪一题了”,纯函数管”这一题对不对”,两者各管一面。

派生量不存 state

工作记忆窗口的内容是 trials.slice(currentIndex - n, currentIndex).map(t => t.stimulus)——要的时候算。已答 trial 数 = currentIndex,剩余 = TRIAL_COUNT - currentIndex - 1,完成率 = currentIndex / TRIAL_COUNT。所有派生量都通过 selector / 纯函数算,不污染 state 真相。

与其他方案对比

方案优点缺点适合场景
环形 buffer + indexO(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 设计错误。

面试问题

参见

参考