From 4052a40857d6a2c571c4041830361fbf4cd42662 Mon Sep 17 00:00:00 2001 From: Newdea <9208450+Newdea@users.noreply.github.com> Date: Mon, 12 Feb 2024 22:03:03 +0800 Subject: [PATCH] add reschedule and postpone --- src/algorithms/algorithms.ts | 9 ----- src/algorithms/algorithms_switch.ts | 2 +- src/algorithms/anki.ts | 2 +- src/algorithms/balance/balance.ts | 35 +++++++++++++--- src/algorithms/balance/postpone.ts | 60 ++++++++++++++++++++++++++++ src/algorithms/balance/reschedule.ts | 54 +++++++++++++++++++++++++ src/algorithms/fsrs.ts | 16 -------- src/algorithms/scheduling_default.ts | 10 ++--- src/algorithms/supermemo.ts | 1 - src/commands.ts | 54 +++++++++++++++++++++++++ src/dataStore/repetitionItem.ts | 55 +++++++++++++++++++++++-- src/main.ts | 9 +---- tests/unit/data.test.ts | 3 +- 13 files changed, 260 insertions(+), 50 deletions(-) create mode 100644 src/algorithms/balance/postpone.ts create mode 100644 src/algorithms/balance/reschedule.ts diff --git a/src/algorithms/algorithms.ts b/src/algorithms/algorithms.ts index 8f83597b..ba7d1a67 100644 --- a/src/algorithms/algorithms.ts +++ b/src/algorithms/algorithms.ts @@ -11,7 +11,6 @@ export enum algorithmNames { export abstract class SrsAlgorithm { settings: unknown; // plugin: SRPlugin; - private dueDates: { [type: string]: Record }; public static instance: SrsAlgorithm; public static getInstance(): SrsAlgorithm { @@ -27,14 +26,6 @@ export abstract class SrsAlgorithm { // this.plugin = plugin; SrsAlgorithm.instance = this; } - setDueDates(notedueDates: Record, carddueDates: Record) { - this.dueDates = {}; - this.dueDates[RPITEMTYPE.NOTE] = notedueDates; - this.dueDates[RPITEMTYPE.CARD] = carddueDates; - } - getDueDates(itemType: string) { - return this.dueDates && itemType in this.dueDates ? this.dueDates[itemType] : undefined; - } abstract defaultSettings(): unknown; abstract defaultData(): unknown; diff --git a/src/algorithms/algorithms_switch.ts b/src/algorithms/algorithms_switch.ts index 829cb5a4..1631e095 100644 --- a/src/algorithms/algorithms_switch.ts +++ b/src/algorithms/algorithms_switch.ts @@ -38,7 +38,7 @@ export async function algorithmSwitchData( try { const algo = algorithms[toAlgo]; algo.updateSettings(plugin.data.settings.algorithmSettings[toAlgo]); - algo.setDueDates(plugin.noteStats.delayedDays.dict, plugin.cardStats.delayedDays.dict); + // algo.setDueDates(plugin.noteStats.delayedDays.dict, plugin.cardStats.delayedDays.dict); algo.importer(fromAlgo, items); if (toAlgo === algorithmNames.Fsrs) { store.data.items.find((item) => { diff --git a/src/algorithms/anki.ts b/src/algorithms/anki.ts index f044d74b..6d513b29 100644 --- a/src/algorithms/anki.ts +++ b/src/algorithms/anki.ts @@ -114,7 +114,7 @@ export class AnkiAlgorithm extends SrsAlgorithm { data.ease = MiscUtils.fixed(data.ease, 3); data.iteration += 1; - nextInterval = balance(nextInterval, this.getDueDates(item.itemType)); + // nextInterval = balance(nextInterval, item.itemType); data.lastInterval = nextInterval; return { diff --git a/src/algorithms/balance/balance.ts b/src/algorithms/balance/balance.ts index 18b1abfe..838c8d3c 100644 --- a/src/algorithms/balance/balance.ts +++ b/src/algorithms/balance/balance.ts @@ -1,4 +1,20 @@ import { Notice } from "obsidian"; +import { RPITEMTYPE } from "src/dataStore/repetitionItem"; + +let dueDatesDict: { [type: string]: Record }; + +export function setDueDates( + notedueDates: Record, + carddueDates: Record, +) { + dueDatesDict = {}; + dueDatesDict[RPITEMTYPE.NOTE] = notedueDates; + dueDatesDict[RPITEMTYPE.CARD] = carddueDates; +} + +function getDueDates(itemType: string) { + return dueDatesDict && itemType in dueDatesDict ? dueDatesDict[itemType] : undefined; +} /** * balance review counts in a day, return new interval day. @@ -9,7 +25,8 @@ import { Notice } from "obsidian"; */ export function balance( interval: number, - dueDates: Record, + type: RPITEMTYPE, + // dueDates: Record, maximumInterval: number = 36525, lowestCount: number = 10, tolerance: number = 5, @@ -17,6 +34,7 @@ export function balance( // replaces random fuzz with load balancing over the fuzz interval const beforeIntvl = interval; let isChange = false; + let dueDates = dueDatesDict[type]; if (dueDates !== undefined) { interval = Math.round(interval); // const due = window.moment().add(interval,"days"); @@ -26,11 +44,8 @@ export function balance( dueDates[interval] = 0; } else if (dueDates[interval] >= lowestCount) { // disable fuzzing for small intervals - if (interval > 4) { - let fuzz = 0; - if (interval < 7) fuzz = 1; - else if (interval < 30) fuzz = Math.max(2, Math.floor(interval * 0.15)); - else fuzz = Math.max(4, Math.floor(interval * 0.05)); + if (interval >= 1) { + let fuzz = getFuzz(interval); const originalInterval = interval; outer: for (let i = 1; i <= fuzz; i++) { @@ -62,3 +77,11 @@ export function balance( } return interval; } + +function getFuzz(interval: number) { + let fuzz = 0; + if (interval < 7) fuzz = 1; + else if (interval < 30) fuzz = Math.max(2, Math.floor(interval * 0.15)); + else fuzz = Math.max(4, Math.floor(interval * 0.05)); + return fuzz; +} diff --git a/src/algorithms/balance/postpone.ts b/src/algorithms/balance/postpone.ts new file mode 100644 index 00000000..c126e828 --- /dev/null +++ b/src/algorithms/balance/postpone.ts @@ -0,0 +1,60 @@ +import { RepetitionItem } from "src/dataStore/repetitionItem"; +import { DateUtils, debug } from "src/util/utils_recall"; + +export function postponeItems( + items: RepetitionItem[], + cnt?: number, + days?: number, +): RepetitionItem[] { + const now = Date.now(); + const newdue: number = days ? now + days * DateUtils.DAYS_TO_MILLIS : undefined; + const fltItems = items + .filter((item) => item.isTracked && item.nextReview < DateUtils.StartofToday) + .sort((a, b) => currentRetention(a) - currentRetention(b)) + // https://github.com/open-spaced-repetition/fsrs4anki-helper/blob/58bcfcf8b5eeb60835c5cbde1d0d0ef769af62b0/schedule/postpone.py#L73 + .filter((item) => { + // currentR>0.65 + // const rate = (1 / currentRetention(item) - 1) / (1 / 0.9 - 1) - 1; + // const rate = currentRetention(item) / 0.9 - 1; + console.debug("current R:", currentRetention(item), 1 / currentRetention(item) - 1); + return currentRetention(item) > 0.65; + }); + const safe_cnt = fltItems.length; + debug("postpone", 0, { safe_cnt }); + postpone(fltItems, newdue); + return items; +} + +function elapsed_days(item: RepetitionItem) { + const delay = (Date.now() - item.nextReview) / DateUtils.DAYS_TO_MILLIS; + return item.interval + delay; +} +function currentRetention(item: RepetitionItem) { + // from fsrs.js repeat retrievability + return Math.pow(1 + elapsed_days(item) / (9 * item.interval), -1); +} + +function postpone(items: RepetitionItem[], newdue?: number): RepetitionItem[] { + let cnt = 0; + items.map((item) => { + let newitvl: number, + olastview = item.hasDue ? item.nextReview - item.interval*DateUtils.DAYS_TO_MILLIS : Date.now(); + + // reschedule, request Retention=0.9 + // let interval = item.interval * 9 * (1 / 0.9 - 1); + // newitvl = Math.min(Math.max(Math.round(interval), 1), 3650); + + const delay = (Date.now() - olastview) / DateUtils.DAYS_TO_MILLIS - item.interval; + newitvl = Math.min( + Math.max(1, Math.ceil(item.interval * (1.05 + 0.05 * Math.random())) + delay), + 36500, + ); + // newdue = newdue ? newdue : Date.now() + newitvl * DateUtils.DAYS_TO_MILLIS; + if (newitvl !== item.interval) { + cnt++; + item.updateDueInterval(newitvl, newdue); + } + }); + debug("postpone", 0, { cnt }); + return items; +} diff --git a/src/algorithms/balance/reschedule.ts b/src/algorithms/balance/reschedule.ts new file mode 100644 index 00000000..2c2c1ac6 --- /dev/null +++ b/src/algorithms/balance/reschedule.ts @@ -0,0 +1,54 @@ +import { RepetitionItem } from "src/dataStore/repetitionItem"; +import { DateUtils, debug } from "src/util/utils_recall"; +import { SrsAlgorithm } from "../algorithms"; +import { FsrsAlgorithm, FsrsData } from "../fsrs"; + +export function reschedule(items: RepetitionItem[]): RepetitionItem[] { + let reCnt = 0; + console.group(`reschedule`); + if (items[0].isFsrs) { + const result = reschedule_fsrs(items); + reCnt = result.reCnt; + } else { + reCnt=reschedule_default(items).reCnt; + } + console.groupEnd(); + debug("reschedule", 0, { items, reCnt }); + return items; +} + +function reschedule_default(items: RepetitionItem[]) { + let reCnt = 0; + + items.map((item) => { + let newitvl: number; + + let interval = item.interval * 9 * (1 / 0.9 - 1); + newitvl = Math.min(Math.max(Math.round(interval), 1), 3650); + if (newitvl !== item.interval) { + reCnt++; + item.updateDueInterval(newitvl); + } + }); + + // debug("reschedule", 0, { items, reCnt }); + return { items, reCnt }; +} + +function reschedule_fsrs(items: RepetitionItem[]) { + let reCnt = 0; + let fsrs = (SrsAlgorithm.getInstance() as FsrsAlgorithm).fsrs; + + items.map((item) => { + let newitvl: number; + if (!item.isTracked) return; + const data = item.data as FsrsData; + newitvl = fsrs.next_interval(data.stability); + if (newitvl !== data.scheduled_days) { + reCnt++; + item.updateDueInterval(newitvl); + } + }); + + return { items, reCnt }; +} diff --git a/src/algorithms/fsrs.ts b/src/algorithms/fsrs.ts index 37ec67a8..15dbde5d 100644 --- a/src/algorithms/fsrs.ts +++ b/src/algorithms/fsrs.ts @@ -8,7 +8,6 @@ import { t } from "src/lang/helpers"; import deepcopy from "deepcopy"; import { AnkiData } from "./anki"; import { Rating, ReviewLog } from "fsrs.js"; -import { balance } from "./balance/balance"; import { RepetitionItem, ReviewResult } from "src/dataStore/repetitionItem"; // https://github.com/mgmeyers/obsidian-kanban/blob/main/src/Settings.ts @@ -120,8 +119,6 @@ export class FsrsAlgorithm extends SrsAlgorithm { calcAllOptsIntervals(item: RepetitionItem) { const data = item.data as FsrsData; - data.due = new Date(data.due); - data.last_review = new Date(data.last_review); const card = deepcopy(data); const now = new Date(); const scheduling_cards = this.fsrs.repeat(card, now); @@ -143,8 +140,6 @@ export class FsrsAlgorithm extends SrsAlgorithm { log: boolean = true, ): ReviewResult { let data = item.data as FsrsData; - data.due = new Date(data.due); - data.last_review = new Date(data.last_review); const response = FsrsOptions.indexOf(optionStr) + 1; let correct = true; @@ -169,12 +164,6 @@ export class FsrsAlgorithm extends SrsAlgorithm { data.difficulty = MiscUtils.fixed(data.difficulty, 5); data.elapsed_days = MiscUtils.fixed(data.elapsed_days, 3); - //Get the due date for card: - // const due = card.due; - - //Get the state for card: - // state = card.state; - // Get the review log after rating : if (log) { const review_log = scheduling_cards[response].review_log; @@ -182,11 +171,6 @@ export class FsrsAlgorithm extends SrsAlgorithm { } let nextInterval = data.due.valueOf() - data.last_review.valueOf(); - // not sure should use balance or not. - let days = nextInterval / DateUtils.DAYS_TO_MILLIS; - days = balance(days, this.getDueDates(item.itemType), this.settings.maximum_interval); - nextInterval = days * DateUtils.DAYS_TO_MILLIS; - data.due = new Date(nextInterval + now.getTime()); return { correct, diff --git a/src/algorithms/scheduling_default.ts b/src/algorithms/scheduling_default.ts index 458f1ebf..3c14a0b7 100644 --- a/src/algorithms/scheduling_default.ts +++ b/src/algorithms/scheduling_default.ts @@ -66,7 +66,7 @@ export function schedule( // replaces random fuzz with load balancing over the fuzz interval - interval = balance(interval, dueDates, settingsObj.maximumInterval); + // interval = balance(interval, dueDates, settingsObj.maximumInterval); return { interval: Math.round(interval * 10) / 10, ease }; } @@ -102,12 +102,12 @@ export class DefaultAlgorithm extends SrsAlgorithm { const now: number = Date.now(); const delayBeforeReview = due === 0 ? 0 : now - due; //just in case. // console.log("item.data:", item.data); - const dueDatesNotesorCards = this.getDueDates(item.itemType); + // const dueDatesNotesorCards = this.getDueDates(item.itemType); const intvls: number[] = []; this.srsOptions().forEach((opt, ind) => { const dataCopy = deepcopy(data); - const dueDates = deepcopy(dueDatesNotesorCards); + // const dueDates = deepcopy(dueDatesNotesorCards); const schedObj: Record = schedule( ind, @@ -115,7 +115,7 @@ export class DefaultAlgorithm extends SrsAlgorithm { dataCopy.ease, delayBeforeReview, this.settings, - dueDates, + // dueDates, ); const nextInterval = schedObj.interval; intvls.push(nextInterval); @@ -148,7 +148,7 @@ export class DefaultAlgorithm extends SrsAlgorithm { data.ease, delayBeforeReview, this.settings, - this.getDueDates(item.itemType), + // this.getDueDates(item.itemType), ); const nextReview = schedObj.interval; diff --git a/src/algorithms/supermemo.ts b/src/algorithms/supermemo.ts index f6639739..1ba2f4dd 100644 --- a/src/algorithms/supermemo.ts +++ b/src/algorithms/supermemo.ts @@ -86,7 +86,6 @@ export class Sm2Algorithm extends SrsAlgorithm { } data.ease = MiscUtils.fixed(data.ease, 3); - nextReview = balance(nextReview, this.getDueDates(item.itemType)); data.lastInterval = nextReview; // console.log("item.data:", item.data); // console.log("smdata:", data); diff --git a/src/commands.ts b/src/commands.ts index f490ffd5..f6a23dd4 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,6 +4,9 @@ import { ReviewNote } from "src/reviewNote/review-note"; import { ItemInfoModal } from "src/gui/info"; import { Queue } from "./dataStore/queue"; import { debug } from "./util/utils_recall"; +import { postponeItems } from "./algorithms/balance/postpone"; +import { ReviewDeckSelectionModal } from "./gui/reviewDeckSelectionModal"; +import { reschedule } from "./algorithms/balance/reschedule"; export default class Commands { plugin: ObsidianSrsPlugin; @@ -209,5 +212,56 @@ export default class Commands { plugin.store.verifyItems(); }, }); + + plugin.addCommand({ + id: "reschedule", + name: "Reschedule", + callback: () => { + reschedule( + plugin.store.items.filter((item) => item.hasDue && item.isTracked), + ); + }, + }); + + plugin.addCommand({ + id: "postpone-cards", + name: "Postpone cards", + callback: () => { + postponeItems(plugin.store.items.filter((item) => item.isCard && item.isTracked)); + }, + }); + plugin.addCommand({ + id: "postpone-notes", + name: "Postpone notes", + callback: () => { + postponeItems(plugin.store.items.filter((item) => !item.isCard && item.isTracked)); + }, + }); + plugin.addCommand({ + id: "postpone-all", + name: "Postpone All", + callback: () => { + postponeItems(plugin.store.items.filter((item) => item.isTracked)); + }, + }); + // plugin.addCommand({ + // id: "postpone-manual", + // name: "Postpone after x days(wip)", + // callback: () => { + // return; + // const reviewDeckNames: string[] = Object.keys(plugin.reviewDecks); + // const cardItems = plugin.store.items.filter( + // (item) => item.isCard && item.isTracked, + // ); + // const cardDeckNames: string[] = cardItems.map((item) => item.deckName).unique(); + // const deckSelectionModal = new ReviewDeckSelectionModal( + // plugin.app, + // reviewDeckNames, + // ); + // deckSelectionModal.submitCallback = (deck: string) => plugin.reviewNextNote(deck); + // deckSelectionModal.open(); + // postponeItems(plugin.store.items.filter((item) => item.isTracked)); + // }, + // }); } } diff --git a/src/dataStore/repetitionItem.ts b/src/dataStore/repetitionItem.ts index 37ee6a69..2d362176 100644 --- a/src/dataStore/repetitionItem.ts +++ b/src/dataStore/repetitionItem.ts @@ -1,4 +1,5 @@ import { AnkiData } from "src/algorithms/anki"; +import { balance } from "src/algorithms/balance/balance"; import { FsrsData } from "src/algorithms/fsrs"; import { DateUtils } from "src/util/utils_recall"; @@ -66,6 +67,11 @@ export class RepetitionItem { static create(item: RepetitionItem) { const newItem = new RepetitionItem(); Object.assign(newItem, item); + if (newItem.isFsrs) { + let data = item.data as FsrsData; + data.due = new Date(data.due); + data.last_review = new Date(data.last_review); + } return newItem; } @@ -93,6 +99,7 @@ export class RepetitionItem { */ reviewUpdate(result: ReviewResult) { this.nextReview = DateUtils.fromNow(result.nextReview).getTime(); + this.updateDueInterval(this.interval); this.timesReviewed += 1; if (result.correct) { this.timesCorrect += 1; @@ -130,7 +137,7 @@ export class RepetitionItem { return sched; } - private isFsrs(): boolean { + get isFsrs(): boolean { return Object.prototype.hasOwnProperty.call(this.data, "state"); } @@ -140,6 +147,7 @@ export class RepetitionItem { const due = window.moment(this.nextReview); sched[1] = due.format("YYYY-MM-DD"); + sched[2] = parseFloat(sched[2]).toFixed(0); return sched; } @@ -167,10 +175,51 @@ export class RepetitionItem { } get interval(): number { - return Number(this.getSched()[2]); + const sched = this.getSched(); + return sched ? Number(sched[2]) : 0; } + + updateDueInterval(newitvl: number, newdue?: number) { + // 240212-interval will be used to calc current retention, shoudn't update. + const now = Date.now(); + const enableBalance = newdue == undefined; + let oitvl = this.interval, + odue = this.hasDue ? this.nextReview : now; + + if (this.isFsrs) { + const data = this.data as FsrsData; + + newdue = newdue + ? newdue + : // : odue - (data.scheduled_days - newitvl) * DateUtils.DAYS_TO_MILLIS; + data.last_review.getTime() + newitvl * DateUtils.DAYS_TO_MILLIS; + // data.scheduled_days = newitvl; + data.due = new Date(newdue); + } else { + newdue = newdue ? newdue : odue - (this.interval - newitvl) * DateUtils.DAYS_TO_MILLIS; + // (this.data as AnkiData).lastInterval = newitvl; + } + + if (enableBalance) { + let days = Math.max(0, newdue - now) / DateUtils.DAYS_TO_MILLIS; + days = balance(days, this.itemType); + let nextInterval = days * DateUtils.DAYS_TO_MILLIS; + newdue = nextInterval + now; + } + + console.debug({ + oitvl, + newitvl, + odue: new Date(this.nextReview).toISOString(), + ndue: new Date(newdue).toISOString(), + }); + this.isFsrs ? ((this.data as FsrsData).due = new Date(newdue)) : null; + this.nextReview = newdue; + } + get ease(): number { - return Number(this.getSched()[3]); + const sched = this.getSched(); + return sched ? Number(sched[2]) : 0; } /** diff --git a/src/main.ts b/src/main.ts index bcd14c79..49f2cc01 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,6 +59,7 @@ import { ItemToDecks } from "./dataStore/itemToDecks"; import { LinkRank } from "src/algorithms/priorities/linkPageranks"; import { Queue } from "./dataStore/queue"; import { ReviewDeckSelectionModal } from "./gui/reviewDeckSelectionModal"; +import { setDueDates } from "./algorithms/balance/balance"; interface PluginData { settings: SRSettings; @@ -429,9 +430,6 @@ export default class SRPlugin extends Plugin { // Reviewable cards are all except those with the "edit later" tag this.deckTree = DeckTreeFilter.filterForReviewableCards(fullDeckTree); - // Reviewable cards are all except those with the "edit later" tag - this.deckTree = DeckTreeFilter.filterForReviewableCards(fullDeckTree); - // sort the deck names this.deckTree.sortSubdecksList(); this.remainingDeckTree = DeckTreeFilter.filterForRemainingCards( @@ -547,10 +545,7 @@ export default class SRPlugin extends Plugin { reviewDeck.sortNotes(this.linkRank.pageranks); }); - this.algorithm.setDueDates( - this.noteStats.delayedDays.dict, - this.cardStats.delayedDays.dict, - ); + setDueDates(this.noteStats.delayedDays.dict, this.cardStats.delayedDays.dict); this.updateStatusBar(); diff --git a/tests/unit/data.test.ts b/tests/unit/data.test.ts index 0dfe7e2e..5efabe4d 100644 --- a/tests/unit/data.test.ts +++ b/tests/unit/data.test.ts @@ -1,4 +1,5 @@ import { SrsAlgorithm, algorithmNames } from "src/algorithms/algorithms"; +import { setDueDates } from "src/algorithms/balance/balance"; import { DefaultAlgorithm } from "src/algorithms/scheduling_default"; import { DataStore } from "src/dataStore/data"; import { DataLocation } from "src/dataStore/dataLocation"; @@ -51,7 +52,7 @@ export class SampleDataStore { noteStats.updateStats(item); } }); - algo.setDueDates(noteStats.delayedDays.dict, cardStats.delayedDays.dict); + setDueDates(noteStats.delayedDays.dict, cardStats.delayedDays.dict); const size = store.itemSize; arr.map((_v) => { store.reviewId(