├── .github └── workflows │ └── swift.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── FSRS │ ├── Algorithm │ ├── FSRS.swift │ └── FSRSAlgorithm.swift │ ├── Helper │ ├── FSRSAlea.swift │ └── FSRSHelper.swift │ ├── Models │ ├── FSRSDefaults.swift │ ├── FSRSModels.swift │ └── FSRSTypes.swift │ └── Scheduler │ ├── AbstractScheduler.swift │ ├── BasicScheduler.swift │ ├── FSRSReschedule.swift │ └── LongTermScheduler.swift └── Tests └── FSRSTests ├── FSRSAbstractSchedulerTests.swift ├── FSRSAleaTests.swift ├── FSRSBasicSchedulerTests.swift ├── FSRSCalcElapsedDaysTests.swift ├── FSRSDefaultTests.swift ├── FSRSElapsedDaysTests.swift ├── FSRSForgetTests.swift ├── FSRSFuzzSameSeedTests.swift ├── FSRSLongTermSchedulerTests.swift ├── FSRSReschduleTests.swift ├── FSRSRollbackTests.swift ├── FSRSShowDiffMessageTests.swift └── FSRSV5Tests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | paths-ignore: 10 | # - '.github/**' 11 | - ".gitignore" 12 | - "LICENSE" 13 | - "README.md" 14 | pull_request: 15 | branches: [ "main" ] 16 | paths-ignore: 17 | # - '.github/**' 18 | - ".gitignore" 19 | - "LICENSE" 20 | - "README.md" 21 | jobs: 22 | build: 23 | 24 | runs-on: macos-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Build 29 | run: swift build -v 30 | - name: Run tests 31 | run: swift test -v --enable-code-coverage 32 | 33 | - name: Test coverage 34 | uses: ningkaiqiang/spm-lcov-action@master 35 | with: 36 | output-file: ./coverage/lcov.info 37 | 38 | - name: Upload coverage reports to Codecov 39 | uses: codecov/codecov-action@v4 40 | with: 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | file: ./coverage/lcov.info 43 | flags: unittests 44 | name: codecov-umbrella 45 | fail_ci_if_error: true 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | FSRS.xcodeproj/project.xcworkspace/xcuserdata/ 3 | FSRS.xcodeproj/xcuserdata/ 4 | .swiftpm 5 | .build 6 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ben Smiley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10.0 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "FSRS", 8 | platforms: [ 9 | .macOS(.v10_13), .iOS(.v14), 10 | ], 11 | products: [ 12 | .library( 13 | name: "FSRS", 14 | targets: ["FSRS"]), 15 | ], 16 | targets: [ 17 | .target( 18 | name: "FSRS", 19 | path: "Sources/FSRS/" 20 | ), 21 | .testTarget( 22 | name: "FSRSTests", 23 | dependencies: ["FSRS"], 24 | path: "./Tests/FSRSTests" 25 | ), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A Swift implementation of FSRS5.0 2 | 3 | [![codecov](https://codecov.io/gh/open-spaced-repetition/swift-fsrs/graph/badge.svg?token=K2C0Z5PFEH)](https://codecov.io/gh/open-spaced-repetition/swift-fsrs) 4 | -------------------------------------------------------------------------------- /Sources/FSRS/Algorithm/FSRS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRS.swift 3 | // 4 | // Created by nkq on 10/13/24. 5 | // 6 | 7 | import Foundation 8 | 9 | public class FSRS: FSRSAlgorithm { 10 | 11 | override func processparameters(_ parameters: FSRSParameters) { 12 | let parameters = defaults.generatorParameters(props: parameters) 13 | if parameters.requestRetention.isFinite { 14 | do { 15 | intervalModifier = try calculateIntervalModifier(requestRetention: parameters.requestRetention) 16 | } catch { 17 | print(error.localizedDescription) 18 | } 19 | } 20 | if parameters != self.parameters { 21 | self.parameters = parameters 22 | } 23 | } 24 | 25 | override public init(parameters: FSRSParameters) { 26 | super.init(parameters: parameters) 27 | } 28 | 29 | /** 30 | * Display the collection of cards and logs for the four scenarios after scheduling the card at the current time. 31 | * @param card Card to be processed 32 | * @param now Current time or scheduled time 33 | * @param afterHandler Convert the result to another type. (Optional) 34 | * @example 35 | * ``` 36 | * const card: Card = createEmptyCard(new Date()); 37 | * const f = fsrs(); 38 | * const recordLog = f.repeat(card, new Date()); 39 | * ``` 40 | * @example 41 | * ``` 42 | * interface RevLogUnchecked 43 | * extends Omit { 44 | * cid: string; 45 | * due: Date | number; 46 | * state: StateType; 47 | * review: Date | number; 48 | * rating: RatingType; 49 | * } 50 | * 51 | * interface RepeatRecordLog { 52 | * card: CardUnChecked; //see method: createEmptyCard 53 | * log: RevLogUnchecked; 54 | * } 55 | * 56 | * function repeatAfterHandler(recordLog: RecordLog) { 57 | * const record: { [key in Grade]: RepeatRecordLog } = {} as { 58 | * [key in Grade]: RepeatRecordLog; 59 | * }; 60 | * for (const grade of Grades) { 61 | * record[grade] = { 62 | * card: { 63 | * ...(recordLog[grade].card as Card & { cid: string }), 64 | * due: recordLog[grade].card.due.getTime(), 65 | * state: State[recordLog[grade].card.state] as StateType, 66 | * last_review: recordLog[grade].card.last_review 67 | * ? recordLog[grade].card.last_review!.getTime() 68 | * : null, 69 | * }, 70 | * log: { 71 | * ...recordLog[grade].log, 72 | * cid: (recordLog[grade].card as Card & { cid: string }).cid, 73 | * due: recordLog[grade].log.due.getTime(), 74 | * review: recordLog[grade].log.review.getTime(), 75 | * state: State[recordLog[grade].log.state] as StateType, 76 | * rating: Rating[recordLog[grade].log.rating] as RatingType, 77 | * }, 78 | * }; 79 | * } 80 | * return record; 81 | * } 82 | * const card: Card = createEmptyCard(new Date(), cardAfterHandler); //see method: createEmptyCard 83 | * const f = fsrs(); 84 | * const recordLog = f.repeat(card, new Date(), repeatAfterHandler); 85 | * ``` 86 | */ 87 | public func `repeat`( 88 | card: Card, 89 | now: Date, 90 | _ completion: ((_ log: IPreview) -> IPreview)? = nil 91 | ) -> IPreview { 92 | let obj = params.enableShortTerm 93 | ? BasicScheduler(card: card, reviewTime: now, algorithm: self) 94 | : LongTermScheduler(card: card, reviewTime: now, algorithm: self) 95 | let log = obj.preview 96 | if let completion = completion { 97 | return completion(log) 98 | } else { 99 | return log 100 | } 101 | } 102 | 103 | /** 104 | * Display the collection of cards and logs for the card scheduled at the current time, after applying a specific grade rating. 105 | * @param card Card to be processed 106 | * @param now Current time or scheduled time 107 | * @param grade Rating of the review (Again, Hard, Good, Easy) 108 | * @param afterHandler Convert the result to another type. (Optional) 109 | * @example 110 | * ``` 111 | * const card: Card = createEmptyCard(new Date()); 112 | * const f = fsrs(); 113 | * const recordLogItem = f.next(card, new Date(), Rating.Again); 114 | * ``` 115 | * @example 116 | * ``` 117 | * interface RevLogUnchecked 118 | * extends Omit { 119 | * cid: string; 120 | * due: Date | number; 121 | * state: StateType; 122 | * review: Date | number; 123 | * rating: RatingType; 124 | * } 125 | * 126 | * interface NextRecordLog { 127 | * card: CardUnChecked; //see method: createEmptyCard 128 | * log: RevLogUnchecked; 129 | * } 130 | * 131 | function nextAfterHandler(recordLogItem: RecordLogItem) { 132 | const recordItem = { 133 | card: { 134 | ...(recordLogItem.card as Card & { cid: string }), 135 | due: recordLogItem.card.due.getTime(), 136 | state: State[recordLogItem.card.state] as StateType, 137 | last_review: recordLogItem.card.last_review 138 | ? recordLogItem.card.last_review!.getTime() 139 | : null, 140 | }, 141 | log: { 142 | ...recordLogItem.log, 143 | cid: (recordLogItem.card as Card & { cid: string }).cid, 144 | due: recordLogItem.log.due.getTime(), 145 | review: recordLogItem.log.review.getTime(), 146 | state: State[recordLogItem.log.state] as StateType, 147 | rating: Rating[recordLogItem.log.rating] as RatingType, 148 | }, 149 | }; 150 | return recordItem 151 | } 152 | * const card: Card = createEmptyCard(new Date(), cardAfterHandler); //see method: createEmptyCard 153 | * const f = fsrs(); 154 | * const recordLogItem = f.repeat(card, new Date(), Rating.Again, nextAfterHandler); 155 | * ``` 156 | */ 157 | public func next( 158 | card: Card, 159 | now: Date, 160 | grade: Rating, 161 | completion: ((_ log: RecordLogItem) -> RecordLogItem)? = nil 162 | ) throws -> RecordLogItem { 163 | if grade == .manual { 164 | throw FSRSError(.invalidRating, "Cannot review a manual rating") 165 | } 166 | let obj = params.enableShortTerm 167 | ? BasicScheduler(card: card, reviewTime: now, algorithm: self) 168 | : LongTermScheduler(card: card, reviewTime: now, algorithm: self) 169 | let log = obj.review(grade) 170 | if let completion = completion { 171 | return completion(log) 172 | } else { 173 | return log 174 | } 175 | } 176 | 177 | /** 178 | * Get the retrievability of the card 179 | * @param card Card to be processed 180 | * @param now Current time or scheduled time 181 | * @param format default:true , Convert the result to another type. (Optional) 182 | * @returns The retrievability of the card,if format is true, the result is a string, otherwise it is a number 183 | */ 184 | func getRetrievability( 185 | card: Card, 186 | now: Date = Date() 187 | ) -> (string: String, number: Double) { 188 | let processed = card.newCard 189 | let time = processed.state != .new 190 | ? max(Date.dateDiff(now: now, pre: processed.lastReview, unit: .days), 0) 191 | : 0 192 | let retrievability = processed.state != .new 193 | ? forgettingCurve(elapsedDays: time, stability: processed.stability.toFixedNumber(8)) 194 | : 0 195 | return ("\((retrievability * 100).toFixed(2))%", retrievability) 196 | } 197 | 198 | /** 199 | * 200 | * @param card Card to be processed 201 | * @param log last review log 202 | * @param afterHandler Convert the result to another type. (Optional) 203 | * @example 204 | * ``` 205 | * const now = new Date(); 206 | * const f = fsrs(); 207 | * const emptyCardFormAfterHandler = createEmptyCard(now); 208 | * const repeatFormAfterHandler = f.repeat(emptyCardFormAfterHandler, now); 209 | * const { card, log } = repeatFormAfterHandler[Rating.Hard]; 210 | * const rollbackFromAfterHandler = f.rollback(card, log); 211 | * ``` 212 | * 213 | * @example 214 | * ``` 215 | * const now = new Date(); 216 | * const f = fsrs(); 217 | * const emptyCardFormAfterHandler = createEmptyCard(now, cardAfterHandler); //see method: createEmptyCard 218 | * const repeatFormAfterHandler = f.repeat(emptyCardFormAfterHandler, now, repeatAfterHandler); //see method: fsrs.repeat() 219 | * const { card, log } = repeatFormAfterHandler[Rating.Hard]; 220 | * const rollbackFromAfterHandler = f.rollback(card, log, cardAfterHandler); 221 | * ``` 222 | */ 223 | public func rollback( 224 | card: Card, 225 | log: ReviewLog, 226 | completion: ((Card) -> Card)? = nil 227 | ) throws -> Card { 228 | let processdCard = card.newCard 229 | let processedLog = log.newLog 230 | 231 | guard processedLog.rating != .manual else { 232 | throw FSRSError(.invalidRating, "Cannot rollback a manual rating") 233 | } 234 | var lastDue: Date, lastReview: Date?, lastLapses: Int 235 | guard let state = processedLog.state else { 236 | throw FSRSError(.invalidParam, "Rollback card must have a state") 237 | } 238 | switch state { 239 | case .new: 240 | guard let due = processedLog.due else { 241 | throw FSRSError(.invalidParam, "Rollback card must have a due date") 242 | } 243 | lastDue = due 244 | lastReview = nil 245 | lastLapses = 0 246 | case .learning, .review, .relearning: 247 | lastDue = processedLog.review 248 | lastReview = processedLog.due 249 | lastLapses = processdCard.lapses - ( 250 | (processedLog.rating == .again && processedLog.state == .review) ? 1 : 0 251 | ) 252 | } 253 | var previousCard = processdCard.newCard 254 | previousCard.due = lastDue 255 | previousCard.stability = processedLog.stability ?? 0 256 | previousCard.difficulty = processedLog.difficulty ?? 0 257 | previousCard.elapsedDays = processedLog.lastElapsedDays 258 | previousCard.scheduledDays = processedLog.scheduledDays 259 | previousCard.reps = max(0, processdCard.reps - 1) 260 | previousCard.lapses = max(0, lastLapses) 261 | previousCard.state = state 262 | previousCard.lastReview = lastReview 263 | 264 | if let completion = completion { 265 | return completion(previousCard) 266 | } else { 267 | return previousCard 268 | } 269 | } 270 | 271 | /** 272 | * 273 | * @param card Card to be processed 274 | * @param now Current time or scheduled time 275 | * @param reset_count Should the review count information(reps,lapses) be reset. (Optional) 276 | * @param afterHandler Convert the result to another type. (Optional) 277 | * @example 278 | * ``` 279 | * const now = new Date(); 280 | * const f = fsrs(); 281 | * const emptyCard = createEmptyCard(now); 282 | * const scheduling_cards = f.repeat(emptyCard, now); 283 | * const { card, log } = scheduling_cards[Rating.Hard]; 284 | * const forgetCard = f.forget(card, new Date(), true); 285 | * ``` 286 | * 287 | * @example 288 | * ``` 289 | * interface RepeatRecordLog { 290 | * card: CardUnChecked; //see method: createEmptyCard 291 | * log: RevLogUnchecked; //see method: fsrs.repeat() 292 | * } 293 | * 294 | * function forgetAfterHandler(recordLogItem: RecordLogItem): RepeatRecordLog { 295 | * return { 296 | * card: { 297 | * ...(recordLogItem.card as Card & { cid: string }), 298 | * due: recordLogItem.card.due.getTime(), 299 | * state: State[recordLogItem.card.state] as StateType, 300 | * last_review: recordLogItem.card.last_review 301 | * ? recordLogItem.card.last_review!.getTime() 302 | * : null, 303 | * }, 304 | * log: { 305 | * ...recordLogItem.log, 306 | * cid: (recordLogItem.card as Card & { cid: string }).cid, 307 | * due: recordLogItem.log.due.getTime(), 308 | * review: recordLogItem.log.review.getTime(), 309 | * state: State[recordLogItem.log.state] as StateType, 310 | * rating: Rating[recordLogItem.log.rating] as RatingType, 311 | * }, 312 | * }; 313 | * } 314 | * const now = new Date(); 315 | * const f = fsrs(); 316 | * const emptyCardFormAfterHandler = createEmptyCard(now, cardAfterHandler); //see method: createEmptyCard 317 | * const repeatFormAfterHandler = f.repeat(emptyCardFormAfterHandler, now, repeatAfterHandler); //see method: fsrs.repeat() 318 | * const { card } = repeatFormAfterHandler[Rating.Hard]; 319 | * const forgetFromAfterHandler = f.forget(card, date_scheduler(now, 1, true), false, forgetAfterHandler); 320 | * ``` 321 | */ 322 | public func forget( 323 | card: Card, 324 | now: Date, 325 | resetCount: Bool = false, 326 | _ completion: ((_ recordLogItem: RecordLogItem) -> RecordLogItem)? = nil 327 | ) -> RecordLogItem { 328 | let processedCard = card.newCard 329 | let scheduledDay = processedCard.state == .new 330 | ? 0 331 | : Date.dateDiff(now: now, pre: processedCard.lastReview, unit: .days) 332 | let forgetLog = ReviewLog( 333 | rating: .manual, 334 | state: processedCard.state, 335 | due: processedCard.due, 336 | stability: processedCard.stability, 337 | difficulty: processedCard.difficulty, 338 | elapsedDays: 0, 339 | lastElapsedDays: processedCard.elapsedDays, 340 | scheduledDays: scheduledDay, 341 | review: now 342 | ) 343 | let forgetCard = Card( 344 | due: now, 345 | reps: resetCount ? 0 : processedCard.reps, 346 | lapses: resetCount ? 0 : processedCard.lapses, 347 | state: .new, 348 | lastReview: processedCard.lastReview 349 | ) 350 | let log = RecordLogItem(card: forgetCard, log: forgetLog) 351 | if let completion = completion { 352 | return completion(log) 353 | } else { 354 | return log 355 | } 356 | } 357 | 358 | 359 | /** 360 | * Reschedules the current card and returns the rescheduled collections and reschedule item. 361 | * 362 | * @template T - The type of the record log item. 363 | * @param {CardInput | Card} current_card - The current card to be rescheduled. 364 | * @param {Array} reviews - The array of FSRSHistory objects representing the reviews. 365 | * @param {Partial>} options - The optional reschedule options. 366 | * @returns {IReschedule} - The rescheduled collections and reschedule item. 367 | * 368 | * @example 369 | * ``` 370 | const f = fsrs() 371 | const grades: Grade[] = [Rating.Good, Rating.Good, Rating.Good, Rating.Good] 372 | const reviews_at = [ 373 | new Date(2024, 8, 13), 374 | new Date(2024, 8, 13), 375 | new Date(2024, 8, 17), 376 | new Date(2024, 8, 28), 377 | ] 378 | 379 | const reviews: FSRSHistory[] = [] 380 | for (let i = 0; i < grades.length; i++) { 381 | reviews.push({ 382 | rating: grades[i], 383 | review: reviews_at[i], 384 | }) 385 | } 386 | 387 | const results_short = scheduler.reschedule( 388 | createEmptyCard(), 389 | reviews, 390 | { 391 | skipManual: false, 392 | } 393 | ) 394 | console.log(results_short) 395 | * ``` 396 | */ 397 | public func reschedule( 398 | currentCard: Card, 399 | reviews: [ReviewLog], 400 | options: RescheduleOptions 401 | ) throws -> IReschedule { 402 | var reviews = reviews 403 | if let sortOrder = options.reviewsOrderBy { 404 | reviews.sort(by: sortOrder) 405 | } 406 | if options.skipManual { 407 | reviews = reviews.filter({ $0.rating != .manual }) 408 | } 409 | let rescheduleSvc = FSRSReschedule(fsrs: self) 410 | let items = try rescheduleSvc.reschedule( 411 | currentCard: options.firstCard ?? FSRSDefaults().createEmptyCard(), 412 | reviews: reviews 413 | ) 414 | 415 | let curCard = currentCard.newCard 416 | let manualItem = try rescheduleSvc.calculateManualRecord( 417 | currentCard: curCard, 418 | now: options.now, 419 | recordLogItem: items.last ?? nil, 420 | updateMemory: options.updateMemoryState 421 | ) 422 | if let handler = options.recordLogHandler { 423 | return .init( 424 | collections: items.map(handler), 425 | rescheduleItem: manualItem == nil ? nil : handler(manualItem) 426 | ) 427 | } else { 428 | return .init(collections: items, rescheduleItem: manualItem) 429 | } 430 | } 431 | } 432 | -------------------------------------------------------------------------------- /Sources/FSRS/Algorithm/FSRSAlgorithm.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSAlgorithm.swift 3 | // 4 | // Created by nkq on 10/14/24. 5 | // 6 | 7 | import Foundation 8 | /** 9 | * @see https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm#fsrs-45 10 | */ 11 | public class FSRSAlgorithm { 12 | /** 13 | * @default DECAY = -0.5 14 | */ 15 | let decay = -0.5 16 | /** 17 | * FACTOR = Math.pow(0.9, 1 / DECAY) - 1= 19 / 81 18 | * 19 | * $$\text{FACTOR} = \frac{19}{81}$$ 20 | * @default FACTOR = 19 / 81 21 | */ 22 | let factor: Double = 19 / 81 23 | 24 | let defaults = FSRSDefaults() 25 | 26 | internal var parameters: FSRSParameters 27 | 28 | var params: FSRSParameters { 29 | get { 30 | parameters 31 | } 32 | set { 33 | processparameters(newValue) 34 | } 35 | } 36 | 37 | func processparameters(_ parameters: FSRSParameters) { 38 | let parameters = defaults.generatorParameters(props: parameters) 39 | if parameters.requestRetention.isFinite { 40 | do { 41 | intervalModifier = try calculateIntervalModifier( 42 | requestRetention: parameters.requestRetention 43 | ) 44 | } catch { 45 | print(error.localizedDescription) 46 | } 47 | 48 | } 49 | if parameters != self.parameters { 50 | self.parameters = parameters 51 | } 52 | } 53 | 54 | var intervalModifier: Double = 1 55 | var seed: String? 56 | 57 | init(parameters: FSRSParameters) { 58 | self.parameters = parameters 59 | processparameters(parameters) 60 | } 61 | 62 | /** 63 | * @see https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm#fsrs-45 64 | * 65 | * The formula used is: $$I(r,s) = (r^{\frac{1}{DECAY}} - 1) / FACTOR \times s$$ 66 | * @param request_retention 0 Double { 70 | guard requestRetention > 0 && requestRetention <= 1 else { 71 | throw FSRSError(.invalidRetention, "Requested retention rate should be in the range (0,1]") 72 | } 73 | let result = (pow(requestRetention, 1 / decay) - 1.0) / factor 74 | return result.toFixedNumber(8) 75 | } 76 | 77 | /** 78 | * The formula used is : 79 | * $$ S_0(G) = w_{G-1}$$ 80 | * $$S_0 = \max \lbrace S_0,0.1\rbrace $$ 81 | 82 | * @param g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] 83 | * @return Stability (interval when R=90%) 84 | */ 85 | func initStability(g: Rating) -> Double { 86 | max(parameters.w[g.rawValue - 1], 0.1) 87 | } 88 | 89 | /** 90 | * The formula used is : 91 | * $$D_0(G) = w_4 - e^{(G-1) \cdot w_5} + 1 $$ 92 | * $$D_0 = \min \lbrace \max \lbrace D_0(G),1 \rbrace,10 \rbrace$$ 93 | * where the $$D_0(1)=w_4$$ when the first rating is good. 94 | * 95 | * @param {Grade} g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] 96 | * @return {number} Difficulty $$D \in [1,10]$$ 97 | */ 98 | func initDifficulty(_ grade: Rating) -> Double { 99 | constrainDifficulty( 100 | r: parameters.w[4] - exp((Double(grade.rawValue) - 1) * parameters.w[5]) + 1 101 | ) 102 | } 103 | 104 | func constrainDifficulty(r: Double) -> Double { 105 | min(max(r.toFixedNumber(8), 1.0), 10.0) 106 | } 107 | 108 | /** 109 | * If fuzzing is disabled or ivl is less than 2.5, it returns the original interval. 110 | * @param {number} ivl - The interval to be fuzzed. 111 | * @param {number} elapsed_days t days since the last review 112 | * @return {number} - The fuzzed interval. 113 | **/ 114 | func applyFuzz(ivl: Double, elapsedDays: Double) -> Int { 115 | guard parameters.enableFuzz && ivl >= 2.5 else { return Int(round(ivl)) } 116 | let genetaor = alea(seed: seed) 117 | let fuzzFactor = genetaor.next() 118 | let ivls = FSRSHelper.getFuzzRange( 119 | interval: ivl, 120 | elapsedDays: elapsedDays, 121 | maximumInterval: parameters.maximumInterval 122 | ) 123 | return Int(floor(fuzzFactor * (ivls.maxIvl - ivls.minIvl + 1) + ivls.minIvl)) 124 | } 125 | 126 | /** 127 | * @see The formula used is : {@link FSRSAlgorithm.calculate_interval_modifier} 128 | * @param {number} s - Stability (interval when R=90%) 129 | * @param {number} elapsed_days t days since the last review 130 | */ 131 | func nextInterval(s: Double, elapsedDays: Double) -> Int { 132 | let newInterval = min(max(1, round(s * intervalModifier)), parameters.maximumInterval) 133 | return applyFuzz(ivl: newInterval, elapsedDays: elapsedDays) 134 | } 135 | 136 | /** 137 | * @see https://github.com/open-spaced-repetition/fsrs4anki/issues/697 138 | */ 139 | func linearDamping(deltaD: Double, oldD: Double) -> Double { 140 | (deltaD * (10 - oldD) / 9).toFixedNumber(8) 141 | } 142 | 143 | /** 144 | * The formula used is : 145 | * $$\text{delta}_d = -w_6 \cdot (g - 3)$$ 146 | * $$\text{next}_d = D + \text{linear damping}(\text{delta}_d , D)$$ 147 | * $$D^\prime(D,R) = w_7 \cdot D_0(4) +(1 - w_7) \cdot \text{next}_d$$ 148 | * @param {number} d Difficulty $$D \in [1,10]$$ 149 | * @param {Grade} g Grade (rating at Anki) [1.again,2.hard,3.good,4.easy] 150 | * @return {number} $$\text{next}_D$$ 151 | */ 152 | func nextDifficulty(d: Double, g: Rating) -> Double { 153 | let deltaD = -(parameters.w[6] * Double(g.rawValue - 3)) 154 | let nextD = d + linearDamping(deltaD: deltaD, oldD: d) 155 | return constrainDifficulty(r: meanReversion(initValue: initDifficulty(.easy), current: nextD)) 156 | } 157 | 158 | /** 159 | * The formula used is : 160 | * $$w_7 \cdot \text{init} +(1 - w_7) \cdot \text{current}$$ 161 | * @param {number} init $$w_2 : D_0(3) = w_2 + (R-2) \cdot w_3= w_2$$ 162 | * @param {number} current $$D - w_6 \cdot (R - 2)$$ 163 | * @return {number} difficulty 164 | */ 165 | func meanReversion(initValue: Double, current: Double) -> Double { 166 | (parameters.w[7] * initValue + (1 - parameters.w[7]) * current).toFixedNumber(8) 167 | } 168 | 169 | func nextRecallStability(d: Double, s: Double, r: Double, g: Rating) -> Double { 170 | let hardPenalty = g == .hard ? parameters.w[15] : 1 171 | let easyBound = g == .easy ? parameters.w[16] : 1 172 | return FSRSHelper.clamp( 173 | s * ( 174 | 1 + exp(parameters.w[8]) * (11 - d) * pow(s, -(parameters.w[9])) * 175 | (exp((1 - r) * parameters.w[10]) - 1) * hardPenalty * easyBound 176 | ), 177 | 0.01, 178 | 36500 179 | ) 180 | .toFixedNumber(8) 181 | } 182 | 183 | /** 184 | * The formula used is : 185 | * $$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)}$$ 186 | * enable_short_term = true : $$S^\prime_f \in \min \lbrace \max \lbrace S^\prime_f,0.01\rbrace, \frac{S}{e^{w_{17} \cdot w_{18}}} \rbrace$$ 187 | * enable_short_term = false : $$S^\prime_f \in \min \lbrace \max \lbrace S^\prime_f,0.01\rbrace, S \rbrace$$ 188 | * @param {number} d Difficulty D \in [1,10] 189 | * @param {number} s Stability (interval when R=90%) 190 | * @param {number} r Retrievability (probability of recall) 191 | * @return {number} S^\prime_f new stability after forgetting 192 | */ 193 | func nextForgetStability(d: Double, s: Double, r: Double) -> Double { 194 | let p1 = pow(d, -(parameters.w[12])) 195 | let p2 = pow(s + 1, parameters.w[13]) - 1 196 | let p3 = exp((1 - r) * parameters.w[14]) 197 | return FSRSHelper.clamp( 198 | parameters.w[11] * p1 * p2 * p3, 199 | FSRSDefaults.S_MIN, 200 | 36500 201 | ).toFixedNumber(8) 202 | } 203 | 204 | /** 205 | * The formula used is : 206 | * $$S^\prime_s(S,G) = S \cdot e^{w_{17} \cdot (G-3+w_{18})}$$ 207 | * @param {number} s Stability (interval when R=90%) 208 | * @param {Grade} g Grade (Rating[0.again,1.hard,2.good,3.easy]) 209 | */ 210 | func nextShortTermStability(s: Double, g: Rating) -> Double { 211 | let part = Double(g.rawValue) - 3 + parameters.w[18] 212 | return FSRSHelper.clamp( 213 | s * exp(parameters.w[17] * part), 214 | FSRSDefaults.S_MIN, 215 | 36500 216 | ).toFixedNumber(8) 217 | } 218 | 219 | /** 220 | * The formula used is : 221 | * $$R(t,S) = (1 + \text{FACTOR} \times \frac{t}{9 \cdot S})^{\text{DECAY}}$$ 222 | * @param {number} elapsed_days t days since the last review 223 | * @param {number} stability Stability (interval when R=90%) 224 | * @return {number} r Retrievability (probability of recall) 225 | */ 226 | func forgettingCurve(elapsedDays: Double, stability: Double) -> Double { 227 | pow(1 + ((factor * elapsedDays) / stability), decay).toFixedNumber(8) 228 | } 229 | 230 | /** 231 | * Calculates the next state of memory based on the current state, time elapsed, and grade. 232 | * 233 | * @param memory_state - The current state of memory, which can be null. 234 | * @param t - The time elapsed since the last review. 235 | * @param {Rating} g Grade (Rating[0.Manual,1.Again,2.Hard,3.Good,4.Easy]) 236 | * @returns The next state of memory with updated difficulty and stability. 237 | */ 238 | func nextState(memoryState: FSRSState?, t: Double, g: Rating) throws -> FSRSState { 239 | var difficulty = memoryState?.difficulty ?? 0.0 240 | var stability = memoryState?.stability ?? 0.0 241 | if t < 0 { 242 | throw FSRSError.init(.invalidDeltaT) 243 | } 244 | if difficulty == 0 && stability == 0 { 245 | return FSRSState(stability: initStability(g: g), difficulty: initDifficulty(g)) 246 | } 247 | if g == .manual { 248 | return FSRSState(stability: stability, difficulty: difficulty) 249 | } 250 | if difficulty < 1 || stability < FSRSDefaults.S_MIN { 251 | throw FSRSError(.invalidParam) 252 | } 253 | let r = forgettingCurve(elapsedDays: t, stability: stability) 254 | let sAfterSuccess = nextRecallStability(d: difficulty, s: stability, r: r, g: g) 255 | let sAfterFail = nextForgetStability(d: difficulty, s: stability, r: r) 256 | let sAfterShortTerm = nextShortTermStability(s: stability, g: g) 257 | var newS = sAfterSuccess 258 | 259 | if g == .again { 260 | var w17 = 0.0 261 | var w18 = 0.0 262 | if params.enableShortTerm { 263 | w17 = params.w[17] 264 | w18 = params.w[18] 265 | } 266 | let nextSMin = stability / exp(w17 * w18) 267 | newS = FSRSHelper.clamp(nextSMin, FSRSDefaults.S_MIN, sAfterFail) 268 | } 269 | 270 | if t == 0 && params.enableShortTerm { 271 | newS = sAfterShortTerm 272 | } 273 | 274 | var newD = nextDifficulty(d: difficulty, g: g) 275 | return FSRSState(stability: newS, difficulty: newD) 276 | } 277 | } 278 | -------------------------------------------------------------------------------- /Sources/FSRS/Helper/FSRSAlea.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSAlea.swift 3 | // 4 | // Created by nkq on 10/13/24. 5 | // 6 | 7 | import Foundation 8 | import JavaScriptCore 9 | 10 | class FSRSAlea { 11 | struct State: Equatable { 12 | var c: Int 13 | var s0: Double 14 | var s1: Double 15 | var s2: Double 16 | } 17 | 18 | private var c: Int 19 | private var s0: Double 20 | private var s1: Double 21 | private var s2: Double 22 | 23 | init(seed: Any? = nil) { 24 | let mash = MashWrapper() 25 | c = 1 26 | s0 = mash.do(" ") 27 | s1 = mash.do(" ") 28 | s2 = mash.do(" ") 29 | 30 | let seedValue: String = String(describing: seed ?? Date().timeIntervalSince1970) 31 | s0 -= mash.do(seedValue) 32 | if s0 < 0 { s0 += 1 } 33 | s1 -= mash.do(seedValue) 34 | if s1 < 0 { s1 += 1 } 35 | s2 -= mash.do(seedValue) 36 | if s2 < 0 { s2 += 1 } 37 | } 38 | 39 | func next() -> Double { 40 | let t = 2091639 * s0 + Double(c) * 2.3283064365386963e-10 // 2^-32 41 | s0 = s1 42 | s1 = s2 43 | c = Int(floor(t)) 44 | s2 = t - floor(t) 45 | return s2 46 | } 47 | 48 | var state: State { 49 | get { 50 | State(c: c, s0: s0, s1: s1, s2: s2) 51 | } 52 | set { 53 | c = newValue.c 54 | s0 = newValue.s0 55 | s1 = newValue.s1 56 | s2 = newValue.s2 57 | } 58 | } 59 | } 60 | 61 | struct MashWrapper { 62 | var helper: JSContext? = { 63 | let context = JSContext() 64 | context?.exceptionHandler = { 65 | print($0.debugDescription) 66 | print($1.debugDescription) 67 | } 68 | context?.evaluateScript( 69 | """ 70 | function Mash() { 71 | let n = 0xefc8249d; 72 | return function mash(data) { 73 | data = String(data); 74 | for (let i = 0; i < data.length; i++) { 75 | n += data.charCodeAt(i); 76 | let h = 0.02519603282416938 * n; 77 | n = h >>> 0; 78 | h -= n; 79 | h *= n; 80 | n = h >>> 0; 81 | h -= n; 82 | n += h * 0x100000000; // 2^32 83 | } 84 | return (n >>> 0) * 2.3283064365386963e-10; // 2^-32 85 | } 86 | } 87 | const mash = Mash() 88 | """ 89 | ) 90 | return context 91 | }() 92 | 93 | func `do`(_ data: String) -> Double { 94 | let value = helper?.evaluateScript( 95 | "mash('\(data)')" 96 | ) 97 | return value?.toDouble() ?? 0 98 | } 99 | } 100 | 101 | protocol PRNG { 102 | func next() -> Double 103 | func int32() -> Int32 104 | func double() -> Double 105 | func state() -> FSRSAlea.State 106 | func importState(_ state: FSRSAlea.State) 107 | } 108 | 109 | struct RandomNumberGeneratorWrapper: PRNG { 110 | private let alea: FSRSAlea 111 | 112 | init(seed: Any? = nil) { 113 | alea = FSRSAlea(seed: seed) 114 | } 115 | 116 | func next() -> Double { 117 | alea.next() 118 | } 119 | 120 | func int32() -> Int32 { 121 | Int32(truncatingIfNeeded: Int(alea.next() * Double(0x100000000))) 122 | } 123 | 124 | func double() -> Double { 125 | next() + Double(UInt(next() * 0x200000)) * 1.1102230246251565e-16 // 2^-53 126 | } 127 | 128 | func state() -> FSRSAlea.State { 129 | alea.state 130 | } 131 | 132 | func importState(_ state: FSRSAlea.State) { 133 | alea.state = state 134 | } 135 | } 136 | 137 | func alea(seed: Any? = nil) -> RandomNumberGeneratorWrapper { 138 | RandomNumberGeneratorWrapper(seed: seed) 139 | } 140 | 141 | -------------------------------------------------------------------------------- /Sources/FSRS/Helper/FSRSHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSHelper.swift 3 | // 4 | // Created by nkq on 10/14/24. 5 | // 6 | 7 | import Foundation 8 | 9 | class FSRSHelper { 10 | struct FuzzRange { 11 | let start: Double 12 | let end: Double 13 | let factor: Double 14 | } 15 | 16 | static let fuzzRanges = [ 17 | FuzzRange(start: 2.5, end: 7.0, factor: 0.15), 18 | .init(start: 7.0, end: 20.0, factor: 0.1), 19 | .init(start: 20, end: .infinity, factor: 0.05) 20 | ] 21 | 22 | static func getFuzzRange( 23 | interval: Double, 24 | elapsedDays: Double, 25 | maximumInterval: Double 26 | ) -> (minIvl: Double, maxIvl: Double) { 27 | var delta = 1.0 28 | for range in fuzzRanges { 29 | delta += range.factor * max(min(interval, range.end) - range.start, 0.0) 30 | } 31 | let newInterval = min(interval, maximumInterval) 32 | var minIvl = max(2, round(newInterval - delta)) 33 | let maxIvl = min(round(newInterval + delta), maximumInterval) 34 | if newInterval > elapsedDays { 35 | minIvl = max(minIvl, elapsedDays + 1) 36 | } 37 | minIvl = min(minIvl, maxIvl) 38 | return (minIvl, maxIvl) 39 | } 40 | 41 | static func clamp(_ value: Double, _ minV: Double, _ maxV: Double) -> Double { 42 | min(max(value, minV), maxV) 43 | } 44 | } 45 | 46 | public struct FSRSError: Error, Equatable { 47 | enum Reason: String, Error { 48 | case invalidInterval 49 | case invalidRating 50 | case invalidRetention 51 | case invalidParam 52 | case invalidDeltaT 53 | } 54 | var errorReason: Reason 55 | var message: String? 56 | 57 | init(_ errorReason: Reason, _ message: String? = nil) { 58 | self.message = message 59 | self.errorReason = errorReason 60 | } 61 | } 62 | 63 | extension Date { 64 | 65 | enum TimeUnit: String, Codable { 66 | case days 67 | case minutes 68 | } 69 | 70 | /** 71 | * 计算日期和时间的偏移,并返回一个新的日期对象。 72 | * @param now 当前日期和时间 73 | * @param t 时间偏移量,当 isDay 为 true 时表示天数,为 false 时表示分钟 74 | * @param unit (可选)是否按天数单位进行偏移,默认为 minutes,表示按分钟单位计算偏移 75 | * @returns 偏移后的日期和时间对象 76 | */ 77 | static func dateScheduler(now: Date, t: Double, unit: TimeUnit = .minutes) -> Date { 78 | Date(timeIntervalSince1970: 79 | unit == .days 80 | ? now.timeIntervalSince1970 + t * 24 * 60 * 60 81 | : now.timeIntervalSince1970 + t * 60 82 | ) 83 | } 84 | 85 | static func dateDiff(now: Date, pre: Date?, unit: TimeUnit) -> Double { 86 | guard let pre = pre else { return 0.0 } 87 | let diff = now.timeIntervalSince1970 - pre.timeIntervalSince1970 88 | var r = 0.0 89 | switch unit { 90 | case .days: 91 | r = floor(diff / (24 * 60 * 60)) 92 | case .minutes: 93 | r = floor(diff / 60) 94 | } 95 | return r 96 | } 97 | 98 | static func dateDiffInDays(from last: Date?, to cur: Date) -> Double { 99 | guard let last = last else { return 0.0 } 100 | var calendar = Calendar(identifier: .gregorian) 101 | calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .autoupdatingCurrent 102 | let startOfLast = calendar.startOfDay(for: last) 103 | let startOfCur = calendar.startOfDay(for: cur) 104 | return floor((startOfCur.timeIntervalSince1970 - startOfLast.timeIntervalSince1970) / (24 * 60 * 60)) 105 | } 106 | 107 | func toString(_ dateFormat: String) -> String? { 108 | let formatter = DateFormatter() 109 | formatter.dateFormat = dateFormat 110 | return formatter.string(from: self) 111 | } 112 | 113 | static func formatDate(date: Date) -> String { 114 | date.toString("yyyy-MM-dd HH:mm:ss") ?? "" 115 | } 116 | 117 | static func fromString(_ date: String) -> Date? { 118 | let formatter = DateFormatter() 119 | formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 120 | if let gmt = TimeZone(secondsFromGMT: 0) { 121 | formatter.timeZone = gmt 122 | } 123 | return formatter.date(from: date) 124 | } 125 | 126 | static let timeUnit = [60.0, 60, 24, 31, 12] 127 | static let timeUnitsFormat = ["second", "min", "hour", "day", "month", "year"] 128 | static func showDiffMessage( 129 | _ due: Date, 130 | _ lastReview: Date, 131 | _ detailed: Bool = false, 132 | _ unit: [String] = timeUnitsFormat 133 | ) -> String { 134 | var unit = unit 135 | if unit.count != timeUnitsFormat.count { 136 | unit = timeUnitsFormat 137 | } 138 | var diff = due.timeIntervalSince1970 - lastReview.timeIntervalSince1970 139 | var i = 0 140 | for (index, unit) in timeUnit.enumerated() { 141 | if diff < unit { 142 | i = index 143 | break 144 | } else { 145 | diff /= unit 146 | } 147 | i += 1 148 | } 149 | return "\(Int(floor(diff)))\(detailed ? (unit[i]) : "")" 150 | } 151 | } 152 | 153 | extension Double { 154 | func toFixed(_ places: Int) -> String { 155 | return String(format: "%.\(places)f", self) 156 | } 157 | 158 | func toFixedNumber(_ places: Int) -> Double { 159 | return Double(String(format: "%.\(places)f", self)) ?? 0 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Sources/FSRS/Models/FSRSDefaults.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSDefaults.swift 3 | // 4 | // Created by nkq on 10/13/24. 5 | // 6 | 7 | import Foundation 8 | 9 | public class FSRSDefaults { 10 | static let S_MIN = 0.01 11 | static let INIT_S_MAX = 100.0 12 | static let CLAMP_PARAMETERS = [ 13 | [S_MIN, INIT_S_MAX] /** initial stability (Again) */, 14 | [S_MIN, INIT_S_MAX] /** initial stability (Hard) */, 15 | [S_MIN, INIT_S_MAX] /** initial stability (Good) */, 16 | [S_MIN, INIT_S_MAX] /** initial stability (Easy) */, 17 | [1.0, 10.0] /** initial difficulty (Good) */, 18 | [0.001, 4.0] /** initial difficulty (multiplier) */, 19 | [0.001, 4.0] /** difficulty (multiplier) */, 20 | [0.001, 0.75] /** difficulty (multiplier) */, 21 | [0.0, 4.5] /** stability (exponent) */, 22 | [0.0, 0.8] /** stability (negative power) */, 23 | [0.001, 3.5] /** stability (exponent) */, 24 | [0.001, 5.0] /** fail stability (multiplier) */, 25 | [0.001, 0.25] /** fail stability (negative power) */, 26 | [0.001, 0.9] /** fail stability (power) */, 27 | [0.0, 4.0] /** fail stability (exponent) */, 28 | [0.0, 1.0] /** stability (multiplier for Hard) */, 29 | [1.0, 6.0] /** stability (multiplier for Easy) */, 30 | [0.0, 2.0] /** short-term stability (exponent) */, 31 | [0.0, 2.0] /** short-term stability (exponent) */, 32 | ] 33 | 34 | var defaultRequestRetention = 0.9 35 | var defaultMaximumInterval = 36500.0 36 | let defaultW = [ 37 | 0.40255, 1.18385, 3.173, 15.69105, 7.1949, 38 | 0.5345, 1.4604, 0.0046, 1.54575, 0.1192, 39 | 1.01925, 1.9395, 0.11, 0.29605, 2.2698, 40 | 0.2315, 2.9898, 0.51655, 0.6621 41 | ] 42 | var defaultEnableFuzz = false 43 | var defaultEnableShortTerm = true 44 | 45 | var FSRSVersion: String = "v5.1.0 using FSRS-5.0" 46 | 47 | func generatorParameters(props: FSRSParameters? = nil) -> FSRSParameters { 48 | var w = defaultW 49 | if let p = props { 50 | if p.w.count == 19 { 51 | w = p.w 52 | } else if p.w.count == 17 { 53 | w = p.w 54 | w.append(0.0) 55 | w.append(0.0) 56 | w[4] = (w[5] * 2.0 + w[4]).toFixedNumber(8) 57 | w[5] = (log(w[5] * 3.0 + 1.0) / 3.0).toFixedNumber(8) 58 | w[6] = (w[6] + 0.5).toFixedNumber(8) 59 | print("[FSRS V5]auto fill w to 19 length") 60 | } 61 | } 62 | w = w.enumerated().map({ 63 | FSRSHelper.clamp($0.element, Self.CLAMP_PARAMETERS[$0.offset][0], Self.CLAMP_PARAMETERS[$0.offset][1]) 64 | }) 65 | return FSRSParameters( 66 | requestRetention: props?.requestRetention ?? defaultRequestRetention, 67 | maximumInterval: props?.maximumInterval ?? defaultMaximumInterval, 68 | w: w, 69 | enableFuzz: props?.enableFuzz ?? defaultEnableFuzz, 70 | enableShortTerm: props?.enableShortTerm ?? defaultEnableShortTerm 71 | ) 72 | } 73 | 74 | 75 | /** 76 | * Create an empty card 77 | * @param now Current time 78 | * @param afterHandler Convert the result to another type. (Optional) 79 | * @example 80 | * ``` 81 | * const card: Card = createEmptyCard(new Date()); 82 | * ``` 83 | * @example 84 | * ``` 85 | * interface CardUnChecked 86 | * extends Omit { 87 | * cid: string; 88 | * due: Date | number; 89 | * last_review: Date | null | number; 90 | * state: StateType; 91 | * } 92 | * 93 | * function cardAfterHandler(card: Card) { 94 | * return { 95 | * ...card, 96 | * cid: "test001", 97 | * state: State[card.state], 98 | * last_review: card.last_review ?? null, 99 | * } as CardUnChecked; 100 | * } 101 | * 102 | * const card: CardUnChecked = createEmptyCard(new Date(), cardAfterHandler); 103 | * ``` 104 | */ 105 | func createEmptyCard(now: Date = Date(), afterHandler: ((Card) -> Card)? = nil) -> Card { 106 | let card = Card(due: now) 107 | return afterHandler?(card) ?? card 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/FSRS/Models/FSRSModels.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSModels.swift 3 | // 4 | // Created by nkq on 10/13/24. 5 | // 6 | 7 | import Foundation 8 | 9 | public enum CardState: Int, Codable { 10 | case new = 0 11 | case learning = 1 12 | case review = 2 13 | case relearning = 3 14 | 15 | public var stringValue: String { 16 | switch self { 17 | case .new: return "new" 18 | case .learning: return "learning" 19 | case .review: return "review" 20 | case .relearning: return "relearning" 21 | } 22 | } 23 | } 24 | 25 | public enum Rating: Int, Codable, Equatable, CaseIterable { 26 | case manual = 0, again = 1, hard, good, easy 27 | 28 | public var stringValue: String { 29 | switch self { 30 | case .manual: return "manual" 31 | case .again: return "again" 32 | case .hard: return "hard" 33 | case .good: return "good" 34 | case .easy: return "easy" 35 | } 36 | } 37 | } 38 | 39 | public struct ReviewLog: Equatable, Codable, Hashable { 40 | public var rating: Rating // Rating of the review (Again, Hard, Good, Easy) 41 | public var state: CardState? // State of the review (New, Learning, Review, Relearning) 42 | public var due: Date? // Date of the last scheduling 43 | public var stability: Double? // Memory stability during the review 44 | public var difficulty: Double? // Difficulty of the card during the review 45 | public var elapsedDays: Double // Number of days elapsed since the last review 46 | public var lastElapsedDays: Double // Number of days between the last two reviews 47 | public var scheduledDays: Double // Number of days until the next review 48 | public var review: Date // Date of the review 49 | 50 | public init( 51 | rating: Rating, 52 | state: CardState? = nil, 53 | due: Date? = nil, 54 | stability: Double? = nil, 55 | difficulty: Double? = nil, 56 | elapsedDays: Double = 0, 57 | lastElapsedDays: Double = 0, 58 | scheduledDays: Double = 0, 59 | review: Date 60 | ) { 61 | self.rating = rating 62 | self.state = state 63 | self.due = due 64 | self.stability = stability 65 | self.difficulty = difficulty 66 | self.elapsedDays = elapsedDays 67 | self.lastElapsedDays = lastElapsedDays 68 | self.scheduledDays = scheduledDays 69 | self.review = review 70 | } 71 | 72 | public var newLog: ReviewLog { 73 | ReviewLog( 74 | rating: rating, 75 | state: state, 76 | due: due, 77 | stability: stability, 78 | difficulty: difficulty, 79 | elapsedDays: elapsedDays, 80 | lastElapsedDays: lastElapsedDays, 81 | scheduledDays: scheduledDays, 82 | review: review 83 | ) 84 | } 85 | } 86 | 87 | public struct Card: Equatable, Codable, Hashable { 88 | public var due: Date // Date when the card is next due for review 89 | public var stability: Double // A measure of how well the information is retained 90 | public var difficulty: Double // Reflects the inherent difficulty of the card content 91 | public var elapsedDays: Double // Days since the card was last reviewed 92 | public var scheduledDays: Double // The interval at which the card is next scheduled 93 | public var reps: Int // Total number of times the card has been reviewed 94 | public var lapses: Int // Times the card was forgotten or remembered incorrectly 95 | public var state: CardState // The current state of the card (New, Learning, Review, Relearning) 96 | public var lastReview: Date? // The most recent review date, if applicable 97 | 98 | public init( 99 | due: Date = Date(), 100 | stability: Double = 0, 101 | difficulty: Double = 0, 102 | elapsedDays: Double = 0, 103 | scheduledDays: Double = 0, 104 | reps: Int = 0, 105 | lapses: Int = 0, 106 | state: CardState = .new, 107 | lastReview: Date? = nil 108 | ) { 109 | self.due = due 110 | self.stability = stability 111 | self.difficulty = difficulty 112 | self.elapsedDays = elapsedDays 113 | self.scheduledDays = scheduledDays 114 | self.reps = reps 115 | self.lapses = lapses 116 | self.state = state 117 | self.lastReview = lastReview 118 | } 119 | 120 | public var newCard: Card { 121 | Card( 122 | due: due, 123 | stability: stability, 124 | difficulty: difficulty, 125 | elapsedDays: elapsedDays, 126 | scheduledDays: scheduledDays, 127 | reps: reps, 128 | lapses: lapses, 129 | state: state, 130 | lastReview: lastReview 131 | ) 132 | } 133 | 134 | func printLog() { 135 | do { 136 | let encoder = JSONEncoder() 137 | encoder.outputFormatting = .prettyPrinted 138 | let data = try encoder.encode(self) 139 | print(data) 140 | } catch { 141 | print("Error serializing JSON: \(error)") 142 | } 143 | } 144 | } 145 | 146 | public struct RecordLogItem: Codable, Equatable, Hashable { 147 | public var card: Card 148 | public var log: ReviewLog 149 | 150 | public init(card: Card, log: ReviewLog) { 151 | self.card = card 152 | self.log = log 153 | } 154 | } 155 | 156 | public typealias RecordLog = [Rating: RecordLogItem] 157 | 158 | public struct FSRSParameters: Codable, Equatable { 159 | public var requestRetention: Double 160 | public var maximumInterval: Double 161 | public var w: [Double] 162 | public var enableFuzz: Bool 163 | public var enableShortTerm: Bool 164 | 165 | public init( 166 | requestRetention: Double? = nil, 167 | maximumInterval: Double? = nil, 168 | w: [Double]? = nil, 169 | enableFuzz: Bool? = nil, 170 | enableShortTerm: Bool? = nil 171 | ) { 172 | let defaults = FSRSDefaults() 173 | self.requestRetention = requestRetention ?? defaults.defaultRequestRetention 174 | self.maximumInterval = maximumInterval ?? defaults.defaultMaximumInterval 175 | self.w = w ?? defaults.defaultW 176 | self.enableFuzz = enableFuzz ?? defaults.defaultEnableFuzz 177 | self.enableShortTerm = enableShortTerm ?? defaults.defaultEnableShortTerm 178 | } 179 | } 180 | 181 | public struct FSRSReview: Codable { 182 | /** 183 | * 0-4: Manual, Again, Hard, Good, Easy 184 | * = revlog.rating 185 | */ 186 | public var rating: Rating 187 | /** 188 | * The number of days that passed 189 | * = revlog.elapsed_days 190 | * = round(revlog[-1].review - revlog[-2].review) 191 | */ 192 | public var deltaT: Double 193 | } 194 | 195 | public struct FSRSState: Codable { 196 | public var stability: Double 197 | public var difficulty: Double 198 | } 199 | -------------------------------------------------------------------------------- /Sources/FSRS/Models/FSRSTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSTypes.swift 3 | // 4 | // Created by nkq on 10/13/24. 5 | // 6 | 7 | import Foundation 8 | 9 | public struct IPreview { 10 | var recordLog: RecordLog 11 | 12 | init(recordLog: RecordLog) { 13 | self.recordLog = recordLog 14 | } 15 | 16 | public subscript(rating: Rating) -> RecordLogItem? { 17 | get { 18 | recordLog[rating] 19 | } 20 | set { 21 | recordLog[rating] = newValue 22 | } 23 | } 24 | } 25 | 26 | public protocol IScheduler { 27 | var preview: IPreview { get } 28 | func review(_ g: Rating) -> RecordLogItem 29 | } 30 | 31 | /** 32 | * Options for rescheduling. 33 | * 34 | * @template T - The type of the result returned by the `recordLogHandler` function. 35 | */ 36 | public struct RescheduleOptions { 37 | /** 38 | * A function that handles recording the log. 39 | * 40 | * @param recordLog - The log to be recorded. 41 | * @returns The result of recording the log. 42 | */ 43 | var recordLogHandler: ((_ recordLog: RecordLogItem?) -> RecordLogItem?)? 44 | 45 | /** 46 | * A function that defines the order of reviews. 47 | * 48 | * @param a - The first FSRSHistory object. 49 | * @param b - The second FSRSHistory object. 50 | */ 51 | var reviewsOrderBy: ((_ a: ReviewLog, _ b: ReviewLog) -> Bool)? 52 | 53 | /** 54 | * Indicating whether to skip manual steps. 55 | */ 56 | var skipManual: Bool = true 57 | 58 | /** 59 | * Indicating whether to update the FSRS memory state. 60 | */ 61 | var updateMemoryState: Bool = false 62 | 63 | /** 64 | * The current date and time. 65 | */ 66 | var now: Date = Date() 67 | 68 | /** 69 | * The input for the first card. 70 | */ 71 | var firstCard: Card? 72 | } 73 | 74 | public struct IReschedule: Equatable { 75 | var collections: [RecordLogItem?] 76 | var rescheduleItem: RecordLogItem? 77 | } 78 | -------------------------------------------------------------------------------- /Sources/FSRS/Scheduler/AbstractScheduler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AbstractSch.swift 3 | // 4 | // Created by nkq on 10/13/24. 5 | // 6 | 7 | import Foundation 8 | 9 | class AbstractScheduler: IScheduler { 10 | var preview: IPreview { 11 | .init(recordLog: [ 12 | .again: review(.again), 13 | .hard: review(.hard), 14 | .good: review(.good), 15 | .easy: review(.easy) 16 | ]) 17 | } 18 | var last: Card 19 | var current: Card 20 | var reviewTime: Date 21 | var next: [Rating: RecordLogItem] = [:] 22 | var algorithm: FSRSAlgorithm 23 | 24 | init( 25 | card: Card, 26 | reviewTime: Date, 27 | algorithm: FSRSAlgorithm 28 | ) { 29 | self.algorithm = algorithm 30 | self.last = card.newCard 31 | self.current = card.newCard 32 | self.reviewTime = reviewTime 33 | 34 | var interval = 0.0 35 | if current.state != .new && current.lastReview != nil { 36 | interval = Date.dateDiffInDays(from: current.lastReview, to: reviewTime) 37 | } 38 | self.current.lastReview = reviewTime 39 | self.current.elapsedDays = interval 40 | self.current.reps += 1 41 | self.algorithm.seed = "\(reviewTime.timeIntervalSince1970)_\(current.reps)_\(current.difficulty * current.stability)" 42 | } 43 | 44 | var seed: String { 45 | get { algorithm.seed ?? "" } 46 | set { algorithm.seed = newValue } 47 | } 48 | 49 | func review(_ g: Rating) -> RecordLogItem { 50 | switch last.state { 51 | case .new: 52 | return newState(grade: g) 53 | case .learning, .relearning: 54 | return learningState(grade: g) 55 | case .review: 56 | return reviewState(grade: g) 57 | } 58 | } 59 | 60 | func newState(grade: Rating) -> RecordLogItem { 61 | print("subclass must override") 62 | return .init(card: Card(), log: ReviewLog(rating: .manual, state: .new, due: Date(), review: Date())) 63 | } 64 | func learningState(grade: Rating) -> RecordLogItem { 65 | print("subclass must override") 66 | return .init(card: Card(), log: ReviewLog(rating: .manual, state: .new, due: Date(), review: Date())) 67 | } 68 | func reviewState(grade: Rating) -> RecordLogItem { 69 | print("subclass must override") 70 | return .init(card: Card(), log: ReviewLog(rating: .manual, state: .new, due: Date(), review: Date())) 71 | } 72 | 73 | func buildLog(rating: Rating) -> ReviewLog { 74 | .init(rating: rating, 75 | state: current.state, 76 | due: last.lastReview == nil ? last.due : last.lastReview ?? Date(), 77 | stability: current.stability, 78 | difficulty: current.difficulty, 79 | elapsedDays: current.elapsedDays, 80 | lastElapsedDays: last.elapsedDays, 81 | scheduledDays: current.scheduledDays, 82 | review: reviewTime 83 | ) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/FSRS/Scheduler/BasicScheduler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BasicScheduler.swift 3 | // 4 | // Created by nkq on 10/14/24. 5 | // 6 | 7 | import Foundation 8 | 9 | class BasicScheduler: AbstractScheduler { 10 | override func newState(grade: Rating) -> RecordLogItem { 11 | if let item = next[grade] { return item } 12 | var next = current.newCard 13 | next.difficulty = algorithm.initDifficulty(grade) 14 | next.stability = algorithm.initStability(g: grade) 15 | switch grade { 16 | case .again: 17 | next.scheduledDays = 0 18 | next.due = Date.dateScheduler(now: reviewTime, t: 1) 19 | next.state = .learning 20 | case .hard: 21 | next.scheduledDays = 0 22 | next.due = Date.dateScheduler(now: reviewTime, t: 5) 23 | next.state = .learning 24 | case .good: 25 | next.scheduledDays = 0 26 | next.due = Date.dateScheduler(now: reviewTime, t: 10) 27 | next.state = .learning 28 | case .easy: 29 | let easyInterval = algorithm.nextInterval( 30 | s: next.stability, 31 | elapsedDays: current.elapsedDays 32 | ) 33 | next.scheduledDays = Double(easyInterval) 34 | next.due = Date.dateScheduler(now: reviewTime, t: Double(easyInterval), unit: .days) 35 | next.state = .review 36 | case .manual: break 37 | } 38 | return .init(card: next, log: buildLog(rating: grade)) 39 | } 40 | 41 | override func learningState(grade: Rating) -> RecordLogItem { 42 | if let item = next[grade] { return item } 43 | var next = current.newCard 44 | let interval = current.elapsedDays 45 | next.difficulty = algorithm.nextDifficulty(d: last.difficulty, g: grade) 46 | next.stability = algorithm.nextShortTermStability(s: last.stability, g: grade) 47 | switch grade { 48 | case .again: 49 | next.scheduledDays = 0 50 | next.due = Date.dateScheduler(now: reviewTime, t: 5) 51 | next.state = last.state 52 | case .hard: 53 | next.scheduledDays = 0 54 | next.due = Date.dateScheduler(now: reviewTime, t: 10) 55 | next.state = last.state 56 | case .good: 57 | let goodInterval = algorithm.nextInterval( 58 | s: next.stability, 59 | elapsedDays: interval 60 | ) 61 | next.scheduledDays = Double(goodInterval) 62 | next.due = Date.dateScheduler(now: reviewTime, t: Double(goodInterval), unit: .days) 63 | next.state = .review 64 | case .easy: 65 | let goodStability = algorithm.nextShortTermStability( 66 | s: last.stability, 67 | g: .good 68 | ) 69 | let goodInterval = algorithm.nextInterval( 70 | s: goodStability, 71 | elapsedDays: interval 72 | ) 73 | let easyInterval = max(algorithm.nextInterval( 74 | s: next.stability, 75 | elapsedDays: interval 76 | ), goodInterval + 1) 77 | next.scheduledDays = Double(easyInterval) 78 | next.due = Date.dateScheduler(now: reviewTime, t: Double(easyInterval), unit: .days) 79 | next.state = .review 80 | case .manual: break 81 | } 82 | return .init(card: next, log: buildLog(rating: grade)) 83 | } 84 | 85 | override func reviewState(grade: Rating) -> RecordLogItem { 86 | if let item = next[grade] { return item } 87 | let interval = current.elapsedDays 88 | let retrievability = algorithm.forgettingCurve( 89 | elapsedDays: interval, stability: last.stability 90 | ) 91 | let nextArray = Array(repeating: current.newCard, count: 4) 92 | var nextAgain = nextArray[0] 93 | var nextHard = nextArray[1] 94 | var nextGood = nextArray[2] 95 | var nextEasy = nextArray[3] 96 | 97 | nextDs( 98 | &nextAgain, &nextHard, &nextGood, &nextEasy, 99 | difficulty: last.difficulty, 100 | stability: last.stability, 101 | retrievability: retrievability 102 | ) 103 | 104 | nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval: interval) 105 | nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) 106 | 107 | nextAgain.lapses += 1 108 | 109 | let itemAgain = RecordLogItem( 110 | card: nextAgain, 111 | log: buildLog(rating: .again) 112 | ) 113 | let itemHard = RecordLogItem( 114 | card: nextHard, 115 | log: buildLog(rating: .hard) 116 | ) 117 | let itemGood = RecordLogItem( 118 | card: nextGood, 119 | log: buildLog(rating: .good) 120 | ) 121 | let itemEasy = RecordLogItem( 122 | card: nextEasy, 123 | log: buildLog(rating: .easy) 124 | ) 125 | 126 | next[.again] = itemAgain 127 | next[.hard] = itemHard 128 | next[.good] = itemGood 129 | next[.easy] = itemEasy 130 | 131 | return next[grade]! 132 | } 133 | 134 | private func nextDs( 135 | _ nextAgain: inout Card, 136 | _ nextHard: inout Card, 137 | _ nextGood: inout Card, 138 | _ nextEasy: inout Card, 139 | difficulty: Double, 140 | stability: Double, 141 | retrievability: Double 142 | ) { 143 | nextAgain.difficulty = algorithm.nextDifficulty(d: difficulty, g: .again) 144 | let nextSMin = stability / exp(algorithm.parameters.w[17] * algorithm.parameters.w[18]) 145 | let sAfterAll = algorithm.nextForgetStability(d: difficulty, s: stability, r: retrievability) 146 | nextAgain.stability = FSRSHelper.clamp(nextSMin.toFixedNumber(8), FSRSDefaults.S_MIN, sAfterAll) 147 | 148 | nextHard.difficulty = algorithm.nextDifficulty(d: difficulty, g: .hard) 149 | nextHard.stability = algorithm.nextRecallStability( 150 | d: difficulty, s: stability, r: retrievability, g: .hard 151 | ) 152 | 153 | nextGood.difficulty = algorithm.nextDifficulty(d: difficulty, g: .good) 154 | nextGood.stability = algorithm.nextRecallStability( 155 | d: difficulty, s: stability, r: retrievability, g: .good 156 | ) 157 | 158 | nextEasy.difficulty = algorithm.nextDifficulty(d: difficulty, g: .easy) 159 | nextEasy.stability = algorithm.nextRecallStability( 160 | d: difficulty, s: stability, r: retrievability, g: .easy 161 | ) 162 | } 163 | 164 | private func nextInterval( 165 | _ nextAgain: inout Card, 166 | _ nextHard: inout Card, 167 | _ nextGood: inout Card, 168 | _ nextEasy: inout Card, 169 | interval: Double 170 | ) { 171 | var hardInterval = algorithm.nextInterval( 172 | s: nextHard.stability, elapsedDays: interval 173 | ) 174 | var goodInterval = algorithm.nextInterval( 175 | s: nextGood.stability, elapsedDays: interval 176 | ) 177 | hardInterval = min(hardInterval, goodInterval) 178 | goodInterval = max(goodInterval, hardInterval + 1) 179 | let easyInteval = max( 180 | algorithm.nextInterval(s: nextEasy.stability, elapsedDays: interval), 181 | goodInterval + 1 182 | ) 183 | nextAgain.scheduledDays = 0 184 | nextAgain.due = Date.dateScheduler(now: reviewTime, t: 5) 185 | 186 | nextHard.scheduledDays = Double(hardInterval) 187 | nextHard.due = Date.dateScheduler(now: reviewTime, t: Double(hardInterval), unit: .days) 188 | 189 | nextGood.scheduledDays = Double(goodInterval) 190 | nextGood.due = Date.dateScheduler(now: reviewTime, t: Double(goodInterval), unit: .days) 191 | 192 | nextEasy.scheduledDays = Double(easyInteval) 193 | nextEasy.due = Date.dateScheduler(now: reviewTime, t: Double(easyInteval), unit: .days) 194 | } 195 | 196 | private func nextState( 197 | _ nextAgain: inout Card, 198 | _ nextHard: inout Card, 199 | _ nextGood: inout Card, 200 | _ nextEasy: inout Card 201 | ) { 202 | nextAgain.state = .relearning 203 | nextHard.state = .review 204 | nextGood.state = .review 205 | nextEasy.state = .review 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /Sources/FSRS/Scheduler/FSRSReschedule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSReschedule.swift 3 | // 4 | // Created by nkq on 10/15/24. 5 | // 6 | 7 | import Foundation 8 | 9 | /** 10 | * The `Reschedule` class provides methods to handle the rescheduling of cards based on their review history. 11 | * determine the next review dates and update the card's state accordingly. 12 | */ 13 | class FSRSReschedule { 14 | private var fsrs: FSRS 15 | 16 | /** 17 | * Creates an instance of the `Reschedule` class. 18 | * @param fsrs - An instance of the FSRS class used for scheduling. 19 | */ 20 | init(fsrs: FSRS) { 21 | self.fsrs = fsrs 22 | } 23 | 24 | /** 25 | * Replays a review for a card and determines the next review date based on the given rating. 26 | * @param card - The card being reviewed. 27 | * @param reviewed - The date the card was reviewed. 28 | * @param rating - The grade given to the card during the review. 29 | * @returns A `RecordLogItem` containing the updated card and review log. 30 | */ 31 | func replay( 32 | card: Card, 33 | reviewDate: Date, 34 | rating: Rating 35 | ) throws -> RecordLogItem { 36 | try fsrs.next(card: card, now: reviewDate, grade: rating) 37 | } 38 | 39 | /** 40 | * Processes a manual review for a card, allowing for custom state, stability, difficulty, and due date. 41 | * @param card - The card being reviewed. 42 | * @param state - The state of the card after the review. 43 | * @param reviewed - The date the card was reviewed. 44 | * @param elapsed_days - The number of days since the last review. 45 | * @param stability - (Optional) The stability of the card. 46 | * @param difficulty - (Optional) The difficulty of the card. 47 | * @param due - (Optional) The due date for the next review. 48 | * @returns A `RecordLogItem` containing the updated card and review log. 49 | * @throws Will throw an error if the state or due date is not provided when required. 50 | */ 51 | func handleManualRating( 52 | card: Card, 53 | state: CardState, 54 | reviewDate: Date, 55 | elapsedDays: Double, 56 | stability: Double?, 57 | difficulty: Double?, 58 | due: Date? 59 | ) throws -> RecordLogItem { 60 | var log: ReviewLog 61 | var nextCard: Card 62 | 63 | if state == .new { 64 | log = .init( 65 | rating: .manual, 66 | state: state, 67 | due: due ?? reviewDate, 68 | stability: card.stability, 69 | difficulty: card.difficulty, 70 | elapsedDays: elapsedDays, 71 | lastElapsedDays: card.elapsedDays, 72 | scheduledDays: card.scheduledDays, 73 | review: reviewDate 74 | ) 75 | nextCard = FSRSDefaults().createEmptyCard( 76 | now: reviewDate 77 | ) 78 | nextCard.lastReview = reviewDate 79 | } else { 80 | guard let due = due else { 81 | throw FSRSError(.invalidParam, "reschedule: due is required for manual rating") 82 | } 83 | let schduledDays = Date.dateDiff(now: due, pre: reviewDate, unit: .days) 84 | log = .init( 85 | rating: .manual, 86 | state: card.state, 87 | due: card.lastReview ?? card.due, 88 | stability: card.stability, 89 | difficulty: card.difficulty, 90 | elapsedDays: elapsedDays, 91 | lastElapsedDays: card.elapsedDays, 92 | scheduledDays: card.scheduledDays, 93 | review: reviewDate 94 | ) 95 | nextCard = .init( 96 | due: due, 97 | stability: stability ?? card.stability, 98 | difficulty: difficulty ?? card.difficulty, 99 | elapsedDays: elapsedDays, 100 | scheduledDays: schduledDays, 101 | reps: card.reps + 1, 102 | lapses: card.lapses, 103 | state: state, 104 | lastReview: reviewDate 105 | ) 106 | } 107 | return .init(card: nextCard, log: log) 108 | } 109 | 110 | 111 | /** 112 | * Reschedules a card based on its review history. 113 | * 114 | * @param current_card - The card to be rescheduled. 115 | * @param reviews - An array of review history objects. 116 | * @returns An array of record log items representing the rescheduling process. 117 | */ 118 | func reschedule( 119 | currentCard: Card, 120 | reviews: [ReviewLog] 121 | ) throws -> [RecordLogItem] { 122 | var result = [RecordLogItem]() 123 | var curCard = FSRSDefaults().createEmptyCard(now: currentCard.due) 124 | for review in reviews { 125 | var item: RecordLogItem 126 | if review.rating == .manual { 127 | var interval = 0.0 128 | if curCard.state != .new, let lastReview = curCard.lastReview { 129 | interval = Date.dateDiff( 130 | now: review.review, 131 | pre: lastReview, 132 | unit: .days 133 | ) 134 | } 135 | guard let state = review.state else { 136 | throw FSRSError(.invalidParam, "reschedule: state is required for manual rating") 137 | } 138 | item = try handleManualRating( 139 | card: curCard, 140 | state: state, 141 | reviewDate: review.review, 142 | elapsedDays: interval, 143 | stability: review.stability, 144 | difficulty: review.difficulty, 145 | due: review.due 146 | ) 147 | result.append(item) 148 | curCard = item.card 149 | } else { 150 | do { 151 | item = try replay( 152 | card: curCard, reviewDate: review.review, rating: review.rating 153 | ) 154 | result.append(item) 155 | curCard = item.card 156 | } catch { 157 | print(error.localizedDescription) 158 | } 159 | } 160 | } 161 | return result 162 | } 163 | 164 | func calculateManualRecord( 165 | currentCard: Card, 166 | now: Date, 167 | recordLogItem: RecordLogItem?, 168 | updateMemory: Bool = false 169 | ) throws -> RecordLogItem? { 170 | guard let item = recordLogItem else { return nil } 171 | let rescheduleCard = item.card 172 | let log = item.log 173 | 174 | var curCard = currentCard.newCard 175 | if curCard.due.timeIntervalSince1970 == rescheduleCard.due.timeIntervalSince1970 { 176 | return nil 177 | } 178 | curCard.scheduledDays = Date.dateDiff( 179 | now: rescheduleCard.due, 180 | pre: curCard.due, 181 | unit: .days 182 | ) 183 | return try handleManualRating( 184 | card: curCard, 185 | state: rescheduleCard.state, 186 | reviewDate: now, 187 | elapsedDays: log.elapsedDays, 188 | stability: updateMemory ? rescheduleCard.stability : nil, 189 | difficulty: updateMemory ? rescheduleCard.difficulty : nil, 190 | due: rescheduleCard.due 191 | ) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /Sources/FSRS/Scheduler/LongTermScheduler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LongTermScheduler.swift 3 | // 4 | // Created by nkq on 10/14/24. 5 | // 6 | 7 | import Foundation 8 | 9 | class LongTermScheduler: AbstractScheduler { 10 | override func newState(grade: Rating) -> RecordLogItem { 11 | if let item = next[grade] { return item } 12 | 13 | current.scheduledDays = 0 14 | current.elapsedDays = 0 15 | 16 | let nextArray = Array(repeating: current.newCard, count: 4) 17 | var nextAgain = nextArray[0] 18 | var nextHard = nextArray[1] 19 | var nextGood = nextArray[2] 20 | var nextEasy = nextArray[3] 21 | 22 | initDs(&nextAgain, &nextHard, &nextGood, &nextEasy) 23 | 24 | let firstInterval = 0.0 25 | 26 | nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval: firstInterval) 27 | 28 | nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) 29 | 30 | updateNext(&nextAgain, &nextHard, &nextGood, &nextEasy) 31 | 32 | return next[grade]! 33 | } 34 | 35 | override func learningState(grade: Rating) -> RecordLogItem { 36 | reviewState(grade: grade) 37 | } 38 | 39 | override func reviewState(grade: Rating) -> RecordLogItem { 40 | if let item = next[grade] { return item } 41 | 42 | let interval = current.elapsedDays 43 | let retrievability = algorithm.forgettingCurve(elapsedDays: interval, stability: last.stability) 44 | let nextArray = Array(repeating: current.newCard, count: 4) 45 | var nextAgain = nextArray[0] 46 | var nextHard = nextArray[1] 47 | var nextGood = nextArray[2] 48 | var nextEasy = nextArray[3] 49 | 50 | nextDs( 51 | &nextAgain, &nextHard, &nextGood, &nextEasy, 52 | difficulty: last.difficulty, 53 | stability: last.stability, 54 | retrievability: retrievability 55 | ) 56 | 57 | nextInterval(&nextAgain, &nextHard, &nextGood, &nextEasy, interval: interval) 58 | 59 | nextState(&nextAgain, &nextHard, &nextGood, &nextEasy) 60 | nextAgain.lapses += 1 61 | 62 | updateNext(&nextAgain, &nextHard, &nextGood, &nextEasy) 63 | 64 | return next[grade]! 65 | } 66 | 67 | private func initDs( 68 | _ nextAgain: inout Card, 69 | _ nextHard: inout Card, 70 | _ nextGood: inout Card, 71 | _ nextEasy: inout Card 72 | ) { 73 | nextAgain.difficulty = algorithm.initDifficulty(.again) 74 | nextAgain.stability = algorithm.initStability(g: .again) 75 | 76 | nextHard.difficulty = algorithm.initDifficulty(.hard) 77 | nextHard.stability = algorithm.initStability(g: .hard) 78 | 79 | nextGood.difficulty = algorithm.initDifficulty(.good) 80 | nextGood.stability = algorithm.initStability(g: .good) 81 | 82 | nextEasy.difficulty = algorithm.initDifficulty(.easy) 83 | nextEasy.stability = algorithm.initStability(g: .easy) 84 | } 85 | 86 | private func nextDs( 87 | _ nextAgain: inout Card, 88 | _ nextHard: inout Card, 89 | _ nextGood: inout Card, 90 | _ nextEasy: inout Card, 91 | difficulty: Double, 92 | stability: Double, 93 | retrievability: Double 94 | ) { 95 | nextAgain.difficulty = algorithm.nextDifficulty(d: difficulty, g: .again) 96 | let sAfterAll = algorithm.nextForgetStability(d: difficulty, s: stability, r: retrievability) 97 | nextAgain.stability = FSRSHelper.clamp(stability, FSRSDefaults.S_MIN, sAfterAll) 98 | 99 | nextHard.difficulty = algorithm.nextDifficulty(d: difficulty, g: .hard) 100 | nextHard.stability = algorithm.nextRecallStability( 101 | d: difficulty, s: stability, r: retrievability, g: .hard 102 | ) 103 | 104 | nextGood.difficulty = algorithm.nextDifficulty(d: difficulty, g: .good) 105 | nextGood.stability = algorithm.nextRecallStability( 106 | d: difficulty, s: stability, r: retrievability, g: .good 107 | ) 108 | 109 | nextEasy.difficulty = algorithm.nextDifficulty(d: difficulty, g: .easy) 110 | nextEasy.stability = algorithm.nextRecallStability( 111 | d: difficulty, s: stability, r: retrievability, g: .easy 112 | ) 113 | } 114 | 115 | private func nextInterval( 116 | _ nextAgain: inout Card, 117 | _ nextHard: inout Card, 118 | _ nextGood: inout Card, 119 | _ nextEasy: inout Card, 120 | interval: Double 121 | ) { 122 | let againInterval = algorithm.nextInterval(s: nextAgain.stability, elapsedDays: interval) 123 | let hardInterval = algorithm.nextInterval(s: nextHard.stability, elapsedDays: interval) 124 | let goodInterval = algorithm.nextInterval(s: nextGood.stability, elapsedDays: interval) 125 | let easyInterval = algorithm.nextInterval(s: nextEasy.stability, elapsedDays: interval) 126 | 127 | 128 | let newAgainInterval = min(againInterval, hardInterval) 129 | let newHardInterval = max(hardInterval, (againInterval + 1)) 130 | let newGoodInterval = max(goodInterval, (hardInterval + 1)) 131 | let newEasyInterval = max(easyInterval, (goodInterval + 1)) 132 | 133 | nextAgain.scheduledDays = Double(newAgainInterval) 134 | nextAgain.due = Date.dateScheduler(now: reviewTime, t: Double(newAgainInterval), unit: .days) 135 | 136 | nextHard.scheduledDays = Double(newHardInterval) 137 | nextHard.due = Date.dateScheduler(now: reviewTime, t: Double(newHardInterval), unit: .days) 138 | 139 | nextGood.scheduledDays = Double(newGoodInterval) 140 | nextGood.due = Date.dateScheduler(now: reviewTime, t: Double(newGoodInterval), unit: .days) 141 | 142 | nextEasy.scheduledDays = Double(newEasyInterval) 143 | nextEasy.due = Date.dateScheduler(now: reviewTime, t: Double(newEasyInterval), unit: .days) 144 | } 145 | 146 | private func nextState( 147 | _ nextAgain: inout Card, 148 | _ nextHard: inout Card, 149 | _ nextGood: inout Card, 150 | _ nextEasy: inout Card 151 | ) { 152 | nextAgain.state = .review 153 | nextHard.state = .review 154 | nextGood.state = .review 155 | nextEasy.state = .review 156 | } 157 | 158 | private func updateNext( 159 | _ nextAgain: inout Card, 160 | _ nextHard: inout Card, 161 | _ nextGood: inout Card, 162 | _ nextEasy: inout Card 163 | ) { 164 | let again = RecordLogItem(card: nextAgain, log: buildLog(rating: .again)) 165 | let hard = RecordLogItem(card: nextHard, log: buildLog(rating: .hard)) 166 | let good = RecordLogItem(card: nextGood, log: buildLog(rating: .good)) 167 | let easy = RecordLogItem(card: nextEasy, log: buildLog(rating: .easy)) 168 | 169 | next[.again] = again 170 | next[.hard] = hard 171 | next[.good] = good 172 | next[.easy] = easy 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Tests/FSRSTests/FSRSAbstractSchedulerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BasicSchedulerTests.swift 3 | // FSRS 4 | // 5 | // Created by nkq on 10/20/24. 6 | // 7 | 8 | 9 | import XCTest 10 | @testable import FSRS 11 | 12 | class BasicSchedulerTests: XCTestCase { 13 | func testSymbolIterator() { 14 | let now = Date() 15 | let card = FSRSDefaults().createEmptyCard(now: now) 16 | let f = FSRS(parameters: .init()) 17 | let preview = f.repeat(card: card, now: now) 18 | let again = try! f.next(card: card, now: now, grade: .again) 19 | let hard = try! f.next(card: card, now: now, grade: .hard) 20 | let good = try! f.next(card: card, now: now, grade: .good) 21 | let easy = try! f.next(card: card, now: now, grade: .easy) 22 | 23 | let expectPreview: [Rating: Card] = [ 24 | .again: again.card, 25 | .hard: hard.card, 26 | .good: good.card, 27 | .easy: easy.card, 28 | ] 29 | 30 | // Check that preview matches expected structure 31 | XCTAssertEqual(preview.recordLog[.again]?.card, expectPreview[.again]) 32 | XCTAssertEqual(preview.recordLog[.good]?.card, expectPreview[.good]) 33 | XCTAssertEqual(preview.recordLog[.easy]?.card, expectPreview[.easy]) 34 | XCTAssertEqual(preview.recordLog[.hard]?.card, expectPreview[.hard]) 35 | 36 | 37 | 38 | // Iterate over preview and check values 39 | for item in preview.recordLog { 40 | let expectedCard = expectPreview[item.value.log.rating] 41 | XCTAssertEqual(item.value.card, expectedCard) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/FSRSTests/FSRSAleaTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import FSRS 3 | 4 | final class FSRSAleaTests: XCTestCase { 5 | func testExample() throws { 6 | let prng1 = alea(seed: 1) 7 | let prng2 = alea(seed: 3) 8 | let prng3 = alea(seed: 1) 9 | 10 | let a = prng1.state() 11 | let b = prng2.state() 12 | let c = prng3.state() 13 | 14 | XCTAssert(a == c) 15 | XCTAssert(a != b) 16 | 17 | var generator = alea(seed: 12345) 18 | let v4 = generator.next() 19 | let v5 = generator.next() 20 | let v6 = generator.next() 21 | print([0.27138191112317145, 0.19615925149992108, 0.6810678059700876].debugDescription) 22 | print([v4, v5, v6].debugDescription) 23 | XCTAssert([v4, v5, v6].elementsEqual([0.27138191112317145, 0.19615925149992108, 0.6810678059700876])) 24 | 25 | generator = alea(seed: "int32test") 26 | let value = generator.int32() 27 | XCTAssert(Int(value) <= 0xffffffff) 28 | XCTAssert(value >= 0) 29 | 30 | generator = alea(seed: 12345) 31 | let v1 = generator.int32() 32 | let v2 = generator.int32() 33 | let v3 = generator.int32() 34 | print([1165576433, 842497570, -1369803343].debugDescription) 35 | print([v1, v2, v3].debugDescription) 36 | XCTAssert([v1, v2, v3].elementsEqual([1165576433, 842497570, -1369803343])) 37 | 38 | generator = alea(seed: "doubletest") 39 | let value1 = generator.double() 40 | XCTAssert(value1 < 1) 41 | XCTAssert(value1 >= 0) 42 | 43 | generator = alea(seed: 12345) 44 | let v7 = generator.double() 45 | let v8 = generator.double() 46 | let v9 = generator.double() 47 | print([0.27138191116884325, 0.6810678062004586, 0.3407802057882554].debugDescription) 48 | print([v7, v8, v9].debugDescription) 49 | XCTAssert([v7, v8, v9].elementsEqual([0.27138191116884325, 0.6810678062004586, 0.3407802057882554])) 50 | 51 | let prng4 = alea(seed: Double.random(in: 0...1)) 52 | _ = prng4.next() 53 | _ = prng4.next() 54 | _ = prng4.next() 55 | let prng5 = alea() 56 | prng5.importState(prng4.state()) 57 | XCTAssert(prng4.state() == prng5.state()) 58 | 59 | for _ in 1...10000 { 60 | let q = prng4.next() 61 | let b = prng5.next() 62 | XCTAssert(q == b) 63 | XCTAssert(q >= 0) 64 | XCTAssert(q < 1) 65 | XCTAssert(b < 1) 66 | } 67 | 68 | generator = alea(seed: "statetest") 69 | let state1 = generator.state() 70 | let next1 = generator.next() 71 | let state2 = generator.state() 72 | let next2 = generator.next() 73 | XCTAssert(state1.s0 != state2.s0) 74 | XCTAssert(state1.s1 != state2.s1) 75 | XCTAssert(state1.s2 != state2.s2) 76 | XCTAssert(next1 != next2) 77 | 78 | 79 | generator = alea(seed: 12345) 80 | generator.importState(.init(c: 0, s0: 0, s1: 0, s2: -0.5)) 81 | let res = generator.next() 82 | let state3 = generator.state() 83 | XCTAssert(res == 0) 84 | XCTAssert(state3 == .init(c: 0, s0: 0, s1: -0.5, s2: 0)) 85 | 86 | generator = alea(seed: "1727015666066") 87 | let res1 = generator.next() 88 | let state4 = generator.state() 89 | XCTAssert(res1 == 0.6320083506871015) 90 | XCTAssert(state4 == .init( 91 | c: 1828249, 92 | s0: 0.5888567129150033, 93 | s1: 0.5074866858776659, 94 | s2: 0.6320083506871015 95 | )) 96 | 97 | generator = alea(seed: "Seedp5fxh9kf4r0") 98 | let res2 = generator.next() 99 | let state5 = generator.state() 100 | XCTAssert(res2 == 0.14867847645655274) 101 | XCTAssert(state5 == .init( 102 | c: 1776946, 103 | s0: 0.6778371171094477, 104 | s1: 0.0770602801349014, 105 | s2: 0.14867847645655274 106 | )) 107 | 108 | generator = alea(seed: "NegativeS2Seed") 109 | let res3 = generator.next() 110 | let state6 = generator.state() 111 | XCTAssert(res3 == 0.830770346801728) 112 | XCTAssert(state6 == .init( 113 | c: 952982, 114 | s0: 0.25224833423271775, 115 | s1: 0.9213257452938706, 116 | s2: 0.830770346801728 117 | )) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /Tests/FSRSTests/FSRSBasicSchedulerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BasicSchedulerTests.swift 3 | // FSRS 4 | // 5 | // Created by nkq on 10/20/24. 6 | // 7 | 8 | 9 | import XCTest 10 | @testable import FSRS 11 | 12 | class FSRSBasicSchedulerTests: XCTestCase { 13 | var params: FSRSParameters! 14 | var algorithm: FSRS! 15 | var now: Date! 16 | 17 | override func setUp() { 18 | super.setUp() 19 | params = FSRSDefaults().generatorParameters() 20 | algorithm = FSRS(parameters: params) 21 | now = Date() 22 | } 23 | 24 | func testStateNewExist() { 25 | let card = FSRSDefaults().createEmptyCard(now: now) 26 | let basicScheduler = BasicScheduler(card: card, reviewTime: now, algorithm: algorithm) 27 | let preview = basicScheduler.preview 28 | let again = basicScheduler.review(.again) 29 | let hard = basicScheduler.review(.hard) 30 | let good = basicScheduler.review(.good) 31 | let easy = basicScheduler.review(.easy) 32 | 33 | let expectedPreview: [Rating: Card] = [ 34 | .again: again.card, 35 | .hard: hard.card, 36 | .good: good.card, 37 | .easy: easy.card 38 | ] 39 | 40 | // Check that preview matches expected structure 41 | XCTAssertEqual(preview.recordLog[.again]?.card, expectedPreview[.again]) 42 | XCTAssertEqual(preview.recordLog[.good]?.card, expectedPreview[.good]) 43 | XCTAssertEqual(preview.recordLog[.easy]?.card, expectedPreview[.easy]) 44 | XCTAssertEqual(preview.recordLog[.hard]?.card, expectedPreview[.hard]) 45 | 46 | for item in preview.recordLog { 47 | let expectedCard = basicScheduler.review(item.value.log.rating) 48 | XCTAssertEqual(item.value, expectedCard) 49 | } 50 | } 51 | 52 | func testStateLearningExist() { 53 | let cardByNew = FSRSDefaults().createEmptyCard(now: now) 54 | let card = BasicScheduler(card: cardByNew, reviewTime: now, algorithm: algorithm).review(.again).card 55 | let basicScheduler = BasicScheduler(card: card, reviewTime: now, algorithm: algorithm) 56 | 57 | let preview = basicScheduler.preview 58 | let again = basicScheduler.review(.again) 59 | let hard = basicScheduler.review(.hard) 60 | let good = basicScheduler.review(.good) 61 | let easy = basicScheduler.review(.easy) 62 | 63 | let expectedPreview: [Rating: Card] = [ 64 | .again: again.card, 65 | .hard: hard.card, 66 | .good: good.card, 67 | .easy: easy.card 68 | ] 69 | 70 | // Check that preview matches expected structure 71 | XCTAssertEqual(preview.recordLog[.again]?.card, expectedPreview[.again]) 72 | XCTAssertEqual(preview.recordLog[.good]?.card, expectedPreview[.good]) 73 | XCTAssertEqual(preview.recordLog[.easy]?.card, expectedPreview[.easy]) 74 | XCTAssertEqual(preview.recordLog[.hard]?.card, expectedPreview[.hard]) 75 | 76 | for item in preview.recordLog { 77 | let expectedCard = basicScheduler.review(item.value.log.rating) 78 | XCTAssertEqual(item.value, expectedCard) 79 | } 80 | } 81 | 82 | func testStateReviewExist() { 83 | let cardByNew = FSRSDefaults().createEmptyCard(now: now) 84 | let card = BasicScheduler(card: cardByNew, reviewTime: now, algorithm: algorithm).review(.easy).card 85 | let basicScheduler = BasicScheduler(card: card, reviewTime: now, algorithm: algorithm) 86 | 87 | let preview = basicScheduler.preview 88 | let again = basicScheduler.review(.again) 89 | let hard = basicScheduler.review(.hard) 90 | let good = basicScheduler.review(.good) 91 | let easy = basicScheduler.review(.easy) 92 | 93 | let expectedPreview: [Rating: Card] = [ 94 | .again: again.card, 95 | .hard: hard.card, 96 | .good: good.card, 97 | .easy: easy.card 98 | ] 99 | 100 | // Check that preview matches expected structure 101 | XCTAssertEqual(preview.recordLog[.again]?.card, expectedPreview[.again]) 102 | XCTAssertEqual(preview.recordLog[.good]?.card, expectedPreview[.good]) 103 | XCTAssertEqual(preview.recordLog[.easy]?.card, expectedPreview[.easy]) 104 | XCTAssertEqual(preview.recordLog[.hard]?.card, expectedPreview[.hard]) 105 | 106 | for item in preview.recordLog { 107 | let expectedCard = basicScheduler.review(item.value.log.rating) 108 | XCTAssertEqual(item.value, expectedCard) 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/FSRSTests/FSRSCalcElapsedDaysTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSCalcElapsedDaysTests.swift 3 | // FSRS 4 | // 5 | // Created by nkq on 4/5/25. 6 | // 7 | 8 | import XCTest 9 | @testable import FSRS 10 | 11 | /** 12 | * @see https://forums.ankiweb.net/t/feature-request-estimated-total-knowledge-over-time/53036/58?u=l.m.sherlock 13 | * @see https://ankiweb.net/shared/info/1613056169 14 | */ 15 | 16 | class FSRSCalcElapsedDaysTests: XCTestCase { 17 | var f: FSRS! 18 | let rids = [1704468957.0, 1704469645.0, 1704599572.0, 1705509507.0] 19 | 20 | 21 | override func setUp() { 22 | super.setUp() 23 | f = FSRS(parameters: .init( 24 | w: [ 25 | 1.1596, 1.7974, 13.1205, 49.3729, 7.2303, 0.5081, 1.5371, 0.001, 1.5052, 26 | 0.1261, 0.9735, 1.8924, 0.1486, 0.2407, 2.1937, 0.1518, 3.0699, 0.4636, 27 | 0.6048, 28 | ] 29 | )) 30 | } 31 | 32 | func testElapsedDays() { 33 | let expected = [13.1205, 17.3668145, 21.28550751, 39.63452215] 34 | var card = FSRSDefaults().createEmptyCard(now: Date(timeIntervalSince1970: rids[0])) 35 | let grade = [Rating.good, .good, .good, .good] 36 | for (index, rid) in rids.enumerated() { 37 | do { 38 | let now = Date(timeIntervalSince1970: rid) 39 | let log = try f.next(card: card, now: now, grade: grade[index]) 40 | card = log.card 41 | XCTAssertEqual(card.stability, expected[index]) 42 | } catch { 43 | print(error.localizedDescription) 44 | } 45 | } 46 | } 47 | 48 | func testSSEUseNextState() { 49 | let f = FSRS(parameters: .init( 50 | w: [ 51 | 0.4911, 4.5674, 24.8836, 77.045, 7.5474, 0.1873, 1.7732, 0.001, 1.1112, 52 | 0.152, 0.5728, 1.8747, 0.1733, 0.2449, 2.2905, 0.0, 2.9898, 0.0883, 53 | 0.9033, 54 | ] 55 | )) 56 | let rids = [ 57 | 1698678054.940 /**2023-10-30T15:00:54.940Z */, 58 | 1698678126.399 /**2023-10-30T15:02:06.399Z */, 59 | 1698688771.401 /**2023-10-30T17:59:31.401Z */, 60 | 1698688837.021 /**2023-10-30T18:00:37.021Z */, 61 | 1698688916.440 /**2023-10-30T18:01:56.440Z */, 62 | 1698698192.380 /**2023-10-30T20:36:32.380Z */, 63 | 1699260169.343 /**2023-11-06T08:42:49.343Z */, 64 | 1702718934.003 /**2023-12-16T09:28:54.003Z */, 65 | 1704910583.686 /**2024-01-10T18:16:23.686Z */, 66 | 1713000017.248 /**2024-04-13T09:20:17.248Z */, 67 | ] 68 | let ratings = [Rating.good, .good, .again, .good, .good, .good, .manual, .good, .manual, .good] 69 | var last = Date(timeIntervalSince1970: rids[0]) 70 | var memoryState: FSRSState? 71 | for (index, rid) in rids.enumerated() { 72 | let current = Date(timeIntervalSince1970: rid) 73 | let rating = ratings[index] 74 | let deltaT = Date.dateDiffInDays(from: last, to: current) 75 | let nextStates = try! f.nextState(memoryState: memoryState, t: deltaT, g: rating) 76 | if rating != .manual { 77 | last = Date(timeIntervalSince1970: rid) 78 | } 79 | print("\(rid) \(deltaT) \(nextStates.stability.toFixedNumber(2)) \(nextStates.difficulty.toFixedNumber(2))") 80 | memoryState = nextStates 81 | } 82 | 83 | XCTAssertEqual(memoryState?.stability.toFixedNumber(2) ?? 0.0, 71.77) 84 | } 85 | 86 | // func testSSE7177() { 87 | // let f = FSRS(parameters: .init( 88 | // w: [ 89 | // 0.4911, 4.5674, 24.8836, 77.045, 7.5474, 0.1873, 1.7732, 0.001, 1.1112, 90 | // 0.152, 0.5728, 1.8747, 0.1733, 0.2449, 2.2905, 0.0, 2.9898, 0.0883, 91 | // 0.9033, 92 | // ] 93 | // )) 94 | // let rids = [ 95 | // 1698678054.940 /**2023-10-30T15:00:54.940Z */, 96 | // 1698678126.399 /**2023-10-30T15:02:06.399Z */, 97 | // 1698688771.401 /**2023-10-30T17:59:31.401Z */, 98 | // 1698688837.021 /**2023-10-30T18:00:37.021Z */, 99 | // 1698688916.440 /**2023-10-30T18:01:56.440Z */, 100 | // 1698698192.380 /**2023-10-30T20:36:32.380Z */, 101 | // 1699260169.343 /**2023-11-06T08:42:49.343Z */, 102 | // 1702718934.003 /**2023-12-16T09:28:54.003Z */, 103 | // 1704910583.686 /**2024-01-10T18:16:23.686Z */, 104 | // 1713000017.248 /**2024-04-13T09:20:17.248Z */, 105 | // ] 106 | // let ratings = [Rating.good, .good, .again, .good, .good, .good, .manual, .good, .manual, .good] 107 | // 108 | // let expected = [ 109 | // 0: [ 110 | // "elapsed_days": 0, 111 | // "s": 24.88, 112 | // "d": 7.09, 113 | // ], 114 | // 1: [ 115 | // "elapsed_days": 0, 116 | // "s": 26.95, 117 | // "d": 7.09, 118 | // ], 119 | // 2: [ 120 | // "elapsed_days": 0, 121 | // "s": 24.46, 122 | // "d": 8.24, 123 | // ], 124 | // 3: [ 125 | // "elapsed_days": 0, 126 | // "s": 26.48, 127 | // "d": 8.24, 128 | // ], 129 | // 4: [ 130 | // "elapsed_days": 0, 131 | // "s": 28.69, 132 | // "d": 8.23, 133 | // ], 134 | // 5: [ 135 | // "elapsed_days": 0, 136 | // "s": 31.08, 137 | // "d": 8.23, 138 | // ], 139 | // 7: [ 140 | // "elapsed_days": 0, 141 | // "s": 47.44, 142 | // "d": 8.23, 143 | // ], 144 | // 9: [ 145 | // "elapsed_days": 119, 146 | // "s": 71.77, 147 | // "d": 8.23, 148 | // ], 149 | // ] 150 | // 151 | // var card = FSRSDefaults().createEmptyCard(now: Date(timeIntervalSince1970: rids[0])) 152 | // for (index, rid) in rids.enumerated() { 153 | // let rating = ratings[index] 154 | // if rating == .manual { 155 | // continue 156 | // } 157 | // let now = Date(timeIntervalSince1970: rid) 158 | // let log = try! f.next(card: card, now: now, grade: rating) 159 | // card = log.card 160 | // print(index + 1) 161 | // XCTAssertEqual(card.elapsedDays, expected[index]?["elapsed_days"]) 162 | // XCTAssertEqual(card.stability.toFixedNumber(2), expected[index]?["s"]) 163 | // XCTAssertEqual(card.difficulty.toFixedNumber(2), expected[index]?["d"]) 164 | // } 165 | // XCTAssertEqual(card.stability, 71.77) 166 | // } 167 | } 168 | -------------------------------------------------------------------------------- /Tests/FSRSTests/FSRSDefaultTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSDefaultTests.swift 3 | // FSRS 4 | // 5 | // Created by nkq on 10/19/24. 6 | // 7 | 8 | 9 | import XCTest 10 | @testable import FSRS 11 | 12 | 13 | class YourTestClass: XCTestCase { 14 | 15 | func testDefaultParams() { 16 | let expectedW = [ 17 | 0.40255, 1.18385, 3.173, 15.69105, 7.1949, 0.5345, 1.4604, 0.0046, 1.54575, 18 | 0.1192, 1.01925, 1.9395, 0.11, 0.29605, 2.2698, 0.2315, 2.9898, 0.51655, 19 | 0.6621, 20 | ] 21 | let defaults = FSRSDefaults() 22 | XCTAssertEqual(defaults.defaultRequestRetention, 0.9) 23 | XCTAssertEqual(defaults.defaultMaximumInterval, 36500) 24 | XCTAssertEqual(defaults.defaultEnableFuzz, false) 25 | XCTAssertEqual(defaults.defaultW.count, expectedW.count) 26 | XCTAssertEqual(defaults.defaultW, expectedW) 27 | 28 | let params = defaults.generatorParameters() 29 | 30 | XCTAssertEqual(params.requestRetention, defaults.defaultRequestRetention) 31 | XCTAssertEqual(params.maximumInterval, defaults.defaultMaximumInterval) 32 | XCTAssertEqual(params.w, expectedW) 33 | XCTAssertEqual(params.enableFuzz, defaults.defaultEnableFuzz) 34 | 35 | let params2 = defaults.generatorParameters(props: .init(w: [ 36 | 0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 37 | 0.05, 0.34, 1.26, 0.29, 2.61, 38 | ])) 39 | 40 | XCTAssertEqual(params2.w, [ 41 | 0.4, 0.6, 2.4, 5.8, 6.81, 0.44675014, 1.36, 0.01, 1.49, 0.14, 0.94, 2.18, 42 | 0.05, 0.34, 1.26, 0.29, 2.61, 0.0, 0.0, 43 | ]) 44 | 45 | var w = Array(repeating: 0.0, count: 19) 46 | var paramsClamp = defaults.generatorParameters(props: .init(w: w)) 47 | let w_min = FSRSDefaults.CLAMP_PARAMETERS.map({ $0[0] }) 48 | XCTAssertEqual(paramsClamp.w, w_min) 49 | 50 | w = Array(repeating: .infinity, count: 19) 51 | paramsClamp = defaults.generatorParameters(props: .init(w: w)) 52 | let w_max = FSRSDefaults.CLAMP_PARAMETERS.map({ $0[1] }) 53 | XCTAssertEqual(paramsClamp.w, w_max) 54 | } 55 | 56 | func testDefaultCard() { 57 | let times = [Date(), Date(timeIntervalSince1970: 1696291200)] // Replace with the appropriate timestamp 58 | for now in times { 59 | let card = FSRSDefaults().createEmptyCard(now: now) 60 | XCTAssertEqual(card.due, now) 61 | XCTAssertEqual(card.stability, 0) 62 | XCTAssertEqual(card.difficulty, 0) 63 | XCTAssertEqual(card.elapsedDays, 0) 64 | XCTAssertEqual(card.scheduledDays, 0) 65 | XCTAssertEqual(card.reps, 0) 66 | XCTAssertEqual(card.lapses, 0) 67 | XCTAssertEqual(card.state.rawValue, 0) 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Tests/FSRSTests/FSRSElapsedDaysTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSElapsedDaysTests.swift 3 | // 4 | // Created by nkq on 10/19/24. 5 | // 6 | 7 | import XCTest 8 | @testable import FSRS 9 | 10 | class FSRSElapsedDaysTests: XCTestCase { 11 | 12 | var f: FSRS! 13 | var currentLog: ReviewLog? 14 | var card: Card! 15 | var calendar = Calendar.current 16 | 17 | override func setUp() { 18 | super.setUp() 19 | f = FSRS(parameters: .init()) 20 | calendar.timeZone = .init(secondsFromGMT: 0)! 21 | let components = DateComponents(year: 2023, month: 10, day: 18, hour: 14, minute: 32, second: 03) 22 | let createDue = calendar.date(from: components)! // UTC 2023-10-18 14:32:03.370 23 | card = FSRSDefaults().createEmptyCard(now: createDue) 24 | } 25 | 26 | func testFirstRepeatGood() { 27 | var components = DateComponents(year: 2023, month: 11, day: 05, hour: 08, minute: 27, second: 02) 28 | let firstDue = calendar.date(from: components)! // UTC 2023-11-05 08:27:02.605 29 | var sc = f.repeat(card: card, now: firstDue) 30 | 31 | currentLog = sc[.good]?.log 32 | XCTAssertEqual(currentLog?.elapsedDays, 0) 33 | 34 | card = sc[.good]?.card ?? card 35 | 36 | components = DateComponents(year: 2023, month: 11, day: 08, hour: 15, minute: 02, second: 09) 37 | let secondDue = calendar.date(from: components)! // UTC 2023-11-08 15:02:09.791 38 | XCTAssertNotNil(card) 39 | 40 | sc = f.repeat(card: card, now: secondDue) 41 | currentLog = sc[.again]?.log 42 | 43 | var expectedElapsedDays: Double = Date.dateDiff(now: secondDue, pre: card.lastReview, unit: .days) 44 | XCTAssertEqual(currentLog?.elapsedDays, expectedElapsedDays) 45 | XCTAssertEqual(currentLog?.elapsedDays, 3) 46 | 47 | card = sc[.again]?.card ?? card 48 | 49 | components = DateComponents(year: 2023, month: 11, day: 08, hour: 15, minute: 02, second: 30) 50 | let thirdDue = calendar.date(from: components)! // UTC 2023-11-08 15:02:30.799 51 | XCTAssertNotNil(card) 52 | 53 | sc = f.repeat(card: card, now: thirdDue) 54 | currentLog = sc[.again]?.log 55 | 56 | expectedElapsedDays = Date.dateDiff(now: thirdDue, pre: card.lastReview, unit: .days) 57 | XCTAssertEqual(currentLog?.elapsedDays, expectedElapsedDays) 58 | XCTAssertEqual(currentLog?.elapsedDays, 0) 59 | 60 | card = sc[.again]?.card ?? card 61 | 62 | components = DateComponents(year: 2023, month: 11, day: 08, hour: 15, minute: 04, second: 08) 63 | let fourthDue = calendar.date(from: components)! // UTC 2023-11-08 15:04:08.739 64 | XCTAssertNotNil(card) 65 | 66 | sc = f.repeat(card: card, now: fourthDue) 67 | currentLog = sc[.good]?.log 68 | 69 | expectedElapsedDays = Date.dateDiff(now: fourthDue, pre: card.lastReview, unit: .days) 70 | XCTAssertEqual(currentLog?.elapsedDays, expectedElapsedDays) 71 | XCTAssertEqual(currentLog?.elapsedDays, 0) 72 | 73 | card = sc[.good]?.card ?? card 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Tests/FSRSTests/FSRSForgetTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSForgetTests.swift 3 | // FSRS 4 | // 5 | // Created by nkq on 10/19/24. 6 | // 7 | 8 | 9 | import XCTest 10 | @testable import FSRS 11 | 12 | class FSRSForgetTests: XCTestCase { 13 | var f: FSRS! 14 | var calendar: Calendar = { 15 | var res = Calendar.current 16 | res.timeZone = .init(secondsFromGMT: 0)! 17 | return res 18 | }() 19 | 20 | 21 | override func setUp() { 22 | super.setUp() 23 | f = FSRS(parameters: .init( 24 | w: [ 25 | 1.14, 1.01, 5.44, 14.67, 5.3024, 1.5662, 1.2503, 0.0028, 1.5489, 0.1763, 26 | 0.9953, 2.7473, 0.0179, 0.3105, 0.3976, 0.0, 2.0902, 27 | ], 28 | enableFuzz: false 29 | )) 30 | } 31 | 32 | func testForget() { 33 | let card = FSRSDefaults().createEmptyCard() 34 | 35 | let now = calendar.date(from: DateComponents(year: 2022, month: 12, day: 29, hour: 12, minute: 30))! 36 | let forgetNow = calendar.date(from: DateComponents(year: 2023, month: 12, day: 30, hour: 12, minute: 30))! 37 | let schedulingCards = f.repeat(card: card, now: now) 38 | 39 | let grades: [Rating] = [.again, .hard, .good, .easy] 40 | 41 | for grade in grades { 42 | let forgetCard = f.forget(card: schedulingCards[grade]?.card ?? Card(), now: forgetNow, resetCount: true) 43 | XCTAssertEqual(forgetCard.card, Card( 44 | due: forgetNow, 45 | elapsedDays: 0, 46 | reps: 0, 47 | lastReview: schedulingCards[grade]?.card.lastReview 48 | )) 49 | XCTAssertEqual(forgetCard.log.rating, Rating.manual) 50 | XCTAssertThrowsError(try f.rollback(card: forgetCard.card, log: forgetCard.log)) { error in 51 | XCTAssertEqual((error as? FSRSError)?.errorReason, .invalidRating) 52 | } 53 | } 54 | 55 | for grade in grades { 56 | let forgetCard = f.forget(card: schedulingCards[grade]?.card ?? Card(), now: forgetNow) 57 | XCTAssertEqual(forgetCard.card, Card( 58 | due: forgetNow, 59 | elapsedDays: schedulingCards[grade]?.card.elapsedDays ?? 0, 60 | reps: schedulingCards[grade]?.card.reps ?? 0, 61 | lastReview: schedulingCards[grade]?.card.lastReview 62 | )) 63 | XCTAssertEqual(forgetCard.log.rating, Rating.manual) 64 | XCTAssertThrowsError(try f.rollback(card: forgetCard.card, log: forgetCard.log)) { error in 65 | XCTAssertEqual((error as? FSRSError)?.errorReason, .invalidRating) 66 | } 67 | } 68 | } 69 | 70 | func testNewCardForgetResetTrue() { 71 | let card = FSRSDefaults().createEmptyCard() 72 | let forgetNow = calendar.date(from: DateComponents(year: 2023, month: 12, day: 30, hour: 12, minute: 30))! 73 | let forgetCard = f.forget(card: card, now: forgetNow, resetCount: true) 74 | XCTAssertEqual(forgetCard.card, Card(due: forgetNow, elapsedDays: 0, reps: 0)) 75 | } 76 | 77 | func testNewCardForget() { 78 | let card = FSRSDefaults().createEmptyCard() 79 | let forgetNow = calendar.date(from: DateComponents(year: 2023, month: 12, day: 30, hour: 12, minute: 30))! 80 | let forgetCard = f.forget(card: card, now: forgetNow, resetCount: true) 81 | XCTAssertEqual(forgetCard.card, Card(due: forgetNow)) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Tests/FSRSTests/FSRSFuzzSameSeedTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FuzzSameSeedTests.swift 3 | // FSRS 4 | // 5 | // Created by nkq on 10/20/24. 6 | // 7 | 8 | 9 | import XCTest 10 | @testable import FSRS 11 | 12 | class FuzzSameSeedTests: XCTestCase { 13 | var mockNow: Date { calendar.date(from: DateComponents(year: 2024, month: 8, day: 15))! } 14 | var calendar: Calendar = { 15 | var res = Calendar.current 16 | res.timeZone = .init(secondsFromGMT: 0)! 17 | return res 18 | }() 19 | 20 | func testFuzzSameShortTerm() { 21 | do { 22 | let initialCard = FSRSDefaults().createEmptyCard() 23 | let fsrsInstance = FSRS(parameters: .init()) 24 | let card: Card = try fsrsInstance.next(card: initialCard, now: mockNow, grade: .good).card 25 | let mockTomorrow = calendar.date(from: DateComponents(year: 2024, month: 8, day: 16))! 26 | 27 | var timestamps: [TimeInterval] = [] 28 | 29 | for _ in 0..<100 { 30 | if #available(macOS 14.0, *) { 31 | DispatchQueue.main.asyncAfterUnsafe(deadline: .now() + 0.05) { 32 | do { 33 | let scheduler = FSRS(parameters: .init(enableFuzz: true)) 34 | let nextCard = try scheduler.next(card: card, now: mockTomorrow, grade: .good).card 35 | timestamps.append(nextCard.due.timeIntervalSince1970) 36 | 37 | if timestamps.count == 100 { 38 | let firstValue = timestamps[0] 39 | XCTAssertTrue(timestamps.allSatisfy { $0 == firstValue }) 40 | } 41 | } catch { 42 | 43 | } 44 | } 45 | } else { 46 | // Fallback on earlier versions 47 | } 48 | } 49 | } catch { 50 | 51 | } 52 | } 53 | 54 | func testFuzzSameLongTerm() { 55 | do { 56 | let initialCard = FSRSDefaults().createEmptyCard() 57 | let fsrsInstance = FSRS(parameters: .init(enableShortTerm: false)) 58 | let card = try fsrsInstance.next(card: initialCard, now: mockNow, grade: .good).card 59 | let mockTomorrow = calendar.date(from: DateComponents(year: 2024, month: 8, day: 18))! 60 | 61 | var timestamps: [TimeInterval] = [] 62 | 63 | for _ in 0..<100 { 64 | if #available(macOS 14.0, *) { 65 | DispatchQueue.main.asyncAfterUnsafe(deadline: .now() + 0.05) { 66 | do { 67 | let scheduler = FSRS(parameters: .init(enableFuzz: true, enableShortTerm: false)) 68 | let nextCard = try scheduler.next(card: card, now: mockTomorrow, grade: .good).card 69 | timestamps.append(nextCard.due.timeIntervalSince1970) 70 | 71 | if timestamps.count == 100 { 72 | let firstValue = timestamps[0] 73 | XCTAssertTrue(timestamps.allSatisfy { $0 == firstValue }) 74 | } 75 | } catch {} 76 | } 77 | } else { 78 | // Fallback on earlier versions 79 | } 80 | } 81 | 82 | } catch { 83 | 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Tests/FSRSTests/FSRSLongTermSchedulerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FSRSLongTermSchedulerTests.swift 3 | // 4 | // Created by nkq on 10/20/24. 5 | // 6 | 7 | 8 | import XCTest 9 | @testable import FSRS 10 | 11 | class LongTermSchedulerTests: XCTestCase { 12 | var params: FSRSParameters! 13 | var algorithm: FSRS! 14 | var calendar: Calendar = { 15 | var res = Calendar.current 16 | res.timeZone = .init(secondsFromGMT: 0)! 17 | return res 18 | }() 19 | var dateFormatter: DateFormatter = .init() 20 | 21 | override func setUp() { 22 | super.setUp() 23 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" 24 | let w: [Double] = [ 25 | 0.4197, 1.1869, 3.0412, 15.2441, 7.1434, 0.6477, 1.0007, 0.0674, 1.6597, 26 | 0.1712, 1.1178, 2.0225, 0.0904, 0.3025, 2.1214, 0.2498, 2.9466, 0.4891, 27 | 0.6468, 28 | ] 29 | params = FSRSDefaults().generatorParameters(props: .init(w: w, enableShortTerm: false)) 30 | algorithm = FSRS(parameters: params) 31 | } 32 | 33 | 34 | func test1() { 35 | let testAdditionalCases: ( 36 | _ now: Date, 37 | _ ratings: [Rating], 38 | _ exivlHistory: [Int], 39 | _ exsHistory: [Double], 40 | _ exdHistory: [Double] 41 | ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in 42 | guard let self = self else { return } 43 | var now = now 44 | var card = FSRSDefaults().createEmptyCard() 45 | var ivlHistory: [Int] = [] 46 | var sHistory: [Double] = [] 47 | var dHistory: [Double] = [] 48 | 49 | for rating in ratings { 50 | let record = algorithm.repeat(card: card, now: now)[rating] 51 | let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) 52 | XCTAssertEqual(record, next) 53 | 54 | card = record!.card 55 | ivlHistory.append(Int(card.scheduledDays)) 56 | sHistory.append(card.stability) 57 | dHistory.append(card.difficulty) 58 | now = card.due 59 | } 60 | 61 | XCTAssertEqual(ivlHistory, exivlHistory) 62 | XCTAssertEqual(sHistory, exsHistory) 63 | XCTAssertEqual(dHistory, exdHistory) 64 | } 65 | testAdditionalCases( 66 | calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, 67 | [ 68 | .good, .good, .good, .good, .good, .good, .again, 69 | .again, .good, .good, .good, .good, .good 70 | ], 71 | [ 72 | 3, 13, 48, 155, 445, 1158, 17, 3, 11, 37, 112, 307, 773, 73 | ], 74 | [ 75 | 3.0412, 13.09130698, 48.15848988, 154.93732625, 445.05562739, 76 | 1158.07779739, 16.63063166, 3.01732209, 11.42247264, 37.37521902, 77 | 111.8752758, 306.5974569, 772.94031572, 78 | ], 79 | [ 80 | 4.49094334, 4.26664289, 4.05746029, 3.86237659, 3.68044154, 3.51076891, 81 | 4.69833071, 5.55956298, 5.26323756, 4.98688448, 4.72915759, 4.4888015, 82 | 4.26464541, 83 | ]) 84 | } 85 | 86 | func test2() { 87 | let testAdditionalCases: ( 88 | _ now: Date, 89 | _ ratings: [Rating], 90 | _ exivlHistory: [Int], 91 | _ exsHistory: [Double], 92 | _ exdHistory: [Double] 93 | ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in 94 | guard let self = self else { return } 95 | var now = now 96 | var card = FSRSDefaults().createEmptyCard() 97 | var ivlHistory: [Int] = [] 98 | var sHistory: [Double] = [] 99 | var dHistory: [Double] = [] 100 | 101 | for rating in ratings { 102 | let record = algorithm.repeat(card: card, now: now)[rating] 103 | let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) 104 | XCTAssertEqual(record, next) 105 | 106 | card = record!.card 107 | ivlHistory.append(Int(card.scheduledDays)) 108 | sHistory.append(card.stability) 109 | dHistory.append(card.difficulty) 110 | now = card.due 111 | } 112 | 113 | XCTAssertEqual(ivlHistory, exivlHistory) 114 | XCTAssertEqual(sHistory, exsHistory) 115 | XCTAssertEqual(dHistory, exdHistory) 116 | } 117 | testAdditionalCases( 118 | calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, 119 | [ 120 | .again, 121 | .hard, 122 | .good, 123 | .easy, 124 | .again, 125 | .hard, 126 | .good, 127 | .easy, 128 | ], 129 | [ 130 | 1, 2, 6, 41, 4, 7, 21, 133 131 | ], 132 | [ 133 | 0.4197, 1.0344317, 5.5356759, 41.0033667, 4.46605519, 6.67743292, 134 | 20.88868155, 132.81849454, 135 | ], 136 | [ 137 | 7.1434, 7.03653841, 6.64066485, 5.92312772, 6.44779861, 6.45995078, 138 | 6.10293922, 5.36588547, 139 | ]) 140 | } 141 | 142 | func test3() { 143 | let testAdditionalCases: ( 144 | _ now: Date, 145 | _ ratings: [Rating], 146 | _ exivlHistory: [Int], 147 | _ exsHistory: [Double], 148 | _ exdHistory: [Double] 149 | ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in 150 | guard let self = self else { return } 151 | var now = now 152 | var card = FSRSDefaults().createEmptyCard() 153 | var ivlHistory: [Int] = [] 154 | var sHistory: [Double] = [] 155 | var dHistory: [Double] = [] 156 | 157 | for rating in ratings { 158 | let record = algorithm.repeat(card: card, now: now)[rating] 159 | let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) 160 | XCTAssertEqual(record, next) 161 | 162 | card = record!.card 163 | ivlHistory.append(Int(card.scheduledDays)) 164 | sHistory.append(card.stability) 165 | dHistory.append(card.difficulty) 166 | now = card.due 167 | } 168 | 169 | XCTAssertEqual(ivlHistory, exivlHistory) 170 | XCTAssertEqual(sHistory, exsHistory) 171 | XCTAssertEqual(dHistory, exdHistory) 172 | } 173 | testAdditionalCases( 174 | calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, 175 | [ 176 | .hard, 177 | .good, 178 | .easy, 179 | .again, 180 | .hard, 181 | .good, 182 | .easy, 183 | .again, 184 | ], 185 | [ 186 | 2, 7, 54, 5, 8, 26, 171, 8 187 | ], 188 | [ 189 | 1.1869, 6.59167572, 53.76078737, 5.0853693, 8.09786749, 25.52991279, 190 | 171.16195166, 8.11072373, 191 | ], 192 | [ 193 | 6.23225985, 5.89059466, 5.14583392, 5.884097, 5.99269555, 5.667177, 194 | 4.91430736, 5.71619151, 195 | ]) 196 | } 197 | 198 | func test4() { 199 | let testAdditionalCases: ( 200 | _ now: Date, 201 | _ ratings: [Rating], 202 | _ exivlHistory: [Int], 203 | _ exsHistory: [Double], 204 | _ exdHistory: [Double] 205 | ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in 206 | guard let self = self else { return } 207 | var now = now 208 | var card = FSRSDefaults().createEmptyCard() 209 | var ivlHistory: [Int] = [] 210 | var sHistory: [Double] = [] 211 | var dHistory: [Double] = [] 212 | 213 | for rating in ratings { 214 | let record = algorithm.repeat(card: card, now: now)[rating] 215 | let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) 216 | XCTAssertEqual(record, next) 217 | 218 | card = record!.card 219 | ivlHistory.append(Int(card.scheduledDays)) 220 | sHistory.append(card.stability) 221 | dHistory.append(card.difficulty) 222 | now = card.due 223 | } 224 | 225 | XCTAssertEqual(ivlHistory, exivlHistory) 226 | XCTAssertEqual(sHistory, exsHistory) 227 | XCTAssertEqual(dHistory, exdHistory) 228 | } 229 | testAdditionalCases( 230 | calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, 231 | [ 232 | .good, 233 | .easy, 234 | .again, 235 | .hard, 236 | .good, 237 | .easy, 238 | .again, 239 | .hard, 240 | ], 241 | [ 242 | 3, 33, 4, 7, 26, 193, 9, 14 243 | ], 244 | [ 245 | 3.0412, 32.65484522, 4.22256838, 7.23250123, 25.52681848, 193.36619432, 246 | 8.63899858, 14.31323884, 247 | ], 248 | [ 249 | 4.49094334, 3.69538259, 4.83221448, 5.12078462, 4.85403286, 4.07165035, 250 | 5.1050878, 5.34697075, 251 | ]) 252 | } 253 | 254 | func test5() { 255 | let testAdditionalCases: ( 256 | _ now: Date, 257 | _ ratings: [Rating], 258 | _ exivlHistory: [Int], 259 | _ exsHistory: [Double], 260 | _ exdHistory: [Double] 261 | ) -> Void = { [weak self] now,ratings,exivlHistory,exsHistory,exdHistory in 262 | guard let self = self else { return } 263 | var now = now 264 | var card = FSRSDefaults().createEmptyCard() 265 | var ivlHistory: [Int] = [] 266 | var sHistory: [Double] = [] 267 | var dHistory: [Double] = [] 268 | 269 | for rating in ratings { 270 | let record = algorithm.repeat(card: card, now: now)[rating] 271 | let next = try! FSRS(parameters: params).next(card: card, now: now, grade: rating) 272 | XCTAssertEqual(record, next) 273 | 274 | card = record!.card 275 | ivlHistory.append(Int(card.scheduledDays)) 276 | sHistory.append(card.stability) 277 | dHistory.append(card.difficulty) 278 | now = card.due 279 | } 280 | 281 | XCTAssertEqual(ivlHistory, exivlHistory) 282 | XCTAssertEqual(sHistory, exsHistory) 283 | XCTAssertEqual(dHistory, exdHistory) 284 | } 285 | testAdditionalCases( 286 | calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))!, 287 | [ 288 | .easy, 289 | .again, 290 | .hard, 291 | .good, 292 | .easy, 293 | .again, 294 | .hard, 295 | .good, 296 | ], 297 | [ 298 | 15, 3, 6, 27, 240, 10, 17, 60 299 | ], 300 | [ 301 | 15.2441, 3.25621013, 6.32684549, 26.56339029, 239.70462771, 9.75621519, 302 | 17.06035531, 59.59547542, 303 | ], 304 | [ 305 | 1.16304343, 2.99573557, 3.59851762, 3.43436666, 2.60045771, 4.03816348, 306 | 4.46259158, 4.24020203, 307 | ]) 308 | } 309 | 310 | func testStateSwitching() { 311 | var ivlHistory: [Int] = [] 312 | var sHistory: [Double] = [] 313 | var dHistory: [Double] = [] 314 | var stateHistory: [CardState] = [] 315 | 316 | let grades: [Rating] = [ 317 | .good, .good, .again, .good, .good, .again 318 | ] 319 | let shortTerm = [true, false, false, false, true, true] 320 | 321 | var now = calendar.date(from: DateComponents(calendar: Calendar.current, year: 2022, month: 12, day: 29, hour: 12, minute: 30))! 322 | var card = FSRSDefaults().createEmptyCard(now: now) 323 | 324 | for i in 0.. [ReviewState] { 37 | var filteredReviews = reviews 38 | if skipManual { 39 | filteredReviews = reviews.filter { $0.rating != .manual } 40 | } 41 | 42 | return filteredReviews.enumerated().reduce(into: [ReviewState]()) { state, reviewEnum in 43 | let (index, review) = reviewEnum 44 | 45 | let currentCard: Card = { 46 | if let previousState = state.last { 47 | return Card( 48 | due: previousState.due, 49 | stability: previousState.stability, 50 | difficulty: previousState.difficulty, 51 | elapsedDays: calculateElapsedDays(state: state, index: index), 52 | scheduledDays: calculateScheduledDays(previousState), 53 | reps: previousState.reps, 54 | lapses: previousState.lapses, 55 | state: previousState.state, 56 | lastReview: previousState.review 57 | ) 58 | } else { 59 | return FSRSDefaults().createEmptyCard(now: MOCK_NOW) 60 | } 61 | }() 62 | var card: Card 63 | var log: ReviewLog 64 | if review.rating == .manual { 65 | 66 | if let previousState = state.last { 67 | log = .init( 68 | rating: .manual, 69 | state: .new, 70 | due: previousState.due, 71 | stability: previousState.stability, 72 | difficulty: previousState.difficulty, 73 | elapsedDays: previousState.elapsedDays, 74 | lastElapsedDays: previousState.elapsedDays, 75 | scheduledDays: previousState.scheduledDays, 76 | review: review.review 77 | ) 78 | } else { 79 | log = .init( 80 | rating: .manual, 81 | state: .new, 82 | due: MOCK_NOW, 83 | stability: 0, 84 | difficulty: 0, 85 | elapsedDays: 0, 86 | lastElapsedDays: 0, 87 | scheduledDays: 0, 88 | review: review.review 89 | ) 90 | } 91 | card = FSRSDefaults().createEmptyCard(now: review.review) 92 | 93 | } else { 94 | let result = try! scheduler.next(card: currentCard, now: review.review, grade: review.rating) 95 | card = result.card 96 | log = result.log 97 | } 98 | 99 | state.append(ReviewState( 100 | difficulty: card.difficulty, 101 | due: card.due, 102 | rating: log.rating, 103 | review: log.review, 104 | stability: card.stability, 105 | state: card.state, 106 | reps: card.reps, 107 | lapses: card.lapses, 108 | elapsedDays: card.elapsedDays, 109 | scheduledDays: card.scheduledDays 110 | )) 111 | } 112 | } 113 | 114 | func testReschedule(scheduler: FSRS, tests: [[Rating]], options: RescheduleOptions) { 115 | let mockNowTime = MOCK_NOW.timeIntervalSince1970 116 | for test in tests { 117 | let reviews = test.enumerated().map { index, rating in 118 | ReviewLog( 119 | rating: rating, 120 | state: rating == .manual ? .new : nil, 121 | review: Date(timeIntervalSince1970: mockNowTime + TimeInterval(24 * 60 * 60 * (index + 1))) 122 | ) 123 | } 124 | 125 | let control = try? scheduler.reschedule( 126 | currentCard: FSRSDefaults().createEmptyCard(), 127 | reviews: reviews, 128 | options: options 129 | ).collections 130 | 131 | let experimentResult = experiment( 132 | scheduler: scheduler, 133 | reviews: reviews, 134 | skipManual: options.skipManual 135 | ) 136 | 137 | for (index, controlItem) in (control ?? []).enumerated() { 138 | let experimentItem = experimentResult[index] 139 | 140 | XCTAssertEqual(controlItem!.card.difficulty, experimentItem.difficulty) 141 | XCTAssertEqual(controlItem!.card.due, experimentItem.due) 142 | XCTAssertEqual(controlItem!.card.stability, experimentItem.stability) 143 | XCTAssertEqual(controlItem!.card.state, experimentItem.state) 144 | XCTAssertEqual(controlItem!.card.lastReview?.timeIntervalSince1970, 145 | experimentItem.review?.timeIntervalSince1970) 146 | XCTAssertEqual(controlItem!.card.reps, experimentItem.reps) 147 | XCTAssertEqual(controlItem!.card.lapses, experimentItem.lapses) 148 | XCTAssertEqual(controlItem!.card.elapsedDays, experimentItem.elapsedDays) 149 | XCTAssertEqual(controlItem!.card.scheduledDays, experimentItem.scheduledDays) 150 | } 151 | } 152 | } 153 | 154 | // MARK: - Helper Functions 155 | private func calculateElapsedDays(state: [ReviewState], index: Int) -> Double { 156 | guard index >= 2, 157 | let previousReview = state[index - 1].review, 158 | let twoReviewsAgo = state[index - 2].review else { 159 | return 0 160 | } 161 | return Date.dateDiff(now: twoReviewsAgo, pre: previousReview, unit: .days) 162 | } 163 | 164 | private func calculateScheduledDays(_ previousState: ReviewState) -> Double { 165 | guard let review = previousState.review else { return 0 } 166 | return Date.dateDiff(now: review, pre: previousState.due, unit: .days) 167 | } 168 | 169 | var scheduler: FSRS! 170 | 171 | override func setUp() { 172 | super.setUp() 173 | scheduler = FSRS(parameters: .init()) 174 | } 175 | 176 | func testBasicGrade() { 177 | let grade = [Rating.again, .hard, .good, .easy] 178 | var tests: [[Rating]] = [] 179 | for i in 0..