From 81547951c0ff2c14568e9307336578d0aa1be2ba Mon Sep 17 00:00:00 2001 From: ishiko Date: Sat, 4 Nov 2023 20:59:16 +0800 Subject: [PATCH] Feat/forget (#42) * Feat/forget * cherry-pick Feat/grades * 3.1.0-beta2 --- README.md | 41 ++++++++++++-------------- __tests__/FSRSV4.test.ts | 24 ++++++++------- __tests__/forget.test.ts | 60 ++++++++++++++++++++++++++++++++++++++ __tests__/help.tsts.ts | 5 ++++ __tests__/rollback.test.ts | 6 ++-- package.json | 2 +- src/fsrs/algorithm.ts | 16 +++++----- src/fsrs/default.ts | 2 +- src/fsrs/fsrs.ts | 37 ++++++++++++++++++++++- src/fsrs/help.ts | 4 ++- src/fsrs/index.ts | 2 ++ src/fsrs/models.ts | 7 ++++- 12 files changed, 158 insertions(+), 48 deletions(-) create mode 100644 __tests__/forget.test.ts create mode 100644 __tests__/help.tsts.ts diff --git a/README.md b/README.md index 3bc2012..5f7cfc9 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ cp .env.local.example .env.local # Example ```typescript -import {createEmptyCard, formatDate, fsrs, generatorParameters, Rating} from 'ts-fsrs'; +import {createEmptyCard, formatDate, fsrs, generatorParameters, Rating, Grades} from 'ts-fsrs'; const params = generatorParameters({ enable_fuzz: true }); const f = fsrs(params); @@ -30,28 +30,25 @@ const now = new Date('2022-2-2 10:00:00');// new Date(); const scheduling_cards = f.repeat(card, now); // console.log(scheduling_cards); -Object.keys(Rating) - .filter(key => !isNaN(Number(key))) - .map(key => Number(key) as Rating) // [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy] - .forEach(grade => { - const { log, card } = scheduling_cards[grade]; - console.group(`${Rating[grade]}`); - console.table({ - [`card_${Rating[grade]}`]: { - ...card, - due: formatDate(card.due), - last_review: formatDate(card.last_review as Date), - }, - }); - console.table({ - [`log_${Rating[grade]}`]: { - ...log, - review: formatDate(log.review), - }, - }); - console.groupEnd(); - console.log('----------------------------------------------------------------'); +Grades.forEach(grade => { // [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy] + const { log, card } = scheduling_cards[grade]; + console.group(`${Rating[grade]}`); + console.table({ + [`card_${Rating[grade]}`]: { + ...card, + due: formatDate(card.due), + last_review: formatDate(card.last_review as Date), + }, }); + console.table({ + [`log_${Rating[grade]}`]: { + ...log, + review: formatDate(log.review), + }, + }); + console.groupEnd(); + console.log('----------------------------------------------------------------'); +}); ``` > More examples refer to the [Example](https://github.com/ishiko732/ts-fsrs/blob/master/example/index.ts) diff --git a/__tests__/FSRSV4.test.ts b/__tests__/FSRSV4.test.ts index 92d3e69..a305ecf 100644 --- a/__tests__/FSRSV4.test.ts +++ b/__tests__/FSRSV4.test.ts @@ -5,16 +5,15 @@ import { FSRS, createEmptyCard, State, + Grade, + Grades } from "../src/fsrs"; describe("initial FSRS V4", () => { const params = generatorParameters(); const f: FSRS = fsrs(params); - const Ratings = Object.keys(Rating) - .filter((key) => !isNaN(Number(key))) - .map((key) => Number(key) as Rating); it("initial stability ", () => { - Ratings.forEach((grade) => { + Grades.forEach((grade) => { const s = f.init_stability(grade); expect(s).toEqual(params.w[grade - 1]); }); @@ -27,7 +26,7 @@ describe("initial FSRS V4", () => { }); it("initial difficulty ", () => { - Ratings.forEach((grade) => { + Grades.forEach((grade) => { const s = f.init_difficulty(grade); expect(s).toEqual(params.w[4] - (grade - 3) * params.w[5]); }); @@ -50,12 +49,12 @@ describe("FSRS V4 AC by py-fsrs", () => { ], enable_fuzz: false, }); - const grade = [Rating.Again, Rating.Hard, Rating.Good,Rating.Easy]; + const grade: Grade[] = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy]; it("ivl_history", () => { let card = createEmptyCard(); let now = new Date(2022, 11, 29, 12, 30, 0, 0); let scheduling_cards = f.repeat(card, now); - const ratings = [ + const ratings: Grade[] = [ Rating.Good, Rating.Good, Rating.Good, @@ -74,8 +73,8 @@ describe("FSRS V4 AC by py-fsrs", () => { for (const rating of ratings) { for (const check of grade) { const rollbackCard = f.rollback( - scheduling_cards[check].card, - scheduling_cards[check].log, + scheduling_cards[check].card, + scheduling_cards[check].log, ); expect(rollbackCard).toEqual(card); } @@ -95,7 +94,12 @@ describe("FSRS V4 AC by py-fsrs", () => { const card = createEmptyCard(); const now = new Date(2022, 11, 29, 12, 30, 0, 0); const scheduling_cards = f.repeat(card, now); - const grades = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy]; + const grades: Grade[] = [ + Rating.Again, + Rating.Hard, + Rating.Good, + Rating.Easy, + ]; const stability: number[] = []; const difficulty: number[] = []; diff --git a/__tests__/forget.test.ts b/__tests__/forget.test.ts new file mode 100644 index 0000000..cc59163 --- /dev/null +++ b/__tests__/forget.test.ts @@ -0,0 +1,60 @@ +import { createEmptyCard, fsrs, FSRS, Rating } from "../src/fsrs"; +import { Grade } from "../src/fsrs/models"; + +describe("FSRS forget", () => { + const f: FSRS = fsrs({ + w: [ + 1.14, 1.01, 5.44, 14.67, 5.3024, 1.5662, 1.2503, 0.0028, 1.5489, 0.1763, + 0.9953, 2.7473, 0.0179, 0.3105, 0.3976, 0.0, 2.0902, + ], + enable_fuzz: false, + }); + it("forget", () => { + const card = createEmptyCard(); + const now = new Date(2022, 11, 29, 12, 30, 0, 0); + const forget_now = new Date(2023, 11, 30, 12, 30, 0, 0); + const scheduling_cards = f.repeat(card, now); + const grades: Grade[] = [ + Rating.Again, + Rating.Hard, + Rating.Good, + Rating.Easy, + ]; + for (const grade of grades) { + const forgetCard = f.forget( + scheduling_cards[grade].card, + forget_now, + true, + ); + expect(forgetCard.card).toEqual({ + ...card, + due: forget_now, + lapses: 0, + reps: 0, + last_review: scheduling_cards[grade].card.last_review, + }); + expect(forgetCard.log.rating).toEqual(Rating.Manual); + expect(() => f.rollback(forgetCard.card, forgetCard.log)).toThrowError( + "Cannot rollback a manual rating", + ); + } + for (const grade of grades) { + const forgetCard = f.forget( + scheduling_cards[grade].card, + forget_now, + false, + ); + expect(forgetCard.card).toEqual({ + ...card, + due: forget_now, + lapses: scheduling_cards[grade].card.lapses, + reps: scheduling_cards[grade].card.reps, + last_review: scheduling_cards[grade].card.last_review, + }); + expect(forgetCard.log.rating).toEqual(Rating.Manual); + expect(() => f.rollback(forgetCard.card, forgetCard.log)).toThrowError( + "Cannot rollback a manual rating", + ); + } + }); +}); diff --git a/__tests__/help.tsts.ts b/__tests__/help.tsts.ts new file mode 100644 index 0000000..0875100 --- /dev/null +++ b/__tests__/help.tsts.ts @@ -0,0 +1,5 @@ +import { Grades, Rating } from "../src/fsrs"; + +test("FSRS-Grades", () => { + expect(Grades).toStrictEqual([Rating.Again, Rating.Hard, Rating.Good, Rating.Easy]); +}); diff --git a/__tests__/rollback.test.ts b/__tests__/rollback.test.ts index a1bdaf6..7d523d3 100644 --- a/__tests__/rollback.test.ts +++ b/__tests__/rollback.test.ts @@ -1,4 +1,4 @@ -import { createEmptyCard, fsrs, FSRS, Rating } from "../src/fsrs"; +import {createEmptyCard, fsrs, FSRS, Grade, Rating} from "../src/fsrs"; describe("FSRS rollback", () => { const f: FSRS = fsrs({ @@ -12,7 +12,7 @@ describe("FSRS rollback", () => { const card = createEmptyCard(); const now = new Date(2022, 11, 29, 12, 30, 0, 0); const scheduling_cards = f.repeat(card, now); - const grade = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy]; + const grade:Grade[] = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy]; for (const rating of grade) { const rollbackCard = f.rollback( scheduling_cards[rating].card, @@ -29,7 +29,7 @@ describe("FSRS rollback", () => { card = scheduling_cards["4"].card; now = card.due; scheduling_cards = f.repeat(card, now); - const grade = [Rating.Again, Rating.Hard, Rating.Good,Rating.Easy]; + const grade:Grade[] = [Rating.Again, Rating.Hard, Rating.Good,Rating.Easy]; for (const rating of grade) { const rollbackCard = f.rollback( scheduling_cards[rating].card, diff --git a/package.json b/package.json index 015e23e..661e760 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts-fsrs", - "version": "3.1.0-beta1", + "version": "3.1.0-beta2", "description": "ts-fsrs is a TypeScript package used to implement the Free Spaced Repetition Scheduler (FSRS) algorithm. It helps developers apply FSRS to their flashcard applications, thereby improving the user learning experience.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/fsrs/algorithm.ts b/src/fsrs/algorithm.ts index dba1789..10e0495 100644 --- a/src/fsrs/algorithm.ts +++ b/src/fsrs/algorithm.ts @@ -1,6 +1,6 @@ import pseudorandom from "seedrandom"; import { generatorParameters, SchedulingCard } from "./index"; -import { FSRSParameters, Rating } from "./models"; +import {FSRSParameters, Grade, Rating} from "./models"; import type { int } from "./type"; // Ref: https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm#fsrs-v4 @@ -77,7 +77,7 @@ export class FSRSAlgorithm { * @param g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] * @return Stability (interval when R=90%) */ - init_stability(g: number): number { + init_stability(g: Grade): number { return Math.max(this.param.w[g - 1], 0.1); } @@ -86,10 +86,10 @@ export class FSRSAlgorithm { * $$D_0(G) = w_4 - (G-3) \cdot w_5$$ * $$\min \{\max \{D_0(G),1\},10\}$$ * where the D_0(3)=w_4 when the first rating is good. - * @param {number} g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] + * @param {Grade} g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] * @return {number} Difficulty D \in [1,10] */ - init_difficulty(g: number): number { + init_difficulty(g: Grade): number { return Math.min( Math.max(this.param.w[4] - (g - 3) * this.param.w[5], 1), 10, @@ -130,10 +130,10 @@ export class FSRSAlgorithm { * $$next_d = D - w_6 \cdot (R - 2)$$ * $$D^\prime(D,R) = w_5 \cdot D_0(2) +(1 - w_5) \cdot next_d$$ * @param {number} d Difficulty D \in [1,10] - * @param {Rating} g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] + * @param {Grade} g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] * @return {number} next_D */ - next_difficulty(d: number, g: number): number { + next_difficulty(d: number, g: Grade): number { const next_d = d - this.param.w[6] * (g - 3); return this.constrain_difficulty( this.mean_reversion(this.param.w[4], next_d), @@ -166,10 +166,10 @@ export class FSRSAlgorithm { * @param {number} d Difficulty D \in [1,10] * @param {number} s Stability (interval when R=90%) * @param {number} r Retrievability (probability of recall) - * @param {Rating} g Grade (Rating[0.again,1.hard,2.good,3.easy]) + * @param {Grade} g Grade (Rating[0.again,1.hard,2.good,3.easy]) * @return {number} S^\prime_r new stability after recall */ - next_recall_stability(d: number, s: number, r: number, g: Rating): number { + next_recall_stability(d: number, s: number, r: number, g: Grade): number { const hard_penalty = Rating.Hard === g ? this.param.w[15] : 1; const easy_bound = Rating.Easy === g ? this.param.w[16] : 1; return ( diff --git a/src/fsrs/default.ts b/src/fsrs/default.ts index 279308b..bddc5b8 100644 --- a/src/fsrs/default.ts +++ b/src/fsrs/default.ts @@ -30,7 +30,7 @@ export const default_w = envParams.FSRS_W || [ ]; export const default_enable_fuzz = envParams.FSRS_ENABLE_FUZZ || false; -export const FSRSVersion: string = "3.1.0-beta1"; +export const FSRSVersion: string = "3.1.0-beta2"; export const generatorParameters = (props?: Partial): FSRSParameters => { return { diff --git a/src/fsrs/fsrs.ts b/src/fsrs/fsrs.ts index 7b6b5eb..614c63c 100644 --- a/src/fsrs/fsrs.ts +++ b/src/fsrs/fsrs.ts @@ -7,6 +7,7 @@ import { FSRSParameters, Rating, RecordLog, + RecordLogItem, ReviewLog, ReviewLogInput, State, @@ -103,7 +104,9 @@ export class FSRS extends FSRSAlgorithm { rollback = (card: CardInput, log: ReviewLogInput): Card => { card = this.preProcessCard(card); log = this.preProcessLog(log); - + if (log.rating === Rating.Manual) { + throw new Error("Cannot rollback a manual rating"); + } let last_due, last_review, last_lapses; switch (log.state) { case State.New: @@ -135,4 +138,36 @@ export class FSRS extends FSRSAlgorithm { last_review: last_review, }; }; + + forget = ( + card: CardInput, + now: DateInput, + reset_count: boolean = false, + ): RecordLogItem => { + card = this.preProcessCard(card); + now = this.preProcessDate(now); + const forget_log: ReviewLog = { + rating: Rating.Manual, + state: card.state, + due: card.due, + stability: card.stability, + difficulty: card.difficulty, + elapsed_days: card.elapsed_days, + scheduled_days: card.scheduled_days, + review: now, + }; + const forget_card: Card = { + ...card, + due: now, + stability: 0, + difficulty: 0, + elapsed_days: 0, + scheduled_days: 0, + reps: reset_count ? 0 : card.reps, + lapses: reset_count ? 0 : card.lapses, + state: State.New, + last_review: card.last_review, + }; + return { card: forget_card, log: forget_log }; + }; } diff --git a/src/fsrs/help.ts b/src/fsrs/help.ts index 7459625..ebaa02a 100644 --- a/src/fsrs/help.ts +++ b/src/fsrs/help.ts @@ -1,5 +1,5 @@ import type { int, unit } from "./type"; -import { Rating, State } from "./models"; +import {Grade, Rating, State} from "./models"; declare global { export interface Date { @@ -143,3 +143,5 @@ export function fixRating(value: unknown): Rating { } throw new Error(`Invalid rating:[${value}]`); } + +export const Grades: Grade[] = [Rating.Again, Rating.Hard, Rating.Good, Rating.Easy]; \ No newline at end of file diff --git a/src/fsrs/index.ts b/src/fsrs/index.ts index b329752..8f32adf 100644 --- a/src/fsrs/index.ts +++ b/src/fsrs/index.ts @@ -15,6 +15,7 @@ export { date_diff, formatDate, show_diff_message, + Grades } from "./help"; export type { int, double } from "./type"; @@ -26,6 +27,7 @@ export type { RecordLogItem, StateType, RatingType, + Grade, CardInput, DateInput } from "./models"; diff --git a/src/fsrs/models.ts b/src/fsrs/models.ts index 7d9f6fd..2c0b974 100644 --- a/src/fsrs/models.ts +++ b/src/fsrs/models.ts @@ -10,12 +10,17 @@ export enum State { export type RatingType = "Again" | "Hard" | "Good" | "Easy"; export enum Rating { + Manual = 0, Again = 1, Hard = 2, Good = 3, Easy = 4, } +type ExcludeManual = Exclude; + +export type Grade = ExcludeManual; + export interface ReviewLog { rating: Rating; state: State; @@ -30,7 +35,7 @@ export type RecordLogItem = { card: Card; log: ReviewLog } export type RecordLog = { - [key in Rating]: RecordLogItem; + [key in Grade]: RecordLogItem; }; export interface Card {