learning randomization

This commit is contained in:
Stefan Hardegger
2025-11-22 14:28:26 +01:00
parent 33377009d0
commit de4e7c4c6e
6 changed files with 1265 additions and 99 deletions

View File

@@ -26,8 +26,6 @@ describe("SM-2 Algorithm", () => {
expect(INITIAL_PROGRESS.interval).toBe(1)
expect(INITIAL_PROGRESS.consecutiveCorrect).toBe(0)
expect(INITIAL_PROGRESS.incorrectCount).toBe(0)
expect(INITIAL_PROGRESS.lastReviewDate).toBeNull()
expect(INITIAL_PROGRESS.nextReviewDate).toBeNull()
})
})
@@ -51,7 +49,6 @@ describe("SM-2 Algorithm", () => {
interval: 1,
consecutiveCorrect: 1,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-02"),
}
@@ -69,7 +66,6 @@ describe("SM-2 Algorithm", () => {
interval: 6,
consecutiveCorrect: 2,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-02"),
nextReviewDate: new Date("2025-01-08"),
}
@@ -88,7 +84,6 @@ describe("SM-2 Algorithm", () => {
interval: 50,
consecutiveCorrect: 5,
incorrectCount: 2,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-02-20"),
}
@@ -130,7 +125,6 @@ describe("SM-2 Algorithm", () => {
interval: 365,
consecutiveCorrect: 10,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2026-01-01"),
}
@@ -149,7 +143,6 @@ describe("SM-2 Algorithm", () => {
interval: 16,
consecutiveCorrect: 3,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-17"),
}
@@ -165,7 +158,6 @@ describe("SM-2 Algorithm", () => {
interval: 16,
consecutiveCorrect: 5,
incorrectCount: 1,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-17"),
}
@@ -180,7 +172,6 @@ describe("SM-2 Algorithm", () => {
interval: 6,
consecutiveCorrect: 2,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-07"),
}
@@ -195,7 +186,6 @@ describe("SM-2 Algorithm", () => {
interval: 1,
consecutiveCorrect: 0,
incorrectCount: 5,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-02"),
}
@@ -211,7 +201,6 @@ describe("SM-2 Algorithm", () => {
interval: 6,
consecutiveCorrect: 2,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-07"),
}
@@ -226,7 +215,6 @@ describe("SM-2 Algorithm", () => {
interval: 6,
consecutiveCorrect: 2,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-07"),
}
@@ -254,7 +242,6 @@ describe("SM-2 Algorithm", () => {
interval: 16,
consecutiveCorrect: 3,
incorrectCount: 0,
lastReviewDate: new Date("2025-01-01"),
nextReviewDate: new Date("2025-01-17"),
}
@@ -269,7 +256,6 @@ describe("SM-2 Algorithm", () => {
interval: result.interval,
consecutiveCorrect: result.consecutiveCorrect,
incorrectCount: result.incorrectCount,
lastReviewDate: new Date("2025-01-17"),
nextReviewDate: result.nextReviewDate,
}
result = calculateIncorrectAnswer(progress, new Date("2025-01-18"))
@@ -282,7 +268,6 @@ describe("SM-2 Algorithm", () => {
interval: result.interval,
consecutiveCorrect: result.consecutiveCorrect,
incorrectCount: result.incorrectCount,
lastReviewDate: new Date("2025-01-18"),
nextReviewDate: result.nextReviewDate,
}
result = calculateIncorrectAnswer(progress, new Date("2025-01-19"))
@@ -301,25 +286,25 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: new Date("2025-01-14T10:00:00Z"), // Due
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "2",
nextReviewDate: new Date("2025-01-16T10:00:00Z"), // Not due
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "3",
nextReviewDate: new Date("2025-01-13T10:00:00Z"), // Due
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
expect(selected.length).toBe(2)
expect(selected.map((c) => c.id)).toContain("1")
@@ -334,7 +319,7 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "2",
@@ -345,7 +330,7 @@ describe("SM-2 Algorithm", () => {
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
expect(selected.length).toBe(1)
expect(selected[0].id).toBe("1")
@@ -372,11 +357,11 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
expect(selected[0].id).toBe("hard")
expect(selected[1].id).toBe("normal")
@@ -390,25 +375,25 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "2",
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "3",
nextReviewDate: new Date("2025-01-13T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
expect(selected[0].id).toBe("2") // Oldest
expect(selected[1].id).toBe("3")
@@ -422,25 +407,25 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 1,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "2",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 3,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "3",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 2,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
expect(selected[0].id).toBe("2") // incorrectCount: 3
expect(selected[1].id).toBe("3") // incorrectCount: 2
@@ -454,25 +439,25 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 3,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "2",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "3",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 2,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
expect(selected[0].id).toBe("2") // consecutiveCorrect: 1
expect(selected[1].id).toBe("3") // consecutiveCorrect: 2
@@ -486,18 +471,18 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: null, // New card
incorrectCount: 0,
consecutiveCorrect: 0,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "2",
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
expect(selected.length).toBe(2)
expect(selected[0].id).toBe("1") // New cards first
@@ -510,10 +495,10 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: new Date("2025-01-14T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
}))
const selected = selectCardsForSession(cards, 5, now)
const selected = selectCardsForSession(cards, 5, now, false)
expect(selected.length).toBe(5)
})
@@ -546,11 +531,11 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: new Date("2025-01-12T10:00:00Z"),
incorrectCount: 5,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
// Expected order:
// 1. HARD difficulty has priority
@@ -564,7 +549,7 @@ describe("SM-2 Algorithm", () => {
})
it("should handle empty card list", () => {
const selected = selectCardsForSession([], 10, now)
const selected = selectCardsForSession([], 10, now, false)
expect(selected.length).toBe(0)
})
@@ -586,7 +571,7 @@ describe("SM-2 Algorithm", () => {
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
expect(selected.length).toBe(0)
})
@@ -597,18 +582,18 @@ describe("SM-2 Algorithm", () => {
nextReviewDate: new Date("2025-01-16T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
{
id: "2",
nextReviewDate: new Date("2025-01-17T10:00:00Z"),
incorrectCount: 0,
consecutiveCorrect: 1,
manualDifficulty: Difficulty.NORMAL,
manualDifficulty: Difficulty.MEDIUM,
},
]
const selected = selectCardsForSession(cards, 10, now)
const selected = selectCardsForSession(cards, 10, now, false)
expect(selected.length).toBe(0)
})
})

View File

@@ -7,6 +7,8 @@
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
*/
import { Difficulty } from "@prisma/client"
/**
* Progress data for a single card
*/
@@ -15,20 +17,17 @@ export interface CardProgress {
interval: number // in days
consecutiveCorrect: number
incorrectCount: number
lastReviewDate: Date | null
nextReviewDate: Date | null
}
/**
* Initial values for a new card
* Initial values for a new card (without nextReviewDate as it's set on creation)
*/
export const INITIAL_PROGRESS: CardProgress = {
export const INITIAL_PROGRESS = {
easeFactor: 2.5,
interval: 1,
consecutiveCorrect: 0,
incorrectCount: 0,
lastReviewDate: null,
nextReviewDate: null,
}
/**
@@ -122,13 +121,20 @@ export function calculateIncorrectAnswer(
}
/**
* Difficulty enum matching the Prisma schema
* Re-export Difficulty enum from Prisma for convenience
*/
export enum Difficulty {
EASY = "EASY",
NORMAL = "NORMAL",
HARD = "HARD",
SUSPENDED = "SUSPENDED",
export { Difficulty }
/**
* Shuffle array using Fisher-Yates algorithm
*/
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
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
}
/**
@@ -160,7 +166,8 @@ export interface SelectableCard {
export function selectCardsForSession(
cards: SelectableCard[],
cardsPerSession: number,
now: Date = new Date()
now: Date = new Date(),
shuffle: boolean = true
): SelectableCard[] {
// Filter out suspended cards
const activeCards = cards.filter(
@@ -177,7 +184,7 @@ export function selectCardsForSession(
// Priority by difficulty: HARD > NORMAL > EASY
const difficultyPriority = {
[Difficulty.HARD]: 0,
[Difficulty.NORMAL]: 1,
[Difficulty.MEDIUM]: 1,
[Difficulty.EASY]: 2,
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
}
@@ -203,11 +210,20 @@ export function selectCardsForSession(
}
// Sort by consecutiveCorrect ASC (fewer correct = higher priority)
return a.consecutiveCorrect - b.consecutiveCorrect
if (a.consecutiveCorrect !== b.consecutiveCorrect) {
return a.consecutiveCorrect - b.consecutiveCorrect
}
// Random tiebreaker for cards with equal priority
return Math.random() - 0.5
})
// Limit to cardsPerSession
return sortedCards.slice(0, cardsPerSession)
const selectedCards = sortedCards.slice(0, cardsPerSession)
// Final shuffle: randomize the order of selected cards for presentation
// This prevents always showing hard/struggling cards first
return shuffle ? shuffleArray(selectedCards) : selectedCards
}
/**