learning randomization
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user