Skip to content

Commit

Permalink
Dynamic card ease factor
Browse files Browse the repository at this point in the history
  • Loading branch information
kubk committed Dec 9, 2023
1 parent 7dc30ff commit 35c4bba
Show file tree
Hide file tree
Showing 4 changed files with 159 additions and 47 deletions.
3 changes: 3 additions & 0 deletions functions/db/databaseTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,23 @@ export interface Database {
Row: {
card_id: number
created_at: string
ease_factor: number
interval: number
last_review_date: string
user_id: number
}
Insert: {
card_id: number
created_at?: string
ease_factor?: number
interval: number
last_review_date?: string
user_id: number
}
Update: {
card_id?: number
created_at?: string
ease_factor?: number
interval?: number
last_review_date?: string
user_id?: number
Expand Down
13 changes: 8 additions & 5 deletions functions/review-cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { reviewCard } from "./services/review-card.ts";
import { DateTime } from "luxon";
import { DatabaseException } from "./db/database-exception.ts";
import { createJsonResponse } from "./lib/json-response/create-json-response.ts";
import { Database } from "./db/databaseTypes.ts";

const requestSchema = z.object({
cards: z.array(
Expand Down Expand Up @@ -36,7 +37,7 @@ export const onRequestPost = handleError(async ({ env, request }) => {

const { data: existingReviews, error } = await db
.from("card_review")
.select("card_id, interval")
.select("card_id, interval, ease_factor")
.eq("user_id", user.id)
.in(
"card_id",
Expand All @@ -52,22 +53,24 @@ export const onRequestPost = handleError(async ({ env, request }) => {
const upsertReviewsResult = await db
.from("card_review")
.upsert(
input.data.cards.map((card) => {
const previousInterval = existingReviews.find(
input.data.cards.map((card): Database['public']['Tables']['card_review']['Insert'] => {
const previousReview = existingReviews.find(
(review) => review.card_id === card.id,
)?.interval;
);

const reviewResult = reviewCard(
now,
previousInterval,
previousReview?.interval,
card.outcome,
previousReview?.ease_factor,
input.data.isInterrupted,
);

return {
user_id: user.id,
card_id: card.id,
last_review_date: now.toJSDate().toISOString(),
ease_factor: reviewResult.easeFactor,
interval: reviewResult.interval,
};
}),
Expand Down
161 changes: 133 additions & 28 deletions functions/services/review-card.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,44 @@
import { expect, test } from "vitest";
import { reviewCard } from "./review-card.ts";
import { reviewCard, ReviewOutcome } from "./review-card.ts";
import { DateTime } from "luxon";

// For comparison: https://github.com/open-spaced-repetition/fsrs4anki/wiki/Compare-Anki's-built-in-scheduler-and-FSRS
test("hit yes all the time", () => {

const testReviewWithAnswers = (answers: ReviewOutcome[]) => {
let date = DateTime.fromSQL("2021-05-20 10:00:00");
let interval = undefined;
let easeFactor = undefined;

const intervals: number[] = [];
const easeFactors: number[] = [];
const dateIntervals = [];

for (let i = 0; i < 4; i++) {
const { nextReviewDate, interval: newInterval } = reviewCard(
date,
interval,
"correct",
);
for (const answer of answers) {
const {
nextReviewDate,
interval: newInterval,
easeFactor: newEaseFactor,
} = reviewCard(date, interval, answer, easeFactor);
intervals.push(newInterval);
easeFactors.push(newEaseFactor);
dateIntervals.push(nextReviewDate.diff(date));

interval = newInterval;
date = nextReviewDate;
easeFactor = newEaseFactor;
}

return { intervals, easeFactors, dateIntervals };
};

test("hit yes all the time", () => {
const { intervals, dateIntervals, easeFactors } = testReviewWithAnswers([
"correct",
"correct",
"correct",
"correct",
]);

expect(intervals).toEqual([1, 2.5, 6.25, 15.63]);
expect(
dateIntervals.map((dateInterval) => dateInterval.milliseconds),
Expand All @@ -36,42 +52,131 @@ test("hit yes all the time", () => {
// 15.625 days
3600 * 24 * 1000 * 15.625,
]);
expect(easeFactors).toEqual([2.5, 2.5, 2.5, 2.5]);
});

test("hit wrong, then hit yes all the time", () => {
const { intervals, easeFactors } = testReviewWithAnswers([
"wrong",
"correct",
"correct",
"correct",
"correct",
]);

expect(intervals).toEqual([0.4, 0.94, 2.3, 5.75, 14.38]);
expect(easeFactors).toEqual([2.35, 2.45, 2.5, 2.5, 2.5]);
});

test("difficult to remember card", () => {
const { intervals } = testReviewWithAnswers([
"wrong",
"correct",
"wrong",
"wrong",
"correct",
"correct",
"correct",
]);

expect(intervals).toEqual([0.4, 0.94, 0.4, 0.4, 0.86, 1.94, 4.56]);
});

test("forgetting resets interval - non interrupted", () => {
const date = DateTime.fromSQL("2021-05-20 10:00:00");

const { interval: newInterval1 } = reviewCard(date, undefined, "correct");
const { interval: newInterval1, easeFactor: newEaseFactor1 } = reviewCard(
date,
undefined,
"correct",
undefined,
);
expect(newInterval1).toBe(1);

const { interval: newInterval2 } = reviewCard(date, 1, "wrong");
expect(newEaseFactor1).toBe(2.5);

const { interval: newInterval2, easeFactor: newEaseFactor2 } = reviewCard(
date,
1,
"wrong",
newEaseFactor1,
);
expect(newInterval2).toBe(0.4);

const { interval: newInterval3 } = reviewCard(date, 0, "wrong");
expect(newEaseFactor2).toBe(2.35);

const { interval: newInterval3, easeFactor: newEaseFactor3 } = reviewCard(
date,
0,
"wrong",
newEaseFactor2,
);
expect(newInterval3).toBe(0.4);

const { interval: newInterval4 } = reviewCard(date, 0, "correct");
expect(newEaseFactor3).toBe(2.2);

const { interval: newInterval4, easeFactor: newEasyFactor4 } = reviewCard(
date,
0,
"correct",
newEaseFactor3,
);
expect(newInterval4).toBe(0.4);

const { interval: newInterval5 } = reviewCard(date, 0.4, "correct");
expect(newInterval5).toBe(1);
expect(newEasyFactor4).toBe(2.3);

const { interval: newInterval5, easeFactor: newEasyFactor5 } = reviewCard(
date,
0.4,
"correct",
newEasyFactor4,
);
expect(newInterval5).toBe(0.92);
expect(newEasyFactor5).toBe(2.4);
});

test("forgetting resets interval - interrupted", () => {
const date = DateTime.fromSQL("2021-05-20 10:00:00");

const { interval: newInterval1 } = reviewCard(date, undefined, "correct");
const { interval: newInterval1, easeFactor: newEaseFactor1 } = reviewCard(
date,
undefined,
"correct",
);
expect(newInterval1).toBe(1);

const { interval: newInterval2 } = reviewCard(date, 1, "wrong", true);
expect(newEaseFactor1).toBe(2.5);

const { interval: newInterval2, easeFactor: newEaseFactor2 } = reviewCard(
date,
1,
"wrong",
newEaseFactor1,
true,
);
expect(newInterval2).toBe(0);

const { interval: newInterval3 } = reviewCard(date, 0, "wrong", true);
expect(newEaseFactor2).toBe(2.35);

const { interval: newInterval3, easeFactor: newEaseFactor3 } = reviewCard(
date,
0,
"wrong",
newEaseFactor2,
true,
);
expect(newInterval3).toBe(0);

const { interval: newInterval4 } = reviewCard(date, 0, "correct");
expect(newEaseFactor3).toBe(2.2);

const { interval: newInterval4, easeFactor: newEaseFactor4 } = reviewCard(
date,
0,
"correct",
newEaseFactor3,
);
expect(newInterval4).toBe(0.4);

const { interval: newInterval5 } = reviewCard(date, 0.4, "correct");
expect(newInterval5).toBe(1);
expect(newEaseFactor4).toBe(2.3);

const { interval: newInterval5, easeFactor: newEaseFactor5 } = reviewCard(
date,
0.4,
"correct",
newEaseFactor4,
);
expect(newInterval5).toBe(0.92);
expect(newEaseFactor5).toBe(2.4);
});
29 changes: 15 additions & 14 deletions functions/services/review-card.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,36 @@ Settings.throwOnInvalid = true;
export type Result = {
nextReviewDate: DateTime;
interval: number;
easeFactor: number;
};

export type ReviewOutcome = "correct" | "wrong";

const easeFactor = 2.5;
const startInterval = 0.4;
const startEaseFactor = 2.5; // is the initial easiness factor. Tells how easy the card is to remember
const startInterval = 0.4; // is the initial interval for the first review in days
const easeFactorDecrement = 0.15; // The amount to decrease easeFactor when the answer is wrong
const minimumEaseFactor = 1.3; // Set a minimum easeFactor to avoid it becoming too small
const easeFactorIncrement = 0.1; // The amount to increase easeFactor if the answer is correct

export const reviewCard = (
now: DateTime,
interval: number | undefined,
interval: number | undefined = startInterval,
reviewOutcome: ReviewOutcome,
easeFactor: number | undefined = startEaseFactor,
isInterrupted = false,
): Result => {
let calculatedInterval = interval === undefined ? startInterval : interval;

if (reviewOutcome === "correct") {
if (calculatedInterval === 0) {
calculatedInterval = startInterval;
} else {
calculatedInterval *= easeFactor;
}
interval = interval === 0 ? startInterval : interval * easeFactor;
easeFactor = Math.min(easeFactor + easeFactorIncrement, startEaseFactor);
} else if (reviewOutcome === "wrong") {
calculatedInterval = isInterrupted ? 0 : startInterval;
easeFactor = Math.max(easeFactor - easeFactorDecrement, minimumEaseFactor);
interval = isInterrupted ? 0 : startInterval;
}

const nextReviewDate = now.plus({ day: calculatedInterval });

return {
nextReviewDate,
interval: parseFloat(calculatedInterval.toFixed(2)),
nextReviewDate: now.plus({ day: interval }),
easeFactor: parseFloat(easeFactor.toFixed(2)),
interval: parseFloat(interval.toFixed(2)),
};
};

0 comments on commit 35c4bba

Please sign in to comment.