From 8355606f6f33e3638d32087d56747b79fc2f1e43 Mon Sep 17 00:00:00 2001 From: ishiko Date: Tue, 7 Jan 2025 00:14:11 +0800 Subject: [PATCH] Feat/add `next_state` method (#146) * Feat/add next_state method * remove test * confirm the boundaries&clip PLS * bump version to 4.6.0 * Update the boundaries * Update src/fsrs/algorithm.ts Co-authored-by: Luc Mcgrady * Update src/fsrs/algorithm.ts Co-authored-by: Luc Mcgrady * Update __tests__/algorithm.test.ts Co-authored-by: Luc Mcgrady * Update __tests__/algorithm.test.ts Co-authored-by: Luc Mcgrady * Update __tests__/algorithm.test.ts Co-authored-by: Luc Mcgrady * Update __tests__/algorithm.test.ts Co-authored-by: Luc Mcgrady * Update __tests__/algorithm.test.ts Co-authored-by: Luc Mcgrady --------- Co-authored-by: Luc Mcgrady --- __tests__/algorithm.test.ts | 68 ++++++++++ __tests__/fixed/calc-elapsed-days.test.ts | 144 +++++++++++++++++++++- package.json | 2 +- src/fsrs/algorithm.ts | 58 ++++++++- src/fsrs/default.ts | 2 +- src/fsrs/index.ts | 1 + src/fsrs/models.ts | 5 + 7 files changed, 276 insertions(+), 4 deletions(-) diff --git a/__tests__/algorithm.test.ts b/__tests__/algorithm.test.ts index 9025276..4c44307 100644 --- a/__tests__/algorithm.test.ts +++ b/__tests__/algorithm.test.ts @@ -460,3 +460,71 @@ describe('change Params', () => { }).toThrow('Requested retention rate should be in the range (0,1]') }) }) + +describe('next_state', () => { + it('next_state not NaN', () => { + const f = fsrs() + const next_state = f.next_state( + { stability: 0, difficulty: 0 }, + 1, + 1 /** Again */ + ) + + expect(Number.isNaN(next_state.stability)).toBe(false) + expect(next_state).toEqual(f.next_state(null, 1, 1 /** Again */)) + expect(next_state).toEqual( + f.next_state({ difficulty: 0, stability: 0 }, 1, 1 /** Again */) + ) + }) + + it('invalid memory state', () => { + const f = fsrs() + + const init = f.next_state(null, 0, 3 /** Good */) + // d<1 + expect(() => { + f.next_state( + { stability: init.stability, difficulty: 0 }, + 1, + 1 /** Again */ + ) + }).toThrow(/^Invalid memory state/) + + // s<0.01 + expect(() => { + f.next_state( + { stability: 0, difficulty: init.stability }, + 1, + 1 /** Again */ + ) + }).toThrow(/^Invalid memory state/) + + // t<0 + expect(() => { + f.next_state( + { stability: 0, difficulty: 0 }, + -1 /** invalid delta_t */, + 1 /** Again */ + ) + }).toThrow(/^Invalid delta_t/) + + // g<0 + expect(() => { + f.next_state(init, 1, -1 /** invalid grade */) + }).toThrow(/^Invalid grade/) + + // g>4 + expect(() => { + f.next_state(init, 1, 5 /** invalid grade */) + }).toThrow(/^Invalid grade/) + }) + + it('clamped s', () => { + const f = fsrs() + const state = { difficulty: 9.98210112, stability: 0.01020119 } + + const newState = f.next_state(state, 1, 1) + + expect(newState.stability).toBeGreaterThanOrEqual(0.01) + }) +}) diff --git a/__tests__/fixed/calc-elapsed-days.test.ts b/__tests__/fixed/calc-elapsed-days.test.ts index 792c97f..2f45b73 100644 --- a/__tests__/fixed/calc-elapsed-days.test.ts +++ b/__tests__/fixed/calc-elapsed-days.test.ts @@ -1,4 +1,13 @@ -import { createEmptyCard, fsrs, Grade, Rating } from '../../src/fsrs' +import { + createEmptyCard, + dateDiffInDays, + fsrs, + FSRSHistory, + Grade, + Rating, + State, + FSRSState, +} from '../../src/fsrs' /** * @see https://forums.ankiweb.net/t/feature-request-estimated-total-knowledge-over-time/53036/58?u=l.m.sherlock @@ -24,3 +33,136 @@ test('TS-FSRS-Simulator', () => { expect(card.stability).toBeCloseTo(expected[i], 4) } }) + + +test('SSE use next_state', () => { + const f = fsrs({ + w: [ + 0.4911, 4.5674, 24.8836, 77.045, 7.5474, 0.1873, 1.7732, 0.001, 1.1112, + 0.152, 0.5728, 1.8747, 0.1733, 0.2449, 2.2905, 0.0, 2.9898, 0.0883, + 0.9033, + ], + }) + + const rids = [ + 1698678054940 /**2023-10-30T15:00:54.940Z */, + 1698678126399 /**2023-10-30T15:02:06.399Z */, + 1698688771401 /**2023-10-30T17:59:31.401Z */, + 1698688837021 /**2023-10-30T18:00:37.021Z */, + 1698688916440 /**2023-10-30T18:01:56.440Z */, + 1698698192380 /**2023-10-30T20:36:32.380Z */, + 1699260169343 /**2023-11-06T08:42:49.343Z */, + 1702718934003 /**2023-12-16T09:28:54.003Z */, + 1704910583686 /**2024-01-10T18:16:23.686Z */, + 1713000017248 /**2024-04-13T09:20:17.248Z */, + ] + const ratings: Rating[] = [3, 3, 1, 3, 3, 3, 0, 3, 0, 3] + // 0,0,0,0,0,0,47,119 + let last = new Date(rids[0]) + let memoryState: FSRSState | null = null + for (let i = 0; i < rids.length; i++) { + const current = new Date(rids[i]) + const rating = ratings[i] + const delta_t = dateDiffInDays(last, current) + const nextStates = f.next_state(memoryState, delta_t, rating) + if (rating !== 0) { + last = new Date(rids[i]) + } + + console.debug( + rids[i + 1], + rids[i], + delta_t, + +nextStates.stability.toFixed(2), + +nextStates.difficulty.toFixed(2) + ) + memoryState = nextStates + } + expect(memoryState?.stability).toBeCloseTo(71.77) +}) + +test.skip('SSE 71.77', () => { + const f = fsrs({ + w: [ + 0.4911, 4.5674, 24.8836, 77.045, 7.5474, 0.1873, 1.7732, 0.001, 1.1112, + 0.152, 0.5728, 1.8747, 0.1733, 0.2449, 2.2905, 0.0, 2.9898, 0.0883, + 0.9033, + ], + }) + + const rids = [ + 1698678054940 /**2023-10-30T15:00:54.940Z */, + 1698678126399 /**2023-10-30T15:02:06.399Z */, + 1698688771401 /**2023-10-30T17:59:31.401Z */, + 1698688837021 /**2023-10-30T18:00:37.021Z */, + 1698688916440 /**2023-10-30T18:01:56.440Z */, + 1698698192380 /**2023-10-30T20:36:32.380Z */, + 1699260169343 /**2023-11-06T08:42:49.343Z */, + 1702718934003 /**2023-12-16T09:28:54.003Z */, + 1704910583686 /**2024-01-10T18:16:23.686Z */, + 1713000017248 /**2024-04-13T09:20:17.248Z */, + ] + const ratings: Rating[] = [3, 3, 1, 3, 3, 3, 0, 3, 0, 3] + + const expected = [ + { + elapsed_days: 0, + s: 24.88, + d: 7.09, + }, + { + elapsed_days: 0, + s: 26.95, + d: 7.09, + }, + { + elapsed_days: 0, + s: 24.46, + d: 8.24, + }, + { + elapsed_days: 0, + s: 26.48, + d: 8.24, + }, + { + elapsed_days: 0, + s: 28.69, + d: 8.23, + }, + { + elapsed_days: 0, + s: 31.08, + d: 8.23, + }, + { + elapsed_days: 0, + s: 47.44, + d: 8.23, + }, + { + elapsed_days: 119, + s: 71.77, + d: 8.23, + }, + ] + + let card = createEmptyCard(new Date(rids[0])) + + for (let i = 0; i < rids.length; i++) { + const rating = ratings[i] + if (rating == 0) { + continue + } + + const now = new Date(rids[i]) + const log = f.next(card, now, rating) + card = log.card + console.debug(i + 1) + expect(card.elapsed_days).toBe(expected[i].elapsed_days) + expect(card.stability).toBeCloseTo(expected[i].s, 2) + expect(card.difficulty).toBeCloseTo(expected[i].d, 2) + } + + expect(card.stability).toBeCloseTo(71.77) +}) diff --git a/package.json b/package.json index 1926dbe..2dc8fa0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts-fsrs", - "version": "4.5.2", + "version": "4.6.0", "description": "ts-fsrs is a versatile package based on TypeScript that supports ES modules, CommonJS, and UMD. It implements the Free Spaced Repetition Scheduler (FSRS) algorithm, enabling developers to integrate FSRS into their flashcard applications to enhance the user learning experience.", "main": "dist/index.cjs", "umd": "dist/index.umd.js", diff --git a/src/fsrs/algorithm.ts b/src/fsrs/algorithm.ts index 73aedae..4ef44f7 100644 --- a/src/fsrs/algorithm.ts +++ b/src/fsrs/algorithm.ts @@ -1,5 +1,5 @@ import { generatorParameters } from './default' -import { FSRSParameters, Grade, Rating } from './models' +import { FSRSParameters, FSRSState, Grade, Rating } from './models' import type { int } from './types' import { clamp, get_fuzz_range } from './help' import { alea } from './alea' @@ -275,4 +275,60 @@ export class FSRSAlgorithm { forgetting_curve(elapsed_days: number, stability: number): number { return +Math.pow(1 + (FACTOR * elapsed_days) / stability, DECAY).toFixed(8) } + + /** + * Calculates the next state of memory based on the current state, time elapsed, and grade. + * + * @param memory_state - The current state of memory, which can be null. + * @param t - The time elapsed since the last review. + * @param {Rating} g Grade (Rating[0.Manual,1.Again,2.Hard,3.Good,4.Easy]) + * @returns The next state of memory with updated difficulty and stability. + */ + next_state(memory_state: FSRSState | null, t: number, g: number): FSRSState { + const { difficulty: d, stability: s } = memory_state ?? { + difficulty: 0, + stability: 0, + } + if (t < 0) { + throw new Error(`Invalid delta_t "${t}"`) + } + if (g < 0 || g > 4) { + throw new Error(`Invalid grade "${g}"`) + } + if (d === 0 && s === 0) { + return { + difficulty: this.init_difficulty(g), + stability: this.init_stability(g), + } + } + if (g === 0) { + return { + difficulty: d, + stability: s, + } + } + if (d < 1 || s < 0.01) { + throw new Error(`Invalid memory state { difficulty: ${d}, stability: ${s} }`) + } + const r = this.forgetting_curve(t, s) + const s_after_success = this.next_recall_stability(d, s, r, g) + const s_after_fail = this.next_forget_stability(d, s, r) + const s_after_short_term = this.next_short_term_stability(s, g) + let new_s = s_after_success + if (g === 1) { + let [w_17, w_18] = [0, 0] + if (this.param.enable_short_term) { + w_17 = this.param.w[17] + w_18 = this.param.w[18] + } + const next_s_min = s / Math.exp(w_17 * w_18) + new_s = clamp(next_s_min, 0.01, s_after_fail) + } + if (t === 0 && this.param.enable_short_term) { + new_s = s_after_short_term + } + + const new_d = this.next_difficulty(d, g) + return { difficulty: new_d, stability: new_s } + } } diff --git a/src/fsrs/default.ts b/src/fsrs/default.ts index 28c1f23..bfa96d3 100644 --- a/src/fsrs/default.ts +++ b/src/fsrs/default.ts @@ -11,7 +11,7 @@ export const default_w = [ export const default_enable_fuzz = false export const default_enable_short_term = true -export const FSRSVersion: string = 'v4.5.2 using FSRS-5.0' +export const FSRSVersion: string = 'v4.6.0 using FSRS-5.0' export const generatorParameters = ( props?: Partial diff --git a/src/fsrs/index.ts b/src/fsrs/index.ts index b9c10c9..417669d 100644 --- a/src/fsrs/index.ts +++ b/src/fsrs/index.ts @@ -18,6 +18,7 @@ export type { DateInput, FSRSReview, FSRSHistory, + FSRSState } from './models' export { State, Rating } from './models' diff --git a/src/fsrs/models.ts b/src/fsrs/models.ts index 9ab21c8..5877625 100644 --- a/src/fsrs/models.ts +++ b/src/fsrs/models.ts @@ -106,3 +106,8 @@ export type FSRSHistory = Partial< review: DateInput | Date } ) + +export interface FSRSState { + stability: number + difficulty: number +}