Skip to content

Commit

Permalink
Refactor/Scheduler Framework (#99)
Browse files Browse the repository at this point in the history
* type.ts -> types.ts

* add interface

* add type convert

* refactor scheduler

* fix lapses

* remove scheduler.ts

* fix record log

* transforming node_modules

* remove ISchedulerLifecycle type

* impl basic schduler

* add test

* update jest.config.js

* use TypeConvert.time
  • Loading branch information
ishiko732 authored Jul 21, 2024
1 parent 92c64fd commit ba57899
Show file tree
Hide file tree
Showing 13 changed files with 619 additions and 333 deletions.
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

0 comments on commit ba57899

Please sign in to comment.