diff --git a/__tests__/algorithm.test.ts b/__tests__/algorithm.test.ts index 1e017bb..6726968 100644 --- a/__tests__/algorithm.test.ts +++ b/__tests__/algorithm.test.ts @@ -274,7 +274,7 @@ describe('next_interval', () => { (_, i) => (i + 1) / 10 ) const intervals: number[] = desired_retentions.map((r) => - fsrs({ request_retention: r }).next_interval(1.0, 0, false) + fsrs({ request_retention: r }).next_interval(1.0, 0) ) expect(intervals).toEqual([422, 102, 43, 22, 13, 8, 4, 2, 1, 1]) }) @@ -284,14 +284,15 @@ describe('next_interval', () => { const params = generatorParameters({ maximum_interval: 365 }) const intervalModifier = (Math.pow(params.request_retention, 1 / DECAY) - 1) / FACTOR - const f: FSRS = fsrs(params) + let f: FSRS = fsrs(params) const s = 737.47 - const next_ivl = f.next_interval(s, 0, false) + const next_ivl = f.next_interval(s, 0) expect(next_ivl).toEqual(params.maximum_interval) const t_fuzz = 98 - const next_ivl_fuzz = f.next_interval(s, t_fuzz, true) + f = fsrs({ ...params, enable_fuzz: true }) + const next_ivl_fuzz = fsrs(params).next_interval(s, t_fuzz) const { min_ivl, max_ivl } = get_fuzz_range( Math.round(s * intervalModifier), t_fuzz, @@ -331,6 +332,21 @@ describe('FSRS apply_fuzz', () => { expect(fuzzedInterval).toBeGreaterThanOrEqual(min_ivl) expect(fuzzedInterval).toBeLessThanOrEqual(max_ivl) }) + + test('return original interval when ivl is less than 3', () => { + const ivl = 3 + const enable_fuzz = true + const algorithm = new FSRSAlgorithm({ enable_fuzz: enable_fuzz }) + algorithm.seed = 'NegativeS2Seed' + const { min_ivl, max_ivl } = get_fuzz_range( + Math.round(ivl), + 0, + default_maximum_interval + ) + const fuzzedInterval = algorithm.apply_fuzz(ivl, 0) + expect(fuzzedInterval).toBeGreaterThanOrEqual(min_ivl) + expect(fuzzedInterval).toBeLessThanOrEqual(max_ivl) + }) }) describe('change Params', () => { diff --git a/__tests__/reschedule.test.ts b/__tests__/reschedule.test.ts index 7443924..ac35293 100644 --- a/__tests__/reschedule.test.ts +++ b/__tests__/reschedule.test.ts @@ -1,168 +1,643 @@ import { - Card, createEmptyCard, - date_scheduler, - default_maximum_interval, - default_request_retention, - fsrs, + date_diff, FSRS, - get_fuzz_range, + fsrs, + Grade, + Grades, + Rating, + RecordLogItem, + RescheduleOptions, + ReviewLog, State, + TypeConvert, } from '../src/fsrs' +import { Card, DateInput, FSRSHistory } from '../src/fsrs/models' -describe('FSRS reschedule', () => { - const DECAY: number = -0.5 - const FACTOR: number = 19 / 81 - const request_retentions = [default_request_retention, 0.95, 0.85, 0.8] +type reviewState = { + difficulty: number + due: Date + rating: Rating + review?: Date + stability: number + state: State + reps: number + lapses: number + elapsed_days: number + scheduled_days: number +} - type CardType = Card & { - cid: number - } +const MOCK_NOW = new Date(1723338000000 /**2024, 7, 11, 1, 0, 0 UTC**/) - function cardHandler(card: Card) { - ;(card as CardType)['cid'] = 1 - return card as CardType - } +// https://github.com/open-spaced-repetition/ts-fsrs/issues/112#issuecomment-2286238381 - const newCard = createEmptyCard(undefined, cardHandler) - const learningCard: CardType = { - cid: 1, - due: new Date(), - stability: 0.6, - difficulty: 5.87, - elapsed_days: 0, - scheduled_days: 0, - reps: 1, - lapses: 0, - state: State.Learning, - last_review: new Date('2024-03-08 05:00:00'), - } - const reviewCard: CardType = { - cid: 1, - due: new Date('2024-03-17 04:43:02'), - stability: 48.26139059062234, - difficulty: 5.67, - elapsed_days: 18, - scheduled_days: 51, - reps: 8, - lapses: 1, - state: State.Review, - last_review: new Date('2024-01-26 04:43:02'), - } - const relearningCard: CardType = { - cid: 1, - due: new Date('2024-02-15 08:43:05'), - stability: 0.27, - difficulty: 10, - elapsed_days: 2, - scheduled_days: 0, - reps: 42, - lapses: 8, - state: State.Relearning, - last_review: new Date('2024-02-15 08:38:05'), +function experiment( + scheduler: FSRS, + reviews: Array, + skipManual: boolean = true +) { + if (skipManual) { + reviews = reviews.filter((review) => review.rating !== Rating.Manual) } + const output = reviews.reduce( + (state: reviewState[], review: FSRSHistory, index: number) => { + const currentCard = state[index - 1] + ? { + due: state[index - 1].due, + stability: state[index - 1].stability, + difficulty: state[index - 1].difficulty, + elapsed_days: + state[index - 2]?.review && state[index - 1]?.review + ? date_diff( + state[index - 1].review!, + state[index - 2].review!, + 'days' + ) + : 0, + scheduled_days: + state[index - 1].review && state[index - 1].due + ? date_diff( + state[index - 1].review!, + state[index - 1].due, + 'days' + ) + : 0, + reps: state[index - 1].reps, + lapses: state[index - 1].lapses, + state: state[index - 1].state, + last_review: state[index - 1].review, + } + : createEmptyCard(MOCK_NOW) + + if (review.review) { + let card = currentCard + let log: ReviewLog + if (review.rating) { + const item = scheduler.next(currentCard, review.review, review.rating) + card = item.card + log = item.log + } else { + review.review = TypeConvert.time(review.review) + log = state[index - 1] + ? { + rating: Rating.Manual, + state: State.New, + due: state[index - 1].due, + stability: state[index - 1].stability, + difficulty: state[index - 1].difficulty, + elapsed_days: state[index - 1].elapsed_days, + last_elapsed_days: state[index - 1].elapsed_days, + scheduled_days: state[index - 1].scheduled_days, + review: review.review, + } + : { + rating: Rating.Manual, + state: State.New, + due: new Date(MOCK_NOW), + stability: 0, + difficulty: 0, + elapsed_days: 0, + last_elapsed_days: 0, + scheduled_days: 0, + review: review.review, + } + card = createEmptyCard(review.review) + } + + return [ + ...state, + { + difficulty: card.difficulty, + due: card.due, + rating: log.rating, + review: log.review, + stability: card.stability, + state: card.state, + reps: card.reps, + lapses: card.lapses, + elapsed_days: card.elapsed_days, + scheduled_days: card.scheduled_days, + } satisfies reviewState, + ] + } - function dateHandler(date: Date) { - return date.getTime() + return state + }, + [] + ) + + return output +} + +function testReschedule( + scheduler: FSRS, + tests: number[][], + options: Partial = {} +) { + for (const test of tests) { + const reviews = test.map((rating, index) => ({ + rating: rating, + review: new Date( + new Date(MOCK_NOW).valueOf() + 1000 * 60 * 60 * 24 * (index + 1) + ), + state: rating === Rating.Manual ? State.New : undefined, + })) + const { collections: control } = scheduler.reschedule( + createEmptyCard(), + reviews, + options + ) + const experimentResult = experiment( + scheduler, + reviews, + options.skipManual ?? true + ) + for (const [index, controlItem] of control.entries()) { + const experimentItem = experimentResult[index] + // console.log(controlItem, experimentItem, index, test) + expect(controlItem.card.difficulty).toEqual(experimentItem.difficulty) + expect(controlItem.card.due).toEqual(experimentItem.due) + expect(controlItem.card.stability).toEqual(experimentItem.stability) + expect(controlItem.card.state).toEqual(experimentItem.state) + expect(controlItem.card.last_review?.getTime()).toEqual( + experimentItem.review?.getTime() + ) + expect(controlItem.card.reps).toEqual(experimentItem.reps) + expect(controlItem.card.lapses).toEqual(experimentItem.lapses) + + expect(controlItem.card.elapsed_days).toEqual(experimentItem.elapsed_days) + expect(controlItem.card.scheduled_days).toEqual( + experimentItem.scheduled_days + ) + } } +} + +describe('FSRS reschedule', () => { + const scheduler = fsrs() - const cards = [newCard, learningCard, reviewCard, relearningCard] - it('reschedule', () => { - for (const requestRetention of request_retentions) { - const f: FSRS = fsrs({ request_retention: requestRetention }) - const intervalModifier = - (Math.pow(requestRetention, 1 / DECAY) - 1) / FACTOR - const reschedule_cards = f.reschedule(cards) - if (reschedule_cards.length > 0) { - // next_ivl !== scheduled_days - expect(reschedule_cards.length).toBeGreaterThanOrEqual(1) - expect(reschedule_cards[0].cid).toBeGreaterThanOrEqual(1) - - const { min_ivl, max_ivl } = get_fuzz_range( - Math.round(reviewCard.stability * intervalModifier), - reviewCard.elapsed_days, - default_maximum_interval - ) - expect(reschedule_cards[0].scheduled_days).toBeGreaterThanOrEqual( - min_ivl - ) - expect(reschedule_cards[0].scheduled_days).toBeLessThanOrEqual(max_ivl) - expect(reschedule_cards[0].due).toEqual( - date_scheduler( - reviewCard.last_review!, - reschedule_cards[0].scheduled_days, - true - ) - ) + it('basic grade', () => { + const tests: number[][] = [] + for (let i = 0; i < Grades.length; i++) { + for (let j = 0; j < Grades.length; j++) { + for (let k = 0; k < Grades.length; k++) { + for (let l = 0; l < Grades.length; l++) { + tests.push([Grades[i], Grades[j], Grades[k], Grades[l]]) + } + } } } + testReschedule(scheduler, tests, { + reviewsOrderBy: (a: FSRSHistory, b: FSRSHistory) => + date_diff(a.review, b.review, 'days'), + recordLogHandler: (recordLog) => recordLog, + }) }) - it('reschedule[dateHandler]', () => { - for (const requestRetention of request_retentions) { - const f: FSRS = fsrs({ request_retention: requestRetention }) - const intervalModifier = - (Math.pow(requestRetention, 1 / DECAY) - 1) / FACTOR - const [rescheduleCard] = f.reschedule([reviewCard], { - dateHandler, - }) - if (rescheduleCard) { - // next_ivl !== scheduled_days - expect(rescheduleCard.cid).toEqual(1) - const { min_ivl, max_ivl } = get_fuzz_range( - Math.round(reviewCard.stability * intervalModifier), - reviewCard.elapsed_days, - default_maximum_interval - ) - // reviewCard.stability * intervalModifier = 115.73208467290684 = ivl = 116 - // max_ivl=124 expected = 124 - - expect(rescheduleCard.scheduled_days).toBeGreaterThanOrEqual(min_ivl) - expect(rescheduleCard.scheduled_days).toBeLessThanOrEqual(max_ivl) - expect(rescheduleCard.due as unknown as number).toEqual( - date_scheduler( - reviewCard.last_review!, - rescheduleCard.scheduled_days, - true - ).getTime() - ) - expect(typeof rescheduleCard.due).toEqual('number') + it('case : include Manual rating -> set forget', () => { + const tests: number[][] = [] + const Ratings = [ + Rating.Manual, + Rating.Again, + Rating.Hard, + Rating.Good, + Rating.Easy, + ] + for (let i = 0; i < Ratings.length; i++) { + for (let j = 0; j < Ratings.length; j++) { + for (let k = 0; k < Ratings.length; k++) { + for (let l = 0; l < Ratings.length; l++) { + for (let m = 0; m < Ratings.length; m++) { + tests.push([ + Ratings[i], + Ratings[j], + Ratings[k], + Ratings[l], + Ratings[m], + ]) + } + } + } } } + console.debug('reschedule case size:', tests.length) + testReschedule(scheduler, tests, { + reviewsOrderBy: (a: FSRSHistory, b: FSRSHistory) => + date_diff(a.review, b.review, 'days'), + recordLogHandler: (recordLog) => recordLog, + skipManual: false, + }) + }) + + it('case : include Manual rating -> state have not been provided', () => { + const test = [Rating.Easy, Rating.Good, Rating.Manual, Rating.Good] + const reviews = test.map((rating, index) => ({ + rating: rating, + review: new Date( + new Date(MOCK_NOW).valueOf() + 1000 * 60 * 60 * 24 * (index + 1) + ), + })) + expect(() => { + scheduler.reschedule(createEmptyCard(), reviews, { skipManual: false }) + }).toThrow('reschedule: state is required for manual rating') + }) + + it('case : include Manual rating -> due have not been provided', () => { + const test = [Rating.Easy, Rating.Good, Rating.Manual, Rating.Good] + const reviews = test.map((rating, index) => ({ + rating: rating, + review: new Date( + new Date(MOCK_NOW).valueOf() + 1000 * 60 * 60 * 24 * (index + 1) + ), + state: rating === Rating.Manual ? State.Review : undefined, + })) + expect(() => { + scheduler.reschedule(createEmptyCard(), reviews, { skipManual: false }) + }).toThrow('reschedule: due is required for manual rating') + }) + + it('case : include Manual rating -> Manually configure the data', () => { + const test = [Rating.Easy, Rating.Good, Rating.Manual, Rating.Good] + const reviews = test.map( + (rating, index) => + ({ + rating: rating, + review: new Date( + new Date(MOCK_NOW).getTime() + 1000 * 60 * 60 * 24 * (index + 1) + ), + state: rating === Rating.Manual ? State.Review : undefined, + difficulty: 3.2828565, + stability: 21.79806877, + due: new Date(1725469200000 /**2024-09-04T17:00:00.000Z*/), + }) satisfies FSRSHistory + ) + const expected = { + card: { + due: new Date(1725469200000 /**2024-09-04T17:00:00.000Z*/), + stability: 21.79806877, + difficulty: 3.2828565, + elapsed_days: 1, + scheduled_days: 21, + reps: 3, + lapses: 0, + state: 2, + last_review: new Date(1723597200000 /**2024-08-14T01:00:00.000Z*/), + }, + log: { + rating: 0, + state: 2, + due: new Date(1723510800000 /**2024-08-13T01:00:00.000Z*/), + stability: 18.67917062, + difficulty: 3.2828565, + elapsed_days: 1, + last_elapsed_days: 1, + scheduled_days: 19, + review: new Date(1723597200000 /**2024-08-14T01:00:00.000Z*/), + }, + } + + const nextItemExpected = { + card: { + due: new Date(1725843600000 /**2024-09-09T01:00:00.000Z*/), + stability: 24.84609459, + difficulty: 3.2828565, + elapsed_days: 1, + scheduled_days: 25, + reps: 4, + lapses: 0, + state: State.Review, + last_review: new Date(1723683600000 /**2024-08-15T01:00:00.000Z*/), + }, + log: { + rating: Rating.Good, + state: State.Review, + due: new Date(1723597200000 /**2024-08-14T01:00:00.000Z*/), + stability: 21.79806877, + difficulty: 3.2828565, + elapsed_days: 1, + last_elapsed_days: 1, + scheduled_days: 21, + review: new Date(1723683600000 /**2024-08-15T01:00:00.000Z*/), + }, + } + + const { collections: control } = scheduler.reschedule( + createEmptyCard(), + reviews, + { + skipManual: false, + } + ) + expect(control[2]).toEqual(expected) + expect(control[3]).toEqual(nextItemExpected) }) - it('reschedule[next_ivl === scheduled_days]', () => { - const f: FSRS = fsrs() - const reschedule_cards = f.reschedule( - [ + it('case : include Manual rating -> Manually configure the data and ds have not been provided', () => { + const test = [Rating.Easy, Rating.Good, Rating.Manual, Rating.Good] + const reviews = test.map( + (rating, index) => + ({ + rating: rating, + review: new Date( + new Date(MOCK_NOW).getTime() + 1000 * 60 * 60 * 24 * (index + 1) + ), + state: rating === Rating.Manual ? State.Review : undefined, + due: new Date(1725469200000 /**'2024-09-04T17:00:00.000Z'*/), + }) satisfies FSRSHistory + ) + const expected = { + card: { + due: new Date(1725469200000 /**'2024-09-04T17:00:00.000Z'*/), + stability: 18.67917062, + difficulty: 3.2828565, + elapsed_days: 1, + scheduled_days: 21, + reps: 3, + lapses: 0, + state: State.Review, + last_review: new Date(1723597200000 /**'2024-08-14T01:00:00.000Z'*/), + }, + log: { + rating: Rating.Manual, + state: State.Review, + due: new Date(1723510800000 /**2024-08-13T01:00:00.000Z*/), + stability: 18.67917062, + difficulty: 3.2828565, + elapsed_days: 1, + last_elapsed_days: 1, + scheduled_days: 19, + review: new Date(1723597200000 /**'2024-08-14T01:00:00.000Z'*/), + }, + } + + const current_card = { + due: new Date(1725584400000 /**'2024-09-06T01:00:00.000Z'*/), + stability: 21.79806877, + difficulty: 3.2828565, + elapsed_days: 1, + scheduled_days: 22, + reps: 4, + lapses: 0, + state: State.Review, + last_review: new Date(1723683600000 /**'2024-08-15T01:00:00.000Z'*/), + } + + const { collections: control, reschedule_item } = scheduler.reschedule( + current_card, + reviews, + { + skipManual: false, + } + ) + expect(control[2]).toEqual(expected) + expect(reschedule_item).toBeNull() + }) + + it('case : get reschedule item', () => { + const test = [Rating.Easy, Rating.Good, Rating.Good, Rating.Good] + const reviews = test.map( + (rating, index) => + ({ + rating: rating, + review: new Date( + new Date(MOCK_NOW).getTime() + 1000 * 60 * 60 * 24 * (index + 1) + ), + state: rating === Rating.Manual ? State.Review : undefined, + due: new Date(1725469200000 /**'2024-09-04T17:00:00.000Z'*/), + }) satisfies FSRSHistory + ) + + const expected = { + card: { + due: new Date(1725843600000 /**'2024-09-09T01:00:00.000Z'*/), + stability: 24.84609459, + difficulty: 3.2828565, + elapsed_days: 1, + scheduled_days: 25, + reps: 4, + lapses: 0, + state: State.Review, + last_review: new Date(1723683600000 /**'2024-08-15T01:00:00.000Z'*/), + }, + log: { + rating: Rating.Good, + state: State.Review, + due: new Date(1723597200000 /**2024-08-14T01:00:00.000Z*/), + stability: 21.79806877, + difficulty: 3.2828565, + elapsed_days: 1, + last_elapsed_days: 1, + scheduled_days: 22, + review: new Date(1723683600000 /**'2024-08-15T01:00:00.000Z'*/), + }, + } + let cur_card = createEmptyCard(MOCK_NOW) + let index = 0 + const review_at = new Date(1723683600000 /**'2024-08-15T01:00:00.000Z'*/) + for (const _ of test) { + const { collections: control, reschedule_item } = scheduler.reschedule( + cur_card, + reviews, { - cid: 1, - due: new Date('2024-03-13 04:43:02'), - stability: 48.26139059062234, - difficulty: 5.67, - elapsed_days: 18, - scheduled_days: 48, - reps: 8, - lapses: 1, - state: State.Review, - last_review: new Date('2024-01-26 04:43:02'), + skipManual: false, + update_memory_state: true, + now: review_at, + } + ) + const scheduled_days = reschedule_item!.card.due.diff( + cur_card.due, + 'days' + ) + expect(control[control.length - 1]).toEqual(expected) + expect(reschedule_item).toEqual({ + card: { + ...expected.card, + last_review: review_at, + reps: cur_card.reps + 1, + }, + log: { + ...expected.log, + rating: Rating.Manual, + state: cur_card.state, + due: cur_card.last_review || cur_card.due, + last_elapsed_days: cur_card.elapsed_days, + scheduled_days: scheduled_days, + stability: cur_card.stability, + difficulty: cur_card.difficulty, + review: review_at, }, - ], - { enable_fuzz: false } + } satisfies RecordLogItem) + cur_card = control[index++].card + // index++ + } + }) + + it('Handling the case of an empty set.', () => { + const control = scheduler.reschedule(createEmptyCard(), []) + expect(control).toEqual({ + collections: [], + reschedule_item: null, + }) + + const control2 = scheduler.reschedule(createEmptyCard()) + expect(control2).toEqual({ + collections: [], + reschedule_item: null, + }) + }) + + it('case : basic test', () => { + const f = fsrs() + const grades: Grade[] = [Rating.Good, Rating.Good, Rating.Good, Rating.Good] + const reviews_at = [ + new Date(2024, 8, 13), + new Date(2024, 8, 13), + new Date(2024, 8, 17), + new Date(2024, 8, 28), + ] + + const reviews: FSRSHistory[] = [] + for (let i = 0; i < grades.length; i++) { + reviews.push({ + rating: grades[i], + review: reviews_at[i], + }) + } + + const results_short = f.reschedule(createEmptyCard(), reviews, { + skipManual: false, + }) + const ivl_history_short = results_short.collections.map( + (item) => item.card.scheduled_days + ) + const s_history_short = results_short.collections.map( + (item) => item.card.stability ) - expect(reschedule_cards.length).toEqual(0) + const d_history_short = results_short.collections.map( + (item) => item.card.difficulty + ) + + expect(results_short.reschedule_item).not.toBeNull() + expect(results_short.collections.length).toEqual(4) + expect(ivl_history_short).toEqual([0, 4, 15, 40]) + expect(s_history_short).toEqual([ + 3.1262, 4.35097949, 14.94870008, 39.68105285, + ]) + expect(d_history_short).toEqual([ + 5.31457783, 5.26703555, 5.22060576, 5.17526243, + ]) + + // switch long-term scheduler + f.parameters.enable_short_term = false + const results = f.reschedule(createEmptyCard(), reviews, { + skipManual: false, + }) + const ivl_history_long = results.collections.map( + (item) => item.card.scheduled_days + ) + const s_history_long = results.collections.map( + (item) => item.card.stability + ) + const d_history_long = results.collections.map( + (item) => item.card.difficulty + ) + expect(results.reschedule_item).not.toBeNull() + expect(results.collections.length).toEqual(4) + expect(ivl_history_long).toEqual([3, 4, 14, 39]) + expect(s_history_long).toEqual([3.1262, 3.1262, 13.89723677, 38.7694699]) + expect(d_history_long).toEqual([ + 5.31457783, 5.26703555, 5.22060576, 5.17526243, + ]) }) - it('reschedule by empty array', () => { - const f: FSRS = fsrs() - const reschedule_cards = f.reschedule([]) - expect(reschedule_cards.length).toEqual(0) + it('case : current card = reschedule card', () => { + const grades: Grade[] = [Rating.Good, Rating.Good, Rating.Good, Rating.Good] + const reviews_at: number[] = [ + Date.UTC(2024, 8, 13, 0, 0, 0), + Date.UTC(2024, 8, 13, 0, 0, 0), + Date.UTC(2024, 8, 17, 0, 0, 0), + Date.UTC(2024, 8, 28, 0, 0, 0), + ] + + const reviews: FSRSHistory[] = [] + for (let i = 0; i < grades.length; i++) { + reviews.push({ + rating: grades[i], + review: reviews_at[i], + }) + } + const current_card = { + due: new Date(1730937600000 /** 2024-11-07T00:00:00.000Z */), + stability: 39.68105285, + difficulty: 5.17526243, + elapsed_days: 11, + scheduled_days: 40, + reps: 4, + lapses: 0, + state: State.Review, + last_review: Date.UTC(2024, 9, 27, 0, 0, 0), + } + + const results_short = scheduler.reschedule(current_card, reviews, { + recordLogHandler: (recordLog) => { + return recordLog + }, + skipManual: false, + first_card: createEmptyCard(Date.UTC(2024, 8, 13, 0, 0, 0)), + update_memory_state: true, + now: Date.UTC(2024, 9, 27, 0, 0, 0), + }) + expect(results_short.reschedule_item).toBeNull() }) - it('reschedule by not array', () => { - const f: FSRS = fsrs() - expect(() => { - f.reschedule(createEmptyCard() as unknown as Card[]) - }).toThrow('cards must be an array') + it('case : forget', () => { + const grades: Grade[] = [Rating.Good, Rating.Good, Rating.Good, Rating.Good] + const reviews_at: number[] = [ + Date.UTC(2024, 8, 13, 0, 0, 0), + Date.UTC(2024, 8, 13, 0, 0, 0), + Date.UTC(2024, 8, 17, 0, 0, 0), + Date.UTC(2024, 8, 28, 0, 0, 0), + ] + + const reviews: FSRSHistory[] = [] + for (let i = 0; i < grades.length; i++) { + reviews.push({ + rating: grades[i], + review: reviews_at[i], + }) + } + const first_card = createEmptyCard(Date.UTC(2024, 8, 28, 0, 0, 0)) + let current_card: Card = createEmptyCard() + const history_card: Card[] = [] + for (const review of reviews) { + const item = scheduler.next( + current_card, + review.review, + review.rating + ) + current_card = item.card + history_card.push(current_card) + } + const { card: forget_card } = scheduler.forget( + current_card, + Date.UTC(2024, 9, 27, 0, 0, 0) + ) + current_card = forget_card + + const { reschedule_item } = scheduler.reschedule(current_card!, reviews, { + first_card: first_card, + update_memory_state: true, + now: Date.UTC(2024, 9, 27, 0, 0, 0), + }) + expect(reschedule_item).not.toBeNull() + expect(reschedule_item!.card.due).toEqual( + history_card[history_card.length - 1].due + ) + expect(reschedule_item!.card.stability).toEqual( + history_card[history_card.length - 1].stability + ) + expect(reschedule_item!.card.difficulty).toEqual( + history_card[history_card.length - 1].difficulty + ) }) }) diff --git a/package.json b/package.json index abff1d1..1d4ecf0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts-fsrs", - "version": "4.3.1", + "version": "4.4.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 a07c612..bb38b7d 100644 --- a/src/fsrs/algorithm.ts +++ b/src/fsrs/algorithm.ts @@ -134,8 +134,8 @@ export class FSRSAlgorithm { * @param {number} enable_fuzz - This adds a small random delay to the new interval time to prevent cards from sticking together and always being reviewed on the same day. * @return {number} - The fuzzed interval. **/ - apply_fuzz(ivl: number, elapsed_days: number, enable_fuzz?: boolean): int { - if (!enable_fuzz || ivl < 2.5) return Math.round(ivl) as int + apply_fuzz(ivl: number, elapsed_days: number): int { + if (!this.param.enable_fuzz || ivl < 2.5) return Math.round(ivl) as int const generator = alea(this._seed) // I do not want others to directly access the seed externally. const fuzz_factor = generator() const { min_ivl, max_ivl } = get_fuzz_range( @@ -150,18 +150,13 @@ export class FSRSAlgorithm { * @see The formula used is : {@link FSRSAlgorithm.calculate_interval_modifier} * @param {number} s - Stability (interval when R=90%) * @param {number} elapsed_days t days since the last review - * @param {number} enable_fuzz - This adds a small random delay to the new interval time to prevent cards from sticking together and always being reviewed on the same day. */ - next_interval( - s: number, - elapsed_days: number, - enable_fuzz: boolean = this.param.enable_fuzz - ): int { + next_interval(s: number, elapsed_days: number): int { const newInterval = Math.min( Math.max(1, Math.round(s * this.intervalModifier)), this.param.maximum_interval ) as int - return this.apply_fuzz(newInterval, elapsed_days, enable_fuzz) + return this.apply_fuzz(newInterval, elapsed_days) } /** diff --git a/src/fsrs/default.ts b/src/fsrs/default.ts index f727a9e..936a22b 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.3.1 using FSRS V5.0' +export const FSRSVersion: string = 'v4.4.0 using FSRS V5.0' export const generatorParameters = ( props?: Partial diff --git a/src/fsrs/fsrs.ts b/src/fsrs/fsrs.ts index 00c5594..06387c7 100644 --- a/src/fsrs/fsrs.ts +++ b/src/fsrs/fsrs.ts @@ -1,23 +1,23 @@ -import { date_scheduler } from './help' import { Card, CardInput, DateInput, + FSRSHistory, FSRSParameters, Grade, Rating, - RecordLog, RecordLogItem, - RescheduleOptions, ReviewLog, ReviewLogInput, State, } from './models' -import type { int, IPreview } from './types' +import type { IPreview, IReschedule, RescheduleOptions } from './types' import { FSRSAlgorithm } from './algorithm' import { TypeConvert } from './convert' import BasicScheduler from './impl/basic_scheduler' import LongTermScheduler from './impl/long_term_scheduler' +import { createEmptyCard } from './default' +import { Reschedule } from './reschedule' export class FSRS extends FSRSAlgorithm { private Scheduler @@ -206,11 +206,17 @@ export class FSRS extends FSRSAlgorithm { card: CardInput | Card, now?: DateInput, format: T = true as T - ): (T extends true ? string : number) { + ): T extends true ? string : number { const processedCard = TypeConvert.card(card) now = now ? TypeConvert.time(now) : new Date() - const t = processedCard.state !== State.New ? Math.max(now.diff(processedCard.last_review as Date, 'days'), 0) : 0 - const r = processedCard.state !== State.New ? this.forgetting_curve(t, +processedCard.stability.toFixed(8)) : 0 + const t = + processedCard.state !== State.New + ? Math.max(now.diff(processedCard.last_review as Date, 'days'), 0) + : 0 + const r = + processedCard.state !== State.New + ? this.forgetting_curve(t, +processedCard.stability.toFixed(8)) + : 0 return (format ? `${(r * 100).toFixed(2)}%` : r) as T extends true ? string : number @@ -386,61 +392,86 @@ export class FSRS extends FSRSAlgorithm { } /** + * Reschedules the current card and returns the rescheduled collections and reschedule item. * - * @param cards scheduled card collection - * @param options Reschedule options,fuzz is enabled by default.If the type of due is not Date, please implement dataHandler. + * @template T - The type of the record log item. + * @param {CardInput | Card} current_card - The current card to be rescheduled. + * @param {Array} reviews - The array of FSRSHistory objects representing the reviews. + * @param {Partial>} options - The optional reschedule options. + * @returns {IReschedule} - The rescheduled collections and reschedule item. + * * @example - * ```typescript - * type CardType = Card & { - * cid: number; - * }; - * const reviewCard: CardType = { - * cid: 1, - * due: new Date("2024-03-17 04:43:02"), - * stability: 48.26139059062234, - * difficulty: 5.67, - * elapsed_days: 18, - * scheduled_days: 51, - * reps: 8, - * lapses: 1, - * state: State.Review, - * last_review: new Date("2024-01-26 04:43:02"), - * }; - * const f = fsrs(); - * const reschedule_cards = f.reschedule([reviewCard]); * ``` - * + const f = fsrs() + const grades: Grade[] = [Rating.Good, Rating.Good, Rating.Good, Rating.Good] + const reviews_at = [ + new Date(2024, 8, 13), + new Date(2024, 8, 13), + new Date(2024, 8, 17), + new Date(2024, 8, 28), + ] + + const reviews: FSRSHistory[] = [] + for (let i = 0; i < grades.length; i++) { + reviews.push({ + rating: grades[i], + review: reviews_at[i], + }) + } + + const results_short = scheduler.reschedule( + createEmptyCard(), + reviews, + { + skipManual: false, + } + ) + console.log(results_short) + * ``` */ - reschedule( - cards: Array, - options: RescheduleOptions = {} - ): Array { - if (!Array.isArray(cards)) { - throw new Error('cards must be an array') + reschedule( + current_card: CardInput | Card, + reviews: FSRSHistory[] = [], + options: Partial> = {} + ): IReschedule { + const { + recordLogHandler, + reviewsOrderBy, + skipManual: skipManual = true, + now: now = new Date(), + update_memory_state: updateMemoryState = false, + } = options + if (reviewsOrderBy && typeof reviewsOrderBy === 'function') { + reviews.sort(reviewsOrderBy) + } + if (skipManual) { + reviews = reviews.filter((review) => review.rating !== Rating.Manual) } - const processedCard: T[] = [] - for (const card of cards) { - if (TypeConvert.state(card.state) !== State.Review || !card.last_review) - continue - const scheduled_days = Math.floor(card.scheduled_days) as int - const next_ivl = this.next_interval( - +card.stability.toFixed(2), - Math.round(card.elapsed_days), - options.enable_fuzz ?? true - ) - if (next_ivl === scheduled_days || next_ivl === 0) continue + const rescheduleSvc = new Reschedule(this) - const processCard: T = { ...card } - processCard.scheduled_days = next_ivl - const new_due = date_scheduler(processCard.last_review!, next_ivl, true) - if (options.dateHandler && typeof options.dateHandler === 'function') { - processCard.due = options.dateHandler(new_due) - } else { - processCard.due = new_due + const collections = rescheduleSvc.reschedule( + options.first_card || createEmptyCard(), + reviews + ) + const len = collections.length + const cur_card = TypeConvert.card(current_card) + const manual_item = rescheduleSvc.calculateManualRecord( + cur_card, + now, + len ? collections[len - 1] : undefined, + updateMemoryState + ) + + if (recordLogHandler && typeof recordLogHandler === 'function') { + return { + collections: collections.map(recordLogHandler), + reschedule_item: manual_item ? recordLogHandler(manual_item) : null, } - processedCard.push(processCard) } - return processedCard + return { + collections, + reschedule_item: manual_item, + } as IReschedule } } diff --git a/src/fsrs/impl/basic_scheduler.ts b/src/fsrs/impl/basic_scheduler.ts index 5d3447e..e3583c6 100644 --- a/src/fsrs/impl/basic_scheduler.ts +++ b/src/fsrs/impl/basic_scheduler.ts @@ -38,8 +38,7 @@ export default class BasicScheduler extends AbstractScheduler { case Rating.Easy: { const easy_interval = this.algorithm.next_interval( next.stability, - this.current.elapsed_days, - this.algorithm.parameters.enable_fuzz + this.current.elapsed_days ) next.scheduled_days = easy_interval next.due = this.review_time.scheduler(easy_interval as int, true) diff --git a/src/fsrs/models.ts b/src/fsrs/models.ts index a453774..9ab21c8 100644 --- a/src/fsrs/models.ts +++ b/src/fsrs/models.ts @@ -77,7 +77,32 @@ export interface FSRSParameters { enable_short_term: boolean } -export type RescheduleOptions = { - enable_fuzz?: boolean - dateHandler?: (date: Date) => DateInput +export interface FSRSReview { + /** + * 0-4: Manual, Again, Hard, Good, Easy + * = revlog.rating + */ + rating: Rating + /** + * The number of days that passed + * = revlog.elapsed_days + * = round(revlog[-1].review - revlog[-2].review) + */ + delta_t: number } + +export type FSRSHistory = Partial< + Omit +> & + ( + | { + rating: Grade + review: DateInput | Date + } + | { + rating: Rating.Manual + due: DateInput | Date + state: State + review: DateInput | Date + } + ) diff --git a/src/fsrs/reschedule.ts b/src/fsrs/reschedule.ts new file mode 100644 index 0000000..e1c0a7e --- /dev/null +++ b/src/fsrs/reschedule.ts @@ -0,0 +1,179 @@ +import { TypeConvert } from './convert' +import { createEmptyCard } from './default' +import type { FSRS } from './fsrs' +import { + type Card, + type CardInput, + DateInput, + type FSRSHistory, + type Grade, + Rating, + type RecordLogItem, + type ReviewLog, + State, +} from './models' + +/** + * The `Reschedule` class provides methods to handle the rescheduling of cards based on their review history. + * determine the next review dates and update the card's state accordingly. + */ +export class Reschedule { + private fsrs: FSRS + /** + * Creates an instance of the `Reschedule` class. + * @param fsrs - An instance of the FSRS class used for scheduling. + */ + constructor(fsrs: FSRS) { + this.fsrs = fsrs + } + + /** + * Replays a review for a card and determines the next review date based on the given rating. + * @param card - The card being reviewed. + * @param reviewed - The date the card was reviewed. + * @param rating - The grade given to the card during the review. + * @returns A `RecordLogItem` containing the updated card and review log. + */ + replay(card: Card, reviewed: Date, rating: Grade): RecordLogItem { + return this.fsrs.next(card, reviewed, rating) + } + + /** + * Processes a manual review for a card, allowing for custom state, stability, difficulty, and due date. + * @param card - The card being reviewed. + * @param state - The state of the card after the review. + * @param reviewed - The date the card was reviewed. + * @param elapsed_days - The number of days since the last review. + * @param stability - (Optional) The stability of the card. + * @param difficulty - (Optional) The difficulty of the card. + * @param due - (Optional) The due date for the next review. + * @returns A `RecordLogItem` containing the updated card and review log. + * @throws Will throw an error if the state or due date is not provided when required. + */ + handleManualRating( + card: Card, + state: State, + reviewed: Date, + elapsed_days: number, + stability?: number, + difficulty?: number, + due?: Date + ): RecordLogItem { + if (typeof state === 'undefined') { + throw new Error('reschedule: state is required for manual rating') + } + let log: ReviewLog + let next_card: Card + if (state === State.New) { + log = { + rating: Rating.Manual, + state: state, + due: due ?? reviewed, + stability: card.stability, + difficulty: card.difficulty, + elapsed_days: elapsed_days, + last_elapsed_days: card.elapsed_days, + scheduled_days: card.scheduled_days, + review: reviewed, + } satisfies ReviewLog + next_card = createEmptyCard(reviewed) + next_card.last_review = reviewed + } else { + if (typeof due === 'undefined') { + throw new Error('reschedule: due is required for manual rating') + } + const scheduled_days = due.diff(reviewed as Date, 'days') + log = { + rating: Rating.Manual, + state: card.state, + due: card.last_review || card.due, + stability: card.stability, + difficulty: card.difficulty, + elapsed_days: elapsed_days, + last_elapsed_days: card.elapsed_days, + scheduled_days: card.scheduled_days, + review: reviewed, + } satisfies ReviewLog + next_card = { + ...card, + state: state, + due: due, + last_review: reviewed, + stability: stability || card.stability, + difficulty: difficulty || card.difficulty, + elapsed_days: elapsed_days, + scheduled_days: scheduled_days, + reps: card.reps + 1, + } satisfies Card + } + + return { card: next_card, log } + } + + /** + * Reschedules a card based on its review history. + * + * @param current_card - The card to be rescheduled. + * @param reviews - An array of review history objects. + * @returns An array of record log items representing the rescheduling process. + */ + reschedule(current_card: CardInput, reviews: FSRSHistory[]) { + const collections: RecordLogItem[] = [] + let cur_card = createEmptyCard(current_card.due) + for (const review of reviews) { + let item: RecordLogItem + review.review = TypeConvert.time(review.review) + if (review.rating === Rating.Manual) { + // ref: abstract_scheduler.ts#init + let interval = 0 + if (cur_card.state !== State.New && cur_card.last_review) { + interval = review.review.diff(cur_card.last_review as Date, 'days') + } + item = this.handleManualRating( + cur_card, + review.state, + review.review, + interval, + review.stability, + review.difficulty, + review.due ? TypeConvert.time(review.due) : undefined + ) + } else { + item = this.replay(cur_card, review.review, review.rating) + } + collections.push(item) + cur_card = item.card + } + return collections + } + + calculateManualRecord( + current_card: CardInput, + now: DateInput, + record_log_item?: RecordLogItem, + update_memory?: boolean + ): RecordLogItem | null { + if (!record_log_item) { + return null + } + // if first_card === recordItem.card then return null + const { card: reschedule_card, log } = record_log_item + const cur_card = TypeConvert.card(current_card) // copy card + if (cur_card.due.getTime() === reschedule_card.due.getTime()) { + return null + } + cur_card.scheduled_days = reschedule_card.due.diff( + cur_card.due as Date, + 'days' + ) + return this.handleManualRating( + cur_card, + reschedule_card.state, + TypeConvert.time(now), + log.elapsed_days, + update_memory ? reschedule_card.stability : undefined, + update_memory ? reschedule_card.difficulty : undefined, + reschedule_card.due + ) + } +} diff --git a/src/fsrs/types.ts b/src/fsrs/types.ts index 8f3ab17..0d5dbaa 100644 --- a/src/fsrs/types.ts +++ b/src/fsrs/types.ts @@ -1,7 +1,12 @@ import type { + Card, + CardInput, + DateInput, + FSRSHistory, Grade, RecordLog, RecordLogItem, + ReviewLog, } from './models' export type unit = 'days' | 'minutes' @@ -16,3 +21,52 @@ export interface IScheduler { preview(): IPreview review(state: Grade): RecordLogItem } + +/** + * Options for rescheduling. + * + * @template T - The type of the result returned by the `recordLogHandler` function. + */ +export type RescheduleOptions = { + /** + * A function that handles recording the log. + * + * @param recordLog - The log to be recorded. + * @returns The result of recording the log. + */ + recordLogHandler: (recordLog: RecordLogItem) => T + + /** + * A function that defines the order of reviews. + * + * @param a - The first FSRSHistory object. + * @param b - The second FSRSHistory object. + * @returns A negative number if `a` should be ordered before `b`, a positive number if `a` should be ordered after `b`, or 0 if they have the same order. + */ + reviewsOrderBy: (a: FSRSHistory, b: FSRSHistory) => number + + /** + * Indicating whether to skip manual steps. + */ + skipManual: boolean + + /** + * Indicating whether to update the FSRS memory state. + */ + update_memory_state: boolean + + /** + * The current date and time. + */ + now: DateInput + + /** + * The input for the first card. + */ + first_card?: CardInput +} + +export type IReschedule = { + collections: T[] + reschedule_item: T | null +}