From 129e4dc4e99625ee2dd2ee627fab866f2993e3d7 Mon Sep 17 00:00:00 2001 From: nkq <52522174+ningkaiqiang@users.noreply.github.com> Date: Tue, 29 Oct 2024 14:36:38 +0900 Subject: [PATCH] feat: keep up with ts-fsrs, update algorithm version to 5.0 (#15) - Delete original source file - Implemented FSRS 5.0 - Add tests --- Package.swift | 23 +- Sources/FSRS/Algorithm/FSRS.swift | 431 ++++++++++++ Sources/FSRS/Algorithm/FSRSAlgorithm.swift | 219 +++++++ Sources/FSRS/FSRS.swift | 380 ----------- Sources/FSRS/Helper/FSRSAlea.swift | 140 ++++ Sources/FSRS/Helper/FSRSHelper.swift | 151 +++++ Sources/FSRS/Models/FSRSDefaults.swift | 79 +++ Sources/FSRS/Models/FSRSModels.swift | 188 ++++++ Sources/FSRS/Models/FSRSTypes.swift | 77 +++ .../FSRS/Scheduler/AbstractScheduler.swift | 89 +++ Sources/FSRS/Scheduler/BasicScheduler.swift | 207 ++++++ Sources/FSRS/Scheduler/FSRSReschedule.swift | 193 ++++++ .../FSRS/Scheduler/LongTermScheduler.swift | 175 +++++ .../FSRSAbstractSchedulerTests.swift | 44 ++ Tests/FSRSTests/FSRSAleaTests.swift | 119 ++++ Tests/FSRSTests/FSRSBasicSchedulerTests.swift | 111 ++++ Tests/FSRSTests/FSRSDefaultTests.swift | 50 ++ Tests/FSRSTests/FSRSElapsedDaysTests.swift | 75 +++ Tests/FSRSTests/FSRSForgetTests.swift | 83 +++ Tests/FSRSTests/FSRSFuzzSameSeedTests.swift | 86 +++ .../FSRSLongTermSchedulerTests.swift | 369 +++++++++++ Tests/FSRSTests/FSRSReschduleTests.swift | 615 ++++++++++++++++++ Tests/FSRSTests/FSRSRollbackTests.swift | 62 ++ .../FSRSTests/FSRSShowDiffMessageTests.swift | 134 ++++ Tests/FSRSTests/FSRSTests.swift | 102 --- Tests/FSRSTests/FSRSV5Tests.swift | 209 ++++++ 26 files changed, 3921 insertions(+), 490 deletions(-) create mode 100644 Sources/FSRS/Algorithm/FSRS.swift create mode 100644 Sources/FSRS/Algorithm/FSRSAlgorithm.swift delete mode 100644 Sources/FSRS/FSRS.swift create mode 100644 Sources/FSRS/Helper/FSRSAlea.swift create mode 100644 Sources/FSRS/Helper/FSRSHelper.swift create mode 100644 Sources/FSRS/Models/FSRSDefaults.swift create mode 100644 Sources/FSRS/Models/FSRSModels.swift create mode 100644 Sources/FSRS/Models/FSRSTypes.swift create mode 100644 Sources/FSRS/Scheduler/AbstractScheduler.swift create mode 100644 Sources/FSRS/Scheduler/BasicScheduler.swift create mode 100644 Sources/FSRS/Scheduler/FSRSReschedule.swift create mode 100644 Sources/FSRS/Scheduler/LongTermScheduler.swift create mode 100644 Tests/FSRSTests/FSRSAbstractSchedulerTests.swift create mode 100644 Tests/FSRSTests/FSRSAleaTests.swift create mode 100644 Tests/FSRSTests/FSRSBasicSchedulerTests.swift create mode 100644 Tests/FSRSTests/FSRSDefaultTests.swift create mode 100644 Tests/FSRSTests/FSRSElapsedDaysTests.swift create mode 100644 Tests/FSRSTests/FSRSForgetTests.swift create mode 100644 Tests/FSRSTests/FSRSFuzzSameSeedTests.swift create mode 100644 Tests/FSRSTests/FSRSLongTermSchedulerTests.swift create mode 100644 Tests/FSRSTests/FSRSReschduleTests.swift create mode 100644 Tests/FSRSTests/FSRSRollbackTests.swift create mode 100644 Tests/FSRSTests/FSRSShowDiffMessageTests.swift delete mode 100644 Tests/FSRSTests/FSRSTests.swift create mode 100644 Tests/FSRSTests/FSRSV5Tests.swift diff --git a/Package.swift b/Package.swift index 1622a05..b59fbdb 100644 --- a/Package.swift +++ b/Package.swift @@ -1,20 +1,27 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( - name: "fsrs", + name: "FSRS", platforms: [ .macOS(.v10_13), .iOS(.v14), ], products: [ - .library(name: "FSRS", targets: ["FSRS"]), + .library( + name: "FSRS", + targets: ["FSRS"]), ], targets: [ - .target(name: "FSRS"), - .testTarget( - name: "FSRSTests", - dependencies: [ "FSRS" ] - ), + .target( + name: "FSRS", + path: "Sources/FSRS/" + ), + .testTarget( + name: "FSRSTests", + dependencies: ["FSRS"], + path: "./Tests/FSRSTests" + ), ] ) diff --git a/Sources/FSRS/Algorithm/FSRS.swift b/Sources/FSRS/Algorithm/FSRS.swift new file mode 100644 index 0000000..46b08fa --- /dev/null +++ b/Sources/FSRS/Algorithm/FSRS.swift @@ -0,0 +1,431 @@ +// +// FSRS.swift +// +// Created by nkq on 10/13/24. +// + +import Foundation + +public class FSRS: FSRSAlgorithm { + + override func processparameters(_ parameters: FSRSParameters) { + let parameters = defaults.generatorParameters(props: parameters) + if parameters.requestRetention.isFinite { + do { + intervalModifier = try calculateIntervalModifier(requestRetention: parameters.requestRetention) + } catch { + print(error.localizedDescription) + } + } + if parameters != self.parameters { + self.parameters = parameters + } + } + + override init(parameters: FSRSParameters) { + super.init(parameters: parameters) + } + + /** + * Display the collection of cards and logs for the four scenarios after scheduling the card at the current time. + * @param card Card to be processed + * @param now Current time or scheduled time + * @param afterHandler Convert the result to another type. (Optional) + * @example + * ``` + * const card: Card = createEmptyCard(new Date()); + * const f = fsrs(); + * const recordLog = f.repeat(card, new Date()); + * ``` + * @example + * ``` + * interface RevLogUnchecked + * extends Omit { + * cid: string; + * due: Date | number; + * state: StateType; + * review: Date | number; + * rating: RatingType; + * } + * + * interface RepeatRecordLog { + * card: CardUnChecked; //see method: createEmptyCard + * log: RevLogUnchecked; + * } + * + * function repeatAfterHandler(recordLog: RecordLog) { + * const record: { [key in Grade]: RepeatRecordLog } = {} as { + * [key in Grade]: RepeatRecordLog; + * }; + * for (const grade of Grades) { + * record[grade] = { + * card: { + * ...(recordLog[grade].card as Card & { cid: string }), + * due: recordLog[grade].card.due.getTime(), + * state: State[recordLog[grade].card.state] as StateType, + * last_review: recordLog[grade].card.last_review + * ? recordLog[grade].card.last_review!.getTime() + * : null, + * }, + * log: { + * ...recordLog[grade].log, + * cid: (recordLog[grade].card as Card & { cid: string }).cid, + * due: recordLog[grade].log.due.getTime(), + * review: recordLog[grade].log.review.getTime(), + * state: State[recordLog[grade].log.state] as StateType, + * rating: Rating[recordLog[grade].log.rating] as RatingType, + * }, + * }; + * } + * return record; + * } + * const card: Card = createEmptyCard(new Date(), cardAfterHandler); //see method: createEmptyCard + * const f = fsrs(); + * const recordLog = f.repeat(card, new Date(), repeatAfterHandler); + * ``` + */ + func `repeat`( + card: Card, + now: Date, + _ completion: ((_ log: IPreview) -> IPreview)? = nil + ) -> IPreview { + let obj = params.enableShortTerm + ? BasicScheduler(card: card, reviewTime: now, algorithm: self) + : LongTermScheduler(card: card, reviewTime: now, algorithm: self) + let log = obj.preview + if let completion = completion { + return completion(log) + } else { + return log + } + } + + /** + * Display the collection of cards and logs for the card scheduled at the current time, after applying a specific grade rating. + * @param card Card to be processed + * @param now Current time or scheduled time + * @param grade Rating of the review (Again, Hard, Good, Easy) + * @param afterHandler Convert the result to another type. (Optional) + * @example + * ``` + * const card: Card = createEmptyCard(new Date()); + * const f = fsrs(); + * const recordLogItem = f.next(card, new Date(), Rating.Again); + * ``` + * @example + * ``` + * interface RevLogUnchecked + * extends Omit { + * cid: string; + * due: Date | number; + * state: StateType; + * review: Date | number; + * rating: RatingType; + * } + * + * interface NextRecordLog { + * card: CardUnChecked; //see method: createEmptyCard + * log: RevLogUnchecked; + * } + * + function nextAfterHandler(recordLogItem: RecordLogItem) { + const recordItem = { + card: { + ...(recordLogItem.card as Card & { cid: string }), + due: recordLogItem.card.due.getTime(), + state: State[recordLogItem.card.state] as StateType, + last_review: recordLogItem.card.last_review + ? recordLogItem.card.last_review!.getTime() + : null, + }, + log: { + ...recordLogItem.log, + cid: (recordLogItem.card as Card & { cid: string }).cid, + due: recordLogItem.log.due.getTime(), + review: recordLogItem.log.review.getTime(), + state: State[recordLogItem.log.state] as StateType, + rating: Rating[recordLogItem.log.rating] as RatingType, + }, + }; + return recordItem + } + * const card: Card = createEmptyCard(new Date(), cardAfterHandler); //see method: createEmptyCard + * const f = fsrs(); + * const recordLogItem = f.repeat(card, new Date(), Rating.Again, nextAfterHandler); + * ``` + */ + func next( + card: Card, + now: Date, + grade: Rating, + completion: ((_ log: RecordLogItem) -> RecordLogItem)? = nil + ) throws -> RecordLogItem { + if grade == .manual { + throw FSRSError(.invalidRating, "Cannot review a manual rating") + } + let obj = params.enableShortTerm + ? BasicScheduler(card: card, reviewTime: now, algorithm: self) + : LongTermScheduler(card: card, reviewTime: now, algorithm: self) + let log = obj.review(grade) + if let completion = completion { + return completion(log) + } else { + return log + } + } + + /** + * Get the retrievability of the card + * @param card Card to be processed + * @param now Current time or scheduled time + * @param format default:true , Convert the result to another type. (Optional) + * @returns The retrievability of the card,if format is true, the result is a string, otherwise it is a number + */ + func getRetrievability( + card: Card, + now: Date = Date() + ) -> (string: String, number: Double) { + let processed = card.newCard + let time = processed.state != .new + ? max(Date.dateDiff(now: now, pre: processed.lastReview, unit: .days), 0) + : 0 + let retrievability = processed.state != .new + ? forgettingCurve(elapsedDays: time, stability: processed.stability.toFixedNumber(8)) + : 0 + return ("\((retrievability * 100).toFixed(2))%", retrievability) + } + + /** + * + * @param card Card to be processed + * @param log last review log + * @param afterHandler Convert the result to another type. (Optional) + * @example + * ``` + * const now = new Date(); + * const f = fsrs(); + * const emptyCardFormAfterHandler = createEmptyCard(now); + * const repeatFormAfterHandler = f.repeat(emptyCardFormAfterHandler, now); + * const { card, log } = repeatFormAfterHandler[Rating.Hard]; + * const rollbackFromAfterHandler = f.rollback(card, log); + * ``` + * + * @example + * ``` + * const now = new Date(); + * const f = fsrs(); + * const emptyCardFormAfterHandler = createEmptyCard(now, cardAfterHandler); //see method: createEmptyCard + * const repeatFormAfterHandler = f.repeat(emptyCardFormAfterHandler, now, repeatAfterHandler); //see method: fsrs.repeat() + * const { card, log } = repeatFormAfterHandler[Rating.Hard]; + * const rollbackFromAfterHandler = f.rollback(card, log, cardAfterHandler); + * ``` + */ + func rollback( + card: Card, + log: ReviewLog, + completion: ((Card) -> Card)? = nil + ) throws -> Card { + let processdCard = card.newCard + let processedLog = log.newLog + + guard processedLog.rating != .manual else { + throw FSRSError(.invalidRating, "Cannot rollback a manual rating") + } + var lastDue: Date, lastReview: Date?, lastLapses: Int + guard let state = processedLog.state else { + throw FSRSError(.invalidParam, "Rollback card must have a state") + } + switch state { + case .new: + guard let due = processedLog.due else { + throw FSRSError(.invalidParam, "Rollback card must have a due date") + } + lastDue = due + lastReview = nil + lastLapses = 0 + case .learning, .review, .relearning: + lastDue = processedLog.review + lastReview = processedLog.due + lastLapses = processdCard.lapses - ( + (processedLog.rating == .again && processedLog.state == .review) ? 1 : 0 + ) + } + var previousCard = processdCard.newCard + previousCard.due = lastDue + previousCard.stability = processedLog.stability ?? 0 + previousCard.difficulty = processedLog.difficulty ?? 0 + previousCard.elapsedDays = processedLog.lastElapsedDays + previousCard.scheduledDays = processedLog.scheduledDays + previousCard.reps = max(0, processdCard.reps - 1) + previousCard.lapses = max(0, lastLapses) + previousCard.state = state + previousCard.lastReview = lastReview + + if let completion = completion { + return completion(previousCard) + } else { + return previousCard + } + } + + /** + * + * @param card Card to be processed + * @param now Current time or scheduled time + * @param reset_count Should the review count information(reps,lapses) be reset. (Optional) + * @param afterHandler Convert the result to another type. (Optional) + * @example + * ``` + * const now = new Date(); + * const f = fsrs(); + * const emptyCard = createEmptyCard(now); + * const scheduling_cards = f.repeat(emptyCard, now); + * const { card, log } = scheduling_cards[Rating.Hard]; + * const forgetCard = f.forget(card, new Date(), true); + * ``` + * + * @example + * ``` + * interface RepeatRecordLog { + * card: CardUnChecked; //see method: createEmptyCard + * log: RevLogUnchecked; //see method: fsrs.repeat() + * } + * + * function forgetAfterHandler(recordLogItem: RecordLogItem): RepeatRecordLog { + * return { + * card: { + * ...(recordLogItem.card as Card & { cid: string }), + * due: recordLogItem.card.due.getTime(), + * state: State[recordLogItem.card.state] as StateType, + * last_review: recordLogItem.card.last_review + * ? recordLogItem.card.last_review!.getTime() + * : null, + * }, + * log: { + * ...recordLogItem.log, + * cid: (recordLogItem.card as Card & { cid: string }).cid, + * due: recordLogItem.log.due.getTime(), + * review: recordLogItem.log.review.getTime(), + * state: State[recordLogItem.log.state] as StateType, + * rating: Rating[recordLogItem.log.rating] as RatingType, + * }, + * }; + * } + * const now = new Date(); + * const f = fsrs(); + * const emptyCardFormAfterHandler = createEmptyCard(now, cardAfterHandler); //see method: createEmptyCard + * const repeatFormAfterHandler = f.repeat(emptyCardFormAfterHandler, now, repeatAfterHandler); //see method: fsrs.repeat() + * const { card } = repeatFormAfterHandler[Rating.Hard]; + * const forgetFromAfterHandler = f.forget(card, date_scheduler(now, 1, true), false, forgetAfterHandler); + * ``` + */ + func forget( + card: Card, + now: Date, + resetCount: Bool = false, + _ completion: ((_ recordLogItem: RecordLogItem) -> RecordLogItem)? = nil + ) -> RecordLogItem { + let processedCard = card.newCard + let scheduledDay = processedCard.state == .new + ? 0 + : Date.dateDiff(now: now, pre: processedCard.lastReview, unit: .days) + let forgetLog = ReviewLog( + rating: .manual, + state: processedCard.state, + due: processedCard.due, + stability: processedCard.stability, + difficulty: processedCard.difficulty, + elapsedDays: 0, + lastElapsedDays: processedCard.elapsedDays, + scheduledDays: scheduledDay, + review: now + ) + let forgetCard = Card( + due: now, + reps: resetCount ? 0 : processedCard.reps, + lapses: resetCount ? 0 : processedCard.lapses, + state: .new, + lastReview: processedCard.lastReview + ) + let log = RecordLogItem(card: forgetCard, log: forgetLog) + if let completion = completion { + return completion(log) + } else { + return log + } + } + + + /** + * Reschedules the current card and returns the rescheduled collections and reschedule item. + * + * @template T - The type of the record log item. + * @param {CardInput | Card} current_card - The current card to be rescheduled. + * @param {Array} reviews - The array of FSRSHistory objects representing the reviews. + * @param {Partial>} options - The optional reschedule options. + * @returns {IReschedule} - The rescheduled collections and reschedule item. + * + * @example + * ``` + const f = fsrs() + const grades: Grade[] = [Rating.Good, Rating.Good, Rating.Good, Rating.Good] + const reviews_at = [ + new Date(2024, 8, 13), + new Date(2024, 8, 13), + new Date(2024, 8, 17), + new Date(2024, 8, 28), + ] + + const reviews: FSRSHistory[] = [] + for (let i = 0; i < grades.length; i++) { + reviews.push({ + rating: grades[i], + review: reviews_at[i], + }) + } + + const results_short = scheduler.reschedule( + createEmptyCard(), + reviews, + { + skipManual: false, + } + ) + console.log(results_short) + * ``` + */ + func reschedule( + currentCard: Card, + reviews: [ReviewLog], + options: RescheduleOptions + ) throws -> IReschedule { + var reviews = reviews + if let sortOrder = options.reviewsOrderBy { + reviews.sort(by: sortOrder) + } + if options.skipManual { + reviews = reviews.filter({ $0.rating != .manual }) + } + let rescheduleSvc = FSRSReschedule(fsrs: self) + let items = try rescheduleSvc.reschedule( + currentCard: options.firstCard ?? FSRSDefaults().createEmptyCard(), + reviews: reviews + ) + + let curCard = currentCard.newCard + let manualItem = try rescheduleSvc.calculateManualRecord( + currentCard: curCard, + now: options.now, + recordLogItem: items.last ?? nil, + updateMemory: options.updateMemoryState + ) + if let handler = options.recordLogHandler { + return .init( + collections: items.map(handler), + rescheduleItem: manualItem == nil ? nil : handler(manualItem) + ) + } else { + return .init(collections: items, rescheduleItem: manualItem) + } + } +} diff --git a/Sources/FSRS/Algorithm/FSRSAlgorithm.swift b/Sources/FSRS/Algorithm/FSRSAlgorithm.swift new file mode 100644 index 0000000..7238ccd --- /dev/null +++ b/Sources/FSRS/Algorithm/FSRSAlgorithm.swift @@ -0,0 +1,219 @@ +// +// FSRSAlgorithm.swift +// +// Created by nkq on 10/14/24. +// + +import Foundation +/** + * @see https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm#fsrs-45 + */ +public class FSRSAlgorithm { + /** + * @default DECAY = -0.5 + */ + let decay = -0.5 + /** + * FACTOR = Math.pow(0.9, 1 / DECAY) - 1= 19 / 81 + * + * $$\text{FACTOR} = \frac{19}{81}$$ + * @default FACTOR = 19 / 81 + */ + let factor: Double = 19 / 81 + + let defaults = FSRSDefaults() + + internal var parameters: FSRSParameters + + var params: FSRSParameters { + get { + parameters + } + set { + processparameters(newValue) + } + } + + func processparameters(_ parameters: FSRSParameters) { + let parameters = defaults.generatorParameters(props: parameters) + if parameters.requestRetention.isFinite { + do { + intervalModifier = try calculateIntervalModifier( + requestRetention: parameters.requestRetention + ) + } catch { + print(error.localizedDescription) + } + + } + if parameters != self.parameters { + self.parameters = parameters + } + } + + var intervalModifier: Double = 1 + var seed: String? + + init(parameters: FSRSParameters) { + self.parameters = parameters + processparameters(parameters) + } + + /** + * @see https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm#fsrs-45 + * + * The formula used is: $$I(r,s) = (r^{\frac{1}{DECAY}} - 1) / FACTOR \times s$$ + * @param request_retention 0 Double { + guard requestRetention > 0 && requestRetention <= 1 else { + throw FSRSError(.invalidRetention, "Requested retention rate should be in the range (0,1]") + } + let result = (pow(requestRetention, 1 / decay) - 1.0) / factor + return result.toFixedNumber(8) + } + + /** + * The formula used is : + * $$ S_0(G) = w_{G-1}$$ + * $$S_0 = \max \lbrace S_0,0.1\rbrace $$ + + * @param g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] + * @return Stability (interval when R=90%) + */ + func initStability(g: Rating) -> Double { + max(parameters.w[g.rawValue - 1], 0.1) + } + + /** + * The formula used is : + * $$D_0(G) = w_4 - e^{(G-1) \cdot w_5} + 1 $$ + * $$D_0 = \min \lbrace \max \lbrace D_0(G),1 \rbrace,10 \rbrace$$ + * where the $$D_0(1)=w_4$$ when the first rating is good. + * + * @param {Grade} g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] + * @return {number} Difficulty $$D \in [1,10]$$ + */ + func initDifficulty(_ grade: Rating) -> Double { + constrainDifficulty( + r: parameters.w[4] - exp((Double(grade.rawValue) - 1) * parameters.w[5]) + 1 + ) + } + + func constrainDifficulty(r: Double) -> Double { + min(max(r.toFixedNumber(8), 1.0), 10.0) + } + + /** + * If fuzzing is disabled or ivl is less than 2.5, it returns the original interval. + * @param {number} ivl - The interval to be fuzzed. + * @param {number} elapsed_days t days since the last review + * @param {number} enable_fuzz - This adds a small random delay to the new interval time to prevent cards from sticking together and always being reviewed on the same day. + * @return {number} - The fuzzed interval. + **/ + func applyFuzz(ivl: Double, elapsedDays: Double) -> Int { + guard parameters.enableFuzz && ivl >= 2.5 else { return Int(round(ivl)) } + let genetaor = alea(seed: seed) + let fuzzFactor = genetaor.next() + let ivls = FSRSHelper.getFuzzRange( + interval: ivl, + elapsedDays: elapsedDays, + maximumInterval: parameters.maximumInterval + ) + return Int(floor(fuzzFactor * (ivls.maxIvl - ivls.minIvl + 1) + ivls.minIvl)) + } + + /** + * @see The formula used is : {@link FSRSAlgorithm.calculate_interval_modifier} + * @param {number} s - Stability (interval when R=90%) + * @param {number} elapsed_days t days since the last review + */ + func nextInterval(s: Double, elapsedDays: Double) -> Int { + let newInterval = min(max(1, round(s * intervalModifier)), parameters.maximumInterval) + return applyFuzz(ivl: newInterval, elapsedDays: elapsedDays) + } + + /** + * The formula used is : + * $$\text{next}_d = D - w_6 \cdot (g - 3)$$ + * $$D^\prime(D,R) = w_7 \cdot D_0(4) +(1 - w_7) \cdot \text{next}_d$$ + * @param {number} d Difficulty $$D \in [1,10]$$ + * @param {Grade} g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] + * @return {number} $$\text{next}_D$$ + */ + func nextDifficulty(d: Double, g: Rating) -> Double { + let nextD = d - (parameters.w[6] * Double(g.rawValue - 3)) + return constrainDifficulty(r: meanReversion(initValue: initDifficulty(.easy), current: nextD)) + } + + /** + * The formula used is : + * $$w_7 \cdot \text{init} +(1 - w_7) \cdot \text{current}$$ + * @param {number} init $$w_2 : D_0(3) = w_2 + (R-2) \cdot w_3= w_2$$ + * @param {number} current $$D - w_6 \cdot (R - 2)$$ + * @return {number} difficulty + */ + func meanReversion(initValue: Double, current: Double) -> Double { + (parameters.w[7] * initValue + (1 - parameters.w[7]) * current).toFixedNumber(8) + } + + func nextRecallStability(d: Double, s: Double, r: Double, g: Rating) -> Double { + let hardPenalty = g == .hard ? parameters.w[15] : 1 + let easyBound = g == .easy ? parameters.w[16] : 1 + return FSRSHelper.clamp( + s * ( + 1 + exp(parameters.w[8]) * (11 - d) * pow(s, -(parameters.w[9])) * + (exp((1 - r) * parameters.w[10]) - 1) * hardPenalty * easyBound + ), + 0.01, + 36500 + ) + .toFixedNumber(8) + } + + /** + * The formula used is : + * $$S^\prime_f(D,S,R) = w_{11}\cdot D^{-w_{12}}\cdot ((S+1)^{w_{13}}-1) \cdot e^{w_{14}\cdot(1-R)}$$ + * @param {number} d Difficulty D \in [1,10] + * @param {number} s Stability (interval when R=90%) + * @param {number} r Retrievability (probability of recall) + * @return {number} S^\prime_f new stability after forgetting + */ + func nextForgetStability(d: Double, s: Double, r: Double) -> Double { + let p1 = pow(d, -(parameters.w[12])) + let p2 = pow(s + 1, parameters.w[13]) - 1 + let p3 = exp((1 - r) * parameters.w[14]) + return FSRSHelper.clamp( + parameters.w[11] * p1 * p2 * p3, + 0.01, + 36500 + ).toFixedNumber(8) + } + + /** + * The formula used is : + * $$S^\prime_s(S,G) = S \cdot e^{w_{17} \cdot (G-3+w_{18})}$$ + * @param {number} s Stability (interval when R=90%) + * @param {Grade} g Grade (Rating[0.again,1.hard,2.good,3.easy]) + */ + func nextShortTermStability(s: Double, g: Rating) -> Double { + let part = Double(g.rawValue) - 3 + parameters.w[18] + return FSRSHelper.clamp( + s * exp(parameters.w[17] * part), + 0.01, + 36500 + ).toFixedNumber(8) + } + + /** + * The formula used is : + * $$R(t,S) = (1 + \text{FACTOR} \times \frac{t}{9 \cdot S})^{\text{DECAY}}$$ + * @param {number} elapsed_days t days since the last review + * @param {number} stability Stability (interval when R=90%) + * @return {number} r Retrievability (probability of recall) + */ + func forgettingCurve(elapsedDays: Double, stability: Double) -> Double { + pow(1 + ((factor * elapsedDays) / stability), decay).toFixedNumber(8) + } +} diff --git a/Sources/FSRS/FSRS.swift b/Sources/FSRS/FSRS.swift deleted file mode 100644 index 4d96a1c..0000000 --- a/Sources/FSRS/FSRS.swift +++ /dev/null @@ -1,380 +0,0 @@ -// -// FlashCardEngine.swift -// -// Created by Ben on 09/08/2023. -// - -import Foundation - -enum Constants { - static let secondsInMinute = 60.0 - static let secondsInHour = Self.secondsInMinute * 60 - static let secondsInDay = Self.secondsInHour * 24 -} - -public enum Status: Codable, Equatable { - case new, learning, review, relearning -} - -public enum Rating: Int, Codable, Equatable { - case again = 1, hard, good, easy -} - -public struct ReviewLog: Equatable, Codable { - public var rating: Rating - public var elapsedDays: Double - public var scheduledDays: Double - public var review: Date - public var status: Status - - public init( - rating: Rating, - elapsedDays: Double, - scheduledDays: Double, - review: Date, - status: Status - ) { - self.rating = rating - self.elapsedDays = elapsedDays - self.scheduledDays = scheduledDays - self.review = review - self.status = status - } -} - -public struct Card: Equatable, Codable { - public var due: Date - public var stability: Double - public var difficulty: Double - public var elapsedDays: Double - public var scheduledDays: Double - public var reps: Int - public var lapses: Int - public var status: Status - public var lastReview: Date - - public init( - due: Date = Date(), - stability: Double = 0, - difficulty: Double = 0, - elapsedDays: Double = 0, - scheduledDays: Double = 0, - reps: Int = 0, - lapses: Int = 0, - status: Status = .new, - lastReview: Date = Date() - ) { - self.due = due - self.stability = stability - self.difficulty = difficulty - self.elapsedDays = elapsedDays - self.scheduledDays = scheduledDays - self.reps = reps - self.lapses = lapses - self.status = status - self.lastReview = lastReview - } - - public func retrievability(for now: Date, params: Params) -> Double? { - guard status == .review else { return nil } - let elapsedDays = max(0, (now.timeIntervalSince(lastReview) / Constants.secondsInDay)) - return forgettingCurve(elapsedDays: elapsedDays, params: params) - } - - public func forgettingCurve(elapsedDays: Double, params p: Params) -> Double { - guard !stability.isZero else { return 0 } - return pow(1.0 + p.factor * elapsedDays / stability, p.decay) - } - - func printLog() { - do { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let data = try encoder.encode(self) - print(data) - } catch { - print("Error serializing JSON: \(error)") - } - } -} - -public struct SchedulingInfo { - public var card: Card - public var reviewLog: ReviewLog - - public init(card: Card, reviewLog: ReviewLog) { - self.card = card - self.reviewLog = reviewLog - } - - public init(rating: Rating, reference: Card, current: Card, review: Date) { - self.card = reference - self.reviewLog = ReviewLog( - rating: rating, - elapsedDays: reference.elapsedDays, - scheduledDays: current.scheduledDays, - review: review, - status: current.status - ) - } -} - -public struct SchedulingCards: Equatable, Codable { - public var again: Card - public var hard: Card - public var good: Card - public var easy: Card - - public init(card: Card) { - self.again = card - self.hard = card - self.good = card - self.easy = card - } - - public mutating func updateStatus(to status: Status) { - switch status { - case .new: - again.status = .learning - hard.status = .learning - good.status = .learning - easy.status = .review - case .learning, .relearning: - again.status = status - hard.status = status - good.status = .review - easy.status = .review - case .review: - again.status = .relearning - hard.status = .review - good.status = .review - easy.status = .review - again.lapses += 1 - } - } - - public mutating func schedule( - now: Date, - hardInterval: Double, - goodInterval: Double, - easyInterval: Double - ) { - again.scheduledDays = 0 - hard.scheduledDays = hardInterval - good.scheduledDays = goodInterval - easy.scheduledDays = easyInterval - - again.due = addTime(now, value: 5, unit: .minute) - if hardInterval > 0 { - hard.due = addTime(now, value: hardInterval, unit: .day) - } else { - hard.due = addTime(now, value: 10, unit: .minute) - } - good.due = addTime(now, value: goodInterval, unit: .day) - easy.due = addTime(now, value: easyInterval, unit: .day) - } - - public mutating func addTime(_ now: Date, value: Double, unit: Calendar.Component) -> Date { - var seconds = 1.0 - switch unit { - case .second: - seconds = 1.0 - case .minute: - seconds = Constants.secondsInMinute - case .hour: - seconds = Constants.secondsInHour - case .day: - seconds = Constants.secondsInDay - default: - assert(false) - } - - return Date(timeIntervalSinceReferenceDate: now.timeIntervalSinceReferenceDate + seconds * value) - } - - func recordLog(for card: Card, now: Date) -> [Rating: SchedulingInfo] { - [ - .again: SchedulingInfo(rating: .again, reference: again, current: card, review: now), - .hard: SchedulingInfo(rating: .hard, reference: hard, current: card, review: now), - .good: SchedulingInfo(rating: .good, reference: good, current: card, review: now), - .easy: SchedulingInfo(rating: .easy, reference: easy, current: card, review: now), - ] - } -} - -public struct Params { - public var decay: Double - public var factor: Double - public var requestRetention: Double - public var maximumInterval: Double - public var w: [Double] - - public init() { - self.decay = -0.5 - self.factor = pow(0.9, (1.0 / self.decay)) - 1.0 - self.requestRetention = 0.9 - self.maximumInterval = 36500 - self.w = [ - 0.4, // Initial Stability for Again - 0.6, // Initial Stability for Hard - 2.4, // Initial Stability for Good - 5.8, // Initial Stability for Easy - 4.93, - 0.94, - 0.86, - 0.01, - 1.49, - 0.14, - 0.94, - 2.18, - 0.05, - 0.34, - 1.26, - 0.29, - 2.61, - ] - } -} - -public struct FSRS { - public var p: Params - - public init(p: Params = Params()) { - self.p = p - } - - // Was repeat - public func `repeat`(card: Card, now: Date) -> [Rating: SchedulingInfo] { - var card = card - if card.status == .new { - card.elapsedDays = 0 - } else { - // Check this is positive... - card.elapsedDays = (now.timeIntervalSince(card.lastReview)) / Constants.secondsInDay - } - - print("Elapsed \(card.elapsedDays)") - card.lastReview = now - card.reps += 1 - - var s = SchedulingCards(card: card) - s.updateStatus(to: card.status) - - switch card.status { - case .new: - initDS(s: &s) - - s.again.due = s.addTime(now, value: 1, unit: .minute) - s.hard.due = s.addTime(now, value: 5, unit: .minute) - s.good.due = s.addTime(now, value: 10, unit: .minute) - - let easyInterval = nextInterval(s: s.easy.stability) - - s.easy.scheduledDays = easyInterval - s.easy.due = s.addTime(now, value: easyInterval, unit: .day) - - case .learning, .relearning: - let hardInterval = 0.0 - let goodInterval = nextInterval(s: s.good.stability) - let easyInterval = max(nextInterval(s: s.easy.stability), goodInterval + 1) - s.schedule(now: now, hardInterval: hardInterval, goodInterval: goodInterval, easyInterval: easyInterval) - - case .review: - let retrievability = card.forgettingCurve(elapsedDays: card.elapsedDays, params: p) - nextDS(&s, lastDifficulty: card.difficulty, lastStability: card.stability, retrievability: retrievability) - - var hardInterval = nextInterval(s: s.hard.stability) - var goodInterval = nextInterval(s: s.good.stability) - - hardInterval = min(hardInterval, goodInterval) - goodInterval = max(goodInterval, hardInterval + 1) - - let easyInterval = max(nextInterval(s: s.easy.stability), goodInterval + 1) - s.schedule(now: now, hardInterval: hardInterval, goodInterval: goodInterval, easyInterval: easyInterval) - } - - return s.recordLog(for: card, now: now) - } - - public func initDS(s: inout SchedulingCards) { - s.again.difficulty = initDifficulty(.again) - s.again.stability = initStability(.again) - s.hard.difficulty = initDifficulty(.hard) - s.hard.stability = initStability(.hard) - s.good.difficulty = initDifficulty(.good) - s.good.stability = initStability(.good) - s.easy.difficulty = initDifficulty(.easy) - s.easy.stability = initStability(.easy) - } - - public func nextDS( - _ scheduling: inout SchedulingCards, - lastDifficulty d: Double, - lastStability s: Double, - retrievability: Double - ) { - scheduling.again.difficulty = nextDifficulty(d: d, rating: .again) - scheduling.again.stability = nextForgetStability(d: scheduling.again.difficulty, s: s, r: retrievability) - scheduling.hard.difficulty = nextDifficulty(d: d, rating: .hard) - scheduling.hard.stability = nextRecallStability( - d: scheduling.hard.difficulty, s: s, r: retrievability, rating: .hard - ) - scheduling.good.difficulty = nextDifficulty(d: d, rating: .good) - scheduling.good.stability = nextRecallStability( - d: scheduling.good.difficulty, s: s, r: retrievability, rating: .good - ) - scheduling.easy.difficulty = nextDifficulty(d: d, rating: .easy) - scheduling.easy.stability = nextRecallStability( - d: scheduling.easy.difficulty, s: s, r: retrievability, rating: .easy - ) - } - - public func initStability(_ rating: Rating) -> Double { - initStability(r: rating.rawValue) - } - - public func initStability(r: Int) -> Double { - max(p.w[r - 1], 0.1) - } - - public func initDifficulty(_ rating: Rating) -> Double { - initDifficulty(r: rating.rawValue) - } - - public func initDifficulty(r: Int) -> Double { - min(max(p.w[4] - p.w[5] * Double(r - 3), 1.0), 10.0) - } - - public func nextInterval(s: Double) -> Double { - let ivl = (s / p.factor) * (pow(p.requestRetention, 1.0 / p.decay) - 1.0) - return constrainInterval(ivl: ivl) - } - - public func nextDifficulty(d: Double, rating: Rating) -> Double { - let r = rating.rawValue - let nextD = d - p.w[6] * Double(r - 3) - return constrainDifficulty(meanReversion(p.w[4], current: nextD)) - } - - func constrainDifficulty(_ d: Double) -> Double { - min(max(d, 1), 10) - } - - func constrainInterval(ivl: Double) -> Double { - min(max(round(ivl), 1), p.maximumInterval) - } - - func meanReversion(_ initial: Double, current: Double) -> Double { - p.w[7] * initial + (1 - p.w[7]) * current - } - - public func nextRecallStability(d: Double, s: Double, r: Double, rating: Rating) -> Double { - let hardPenalty = (rating == .hard) ? p.w[15] : 1 - let easyBonus = (rating == .easy) ? p.w[16] : 1 - return s * (1 + exp(p.w[8]) * (11 - d) * pow(s, -p.w[9]) * (exp((1 - r) * p.w[10]) - 1) * hardPenalty * easyBonus) - } - - public func nextForgetStability(d: Double, s: Double, r: Double) -> Double { - p.w[11] * pow(d, -p.w[12]) * (pow(s + 1.0, p.w[13]) - 1) * exp((1 - r) * p.w[14]) - } -} diff --git a/Sources/FSRS/Helper/FSRSAlea.swift b/Sources/FSRS/Helper/FSRSAlea.swift new file mode 100644 index 0000000..9359799 --- /dev/null +++ b/Sources/FSRS/Helper/FSRSAlea.swift @@ -0,0 +1,140 @@ +// +// FSRSAlea.swift +// +// Created by nkq on 10/13/24. +// + +import Foundation +import JavaScriptCore + +class FSRSAlea { + struct State: Equatable { + var c: Int + var s0: Double + var s1: Double + var s2: Double + } + + private var c: Int + private var s0: Double + private var s1: Double + private var s2: Double + + init(seed: Any? = nil) { + let mash = MashWrapper() + c = 1 + s0 = mash.do(" ") + s1 = mash.do(" ") + s2 = mash.do(" ") + + let seedValue: String = String(describing: seed ?? Date().timeIntervalSince1970) + s0 -= mash.do(seedValue) + if s0 < 0 { s0 += 1 } + s1 -= mash.do(seedValue) + if s1 < 0 { s1 += 1 } + s2 -= mash.do(seedValue) + if s2 < 0 { s2 += 1 } + } + + func next() -> Double { + let t = 2091639 * s0 + Double(c) * 2.3283064365386963e-10 // 2^-32 + s0 = s1 + s1 = s2 + c = Int(floor(t)) + s2 = t - floor(t) + return s2 + } + + var state: State { + get { + State(c: c, s0: s0, s1: s1, s2: s2) + } + set { + c = newValue.c + s0 = newValue.s0 + s1 = newValue.s1 + s2 = newValue.s2 + } + } +} + +struct MashWrapper { + var helper: JSContext? = { + let context = JSContext() + context?.exceptionHandler = { + print($0.debugDescription) + print($1.debugDescription) + } + context?.evaluateScript( +""" +function Mash() { + let n = 0xefc8249d; + return function mash(data) { + data = String(data); + for (let i = 0; i < data.length; i++) { + n += data.charCodeAt(i); + let h = 0.02519603282416938 * n; + n = h >>> 0; + h -= n; + h *= n; + n = h >>> 0; + h -= n; + n += h * 0x100000000; // 2^32 + } + return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 + } +} +const mash = Mash() +""" + ) + return context + }() + + func `do`(_ data: String) -> Double { + let value = helper?.evaluateScript( + "mash('\(data)')" + ) + return value?.toDouble() ?? 0 + } +} + +protocol PRNG { + func next() -> Double + func int32() -> Int32 + func double() -> Double + func state() -> FSRSAlea.State + func importState(_ state: FSRSAlea.State) +} + +struct RandomNumberGeneratorWrapper: PRNG { + private let alea: FSRSAlea + + init(seed: Any? = nil) { + alea = FSRSAlea(seed: seed) + } + + func next() -> Double { + alea.next() + } + + func int32() -> Int32 { + Int32(truncatingIfNeeded: Int(alea.next() * Double(0x100000000))) + } + + func double() -> Double { + next() + Double(UInt(next() * 0x200000)) * 1.1102230246251565e-16 // 2^-53 + } + + func state() -> FSRSAlea.State { + alea.state + } + + func importState(_ state: FSRSAlea.State) { + alea.state = state + } +} + +func alea(seed: Any? = nil) -> RandomNumberGeneratorWrapper { + RandomNumberGeneratorWrapper(seed: seed) +} + diff --git a/Sources/FSRS/Helper/FSRSHelper.swift b/Sources/FSRS/Helper/FSRSHelper.swift new file mode 100644 index 0000000..04a9c32 --- /dev/null +++ b/Sources/FSRS/Helper/FSRSHelper.swift @@ -0,0 +1,151 @@ +// +// FSRSHelper.swift +// +// Created by nkq on 10/14/24. +// + +import Foundation + +class FSRSHelper { + struct FuzzRange { + let start: Double + let end: Double + let factor: Double + } + + static let fuzzRanges = [ + FuzzRange(start: 2.5, end: 7.0, factor: 0.15), + .init(start: 7.0, end: 20.0, factor: 0.1), + .init(start: 20, end: .infinity, factor: 0.05) + ] + + static func getFuzzRange( + interval: Double, + elapsedDays: Double, + maximumInterval: Double + ) -> (minIvl: Double, maxIvl: Double) { + var delta = 1.0 + for range in fuzzRanges { + delta += range.factor * max(min(interval, range.end) - range.start, 0.0) + } + let newInterval = min(interval, maximumInterval) + var minIvl = max(2, round(newInterval - delta)) + let maxIvl = min(round(newInterval + delta), maximumInterval) + if newInterval > elapsedDays { + minIvl = max(minIvl, elapsedDays + 1) + } + minIvl = min(minIvl, maxIvl) + return (minIvl, maxIvl) + } + + static func clamp(_ value: Double, _ minV: Double, _ maxV: Double) -> Double { + min(max(value, minV), maxV) + } +} + +public struct FSRSError: Error, Equatable { + enum Reason: String, Error { + case invalidInterval + case invalidRating + case invalidRetention + case invalidParam + } + var errorReason: Reason + var message: String? + + init(_ errorReason: Reason, _ message: String? = nil) { + self.message = message + self.errorReason = errorReason + } +} + +extension Date { + + enum TimeUnit: String, Codable { + case days + case minutes + } + + /** + * 计算日期和时间的偏移,并返回一个新的日期对象。 + * @param now 当前日期和时间 + * @param t 时间偏移量,当 isDay 为 true 时表示天数,为 false 时表示分钟 + * @param unit (可选)是否按天数单位进行偏移,默认为 minutes,表示按分钟单位计算偏移 + * @returns 偏移后的日期和时间对象 + */ + static func dateScheduler(now: Date, t: Double, unit: TimeUnit = .minutes) -> Date { + Date(timeIntervalSince1970: + unit == .days + ? now.timeIntervalSince1970 + t * 24 * 60 * 60 + : now.timeIntervalSince1970 + t * 60 + ) + } + + static func dateDiff(now: Date, pre: Date?, unit: TimeUnit) -> Double { + guard let pre = pre else { return 0.0 } + let diff = now.timeIntervalSince1970 - pre.timeIntervalSince1970 + var r = 0.0 + switch unit { + case .days: + r = floor(diff / (24 * 60 * 60)) + case .minutes: + r = floor(diff / 60) + } + return r + } + + func toString(_ dateFormat: String) -> String? { + let formatter = DateFormatter() + formatter.dateFormat = dateFormat + return formatter.string(from: self) + } + + static func formatDate(date: Date) -> String { + date.toString("yyyy-MM-dd HH:mm:ss") ?? "" + } + + static func fromString(_ date: String) -> Date? { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + if let gmt = TimeZone(secondsFromGMT: 0) { + formatter.timeZone = gmt + } + return formatter.date(from: date) + } + + static let timeUnit = [60.0, 60, 24, 31, 12] + static let timeUnitsFormat = ["second", "min", "hour", "day", "month", "year"] + static func showDiffMessage( + _ due: Date, + _ lastReview: Date, + _ detailed: Bool = false, + _ unit: [String] = timeUnitsFormat + ) -> String { + var unit = unit + if unit.count != timeUnitsFormat.count { + unit = timeUnitsFormat + } + var diff = due.timeIntervalSince1970 - lastReview.timeIntervalSince1970 + var i = 0 + for (index, unit) in timeUnit.enumerated() { + if diff < unit { + i = index + break + } else { + diff /= unit + } + i += 1 + } + return "\(Int(floor(diff)))\(detailed ? (unit[i]) : "")" + } +} + +extension Double { + func toFixed(_ places: Int) -> String { + return String(format: "%.\(places)f", self) + } + + func toFixedNumber(_ places: Int) -> Double { + return Double(String(format: "%.\(places)f", self)) ?? 0 + } +} diff --git a/Sources/FSRS/Models/FSRSDefaults.swift b/Sources/FSRS/Models/FSRSDefaults.swift new file mode 100644 index 0000000..df2aac1 --- /dev/null +++ b/Sources/FSRS/Models/FSRSDefaults.swift @@ -0,0 +1,79 @@ +// +// FSRSDefaults.swift +// +// Created by nkq on 10/13/24. +// + +import Foundation + +public class FSRSDefaults { + var defaultRequestRetention = 0.9 + var defaultMaximumInterval = 36500.0 + var defaultW = [ + 0.4072, 1.1829, 3.1262, 15.4722, 7.2102, 0.5316, 1.0651, 0.0234, 1.616, + 0.1544, 1.0824, 1.9813, 0.0953, 0.2975, 2.2042, 0.2407, 2.9466, 0.5034, + 0.6567, + ] + var defaultEnableFuzz = false + var defaultEnableShortTerm = true + + var FSRSVersion: String = "v4.4.1 using FSRS V5.0" + + func generatorParameters(props: FSRSParameters? = nil) -> FSRSParameters { + var w = defaultW + if let p = props { + if p.w.count == 19 { + w = p.w + } else if p.w.count == 17 { + w = p.w + w.append(0.0) + w.append(0.0) + print("[FSRS V5]auto fill w to 19 length") + } + } + + return FSRSParameters( + requestRetention: props?.requestRetention ?? defaultRequestRetention, + maximumInterval: props?.maximumInterval ?? defaultMaximumInterval, + w: w, + enableFuzz: props?.enableFuzz ?? defaultEnableFuzz, + enableShortTerm: props?.enableShortTerm ?? defaultEnableShortTerm + ) + } + + + /** + * Create an empty card + * @param now Current time + * @param afterHandler Convert the result to another type. (Optional) + * @example + * ``` + * const card: Card = createEmptyCard(new Date()); + * ``` + * @example + * ``` + * interface CardUnChecked + * extends Omit { + * cid: string; + * due: Date | number; + * last_review: Date | null | number; + * state: StateType; + * } + * + * function cardAfterHandler(card: Card) { + * return { + * ...card, + * cid: "test001", + * state: State[card.state], + * last_review: card.last_review ?? null, + * } as CardUnChecked; + * } + * + * const card: CardUnChecked = createEmptyCard(new Date(), cardAfterHandler); + * ``` + */ + func createEmptyCard(now: Date = Date(), afterHandler: ((Card) -> Card)? = nil) -> Card { + let card = Card(due: now) + return afterHandler?(card) ?? card + } +} diff --git a/Sources/FSRS/Models/FSRSModels.swift b/Sources/FSRS/Models/FSRSModels.swift new file mode 100644 index 0000000..79a0977 --- /dev/null +++ b/Sources/FSRS/Models/FSRSModels.swift @@ -0,0 +1,188 @@ +// +// FSRSModels.swift +// +// Created by nkq on 10/13/24. +// + +import Foundation + +public enum CardState: Int, Codable { + case new = 0 + case learning = 1 + case review = 2 + case relearning = 3 + + var stringValue: String { + switch self { + case .new: return "new" + case .learning: return "learning" + case .review: return "review" + case .relearning: return "relearning" + } + } +} + +public enum Rating: Int, Codable, Equatable, CaseIterable { + case manual = 0, again = 1, hard, good, easy + + var stringValue: String { + switch self { + case .manual: return "manual" + case .again: return "again" + case .hard: return "hard" + case .good: return "good" + case .easy: return "easy" + } + } +} + +public struct ReviewLog: Equatable, Codable { + var rating: Rating // Rating of the review (Again, Hard, Good, Easy) + var state: CardState? // State of the review (New, Learning, Review, Relearning) + var due: Date? // Date of the last scheduling + var stability: Double? // Memory stability during the review + var difficulty: Double? // Difficulty of the card during the review + var elapsedDays: Double // Number of days elapsed since the last review + var lastElapsedDays: Double // Number of days between the last two reviews + var scheduledDays: Double // Number of days until the next review + var review: Date // Date of the review + + public init( + rating: Rating, + state: CardState? = nil, + due: Date? = nil, + stability: Double? = nil, + difficulty: Double? = nil, + elapsedDays: Double = 0, + lastElapsedDays: Double = 0, + scheduledDays: Double = 0, + review: Date + ) { + self.rating = rating + self.state = state + self.due = due + self.stability = stability + self.difficulty = difficulty + self.elapsedDays = elapsedDays + self.lastElapsedDays = lastElapsedDays + self.scheduledDays = scheduledDays + self.review = review + } + + var newLog: ReviewLog { + ReviewLog( + rating: rating, + state: state, + due: due, + stability: stability, + difficulty: difficulty, + elapsedDays: elapsedDays, + lastElapsedDays: lastElapsedDays, + scheduledDays: scheduledDays, + review: review + ) + } +} + +public struct Card: Equatable, Codable { + public var due: Date // Date when the card is next due for review + public var stability: Double // A measure of how well the information is retained + public var difficulty: Double // Reflects the inherent difficulty of the card content + public var elapsedDays: Double // Days since the card was last reviewed + public var scheduledDays: Double // The interval at which the card is next scheduled + public var reps: Int // Total number of times the card has been reviewed + public var lapses: Int // Times the card was forgotten or remembered incorrectly + public var state: CardState // The current state of the card (New, Learning, Review, Relearning) + public var lastReview: Date? // The most recent review date, if applicable + + public init( + due: Date = Date(), + stability: Double = 0, + difficulty: Double = 0, + elapsedDays: Double = 0, + scheduledDays: Double = 0, + reps: Int = 0, + lapses: Int = 0, + state: CardState = .new, + lastReview: Date? = nil + ) { + self.due = due + self.stability = stability + self.difficulty = difficulty + self.elapsedDays = elapsedDays + self.scheduledDays = scheduledDays + self.reps = reps + self.lapses = lapses + self.state = state + self.lastReview = lastReview + } + + var newCard: Card { + Card( + due: due, + stability: stability, + difficulty: difficulty, + elapsedDays: elapsedDays, + scheduledDays: scheduledDays, + reps: reps, + lapses: lapses, + state: state, + lastReview: lastReview + ) + } + + func printLog() { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(self) + print(data) + } catch { + print("Error serializing JSON: \(error)") + } + } +} + +public struct RecordLogItem: Codable, Equatable { + var card: Card + var log: ReviewLog +} + +public typealias RecordLog = [Rating: RecordLogItem] + +public struct FSRSParameters: Codable, Equatable { + var requestRetention: Double + var maximumInterval: Double + var w: [Double] + var enableFuzz: Bool + var enableShortTerm: Bool + + init( + requestRetention: Double? = nil, + maximumInterval: Double? = nil, + w: [Double]? = nil, + enableFuzz: Bool? = nil, + enableShortTerm: Bool? = nil + ) { + let defaults = FSRSDefaults() + self.requestRetention = requestRetention ?? defaults.defaultRequestRetention + self.maximumInterval = maximumInterval ?? defaults.defaultMaximumInterval + self.w = w ?? defaults.defaultW + self.enableFuzz = enableFuzz ?? defaults.defaultEnableFuzz + self.enableShortTerm = enableShortTerm ?? defaults.defaultEnableShortTerm + } +} + +public struct FSRSReview: Codable { + /** + * 0-4: Manual, Again, Hard, Good, Easy + * = revlog.rating + */ + var rating: Rating + /** + * The number of days that passed + * = revlog.elapsed_days + * = round(revlog[-1].review - revlog[-2].review) + */ + var deltaT: Double +} diff --git a/Sources/FSRS/Models/FSRSTypes.swift b/Sources/FSRS/Models/FSRSTypes.swift new file mode 100644 index 0000000..da3d7c1 --- /dev/null +++ b/Sources/FSRS/Models/FSRSTypes.swift @@ -0,0 +1,77 @@ +// +// FSRSTypes.swift +// +// Created by nkq on 10/13/24. +// + +import Foundation + +public struct IPreview { + var recordLog: RecordLog + + init(recordLog: RecordLog) { + self.recordLog = recordLog + } + + subscript(rating: Rating) -> RecordLogItem? { + get { + recordLog[rating] + } + set { + recordLog[rating] = newValue + } + } +} + +public protocol IScheduler { + var preview: IPreview { get } + func review(_ g: Rating) -> RecordLogItem +} + +/** + * Options for rescheduling. + * + * @template T - The type of the result returned by the `recordLogHandler` function. + */ +public struct RescheduleOptions { + /** + * A function that handles recording the log. + * + * @param recordLog - The log to be recorded. + * @returns The result of recording the log. + */ + var recordLogHandler: ((_ recordLog: RecordLogItem?) -> RecordLogItem?)? + + /** + * A function that defines the order of reviews. + * + * @param a - The first FSRSHistory object. + * @param b - The second FSRSHistory object. + */ + var reviewsOrderBy: ((_ a: ReviewLog, _ b: ReviewLog) -> Bool)? + + /** + * Indicating whether to skip manual steps. + */ + var skipManual: Bool = true + + /** + * Indicating whether to update the FSRS memory state. + */ + var updateMemoryState: Bool = false + + /** + * The current date and time. + */ + var now: Date = Date() + + /** + * The input for the first card. + */ + var firstCard: Card? +} + +public struct IReschedule: Equatable { + var collections: [RecordLogItem?] + var rescheduleItem: RecordLogItem? +} diff --git a/Sources/FSRS/Scheduler/AbstractScheduler.swift b/Sources/FSRS/Scheduler/AbstractScheduler.swift new file mode 100644 index 0000000..f72e92c --- /dev/null +++ b/Sources/FSRS/Scheduler/AbstractScheduler.swift @@ -0,0 +1,89 @@ +// +// AbstractSch.swift +// +// Created by nkq on 10/13/24. +// + +import Foundation + +class AbstractScheduler: IScheduler { + var preview: IPreview { + .init(recordLog: [ + .again: review(.again), + .hard: review(.hard), + .good: review(.good), + .easy: review(.easy) + ]) + } + var last: Card + var current: Card + var reviewTime: Date + var next: [Rating: RecordLogItem] = [:] + var algorithm: FSRSAlgorithm + + init( + card: Card, + reviewTime: Date, + algorithm: FSRSAlgorithm + ) { + self.algorithm = algorithm + self.last = card.newCard + self.current = card.newCard + self.reviewTime = reviewTime + + var interval = 0.0 + if current.state != .new && current.lastReview != nil { + interval = Date.dateDiff( + now: reviewTime, + pre: current.lastReview, + unit: .days + ) + } + self.current.lastReview = reviewTime + self.current.elapsedDays = interval + self.current.reps += 1 + self.algorithm.seed = "\(reviewTime.timeIntervalSince1970)_\(current.reps)_\(current.difficulty * current.stability)" + } + + var seed: String { + get { algorithm.seed ?? "" } + set { algorithm.seed = newValue } + } + + func review(_ g: Rating) -> RecordLogItem { + switch last.state { + case .new: + return newState(grade: g) + case .learning, .relearning: + return learningState(grade: g) + case .review: + return reviewState(grade: g) + } + } + + func newState(grade: Rating) -> RecordLogItem { + print("subclass must override") + return .init(card: Card(), log: ReviewLog(rating: .manual, state: .new, due: Date(), review: Date())) + } + func learningState(grade: Rating) -> RecordLogItem { + print("subclass must override") + return .init(card: Card(), log: ReviewLog(rating: .manual, state: .new, due: Date(), review: Date())) + } + func reviewState(grade: Rating) -> RecordLogItem { + print("subclass must override") + return .init(card: Card(), log: ReviewLog(rating: .manual, state: .new, due: Date(), review: Date())) + } + + func buildLog(rating: Rating) -> ReviewLog { + .init(rating: rating, + state: current.state, + due: last.lastReview == nil ? last.due : last.lastReview ?? Date(), + stability: current.stability, + difficulty: current.difficulty, + elapsedDays: current.elapsedDays, + lastElapsedDays: last.elapsedDays, + scheduledDays: current.scheduledDays, + review: reviewTime + ) + } +} diff --git a/Sources/FSRS/Scheduler/BasicScheduler.swift b/Sources/FSRS/Scheduler/BasicScheduler.swift new file mode 100644 index 0000000..f785076 --- /dev/null +++ b/Sources/FSRS/Scheduler/BasicScheduler.swift @@ -0,0 +1,207 @@ +// +// BasicScheduler.swift +// +// Created by nkq on 10/14/24. +// + +import Foundation + +class BasicScheduler: AbstractScheduler { + override func newState(grade: Rating) -> RecordLogItem { + if let item = next[grade] { return item } + var next = current.newCard + next.difficulty = algorithm.initDifficulty(grade) + next.stability = algorithm.initStability(g: grade) + switch grade { + case .again: + next.scheduledDays = 0 + next.due = Date.dateScheduler(now: reviewTime, t: 1) + next.state = .learning + case .hard: + next.scheduledDays = 0 + next.due = Date.dateScheduler(now: reviewTime, t: 5) + next.state = .learning + case .good: + next.scheduledDays = 0 + next.due = Date.dateScheduler(now: reviewTime, t: 10) + next.state = .learning + case .easy: + let easyInterval = algorithm.nextInterval( + s: next.stability, + elapsedDays: current.elapsedDays + ) + next.scheduledDays = Double(easyInterval) + next.due = Date.dateScheduler(now: reviewTime, t: Double(easyInterval), unit: .days) + next.state = .review + case .manual: break + } + return .init(card: next, log: buildLog(rating: grade)) + } + + override func learningState(grade: Rating) -> RecordLogItem { + if let item = next[grade] { return item } + var next = current.newCard + let interval = current.elapsedDays + next.difficulty = algorithm.nextDifficulty(d: last.difficulty, g: grade) + next.stability = algorithm.nextShortTermStability(s: last.stability, g: grade) + switch grade { + case .again: + next.scheduledDays = 0 + next.due = Date.dateScheduler(now: reviewTime, t: 5) + next.state = last.state + case .hard: + next.scheduledDays = 0 + next.due = Date.dateScheduler(now: reviewTime, t: 10) + next.state = last.state + case .good: + let goodInterval = algorithm.nextInterval( + s: next.stability, + elapsedDays: interval + ) + next.scheduledDays = Double(goodInterval) + next.due = Date.dateScheduler(now: reviewTime, t: Double(goodInterval), unit: .days) + next.state = .review + case .easy: + let goodStability = algorithm.nextShortTermStability( + s: last.stability, + g: .good + ) + let goodInterval = algorithm.nextInterval( + s: goodStability, + elapsedDays: interval + ) + let easyInterval = max(algorithm.nextInterval( + s: next.stability, + elapsedDays: interval + ), goodInterval + 1) + next.scheduledDays = Double(easyInterval) + next.due = Date.dateScheduler(now: reviewTime, t: Double(easyInterval), unit: .days) + next.state = .review + case .manual: break + } + return .init(card: next, log: buildLog(rating: grade)) + } + + override func reviewState(grade: Rating) -> RecordLogItem { + if let item = next[grade] { return item } + let interval = current.elapsedDays + let retrievability = algorithm.forgettingCurve( + elapsedDays: interval, stability: last.stability + ) + let nextArray = Array(repeating: current.newCard, count: 4) + var nextAgain = nextArray[0] + var nextHard = nextArray[1] + var nextGood = nextArray[2] + var nextEasy = nextArray[3] + + nextDs( + &nextAgain, &nextHard, &nextGood, &nextEasy, + difficulty: last.difficulty, + stability: last.stability, + retrievability: retrievability + ) + + nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval: interval) + nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) + + nextAgain.lapses += 1 + + let itemAgain = RecordLogItem( + card: nextAgain, + log: buildLog(rating: .again) + ) + let itemHard = RecordLogItem( + card: nextHard, + log: buildLog(rating: .hard) + ) + let itemGood = RecordLogItem( + card: nextGood, + log: buildLog(rating: .good) + ) + let itemEasy = RecordLogItem( + card: nextEasy, + log: buildLog(rating: .easy) + ) + + next[.again] = itemAgain + next[.hard] = itemHard + next[.good] = itemGood + next[.easy] = itemEasy + + return next[grade]! + } + + private func nextDs( + _ nextAgain: inout Card, + _ nextHard: inout Card, + _ nextGood: inout Card, + _ nextEasy: inout Card, + difficulty: Double, + stability: Double, + retrievability: Double + ) { + nextAgain.difficulty = algorithm.nextDifficulty(d: difficulty, g: .again) + nextAgain.stability = algorithm.nextForgetStability( + d: difficulty, s: stability, r: retrievability + ) + + nextHard.difficulty = algorithm.nextDifficulty(d: difficulty, g: .hard) + nextHard.stability = algorithm.nextRecallStability( + d: difficulty, s: stability, r: retrievability, g: .hard + ) + + nextGood.difficulty = algorithm.nextDifficulty(d: difficulty, g: .good) + nextGood.stability = algorithm.nextRecallStability( + d: difficulty, s: stability, r: retrievability, g: .good + ) + + nextEasy.difficulty = algorithm.nextDifficulty(d: difficulty, g: .easy) + nextEasy.stability = algorithm.nextRecallStability( + d: difficulty, s: stability, r: retrievability, g: .easy + ) + } + + private func nextInterval( + _ nextAgain: inout Card, + _ nextHard: inout Card, + _ nextGood: inout Card, + _ nextEasy: inout Card, + interval: Double + ) { + var hardInterval = algorithm.nextInterval( + s: nextHard.stability, elapsedDays: interval + ) + var goodInterval = algorithm.nextInterval( + s: nextGood.stability, elapsedDays: interval + ) + hardInterval = min(hardInterval, goodInterval) + goodInterval = max(goodInterval, hardInterval + 1) + let easyInteval = max( + algorithm.nextInterval(s: nextEasy.stability, elapsedDays: interval), + goodInterval + 1 + ) + nextAgain.scheduledDays = 0 + nextAgain.due = Date.dateScheduler(now: reviewTime, t: 5) + + nextHard.scheduledDays = Double(hardInterval) + nextHard.due = Date.dateScheduler(now: reviewTime, t: Double(hardInterval), unit: .days) + + nextGood.scheduledDays = Double(goodInterval) + nextGood.due = Date.dateScheduler(now: reviewTime, t: Double(goodInterval), unit: .days) + + nextEasy.scheduledDays = Double(easyInteval) + nextEasy.due = Date.dateScheduler(now: reviewTime, t: Double(easyInteval), unit: .days) + } + + private func nextState( + _ nextAgain: inout Card, + _ nextHard: inout Card, + _ nextGood: inout Card, + _ nextEasy: inout Card + ) { + nextAgain.state = .relearning + nextHard.state = .review + nextGood.state = .review + nextEasy.state = .review + } +} diff --git a/Sources/FSRS/Scheduler/FSRSReschedule.swift b/Sources/FSRS/Scheduler/FSRSReschedule.swift new file mode 100644 index 0000000..44bbc1a --- /dev/null +++ b/Sources/FSRS/Scheduler/FSRSReschedule.swift @@ -0,0 +1,193 @@ +// +// FSRSReschedule.swift +// +// Created by nkq on 10/15/24. +// + +import Foundation + +/** + * The `Reschedule` class provides methods to handle the rescheduling of cards based on their review history. + * determine the next review dates and update the card's state accordingly. + */ +class FSRSReschedule { + private var fsrs: FSRS + + /** + * Creates an instance of the `Reschedule` class. + * @param fsrs - An instance of the FSRS class used for scheduling. + */ + init(fsrs: FSRS) { + self.fsrs = fsrs + } + + /** + * Replays a review for a card and determines the next review date based on the given rating. + * @param card - The card being reviewed. + * @param reviewed - The date the card was reviewed. + * @param rating - The grade given to the card during the review. + * @returns A `RecordLogItem` containing the updated card and review log. + */ + func replay( + card: Card, + reviewDate: Date, + rating: Rating + ) throws -> RecordLogItem { + try fsrs.next(card: card, now: reviewDate, grade: rating) + } + + /** + * Processes a manual review for a card, allowing for custom state, stability, difficulty, and due date. + * @param card - The card being reviewed. + * @param state - The state of the card after the review. + * @param reviewed - The date the card was reviewed. + * @param elapsed_days - The number of days since the last review. + * @param stability - (Optional) The stability of the card. + * @param difficulty - (Optional) The difficulty of the card. + * @param due - (Optional) The due date for the next review. + * @returns A `RecordLogItem` containing the updated card and review log. + * @throws Will throw an error if the state or due date is not provided when required. + */ + func handleManualRating( + card: Card, + state: CardState, + reviewDate: Date, + elapsedDays: Double, + stability: Double?, + difficulty: Double?, + due: Date? + ) throws -> RecordLogItem { + var log: ReviewLog + var nextCard: Card + + if state == .new { + log = .init( + rating: .manual, + state: state, + due: due ?? reviewDate, + stability: card.stability, + difficulty: card.difficulty, + elapsedDays: elapsedDays, + lastElapsedDays: card.elapsedDays, + scheduledDays: card.scheduledDays, + review: reviewDate + ) + nextCard = FSRSDefaults().createEmptyCard( + now: reviewDate + ) + nextCard.lastReview = reviewDate + } else { + guard let due = due else { + throw FSRSError(.invalidParam, "reschedule: due is required for manual rating") + } + let schduledDays = Date.dateDiff(now: due, pre: reviewDate, unit: .days) + log = .init( + rating: .manual, + state: card.state, + due: card.lastReview ?? card.due, + stability: card.stability, + difficulty: card.difficulty, + elapsedDays: elapsedDays, + lastElapsedDays: card.elapsedDays, + scheduledDays: card.scheduledDays, + review: reviewDate + ) + nextCard = .init( + due: due, + stability: stability ?? card.stability, + difficulty: difficulty ?? card.difficulty, + elapsedDays: elapsedDays, + scheduledDays: schduledDays, + reps: card.reps + 1, + lapses: card.lapses, + state: state, + lastReview: reviewDate + ) + } + return .init(card: nextCard, log: log) + } + + + /** + * Reschedules a card based on its review history. + * + * @param current_card - The card to be rescheduled. + * @param reviews - An array of review history objects. + * @returns An array of record log items representing the rescheduling process. + */ + func reschedule( + currentCard: Card, + reviews: [ReviewLog] + ) throws -> [RecordLogItem] { + var result = [RecordLogItem]() + var curCard = FSRSDefaults().createEmptyCard(now: currentCard.due) + for review in reviews { + var item: RecordLogItem + if review.rating == .manual { + var interval = 0.0 + if curCard.state != .new, let lastReview = curCard.lastReview { + interval = Date.dateDiff( + now: review.review, + pre: lastReview, + unit: .days + ) + } + guard let state = review.state else { + throw FSRSError(.invalidParam, "reschedule: state is required for manual rating") + } + item = try handleManualRating( + card: curCard, + state: state, + reviewDate: review.review, + elapsedDays: interval, + stability: review.stability, + difficulty: review.difficulty, + due: review.due + ) + result.append(item) + curCard = item.card + } else { + do { + item = try replay( + card: curCard, reviewDate: review.review, rating: review.rating + ) + result.append(item) + curCard = item.card + } catch { + print(error.localizedDescription) + } + } + } + return result + } + + func calculateManualRecord( + currentCard: Card, + now: Date, + recordLogItem: RecordLogItem?, + updateMemory: Bool = false + ) throws -> RecordLogItem? { + guard let item = recordLogItem else { return nil } + let rescheduleCard = item.card + let log = item.log + + var curCard = currentCard.newCard + if curCard.due.timeIntervalSince1970 == rescheduleCard.due.timeIntervalSince1970 { + return nil + } + curCard.scheduledDays = Date.dateDiff( + now: rescheduleCard.due, + pre: curCard.due, + unit: .days + ) + return try handleManualRating( + card: curCard, + state: rescheduleCard.state, + reviewDate: now, + elapsedDays: log.elapsedDays, + stability: updateMemory ? rescheduleCard.stability : nil, + difficulty: updateMemory ? rescheduleCard.difficulty : nil, + due: rescheduleCard.due + ) + } +} diff --git a/Sources/FSRS/Scheduler/LongTermScheduler.swift b/Sources/FSRS/Scheduler/LongTermScheduler.swift new file mode 100644 index 0000000..9f29221 --- /dev/null +++ b/Sources/FSRS/Scheduler/LongTermScheduler.swift @@ -0,0 +1,175 @@ +// +// LongTermScheduler.swift +// +// Created by nkq on 10/14/24. +// + +import Foundation + +class LongTermScheduler: AbstractScheduler { + override func newState(grade: Rating) -> RecordLogItem { + if let item = next[grade] { return item } + + current.scheduledDays = 0 + current.elapsedDays = 0 + + let nextArray = Array(repeating: current.newCard, count: 4) + var nextAgain = nextArray[0] + var nextHard = nextArray[1] + var nextGood = nextArray[2] + var nextEasy = nextArray[3] + + initDs(&nextAgain, &nextHard, &nextGood, &nextEasy) + + let firstInterval = 0.0 + + nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval: firstInterval) + + nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) + + updateNext(&nextAgain, &nextHard, &nextGood, &nextEasy) + + return next[grade]! + } + + override func learningState(grade: Rating) -> RecordLogItem { + reviewState(grade: grade) + } + + override func reviewState(grade: Rating) -> RecordLogItem { + if let item = next[grade] { return item } + + let interval = current.elapsedDays + let retrievability = algorithm.forgettingCurve(elapsedDays: interval, stability: last.stability) + let nextArray = Array(repeating: current.newCard, count: 4) + var nextAgain = nextArray[0] + var nextHard = nextArray[1] + var nextGood = nextArray[2] + var nextEasy = nextArray[3] + + nextDs( + &nextAgain, &nextHard, &nextGood, &nextEasy, + difficulty: last.difficulty, + stability: last.stability, + retrievability: retrievability + ) + + nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval: interval) + + nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) + nextAgain.lapses += 1 + + updateNext(&nextAgain, &nextHard, &nextGood, &nextEasy) + + return next[grade]! + } + + private func initDs( + _ nextAgain: inout Card, + _ nextHard: inout Card, + _ nextGood: inout Card, + _ nextEasy: inout Card + ) { + nextAgain.difficulty = algorithm.initDifficulty(.again) + nextAgain.stability = algorithm.initStability(g: .again) + + nextHard.difficulty = algorithm.initDifficulty(.hard) + nextHard.stability = algorithm.initStability(g: .hard) + + nextGood.difficulty = algorithm.initDifficulty(.good) + nextGood.stability = algorithm.initStability(g: .good) + + nextEasy.difficulty = algorithm.initDifficulty(.easy) + nextEasy.stability = algorithm.initStability(g: .easy) + } + + private func nextDs( + _ nextAgain: inout Card, + _ nextHard: inout Card, + _ nextGood: inout Card, + _ nextEasy: inout Card, + difficulty: Double, + stability: Double, + retrievability: Double + ) { + nextAgain.difficulty = algorithm.nextDifficulty(d: difficulty, g: .again) + nextAgain.stability = algorithm.nextForgetStability( + d: difficulty, s: stability, r: retrievability + ) + + nextHard.difficulty = algorithm.nextDifficulty(d: difficulty, g: .hard) + nextHard.stability = algorithm.nextRecallStability( + d: difficulty, s: stability, r: retrievability, g: .hard + ) + + nextGood.difficulty = algorithm.nextDifficulty(d: difficulty, g: .good) + nextGood.stability = algorithm.nextRecallStability( + d: difficulty, s: stability, r: retrievability, g: .good + ) + + nextEasy.difficulty = algorithm.nextDifficulty(d: difficulty, g: .easy) + nextEasy.stability = algorithm.nextRecallStability( + d: difficulty, s: stability, r: retrievability, g: .easy + ) + } + + private func nextInterval( + _ nextAgain: inout Card, + _ nextHard: inout Card, + _ nextGood: inout Card, + _ nextEasy: inout Card, + interval: Double + ) { + let againInterval = algorithm.nextInterval(s: nextAgain.stability, elapsedDays: interval) + let hardInterval = algorithm.nextInterval(s: nextHard.stability, elapsedDays: interval) + let goodInterval = algorithm.nextInterval(s: nextGood.stability, elapsedDays: interval) + let easyInterval = algorithm.nextInterval(s: nextEasy.stability, elapsedDays: interval) + + + let newAgainInterval = min(againInterval, hardInterval) + let newHardInterval = max(hardInterval, (againInterval + 1)) + let newGoodInterval = max(goodInterval, (hardInterval + 1)) + let newEasyInterval = max(easyInterval, (goodInterval + 1)) + + nextAgain.scheduledDays = Double(newAgainInterval) + nextAgain.due = Date.dateScheduler(now: reviewTime, t: Double(newAgainInterval), unit: .days) + + nextHard.scheduledDays = Double(newHardInterval) + nextHard.due = Date.dateScheduler(now: reviewTime, t: Double(newHardInterval), unit: .days) + + nextGood.scheduledDays = Double(newGoodInterval) + nextGood.due = Date.dateScheduler(now: reviewTime, t: Double(newGoodInterval), unit: .days) + + nextEasy.scheduledDays = Double(newEasyInterval) + nextEasy.due = Date.dateScheduler(now: reviewTime, t: Double(newEasyInterval), unit: .days) + } + + private func nextState( + _ nextAgain: inout Card, + _ nextHard: inout Card, + _ nextGood: inout Card, + _ nextEasy: inout Card + ) { + nextAgain.state = .review + nextHard.state = .review + nextGood.state = .review + nextEasy.state = .review + } + + private func updateNext( + _ nextAgain: inout Card, + _ nextHard: inout Card, + _ nextGood: inout Card, + _ nextEasy: inout Card + ) { + let again = RecordLogItem(card: nextAgain, log: buildLog(rating: .again)) + let hard = RecordLogItem(card: nextHard, log: buildLog(rating: .hard)) + let good = RecordLogItem(card: nextGood, log: buildLog(rating: .good)) + let easy = RecordLogItem(card: nextEasy, log: buildLog(rating: .easy)) + + next[.again] = again + next[.hard] = hard + next[.good] = good + next[.easy] = easy + } +} diff --git a/Tests/FSRSTests/FSRSAbstractSchedulerTests.swift b/Tests/FSRSTests/FSRSAbstractSchedulerTests.swift new file mode 100644 index 0000000..7aa9394 --- /dev/null +++ b/Tests/FSRSTests/FSRSAbstractSchedulerTests.swift @@ -0,0 +1,44 @@ +// +// BasicSchedulerTests.swift +// FSRS +// +// Created by nkq on 10/20/24. +// + + +import XCTest +@testable import FSRS + +class BasicSchedulerTests: XCTestCase { + func testSymbolIterator() { + let now = Date() + let card = FSRSDefaults().createEmptyCard(now: now) + let f = FSRS(parameters: .init()) + let preview = f.repeat(card: card, now: now) + let again = try! f.next(card: card, now: now, grade: .again) + let hard = try! f.next(card: card, now: now, grade: .hard) + let good = try! f.next(card: card, now: now, grade: .good) + let easy = try! f.next(card: card, now: now, grade: .easy) + + let expectPreview: [Rating: Card] = [ + .again: again.card, + .hard: hard.card, + .good: good.card, + .easy: easy.card, + ] + + // Check that preview matches expected structure + XCTAssertEqual(preview.recordLog[.again]?.card, expectPreview[.again]) + XCTAssertEqual(preview.recordLog[.good]?.card, expectPreview[.good]) + XCTAssertEqual(preview.recordLog[.easy]?.card, expectPreview[.easy]) + XCTAssertEqual(preview.recordLog[.hard]?.card, expectPreview[.hard]) + + + + // Iterate over preview and check values + for item in preview.recordLog { + let expectedCard = expectPreview[item.value.log.rating] + XCTAssertEqual(item.value.card, expectedCard) + } + } +} diff --git a/Tests/FSRSTests/FSRSAleaTests.swift b/Tests/FSRSTests/FSRSAleaTests.swift new file mode 100644 index 0000000..b8fcd45 --- /dev/null +++ b/Tests/FSRSTests/FSRSAleaTests.swift @@ -0,0 +1,119 @@ +import XCTest +@testable import FSRS + +final class FSRSAleaTests: XCTestCase { + func testExample() throws { + let prng1 = alea(seed: 1) + let prng2 = alea(seed: 3) + let prng3 = alea(seed: 1) + + let a = prng1.state() + let b = prng2.state() + let c = prng3.state() + + XCTAssert(a == c) + XCTAssert(a != b) + + var generator = alea(seed: 12345) + let v4 = generator.next() + let v5 = generator.next() + let v6 = generator.next() + print([0.27138191112317145, 0.19615925149992108, 0.6810678059700876].debugDescription) + print([v4, v5, v6].debugDescription) + XCTAssert([v4, v5, v6].elementsEqual([0.27138191112317145, 0.19615925149992108, 0.6810678059700876])) + + generator = alea(seed: "int32test") + let value = generator.int32() + XCTAssert(Int(value) <= 0xffffffff) + XCTAssert(value >= 0) + + generator = alea(seed: 12345) + let v1 = generator.int32() + let v2 = generator.int32() + let v3 = generator.int32() + print([1165576433, 842497570, -1369803343].debugDescription) + print([v1, v2, v3].debugDescription) + XCTAssert([v1, v2, v3].elementsEqual([1165576433, 842497570, -1369803343])) + + generator = alea(seed: "doubletest") + let value1 = generator.double() + XCTAssert(value1 < 1) + XCTAssert(value1 >= 0) + + generator = alea(seed: 12345) + let v7 = generator.double() + let v8 = generator.double() + let v9 = generator.double() + print([0.27138191116884325, 0.6810678062004586, 0.3407802057882554].debugDescription) + print([v7, v8, v9].debugDescription) + XCTAssert([v7, v8, v9].elementsEqual([0.27138191116884325, 0.6810678062004586, 0.3407802057882554])) + + let prng4 = alea(seed: Double.random(in: 0...1)) + _ = prng4.next() + _ = prng4.next() + _ = prng4.next() + let prng5 = alea() + prng5.importState(prng4.state()) + XCTAssert(prng4.state() == prng5.state()) + + for _ in 1...10000 { + let q = prng4.next() + let b = prng5.next() + XCTAssert(q == b) + XCTAssert(q >= 0) + XCTAssert(q < 1) + XCTAssert(b < 1) + } + + generator = alea(seed: "statetest") + let state1 = generator.state() + let next1 = generator.next() + let state2 = generator.state() + let next2 = generator.next() + XCTAssert(state1.s0 != state2.s0) + XCTAssert(state1.s1 != state2.s1) + XCTAssert(state1.s2 != state2.s2) + XCTAssert(next1 != next2) + + + generator = alea(seed: 12345) + generator.importState(.init(c: 0, s0: 0, s1: 0, s2: -0.5)) + let res = generator.next() + let state3 = generator.state() + XCTAssert(res == 0) + XCTAssert(state3 == .init(c: 0, s0: 0, s1: -0.5, s2: 0)) + + generator = alea(seed: "1727015666066") + let res1 = generator.next() + let state4 = generator.state() + XCTAssert(res1 == 0.6320083506871015) + XCTAssert(state4 == .init( + c: 1828249, + s0: 0.5888567129150033, + s1: 0.5074866858776659, + s2: 0.6320083506871015 + )) + + generator = alea(seed: "Seedp5fxh9kf4r0") + let res2 = generator.next() + let state5 = generator.state() + XCTAssert(res2 == 0.14867847645655274) + XCTAssert(state5 == .init( + c: 1776946, + s0: 0.6778371171094477, + s1: 0.0770602801349014, + s2: 0.14867847645655274 + )) + + generator = alea(seed: "NegativeS2Seed") + let res3 = generator.next() + let state6 = generator.state() + XCTAssert(res3 == 0.830770346801728) + XCTAssert(state6 == .init( + c: 952982, + s0: 0.25224833423271775, + s1: 0.9213257452938706, + s2: 0.830770346801728 + )) + } +} diff --git a/Tests/FSRSTests/FSRSBasicSchedulerTests.swift b/Tests/FSRSTests/FSRSBasicSchedulerTests.swift new file mode 100644 index 0000000..f64cd83 --- /dev/null +++ b/Tests/FSRSTests/FSRSBasicSchedulerTests.swift @@ -0,0 +1,111 @@ +// +// BasicSchedulerTests.swift +// FSRS +// +// Created by nkq on 10/20/24. +// + + +import XCTest +@testable import FSRS + +class FSRSBasicSchedulerTests: XCTestCase { + var params: FSRSParameters! + var algorithm: FSRS! + var now: Date! + + override func setUp() { + super.setUp() + params = FSRSDefaults().generatorParameters() + algorithm = FSRS(parameters: params) + now = Date() + } + + func testStateNewExist() { + let card = FSRSDefaults().createEmptyCard(now: now) + let basicScheduler = BasicScheduler(card: card, reviewTime: now, algorithm: algorithm) + let preview = basicScheduler.preview + let again = basicScheduler.review(.again) + let hard = basicScheduler.review(.hard) + let good = basicScheduler.review(.good) + let easy = basicScheduler.review(.easy) + + let expectedPreview: [Rating: Card] = [ + .again: again.card, + .hard: hard.card, + .good: good.card, + .easy: easy.card + ] + + // Check that preview matches expected structure + XCTAssertEqual(preview.recordLog[.again]?.card, expectedPreview[.again]) + XCTAssertEqual(preview.recordLog[.good]?.card, expectedPreview[.good]) + XCTAssertEqual(preview.recordLog[.easy]?.card, expectedPreview[.easy]) + XCTAssertEqual(preview.recordLog[.hard]?.card, expectedPreview[.hard]) + + for item in preview.recordLog { + let expectedCard = basicScheduler.review(item.value.log.rating) + XCTAssertEqual(item.value, expectedCard) + } + } + + func testStateLearningExist() { + let cardByNew = FSRSDefaults().createEmptyCard(now: now) + let card = BasicScheduler(card: cardByNew, reviewTime: now, algorithm: algorithm).review(.again).card + let basicScheduler = BasicScheduler(card: card, reviewTime: now, algorithm: algorithm) + + let preview = basicScheduler.preview + let again = basicScheduler.review(.again) + let hard = basicScheduler.review(.hard) + let good = basicScheduler.review(.good) + let easy = basicScheduler.review(.easy) + + let expectedPreview: [Rating: Card] = [ + .again: again.card, + .hard: hard.card, + .good: good.card, + .easy: easy.card + ] + + // Check that preview matches expected structure + XCTAssertEqual(preview.recordLog[.again]?.card, expectedPreview[.again]) + XCTAssertEqual(preview.recordLog[.good]?.card, expectedPreview[.good]) + XCTAssertEqual(preview.recordLog[.easy]?.card, expectedPreview[.easy]) + XCTAssertEqual(preview.recordLog[.hard]?.card, expectedPreview[.hard]) + + for item in preview.recordLog { + let expectedCard = basicScheduler.review(item.value.log.rating) + XCTAssertEqual(item.value, expectedCard) + } + } + + func testStateReviewExist() { + let cardByNew = FSRSDefaults().createEmptyCard(now: now) + let card = BasicScheduler(card: cardByNew, reviewTime: now, algorithm: algorithm).review(.easy).card + let basicScheduler = BasicScheduler(card: card, reviewTime: now, algorithm: algorithm) + + let preview = basicScheduler.preview + let again = basicScheduler.review(.again) + let hard = basicScheduler.review(.hard) + let good = basicScheduler.review(.good) + let easy = basicScheduler.review(.easy) + + let expectedPreview: [Rating: Card] = [ + .again: again.card, + .hard: hard.card, + .good: good.card, + .easy: easy.card + ] + + // Check that preview matches expected structure + XCTAssertEqual(preview.recordLog[.again]?.card, expectedPreview[.again]) + XCTAssertEqual(preview.recordLog[.good]?.card, expectedPreview[.good]) + XCTAssertEqual(preview.recordLog[.easy]?.card, expectedPreview[.easy]) + XCTAssertEqual(preview.recordLog[.hard]?.card, expectedPreview[.hard]) + + for item in preview.recordLog { + let expectedCard = basicScheduler.review(item.value.log.rating) + XCTAssertEqual(item.value, expectedCard) + } + } +} diff --git a/Tests/FSRSTests/FSRSDefaultTests.swift b/Tests/FSRSTests/FSRSDefaultTests.swift new file mode 100644 index 0000000..ed53236 --- /dev/null +++ b/Tests/FSRSTests/FSRSDefaultTests.swift @@ -0,0 +1,50 @@ +// +// FSRSDefaultTests.swift +// FSRS +// +// Created by nkq on 10/19/24. +// + + +import XCTest +@testable import FSRS + + +class YourTestClass: XCTestCase { + + func testDefaultParams() { + let expectedW = [ + 0.4072, 1.1829, 3.1262, 15.4722, 7.2102, 0.5316, 1.0651, 0.0234, 1.616, + 0.1544, 1.0824, 1.9813, 0.0953, 0.2975, 2.2042, 0.2407, 2.9466, 0.5034, + 0.6567, + ] + let defaults = FSRSDefaults() + XCTAssertEqual(defaults.defaultRequestRetention, 0.9) + XCTAssertEqual(defaults.defaultMaximumInterval, 36500) + XCTAssertEqual(defaults.defaultEnableFuzz, false) + XCTAssertEqual(defaults.defaultW.count, expectedW.count) + XCTAssertEqual(defaults.defaultW, expectedW) + + let params = defaults.generatorParameters() + + XCTAssertEqual(params.requestRetention, defaults.defaultRequestRetention) + XCTAssertEqual(params.maximumInterval, defaults.defaultMaximumInterval) + XCTAssertEqual(params.w, expectedW) + XCTAssertEqual(params.enableFuzz, defaults.defaultEnableFuzz) + } + + func testDefaultCard() { + let times = [Date(), Date(timeIntervalSince1970: 1696291200)] // Replace with the appropriate timestamp + for now in times { + let card = FSRSDefaults().createEmptyCard(now: now) + XCTAssertEqual(card.due, now) + XCTAssertEqual(card.stability, 0) + XCTAssertEqual(card.difficulty, 0) + XCTAssertEqual(card.elapsedDays, 0) + XCTAssertEqual(card.scheduledDays, 0) + XCTAssertEqual(card.reps, 0) + XCTAssertEqual(card.lapses, 0) + XCTAssertEqual(card.state.rawValue, 0) + } + } +} diff --git a/Tests/FSRSTests/FSRSElapsedDaysTests.swift b/Tests/FSRSTests/FSRSElapsedDaysTests.swift new file mode 100644 index 0000000..e802877 --- /dev/null +++ b/Tests/FSRSTests/FSRSElapsedDaysTests.swift @@ -0,0 +1,75 @@ +// +// FSRSElapsedDaysTests.swift +// +// Created by nkq on 10/19/24. +// + +import XCTest +@testable import FSRS + +class FSRSElapsedDaysTests: XCTestCase { + + var f: FSRS! + var currentLog: ReviewLog? + var card: Card! + var calendar = Calendar.current + + override func setUp() { + super.setUp() + f = FSRS(parameters: .init()) + calendar.timeZone = .init(secondsFromGMT: 0)! + let components = DateComponents(year: 2023, month: 10, day: 18, hour: 14, minute: 32, second: 03) + let createDue = calendar.date(from: components)! // UTC 2023-10-18 14:32:03.370 + card = FSRSDefaults().createEmptyCard(now: createDue) + } + + func testFirstRepeatGood() { + var components = DateComponents(year: 2023, month: 11, day: 05, hour: 08, minute: 27, second: 02) + let firstDue = calendar.date(from: components)! // UTC 2023-11-05 08:27:02.605 + var sc = f.repeat(card: card, now: firstDue) + + currentLog = sc[.good]?.log + XCTAssertEqual(currentLog?.elapsedDays, 0) + + card = sc[.good]?.card ?? card + + components = DateComponents(year: 2023, month: 11, day: 08, hour: 15, minute: 02, second: 09) + let secondDue = calendar.date(from: components)! // UTC 2023-11-08 15:02:09.791 + XCTAssertNotNil(card) + + sc = f.repeat(card: card, now: secondDue) + currentLog = sc[.again]?.log + + var expectedElapsedDays: Double = Date.dateDiff(now: secondDue, pre: card.lastReview, unit: .days) + XCTAssertEqual(currentLog?.elapsedDays, expectedElapsedDays) + XCTAssertEqual(currentLog?.elapsedDays, 3) + + card = sc[.again]?.card ?? card + + components = DateComponents(year: 2023, month: 11, day: 08, hour: 15, minute: 02, second: 30) + let thirdDue = calendar.date(from: components)! // UTC 2023-11-08 15:02:30.799 + XCTAssertNotNil(card) + + sc = f.repeat(card: card, now: thirdDue) + currentLog = sc[.again]?.log + + expectedElapsedDays = Date.dateDiff(now: thirdDue, pre: card.lastReview, unit: .days) + XCTAssertEqual(currentLog?.elapsedDays, expectedElapsedDays) + XCTAssertEqual(currentLog?.elapsedDays, 0) + + card = sc[.again]?.card ?? card + + components = DateComponents(year: 2023, month: 11, day: 08, hour: 15, minute: 04, second: 08) + let fourthDue = calendar.date(from: components)! // UTC 2023-11-08 15:04:08.739 + XCTAssertNotNil(card) + + sc = f.repeat(card: card, now: fourthDue) + currentLog = sc[.good]?.log + + expectedElapsedDays = Date.dateDiff(now: fourthDue, pre: card.lastReview, unit: .days) + XCTAssertEqual(currentLog?.elapsedDays, expectedElapsedDays) + XCTAssertEqual(currentLog?.elapsedDays, 0) + + card = sc[.good]?.card ?? card + } +} diff --git a/Tests/FSRSTests/FSRSForgetTests.swift b/Tests/FSRSTests/FSRSForgetTests.swift new file mode 100644 index 0000000..39662e2 --- /dev/null +++ b/Tests/FSRSTests/FSRSForgetTests.swift @@ -0,0 +1,83 @@ +// +// FSRSForgetTests.swift +// FSRS +// +// Created by nkq on 10/19/24. +// + + +import XCTest +@testable import FSRS + +class FSRSForgetTests: XCTestCase { + var f: FSRS! + var calendar: Calendar = { + var res = Calendar.current + res.timeZone = .init(secondsFromGMT: 0)! + return res + }() + + + override func setUp() { + super.setUp() + f = FSRS(parameters: .init( + w: [ + 1.14, 1.01, 5.44, 14.67, 5.3024, 1.5662, 1.2503, 0.0028, 1.5489, 0.1763, + 0.9953, 2.7473, 0.0179, 0.3105, 0.3976, 0.0, 2.0902, + ], + enableFuzz: false + )) + } + + func testForget() { + let card = FSRSDefaults().createEmptyCard() + + let now = calendar.date(from: DateComponents(year: 2022, month: 12, day: 29, hour: 12, minute: 30))! + let forgetNow = calendar.date(from: DateComponents(year: 2023, month: 12, day: 30, hour: 12, minute: 30))! + let schedulingCards = f.repeat(card: card, now: now) + + let grades: [Rating] = [.again, .hard, .good, .easy] + + for grade in grades { + let forgetCard = f.forget(card: schedulingCards[grade]?.card ?? Card(), now: forgetNow, resetCount: true) + XCTAssertEqual(forgetCard.card, Card( + due: forgetNow, + elapsedDays: 0, + reps: 0, + lastReview: schedulingCards[grade]?.card.lastReview + )) + XCTAssertEqual(forgetCard.log.rating, Rating.manual) + XCTAssertThrowsError(try f.rollback(card: forgetCard.card, log: forgetCard.log)) { error in + XCTAssertEqual((error as? FSRSError)?.errorReason, .invalidRating) + } + } + + for grade in grades { + let forgetCard = f.forget(card: schedulingCards[grade]?.card ?? Card(), now: forgetNow) + XCTAssertEqual(forgetCard.card, Card( + due: forgetNow, + elapsedDays: schedulingCards[grade]?.card.elapsedDays ?? 0, + reps: schedulingCards[grade]?.card.reps ?? 0, + lastReview: schedulingCards[grade]?.card.lastReview + )) + XCTAssertEqual(forgetCard.log.rating, Rating.manual) + XCTAssertThrowsError(try f.rollback(card: forgetCard.card, log: forgetCard.log)) { error in + XCTAssertEqual((error as? FSRSError)?.errorReason, .invalidRating) + } + } + } + + func testNewCardForgetResetTrue() { + let card = FSRSDefaults().createEmptyCard() + let forgetNow = calendar.date(from: DateComponents(year: 2023, month: 12, day: 30, hour: 12, minute: 30))! + let forgetCard = f.forget(card: card, now: forgetNow, resetCount: true) + XCTAssertEqual(forgetCard.card, Card(due: forgetNow, elapsedDays: 0, reps: 0)) + } + + func testNewCardForget() { + let card = FSRSDefaults().createEmptyCard() + let forgetNow = calendar.date(from: DateComponents(year: 2023, month: 12, day: 30, hour: 12, minute: 30))! + let forgetCard = f.forget(card: card, now: forgetNow, resetCount: true) + XCTAssertEqual(forgetCard.card, Card(due: forgetNow)) + } +} diff --git a/Tests/FSRSTests/FSRSFuzzSameSeedTests.swift b/Tests/FSRSTests/FSRSFuzzSameSeedTests.swift new file mode 100644 index 0000000..6f5b29f --- /dev/null +++ b/Tests/FSRSTests/FSRSFuzzSameSeedTests.swift @@ -0,0 +1,86 @@ +// +// FuzzSameSeedTests.swift +// FSRS +// +// Created by nkq on 10/20/24. +// + + +import XCTest +@testable import FSRS + +class FuzzSameSeedTests: XCTestCase { + var mockNow: Date { calendar.date(from: DateComponents(year: 2024, month: 8, day: 15))! } + var calendar: Calendar = { + var res = Calendar.current + res.timeZone = .init(secondsFromGMT: 0)! + return res + }() + + func testFuzzSameShortTerm() { + do { + let initialCard = FSRSDefaults().createEmptyCard() + let fsrsInstance = FSRS(parameters: .init()) + let card: Card = try fsrsInstance.next(card: initialCard, now: mockNow, grade: .good).card + let mockTomorrow = calendar.date(from: DateComponents(year: 2024, month: 8, day: 16))! + + var timestamps: [TimeInterval] = [] + + for _ in 0..<100 { + if #available(macOS 14.0, *) { + DispatchQueue.main.asyncAfterUnsafe(deadline: .now() + 0.05) { + do { + let scheduler = FSRS(parameters: .init(enableFuzz: true)) + let nextCard = try scheduler.next(card: card, now: mockTomorrow, grade: .good).card + timestamps.append(nextCard.due.timeIntervalSince1970) + + if timestamps.count == 100 { + let firstValue = timestamps[0] + XCTAssertTrue(timestamps.allSatisfy { $0 == firstValue }) + } + } catch { + + } + } + } else { + // Fallback on earlier versions + } + } + } catch { + + } + } + + func testFuzzSameLongTerm() { + do { + let initialCard = FSRSDefaults().createEmptyCard() + let fsrsInstance = FSRS(parameters: .init(enableShortTerm: false)) + let card = try fsrsInstance.next(card: initialCard, now: mockNow, grade: .good).card + let mockTomorrow = calendar.date(from: DateComponents(year: 2024, month: 8, day: 18))! + + var timestamps: [TimeInterval] = [] + + for _ in 0..<100 { + if #available(macOS 14.0, *) { + DispatchQueue.main.asyncAfterUnsafe(deadline: .now() + 0.05) { + do { + let scheduler = FSRS(parameters: .init(enableFuzz: true, enableShortTerm: false)) + let nextCard = try scheduler.next(card: card, now: mockTomorrow, grade: .good).card + timestamps.append(nextCard.due.timeIntervalSince1970) + + if timestamps.count == 100 { + let firstValue = timestamps[0] + XCTAssertTrue(timestamps.allSatisfy { $0 == firstValue }) + } + } catch {} + } + } else { + // Fallback on earlier versions + } + } + + } catch { + + } + } +} diff --git a/Tests/FSRSTests/FSRSLongTermSchedulerTests.swift b/Tests/FSRSTests/FSRSLongTermSchedulerTests.swift new file mode 100644 index 0000000..07bacdd --- /dev/null +++ b/Tests/FSRSTests/FSRSLongTermSchedulerTests.swift @@ -0,0 +1,369 @@ +// +// FSRSLongTermSchedulerTests.swift +// +// Created by nkq on 10/20/24. +// + + +import XCTest +@testable import FSRS + +class LongTermSchedulerTests: XCTestCase { + var params: FSRSParameters! + var algorithm: FSRS! + var calendar: Calendar = { + var res = Calendar.current + res.timeZone = .init(secondsFromGMT: 0)! + return res + }() + var dateFormatter: DateFormatter = .init() + + override func setUp() { + super.setUp() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + let w: [Double] = [ + 0.4197, 1.1869, 3.0412, 15.2441, 7.1434, 0.6477, 1.0007, 0.0674, 1.6597, + 0.1712, 1.1178, 2.0225, 0.0904, 0.3025, 2.1214, 0.2498, 2.9466, 0.4891, + 0.6468, + ] + params = FSRSDefaults().generatorParameters(props: .init(w: w, enableShortTerm: false)) + algorithm = FSRS(parameters: params) + } + + + func test1() { + let testAdditionalCases: ( + _ now: Date, + _ ratings: [Rating], + _ exivlHistory: [Int], + _ exsHistory: [Double], + _ exdHistory: [Double] + ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in + guard let self = self else { return } + var now = now + var card = FSRSDefaults().createEmptyCard() + var ivlHistory: [Int] = [] + var sHistory: [Double] = [] + var dHistory: [Double] = [] + + for rating in ratings { + let record = algorithm.repeat(card: card, now: now)[rating] + let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) + XCTAssertEqual(record, next) + + card = record!.card + ivlHistory.append(Int(card.scheduledDays)) + sHistory.append(card.stability) + dHistory.append(card.difficulty) + now = card.due + } + + XCTAssertEqual(ivlHistory, exivlHistory) + XCTAssertEqual(sHistory, exsHistory) + XCTAssertEqual(dHistory, exdHistory) + } + testAdditionalCases( + calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, + [ + .good, .good, .good, .good, .good, .good, .again, + .again, .good, .good, .good, .good, .good + ], + [3, 13, 48, 155, 445, 1158, 17, 3, 9, 27, 74, 190, 457], + [ + 3.0412, 13.09130698, 48.15848988, 154.93732625, 445.05562739, + 1158.07779739, 16.63063166, 2.98878859, 9.46334669, 26.94735845, + 73.97228121, 189.70368068, 457.43785852, + ], + [ + 4.49094334, 4.26664289, 4.05746029, 3.86237659, 3.68044154, 3.51076891, + 5.21903785, 6.81216947, 6.43141837, 6.0763299, 5.74517439, 5.43633876, + 5.14831865, + ]) + } + + func test2() { + let testAdditionalCases: ( + _ now: Date, + _ ratings: [Rating], + _ exivlHistory: [Int], + _ exsHistory: [Double], + _ exdHistory: [Double] + ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in + guard let self = self else { return } + var now = now + var card = FSRSDefaults().createEmptyCard() + var ivlHistory: [Int] = [] + var sHistory: [Double] = [] + var dHistory: [Double] = [] + + for rating in ratings { + let record = algorithm.repeat(card: card, now: now)[rating] + let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) + XCTAssertEqual(record, next) + + card = record!.card + ivlHistory.append(Int(card.scheduledDays)) + sHistory.append(card.stability) + dHistory.append(card.difficulty) + now = card.due + } + + XCTAssertEqual(ivlHistory, exivlHistory) + XCTAssertEqual(sHistory, exsHistory) + XCTAssertEqual(dHistory, exdHistory) + } + testAdditionalCases( + calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, + [ + .again, + .hard, + .good, + .easy, + .again, + .hard, + .good, + .easy, + ], + [1, 2, 5, 31, 4, 6, 14, 71], + [ + 0.4197, 1.0344317, 4.81220091, 31.07244353, 3.94952214, 5.69573414, + 14.10008388, 71.33039653, + ], + [ + 7.1434, 7.67357679, 7.23476684, 5.89227986, 7.44003496, 7.95021855, + 7.49276295, 6.13288703, + ]) + } + + func test3() { + let testAdditionalCases: ( + _ now: Date, + _ ratings: [Rating], + _ exivlHistory: [Int], + _ exsHistory: [Double], + _ exdHistory: [Double] + ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in + guard let self = self else { return } + var now = now + var card = FSRSDefaults().createEmptyCard() + var ivlHistory: [Int] = [] + var sHistory: [Double] = [] + var dHistory: [Double] = [] + + for rating in ratings { + let record = algorithm.repeat(card: card, now: now)[rating] + let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) + XCTAssertEqual(record, next) + + card = record!.card + ivlHistory.append(Int(card.scheduledDays)) + sHistory.append(card.stability) + dHistory.append(card.difficulty) + now = card.due + } + + XCTAssertEqual(ivlHistory, exivlHistory) + XCTAssertEqual(sHistory, exsHistory) + XCTAssertEqual(dHistory, exdHistory) + } + testAdditionalCases( + calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, + [ + .hard, + .good, + .easy, + .again, + .hard, + .good, + .easy, + .again, + ], + [2, 7, 54, 5, 8, 22, 130, 7], + [ + 1.1869, 6.59167572, 53.76078737, 5.13329038, 7.91598767, 22.353464, + 129.65007831, 7.25750204, + ], + [ + 6.23225985, 5.89059466, 4.63870489, 6.27095095, 6.8599308, 6.47596059, + 5.18461715, 6.78006872, + ]) + } + + func test4() { + let testAdditionalCases: ( + _ now: Date, + _ ratings: [Rating], + _ exivlHistory: [Int], + _ exsHistory: [Double], + _ exdHistory: [Double] + ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in + guard let self = self else { return } + var now = now + var card = FSRSDefaults().createEmptyCard() + var ivlHistory: [Int] = [] + var sHistory: [Double] = [] + var dHistory: [Double] = [] + + for rating in ratings { + let record = algorithm.repeat(card: card, now: now)[rating] + let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) + XCTAssertEqual(record, next) + + card = record!.card + ivlHistory.append(Int(card.scheduledDays)) + sHistory.append(card.stability) + dHistory.append(card.difficulty) + now = card.due + } + + XCTAssertEqual(ivlHistory, exivlHistory) + XCTAssertEqual(sHistory, exsHistory) + XCTAssertEqual(dHistory, exdHistory) + } + testAdditionalCases( + calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, + [ + .good, + .easy, + .again, + .hard, + .good, + .easy, + .again, + .hard, + ], + [3, 33, 4, 7, 24, 166, 8, 13], + [ + 3.0412, 32.65484522, 4.26210549, 7.16183801, 23.58957904, 166.25211957, + 8.13553136, 12.60456051, + ], + [ + 4.49094334, 3.33339007, 5.05361435, 5.72464269, 5.4171909, 4.19720854, + 5.85921145, 6.47594255, + ]) + } + + func test5() { + let testAdditionalCases: ( + _ now: Date, + _ ratings: [Rating], + _ exivlHistory: [Int], + _ exsHistory: [Double], + _ exdHistory: [Double] + ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in + guard let self = self else { return } + var now = now + var card = FSRSDefaults().createEmptyCard() + var ivlHistory: [Int] = [] + var sHistory: [Double] = [] + var dHistory: [Double] = [] + + for rating in ratings { + let record = algorithm.repeat(card: card, now: now)[rating] + let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) + XCTAssertEqual(record, next) + + card = record!.card + ivlHistory.append(Int(card.scheduledDays)) + sHistory.append(card.stability) + dHistory.append(card.difficulty) + now = card.due + } + + XCTAssertEqual(ivlHistory, exivlHistory) + XCTAssertEqual(sHistory, exsHistory) + XCTAssertEqual(dHistory, exdHistory) + } + testAdditionalCases( + calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, + [ + .easy, + .again, + .hard, + .good, + .easy, + .again, + .hard, + .good, + ], + [15, 3, 6, 26, 226, 10, 17, 55], + [ + 15.2441, 3.25621013, 6.31387378, 25.90156323, 226.22071942, 9.55915065, + 16.56937382, 55.3790909, + ], + [ + 1.16304343, 3.02954907, 3.83699941, 3.65677478, 2.55544447, 4.32810228, + 5.04803013, 4.78618203, + ]) + } + + func testStateSwitching() { + var ivlHistory: [Int] = [] + var sHistory: [Double] = [] + var dHistory: [Double] = [] + var stateHistory: [CardState] = [] + + let grades: [Rating] = [ + .good, .good, .again, .good, .good, .again + ] + let shortTerm = [true, false, false, false, true, true] + + var now = calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))! + var card = FSRSDefaults().createEmptyCard(now: now) + + for i in 0.. [ReviewState] { + var filteredReviews = reviews + if skipManual { + filteredReviews = reviews.filter { $0.rating != .manual } + } + + return filteredReviews.enumerated().reduce(into: [ReviewState]()) { state, reviewEnum in + let (index, review) = reviewEnum + + let currentCard: Card = { + if let previousState = state.last { + return Card( + due: previousState.due, + stability: previousState.stability, + difficulty: previousState.difficulty, + elapsedDays: calculateElapsedDays(state: state, index: index), + scheduledDays: calculateScheduledDays(previousState), + reps: previousState.reps, + lapses: previousState.lapses, + state: previousState.state, + lastReview: previousState.review + ) + } else { + return FSRSDefaults().createEmptyCard(now: MOCK_NOW) + } + }() + var card: Card + var log: ReviewLog + if review.rating == .manual { + + if let previousState = state.last { + log = .init( + rating: .manual, + state: .new, + due: previousState.due, + stability: previousState.stability, + difficulty: previousState.difficulty, + elapsedDays: previousState.elapsedDays, + lastElapsedDays: previousState.elapsedDays, + scheduledDays: previousState.scheduledDays, + review: review.review + ) + } else { + log = .init( + rating: .manual, + state: .new, + due: MOCK_NOW, + stability: 0, + difficulty: 0, + elapsedDays: 0, + lastElapsedDays: 0, + scheduledDays: 0, + review: review.review + ) + } + card = FSRSDefaults().createEmptyCard(now: review.review) + + } else { + let result = try! scheduler.next(card: currentCard, now: review.review, grade: review.rating) + card = result.card + log = result.log + } + + state.append(ReviewState( + difficulty: card.difficulty, + due: card.due, + rating: log.rating, + review: log.review, + stability: card.stability, + state: card.state, + reps: card.reps, + lapses: card.lapses, + elapsedDays: card.elapsedDays, + scheduledDays: card.scheduledDays + )) + } + } + + func testReschedule(scheduler: FSRS, tests: [[Rating]], options: RescheduleOptions) { + let mockNowTime = MOCK_NOW.timeIntervalSince1970 + for test in tests { + let reviews = test.enumerated().map { index, rating in + ReviewLog( + rating: rating, + state: rating == .manual ? .new : nil, + review: Date(timeIntervalSince1970: mockNowTime + TimeInterval(24 * 60 * 60 * (index + 1))) + ) + } + + let control = try? scheduler.reschedule( + currentCard: FSRSDefaults().createEmptyCard(), + reviews: reviews, + options: options + ).collections + + let experimentResult = experiment( + scheduler: scheduler, + reviews: reviews, + skipManual: options.skipManual + ) + + for (index, controlItem) in (control ?? []).enumerated() { + let experimentItem = experimentResult[index] + + XCTAssertEqual(controlItem!.card.difficulty, experimentItem.difficulty) + XCTAssertEqual(controlItem!.card.due, experimentItem.due) + XCTAssertEqual(controlItem!.card.stability, experimentItem.stability) + XCTAssertEqual(controlItem!.card.state, experimentItem.state) + XCTAssertEqual(controlItem!.card.lastReview?.timeIntervalSince1970, + experimentItem.review?.timeIntervalSince1970) + XCTAssertEqual(controlItem!.card.reps, experimentItem.reps) + XCTAssertEqual(controlItem!.card.lapses, experimentItem.lapses) + XCTAssertEqual(controlItem!.card.elapsedDays, experimentItem.elapsedDays) + XCTAssertEqual(controlItem!.card.scheduledDays, experimentItem.scheduledDays) + } + } + } + + // MARK: - Helper Functions + private func calculateElapsedDays(state: [ReviewState], index: Int) -> Double { + guard index >= 2, + let previousReview = state[index - 1].review, + let twoReviewsAgo = state[index - 2].review else { + return 0 + } + return Date.dateDiff(now: twoReviewsAgo, pre: previousReview, unit: .days) + } + + private func calculateScheduledDays(_ previousState: ReviewState) -> Double { + guard let review = previousState.review else { return 0 } + return Date.dateDiff(now: review, pre: previousState.due, unit: .days) + } + + var scheduler: FSRS! + + override func setUp() { + super.setUp() + scheduler = FSRS(parameters: .init()) + } + + func testBasicGrade() { + let grade = [Rating.again, .hard, .good, .easy] + var tests: [[Rating]] = [] + for i in 0..