276 lines
7.4 KiB
TypeScript
276 lines
7.4 KiB
TypeScript
/**
|
|
* SM-2 Algorithm Implementation
|
|
*
|
|
* Implements the SuperMemo SM-2 spaced repetition algorithm
|
|
* as specified in the MemoHanzi specification.
|
|
*
|
|
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
|
|
*/
|
|
|
|
/**
|
|
* Progress data for a single card
|
|
*/
|
|
export interface CardProgress {
|
|
easeFactor: number
|
|
interval: number // in days
|
|
consecutiveCorrect: number
|
|
incorrectCount: number
|
|
lastReviewDate: Date | null
|
|
nextReviewDate: Date | null
|
|
}
|
|
|
|
/**
|
|
* Initial values for a new card
|
|
*/
|
|
export const INITIAL_PROGRESS: CardProgress = {
|
|
easeFactor: 2.5,
|
|
interval: 1,
|
|
consecutiveCorrect: 0,
|
|
incorrectCount: 0,
|
|
lastReviewDate: null,
|
|
nextReviewDate: null,
|
|
}
|
|
|
|
/**
|
|
* Result of calculating the next review
|
|
*/
|
|
export interface ReviewResult {
|
|
easeFactor: number
|
|
interval: number
|
|
consecutiveCorrect: number
|
|
incorrectCount: number
|
|
nextReviewDate: Date
|
|
}
|
|
|
|
/**
|
|
* Calculate the next review for a correct answer
|
|
*
|
|
* @param progress Current card progress
|
|
* @param reviewDate Date of the review (defaults to now)
|
|
* @returns Updated progress values
|
|
*/
|
|
export function calculateCorrectAnswer(
|
|
progress: CardProgress,
|
|
reviewDate: Date = new Date()
|
|
): ReviewResult {
|
|
let newInterval: number
|
|
let newEaseFactor: number
|
|
let newConsecutiveCorrect: number
|
|
|
|
// Calculate new interval based on consecutive correct count
|
|
if (progress.consecutiveCorrect === 0) {
|
|
newInterval = 1
|
|
} else if (progress.consecutiveCorrect === 1) {
|
|
newInterval = 6
|
|
} else {
|
|
newInterval = Math.round(progress.interval * progress.easeFactor)
|
|
}
|
|
|
|
// Increase ease factor (making future intervals longer)
|
|
newEaseFactor = progress.easeFactor + 0.1
|
|
|
|
// Increment consecutive correct count
|
|
newConsecutiveCorrect = progress.consecutiveCorrect + 1
|
|
|
|
// Calculate next review date
|
|
const nextReviewDate = new Date(reviewDate)
|
|
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
|
|
|
|
return {
|
|
easeFactor: newEaseFactor,
|
|
interval: newInterval,
|
|
consecutiveCorrect: newConsecutiveCorrect,
|
|
incorrectCount: progress.incorrectCount,
|
|
nextReviewDate,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate the next review for an incorrect answer
|
|
*
|
|
* @param progress Current card progress
|
|
* @param reviewDate Date of the review (defaults to now)
|
|
* @returns Updated progress values
|
|
*/
|
|
export function calculateIncorrectAnswer(
|
|
progress: CardProgress,
|
|
reviewDate: Date = new Date()
|
|
): ReviewResult {
|
|
// Reset interval to 1 day
|
|
const newInterval = 1
|
|
|
|
// Reset consecutive correct count
|
|
const newConsecutiveCorrect = 0
|
|
|
|
// Decrease ease factor (but not below 1.3)
|
|
const newEaseFactor = Math.max(1.3, progress.easeFactor - 0.2)
|
|
|
|
// Increment incorrect count
|
|
const newIncorrectCount = progress.incorrectCount + 1
|
|
|
|
// Calculate next review date (1 day from now)
|
|
const nextReviewDate = new Date(reviewDate)
|
|
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
|
|
|
|
return {
|
|
easeFactor: newEaseFactor,
|
|
interval: newInterval,
|
|
consecutiveCorrect: newConsecutiveCorrect,
|
|
incorrectCount: newIncorrectCount,
|
|
nextReviewDate,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Difficulty enum matching the Prisma schema
|
|
*/
|
|
export enum Difficulty {
|
|
EASY = "EASY",
|
|
NORMAL = "NORMAL",
|
|
HARD = "HARD",
|
|
SUSPENDED = "SUSPENDED",
|
|
}
|
|
|
|
/**
|
|
* Card for selection with progress and metadata
|
|
*/
|
|
export interface SelectableCard {
|
|
id: string
|
|
nextReviewDate: Date | null
|
|
incorrectCount: number
|
|
consecutiveCorrect: number
|
|
manualDifficulty: Difficulty
|
|
}
|
|
|
|
/**
|
|
* Select cards for a learning session
|
|
*
|
|
* Algorithm:
|
|
* 1. Filter out SUSPENDED cards
|
|
* 2. Filter cards that are due (nextReviewDate <= now)
|
|
* 3. Apply priority: HARD cards first, NORMAL, then EASY
|
|
* 4. Sort by: nextReviewDate ASC, incorrectCount DESC, consecutiveCorrect ASC
|
|
* 5. Limit to cardsPerSession
|
|
*
|
|
* @param cards Available cards
|
|
* @param cardsPerSession Maximum number of cards to select
|
|
* @param now Current date (defaults to now)
|
|
* @returns Selected cards for the session
|
|
*/
|
|
export function selectCardsForSession(
|
|
cards: SelectableCard[],
|
|
cardsPerSession: number,
|
|
now: Date = new Date()
|
|
): SelectableCard[] {
|
|
// Filter out suspended cards
|
|
const activeCards = cards.filter(
|
|
(card) => card.manualDifficulty !== Difficulty.SUSPENDED
|
|
)
|
|
|
|
// Filter cards that are due (nextReviewDate <= now or null for new cards)
|
|
const dueCards = activeCards.filter(
|
|
(card) => card.nextReviewDate === null || card.nextReviewDate <= now
|
|
)
|
|
|
|
// Apply difficulty priority and sort
|
|
const sortedCards = dueCards.sort((a, b) => {
|
|
// Priority by difficulty: HARD > NORMAL > EASY
|
|
const difficultyPriority = {
|
|
[Difficulty.HARD]: 0,
|
|
[Difficulty.NORMAL]: 1,
|
|
[Difficulty.EASY]: 2,
|
|
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
|
|
}
|
|
|
|
const aPriority = difficultyPriority[a.manualDifficulty]
|
|
const bPriority = difficultyPriority[b.manualDifficulty]
|
|
|
|
if (aPriority !== bPriority) {
|
|
return aPriority - bPriority
|
|
}
|
|
|
|
// Sort by nextReviewDate (null = new cards, should come first)
|
|
if (a.nextReviewDate === null && b.nextReviewDate !== null) return -1
|
|
if (a.nextReviewDate !== null && b.nextReviewDate === null) return 1
|
|
if (a.nextReviewDate !== null && b.nextReviewDate !== null) {
|
|
const dateCompare = a.nextReviewDate.getTime() - b.nextReviewDate.getTime()
|
|
if (dateCompare !== 0) return dateCompare
|
|
}
|
|
|
|
// Sort by incorrectCount DESC (more incorrect = higher priority)
|
|
if (a.incorrectCount !== b.incorrectCount) {
|
|
return b.incorrectCount - a.incorrectCount
|
|
}
|
|
|
|
// Sort by consecutiveCorrect ASC (fewer correct = higher priority)
|
|
return a.consecutiveCorrect - b.consecutiveCorrect
|
|
})
|
|
|
|
// Limit to cardsPerSession
|
|
return sortedCards.slice(0, cardsPerSession)
|
|
}
|
|
|
|
/**
|
|
* Hanzi option for wrong answer generation
|
|
*/
|
|
export interface HanziOption {
|
|
id: string
|
|
simplified: string
|
|
pinyin: string
|
|
hskLevel: string
|
|
}
|
|
|
|
/**
|
|
* Generate wrong answers for a multiple choice question
|
|
*
|
|
* Selects 3 random incorrect pinyin from the same HSK level,
|
|
* ensuring no duplicates.
|
|
*
|
|
* @param correctAnswer The correct hanzi
|
|
* @param sameHskOptions Available hanzi from the same HSK level
|
|
* @returns Array of 3 wrong pinyin options
|
|
*/
|
|
export function generateWrongAnswers(
|
|
correctAnswer: HanziOption,
|
|
sameHskOptions: HanziOption[]
|
|
): string[] {
|
|
// Filter out the correct answer and any with duplicate pinyin
|
|
const candidates = sameHskOptions.filter(
|
|
(option) =>
|
|
option.id !== correctAnswer.id && option.pinyin !== correctAnswer.pinyin
|
|
)
|
|
|
|
// If not enough candidates, throw error
|
|
if (candidates.length < 3) {
|
|
throw new Error(
|
|
`Not enough wrong answers available. Need 3, found ${candidates.length}`
|
|
)
|
|
}
|
|
|
|
// Fisher-Yates shuffle
|
|
const shuffled = [...candidates]
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1))
|
|
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
|
}
|
|
|
|
// Take first 3
|
|
return shuffled.slice(0, 3).map((option) => option.pinyin)
|
|
}
|
|
|
|
/**
|
|
* Shuffle an array of options (for randomizing answer positions)
|
|
* Uses Fisher-Yates shuffle algorithm
|
|
*
|
|
* @param options Array to shuffle
|
|
* @returns Shuffled array
|
|
*/
|
|
export function shuffleOptions<T>(options: T[]): T[] {
|
|
const shuffled = [...options]
|
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1))
|
|
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
|
}
|
|
return shuffled
|
|
}
|