Skip to content

Commit 0d43362

Browse files
committed
fix(client): 学习算法修复——FSRS初始稳定性对齐权重/sm2缺失字段防御/复习写失败可恢复
- ALG-H1: FSRS S0(G) 从 FSRS-4 硬编码常量 [0.4,0.6,2.4,9.0] 改为权重 w[0..3](fsrs-rs 官方语义 S0(G)=w[G-1]);修正误导注释(权重为项目自定义非官方默认、w[6] 为保留位不参与 difficulty 更新、D0 为固定表近似并标注官方公式);同步更新测试断言 - ALG-M1: sm2 easeFactor 缺失/NaN/0 防御(回退 2.5),消除 NaN interval → Invalid Date 无限传播 - ALG-M2: rateCard 中 updateCard 改为 await + try/catch——异步写失败不再 unhandled rejection;review 已落库时继续推进 UI(内存态最新,DB 旧值下次复习覆盖,不丢数据不重复调度) - ALG-L1: SM-2 历史 interval 反推 stability 的近似性注释说明 - ALG-L2: goldenErrorMultiplier 历史遗留接口注释(实际由 store 层 compressForGoldenError 后处理生效) 验证: lint 0 errors + 875 tests passed + build 成功(FSRS/SM2 44 项算法测试全通过)
1 parent c7bdb7b commit 0d43362

4 files changed

Lines changed: 68 additions & 34 deletions

File tree

‎client/src/features/flashcards/store/useStudySessionStore.ts‎

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -318,16 +318,24 @@ export const useStudySessionStore = create<StudySessionState>((set, get) => {
318318

319319
// 同步 flashcard store 中对应的卡片状态(含操作日志)
320320
const flashcardState = useFlashcardStore.getState();
321-
flashcardState.updateCard(card.id, {
322-
easeFactor: result.easeFactor,
323-
interval: effectiveInterval,
324-
repetitions: result.repetitions,
325-
lapses: result.lapses,
326-
dueDate: effectiveDueDate,
327-
stability: result.stability,
328-
difficulty: result.difficulty,
329-
lastReviewDate: updatedAt,
330-
});
321+
try {
322+
// ALG-M2: updateCard 为异步写(原未 await 的 rejection 是 unhandled)。
323+
// 若此处失败(review 已落库、card 未更新),捕获并继续推进 UI——
324+
// 内存态(updatedCards)已是最新,DB 旧值将在下次复习时被覆盖,
325+
// 不丢数据;失败仅造成一次性的调度偏差,而非半写卡死或重复调度
326+
await flashcardState.updateCard(card.id, {
327+
easeFactor: result.easeFactor,
328+
interval: effectiveInterval,
329+
repetitions: result.repetitions,
330+
lapses: result.lapses,
331+
dueDate: effectiveDueDate,
332+
stability: result.stability,
333+
difficulty: result.difficulty,
334+
lastReviewDate: updatedAt,
335+
});
336+
} catch (updateErr) {
337+
console.error('[StudySession] 卡片状态更新失败(复习记录已保存):', updateErr);
338+
}
331339

332340
const nextIndex = currentIndex + 1;
333341
const isLastCard = nextIndex >= sessionCards.length;

‎client/src/lib/fsrs.test.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ const W = [
4040
2.8278, 0.7846, 0.2,
4141
];
4242

43-
/** 初始 stability 按评分索引:S0 = [0.4, 0.6, 2.4, 9.0] */
44-
const S0 = [0.4, 0.6, 2.4, 9.0];
43+
/** 初始 stability 按评分索引:S0(G) = w[G-1](ALG-H1 官方对齐后为 W[0..3]) */
44+
const S0 = [W[0], W[1], W[2], W[3]];
4545

4646
/** 初始 difficulty 按评分索引:D0 = [4.3, 3.3, 2.6, 1.0] */
4747
const D0 = [4.3, 3.3, 2.6, 1.0];
@@ -76,7 +76,7 @@ describe('FSRS-5 算法', () => {
7676
// ── 新卡片首次复习 ─────────────────────────────────────────────────────
7777

7878
describe('新卡片首次复习(stability=0,interval=0)', () => {
79-
it('Again 评分:应使用 S0[0]=0.4 和 D0[0]=4.3', () => {
79+
it('Again 评分:应使用 S0[0]=w[0] 和 D0[0]=4.3(ALG-H1: S0 取权重而非旧常量)', () => {
8080
const result = fsrs(NEW_CARD, Rating.Again, NOW);
8181
expect(result.stability).toBe(S0[Rating.Again]);
8282
expect(result.difficulty).toBe(D0[Rating.Again]);
@@ -85,21 +85,21 @@ describe('FSRS-5 算法', () => {
8585
expect(result.lapses).toBe(0);
8686
});
8787

88-
it('Hard 评分:应使用 S0[1]=0.6 和 D0[1]=3.3', () => {
88+
it('Hard 评分:应使用 S0[1]=w[1] 和 D0[1]=3.3(ALG-H1)', () => {
8989
const result = fsrs(NEW_CARD, Rating.Hard, NOW);
9090
expect(result.stability).toBe(S0[Rating.Hard]);
9191
expect(result.difficulty).toBe(D0[Rating.Hard]);
9292
expect(result.repetitions).toBe(1);
9393
});
9494

95-
it('Good 评分:应使用 S0[2]=2.4 和 D0[2]=2.6', () => {
95+
it('Good 评分:应使用 S0[2]=w[2] 和 D0[2]=2.6(ALG-H1)', () => {
9696
const result = fsrs(NEW_CARD, Rating.Good, NOW);
9797
expect(result.stability).toBe(S0[Rating.Good]);
9898
expect(result.difficulty).toBe(D0[Rating.Good]);
9999
expect(result.repetitions).toBe(1);
100100
});
101101

102-
it('Easy 评分:应使用 S0[3]=9.0 和 D0[3]=1.0', () => {
102+
it('Easy 评分:应使用 S0[3]=w[3] 和 D0[3]=1.0(ALG-H1)', () => {
103103
const result = fsrs(NEW_CARD, Rating.Easy, NOW);
104104
expect(result.stability).toBe(S0[Rating.Easy]);
105105
expect(result.difficulty).toBe(D0[Rating.Easy]);

‎client/src/lib/fsrs.ts‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,17 @@ export interface FSRSResult {
4242
}
4343

4444
// ---------------------------------------------------------------------------
45-
// FSRS-5 核心参数(参考 open-spaced-repetition/fsrs-rs)
45+
// FSRS-5 核心参数(结构参考 open-spaced-repetition/fsrs-rs)
4646
// ---------------------------------------------------------------------------
4747

48-
/** 初始 difficulty 按 rating 索引:D0(G) = [4.3, 3.3, 2.6, 1.0] */
48+
/**
49+
* 初始 difficulty 按 rating 索引(项目自定义近似表,结构对齐 FSRS)。
50+
* ALG-H1: fsrs-rs 的 D0 由 w[4]/w[5] 经指数公式计算,本项目采用固定表近似;
51+
* 已知与官方值存在偏差,但保持了单调性与量级一致性。若需完全对齐官方,
52+
* 应替换为 D0(G) = clamp(w[4] - exp(w[5]*(G-1)) + 1, 1, 10) 并重训权重。
53+
*/
4954
const D0 = [4.3, 3.3, 2.6, 1.0];
5055

51-
/** 初始 stability 按 rating 索引:S0(G) = [0.4, 0.6, 2.4, 9.0] */
52-
const S0 = [0.4, 0.6, 2.4, 9.0];
53-
5456
/** 目标保留率 */
5557
const REQUESTED_RETENTION = 0.9;
5658

@@ -59,17 +61,20 @@ const DECAY = -0.5;
5961

6062
/**
6163
* FSRS-5 的 19 个核心参数 w[0]-w[18]
62-
* 来自 open-spaced-repetition/fsrs-rs 默认参数
64+
* ALG-H1: 结构对齐 fsrs-rs v5(w[0..3]=初始稳定性、w[4..5]=难度初始化、
65+
* w[7]=难度回归强度、w[8..14]=稳定性更新、w[15..18]=惩罚/奖励/短时稳定性),
66+
* 具体数值为项目自定义(与 fsrs-rs 官方默认值存在偏差,禁止与官方工具混用);
67+
* 参数间存在耦合,禁止单独微调。
6368
*/
6469
const W = [
65-
0.4872, // w[0] — S0 相关
66-
1.4003, // w[1] — S0 相关
67-
3.7145, // w[2] — S0 相关
68-
13.8206, // w[3] — S0 相关
69-
5.1618, // w[4] — difficulty 相关
70-
1.2298, // w[5] — difficulty 相关
71-
0.8975, // w[6] — mean reversion weight (used in difficulty update)
72-
0.031, // w[7] — mean reversion weight (used in difficulty update)
70+
0.4872, // w[0] — 初始稳定性 Again
71+
1.4003, // w[1] — 初始稳定性 Hard
72+
3.7145, // w[2] — 初始稳定性 Good
73+
13.8206, // w[3] — 初始稳定性 Easy
74+
5.1618, // w[4] — difficulty 初始化
75+
1.2298, // w[5] — difficulty 初始化
76+
0.8975, // w[6] — 保留位(不参与 difficulty 更新,维持数组索引完整)
77+
0.031, // w[7] — 难度均值回归强度
7378
1.6474, // w[8] — stability after success
7479
0.1712, // w[9] — stability after success
7580
1.0872, // w[10] — stability after success
@@ -83,6 +88,15 @@ const W = [
8388
0.2, // w[18] — short-term stability
8489
];
8590

91+
/**
92+
* 初始 stability 按 rating 索引:S0(G) = w[G-1](fsrs-rs 官方语义),
93+
* 即 w[0..3] 分别对应 Again/Hard/Good/Easy 的初始稳定性。
94+
* ALG-H1: 原实现使用 FSRS-4 时代硬编码常量 [0.4, 0.6, 2.4, 9.0] 且未参与
95+
* W 权重计算,与"权重来自 fsrs-rs"的注释相矛盾,造成系统性调度偏差;
96+
* 现改为直接取权重 w[0..3](须在 W 定义之后求值,避免 TDZ)。
97+
*/
98+
const S0 = [W[0], W[1], W[2], W[3]];
99+
86100
// ---------------------------------------------------------------------------
87101
// 工具函数
88102
// ---------------------------------------------------------------------------
@@ -112,8 +126,9 @@ function intervalFromStability(stability: number, retention: number): number {
112126

113127
/**
114128
* 计算初始 difficulty(均值回归更新)
115-
* D' = w[7] * (D - w[6] * (D - D0(G))) + (1 - w[7]) * D
116-
* 简化:D' = D - w[7] * w[6] * (D - D0(G))
129+
* D' = w[7] * D0(G) + (1 - w[7]) * D,等价于 D' = D - w[7] * (D - D0(G))
130+
* ALG-H1: fsrs-rs v5 的难度回归仅使用 w[7](回归强度),w[6] 不参与
131+
* difficulty 更新(保留在参数数组中仅为维持索引完整性)
117132
*/
118133
function updateDifficulty(oldD: number, rating: Rating): number {
119134
const d0 = D0[rating];
@@ -183,8 +198,10 @@ export function fsrs(card: FSRSCardInput, rating: Rating, now?: Date): FSRSResul
183198
} else {
184199
// 首次使用 FSRS:根据历史 interval 推算初始 stability
185200
if (card.interval > 0 && card.repetitions > 0) {
186-
// 已有 SM-2 历史:从 interval 反推 stability
201+
// 已有 SM-2 历史:从 interval 反推 stability(ALG-L1: 近似值)
187202
// interval ≈ 9 * S * (1/R - 1) → S = interval / (9 * (1/R - 1))
203+
// 该反推基于"理想遗忘曲线 R=0.9"假设,忽略用户实际复习表现
204+
// (真实 R ≠ 0.9),迁移卡片的首次 FSRS 间隔可能与预期有一次性偏差
188205
S = card.interval / (9 * (1 / REQUESTED_RETENTION - 1));
189206
D = card.difficulty ?? D0[Rating.Good];
190207
} else {

‎client/src/lib/sm2.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,15 @@ export function sm2(card: SM2CardInput, rating: Rating, options?: SM2Options): S
9595
const q = toQuality(rating);
9696
const lapses = card.lapses ?? 0;
9797

98+
// ALG-M1: easeFactor 缺失/非法防御——历史数据缺字段、迁移列错位等场景下
99+
// easeFactor 为 undefined/NaN/0 时,原实现产生 NaN 并无限传播
100+
// (NaN interval → Invalid Date 时间戳损坏 → 后续复习全部异常)
101+
const rawEF = Number(card.easeFactor);
102+
const baseEF = Number.isFinite(rawEF) && rawEF > 0 ? rawEF : 2.5;
103+
98104
// 更新难度因子(EF)
99105
let newEF =
100-
card.easeFactor + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
106+
baseEF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02));
101107
newEF = Math.max(newEF, 1.3);
102108

103109
let newInterval: number;
@@ -137,6 +143,9 @@ export function sm2(card: SM2CardInput, rating: Rating, options?: SM2Options): S
137143
newInterval = Math.min(newInterval, 1825);
138144

139145
// Golden error:高自信答错时缩短间隔
146+
// ALG-L2: 本参数为历史遗留接口——实际黄金错误加速由 store 层
147+
// compressForGoldenError 后处理实现(useStudySessionStore.ts),
148+
// 本参数未传入时保持默认不调整;两套机制并存但仅后处理生效
140149
const multiplier = options?.goldenErrorMultiplier;
141150
// 下界取 >= 0:传 0 表示尽可能缩短,经 Math.max 收敛到最小间隔 1 天;
142151
// 上界仍为 < 1,因 1.0 的语义就是不调整

0 commit comments

Comments
 (0)