Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor/Scheduler Framework #99

Merged
merged 13 commits into from
Jul 21, 2024
97 changes: 97 additions & 0 deletions __tests__/impl/basic_schduler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {
createEmptyCard,
FSRSAlgorithm,
generatorParameters,
Grade,
Rating,
} from '../../src/fsrs'
import BasicScheduler from '../../src/fsrs/impl/basic_schduler'

describe('basic schduler', () => {
const params = generatorParameters()
const algorithm = new FSRSAlgorithm(params)
const now = new Date()

it('[State.New]exist', () => {
const card = createEmptyCard(now)
const basicScheduler = new BasicScheduler(card, now, algorithm)
const preview = basicScheduler.preview()
const again = basicScheduler.review(Rating.Again)
const hard = basicScheduler.review(Rating.Hard)
const good = basicScheduler.review(Rating.Good)
const easy = basicScheduler.review(Rating.Easy)
expect(preview).toEqual({
[Rating.Again]: again,
[Rating.Hard]: hard,
[Rating.Good]: good,
[Rating.Easy]: easy,
})
})
it('[State.New]invalid grade', () => {
const card = createEmptyCard(now)
const basicScheduler = new BasicScheduler(card, now, algorithm)
expect(() => basicScheduler.review('invalid' as unknown as Grade)).toThrow(
'Invalid grade'
)
})

it('[State.Learning]exist', () => {
const cardByNew = createEmptyCard(now)
const { card } = new BasicScheduler(cardByNew, now, algorithm).review(
Rating.Again
)
const basicScheduler = new BasicScheduler(card, now, algorithm)

const preview = basicScheduler.preview()
const again = basicScheduler.review(Rating.Again)
const hard = basicScheduler.review(Rating.Hard)
const good = basicScheduler.review(Rating.Good)
const easy = basicScheduler.review(Rating.Easy)
expect(preview).toEqual({
[Rating.Again]: again,
[Rating.Hard]: hard,
[Rating.Good]: good,
[Rating.Easy]: easy,
})
})
it('[State.Learning]invalid grade', () => {
const cardByNew = createEmptyCard(now)
const { card } = new BasicScheduler(cardByNew, now, algorithm).review(
Rating.Again
)
const basicScheduler = new BasicScheduler(card, now, algorithm)
expect(() => basicScheduler.review('invalid' as unknown as Grade)).toThrow(
'Invalid grade'
)
})

it('[State.Review]exist', () => {
const cardByNew = createEmptyCard(now)
const { card } = new BasicScheduler(cardByNew, now, algorithm).review(
Rating.Easy
)
const basicScheduler = new BasicScheduler(card, now, algorithm)

const preview = basicScheduler.preview()
const again = basicScheduler.review(Rating.Again)
const hard = basicScheduler.review(Rating.Hard)
const good = basicScheduler.review(Rating.Good)
const easy = basicScheduler.review(Rating.Easy)
expect(preview).toEqual({
[Rating.Again]: again,
[Rating.Hard]: hard,
[Rating.Good]: good,
[Rating.Easy]: easy,
})
})
it('[State.Review]invalid grade', () => {
const cardByNew = createEmptyCard(now)
const { card } = new BasicScheduler(cardByNew, now, algorithm).review(
Rating.Easy
)
const basicScheduler = new BasicScheduler(card, now, algorithm)
expect(() => basicScheduler.review('invalid' as unknown as Grade)).toThrow(
'Invalid grade'
)
})
})
14 changes: 9 additions & 5 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
testMatch: ["**/__tests__/*.js?(x)", "**/__tests__/*.ts?(x)"],
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: [
'**/__tests__/*.ts?(x)',
'**/__tests__/**/*.ts?(x)',
],
collectCoverage: true,
coverageReporters: ["text", "cobertura"],
coverageReporters: ['text', 'cobertura'],
coverageThreshold: {
global: {
lines: 80,
},
},
};
transformIgnorePatterns: ['/node_modules/(?!(module-to-transform)/)'],
}
102 changes: 102 additions & 0 deletions src/fsrs/abstract_schduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { FSRSAlgorithm } from './algorithm'
import { TypeConvert } from './convert'
import {
type Card,
type RecordLog,
type Grade,
type RecordLogItem,
State,
Rating,
type ReviewLog,
type CardInput,
type DateInput,
} from './models'
import type { IScheduler } from './types'

export abstract class AbstractScheduler implements IScheduler {
protected last: Card
protected current: Card
protected review_time: Date
protected next: Map<Grade, RecordLogItem> = new Map()
protected algorithm: FSRSAlgorithm

constructor(
card: CardInput | Card,
now: DateInput,
algorithm: FSRSAlgorithm
) {
this.algorithm = algorithm

this.last = TypeConvert.card(card)
this.current = TypeConvert.card(card)
this.review_time = TypeConvert.time(now)
this.init()
}

private init() {
const { state, last_review } = this.current
let interval = 0 // card.state === State.New => 0
if (state !== State.New && last_review) {
interval = this.review_time.diff(last_review as Date, 'days')
}
this.current.last_review = this.review_time
this.current.elapsed_days = interval
this.current.reps += 1
this.initSeed()
}

public preview(): RecordLog {
return {
[Rating.Again]: this.review(Rating.Again),
[Rating.Hard]: this.review(Rating.Hard),
[Rating.Good]: this.review(Rating.Good),
[Rating.Easy]: this.review(Rating.Easy),
} satisfies RecordLog
}
public review(grade: Grade): RecordLogItem {
const { state } = this.last
switch (state) {
case State.New:
return this.newState(grade)
case State.Learning:
case State.Relearning:
return this.learningState(grade)
case State.Review: {
const item = this.reviewState(grade)
if (!item) {
throw new Error('Invalid grade')
}
return item
}
}
}

protected abstract newState(grade: Grade): RecordLogItem

protected abstract learningState(grade: Grade): RecordLogItem

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

return {
rating: rating,
state: this.current.state,
due: last_review || due,
stability: this.current.stability,
difficulty: this.current.difficulty,
elapsed_days: this.current.elapsed_days,
last_elapsed_days: elapsed_days,
scheduled_days: this.current.scheduled_days,
review: this.review_time,
} satisfies ReviewLog
}
}
75 changes: 6 additions & 69 deletions src/fsrs/algorithm.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import pseudorandom from 'seedrandom'
import { generatorParameters } from './default'
import { SchedulingCard } from './scheduler'
import { FSRSParameters, Grade, Rating } from './models'
import type { int } from './type'
import type { int } from './types'
import { get_fuzz_range } from './help'

/**
Expand All @@ -23,7 +22,7 @@ export const FACTOR: number = 19 / 81
export class FSRSAlgorithm {
protected param!: FSRSParameters
protected intervalModifier!: number
protected seed?: string
protected _seed?: string

constructor(params: Partial<FSRSParameters>) {
this.param = new Proxy(
Expand All @@ -39,6 +38,10 @@ export class FSRSAlgorithm {
return this.intervalModifier
}

set seed(seed: string) {
this._seed = seed
}

/**
* @see https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm#fsrs-45
*
Expand Down Expand Up @@ -95,72 +98,6 @@ export class FSRSAlgorithm {
}
}

init_ds(s: SchedulingCard): void {
s.again.difficulty = this.init_difficulty(Rating.Again)
s.again.stability = this.init_stability(Rating.Again)
s.hard.difficulty = this.init_difficulty(Rating.Hard)
s.hard.stability = this.init_stability(Rating.Hard)
s.good.difficulty = this.init_difficulty(Rating.Good)
s.good.stability = this.init_stability(Rating.Good)
s.easy.difficulty = this.init_difficulty(Rating.Easy)
s.easy.stability = this.init_stability(Rating.Easy)
}

next_short_term_ds(s: SchedulingCard): void {
const last_d = s.again.difficulty
const last_s = s.again.stability
s.again.difficulty = this.next_difficulty(last_d, Rating.Again)
s.again.stability = this.next_short_term_stability(last_s, Rating.Again)
s.hard.difficulty = this.next_difficulty(last_d, Rating.Hard)
s.hard.stability = this.next_short_term_stability(last_s, Rating.Hard)
s.good.difficulty = this.next_difficulty(last_d, Rating.Good)
s.good.stability = this.next_short_term_stability(last_s, Rating.Good)
s.easy.difficulty = this.next_difficulty(last_d, Rating.Easy)
s.easy.stability = this.next_short_term_stability(last_s, Rating.Easy)
}

/**
* Updates the difficulty and stability values of the scheduling card based on the last difficulty,
* last stability, and the current retrievability.
* @param {SchedulingCard} s scheduling Card
* @param {number} last_d Difficulty
* @param {number} last_s Stability
* @param retrievability Retrievability
*/
next_ds(
s: SchedulingCard,
last_d: number,
last_s: number,
retrievability: number
): void {
s.again.difficulty = this.next_difficulty(last_d, Rating.Again)
s.again.stability = this.next_forget_stability(
last_d,
last_s,
retrievability
)
s.hard.difficulty = this.next_difficulty(last_d, Rating.Hard)
s.hard.stability = this.next_recall_stability(
last_d,
last_s,
retrievability,
Rating.Hard
)
s.good.difficulty = this.next_difficulty(last_d, Rating.Good)
s.good.stability = this.next_recall_stability(
last_d,
last_s,
retrievability,
Rating.Good
)
s.easy.difficulty = this.next_difficulty(last_d, Rating.Easy)
s.easy.stability = this.next_recall_stability(
last_d,
last_s,
retrievability,
Rating.Easy
)
}

/**
* The formula used is :
Expand Down
Loading