From 2845647ab988e288ed7ae010014f0ac7ebc0f5af Mon Sep 17 00:00:00 2001 From: ishiko Date: Sat, 30 Nov 2024 14:13:53 +0800 Subject: [PATCH] Feat/Impl `initSeed`& `Scheduler` extension based on the strategy pattern (#137) * Refactor/implement seed strategy and scheduler enhancements * export Scheduler typo * rename strategy->strategies * update card_id_field type to string|number * rename GenCardIdSeedStrategy->GenSeedStrategyWithCardId * add test * update typo * fix instace-> instance --- __tests__/strategies/seed.test.ts | 185 ++++++++++++++++++++++++++++++ src/fsrs/abstract_scheduler.ts | 20 ++-- src/fsrs/fsrs.ts | 63 ++++++++-- src/fsrs/index.ts | 5 + src/fsrs/strategies/index.ts | 2 + src/fsrs/strategies/seed.ts | 46 ++++++++ src/fsrs/strategies/types.ts | 25 ++++ src/fsrs/types.ts | 2 - 8 files changed, 329 insertions(+), 19 deletions(-) create mode 100644 __tests__/strategies/seed.test.ts create mode 100644 src/fsrs/strategies/index.ts create mode 100644 src/fsrs/strategies/seed.ts create mode 100644 src/fsrs/strategies/types.ts diff --git a/__tests__/strategies/seed.test.ts b/__tests__/strategies/seed.test.ts new file mode 100644 index 0000000..6964772 --- /dev/null +++ b/__tests__/strategies/seed.test.ts @@ -0,0 +1,185 @@ +import { + AbstractScheduler, + Card, + createEmptyCard, + DefaultInitSeedStrategy, + fsrs, + GenSeedStrategyWithCardId, + Rating, + StrategyMode, +} from '../../src/fsrs' + +interface ICard extends Card { + card_id: number +} + +describe('seed strategy', () => { + it('default seed strategy', () => { + const seedStrategy = DefaultInitSeedStrategy + const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) + const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) + + const card = createEmptyCard(now, (card: Card) => { + Object.assign(card, { card_id: 555 }) + return card as ICard + }) + + const record = f.repeat(card, now) + const scheduler = new f['Scheduler'](card, now, f, { + seed: seedStrategy, + }) as AbstractScheduler + + const seed = seedStrategy.bind(scheduler)() + console.debug('seed', seed) + + expect(f['_seed']).toBe(seed) + }) +}) + +describe('seed strategy with card ID', () => { + it('use seedStrategy', () => { + const seedStrategy = GenSeedStrategyWithCardId('card_id') + const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) + + expect(f['strategyHandler'].get(StrategyMode.SEED)).toBe(seedStrategy) + + f.clearStrategy() + expect(f['strategyHandler'].get(StrategyMode.SEED)).toBeUndefined() + }) + it('clear seedStrategy', () => { + const seedStrategy = GenSeedStrategyWithCardId('card_id') + const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) + const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) + + const card = createEmptyCard(now, (card: Card) => { + Object.assign(card, { card_id: 555 }) + return card as ICard + }) + + f.repeat(card, now) + let scheduler = new f['Scheduler'](card, now, f, { + seed: seedStrategy, + }) as AbstractScheduler + + const seed_with_card_id = seedStrategy.bind(scheduler)() + console.debug('seed with card_id=555', seed_with_card_id) + + f.clearStrategy(StrategyMode.SEED) + + f.repeat(card, now) + scheduler = new f['Scheduler'](card, now, f, { + seed: DefaultInitSeedStrategy, + }) as AbstractScheduler + const basic_seed = DefaultInitSeedStrategy.bind(scheduler)() + console.debug('basic_seed with card_id=555', basic_seed) + + expect(f['_seed']).toBe(basic_seed) + + expect(seed_with_card_id).not.toBe(basic_seed) + }) + + it('exist card_id', () => { + const seedStrategy = GenSeedStrategyWithCardId('card_id') + const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) + const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) + + const card = createEmptyCard(now, (card: Card) => { + Object.assign(card, { card_id: 555 }) + return card as ICard + }) + + const record = f.repeat(card, now) + const scheduler = new f['Scheduler'](card, now, f, { + seed: seedStrategy, + }) as AbstractScheduler + + const seed = seedStrategy.bind(scheduler)() + console.debug('seed with card_id=555', seed) + + expect(f['_seed']).toBe(seed) + }) + + it('not exist card_id', () => { + const seedStrategy = GenSeedStrategyWithCardId('card_id') + const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) + const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) + + const card = createEmptyCard(now) + + const record = f.repeat(card, now) + const scheduler = new f['Scheduler'](card, now, f, { + seed: seedStrategy, + }) as AbstractScheduler + + const seed = seedStrategy.bind(scheduler)() + console.debug('seed with card_id=undefined(default)', seed) + + expect(f['_seed']).toBe(seed) + }) + + it('card_id = -1', () => { + const seedStrategy = GenSeedStrategyWithCardId('card_id') + const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) + const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) + + const card = createEmptyCard(now, (card: Card) => { + Object.assign(card, { card_id: -1 }) + return card as ICard + }) + + const record = f.repeat(card, now) + const scheduler = new f['Scheduler'](card, now, f, { + seed: seedStrategy, + }) as AbstractScheduler + + const seed = seedStrategy.bind(scheduler)() + console.debug('with card_id=-1', seed) + + expect(f['_seed']).toBe(seed) + expect(f['_seed']).toBe('0') + }) + + it('card_id is undefined', () => { + const seedStrategy = GenSeedStrategyWithCardId('card_id') + const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) + const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) + + const card = createEmptyCard(now, (card: Card) => { + Object.assign(card, { card_id: undefined }) + return card as ICard + }) + + const item = f.next(card, now, Rating.Good) + const scheduler = new f['Scheduler'](card, now, f, { + seed: seedStrategy, + }) as AbstractScheduler + + const seed = seedStrategy.bind(scheduler)() + console.debug('seed with card_id=undefined', seed) + + expect(f['_seed']).toBe(seed) + expect(f['_seed']).toBe(`${item.card.reps}`) + }) + + it('card_id is null', () => { + const seedStrategy = GenSeedStrategyWithCardId('card_id') + const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) + const now = Date.UTC(2022, 11, 29, 12, 30, 0, 0) + + const card = createEmptyCard(now, (card: Card) => { + Object.assign(card, { card_id: null }) + return card as ICard + }) + + const item = f.next(card, now, Rating.Good) + const scheduler = new f['Scheduler'](card, now, f, { + seed: seedStrategy, + }) as AbstractScheduler + + const seed = seedStrategy.bind(scheduler)() + console.debug('seed with card_id=null', seed) + + expect(f['_seed']).toBe(seed) + expect(f['_seed']).toBe(`${item.card.reps}`) + }) +}) diff --git a/src/fsrs/abstract_scheduler.ts b/src/fsrs/abstract_scheduler.ts index 0f87068..ffbc185 100644 --- a/src/fsrs/abstract_scheduler.ts +++ b/src/fsrs/abstract_scheduler.ts @@ -11,6 +11,8 @@ import { type CardInput, type DateInput, } from './models' +import { DefaultInitSeedStrategy } from './strategies' +import type { TSeedStrategy } from './strategies/types' import type { IPreview, IScheduler } from './types' export abstract class AbstractScheduler implements IScheduler { @@ -19,13 +21,20 @@ export abstract class AbstractScheduler implements IScheduler { protected review_time: Date protected next: Map = new Map() protected algorithm: FSRSAlgorithm + private initSeedStrategy: TSeedStrategy constructor( card: CardInput | Card, now: DateInput, - algorithm: FSRSAlgorithm + algorithm: FSRSAlgorithm, + strategies: { + seed: TSeedStrategy + } = { + seed: DefaultInitSeedStrategy, + } ) { this.algorithm = algorithm + this.initSeedStrategy = strategies.seed.bind(this) this.last = TypeConvert.card(card) this.current = TypeConvert.card(card) @@ -42,7 +51,7 @@ export abstract class AbstractScheduler implements IScheduler { this.current.last_review = this.review_time this.current.elapsed_days = interval this.current.reps += 1 - this.initSeed() + this.algorithm.seed = this.initSeedStrategy() } public preview(): IPreview { @@ -88,13 +97,6 @@ export abstract class AbstractScheduler implements IScheduler { protected abstract reviewState(grade: Grade): RecordLogItem - private initSeed() { - const time = this.review_time.getTime() - const reps = this.current.reps - const mul = this.current.difficulty * this.current.stability - this.algorithm.seed = `${time}_${reps}_${mul}` - } - protected buildLog(rating: Grade): ReviewLog { const { last_review, due, elapsed_days } = this.last diff --git a/src/fsrs/fsrs.ts b/src/fsrs/fsrs.ts index 06387c7..6edc07a 100644 --- a/src/fsrs/fsrs.ts +++ b/src/fsrs/fsrs.ts @@ -11,16 +11,29 @@ import { ReviewLogInput, State, } from './models' -import type { IPreview, IReschedule, RescheduleOptions } from './types' +import { + type IPreview, + type IReschedule, + type RescheduleOptions, + type IScheduler, +} 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' +import { DefaultInitSeedStrategy } from './strategies/seed' +import { + StrategyMode, + type TSeedStrategy, + type TSchedulerStrategy, + type TStrategyHandler, +} from './strategies/types' export class FSRS extends FSRSAlgorithm { - private Scheduler + private strategyHandler = new Map() + private Scheduler: TSchedulerStrategy constructor(param: Partial) { super(param) const { enable_short_term } = this.parameters @@ -48,6 +61,42 @@ export class FSRS extends FSRSAlgorithm { } } + useStrategy( + mode: T, + handler: TStrategyHandler + ): this { + this.strategyHandler.set(mode, handler) + return this + } + + clearStrategy(mode?: StrategyMode): this { + if (mode) { + this.strategyHandler.delete(mode) + } else { + this.strategyHandler.clear() + } + return this + } + + private getScheduler(card: CardInput | Card, now: DateInput): IScheduler { + const seedStrategy = this.strategyHandler.get(StrategyMode.SEED) as + | TSeedStrategy + | undefined + + // Strategy scheduler + const schedulerStrategy = this.strategyHandler.get( + StrategyMode.SCHEDULER + ) as TSchedulerStrategy | undefined + + const Scheduler = schedulerStrategy || this.Scheduler + const Seed = seedStrategy || DefaultInitSeedStrategy + const instance = new Scheduler(card, now, this, { + seed: Seed, + }) + + return instance + } + /** * Display the collection of cards and logs for the four scenarios after scheduling the card at the current time. * @param card Card to be processed @@ -111,9 +160,8 @@ export class FSRS extends FSRSAlgorithm { now: DateInput, afterHandler?: (recordLog: IPreview) => R ): R { - const Scheduler = this.Scheduler - const instace = new Scheduler(card, now, this satisfies FSRSAlgorithm) - const recordLog = instace.preview() + const instance = this.getScheduler(card, now) + const recordLog = instance.preview() if (afterHandler && typeof afterHandler === 'function') { return afterHandler(recordLog) } else { @@ -181,13 +229,12 @@ export class FSRS extends FSRSAlgorithm { grade: Grade, afterHandler?: (recordLog: RecordLogItem) => R ): R { - const Scheduler = this.Scheduler - const instace = new Scheduler(card, now, this satisfies FSRSAlgorithm) + const instance = this.getScheduler(card, now) const g = TypeConvert.rating(grade) if (g === Rating.Manual) { throw new Error('Cannot review a manual rating') } - const recordLogItem = instace.review(g) + const recordLogItem = instance.review(g) if (afterHandler && typeof afterHandler === 'function') { return afterHandler(recordLogItem) } else { diff --git a/src/fsrs/index.ts b/src/fsrs/index.ts index 62ae0a2..b9c10c9 100644 --- a/src/fsrs/index.ts +++ b/src/fsrs/index.ts @@ -22,3 +22,8 @@ export type { export { State, Rating } from './models' export * from './convert' + +export * from './strategies' +export * from './abstract_scheduler' +export * from './impl/basic_scheduler' +export * from './impl/long_term_scheduler' diff --git a/src/fsrs/strategies/index.ts b/src/fsrs/strategies/index.ts new file mode 100644 index 0000000..a14054b --- /dev/null +++ b/src/fsrs/strategies/index.ts @@ -0,0 +1,2 @@ +export * from './seed' +export * from './types' diff --git a/src/fsrs/strategies/seed.ts b/src/fsrs/strategies/seed.ts new file mode 100644 index 0000000..03b631a --- /dev/null +++ b/src/fsrs/strategies/seed.ts @@ -0,0 +1,46 @@ +import type { AbstractScheduler } from '../abstract_scheduler' +import type { TSeedStrategy } from './types' + +export function DefaultInitSeedStrategy(this: AbstractScheduler): string { + const time = this.review_time.getTime() + const reps = this.current.reps + const mul = this.current.difficulty * this.current.stability + return `${time}_${reps}_${mul}` +} + +/** + * Generates a seed strategy function for card IDs. + * + * @param card_id_field - The field name of the card ID in the current object. + * @returns A function that generates a seed based on the card ID and repetitions. + * + * @remarks + * The returned function uses the `card_id_field` to retrieve the card ID from the current object. + * It then adds the number of repetitions (`reps`) to the card ID to generate the seed. + * + * @example + * ```typescript + * const seedStrategy = GenCardIdSeedStrategy('card_id'); + * const f = fsrs().useStrategy(StrategyMode.SEED, seedStrategy) + * const card = createEmptyCard() + * card.card_id = 555 + * const record = f.repeat(card, new Date()) + * ``` + */ +export function GenSeedStrategyWithCardId( + card_id_field: string | number +): TSeedStrategy { + return function (this: AbstractScheduler): string { + // https://github.com/open-spaced-repetition/ts-fsrs/issues/131#issuecomment-2408426225 + const card_id = Reflect.get(this.current, card_id_field) ?? 0 + const reps = this.current.reps + // ex1 + // card_id:string + reps:number = 'e2ecb1f7-8d15-420b-bec4-c7212ad2e5dc' + 4 + // = 'e2ecb1f7-8d15-420b-bec4-c7212ad2e5dc4' + + // ex2 + // card_id:number + reps:number = 1732452519198 + 4 + // = '17324525191984' + return String(card_id + reps || 0) + } +} diff --git a/src/fsrs/strategies/types.ts b/src/fsrs/strategies/types.ts new file mode 100644 index 0000000..13e343b --- /dev/null +++ b/src/fsrs/strategies/types.ts @@ -0,0 +1,25 @@ +import type { AbstractScheduler } from '../abstract_scheduler' +import type { FSRSAlgorithm } from '../algorithm' +import type { Card, CardInput, DateInput } from '../models' +import type { IScheduler } from '../types' + +export enum StrategyMode { + SCHEDULER = 'Scheduler', + SEED = 'Seed', +} + +export type TSeedStrategy = (this: AbstractScheduler) => string +export type TSchedulerStrategy = + new ( + card: T, + now: DateInput, + algorithm: FSRSAlgorithm, + strategies: { seed: TSeedStrategy } + ) => IScheduler + +export type TStrategyHandler = + E extends StrategyMode.SCHEDULER + ? TSchedulerStrategy + : E extends StrategyMode.SEED + ? TSeedStrategy + : never diff --git a/src/fsrs/types.ts b/src/fsrs/types.ts index 0d5dbaa..2ee5900 100644 --- a/src/fsrs/types.ts +++ b/src/fsrs/types.ts @@ -1,12 +1,10 @@ import type { - Card, CardInput, DateInput, FSRSHistory, Grade, RecordLog, RecordLogItem, - ReviewLog, } from './models' export type unit = 'days' | 'minutes'