├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── SwiftFSRS │ ├── FSRSAlgorithm.swift │ ├── Helpers.swift │ ├── Models │ ├── Card.swift │ ├── CardReview.swift │ ├── Grade.swift │ ├── Parameters.swift │ ├── Rating.swift │ ├── ReviewLog.swift │ └── Status.swift │ └── Schedulers │ ├── LongTermScheduler.swift │ ├── Scheduler.swift │ └── ShortTermScheduler.swift └── Tests └── SwiftFSRSTests ├── LongTermSchedulerTests.swift ├── Resources ├── long-term-bench-steady.json ├── long-term-bench-unsteady.json ├── short-term-bench-steady.json └── short-term-bench-unsteady.json └── ShortTermSchedulerTests.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "**.swift" 9 | - "**.yml" 10 | pull_request: 11 | workflow_dispatch: 12 | 13 | concurrency: 14 | group: tests 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | linux: 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | image: 23 | - "swift:5.10" 24 | container: 25 | image: ${{ matrix.image }} 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | - name: Test 30 | run: swift test 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Four Rays AB 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. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 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: "SwiftFSRS", 8 | platforms: [.macOS(.v11), .iOS(.v16), .tvOS(.v16)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, making them visible to other packages. 11 | .library( 12 | name: "SwiftFSRS", 13 | targets: ["SwiftFSRS"] 14 | ) 15 | ], 16 | targets: [ 17 | // Targets are the basic building blocks of a package, defining a module or a test suite. 18 | // Targets can depend on other targets in this package and products from dependencies. 19 | .target(name: "SwiftFSRS"), 20 | .testTarget( 21 | name: "SwiftFSRSTests", 22 | dependencies: ["SwiftFSRS"], 23 | resources: [.copy("Resources")] 24 | ), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftFSRS 2 | 3 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F4rays%2Fswift-fsrs%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/4rays/swift-fsrs) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2F4rays%2Fswift-fsrs%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/4rays/swift-fsrs) 4 | 5 | An idiomatic and configurable Swift implementation of the [FSRS spaced repetition algorithm](https://github.com/open-spaced-repetition/fsrs4anki/wiki/The-Algorithm). 6 | 7 | ## Installation 8 | 9 | Add the following to your `Package.swift` file: 10 | 11 | ```swift 12 | dependencies: [ 13 | .package(url: "https://github.com/4rays/swift-fsrs") 14 | ] 15 | ``` 16 | 17 | ## Usage 18 | 19 | ### The Scheduler 20 | 21 | The workhorse of the algorithm is the `Scheduler` protocol. It takes a card and a review and returns a new card and review log object. 22 | 23 | Out of the box, the library ships with a short-term and a long-term scheduler. 24 | Use the short-term scheduler when you want to support multiple reviews of the same card in a single day. Use the long-term scheduler otherwise. 25 | 26 | Here is how you can create your own scheduler: 27 | 28 | ```swift 29 | import SwiftFSRS 30 | 31 | struct CustomScheduler: Scheduler { 32 | func schedule( 33 | card: Card, 34 | algorithm: FSRSAlgorithm, 35 | reviewRating: ReviewRating, 36 | reviewTime: Date 37 | ) -> CardReview { 38 | // Implement your custom algorithm here 39 | } 40 | } 41 | ``` 42 | 43 | ### The Algorithm 44 | 45 | The library implements `v5` of the FSRS algorithm out of the box. It can also support custom implementations. 46 | 47 | ```swift 48 | import SwiftFSRS 49 | 50 | let customAlgorithm = FSRSAlgorithm( 51 | decay: -0.5, 52 | factor: 19 / 81, 53 | requestRetention: 0.9, 54 | maximumInterval: 36500, 55 | parameters: [/* ... */] 56 | ) 57 | 58 | scheduler.schedule( 59 | card: card, 60 | algorithm: customAlgorithm, 61 | reviewRating: .good, 62 | reviewTime: Date() 63 | ) 64 | ``` 65 | 66 | ### Scheduling a Review 67 | 68 | To schedule a review for a given card: 69 | 70 | ```swift 71 | import SwiftFSRS 72 | 73 | let scheduler = LongTermScheduler() 74 | let card = Card() 75 | 76 | let review = scheduler.schedule( 77 | card: card, 78 | algorithm: .v5, 79 | reviewRating: .good, // or .easy, .hard, .again 80 | reviewTime: Date() 81 | ) 82 | 83 | print(review.card) 84 | print(review.log) 85 | ``` 86 | 87 | Cards don't have any content properties and are meant to be properties of your own type. 88 | 89 | ```swift 90 | import SwiftFSRS 91 | 92 | struct MyFlashCard { 93 | let question: String 94 | let answer: String 95 | let fsrsCard: Card 96 | } 97 | ``` 98 | 99 | ## License 100 | 101 | SwiftFSRS is available under the MIT license. See the LICENSE file for more info. 102 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/FSRSAlgorithm.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct FSRSAlgorithm: Codable, Hashable, Sendable { 4 | public enum Version: String, Codable, Hashable, Sendable { 5 | case v5 6 | 7 | public var implementation: FSRSAlgorithm { 8 | switch self { 9 | case .v5: FSRSAlgorithm.v5 10 | } 11 | } 12 | } 13 | 14 | public var decay: Double 15 | public var factor: Double 16 | public var requestRetention: Double 17 | public var maximumInterval: Double 18 | public var parameters: [Double] 19 | 20 | public init( 21 | decay: Double = 0.0, 22 | factor: Double = 0.0, 23 | requestRetention: Double = 0.0, 24 | maximumInterval: Double = 0.0, 25 | parameters: [Double] = [] 26 | ) { 27 | self.decay = decay 28 | self.factor = factor 29 | self.requestRetention = min(max(requestRetention, 0.0), 1.0) 30 | self.maximumInterval = maximumInterval 31 | self.parameters = parameters 32 | } 33 | 34 | public static let v5 = Self( 35 | decay: -0.5, 36 | factor: 19 / 81, 37 | requestRetention: 0.9, 38 | maximumInterval: 36500, 39 | parameters: v5Params 40 | ) 41 | 42 | func initialStability(_ rating: Rating) -> Double { 43 | max(self[rating.value - 1], 0.1) 44 | } 45 | 46 | func initialDifficulty(_ rating: Rating) -> Double { 47 | clampDifficulty( 48 | self[4] - exp(Double((rating.value - 1)) * self[5]) + 1 49 | ) 50 | .roundedUp() 51 | } 52 | 53 | func nextInterval(_ stability: Double) -> Double { 54 | clampInterval( 55 | (stability / factor) * (pow(requestRetention, 1.0 / decay) - 1.0), 56 | maximum: maximumInterval 57 | ) 58 | .roundedUp() 59 | } 60 | 61 | func meanReversion(_ initial: Double, current: Double) -> Double { 62 | self[7] * initial + (1 - self[7]) * current 63 | } 64 | 65 | func nextDifficulty(_ difficulty: Double, rating: Rating) -> Double { 66 | let nextDifficulty = difficulty - self[6] * Double(rating.value - 3) 67 | 68 | return clampDifficulty( 69 | meanReversion( 70 | initialDifficulty(.easy), 71 | current: nextDifficulty 72 | ) 73 | ) 74 | .roundedUp() 75 | } 76 | 77 | func shortTermNextStability(_ stability: Double, rating: Rating) -> Double { 78 | (stability * exp(self[17] * (Double(rating.value) - 3 + self[18]))) 79 | .roundedUp() 80 | } 81 | 82 | func forgettingCurve( 83 | elapsedDays: Double, 84 | stability: Double 85 | ) -> Double { 86 | guard 87 | !stability.isZero 88 | else { return 0 } 89 | 90 | return pow( 91 | 1.0 + factor * elapsedDays / stability, 92 | decay 93 | ) 94 | .roundedUp() 95 | } 96 | 97 | func nextForgetStability( 98 | difficulty: Double, 99 | stability: Double, 100 | retrievability: Double 101 | ) -> Double { 102 | (self[11] * pow(difficulty, -self[12]) * (pow(stability + 1.0, self[13]) - 1) 103 | * exp((1 - retrievability) * self[14])) 104 | .roundedUp() 105 | } 106 | 107 | func nextRecallStability( 108 | difficulty: Double, 109 | stability: Double, 110 | retrievability: Double, 111 | rating: Rating 112 | ) -> Double { 113 | let hardPenalty = rating == .hard ? self[15] : 1 114 | let easyBonus = rating == .easy ? self[16] : 1 115 | let a = exp(self[8]) * (11 - difficulty) * pow(stability, -self[9]) 116 | let b = exp((1 - retrievability) * self[10]) - 1 117 | 118 | return (stability * (1 + a * b * hardPenalty * easyBonus)) 119 | .roundedUp() 120 | } 121 | } 122 | 123 | extension FSRSAlgorithm { 124 | public subscript(index: Int) -> Double { 125 | get { 126 | parameters[index] 127 | } 128 | set { 129 | parameters[index] = newValue 130 | } 131 | } 132 | } 133 | 134 | extension Double { 135 | func roundedUp(toPlaces places: Int = 8) -> Double { 136 | let precision = pow(10.0, Double(places)) 137 | return (self * precision).rounded() / precision 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Helpers.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | func clampDifficulty(_ value: Double) -> Double { 4 | clamp(value, minimum: 1, maximum: 10) 5 | } 6 | 7 | func clampInterval(_ value: Double, maximum: Double) -> Double { 8 | clamp(round(value), minimum: 1, maximum: maximum) 9 | } 10 | 11 | func clamp(_ value: Double, minimum: Double, maximum: Double) -> Double { 12 | min(max(value, minimum), maximum) 13 | } 14 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Models/Card.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Card: Hashable, Codable, Sendable { 4 | /// The current state of the card (New, Learning, Review, Relearning) 5 | public var status: Status 6 | 7 | /// Date when the card is next due for review 8 | public var due: Date 9 | 10 | /// A measure of how well the information is retained 11 | public var stability: Double 12 | 13 | /// Reflects the inherent difficulty of the card content 14 | public var difficulty: Double 15 | 16 | /// Days since the card was last reviewed 17 | public var elapsedDays: Double 18 | 19 | /// The interval at which the card is next scheduled 20 | public var scheduledDays: Double 21 | 22 | /// Total number of times the card has been reviewed 23 | public var reps: Int 24 | 25 | /// Times the card was forgotten or remembered incorrectly 26 | public var lapses: Int 27 | 28 | /// The most recent review date, if applicable 29 | public var lastReview: Date? 30 | 31 | public init( 32 | due: Date = Date(), 33 | stability: Double = 0, 34 | difficulty: Double = 0, 35 | elapsedDays: Double = 0, 36 | scheduledDays: Double = 0, 37 | reps: Int = 0, 38 | lapses: Int = 0, 39 | status: Status = .new, 40 | lastReview: Date? = nil 41 | ) { 42 | self.due = due 43 | self.stability = stability 44 | self.difficulty = difficulty 45 | self.elapsedDays = elapsedDays 46 | self.scheduledDays = scheduledDays 47 | self.reps = reps 48 | self.lapses = lapses 49 | self.status = status 50 | self.lastReview = lastReview 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Models/CardReview.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct CardReview: Codable, Hashable, Sendable { 4 | /// The post-review card. 5 | public var postReviewCard: Card 6 | 7 | /// The review log. 8 | public var reviewLog: ReviewLog 9 | 10 | /// Initialize a new card review. 11 | /// 12 | /// - Parameters: 13 | /// - postReviewCard: The post-review card. 14 | /// - preReviewCard: The pre-review card. 15 | /// - rating: The rating given to the card. 16 | public init(postReviewCard: Card, preReviewCard: Card, rating: Rating) { 17 | self.postReviewCard = postReviewCard 18 | 19 | self.reviewLog = .init( 20 | rating: rating, 21 | status: preReviewCard.status, 22 | due: preReviewCard.due, 23 | stability: preReviewCard.stability, 24 | difficulty: preReviewCard.difficulty, 25 | elapsedDays: preReviewCard.elapsedDays, 26 | scheduledDays: preReviewCard.scheduledDays, 27 | reviewedAt: Date() 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Models/Grade.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Grade: Codable, Hashable, Sendable { 4 | case manual 5 | case rating(Rating) 6 | } 7 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Models/Parameters.swift: -------------------------------------------------------------------------------- 1 | let v5Params = [ 2 | 0.4072, 3 | 1.1829, 4 | 3.1262, 5 | 15.4722, 6 | 7.2102, 7 | 0.5316, 8 | 1.0651, 9 | 0.0234, 10 | 1.616, 11 | 0.1544, 12 | 1.0824, 13 | 1.9813, 14 | 0.0953, 15 | 0.2975, 16 | 2.2042, 17 | 0.2407, 18 | 2.9466, 19 | 0.5034, 20 | 0.6567, 21 | ] 22 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Models/Rating.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Rating: String, Codable, Hashable, Sendable { 4 | case again, hard, good, easy 5 | 6 | public var value: Int { 7 | switch self { 8 | case .again: 1 9 | case .hard: 2 10 | case .good: 3 11 | case .easy: 4 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Models/ReviewLog.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Review record information associated with the card, used for analysis, undoing the review, and optimization 4 | public struct ReviewLog: Hashable, Codable, Sendable { 5 | /// Rating of the review (Again, Hard, Good, Easy) 6 | public var rating: Rating 7 | 8 | /// Status of the review (New, Learning, Review, Relearning) 9 | public var status: Status 10 | 11 | /// Date of the last scheduling 12 | public var due: Date 13 | 14 | /// Stability of the card before the review 15 | public var stability: Double 16 | 17 | /// Difficulty of the card before the review 18 | public var difficulty: Double 19 | 20 | /// Number of days elapsed since the last review 21 | public var elapsedDays: Double 22 | 23 | /// Number of days until the next review 24 | public var scheduledDays: Double 25 | 26 | /// Date of the review 27 | public var reviewedAt: Date 28 | 29 | public init( 30 | rating: Rating, 31 | status: Status, 32 | due: Date, stability: Double, 33 | difficulty: Double, 34 | elapsedDays: Double, 35 | scheduledDays: Double, 36 | reviewedAt: Date 37 | ) { 38 | self.rating = rating 39 | self.status = status 40 | self.due = due 41 | self.stability = stability 42 | self.difficulty = difficulty 43 | self.elapsedDays = elapsedDays 44 | self.scheduledDays = scheduledDays 45 | self.reviewedAt = reviewedAt 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Models/Status.swift: -------------------------------------------------------------------------------- 1 | public enum Status: String, Codable, Hashable, Sendable { 2 | case new 3 | case learning 4 | case review 5 | case relearning 6 | } 7 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Schedulers/LongTermScheduler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct LongTermScheduler: Scheduler { 4 | public func schedule( 5 | card: Card, 6 | algorithm: FSRSAlgorithm, 7 | reviewRating: Rating, 8 | reviewTime: Date = Date() 9 | ) -> CardReview { 10 | var newCard = card 11 | 12 | newCard.lastReview = reviewTime 13 | newCard.reps += 1 14 | 15 | // Elapsed days since the last review 16 | if card.status == .new { 17 | newCard.elapsedDays = 0 18 | } else if let lastReview = card.lastReview, 19 | reviewTime > lastReview 20 | { 21 | newCard.elapsedDays = reviewTime.timeIntervalSince(lastReview) / 86_400 22 | } 23 | 24 | switch newCard.status { 25 | case .new: 26 | newCard.difficulty = algorithm.initialDifficulty(reviewRating) 27 | newCard.stability = algorithm.initialStability(reviewRating) 28 | let firstStability = algorithm.initialStability(reviewRating) 29 | 30 | let againStability = algorithm.shortTermNextStability( 31 | firstStability, 32 | rating: .again 33 | ) 34 | 35 | let hardStability = algorithm.shortTermNextStability( 36 | firstStability, 37 | rating: .hard 38 | ) 39 | 40 | let goodStability = algorithm.shortTermNextStability( 41 | firstStability, 42 | rating: .good 43 | ) 44 | 45 | let easyStability = algorithm.shortTermNextStability( 46 | firstStability, 47 | rating: .easy 48 | ) 49 | 50 | var againInterval = algorithm.nextInterval(againStability) 51 | var hardInterval = algorithm.nextInterval(hardStability) 52 | var goodInterval = algorithm.nextInterval(goodStability) 53 | var easyInterval = algorithm.nextInterval(easyStability) 54 | 55 | againInterval = min(againInterval, hardInterval) 56 | hardInterval = max(hardInterval, againInterval + 1) 57 | goodInterval = max(goodInterval, hardInterval + 1) 58 | easyInterval = max(easyInterval, goodInterval + 1) 59 | 60 | switch reviewRating { 61 | case .again: 62 | newCard.scheduledDays = againInterval 63 | newCard.due = reviewTime.advanced(by: againInterval * 86_400) 64 | newCard.status = .review 65 | 66 | case .hard: 67 | newCard.scheduledDays = hardInterval 68 | newCard.due = reviewTime.advanced(by: hardInterval * 86_400) 69 | newCard.status = .review 70 | 71 | case .good: 72 | newCard.scheduledDays = goodInterval 73 | newCard.due = reviewTime.advanced(by: goodInterval * 86_400) 74 | newCard.status = .review 75 | 76 | case .easy: 77 | newCard.scheduledDays = easyInterval 78 | newCard.due = reviewTime.advanced(by: easyInterval * 86_400) 79 | newCard.status = .review 80 | } 81 | 82 | case .learning, .relearning, .review: 83 | let elapsedDays = newCard.elapsedDays 84 | let difficulty = newCard.difficulty 85 | let stability = newCard.stability 86 | 87 | newCard.difficulty = algorithm.nextDifficulty(difficulty, rating: reviewRating) 88 | 89 | let retrievability = algorithm.forgettingCurve( 90 | elapsedDays: elapsedDays, 91 | stability: stability 92 | ) 93 | 94 | newCard.stability = algorithm.nextForgetStability( 95 | difficulty: difficulty, 96 | stability: stability, 97 | retrievability: retrievability 98 | ) 99 | 100 | let againStability = algorithm.nextForgetStability( 101 | difficulty: difficulty, 102 | stability: stability, 103 | retrievability: retrievability 104 | ) 105 | 106 | let hardStability = algorithm.nextRecallStability( 107 | difficulty: difficulty, 108 | stability: stability, 109 | retrievability: retrievability, 110 | rating: .hard 111 | ) 112 | 113 | let goodStability = algorithm.nextRecallStability( 114 | difficulty: difficulty, 115 | stability: stability, 116 | retrievability: retrievability, 117 | rating: .good 118 | ) 119 | 120 | let easyStability = algorithm.nextRecallStability( 121 | difficulty: difficulty, 122 | stability: stability, 123 | retrievability: retrievability, 124 | rating: .easy 125 | ) 126 | 127 | var againInterval = algorithm.nextInterval(againStability) 128 | var hardInterval = algorithm.nextInterval(hardStability) 129 | var goodInterval = algorithm.nextInterval(goodStability) 130 | var easyInterval = algorithm.nextInterval(easyStability) 131 | 132 | againInterval = min(againInterval, hardInterval) 133 | hardInterval = max(hardInterval, againInterval + 1) 134 | goodInterval = max(goodInterval, hardInterval + 1) 135 | easyInterval = max(easyInterval, goodInterval + 1) 136 | 137 | switch reviewRating { 138 | case .again: 139 | newCard.scheduledDays = againInterval 140 | newCard.due = reviewTime.advanced(by: againInterval * 86_400) 141 | newCard.stability = againStability 142 | newCard.lapses += 1 143 | newCard.status = .review 144 | 145 | case .hard: 146 | newCard.scheduledDays = hardInterval 147 | newCard.due = reviewTime.advanced(by: hardInterval * 86_400) 148 | newCard.stability = hardStability 149 | newCard.status = .review 150 | 151 | case .good: 152 | newCard.scheduledDays = goodInterval 153 | newCard.due = reviewTime.advanced(by: goodInterval * 86_400) 154 | newCard.stability = goodStability 155 | newCard.status = .review 156 | 157 | case .easy: 158 | newCard.scheduledDays = easyInterval 159 | newCard.due = reviewTime.advanced(by: easyInterval * 86_400) 160 | newCard.stability = easyStability 161 | newCard.status = .review 162 | } 163 | } 164 | 165 | return CardReview(postReviewCard: newCard, preReviewCard: card, rating: reviewRating) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Schedulers/Scheduler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol Scheduler { 4 | /// Schedule a card for review. 5 | /// 6 | /// - Parameters: 7 | /// - card: The card to schedule. 8 | /// - grade: The grade assigned to the card. 9 | /// - algorithm: The FSRS algorithm to use. 10 | func schedule( 11 | card: Card, 12 | algorithm: FSRSAlgorithm, 13 | reviewRating: Rating, 14 | reviewTime: Date 15 | ) -> CardReview 16 | } 17 | 18 | public enum SchedulerType: String, Codable, Hashable, Sendable { 19 | case shortTerm 20 | case longTerm 21 | 22 | public var implementation: any Scheduler { 23 | switch self { 24 | case .shortTerm: ShortTermScheduler() 25 | case .longTerm: LongTermScheduler() 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/SwiftFSRS/Schedulers/ShortTermScheduler.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct ShortTermScheduler: Scheduler { 4 | public func schedule( 5 | card: Card, 6 | algorithm: FSRSAlgorithm, 7 | reviewRating: Rating, 8 | reviewTime: Date = Date() 9 | ) -> CardReview { 10 | var newCard = card 11 | 12 | newCard.lastReview = reviewTime 13 | newCard.reps += 1 14 | 15 | // Elapsed days since the last review 16 | if card.status == .new { 17 | newCard.elapsedDays = 0 18 | } else if let lastReview = card.lastReview, 19 | reviewTime > lastReview 20 | { 21 | newCard.elapsedDays = (reviewTime.timeIntervalSince(lastReview) / 86_400).rounded() 22 | } 23 | 24 | switch newCard.status { 25 | case .new: 26 | newCard.difficulty = algorithm.initialDifficulty(reviewRating) 27 | newCard.stability = algorithm.initialStability(reviewRating) 28 | 29 | switch reviewRating { 30 | case .again: 31 | newCard.scheduledDays = 0 32 | newCard.due = reviewTime.advanced(by: 60) 33 | newCard.status = .learning 34 | 35 | case .hard: 36 | newCard.scheduledDays = 0 37 | newCard.due = reviewTime.advanced(by: 60 * 5) 38 | newCard.status = .learning 39 | 40 | case .good: 41 | newCard.scheduledDays = 0 42 | newCard.due = reviewTime.advanced(by: 60 * 10) 43 | newCard.status = .learning 44 | 45 | case .easy: 46 | let interval = algorithm.nextInterval(newCard.stability) 47 | newCard.scheduledDays = interval 48 | newCard.due = reviewTime.advanced(by: interval * 86_400) 49 | newCard.status = .review 50 | } 51 | 52 | case .learning, .relearning: 53 | let status = card.status 54 | let difficulty = card.difficulty 55 | let stability = card.stability 56 | 57 | newCard.difficulty = algorithm.nextDifficulty(difficulty, rating: reviewRating) 58 | newCard.stability = algorithm.shortTermNextStability(stability, rating: reviewRating) 59 | 60 | switch reviewRating { 61 | case .again: 62 | newCard.scheduledDays = 0 63 | newCard.due = reviewTime.advanced(by: 60 * 5) 64 | newCard.status = status 65 | 66 | case .hard: 67 | newCard.scheduledDays = 0 68 | newCard.due = reviewTime.advanced(by: 60 * 10) 69 | newCard.status = status 70 | 71 | case .good: 72 | let interval = algorithm.nextInterval(newCard.stability) 73 | newCard.scheduledDays = interval 74 | newCard.due = reviewTime.advanced(by: interval * 86_400) 75 | newCard.status = .review 76 | 77 | case .easy: 78 | let goodStability = algorithm.shortTermNextStability(stability, rating: .good) 79 | let goodInterval = algorithm.nextInterval(goodStability) 80 | 81 | let easyInterval = max( 82 | goodInterval + 1, 83 | algorithm.nextInterval(newCard.stability) 84 | ) 85 | 86 | newCard.scheduledDays = easyInterval 87 | newCard.due = reviewTime.advanced(by: easyInterval * 86_400) 88 | newCard.status = .review 89 | } 90 | 91 | case .review: 92 | let elapsedDays = newCard.elapsedDays 93 | let difficulty = card.difficulty 94 | let stability = card.stability 95 | 96 | let retrievability = algorithm.forgettingCurve( 97 | elapsedDays: elapsedDays, 98 | stability: stability 99 | ) 100 | 101 | newCard.difficulty = algorithm.nextDifficulty(difficulty, rating: reviewRating) 102 | 103 | // Stability 104 | let hardStability = algorithm.nextRecallStability( 105 | difficulty: difficulty, 106 | stability: stability, 107 | retrievability: retrievability, 108 | rating: .hard 109 | ) 110 | 111 | let goodStability = algorithm.nextRecallStability( 112 | difficulty: difficulty, 113 | stability: stability, 114 | retrievability: retrievability, 115 | rating: .good 116 | ) 117 | 118 | let easyStability = algorithm.nextRecallStability( 119 | difficulty: difficulty, 120 | stability: stability, 121 | retrievability: retrievability, 122 | rating: .easy 123 | ) 124 | 125 | var hardInterval = algorithm.nextInterval(hardStability) 126 | var goodInterval = algorithm.nextInterval(goodStability) 127 | 128 | hardInterval = min(hardInterval, goodInterval) 129 | goodInterval = max(hardInterval + 1, goodInterval) 130 | 131 | let easyInterval = max( 132 | goodInterval + 1, 133 | algorithm.nextInterval(easyStability) 134 | ) 135 | 136 | switch reviewRating { 137 | case .again: 138 | newCard.stability = algorithm.nextForgetStability( 139 | difficulty: difficulty, 140 | stability: stability, 141 | retrievability: retrievability 142 | ) 143 | 144 | newCard.scheduledDays = 0 145 | newCard.status = .relearning 146 | newCard.due = reviewTime.advanced(by: 60 * 5) 147 | newCard.lapses += 1 148 | 149 | case .hard: 150 | newCard.scheduledDays = hardInterval 151 | newCard.stability = hardStability 152 | newCard.status = .review 153 | newCard.due = reviewTime.advanced(by: hardInterval * 86_400) 154 | 155 | case .good: 156 | newCard.scheduledDays = goodInterval 157 | newCard.stability = goodStability 158 | newCard.status = .review 159 | newCard.due = reviewTime.advanced(by: goodInterval * 86_400) 160 | 161 | case .easy: 162 | newCard.scheduledDays = easyInterval 163 | newCard.stability = easyStability 164 | newCard.status = .review 165 | newCard.due = reviewTime.advanced(by: easyInterval * 86_400) 166 | } 167 | } 168 | 169 | return CardReview(postReviewCard: newCard, preReviewCard: card, rating: reviewRating) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Tests/SwiftFSRSTests/LongTermSchedulerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import SwiftFSRS 4 | 5 | final class LongTermSchedulerTests: XCTestCase { 6 | override func setUp() { 7 | super.setUp() 8 | continueAfterFailure = false 9 | } 10 | 11 | func testSchedulingSteadyProgress() async throws { 12 | let jsonFile = Bundle.module.url( 13 | forResource: "long-term-bench-steady", 14 | withExtension: "json" 15 | )! 16 | 17 | let data = try Data(contentsOf: jsonFile) 18 | let decoder = JSONDecoder() 19 | decoder.keyDecodingStrategy = .convertFromSnakeCase 20 | 21 | // 2022-12-23T09:26:00.000Z 22 | decoder.dateDecodingStrategy = .iso8601 23 | 24 | let expectedCards = try decoder.decode([Card].self, from: data) 25 | 26 | let simulatedRatings = [ 27 | Rating.again, 28 | .again, 29 | .hard, 30 | .hard, 31 | .good, 32 | .good, 33 | .good, 34 | .good, 35 | .easy, 36 | .easy, 37 | .easy, 38 | .easy, 39 | ] 40 | 41 | let reviewCards = try await reviewCards( 42 | from: jsonFile, 43 | simulatedRatings: simulatedRatings, 44 | firstCard: expectedCards[0] 45 | ) 46 | 47 | for (index, card) in reviewCards.enumerated() { 48 | XCTAssertEqual(expectedCards[index], card) 49 | } 50 | } 51 | 52 | func testSchedulingUnsteadyProgress() async throws { 53 | let jsonFile = Bundle.module.url( 54 | forResource: "long-term-bench-unsteady", 55 | withExtension: "json" 56 | )! 57 | 58 | let data = try Data(contentsOf: jsonFile) 59 | let decoder = JSONDecoder() 60 | decoder.keyDecodingStrategy = .convertFromSnakeCase 61 | 62 | // 2022-12-23T09:26:00.000Z 63 | decoder.dateDecodingStrategy = .iso8601 64 | 65 | let expectedCards = try decoder.decode([Card].self, from: data) 66 | 67 | let simulatedRatings = [ 68 | Rating.again, 69 | .hard, 70 | .again, 71 | .again, 72 | .hard, 73 | .good, 74 | .good, 75 | .again, 76 | .easy, 77 | ] 78 | 79 | let reviewCards = try await reviewCards( 80 | from: jsonFile, 81 | simulatedRatings: simulatedRatings, 82 | firstCard: expectedCards[0] 83 | ) 84 | 85 | for (index, card) in reviewCards.enumerated() { 86 | XCTAssertEqual(expectedCards[index], card) 87 | } 88 | } 89 | 90 | private func reviewCards( 91 | from jsonFile: URL, 92 | simulatedRatings: [Rating], 93 | firstCard: Card 94 | ) async throws -> [Card] { 95 | let scheduler = LongTermScheduler() 96 | var reviewCards = [firstCard] 97 | 98 | for rating in simulatedRatings { 99 | guard let previousCard = reviewCards.last 100 | else { continue } 101 | 102 | let review = scheduler.schedule( 103 | card: previousCard, 104 | algorithm: .v5, 105 | reviewRating: rating, 106 | reviewTime: previousCard.due 107 | ) 108 | 109 | reviewCards.append(review.postReviewCard) 110 | } 111 | 112 | return reviewCards 113 | } 114 | 115 | } 116 | -------------------------------------------------------------------------------- /Tests/SwiftFSRSTests/Resources/long-term-bench-steady.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "due": "2022-02-01T10:00:00Z", 4 | "stability": 0, 5 | "difficulty": 0, 6 | "elapsed_days": 0, 7 | "scheduled_days": 0, 8 | "reps": 0, 9 | "lapses": 0, 10 | "status": "new", 11 | "last_review": "2022-02-01T10:00:00Z" 12 | }, 13 | { 14 | "rating": "Again", 15 | "due": "2022-02-02T10:00:00Z", 16 | "stability": 0.4072, 17 | "difficulty": 7.2102, 18 | "elapsed_days": 0, 19 | "scheduled_days": 1, 20 | "reps": 1, 21 | "lapses": 0, 22 | "status": "review", 23 | "last_review": "2022-02-01T10:00:00Z" 24 | }, 25 | { 26 | "rating": "Again", 27 | "due": "2022-02-03T10:00:00Z", 28 | "stability": 0.2749183, 29 | "difficulty": 9.19865348, 30 | "elapsed_days": 1, 31 | "scheduled_days": 1, 32 | "reps": 2, 33 | "lapses": 1, 34 | "status": "review", 35 | "last_review": "2022-02-02T10:00:00Z" 36 | }, 37 | { 38 | "rating": "Hard", 39 | "due": "2022-02-05T10:00:00Z", 40 | "stability": 0.51863947, 41 | "difficulty": 10, 42 | "elapsed_days": 1, 43 | "scheduled_days": 2, 44 | "reps": 3, 45 | "lapses": 1, 46 | "status": "review", 47 | "last_review": "2022-02-03T10:00:00Z" 48 | }, 49 | { 50 | "rating": "Hard", 51 | "due": "2022-02-07T10:00:00Z", 52 | "stability": 0.76010686, 53 | "difficulty": 10, 54 | "elapsed_days": 2, 55 | "scheduled_days": 2, 56 | "reps": 4, 57 | "lapses": 1, 58 | "status": "review", 59 | "last_review": "2022-02-05T10:00:00Z" 60 | }, 61 | { 62 | "rating": "Good", 63 | "due": "2022-02-10T10:00:00Z", 64 | "stability": 1.79847412, 65 | "difficulty": 9.84281884, 66 | "elapsed_days": 2, 67 | "scheduled_days": 3, 68 | "reps": 5, 69 | "lapses": 1, 70 | "status": "review", 71 | "last_review": "2022-02-07T10:00:00Z" 72 | }, 73 | { 74 | "rating": "Good", 75 | "due": "2022-02-14T10:00:00Z", 76 | "stability": 3.51178899, 77 | "difficulty": 9.68931572, 78 | "elapsed_days": 3, 79 | "scheduled_days": 4, 80 | "reps": 6, 81 | "lapses": 1, 82 | "status": "review", 83 | "last_review": "2022-02-10T10:00:00Z" 84 | }, 85 | { 86 | "rating": "Good", 87 | "due": "2022-02-20T10:00:00Z", 88 | "stability": 5.96307504, 89 | "difficulty": 9.53940457, 90 | "elapsed_days": 4, 91 | "scheduled_days": 6, 92 | "reps": 7, 93 | "lapses": 1, 94 | "status": "review", 95 | "last_review": "2022-02-14T10:00:00Z" 96 | }, 97 | { 98 | "rating": "Good", 99 | "due": "2022-03-02T10:00:00Z", 100 | "stability": 9.78787813, 101 | "difficulty": 9.39300135, 102 | "elapsed_days": 6, 103 | "scheduled_days": 10, 104 | "reps": 8, 105 | "lapses": 1, 106 | "status": "review", 107 | "last_review": "2022-02-20T10:00:00Z" 108 | }, 109 | { 110 | "rating": "Easy", 111 | "due": "2022-03-31T10:00:00Z", 112 | "stability": 28.90303866, 113 | "difficulty": 8.2098473, 114 | "elapsed_days": 10, 115 | "scheduled_days": 29, 116 | "reps": 9, 117 | "lapses": 1, 118 | "status": "review", 119 | "last_review": "2022-03-02T10:00:00Z" 120 | }, 121 | { 122 | "rating": "Easy", 123 | "due": "2022-07-19T10:00:00Z", 124 | "stability": 110.47831448, 125 | "difficulty": 7.05437906, 126 | "elapsed_days": 29, 127 | "scheduled_days": 110, 128 | "reps": 10, 129 | "lapses": 1, 130 | "status": "review", 131 | "last_review": "2022-03-31T10:00:00Z" 132 | }, 133 | { 134 | "rating": "Easy", 135 | "due": "2023-10-28T10:00:00Z", 136 | "stability": 466.48244662, 137 | "difficulty": 5.92594877, 138 | "elapsed_days": 110, 139 | "scheduled_days": 466, 140 | "reps": 11, 141 | "lapses": 1, 142 | "status": "review", 143 | "last_review": "2022-07-19T10:00:00Z" 144 | }, 145 | { 146 | "rating": "Easy", 147 | "due": "2029-05-08T10:00:00Z", 148 | "stability": 2018.73153962, 149 | "difficulty": 4.82392375, 150 | "elapsed_days": 466, 151 | "scheduled_days": 2019, 152 | "reps": 12, 153 | "lapses": 1, 154 | "status": "review", 155 | "last_review": "2023-10-28T10:00:00Z" 156 | } 157 | ] 158 | -------------------------------------------------------------------------------- /Tests/SwiftFSRSTests/Resources/long-term-bench-unsteady.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "due": "2022-02-01T10:00:00Z", 4 | "stability": 0, 5 | "difficulty": 0, 6 | "elapsed_days": 0, 7 | "scheduled_days": 0, 8 | "reps": 0, 9 | "lapses": 0, 10 | "status": "new", 11 | "last_review": "2022-02-01T10:00:00Z" 12 | }, 13 | { 14 | "rating": "Again", 15 | "due": "2022-02-02T10:00:00Z", 16 | "stability": 0.4072, 17 | "difficulty": 7.2102, 18 | "elapsed_days": 0, 19 | "scheduled_days": 1, 20 | "reps": 1, 21 | "lapses": 0, 22 | "status": "review", 23 | "last_review": "2022-02-01T10:00:00Z" 24 | }, 25 | { 26 | "rating": "Hard", 27 | "due": "2022-02-04T10:00:00Z", 28 | "stability": 0.93625306, 29 | "difficulty": 8.15847682, 30 | "elapsed_days": 1, 31 | "scheduled_days": 2, 32 | "reps": 2, 33 | "lapses": 0, 34 | "status": "review", 35 | "last_review": "2022-02-02T10:00:00Z" 36 | }, 37 | { 38 | "rating": "Again", 39 | "due": "2022-02-05T10:00:00Z", 40 | "stability": 0.52835901, 41 | "difficulty": 10, 42 | "elapsed_days": 2, 43 | "scheduled_days": 1, 44 | "reps": 3, 45 | "lapses": 1, 46 | "status": "review", 47 | "last_review": "2022-02-04T10:00:00Z" 48 | }, 49 | { 50 | "rating": "Again", 51 | "due": "2022-02-06T10:00:00Z", 52 | "stability": 0.30976528, 53 | "difficulty": 10, 54 | "elapsed_days": 1, 55 | "scheduled_days": 1, 56 | "reps": 4, 57 | "lapses": 2, 58 | "status": "review", 59 | "last_review": "2022-02-05T10:00:00Z" 60 | }, 61 | { 62 | "rating": "Hard", 63 | "due": "2022-02-08T10:00:00Z", 64 | "stability": 0.44672576, 65 | "difficulty": 10, 66 | "elapsed_days": 1, 67 | "scheduled_days": 2, 68 | "reps": 5, 69 | "lapses": 2, 70 | "status": "review", 71 | "last_review": "2022-02-06T10:00:00Z" 72 | }, 73 | { 74 | "rating": "Good", 75 | "due": "2022-02-11T10:00:00Z", 76 | "stability": 1.42966094, 77 | "difficulty": 9.84281884, 78 | "elapsed_days": 2, 79 | "scheduled_days": 3, 80 | "reps": 6, 81 | "lapses": 2, 82 | "status": "review", 83 | "last_review": "2022-02-08T10:00:00Z" 84 | }, 85 | { 86 | "rating": "Good", 87 | "due": "2022-02-14T10:00:00Z", 88 | "stability": 3.13882687, 89 | "difficulty": 9.68931572, 90 | "elapsed_days": 3, 91 | "scheduled_days": 3, 92 | "reps": 7, 93 | "lapses": 2, 94 | "status": "review", 95 | "last_review": "2022-02-11T10:00:00Z" 96 | }, 97 | { 98 | "rating": "Again", 99 | "due": "2022-02-15T10:00:00Z", 100 | "stability": 1.03735914, 101 | "difficulty": 10, 102 | "elapsed_days": 3, 103 | "scheduled_days": 1, 104 | "reps": 8, 105 | "lapses": 3, 106 | "status": "review", 107 | "last_review": "2022-02-14T10:00:00Z" 108 | }, 109 | { 110 | "rating": "Easy", 111 | "due": "2022-02-19T10:00:00Z", 112 | "stability": 2.72904636, 113 | "difficulty": 8.80264218, 114 | "elapsed_days": 1, 115 | "scheduled_days": 4, 116 | "reps": 9, 117 | "lapses": 3, 118 | "status": "review", 119 | "last_review": "2022-02-15T10:00:00Z" 120 | } 121 | ] -------------------------------------------------------------------------------- /Tests/SwiftFSRSTests/Resources/short-term-bench-steady.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "due": "2022-02-01T10:00:00Z", 4 | "stability": 0, 5 | "difficulty": 0, 6 | "elapsed_days": 0, 7 | "scheduled_days": 0, 8 | "reps": 0, 9 | "lapses": 0, 10 | "status": "new", 11 | "last_review": "2022-02-01T10:00:00Z" 12 | }, 13 | { 14 | "rating": "Again", 15 | "due": "2022-02-01T10:01:00Z", 16 | "stability": 0.4072, 17 | "difficulty": 7.2102, 18 | "elapsed_days": 0, 19 | "scheduled_days": 0, 20 | "reps": 1, 21 | "lapses": 0, 22 | "status": "learning", 23 | "last_review": "2022-02-01T10:00:00Z" 24 | }, 25 | { 26 | "rating": "Again", 27 | "due": "2022-02-01T10:06:00Z", 28 | "stability": 0.20707628, 29 | "difficulty": 9.19865348, 30 | "elapsed_days": 0, 31 | "scheduled_days": 0, 32 | "reps": 2, 33 | "lapses": 0, 34 | "status": "learning", 35 | "last_review": "2022-02-01T10:01:00Z" 36 | }, 37 | { 38 | "rating": "Hard", 39 | "due": "2022-02-01T10:16:00Z", 40 | "stability": 0.17421149, 41 | "difficulty": 10, 42 | "elapsed_days": 0, 43 | "scheduled_days": 0, 44 | "reps": 3, 45 | "lapses": 0, 46 | "status": "learning", 47 | "last_review": "2022-02-01T10:06:00Z" 48 | }, 49 | { 50 | "rating": "Hard", 51 | "due": "2022-02-01T10:26:00Z", 52 | "stability": 0.14656262, 53 | "difficulty": 10, 54 | "elapsed_days": 0, 55 | "scheduled_days": 0, 56 | "reps": 4, 57 | "lapses": 0, 58 | "status": "learning", 59 | "last_review": "2022-02-01T10:16:00Z" 60 | }, 61 | { 62 | "rating": "Good", 63 | "due": "2022-02-02T10:26:00Z", 64 | "stability": 0.20398278, 65 | "difficulty": 9.84281884, 66 | "elapsed_days": 0, 67 | "scheduled_days": 1, 68 | "reps": 5, 69 | "lapses": 0, 70 | "status": "review", 71 | "last_review": "2022-02-01T10:26:00Z" 72 | }, 73 | { 74 | "rating": "Good", 75 | "due": "2022-02-04T10:26:00Z", 76 | "stability": 0.82787083, 77 | "difficulty": 9.68931572, 78 | "elapsed_days": 1, 79 | "scheduled_days": 2, 80 | "reps": 6, 81 | "lapses": 0, 82 | "status": "review", 83 | "last_review": "2022-02-02T10:26:00Z" 84 | }, 85 | { 86 | "rating": "Good", 87 | "due": "2022-02-06T10:26:00Z", 88 | "stability": 2.19494488, 89 | "difficulty": 9.53940457, 90 | "elapsed_days": 2, 91 | "scheduled_days": 2, 92 | "reps": 7, 93 | "lapses": 0, 94 | "status": "review", 95 | "last_review": "2022-02-04T10:26:00Z" 96 | }, 97 | { 98 | "rating": "Good", 99 | "due": "2022-02-10T10:26:00Z", 100 | "stability": 3.69658368, 101 | "difficulty": 9.39300135, 102 | "elapsed_days": 2, 103 | "scheduled_days": 4, 104 | "reps": 8, 105 | "lapses": 0, 106 | "status": "review", 107 | "last_review": "2022-02-06T10:26:00Z" 108 | }, 109 | { 110 | "rating": "Easy", 111 | "due": "2022-02-23T10:26:00Z", 112 | "stability": 12.53105714, 113 | "difficulty": 8.2098473, 114 | "elapsed_days": 4, 115 | "scheduled_days": 13, 116 | "reps": 9, 117 | "lapses": 0, 118 | "status": "review", 119 | "last_review": "2022-02-10T10:26:00Z" 120 | }, 121 | { 122 | "rating": "Easy", 123 | "due": "2022-04-18T10:26:00Z", 124 | "stability": 53.9977799, 125 | "difficulty": 7.05437906, 126 | "elapsed_days": 13, 127 | "scheduled_days": 54, 128 | "reps": 10, 129 | "lapses": 0, 130 | "status": "review", 131 | "last_review": "2022-02-23T10:26:00Z" 132 | }, 133 | { 134 | "rating": "Easy", 135 | "due": "2022-12-23T10:26:00Z", 136 | "stability": 249.10502978, 137 | "difficulty": 5.92594877, 138 | "elapsed_days": 54, 139 | "scheduled_days": 249, 140 | "reps": 11, 141 | "lapses": 0, 142 | "status": "review", 143 | "last_review": "2022-04-18T10:26:00Z" 144 | }, 145 | { 146 | "rating": "Easy", 147 | "due": "2026-02-28T10:26:00Z", 148 | "stability": 1162.83006278, 149 | "difficulty": 4.82392375, 150 | "elapsed_days": 249, 151 | "scheduled_days": 1163, 152 | "reps": 12, 153 | "lapses": 0, 154 | "status": "review", 155 | "last_review": "2022-12-23T10:26:00Z" 156 | } 157 | ] 158 | -------------------------------------------------------------------------------- /Tests/SwiftFSRSTests/Resources/short-term-bench-unsteady.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "due": "2022-02-01T10:00:00Z", 4 | "stability": 0, 5 | "difficulty": 0, 6 | "elapsed_days": 0, 7 | "scheduled_days": 0, 8 | "reps": 0, 9 | "lapses": 0, 10 | "status": "new", 11 | "last_review": "2022-02-01T10:00:00Z" 12 | }, 13 | { 14 | "rating": "Again", 15 | "due": "2022-02-01T10:01:00Z", 16 | "stability": 0.4072, 17 | "difficulty": 7.2102, 18 | "elapsed_days": 0, 19 | "scheduled_days": 0, 20 | "reps": 1, 21 | "lapses": 0, 22 | "status": "learning", 23 | "last_review": "2022-02-01T10:00:00Z" 24 | }, 25 | { 26 | "rating": "Hard", 27 | "due": "2022-02-01T10:11:00Z", 28 | "stability": 0.34257385, 29 | "difficulty": 8.15847682, 30 | "elapsed_days": 0, 31 | "scheduled_days": 0, 32 | "reps": 2, 33 | "lapses": 0, 34 | "status": "learning", 35 | "last_review": "2022-02-01T10:01:00Z" 36 | }, 37 | { 38 | "rating": "Again", 39 | "due": "2022-02-01T10:16:00Z", 40 | "stability": 0.17421149, 41 | "difficulty": 10, 42 | "elapsed_days": 0, 43 | "scheduled_days": 0, 44 | "reps": 3, 45 | "lapses": 0, 46 | "status": "learning", 47 | "last_review": "2022-02-01T10:11:00Z" 48 | }, 49 | { 50 | "rating": "Again", 51 | "due": "2022-02-01T10:21:00Z", 52 | "stability": 0.088593, 53 | "difficulty": 10, 54 | "elapsed_days": 0, 55 | "scheduled_days": 0, 56 | "reps": 4, 57 | "lapses": 0, 58 | "status": "learning", 59 | "last_review": "2022-02-01T10:16:00Z" 60 | }, 61 | { 62 | "rating": "Hard", 63 | "due": "2022-02-01T10:31:00Z", 64 | "stability": 0.07453253, 65 | "difficulty": 10, 66 | "elapsed_days": 0, 67 | "scheduled_days": 0, 68 | "reps": 5, 69 | "lapses": 0, 70 | "status": "learning", 71 | "last_review": "2022-02-01T10:21:00Z" 72 | }, 73 | { 74 | "rating": "Good", 75 | "due": "2022-02-02T10:31:00Z", 76 | "stability": 0.10373281, 77 | "difficulty": 9.84281884, 78 | "elapsed_days": 0, 79 | "scheduled_days": 1, 80 | "reps": 6, 81 | "lapses": 0, 82 | "status": "review", 83 | "last_review": "2022-02-01T10:31:00Z" 84 | }, 85 | { 86 | "rating": "Good", 87 | "due": "2022-02-04T10:31:00Z", 88 | "stability": 0.63603402, 89 | "difficulty": 9.68931572, 90 | "elapsed_days": 1, 91 | "scheduled_days": 2, 92 | "reps": 7, 93 | "lapses": 0, 94 | "status": "review", 95 | "last_review": "2022-02-02T10:31:00Z" 96 | }, 97 | { 98 | "rating": "Again", 99 | "due": "2022-02-04T10:36:00Z", 100 | "stability": 0.42845472, 101 | "difficulty": 10, 102 | "elapsed_days": 2, 103 | "scheduled_days": 0, 104 | "reps": 8, 105 | "lapses": 1, 106 | "status": "relearning", 107 | "last_review": "2022-02-04T10:31:00Z" 108 | }, 109 | { 110 | "rating": "Easy", 111 | "due": "2022-02-06T10:36:00Z", 112 | "stability": 0.98650446, 113 | "difficulty": 8.80264218, 114 | "elapsed_days": 0, 115 | "scheduled_days": 2, 116 | "reps": 9, 117 | "lapses": 1, 118 | "status": "review", 119 | "last_review": "2022-02-04T10:36:00Z" 120 | } 121 | ] -------------------------------------------------------------------------------- /Tests/SwiftFSRSTests/ShortTermSchedulerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import SwiftFSRS 4 | 5 | final class ShortTermSchedulerTests: XCTestCase { 6 | override func setUp() { 7 | super.setUp() 8 | continueAfterFailure = false 9 | } 10 | 11 | func testSchedulingSteadyProgress() async throws { 12 | let jsonFile = Bundle.module.url( 13 | forResource: "short-term-bench-steady", 14 | withExtension: "json" 15 | )! 16 | 17 | let data = try Data(contentsOf: jsonFile) 18 | let decoder = JSONDecoder() 19 | decoder.keyDecodingStrategy = .convertFromSnakeCase 20 | 21 | // 2022-12-23T09:26:00.000Z 22 | decoder.dateDecodingStrategy = .iso8601 23 | 24 | let expectedCards = try decoder.decode([Card].self, from: data) 25 | 26 | let simulatedRatings = [ 27 | Rating.again, 28 | .again, 29 | .hard, 30 | .hard, 31 | .good, 32 | .good, 33 | .good, 34 | .good, 35 | .easy, 36 | .easy, 37 | .easy, 38 | .easy, 39 | ] 40 | 41 | let reviewCards = try await reviewCards( 42 | from: jsonFile, 43 | simulatedRatings: simulatedRatings, 44 | firstCard: expectedCards[0] 45 | ) 46 | 47 | for (index, card) in reviewCards.enumerated() { 48 | XCTAssertEqual(expectedCards[index], card) 49 | } 50 | } 51 | 52 | func testSchedulingUnsteadyProgress() async throws { 53 | let jsonFile = Bundle.module.url( 54 | forResource: "short-term-bench-unsteady", 55 | withExtension: "json" 56 | )! 57 | 58 | let data = try Data(contentsOf: jsonFile) 59 | let decoder = JSONDecoder() 60 | decoder.keyDecodingStrategy = .convertFromSnakeCase 61 | 62 | // 2022-12-23T09:26:00.000Z 63 | decoder.dateDecodingStrategy = .iso8601 64 | 65 | let expectedCards = try decoder.decode([Card].self, from: data) 66 | 67 | let simulatedRatings = [ 68 | Rating.again, 69 | .hard, 70 | .again, 71 | .again, 72 | .hard, 73 | .good, 74 | .good, 75 | .again, 76 | .easy, 77 | ] 78 | 79 | let reviewCards = try await reviewCards( 80 | from: jsonFile, 81 | simulatedRatings: simulatedRatings, 82 | firstCard: expectedCards[0] 83 | ) 84 | 85 | for (index, card) in reviewCards.enumerated() { 86 | XCTAssertEqual(expectedCards[index], card) 87 | } 88 | } 89 | 90 | private func reviewCards( 91 | from jsonFile: URL, 92 | simulatedRatings: [Rating], 93 | firstCard: Card 94 | ) async throws -> [Card] { 95 | let scheduler = ShortTermScheduler() 96 | var reviewCards = [firstCard] 97 | 98 | for rating in simulatedRatings { 99 | guard let previousCard = reviewCards.last 100 | else { continue } 101 | 102 | let review = scheduler.schedule( 103 | card: previousCard, 104 | algorithm: .v5, 105 | reviewRating: rating, 106 | reviewTime: previousCard.due 107 | ) 108 | 109 | reviewCards.append(review.postReviewCard) 110 | } 111 | 112 | return reviewCards 113 | } 114 | } 115 | --------------------------------------------------------------------------------