From 49808959deb17dfc678ec0135d89329b5b6ed260 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Sun, 22 Feb 2026 20:58:06 +0400 Subject: [PATCH 1/9] Add architectural refactoring design doc Outlines plan to merge calculators, replace scheduler inheritance with composition, rename public API, and restructure directories. Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-02-22-refactoring-design.md | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/plans/2026-02-22-refactoring-design.md diff --git a/docs/plans/2026-02-22-refactoring-design.md b/docs/plans/2026-02-22-refactoring-design.md new file mode 100644 index 0000000..ec8675c --- /dev/null +++ b/docs/plans/2026-02-22-refactoring-design.md @@ -0,0 +1,81 @@ +# SwiftFSRS Full Architectural Refactoring + +## Context + +SwiftFSRS is a ~4,350-line FSRS-6 spaced repetition library. While well-structured, it has accumulated complexity in three areas: + +1. **Scheduler inheritance** — `BaseScheduler` uses class hierarchy with `fatalError` template methods; two 250+ line subclasses duplicate significant logic +2. **Scattered responsibilities** — `FSRS` contains reschedule orchestration; calculators are split unnecessarily; `StrategyManager` exists but isn't wired +3. **API naming** — `repeat` collides with Swift keyword, Java-style getters (`getRetrievability`), vague method names (`next`) + +**Goals**: Reduce complexity, improve extensibility, make the API more Swift-idiomatic. + +--- + +## Step 1: Merge calculators into `MemoryStateCalculator` + +- Merge `StabilityCalculator` + `DifficultyCalculator` into `MemoryStateCalculator` +- Move `BaseScheduler.calculateNextMemoryState()` into `MemoryStateCalculator.nextMemoryState()` +- Keep `IntervalCalculator` separate (distinct concern: stability → interval days) +- All existing calculation logic stays identical — just reorganized + +## Step 2: Replace scheduler inheritance with composition + +``` +SchedulingPipeline (shared) +├── MemoryStateCalculator — compute next stability + difficulty +├── IntervalCalculator — compute interval from stability +└── IntervalConstraintApplier — enforce ordering constraints + +LearningStepsHandler (composable) +└── LearningStepsStrategy — determine step intervals + +ShortTermScheduler = Pipeline + LearningStepsHandler +LongTermScheduler = Pipeline only +``` + +## Step 3: Rename public API + +| Current | New | Reason | +|---------|-----|--------| +| `repeat(card:now:)` | `schedule(card:at:)` | Avoid Swift keyword, clearer intent | +| `next(card:now:rating:)` | `schedule(card:at:rating:)` | Overload of schedule, clearer | +| `getRetrievability(card:now:)` | `formattedRetrievability(of:at:)` | Swift-idiomatic | +| `getRetrievabilityValue(card:now:)` | `retrievability(of:at:)` | Primary method, cleaner name | +| `forget(card:now:)` | `forget(card:at:)` | Consistent `at:` label | +| `reschedule(currentCard:)` | `reschedule(card:)` | Simpler label | + +## Step 4: Clean up FSRS facade + +- Move reschedule orchestration into `RescheduleService` +- Wire `StrategyManager` through init +- Result: `FSRS` drops to ~120 lines + +## Step 5: Remove dead code & restructure directories + +- Delete `ScheduledInterval` value object (unused) +- Delete `Factory.swift` (`fsrs()` free function) +- Reorganize into `Algorithm/`, `Scheduling/`, `Models/`, etc. + +## Final directory structure + +``` +Sources/FSRS/ +├── FSRS.swift +├── Algorithm/ +│ ├── FSRSAlgorithm.swift +│ ├── MemoryStateCalculator.swift +│ ├── IntervalCalculator.swift +│ └── IntervalConstraintApplier.swift +├── Scheduling/ +│ ├── SchedulingPipeline.swift +│ ├── ShortTermScheduler.swift +│ ├── LongTermScheduler.swift +│ ├── LearningStepsHandler.swift +│ └── SchedulerFactory.swift +├── Models/ +├── Protocols/ +├── Services/ +├── Strategies/ +└── Utilities/ +``` From 9aa9c3ae7000cfcdefba1aea087e0b653c5faa6d Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Sun, 22 Feb 2026 21:01:38 +0400 Subject: [PATCH 2/9] Merge calculators into MemoryStateCalculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Unified StabilityCalculator + DifficultyCalculator into single MemoryStateCalculator with nextMemoryState() entry point - Moved calculateNextMemoryState logic from BaseScheduler into MemoryStateCalculator (pure calculation, not scheduling) - Moved IntervalCalculator and IntervalConstraintApplier to Algorithm/ - Deleted old separate calculator files All 125 tests pass — behavioral equivalence verified. Co-Authored-By: Claude Opus 4.6 --- .../IntervalCalculator.swift | 0 .../IntervalConstraintApplier.swift | 0 .../Algorithm/MemoryStateCalculator.swift | 306 ++++++++++++++++++ .../Calculators/DifficultyCalculator.swift | 103 ------ .../Calculators/StabilityCalculator.swift | 180 ----------- Sources/FSRS/Core/FSRSAlgorithm.swift | 16 +- Sources/FSRS/Schedulers/BaseScheduler.swift | 95 +----- Sources/FSRS/Schedulers/BasicScheduler.swift | 2 +- .../FSRS/Schedulers/LongTermScheduler.swift | 2 +- 9 files changed, 322 insertions(+), 382 deletions(-) rename Sources/FSRS/{Core/Calculators => Algorithm}/IntervalCalculator.swift (100%) rename Sources/FSRS/{Schedulers => Algorithm}/IntervalConstraintApplier.swift (100%) create mode 100644 Sources/FSRS/Algorithm/MemoryStateCalculator.swift delete mode 100644 Sources/FSRS/Core/Calculators/DifficultyCalculator.swift delete mode 100644 Sources/FSRS/Core/Calculators/StabilityCalculator.swift diff --git a/Sources/FSRS/Core/Calculators/IntervalCalculator.swift b/Sources/FSRS/Algorithm/IntervalCalculator.swift similarity index 100% rename from Sources/FSRS/Core/Calculators/IntervalCalculator.swift rename to Sources/FSRS/Algorithm/IntervalCalculator.swift diff --git a/Sources/FSRS/Schedulers/IntervalConstraintApplier.swift b/Sources/FSRS/Algorithm/IntervalConstraintApplier.swift similarity index 100% rename from Sources/FSRS/Schedulers/IntervalConstraintApplier.swift rename to Sources/FSRS/Algorithm/IntervalConstraintApplier.swift diff --git a/Sources/FSRS/Algorithm/MemoryStateCalculator.swift b/Sources/FSRS/Algorithm/MemoryStateCalculator.swift new file mode 100644 index 0000000..db8b01c --- /dev/null +++ b/Sources/FSRS/Algorithm/MemoryStateCalculator.swift @@ -0,0 +1,306 @@ +import Foundation + +/// Unified calculator for all memory state operations (stability + difficulty) +/// Stability represents the interval at which retrievability = 90% +/// Difficulty represents how hard a card is to remember (1-10 scale) +public struct MemoryStateCalculator { + private let parameters: FSRSParameters + private let logger: (any FSRSLogger)? + + public init(parameters: FSRSParameters, logger: (any FSRSLogger)? = nil) { + self.parameters = parameters + self.logger = logger + } + + // MARK: - Next Memory State (orchestration) + + /// Calculate next memory state (difficulty and stability) for a rating. + /// This is the primary entry point used by schedulers. + /// + /// - Parameters: + /// - currentCard: The current card (used to detect new cards) + /// - elapsedDays: Days elapsed since last review + /// - rating: Rating given + /// - retrievability: Optional retrievability (calculated if nil) + /// - enableShortTerm: Whether short-term scheduling is enabled + /// - Returns: Next memory state + /// - Throws: FSRSError if calculation fails + public func nextMemoryState( + currentCard: some FSRSCard, + elapsedDays: ElapsedDays, + rating: Rating, + retrievability: Retrievability? = nil, + enableShortTerm: Bool + ) throws -> MemoryState { + // New cards have stability=0.0 and difficulty=0.0, which fail validation + let isNewCard = currentCard.stability == 0.0 && currentCard.difficulty == 0.0 + + if isNewCard { + let initialStability = try initStability(for: rating) + let initialDifficulty = try initDifficulty(for: rating) + + logger?.debug( + """ + New card state: \ + stability=\(initialStability.value), \ + difficulty=\(initialDifficulty.value), \ + rating=\(rating) + """) + + return MemoryState( + stability: initialStability, + difficulty: initialDifficulty + ) + } + + // Card is not new, so we can safely create value objects + let currentStability = try Stability(currentCard.stability) + let currentDifficulty = try Difficulty(currentCard.difficulty) + + // Calculate or use provided retrievability + let actualRetrievability: Retrievability + if let provided = retrievability { + actualRetrievability = provided + } else { + actualRetrievability = try forgettingCurve( + elapsedDays: elapsedDays, + stability: currentStability + ) + } + + // Calculate next difficulty + let nextDiff = try nextDifficulty( + current: currentDifficulty, + rating: rating + ) + + // Calculate next stability based on rating and elapsed time + let nextStab: Stability + if elapsedDays.value == 0 && enableShortTerm { + nextStab = try nextShortTermStability( + stability: currentStability, + rating: rating + ) + } else if rating == .again { + nextStab = try nextForgetStability( + difficulty: currentDifficulty, + stability: currentStability, + retrievability: actualRetrievability + ) + } else { + nextStab = try nextRecallStability( + difficulty: currentDifficulty, + stability: currentStability, + retrievability: actualRetrievability, + rating: rating + ) + } + + logger?.debug( + """ + State transition: \ + s=\(currentStability.value) -> \(nextStab.value), \ + d=\(currentDifficulty.value) -> \(nextDiff.value), \ + rating=\(rating) + """) + + return MemoryState(stability: nextStab, difficulty: nextDiff) + } + + // MARK: - Forgetting Curve + + /// Calculate retrievability using the forgetting curve + /// R(t,S) = (1 + FACTOR * t/(9*S))^DECAY + public func forgettingCurve( + elapsedDays: ElapsedDays, + stability: Stability + ) throws -> Retrievability { + let (decay, factor) = Self.computeDecayFactor(parameters.weights) + let result = pow( + 1 + (factor * elapsedDays.value) / (RETRIEVABILITY_CURVE_DIVISOR * stability.value), + decay + ) + + let clampedResult = clamp(result, min: 0.0, max: 1.0) + let retrievability = try Retrievability(roundToFixed(clampedResult)) + + logger?.info("Forgetting curve: elapsed=\(elapsedDays.value)d, stability=\(stability.value) -> retrievability=\(retrievability.value)") + + return retrievability + } + + /// Compute decay factor from parameters + public static func computeDecayFactor(_ weights: [Double]) -> (decay: Double, factor: Double) { + let decay = -weights[20] + let factor = exp(pow(decay, -1) * log(RETRIEVABILITY_TARGET)) - 1.0 + return (decay: decay, factor: roundToFixed(factor)) + } + + // MARK: - Initial Stability + + /// Initialize stability for a new card based on rating + /// S_0(G) = weights[G-1], S_0 = max{S_0, 0.1} + public func initStability(for rating: Rating) throws -> Stability { + let gradeValue = rating.rawValue + guard gradeValue >= 1 && gradeValue <= 4 else { + throw FSRSError.invalidRating("Grade must be Again(1), Hard(2), Good(3), or Easy(4), got \(rating)") + } + + let stabilityValue = max(parameters.weights[gradeValue - 1], 0.1) + let stability = try Stability(stabilityValue) + + logger?.debug("Initial stability for rating \(rating): \(stability.value)") + + return stability + } + + // MARK: - Recall Stability (Success) + + /// Calculate next stability after successful recall + public func nextRecallStability( + difficulty: Difficulty, + stability: Stability, + retrievability: Retrievability, + rating: Rating + ) throws -> Stability { + let hardPenalty = (rating == .hard) ? parameters.weights[15] : 1.0 + let easyBonus = (rating == .easy) ? parameters.weights[16] : 1.0 + + let result = stability.value * ( + 1 + exp(parameters.weights[8]) * + (DIFFICULTY_CENTER_POINT - difficulty.value) * + pow(stability.value, -parameters.weights[9]) * + (exp((1 - retrievability.value) * parameters.weights[10]) - 1) * + hardPenalty * + easyBonus + ) + + let clampedResult = clamp(result, min: S_MIN, max: S_MAX) + let newStability = try Stability(clampedResult) + + logger?.debug(""" + Recall stability: \ + s=\(stability.value) -> \(newStability.value), \ + d=\(difficulty.value), \ + r=\(retrievability.value), \ + rating=\(rating) + """) + + return newStability + } + + // MARK: - Forget Stability (Failure) + + /// Calculate next stability after forgetting (Again rating in review state) + public func nextForgetStability( + difficulty: Difficulty, + stability: Stability, + retrievability: Retrievability + ) throws -> Stability { + let result = parameters.weights[11] * + pow(difficulty.value, -parameters.weights[12]) * + (pow(stability.value + 1, parameters.weights[13]) - 1) * + exp((1 - retrievability.value) * parameters.weights[14]) + + let clampedResult = clamp(result, min: S_MIN, max: S_MAX) + let newStability = try Stability(clampedResult) + + logger?.debug(""" + Forget stability: \ + s=\(stability.value) -> \(newStability.value), \ + d=\(difficulty.value), \ + r=\(retrievability.value) + """) + + return newStability + } + + // MARK: - Short-term Stability + + /// Calculate next short-term stability (for learning/relearning steps) + public func nextShortTermStability( + stability: Stability, + rating: Rating + ) throws -> Stability { + let gradeValue = Double(rating.rawValue) + let sinc = pow(stability.value, -parameters.weights[19]) * + exp(parameters.weights[17] * (gradeValue - GRADE_NEUTRAL_VALUE + parameters.weights[18])) + + let maskedSinc = gradeValue >= GRADE_NEUTRAL_VALUE ? max(sinc, 1.0) : sinc + let result = stability.value * maskedSinc + + let clampedResult = clamp(result, min: S_MIN, max: S_MAX) + let newStability = try Stability(clampedResult) + + logger?.debug(""" + Short-term stability: \ + s=\(stability.value) -> \(newStability.value), \ + rating=\(rating) + """) + + return newStability + } + + // MARK: - Initial Difficulty + + /// Initialize difficulty for a new card based on rating + /// D_0(G) = weights[4] - e^((G-1)*w[5]) + 1 + public func initDifficulty(for rating: Rating) throws -> Difficulty { + let gradeValue = Double(rating.rawValue) + let difficultyValue = parameters.weights[4] - exp((gradeValue - 1) * parameters.weights[5]) + 1 + let clampedValue = clamp(difficultyValue, min: DIFFICULTY_RANGE_MIN, max: DIFFICULTY_RANGE_MAX) + let difficulty = try Difficulty(roundToFixed(clampedValue)) + + logger?.debug("Initial difficulty for rating \(rating): \(difficulty.value)") + + return difficulty + } + + // MARK: - Next Difficulty + + /// Calculate next difficulty after a review + public func nextDifficulty( + current currentDifficulty: Difficulty, + rating: Rating + ) throws -> Difficulty { + let gradeValue = Double(rating.rawValue) + let delta = -parameters.weights[6] * (gradeValue - GRADE_NEUTRAL_VALUE) + + let damped = linearDamping(delta: delta, currentDifficulty: currentDifficulty) + let afterDamping = currentDifficulty.value + damped + + let clampedAfterDamping = clamp(afterDamping, min: DIFFICULTY_RANGE_MIN, max: DIFFICULTY_RANGE_MAX) + + let withReversion = try applyMeanReversion( + initial: try initDifficulty(for: .easy), + current: try Difficulty(clampedAfterDamping) + ) + + let clampedValue = clamp(withReversion.value, min: DIFFICULTY_RANGE_MIN, max: DIFFICULTY_RANGE_MAX) + let newDifficulty = try Difficulty(clampedValue) + + logger?.debug(""" + Next difficulty: \ + d=\(currentDifficulty.value) -> \(newDifficulty.value), \ + rating=\(rating), \ + delta=\(delta) + """) + + return newDifficulty + } + + // MARK: - Difficulty Helpers + + private func linearDamping(delta: Double, currentDifficulty: Difficulty) -> Double { + let damped = (delta * (DIFFICULTY_RANGE_MAX - currentDifficulty.value)) / DIFFICULTY_RANGE_SPAN + return roundToFixed(damped) + } + + private func applyMeanReversion( + initial: Difficulty, + current: Difficulty + ) throws -> Difficulty { + let reverted = parameters.weights[7] * initial.value + (1 - parameters.weights[7]) * current.value + return try Difficulty(roundToFixed(reverted)) + } +} diff --git a/Sources/FSRS/Core/Calculators/DifficultyCalculator.swift b/Sources/FSRS/Core/Calculators/DifficultyCalculator.swift deleted file mode 100644 index 5ab874f..0000000 --- a/Sources/FSRS/Core/Calculators/DifficultyCalculator.swift +++ /dev/null @@ -1,103 +0,0 @@ -import Foundation - -/// Calculator for all difficulty-related FSRS formulas -/// Difficulty represents how hard a card is to remember (1-10 scale) -public struct DifficultyCalculator { - private let parameters: FSRSParameters - private let logger: (any FSRSLogger)? - - public init(parameters: FSRSParameters, logger: (any FSRSLogger)? = nil) { - self.parameters = parameters - self.logger = logger - } - - // MARK: - Initial Difficulty - - /// Initialize difficulty for a new card based on rating - /// D₀(G) = weights[4] - e^((G-1)·w[5]) + 1 - /// D₀ = min{max{D₀(G), 1}, 10} - /// - /// - Parameter rating: Grade rating - /// - Returns: Initial difficulty - public func initDifficulty(for rating: Rating) throws -> Difficulty { - let gradeValue = Double(rating.rawValue) - let difficultyValue = parameters.weights[4] - exp((gradeValue - 1) * parameters.weights[5]) + 1 - let clampedValue = clamp(difficultyValue, min: DIFFICULTY_RANGE_MIN, max: DIFFICULTY_RANGE_MAX) - let difficulty = try Difficulty(roundToFixed(clampedValue)) - - logger?.debug("Initial difficulty for rating \(rating): \(difficulty.value)") - - return difficulty - } - - // MARK: - Next Difficulty - - /// Calculate next difficulty after a review - /// delta_d = -weights[6] × (G - 3) - /// next_d = D + linear_damping(delta_d, D) - /// D'(D,R) = weights[7] × D₀(4) + (1 - weights[7]) × next_d - /// - /// - Parameters: - /// - currentDifficulty: Current difficulty - /// - rating: Grade rating - /// - Returns: Next difficulty - public func nextDifficulty( - current currentDifficulty: Difficulty, - rating: Rating - ) throws -> Difficulty { - let gradeValue = Double(rating.rawValue) - let delta = -parameters.weights[6] * (gradeValue - GRADE_NEUTRAL_VALUE) - - let damped = linearDamping(delta: delta, currentDifficulty: currentDifficulty) - let afterDamping = currentDifficulty.value + damped - - // Clamp afterDamping before creating Difficulty object to prevent negative values - let clampedAfterDamping = clamp(afterDamping, min: DIFFICULTY_RANGE_MIN, max: DIFFICULTY_RANGE_MAX) - - let withReversion = try applyMeanReversion( - initial: try initDifficulty(for: .easy), - current: try Difficulty(clampedAfterDamping) - ) - - let clampedValue = clamp(withReversion.value, min: DIFFICULTY_RANGE_MIN, max: DIFFICULTY_RANGE_MAX) - let newDifficulty = try Difficulty(clampedValue) - - logger?.debug(""" - Next difficulty: \ - d=\(currentDifficulty.value) -> \(newDifficulty.value), \ - rating=\(rating), \ - delta=\(delta) - """) - - return newDifficulty - } - - // MARK: - Helper Methods - - /// Apply linear damping to difficulty change - /// This prevents extreme difficulty changes for cards near boundaries - /// - /// - Parameters: - /// - delta: Raw difficulty change - /// - currentDifficulty: Current difficulty value - /// - Returns: Damped difficulty change - private func linearDamping(delta: Double, currentDifficulty: Difficulty) -> Double { - let damped = (delta * (DIFFICULTY_RANGE_MAX - currentDifficulty.value)) / DIFFICULTY_RANGE_SPAN - return roundToFixed(damped) - } - - /// Apply mean reversion to pull difficulty toward initial value - /// D' = weights[7] × D_initial + (1 - weights[7]) × D_current - /// - /// - Parameters: - /// - initial: Initial difficulty value - /// - current: Current difficulty value - /// - Returns: Difficulty with mean reversion applied - private func applyMeanReversion( - initial: Difficulty, - current: Difficulty - ) throws -> Difficulty { - let reverted = parameters.weights[7] * initial.value + (1 - parameters.weights[7]) * current.value - return try Difficulty(roundToFixed(reverted)) - } -} diff --git a/Sources/FSRS/Core/Calculators/StabilityCalculator.swift b/Sources/FSRS/Core/Calculators/StabilityCalculator.swift deleted file mode 100644 index b62adac..0000000 --- a/Sources/FSRS/Core/Calculators/StabilityCalculator.swift +++ /dev/null @@ -1,180 +0,0 @@ -import Foundation - -/// Calculator for all stability-related FSRS formulas -/// Stability represents the interval at which retrievability = 90% -public struct StabilityCalculator { - private let parameters: FSRSParameters - private let logger: (any FSRSLogger)? - - public init(parameters: FSRSParameters, logger: (any FSRSLogger)? = nil) { - self.parameters = parameters - self.logger = logger - } - - // MARK: - Forgetting Curve - - /// Calculate retrievability using the forgetting curve - /// R(t,S) = (1 + FACTOR × t/(9·S))^DECAY - /// - /// - Parameters: - /// - elapsedDays: Days since last review - /// - stability: Current stability - /// - Returns: Retrievability (probability of recall) - public func forgettingCurve( - elapsedDays: ElapsedDays, - stability: Stability - ) throws -> Retrievability { - let (decay, factor) = Self.computeDecayFactor(parameters.weights) - let result = pow( - 1 + (factor * elapsedDays.value) / (RETRIEVABILITY_CURVE_DIVISOR * stability.value), - decay - ) - - let clampedResult = clamp(result, min: 0.0, max: 1.0) - let retrievability = try Retrievability(roundToFixed(clampedResult)) - - logger?.info("Forgetting curve: elapsed=\(elapsedDays.value)d, stability=\(stability.value) -> retrievability=\(retrievability.value)") - - return retrievability - } - - /// Compute decay factor from parameters - /// - Parameter weights: Weight parameters array - /// - Returns: Tuple of (decay, factor) - public static func computeDecayFactor(_ weights: [Double]) -> (decay: Double, factor: Double) { - let decay = -weights[20] - let factor = exp(pow(decay, -1) * log(RETRIEVABILITY_TARGET)) - 1.0 - return (decay: decay, factor: roundToFixed(factor)) - } - - // MARK: - Initial Stability - - /// Initialize stability for a new card based on rating - /// S₀(G) = weights[G-1] - /// S₀ = max{S₀, 0.1} - /// - /// - Parameter rating: Grade rating (Again, Hard, Good, Easy) - /// - Returns: Initial stability - public func initStability(for rating: Rating) throws -> Stability { - let gradeValue = rating.rawValue - guard gradeValue >= 1 && gradeValue <= 4 else { - throw FSRSError.invalidRating("Grade must be Again(1), Hard(2), Good(3), or Easy(4), got \(rating)") - } - - let stabilityValue = max(parameters.weights[gradeValue - 1], 0.1) - let stability = try Stability(stabilityValue) - - logger?.debug("Initial stability for rating \(rating): \(stability.value)") - - return stability - } - - // MARK: - Recall Stability (Success) - - /// Calculate next stability after successful recall - /// S'_r(D,S,R,G) = S × (e^weights[8] × (11-D) × S^(-weights[9]) × (e^(weights[10]×(1-R))-1) × weights[15](if G=2) × weights[16](if G=4) + 1) - /// - /// - Parameters: - /// - difficulty: Current difficulty - /// - stability: Current stability - /// - retrievability: Retrievability at time of review - /// - rating: Grade rating - /// - Returns: New stability after successful recall - public func nextRecallStability( - difficulty: Difficulty, - stability: Stability, - retrievability: Retrievability, - rating: Rating - ) throws -> Stability { - let hardPenalty = (rating == .hard) ? parameters.weights[15] : 1.0 - let easyBonus = (rating == .easy) ? parameters.weights[16] : 1.0 - - let result = stability.value * ( - 1 + exp(parameters.weights[8]) * - (DIFFICULTY_CENTER_POINT - difficulty.value) * - pow(stability.value, -parameters.weights[9]) * - (exp((1 - retrievability.value) * parameters.weights[10]) - 1) * - hardPenalty * - easyBonus - ) - - let clampedResult = clamp(result, min: S_MIN, max: S_MAX) - let newStability = try Stability(clampedResult) - - logger?.debug(""" - Recall stability: \ - s=\(stability.value) -> \(newStability.value), \ - d=\(difficulty.value), \ - r=\(retrievability.value), \ - rating=\(rating) - """) - - return newStability - } - - // MARK: - Forget Stability (Failure) - - /// Calculate next stability after forgetting (Again rating in review state) - /// S'_f(D,S,R) = weights[11] × D^(-weights[12]) × ((S+1)^weights[13]-1) × e^(weights[14]×(1-R)) - /// - /// - Parameters: - /// - difficulty: Current difficulty - /// - stability: Current stability - /// - retrievability: Retrievability at time of review - /// - Returns: New stability after forgetting - public func nextForgetStability( - difficulty: Difficulty, - stability: Stability, - retrievability: Retrievability - ) throws -> Stability { - let result = parameters.weights[11] * - pow(difficulty.value, -parameters.weights[12]) * - (pow(stability.value + 1, parameters.weights[13]) - 1) * - exp((1 - retrievability.value) * parameters.weights[14]) - - let clampedResult = clamp(result, min: S_MIN, max: S_MAX) - let newStability = try Stability(clampedResult) - - logger?.debug(""" - Forget stability: \ - s=\(stability.value) -> \(newStability.value), \ - d=\(difficulty.value), \ - r=\(retrievability.value) - """) - - return newStability - } - - // MARK: - Short-term Stability - - /// Calculate next short-term stability (for learning/relearning steps) - /// S'_s(S,G) = S × (S^(-weights[19]) × e^(weights[17] × (G-3+weights[18]))) - /// - /// - Parameters: - /// - stability: Current stability - /// - rating: Grade rating - /// - Returns: New short-term stability - public func nextShortTermStability( - stability: Stability, - rating: Rating - ) throws -> Stability { - let gradeValue = Double(rating.rawValue) - let sinc = pow(stability.value, -parameters.weights[19]) * - exp(parameters.weights[17] * (gradeValue - GRADE_NEUTRAL_VALUE + parameters.weights[18])) - - // Apply mask: if rating >= Good (3), sinc should be at least 1.0 - let maskedSinc = gradeValue >= GRADE_NEUTRAL_VALUE ? max(sinc, 1.0) : sinc - let result = stability.value * maskedSinc - - let clampedResult = clamp(result, min: S_MIN, max: S_MAX) - let newStability = try Stability(clampedResult) - - logger?.debug(""" - Short-term stability: \ - s=\(stability.value) -> \(newStability.value), \ - rating=\(rating) - """) - - return newStability - } -} diff --git a/Sources/FSRS/Core/FSRSAlgorithm.swift b/Sources/FSRS/Core/FSRSAlgorithm.swift index 8bfdb53..c72ff64 100644 --- a/Sources/FSRS/Core/FSRSAlgorithm.swift +++ b/Sources/FSRS/Core/FSRSAlgorithm.swift @@ -46,7 +46,7 @@ open class FSRSAlgorithm: FSRSAlgorithmProtocol { /// - stability: Current stability /// - Returns: Retrievability (probability of recall) public func forgettingCurve(_ elapsedDays: Double, _ stability: Double) -> Double { - let calculator = StabilityCalculator(parameters: parameters, logger: logger) + let calculator = MemoryStateCalculator(parameters: parameters, logger: logger) let result = try? calculator.forgettingCurve( elapsedDays: ElapsedDays(unchecked: elapsedDays), stability: Stability(unchecked: stability) @@ -69,20 +69,14 @@ open class FSRSAlgorithm: FSRSAlgorithmProtocol { guard requestRetention > 0 && requestRetention <= 1 else { throw FSRSError.invalidRequestRetention(requestRetention) } - let (decay, factor) = StabilityCalculator.computeDecayFactor(weights) + let (decay, factor) = MemoryStateCalculator.computeDecayFactor(weights) let result = (pow(requestRetention, 1 / decay) - 1) / factor return roundToFixed(result) } - // Note: All calculation methods have been extracted to dedicated calculator classes: - // - StabilityCalculator: stability-related formulas - // - DifficultyCalculator: difficulty-related formulas + // Note: All calculation methods live in Algorithm/ directory: + // - MemoryStateCalculator: stability + difficulty formulas (unified) // - IntervalCalculator: interval calculation and fuzzing - // - // The old nextState(), initStability(), nextDifficulty(), nextRecallStability(), - // nextForgetStability(), nextShortTermStability(), nextInterval(), and applyFuzz() - // methods have been removed as they are now implemented in the calculator classes. - // - // Schedulers now use these calculators directly via BaseScheduler. + // - IntervalConstraintApplier: interval ordering constraints } diff --git a/Sources/FSRS/Schedulers/BaseScheduler.swift b/Sources/FSRS/Schedulers/BaseScheduler.swift index 877a516..7f562cf 100644 --- a/Sources/FSRS/Schedulers/BaseScheduler.swift +++ b/Sources/FSRS/Schedulers/BaseScheduler.swift @@ -23,11 +23,8 @@ open class BaseScheduler: SchedulerProtocol { /// Logger for debugging internal let logger: (any FSRSLogger)? - /// Calculator for stability operations - internal let stabilityCalculator: StabilityCalculator - - /// Calculator for difficulty operations - internal let difficultyCalculator: DifficultyCalculator + /// Unified memory state calculator (stability + difficulty) + internal let memoryStateCalculator: MemoryStateCalculator /// Calculator for interval operations internal let intervalCalculator: IntervalCalculator @@ -55,11 +52,7 @@ open class BaseScheduler: SchedulerProtocol { self.elapsedDaysValue = (try? ElapsedDays(intervalDays)) ?? .zero // Initialize calculators - self.stabilityCalculator = StabilityCalculator( - parameters: algorithm.parameters, - logger: logger - ) - self.difficultyCalculator = DifficultyCalculator( + self.memoryStateCalculator = MemoryStateCalculator( parameters: algorithm.parameters, logger: logger ) @@ -99,83 +92,13 @@ open class BaseScheduler: SchedulerProtocol { rating: Rating, retrievability: Retrievability? = nil ) throws -> MemoryState { - // Check if card is new BEFORE trying to create value objects - // New cards have stability=0.0 and difficulty=0.0, which fail validation - let isNewCard = currentCard.stability == 0.0 && currentCard.difficulty == 0.0 - - if isNewCard { - let initialStability = try stabilityCalculator.initStability(for: rating) - let initialDifficulty = try difficultyCalculator.initDifficulty(for: rating) - - logger?.debug( - """ - New card state: \ - stability=\(initialStability.value), \ - difficulty=\(initialDifficulty.value), \ - rating=\(rating) - """) - - return MemoryState( - stability: initialStability, - difficulty: initialDifficulty - ) - } - - // Card is not new, so we can safely create value objects - let currentStability = try Stability(currentCard.stability) - let currentDifficulty = try Difficulty(currentCard.difficulty) - - // Calculate or use provided retrievability - let actualRetrievability: Retrievability - if let provided = retrievability { - actualRetrievability = provided - } else { - actualRetrievability = try stabilityCalculator.forgettingCurve( - elapsedDays: elapsedDaysParam, - stability: currentStability - ) - } - - // Calculate next difficulty - let nextDifficulty = try difficultyCalculator.nextDifficulty( - current: currentDifficulty, - rating: rating + try memoryStateCalculator.nextMemoryState( + currentCard: currentCard, + elapsedDays: elapsedDaysParam, + rating: rating, + retrievability: retrievability, + enableShortTerm: algorithm.parameters.enableShortTerm ) - - // Calculate next stability based on rating and elapsed time - let nextStability: Stability - if elapsedDaysParam.value == 0 && algorithm.parameters.enableShortTerm { - // For same-day reviews with short-term enabled, use short-term stability - nextStability = try stabilityCalculator.nextShortTermStability( - stability: currentStability, - rating: rating - ) - } else if rating == .again { - // For "Again", use forget stability - nextStability = try stabilityCalculator.nextForgetStability( - difficulty: currentDifficulty, - stability: currentStability, - retrievability: actualRetrievability - ) - } else { - // For other grades, use recall stability - nextStability = try stabilityCalculator.nextRecallStability( - difficulty: currentDifficulty, - stability: currentStability, - retrievability: actualRetrievability, - rating: rating - ) - } - - logger?.debug( - """ - State transition: \ - s=\(currentStability.value) -> \(nextStability.value), \ - d=\(currentDifficulty.value) -> \(nextDifficulty.value), \ - rating=\(rating) - """) - - return MemoryState(stability: nextStability, difficulty: nextDifficulty) } /// Schedule a card with direct interval (no learning steps) diff --git a/Sources/FSRS/Schedulers/BasicScheduler.swift b/Sources/FSRS/Schedulers/BasicScheduler.swift index a680c90..754d5a5 100644 --- a/Sources/FSRS/Schedulers/BasicScheduler.swift +++ b/Sources/FSRS/Schedulers/BasicScheduler.swift @@ -73,7 +73,7 @@ public final class BasicScheduler: BaseScheduler { // Calculate retrievability let currentStability = try Stability(currentCard.stability) - let retrievability = try stabilityCalculator.forgettingCurve( + let retrievability = try memoryStateCalculator.forgettingCurve( elapsedDays: elapsedDaysValue, stability: currentStability ) diff --git a/Sources/FSRS/Schedulers/LongTermScheduler.swift b/Sources/FSRS/Schedulers/LongTermScheduler.swift index 1f1f8ee..ea6ed73 100644 --- a/Sources/FSRS/Schedulers/LongTermScheduler.swift +++ b/Sources/FSRS/Schedulers/LongTermScheduler.swift @@ -55,7 +55,7 @@ public final class LongTermScheduler: BaseScheduler { // Calculate retrievability let currentStability = try Stability(currentCard.stability) - let retrievability = try stabilityCalculator.forgettingCurve( + let retrievability = try memoryStateCalculator.forgettingCurve( elapsedDays: elapsedDaysValue, stability: currentStability ) From 05db02da4ecc4e6a9bd7819c41a375e677544de2 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Sun, 22 Feb 2026 21:04:21 +0400 Subject: [PATCH 3/9] Replace scheduler inheritance with composition - Created SchedulingPipeline struct with shared state and calculators - Created LearningStepsHandler for step progression logic - Created ShortTermScheduler (replaces BasicScheduler) composing Pipeline + LearningStepsHandler - Created LongTermScheduler (struct) composing Pipeline only - Updated SchedulerFactory to use new scheduler types - Deleted BaseScheduler class and old scheduler files Classes replaced with value-type structs. All 125 tests pass. Co-Authored-By: Claude Opus 4.6 --- Sources/FSRS/Schedulers/BaseScheduler.swift | 193 ------------- Sources/FSRS/Schedulers/BasicScheduler.swift | 254 ------------------ .../Scheduling/LearningStepsHandler.swift | 103 +++++++ .../LongTermScheduler.swift | 148 +++++----- .../SchedulerFactory.swift | 2 +- .../FSRS/Scheduling/SchedulingPipeline.swift | 200 ++++++++++++++ .../FSRS/Scheduling/ShortTermScheduler.swift | 157 +++++++++++ 7 files changed, 536 insertions(+), 521 deletions(-) delete mode 100644 Sources/FSRS/Schedulers/BaseScheduler.swift delete mode 100644 Sources/FSRS/Schedulers/BasicScheduler.swift create mode 100644 Sources/FSRS/Scheduling/LearningStepsHandler.swift rename Sources/FSRS/{Schedulers => Scheduling}/LongTermScheduler.swift (59%) rename Sources/FSRS/{Schedulers => Scheduling}/SchedulerFactory.swift (97%) create mode 100644 Sources/FSRS/Scheduling/SchedulingPipeline.swift create mode 100644 Sources/FSRS/Scheduling/ShortTermScheduler.swift diff --git a/Sources/FSRS/Schedulers/BaseScheduler.swift b/Sources/FSRS/Schedulers/BaseScheduler.swift deleted file mode 100644 index 7f562cf..0000000 --- a/Sources/FSRS/Schedulers/BaseScheduler.swift +++ /dev/null @@ -1,193 +0,0 @@ -import Foundation - -/// Base scheduler containing common logic for all scheduler types -/// Subclasses implement mode-specific behavior (e.g., learning steps vs direct review) -open class BaseScheduler: SchedulerProtocol { - // MARK: - Properties - - /// Last card state (before review) - public let lastCard: Card - - /// Current card state (after initialization, with updated reps) - public var currentCard: Card - - /// Time of review - public let reviewTime: Date - - /// Days elapsed since last review (value object) - internal let elapsedDaysValue: ElapsedDays - - /// FSRS algorithm instance - public let algorithm: any FSRSAlgorithmProtocol - - /// Logger for debugging - internal let logger: (any FSRSLogger)? - - /// Unified memory state calculator (stability + difficulty) - internal let memoryStateCalculator: MemoryStateCalculator - - /// Calculator for interval operations - internal let intervalCalculator: IntervalCalculator - - // MARK: - Initialization - - public init( - card: Card, - now: Date, - algorithm: any FSRSAlgorithmProtocol, - logger: (any FSRSLogger)? = nil - ) { - self.lastCard = card - self.reviewTime = now - self.algorithm = algorithm - self.logger = logger - - // Calculate elapsed days - let intervalDays: Double - if card.state != .new, let lastReview = card.lastReview { - intervalDays = Double(dateDiffInDays(last: lastReview, current: now)) - } else { - intervalDays = 0 - } - self.elapsedDaysValue = (try? ElapsedDays(intervalDays)) ?? .zero - - // Initialize calculators - self.memoryStateCalculator = MemoryStateCalculator( - parameters: algorithm.parameters, - logger: logger - ) - self.intervalCalculator = IntervalCalculator( - parameters: algorithm.parameters, - randomProvider: algorithm.randomProvider, - logger: logger - ) - - // Update current card with review info - var updatedCurrent = card - updatedCurrent.lastReview = now - updatedCurrent.reps += 1 - self.currentCard = updatedCurrent - - logger?.debug( - """ - Scheduler initialized: \ - type=\(type(of: self)), \ - state=\(card.state), \ - elapsed=\(intervalDays)d - """) - } - - // MARK: - Common Methods - - /// Calculate next memory state (difficulty and stability) for a rating - /// - /// - Parameters: - /// - elapsedDays: Days elapsed since last review - /// - rating: Rating given - /// - retrievability: Optional retrievability (calculated if nil) - /// - Returns: Next memory state - /// - Throws: FSRSError if calculation fails - internal func calculateNextMemoryState( - elapsedDays elapsedDaysParam: ElapsedDays, - rating: Rating, - retrievability: Retrievability? = nil - ) throws -> MemoryState { - try memoryStateCalculator.nextMemoryState( - currentCard: currentCard, - elapsedDays: elapsedDaysParam, - rating: rating, - retrievability: retrievability, - enableShortTerm: algorithm.parameters.enableShortTerm - ) - } - - /// Schedule a card with direct interval (no learning steps) - /// - /// - Parameters: - /// - card: Card to schedule - /// - stability: Stability to use for interval calculation - /// - intervalModifier: Interval modifier from algorithm - internal func scheduleWithInterval( - card: inout Card, - stability: Stability, - intervalModifier: Double - ) { - let interval = intervalCalculator.calculateScheduledInterval( - stability: stability, - elapsedDays: elapsedDaysValue, - intervalModifier: intervalModifier - ) - - card.scheduledDays = interval - card.due = dateScheduler( - now: reviewTime, - offset: Double(interval), - isDay: true - ) - card.state = .review - card.learningSteps = 0 - - logger?.debug("Scheduled with interval: \(interval) days") - } - - // MARK: - Template Methods (Override in Subclasses) - - /// Schedule a new card - /// Must be overridden by subclasses - open func scheduleNewCard(rating: Rating) throws -> RecordLogItem { // swiftlint:disable:this unavailable_function - fatalError("Must override scheduleNewCard in subclass") - } - - /// Schedule a learning/relearning card - /// Must be overridden by subclasses - open func scheduleLearningCard(rating: Rating) throws -> RecordLogItem { // swiftlint:disable:this unavailable_function - fatalError("Must override scheduleLearningCard in subclass") - } - - /// Schedule a review card - /// Must be overridden by subclasses - open func scheduleReviewCard(rating: Rating) throws -> RecordLogItem { // swiftlint:disable:this unavailable_function - fatalError("Must override scheduleReviewCard in subclass") - } -} -// MARK: - SchedulerProtocol Implementation - -/// Expose properties and methods required by SchedulerProtocol -public extension BaseScheduler { - /// Last card state (before review) - var last: Card { lastCard } - /// Current card state (after review) - var current: Card { - get { currentCard } - set { currentCard = newValue } - } - - /// Days elapsed since last review - var elapsedDays: Double { - elapsedDaysValue.value - } - - /// Schedule new state based on rating - /// - Parameter rating: Rating given - /// - Throws: Error if scheduling fails - /// - Returns: Record log item with updated card state - func newState(rating: Rating) throws -> RecordLogItem { - try scheduleNewCard(rating: rating) - } - - /// Schedule learning/relearning state based on rating - /// - Parameter rating: Rating given - /// - Throws: Error if scheduling fails - /// - Returns: Record log item with updated card state - func learningState(rating: Rating) throws -> RecordLogItem { - try scheduleLearningCard(rating: rating) - } - - /// Schedule review state based on rating - /// - Parameter rating: Rating given - /// - Throws: Error if scheduling fails - /// - Returns: Record log item with updated card state - func reviewState(rating: Rating) throws -> RecordLogItem { - try scheduleReviewCard(rating: rating) - } -} diff --git a/Sources/FSRS/Schedulers/BasicScheduler.swift b/Sources/FSRS/Schedulers/BasicScheduler.swift deleted file mode 100644 index 754d5a5..0000000 --- a/Sources/FSRS/Schedulers/BasicScheduler.swift +++ /dev/null @@ -1,254 +0,0 @@ -import Foundation - -/// Basic scheduler with learning steps support -/// Cards progress through learning steps before advancing to review state -public final class BasicScheduler: BaseScheduler { - // Learning steps strategy - private let learningStepsStrategy: LearningStepsStrategy - - public init( - card: Card, - now: Date, - algorithm: any FSRSAlgorithmProtocol, - learningStepsStrategy: LearningStepsStrategy? = nil, - logger: (any FSRSLogger)? = nil - ) { - // Use provided or default learning steps strategy - self.learningStepsStrategy = learningStepsStrategy ?? basicLearningStepsStrategy - super.init(card: card, now: now, algorithm: algorithm, logger: logger) - } - - // MARK: - New Card Scheduling - - override public func scheduleNewCard(rating: Rating) throws -> RecordLogItem { - logger?.debug("Basic new card: rating=\(rating)") - - // Calculate next memory state - let nextState = try calculateNextMemoryState( - elapsedDays: elapsedDaysValue, - rating: rating, - retrievability: nil - ) - - // Apply state to card - var nextCard = currentCard - nextCard.stability = nextState.stability.value - nextCard.difficulty = nextState.difficulty.value - - // Apply learning steps - try applyLearningSteps(to: &nextCard, rating: rating, targetState: .learning) - - logger?.debug("New card result: state=\(nextCard.state), learningSteps=\(nextCard.learningSteps)") - return RecordLogItem(card: nextCard, log: buildLog(rating: rating)) - } - - // MARK: - Learning/Relearning Scheduling - - override public func scheduleLearningCard(rating: Rating) throws -> RecordLogItem { - logger?.debug("Basic learning: rating=\(rating), currentState=\(lastCard.state)") - - // Calculate next memory state - let nextState = try calculateNextMemoryState( - elapsedDays: elapsedDaysValue, - rating: rating, - retrievability: nil - ) - - // Apply state to card - var nextCard = currentCard - nextCard.stability = nextState.stability.value - nextCard.difficulty = nextState.difficulty.value - - // Apply learning steps (preserving Learning or Relearning state) - try applyLearningSteps(to: &nextCard, rating: rating, targetState: lastCard.state) - - logger?.debug("Learning result: state=\(nextCard.state), learningSteps=\(nextCard.learningSteps)") - return RecordLogItem(card: nextCard, log: buildLog(rating: rating)) - } - - // MARK: - Review Card Scheduling - // swiftlint:disable:next function_body_length - override public func scheduleReviewCard(rating: Rating) throws -> RecordLogItem { - logger?.debug("Basic review: rating=\(rating)") - - // Calculate retrievability - let currentStability = try Stability(currentCard.stability) - let retrievability = try memoryStateCalculator.forgettingCurve( - elapsedDays: elapsedDaysValue, - stability: currentStability - ) - - logger?.debug("Review retrievability: \(retrievability.value)") - - // For "Again", enter relearning with learning steps - if rating == .again { - let nextState = try calculateNextMemoryState( - elapsedDays: elapsedDaysValue, - rating: .again, - retrievability: retrievability - ) - - var nextCard = currentCard - nextCard.stability = nextState.stability.value - nextCard.difficulty = nextState.difficulty.value - nextCard.lapses += 1 - - try applyLearningSteps(to: &nextCard, rating: .again, targetState: .relearning) - - return RecordLogItem(card: nextCard, log: buildLog(rating: .again)) - } - - // For Hard, Good, Easy - calculate intervals and apply constraints - let hardState = try calculateNextMemoryState( - elapsedDays: elapsedDaysValue, - rating: .hard, - retrievability: retrievability - ) - let goodState = try calculateNextMemoryState( - elapsedDays: elapsedDaysValue, - rating: .good, - retrievability: retrievability - ) - let easyState = try calculateNextMemoryState( - elapsedDays: elapsedDaysValue, - rating: .easy, - retrievability: retrievability - ) - - let intervalModifier = algorithm.intervalModifier - - let hardInterval = intervalCalculator.calculateScheduledInterval( - stability: hardState.stability, - elapsedDays: elapsedDaysValue, - intervalModifier: intervalModifier - ) - let goodInterval = intervalCalculator.calculateScheduledInterval( - stability: goodState.stability, - elapsedDays: elapsedDaysValue, - intervalModifier: intervalModifier - ) - let easyInterval = intervalCalculator.calculateScheduledInterval( - stability: easyState.stability, - elapsedDays: elapsedDaysValue, - intervalModifier: intervalModifier - ) - - // Apply interval constraints - let constrained = IntervalConstraintApplier.applyReviewCardConstraints( - hard: hardInterval, - good: goodInterval, - easy: easyInterval - ) - - // Select and prepare the card based on rating - var nextCard = currentCard - switch rating { - case .hard: - nextCard.stability = hardState.stability.value - nextCard.difficulty = hardState.difficulty.value - nextCard.scheduledDays = constrained.hard - case .good: - nextCard.stability = goodState.stability.value - nextCard.difficulty = goodState.difficulty.value - nextCard.scheduledDays = constrained.good - case .easy: - nextCard.stability = easyState.stability.value - nextCard.difficulty = easyState.difficulty.value - nextCard.scheduledDays = constrained.easy - default: - throw FSRSError.invalidGrade("Unexpected rating in review: \(rating)") - } - - nextCard.due = dateScheduler( - now: reviewTime, - offset: Double(nextCard.scheduledDays), - isDay: true - ) - nextCard.state = .review - nextCard.learningSteps = 0 - - logger?.debug("Review result: scheduledDays=\(nextCard.scheduledDays)") - return RecordLogItem(card: nextCard, log: buildLog(rating: rating)) - } - - // MARK: - Learning Steps Logic - - /// Get learning info for a rating - private func getLearningInfo(card: Card, rating: Rating) -> (scheduledMinutes: Int, nextSteps: Int) { - let parameters = algorithm.parameters - let cardLearningSteps = card.learningSteps - - // Determine which step to use based on state and rating - let effectiveStep = (currentCard.state == .learning && rating != .again && rating != .hard) - ? cardLearningSteps + 1 - : cardLearningSteps - - let stepsStrategy = learningStepsStrategy( - parameters, - card.state, - effectiveStep - ) - - let scheduledMinutes = max(0, stepsStrategy[rating]?.scheduledMinutes ?? 0) - let nextSteps = max(0, stepsStrategy[rating]?.nextStep ?? 0) - - return (scheduledMinutes: scheduledMinutes, nextSteps: nextSteps) - } - - /// Apply learning steps to card - private func applyLearningSteps( - to card: inout Card, - rating: Rating, - targetState: State - ) throws { - let (scheduledMinutes, nextSteps) = getLearningInfo(card: currentCard, rating: rating) - - // Short-term interval (less than 1 day) - if scheduledMinutes > 0 && scheduledMinutes < MINUTES_PER_DAY { - card.learningSteps = nextSteps - card.scheduledDays = 0 - card.state = targetState - card.due = dateScheduler( - now: reviewTime, - offset: Double(scheduledMinutes), - isDay: false - ) - logger?.debug("Applied learning step: \(scheduledMinutes) minutes") - } - // Long interval (>= 1 day) but still counted as a step - else if scheduledMinutes >= MINUTES_PER_DAY { - card.learningSteps = nextSteps - card.state = .review - card.due = dateScheduler( - now: reviewTime, - offset: Double(scheduledMinutes), - isDay: false - ) - card.scheduledDays = scheduledMinutes / MINUTES_PER_DAY - logger?.debug("Applied long learning step: \(scheduledMinutes) minutes = \(card.scheduledDays) days") - } - // No more learning steps - graduate to review - else { - card.learningSteps = 0 - card.state = .review - - let intervalModifier = algorithm.intervalModifier - // At this point, card.stability should be valid (set by calculateNextMemoryState) - // But we need to handle the case where it might still be 0.0 - let stability = try Stability(card.stability) - let interval = intervalCalculator.calculateScheduledInterval( - stability: stability, - elapsedDays: elapsedDaysValue, - intervalModifier: intervalModifier - ) - - card.scheduledDays = interval - card.due = dateScheduler( - now: reviewTime, - offset: Double(interval), - isDay: true - ) - logger?.debug("Graduated to review: \(interval) days") - } - } -} diff --git a/Sources/FSRS/Scheduling/LearningStepsHandler.swift b/Sources/FSRS/Scheduling/LearningStepsHandler.swift new file mode 100644 index 0000000..11ff20f --- /dev/null +++ b/Sources/FSRS/Scheduling/LearningStepsHandler.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Handles learning step progression for cards in learning/relearning states. +/// Extracted from BasicScheduler.applyLearningSteps() for composition. +public struct LearningStepsHandler { + private let learningStepsStrategy: LearningStepsStrategy + private let logger: (any FSRSLogger)? + + public init( + learningStepsStrategy: LearningStepsStrategy? = nil, + logger: (any FSRSLogger)? = nil + ) { + self.learningStepsStrategy = learningStepsStrategy ?? basicLearningStepsStrategy + self.logger = logger + } + + /// Get learning info for a rating + func getLearningInfo( + card: Card, + currentCard: Card, + rating: Rating, + parameters: FSRSParameters + ) -> (scheduledMinutes: Int, nextSteps: Int) { + let cardLearningSteps = card.learningSteps + + // Determine which step to use based on state and rating + let effectiveStep = (currentCard.state == .learning && rating != .again && rating != .hard) + ? cardLearningSteps + 1 + : cardLearningSteps + + let stepsStrategy = learningStepsStrategy( + parameters, + card.state, + effectiveStep + ) + + let scheduledMinutes = max(0, stepsStrategy[rating]?.scheduledMinutes ?? 0) + let nextSteps = max(0, stepsStrategy[rating]?.nextStep ?? 0) + + return (scheduledMinutes: scheduledMinutes, nextSteps: nextSteps) + } + + /// Apply learning steps to card + func applySteps( + to card: inout Card, + rating: Rating, + targetState: State, + pipeline: SchedulingPipeline + ) throws { + let (scheduledMinutes, nextSteps) = getLearningInfo( + card: pipeline.currentCard, + currentCard: pipeline.currentCard, + rating: rating, + parameters: pipeline.algorithm.parameters + ) + + // Short-term interval (less than 1 day) + if scheduledMinutes > 0 && scheduledMinutes < MINUTES_PER_DAY { + card.learningSteps = nextSteps + card.scheduledDays = 0 + card.state = targetState + card.due = dateScheduler( + now: pipeline.reviewTime, + offset: Double(scheduledMinutes), + isDay: false + ) + logger?.debug("Applied learning step: \(scheduledMinutes) minutes") + } + // Long interval (>= 1 day) but still counted as a step + else if scheduledMinutes >= MINUTES_PER_DAY { + card.learningSteps = nextSteps + card.state = .review + card.due = dateScheduler( + now: pipeline.reviewTime, + offset: Double(scheduledMinutes), + isDay: false + ) + card.scheduledDays = scheduledMinutes / MINUTES_PER_DAY + logger?.debug("Applied long learning step: \(scheduledMinutes) minutes = \(card.scheduledDays) days") + } + // No more learning steps - graduate to review + else { + card.learningSteps = 0 + card.state = .review + + let stability = try Stability(card.stability) + let intervalModifier = pipeline.algorithm.intervalModifier + let interval = pipeline.intervalCalculator.calculateScheduledInterval( + stability: stability, + elapsedDays: pipeline.elapsedDaysValue, + intervalModifier: intervalModifier + ) + + card.scheduledDays = interval + card.due = dateScheduler( + now: pipeline.reviewTime, + offset: Double(interval), + isDay: true + ) + logger?.debug("Graduated to review: \(interval) days") + } + } +} diff --git a/Sources/FSRS/Schedulers/LongTermScheduler.swift b/Sources/FSRS/Scheduling/LongTermScheduler.swift similarity index 59% rename from Sources/FSRS/Schedulers/LongTermScheduler.swift rename to Sources/FSRS/Scheduling/LongTermScheduler.swift index ea6ed73..46be0d4 100644 --- a/Sources/FSRS/Schedulers/LongTermScheduler.swift +++ b/Sources/FSRS/Scheduling/LongTermScheduler.swift @@ -1,20 +1,44 @@ import Foundation -/// Long-term scheduler without learning steps -/// Cards go directly to review state with calculated intervals -public final class LongTermScheduler: BaseScheduler { - // MARK: - New Card Scheduling +/// Long-term scheduler without learning steps. +/// Cards go directly to review state with calculated intervals. +/// Composes only SchedulingPipeline. +public struct LongTermScheduler: SchedulerProtocol { + private var pipeline: SchedulingPipeline + + public var last: Card { pipeline.lastCard } + public var current: Card { + get { pipeline.currentCard } + set { pipeline.currentCard = newValue } + } + public var reviewTime: Date { pipeline.reviewTime } + public var elapsedDays: Double { pipeline.elapsedDaysValue.value } + public var algorithm: any FSRSAlgorithmProtocol { pipeline.algorithm } + + public init( + card: Card, + now: Date, + algorithm: any FSRSAlgorithmProtocol, + logger: (any FSRSLogger)? = nil + ) { + self.pipeline = SchedulingPipeline( + card: card, + now: now, + algorithm: algorithm, + logger: logger + ) + } - override public func scheduleNewCard(rating: Rating) throws -> RecordLogItem { - logger?.debug("Long-term new card: rating=\(rating)") + // MARK: - SchedulerProtocol + + public func newState(rating: Rating) throws -> RecordLogItem { + pipeline.logger?.debug("Long-term new card: rating=\(rating)") - // Calculate next states for all grades let nextStates = try calculateAllGradeStates( elapsedDays: .zero, retrievability: nil ) - // Apply intervals and constraints var cardAgain = try createCardWithState(nextStates.again) var cardHard = try createCardWithState(nextStates.hard) var cardGood = try createCardWithState(nextStates.good) @@ -27,7 +51,6 @@ public final class LongTermScheduler: BaseScheduler { cardEasy: &cardEasy ) - // Select card based on actual rating let selectedCard = try selectCard( rating: rating, again: cardAgain, @@ -36,39 +59,27 @@ public final class LongTermScheduler: BaseScheduler { easy: cardEasy ) - logger?.debug("New card result: scheduledDays=\(selectedCard.scheduledDays)") - return RecordLogItem(card: selectedCard, log: buildLog(rating: rating)) + pipeline.logger?.debug("New card result: scheduledDays=\(selectedCard.scheduledDays)") + return RecordLogItem(card: selectedCard, log: pipeline.buildLog(rating: rating)) } - // MARK: - Learning/Relearning Scheduling - - override public func scheduleLearningCard(rating: Rating) throws -> RecordLogItem { + public func learningState(rating: Rating) throws -> RecordLogItem { // In long-term mode, learning state is treated same as review - logger?.debug("Long-term learning: treating as review, rating=\(rating)") - return try scheduleReviewCard(rating: rating) + pipeline.logger?.debug("Long-term learning: treating as review, rating=\(rating)") + return try reviewState(rating: rating) } - // MARK: - Review Card Scheduling + public func reviewState(rating: Rating) throws -> RecordLogItem { + pipeline.logger?.debug("Long-term review: rating=\(rating)") - override public func scheduleReviewCard(rating: Rating) throws -> RecordLogItem { - logger?.debug("Long-term review: rating=\(rating)") + let retrievability = try pipeline.computeRetrievability() + pipeline.logger?.debug("Review retrievability: \(retrievability.value)") - // Calculate retrievability - let currentStability = try Stability(currentCard.stability) - let retrievability = try memoryStateCalculator.forgettingCurve( - elapsedDays: elapsedDaysValue, - stability: currentStability - ) - - logger?.debug("Review retrievability: \(retrievability.value)") - - // Calculate next states for all grades let nextStates = try calculateAllGradeStates( - elapsedDays: elapsedDaysValue, + elapsedDays: pipeline.elapsedDaysValue, retrievability: retrievability ) - // Apply intervals and constraints var cardAgain = try createCardWithState(nextStates.again) var cardHard = try createCardWithState(nextStates.hard) var cardGood = try createCardWithState(nextStates.good) @@ -81,10 +92,8 @@ public final class LongTermScheduler: BaseScheduler { cardEasy: &cardEasy ) - // Increment lapses for Again cardAgain.lapses += 1 - // Select card based on actual rating let selectedCard = try selectCard( rating: rating, again: cardAgain, @@ -93,8 +102,12 @@ public final class LongTermScheduler: BaseScheduler { easy: cardEasy ) - logger?.debug("Review result: scheduledDays=\(selectedCard.scheduledDays)") - return RecordLogItem(card: selectedCard, log: buildLog(rating: rating)) + pipeline.logger?.debug("Review result: scheduledDays=\(selectedCard.scheduledDays)") + return RecordLogItem(card: selectedCard, log: pipeline.buildLog(rating: rating)) + } + + public func buildLog(rating: Rating) -> ReviewLog { + pipeline.buildLog(rating: rating) } // MARK: - Helper Methods @@ -111,22 +124,22 @@ public final class LongTermScheduler: BaseScheduler { retrievability: Retrievability? ) throws -> AllGradeStates { AllGradeStates( - again: try calculateNextMemoryState( + again: try pipeline.computeMemoryState( elapsedDays: elapsedDays, rating: .again, retrievability: retrievability ), - hard: try calculateNextMemoryState( + hard: try pipeline.computeMemoryState( elapsedDays: elapsedDays, rating: .hard, retrievability: retrievability ), - good: try calculateNextMemoryState( + good: try pipeline.computeMemoryState( elapsedDays: elapsedDays, rating: .good, retrievability: retrievability ), - easy: try calculateNextMemoryState( + easy: try pipeline.computeMemoryState( elapsedDays: elapsedDays, rating: .easy, retrievability: retrievability @@ -135,7 +148,7 @@ public final class LongTermScheduler: BaseScheduler { } private func createCardWithState(_ state: MemoryState) throws -> Card { - var card = currentCard + var card = pipeline.currentCard card.stability = state.stability.value card.difficulty = state.difficulty.value return card @@ -147,32 +160,30 @@ public final class LongTermScheduler: BaseScheduler { cardGood: inout Card, cardEasy: inout Card ) throws { - let intervalModifier = algorithm.intervalModifier + let intervalModifier = pipeline.algorithm.intervalModifier + let calc = pipeline.intervalCalculator - // Calculate base intervals - // At this point, stability should be valid (set by calculateNextMemoryState) - let againInterval = intervalCalculator.calculateScheduledInterval( + let againInterval = calc.calculateScheduledInterval( stability: try Stability(cardAgain.stability), elapsedDays: .zero, intervalModifier: intervalModifier ) - let hardInterval = intervalCalculator.calculateScheduledInterval( + let hardInterval = calc.calculateScheduledInterval( stability: try Stability(cardHard.stability), elapsedDays: .zero, intervalModifier: intervalModifier ) - let goodInterval = intervalCalculator.calculateScheduledInterval( + let goodInterval = calc.calculateScheduledInterval( stability: try Stability(cardGood.stability), elapsedDays: .zero, intervalModifier: intervalModifier ) - let easyInterval = intervalCalculator.calculateScheduledInterval( + let easyInterval = calc.calculateScheduledInterval( stability: try Stability(cardEasy.stability), elapsedDays: .zero, intervalModifier: intervalModifier ) - // Apply constraints let constrained = IntervalConstraintApplier.applyNewCardConstraints( again: againInterval, hard: hardInterval, @@ -180,7 +191,6 @@ public final class LongTermScheduler: BaseScheduler { easy: easyInterval ) - // Set intervals and dates setIntervalAndDue(card: &cardAgain, interval: constrained.again) setIntervalAndDue(card: &cardHard, interval: constrained.hard) setIntervalAndDue(card: &cardGood, interval: constrained.good) @@ -193,39 +203,36 @@ public final class LongTermScheduler: BaseScheduler { cardGood: inout Card, cardEasy: inout Card ) throws { - let intervalModifier = algorithm.intervalModifier + let intervalModifier = pipeline.algorithm.intervalModifier + let calc = pipeline.intervalCalculator - // Calculate base intervals - // At this point, stability should be valid (set by calculateNextMemoryState) - let againInterval = intervalCalculator.calculateScheduledInterval( + let againInterval = calc.calculateScheduledInterval( stability: try Stability(cardAgain.stability), - elapsedDays: elapsedDaysValue, + elapsedDays: pipeline.elapsedDaysValue, intervalModifier: intervalModifier ) - let hardInterval = intervalCalculator.calculateScheduledInterval( + let hardInterval = calc.calculateScheduledInterval( stability: try Stability(cardHard.stability), - elapsedDays: elapsedDaysValue, + elapsedDays: pipeline.elapsedDaysValue, intervalModifier: intervalModifier ) - let goodInterval = intervalCalculator.calculateScheduledInterval( + let goodInterval = calc.calculateScheduledInterval( stability: try Stability(cardGood.stability), - elapsedDays: elapsedDaysValue, + elapsedDays: pipeline.elapsedDaysValue, intervalModifier: intervalModifier ) - let easyInterval = intervalCalculator.calculateScheduledInterval( + let easyInterval = calc.calculateScheduledInterval( stability: try Stability(cardEasy.stability), - elapsedDays: elapsedDaysValue, + elapsedDays: pipeline.elapsedDaysValue, intervalModifier: intervalModifier ) - // Apply constraints (no constraint on again for review cards) let constrained = IntervalConstraintApplier.applyReviewCardConstraints( hard: hardInterval, good: goodInterval, easy: easyInterval ) - // Set intervals and dates setIntervalAndDue(card: &cardAgain, interval: againInterval) setIntervalAndDue(card: &cardHard, interval: constrained.hard) setIntervalAndDue(card: &cardGood, interval: constrained.good) @@ -234,7 +241,7 @@ public final class LongTermScheduler: BaseScheduler { private func setIntervalAndDue(card: inout Card, interval: Int) { card.scheduledDays = interval - card.due = dateScheduler(now: reviewTime, offset: Double(interval), isDay: true) + card.due = dateScheduler(now: pipeline.reviewTime, offset: Double(interval), isDay: true) card.state = .review card.learningSteps = 0 } @@ -247,16 +254,11 @@ public final class LongTermScheduler: BaseScheduler { easy: Card ) throws -> Card { switch rating { - case .again: - return again - case .hard: - return hard - case .good: - return good - case .easy: - return easy - case .manual: - throw FSRSError.manualGradeNotAllowed + case .again: return again + case .hard: return hard + case .good: return good + case .easy: return easy + case .manual: throw FSRSError.manualGradeNotAllowed } } } diff --git a/Sources/FSRS/Schedulers/SchedulerFactory.swift b/Sources/FSRS/Scheduling/SchedulerFactory.swift similarity index 97% rename from Sources/FSRS/Schedulers/SchedulerFactory.swift rename to Sources/FSRS/Scheduling/SchedulerFactory.swift index 722d724..e48ee16 100644 --- a/Sources/FSRS/Schedulers/SchedulerFactory.swift +++ b/Sources/FSRS/Scheduling/SchedulerFactory.swift @@ -35,7 +35,7 @@ public struct FSRSSchedulerFactory: SchedulerFactory { logger: (any FSRSLogger)? ) -> any SchedulerProtocol { if useShortTerm { - return BasicScheduler( + return ShortTermScheduler( card: card, now: now, algorithm: algorithm, diff --git a/Sources/FSRS/Scheduling/SchedulingPipeline.swift b/Sources/FSRS/Scheduling/SchedulingPipeline.swift new file mode 100644 index 0000000..e029d27 --- /dev/null +++ b/Sources/FSRS/Scheduling/SchedulingPipeline.swift @@ -0,0 +1,200 @@ +import Foundation + +/// Shared scheduling pipeline that holds common state and calculators. +/// Used by both ShortTermScheduler and LongTermScheduler via composition. +public struct SchedulingPipeline { + // MARK: - Properties + + /// Last card state (before review) + public let lastCard: Card + + /// Current card state (after initialization, with updated reps) + public var currentCard: Card + + /// Time of review + public let reviewTime: Date + + /// Days elapsed since last review + public let elapsedDaysValue: ElapsedDays + + /// FSRS algorithm instance + public let algorithm: any FSRSAlgorithmProtocol + + /// Logger for debugging + let logger: (any FSRSLogger)? + + /// Unified memory state calculator + let memoryStateCalculator: MemoryStateCalculator + + /// Calculator for interval operations + let intervalCalculator: IntervalCalculator + + // MARK: - Initialization + + public init( + card: Card, + now: Date, + algorithm: any FSRSAlgorithmProtocol, + logger: (any FSRSLogger)? = nil + ) { + self.lastCard = card + self.reviewTime = now + self.algorithm = algorithm + self.logger = logger + + // Calculate elapsed days + let intervalDays: Double + if card.state != .new, let lastReview = card.lastReview { + intervalDays = Double(dateDiffInDays(last: lastReview, current: now)) + } else { + intervalDays = 0 + } + self.elapsedDaysValue = (try? ElapsedDays(intervalDays)) ?? .zero + + // Initialize calculators + self.memoryStateCalculator = MemoryStateCalculator( + parameters: algorithm.parameters, + logger: logger + ) + self.intervalCalculator = IntervalCalculator( + parameters: algorithm.parameters, + randomProvider: algorithm.randomProvider, + logger: logger + ) + + // Update current card with review info + var updatedCurrent = card + updatedCurrent.lastReview = now + updatedCurrent.reps += 1 + self.currentCard = updatedCurrent + + logger?.debug( + """ + Pipeline initialized: \ + state=\(card.state), \ + elapsed=\(intervalDays)d + """) + } + + // MARK: - Memory State + + /// Calculate next memory state for a rating + public func computeMemoryState( + elapsedDays: ElapsedDays, + rating: Rating, + retrievability: Retrievability? = nil + ) throws -> MemoryState { + try memoryStateCalculator.nextMemoryState( + currentCard: currentCard, + elapsedDays: elapsedDays, + rating: rating, + retrievability: retrievability, + enableShortTerm: algorithm.parameters.enableShortTerm + ) + } + + /// Calculate retrievability for the current card + public func computeRetrievability() throws -> Retrievability { + let currentStability = try Stability(currentCard.stability) + return try memoryStateCalculator.forgettingCurve( + elapsedDays: elapsedDaysValue, + stability: currentStability + ) + } + + // MARK: - Interval Calculation + + /// Calculate constrained intervals for review cards (Hard, Good, Easy) + public func computeConstrainedReviewIntervals( + retrievability: Retrievability + ) throws -> (hard: Int, good: Int, easy: Int, hardState: MemoryState, goodState: MemoryState, easyState: MemoryState) { // swiftlint:disable:this large_tuple + let hardState = try computeMemoryState( + elapsedDays: elapsedDaysValue, + rating: .hard, + retrievability: retrievability + ) + let goodState = try computeMemoryState( + elapsedDays: elapsedDaysValue, + rating: .good, + retrievability: retrievability + ) + let easyState = try computeMemoryState( + elapsedDays: elapsedDaysValue, + rating: .easy, + retrievability: retrievability + ) + + let intervalModifier = algorithm.intervalModifier + + let hardInterval = intervalCalculator.calculateScheduledInterval( + stability: hardState.stability, + elapsedDays: elapsedDaysValue, + intervalModifier: intervalModifier + ) + let goodInterval = intervalCalculator.calculateScheduledInterval( + stability: goodState.stability, + elapsedDays: elapsedDaysValue, + intervalModifier: intervalModifier + ) + let easyInterval = intervalCalculator.calculateScheduledInterval( + stability: easyState.stability, + elapsedDays: elapsedDaysValue, + intervalModifier: intervalModifier + ) + + let constrained = IntervalConstraintApplier.applyReviewCardConstraints( + hard: hardInterval, + good: goodInterval, + easy: easyInterval + ) + + return ( + hard: constrained.hard, + good: constrained.good, + easy: constrained.easy, + hardState: hardState, + goodState: goodState, + easyState: easyState + ) + } + + // MARK: - Card Scheduling + + /// Schedule a card with direct interval (no learning steps) + public func scheduleWithInterval( + card: inout Card, + stability: Stability + ) { + let intervalModifier = algorithm.intervalModifier + let interval = intervalCalculator.calculateScheduledInterval( + stability: stability, + elapsedDays: elapsedDaysValue, + intervalModifier: intervalModifier + ) + + card.scheduledDays = interval + card.due = dateScheduler( + now: reviewTime, + offset: Double(interval), + isDay: true + ) + card.state = .review + card.learningSteps = 0 + + logger?.debug("Scheduled with interval: \(interval) days") + } + + /// Build review log for the current card state + public func buildLog(rating: Rating) -> ReviewLog { + ReviewLog( + rating: rating, + state: currentCard.state, + due: lastCard.lastReview ?? lastCard.due, + stability: currentCard.stability, + difficulty: currentCard.difficulty, + scheduledDays: currentCard.scheduledDays, + learningSteps: currentCard.learningSteps, + review: reviewTime + ) + } +} diff --git a/Sources/FSRS/Scheduling/ShortTermScheduler.swift b/Sources/FSRS/Scheduling/ShortTermScheduler.swift new file mode 100644 index 0000000..e522623 --- /dev/null +++ b/Sources/FSRS/Scheduling/ShortTermScheduler.swift @@ -0,0 +1,157 @@ +import Foundation + +/// Short-term scheduler with learning steps support. +/// Cards progress through learning steps before advancing to review state. +/// Composes SchedulingPipeline + LearningStepsHandler. +public struct ShortTermScheduler: SchedulerProtocol { + private var pipeline: SchedulingPipeline + private let stepsHandler: LearningStepsHandler + + public var last: Card { pipeline.lastCard } + public var current: Card { + get { pipeline.currentCard } + set { pipeline.currentCard = newValue } + } + public var reviewTime: Date { pipeline.reviewTime } + public var elapsedDays: Double { pipeline.elapsedDaysValue.value } + public var algorithm: any FSRSAlgorithmProtocol { pipeline.algorithm } + + public init( + card: Card, + now: Date, + algorithm: any FSRSAlgorithmProtocol, + learningStepsStrategy: LearningStepsStrategy? = nil, + logger: (any FSRSLogger)? = nil + ) { + self.pipeline = SchedulingPipeline( + card: card, + now: now, + algorithm: algorithm, + logger: logger + ) + self.stepsHandler = LearningStepsHandler( + learningStepsStrategy: learningStepsStrategy, + logger: logger + ) + } + + // MARK: - SchedulerProtocol + + public func newState(rating: Rating) throws -> RecordLogItem { + pipeline.logger?.debug("Short-term new card: rating=\(rating)") + + let nextState = try pipeline.computeMemoryState( + elapsedDays: pipeline.elapsedDaysValue, + rating: rating, + retrievability: nil + ) + + var nextCard = pipeline.currentCard + nextCard.stability = nextState.stability.value + nextCard.difficulty = nextState.difficulty.value + + try stepsHandler.applySteps( + to: &nextCard, + rating: rating, + targetState: .learning, + pipeline: pipeline + ) + + pipeline.logger?.debug("New card result: state=\(nextCard.state), learningSteps=\(nextCard.learningSteps)") + return RecordLogItem(card: nextCard, log: pipeline.buildLog(rating: rating)) + } + + // swiftlint:disable:next function_body_length + public func learningState(rating: Rating) throws -> RecordLogItem { + pipeline.logger?.debug("Short-term learning: rating=\(rating), currentState=\(pipeline.lastCard.state)") + + let nextState = try pipeline.computeMemoryState( + elapsedDays: pipeline.elapsedDaysValue, + rating: rating, + retrievability: nil + ) + + var nextCard = pipeline.currentCard + nextCard.stability = nextState.stability.value + nextCard.difficulty = nextState.difficulty.value + + try stepsHandler.applySteps( + to: &nextCard, + rating: rating, + targetState: pipeline.lastCard.state, + pipeline: pipeline + ) + + pipeline.logger?.debug("Learning result: state=\(nextCard.state), learningSteps=\(nextCard.learningSteps)") + return RecordLogItem(card: nextCard, log: pipeline.buildLog(rating: rating)) + } + + // swiftlint:disable:next function_body_length + public func reviewState(rating: Rating) throws -> RecordLogItem { + pipeline.logger?.debug("Short-term review: rating=\(rating)") + + let retrievability = try pipeline.computeRetrievability() + pipeline.logger?.debug("Review retrievability: \(retrievability.value)") + + // For "Again", enter relearning with learning steps + if rating == .again { + let nextState = try pipeline.computeMemoryState( + elapsedDays: pipeline.elapsedDaysValue, + rating: .again, + retrievability: retrievability + ) + + var nextCard = pipeline.currentCard + nextCard.stability = nextState.stability.value + nextCard.difficulty = nextState.difficulty.value + nextCard.lapses += 1 + + try stepsHandler.applySteps( + to: &nextCard, + rating: .again, + targetState: .relearning, + pipeline: pipeline + ) + + return RecordLogItem(card: nextCard, log: pipeline.buildLog(rating: .again)) + } + + // For Hard, Good, Easy - calculate intervals and apply constraints + let intervals = try pipeline.computeConstrainedReviewIntervals( + retrievability: retrievability + ) + + var nextCard = pipeline.currentCard + switch rating { + case .hard: + nextCard.stability = intervals.hardState.stability.value + nextCard.difficulty = intervals.hardState.difficulty.value + nextCard.scheduledDays = intervals.hard + case .good: + nextCard.stability = intervals.goodState.stability.value + nextCard.difficulty = intervals.goodState.difficulty.value + nextCard.scheduledDays = intervals.good + case .easy: + nextCard.stability = intervals.easyState.stability.value + nextCard.difficulty = intervals.easyState.difficulty.value + nextCard.scheduledDays = intervals.easy + default: + throw FSRSError.invalidGrade("Unexpected rating in review: \(rating)") + } + + nextCard.due = dateScheduler( + now: pipeline.reviewTime, + offset: Double(nextCard.scheduledDays), + isDay: true + ) + nextCard.state = .review + nextCard.learningSteps = 0 + + pipeline.logger?.debug("Review result: scheduledDays=\(nextCard.scheduledDays)") + return RecordLogItem(card: nextCard, log: pipeline.buildLog(rating: rating)) + } + + public func buildLog(rating: Rating) -> ReviewLog { + pipeline.buildLog(rating: rating) + } +} From e47aa91c29795df06ac6143ae6905a6ee51ce09a Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Sun, 22 Feb 2026 21:15:53 +0400 Subject: [PATCH 4/9] Rename public API to Swift-idiomatic names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - repeat(card:now:) → schedule(card:at:) - next(card:now:rating:) → schedule(card:at:rating:) - getRetrievability(card:now:) → formattedRetrievability(of:at:) - getRetrievabilityValue(card:now:) → retrievability(of:at:) - forget(card:now:) → forget(card:at:) - reschedule(currentCard:) → reschedule(card:) Updated all tests and examples. All 125 tests pass. Co-Authored-By: Claude Opus 4.6 --- Sources/Examples/Flashcard.swift | 4 +- Sources/Examples/LoggerExample.swift | 12 +-- Sources/FSRS/Core/FSRS.swift | 36 ++++---- Sources/FSRS/Services/RescheduleService.swift | 2 +- Tests/FSRS/FSRSAPITests.swift | 86 +++++++++---------- Tests/FSRS/IntegrationTests.swift | 64 +++++++------- Tests/FSRS/ParameterTests.swift | 12 +-- Tests/FSRS/StateTransitionTests.swift | 70 +++++++-------- 8 files changed, 143 insertions(+), 143 deletions(-) diff --git a/Sources/Examples/Flashcard.swift b/Sources/Examples/Flashcard.swift index 114c604..58476c5 100644 --- a/Sources/Examples/Flashcard.swift +++ b/Sources/Examples/Flashcard.swift @@ -119,7 +119,7 @@ var card = Flashcard( ) // Preview all possible scheduling outcomes -let scheduling = try fsrs.repeat(card: card, now: Date()) +let scheduling = try fsrs.schedule(card: card, at: Date()) print("If you rate it 'Again':", scheduling[.again]!.card.due) print("If you rate it 'Hard':", scheduling[.hard]!.card.due) @@ -143,4 +143,4 @@ try jsonData.write(to: fileURL) // Load and continue scheduling let loaded = try JSONDecoder().decode(Flashcard.self, from: jsonData) -let nextReview = try fsrs.next(card: loaded, now: Date(), rating: .good) +let nextReview = try fsrs.schedule(card: loaded, at: Date(), rating: .good) diff --git a/Sources/Examples/LoggerExample.swift b/Sources/Examples/LoggerExample.swift index 52747af..b40f373 100644 --- a/Sources/Examples/LoggerExample.swift +++ b/Sources/Examples/LoggerExample.swift @@ -86,7 +86,7 @@ func exampleBasicLogging() { // All operations will now be logged let card = MyCard() do { - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) print("Next due: \(result.card.due)") } catch { print("Error: \(error)") @@ -101,7 +101,7 @@ func exampleFilteredLogging() { // Only warnings and errors will be logged let card = MyCard() do { - _ = try fsrs.forget(card: card, now: Date()) // This will log a warning + _ = try fsrs.forget(card: card, at: Date()) // This will log a warning } catch { print("Error: \(error)") } @@ -118,7 +118,7 @@ func exampleFileLogging() { // All logs will be written to file let card = MyCard() do { - let recordLog = try fsrs.repeat(card: card, now: Date()) + let recordLog = try fsrs.schedule(card: card, at: Date()) print("Logged \(recordLog.count) scenarios to \(logFileURL.path)") } catch { print("Error: \(error)") @@ -151,7 +151,7 @@ func exampleCompositeLogging() { // Logs will go to both console and file let card = MyCard() do { - _ = try fsrs.next(card: card, now: Date(), rating: .good) + _ = try fsrs.schedule(card: card, at: Date(), rating: .good) } catch { print("Error: \(error)") } @@ -216,7 +216,7 @@ func exampleLogAnalysis() { for rating in ratings { do { - let result = try fsrs.next(card: card, now: Date(), rating: rating) + let result = try fsrs.schedule(card: card, at: Date(), rating: rating) card = result.card // You'll see detailed logs showing: @@ -277,7 +277,7 @@ func examplePerformanceMonitoring() { // Run multiple operations for _ in 0..<10 { do { - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) card = result.card } catch { print("Error: \(error)") diff --git a/Sources/FSRS/Core/FSRS.swift b/Sources/FSRS/Core/FSRS.swift index 8fa32a7..5c40d8d 100644 --- a/Sources/FSRS/Core/FSRS.swift +++ b/Sources/FSRS/Core/FSRS.swift @@ -87,10 +87,10 @@ public struct FSRS { /// /// - Parameters: /// - card: Card to process - /// - now: Current time + /// - at: Time of review /// - Returns: Record log with all rating scenarios /// - Throws: FSRSError if any operation fails - public func `repeat`(card: Card, now: Date) throws -> RecordLog { + public func schedule(card: Card, at now: Date) throws -> RecordLog { logger?.debug("Previewing all ratings: state=\(card.state), useShortTerm=\(useShortTerm)") let scheduler = schedulerFactory.makeScheduler( @@ -107,12 +107,12 @@ public struct FSRS { /// /// - Parameters: /// - card: Card to process - /// - now: Current time + /// - at: Time of review /// - rating: Grade rating (Again, Hard, Good, or Easy) /// - Returns: Record log item with next card state /// - Throws: FSRSError if any operation fails - public func next(card: Card, now: Date, rating: Rating) throws -> RecordLogItem { - logger?.debug("Processing next: rating=\(rating), state=\(card.state)") + public func schedule(card: Card, at now: Date, rating: Rating) throws -> RecordLogItem { + logger?.debug("Processing schedule: rating=\(rating), state=\(card.state)") guard rating != .manual else { logger?.error("Manual rating not allowed for scheduling") @@ -133,19 +133,19 @@ public struct FSRS { /// Get retrievability of card as percentage string /// - Parameters: - /// - card: Card to process - /// - now: Optional current time (defaults to now) + /// - of: Card to calculate retrievability for + /// - at: Optional current time (defaults to now) /// - Returns: Retrievability formatted as percentage (e.g., "85.00%") - public func getRetrievability(card: Card, now: Date? = nil) -> String { + public func formattedRetrievability(of card: Card, at now: Date? = nil) -> String { retrievabilityService.getRetrievabilityFormatted(card: card, now: now) } /// Get retrievability of card as numeric value /// - Parameters: - /// - card: Card to process - /// - now: Optional current time (defaults to now) + /// - of: Card to calculate retrievability for + /// - at: Optional current time (defaults to now) /// - Returns: Retrievability as Double (0.0 to 1.0) - public func getRetrievabilityValue(card: Card, now: Date? = nil) -> Double { + public func retrievability(of card: Card, at now: Date? = nil) -> Double { retrievabilityService.getRetrievabilityValue(card: card, now: now) } @@ -164,22 +164,22 @@ public struct FSRS { /// Forget a card (reset to new state) /// - Parameters: /// - card: Card to forget - /// - now: Current time + /// - at: Current time /// - resetCount: Whether to reset reps and lapses /// - Returns: Record log item - public func forget(card: Card, now: Date, resetCount: Bool = false) -> RecordLogItem { + public func forget(card: Card, at now: Date, resetCount: Bool = false) -> RecordLogItem { cardStateService.forget(card: card, now: now, resetCount: resetCount) } /// Reschedule card based on review history /// - Parameters: - /// - currentCard: Current card state + /// - card: Current card state /// - reviews: Review history /// - options: Reschedule options /// - Returns: Reschedule result /// - Throws: FSRSError if any operation fails public func reschedule( - currentCard: Card, + card: Card, reviews: [FSRSHistory], options: RescheduleOptions = RescheduleOptions() ) throws -> RescheduleResult { @@ -200,8 +200,8 @@ public struct FSRS { let rescheduleService = RescheduleService(fsrs: self, logger: logger) // Use firstCard or create empty from currentCard - var emptyCard = currentCard - emptyCard.due = currentCard.due + var emptyCard = card + emptyCard.due = card.due emptyCard.stability = 0 emptyCard.difficulty = 0 emptyCard.scheduledDays = 0 @@ -219,7 +219,7 @@ public struct FSRS { let nowDate = options.now ?? Date() let manualItem = try rescheduleService.calculateManualRecord( - currentCard: currentCard, + currentCard: card, now: nowDate, recordLogItem: collections.last, updateMemory: options.updateMemoryState diff --git a/Sources/FSRS/Services/RescheduleService.swift b/Sources/FSRS/Services/RescheduleService.swift index 5811f7c..85432a9 100644 --- a/Sources/FSRS/Services/RescheduleService.swift +++ b/Sources/FSRS/Services/RescheduleService.swift @@ -23,7 +23,7 @@ public struct RescheduleService { /// - Returns: Record log item /// - Throws: FSRSError if any operation fails public func replay(card: Card, reviewed: Date, rating: Rating) throws -> RecordLogItem { - try fsrs.next(card: card, now: reviewed, rating: rating) + try fsrs.schedule(card: card, at: reviewed, rating: rating) } /// Handle manual rating diff --git a/Tests/FSRS/FSRSAPITests.swift b/Tests/FSRS/FSRSAPITests.swift index 002acfb..7beb217 100644 --- a/Tests/FSRS/FSRSAPITests.swift +++ b/Tests/FSRS/FSRSAPITests.swift @@ -55,7 +55,7 @@ struct FSRSAPITests { let card = createCard() let now = Date() - let recordLog = try fsrs.repeat(card: card, now: now) + let recordLog = try fsrs.schedule(card: card, at: now) // Should contain all 4 grades #expect(recordLog.count == 4) @@ -71,7 +71,7 @@ struct FSRSAPITests { let card = createCard() let now = Date() - let recordLog = try fsrs.repeat(card: card, now: now) + let recordLog = try fsrs.schedule(card: card, at: now) #expect(recordLog[.again]?.card.lastReview == now) #expect(recordLog[.hard]?.card.lastReview == now) @@ -84,7 +84,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createCard() - let recordLog = try fsrs.repeat(card: card, now: Date()) + let recordLog = try fsrs.schedule(card: card, at: Date()) let againStability = try #require(recordLog[.again]).card.stability let hardStability = try #require(recordLog[.hard]).card.stability @@ -103,7 +103,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createCard() - let recordLog = try fsrs.repeat(card: card, now: Date()) + let recordLog = try fsrs.schedule(card: card, at: Date()) let againDifficulty = try #require(recordLog[.again]).card.difficulty let hardDifficulty = try #require(recordLog[.hard]).card.difficulty @@ -122,7 +122,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createCard() - let recordLog = try fsrs.repeat(card: card, now: Date()) + let recordLog = try fsrs.schedule(card: card, at: Date()) #expect(recordLog[.again]?.card.reps == 1) #expect(recordLog[.hard]?.card.reps == 1) @@ -140,7 +140,7 @@ struct FSRSAPITests { notes: "Important note" ) - let recordLog = try fsrs.repeat(card: card, now: Date()) + let recordLog = try fsrs.schedule(card: card, at: Date()) #expect(recordLog[.good]?.card.question == "Custom Question") #expect(recordLog[.good]?.card.answer == "Custom Answer") @@ -156,7 +156,7 @@ struct FSRSAPITests { let card = createCard() let now = Date() - let result = try fsrs.next(card: card, now: now, rating: .good) + let result = try fsrs.schedule(card: card, at: now, rating: .good) #expect(result.card.state != .new) #expect(result.card.lastReview == now) @@ -170,7 +170,7 @@ struct FSRSAPITests { let card = createCard() #expect(throws: FSRSError.self) { - _ = try fsrs.next(card: card, now: Date(), rating: .manual) + _ = try fsrs.schedule(card: card, at: Date(), rating: .manual) } } @@ -179,7 +179,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createCard() - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.state == .learning) } @@ -189,7 +189,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createCard() - let result = try fsrs.next(card: card, now: Date(), rating: .easy) + let result = try fsrs.schedule(card: card, at: Date(), rating: .easy) #expect(result.card.state == .review) #expect(result.card.scheduledDays > 0) @@ -202,9 +202,9 @@ struct FSRSAPITests { let fsrs = createFSRS(enableShortTerm: true) let card = createCard() - let againResult = try fsrs.next(card: card, now: Date(), rating: .again) - let hardResult = try fsrs.next(card: card, now: Date(), rating: .hard) - let goodResult = try fsrs.next(card: card, now: Date(), rating: .good) + let againResult = try fsrs.schedule(card: card, at: Date(), rating: .again) + let hardResult = try fsrs.schedule(card: card, at: Date(), rating: .hard) + let goodResult = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(againResult.card.state == .learning) #expect(hardResult.card.state == .learning) @@ -216,7 +216,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createCard() - let result = try fsrs.next(card: card, now: Date(), rating: .easy) + let result = try fsrs.schedule(card: card, at: Date(), rating: .easy) #expect(result.card.state == .review) } @@ -226,7 +226,7 @@ struct FSRSAPITests { let fsrs = createFSRS(enableShortTerm: true) let card = createReviewCard() - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.state == .relearning) #expect(result.card.lapses == 1) @@ -239,20 +239,20 @@ struct FSRSAPITests { var now = Date() // First review - var result = try fsrs.next(card: card, now: now, rating: .good) + var result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card #expect(card.reps == 1) // Second review now = try #require(Calendar.current.date(byAdding: .day, value: 1, to: now)) - result = try fsrs.next(card: card, now: now, rating: .good) + result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card #expect(card.reps == 2) // Third review now = try #require( Calendar.current.date(byAdding: .day, value: Int(card.scheduledDays), to: now)) - result = try fsrs.next(card: card, now: now, rating: .good) + result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card #expect(card.reps == 3) } @@ -264,7 +264,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createReviewCard() - let retrievability = fsrs.getRetrievability(card: card) + let retrievability = fsrs.formattedRetrievability(of: card) // Should be formatted as percentage #expect(retrievability.contains("%")) @@ -276,7 +276,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createReviewCard() - let value = fsrs.getRetrievabilityValue(card: card) + let value = fsrs.retrievability(of: card) #expect(value >= 0.0) #expect(value <= 1.0) @@ -287,7 +287,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createCard() - let value = fsrs.getRetrievabilityValue(card: card) + let value = fsrs.retrievability(of: card) #expect(value == 0.0) } @@ -308,13 +308,13 @@ struct FSRSAPITests { ) let now1 = try #require(Calendar.current.date(byAdding: .day, value: 1, to: baseDate)) - let result1 = fsrs.getRetrievabilityValue(card: card, now: now1) + let result1 = fsrs.retrievability(of: card, at: now1) let now2 = try #require(Calendar.current.date(byAdding: .day, value: 5, to: baseDate)) - let result2 = fsrs.getRetrievabilityValue(card: card, now: now2) + let result2 = fsrs.retrievability(of: card, at: now2) let now3 = try #require(Calendar.current.date(byAdding: .day, value: 10, to: baseDate)) - let result3 = fsrs.getRetrievabilityValue(card: card, now: now3) + let result3 = fsrs.retrievability(of: card, at: now3) // Retrievability should decrease over time #expect(result1 > result2) @@ -329,7 +329,7 @@ struct FSRSAPITests { let originalCard = createCard() // Perform a review - let result = try fsrs.next(card: originalCard, now: Date(), rating: .good) + let result = try fsrs.schedule(card: originalCard, at: Date(), rating: .good) let updatedCard = result.card let log = result.log @@ -378,7 +378,7 @@ struct FSRSAPITests { ) // Review with Again (increases lapses) - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.lapses == 3) // Rollback should restore original lapses @@ -394,7 +394,7 @@ struct FSRSAPITests { let card = createReviewCard() let now = Date() - let result = fsrs.forget(card: card, now: now) + let result = fsrs.forget(card: card, at: now) #expect(result.card.state == .new) #expect(result.card.stability == 0) @@ -417,7 +417,7 @@ struct FSRSAPITests { lapses: 3 ) - let result = fsrs.forget(card: card, now: Date(), resetCount: true) + let result = fsrs.forget(card: card, at: Date(), resetCount: true) #expect(result.card.reps == 0) #expect(result.card.lapses == 0) @@ -436,7 +436,7 @@ struct FSRSAPITests { lapses: 3 ) - let result = fsrs.forget(card: card, now: Date(), resetCount: false) + let result = fsrs.forget(card: card, at: Date(), resetCount: false) #expect(result.card.reps == 10) #expect(result.card.lapses == 3) @@ -447,7 +447,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createReviewCard() - let result = fsrs.forget(card: card, now: Date()) + let result = fsrs.forget(card: card, at: Date()) #expect(result.log.rating == .manual) } @@ -535,7 +535,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createCard() - let result = try fsrs.reschedule(currentCard: card, reviews: []) + let result = try fsrs.reschedule(card: card, reviews: []) #expect(result.collections.isEmpty) } @@ -560,7 +560,7 @@ struct FSRSAPITests { ] let result = try fsrs.reschedule( - currentCard: card, + card: card, reviews: history, options: RescheduleOptions(skipManual: true) ) @@ -587,7 +587,7 @@ struct FSRSAPITests { ] let result = try fsrs.reschedule( - currentCard: card, + card: card, reviews: history, options: RescheduleOptions(skipManual: true) ) @@ -614,7 +614,7 @@ struct FSRSAPITests { reps: 1 ) - let result = try fsrs.next(card: card, now: dueDate, rating: .good) + let result = try fsrs.schedule(card: card, at: dueDate, rating: .good) #expect(result.card.reps == 2) #expect(result.card.scheduledDays > 0) @@ -636,7 +636,7 @@ struct FSRSAPITests { reps: 1 ) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.reps == 2) } @@ -657,7 +657,7 @@ struct FSRSAPITests { reps: 1 ) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.reps == 2) } @@ -675,7 +675,7 @@ struct FSRSAPITests { reps: 50 ) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.stability >= card.stability) } @@ -693,7 +693,7 @@ struct FSRSAPITests { reps: 10 ) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.difficulty >= 1.0) #expect(result.card.difficulty <= 10.0) @@ -713,7 +713,7 @@ struct FSRSAPITests { lapses: 10 ) - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.lapses == 11) } @@ -725,7 +725,7 @@ struct FSRSAPITests { let fsrs = createFSRS(enableShortTerm: true) let card = createCard() - let recordLog = try fsrs.repeat(card: card, now: Date()) + let recordLog = try fsrs.schedule(card: card, at: Date()) // With short-term enabled, learning steps should be used #expect(recordLog[.good]?.card.learningSteps != nil) @@ -736,7 +736,7 @@ struct FSRSAPITests { let fsrs = createFSRS(enableShortTerm: false) let card = createCard() - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) // With short-term disabled, should go directly to review #expect(result.card.state == .review) @@ -750,7 +750,7 @@ struct FSRSAPITests { let card = createCard() let now = Date() - let result = try fsrs.next(card: card, now: now, rating: .good) + let result = try fsrs.schedule(card: card, at: now, rating: .good) let log = result.log #expect(log.rating == .good) @@ -764,7 +764,7 @@ struct FSRSAPITests { let fsrs = createFSRS() let card = createCard() - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) // Log should contain the old state #expect(result.log.state == .new) diff --git a/Tests/FSRS/IntegrationTests.swift b/Tests/FSRS/IntegrationTests.swift index c8eea0d..a07ea98 100644 --- a/Tests/FSRS/IntegrationTests.swift +++ b/Tests/FSRS/IntegrationTests.swift @@ -30,13 +30,13 @@ struct IntegrationTests { var now = Date() // Day 1: First review - var result = try fsrs.next(card: card, now: now, rating: .good) + var result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card #expect(card.reps == 1) // Day 2: Second review now = try #require(Calendar.current.date(byAdding: .day, value: 1, to: now)) - result = try fsrs.next(card: card, now: now, rating: .good) + result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card #expect(card.reps == 2) @@ -50,7 +50,7 @@ struct IntegrationTests { now = try #require(Calendar.current.date(byAdding: .day, value: 1, to: now)) } - result = try fsrs.next(card: card, now: now, rating: .good) + result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card #expect(card.reps == i) } @@ -69,12 +69,12 @@ struct IntegrationTests { // Multiple failed attempts for _ in 0..<3 { - var result = try fsrs.next(card: card, now: now, rating: .again) + var result = try fsrs.schedule(card: card, at: now, rating: .again) card = result.card // Try again after some time now = try #require(Calendar.current.date(byAdding: .hour, value: 1, to: now)) - result = try fsrs.next(card: card, now: now, rating: .hard) + result = try fsrs.schedule(card: card, at: now, rating: .hard) card = result.card now = try #require(Calendar.current.date(byAdding: .day, value: 1, to: now)) @@ -93,7 +93,7 @@ struct IntegrationTests { // Consistently rate as easy for _ in 0..<5 { - let result = try fsrs.next(card: card, now: now, rating: .easy) + let result = try fsrs.schedule(card: card, at: now, rating: .easy) card = result.card if card.scheduledDays > 0 { @@ -117,24 +117,24 @@ struct IntegrationTests { var now = Date() // Learn the card - var result = try fsrs.next(card: card, now: now, rating: .good) + var result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card // Graduate to review now = try #require(Calendar.current.date(byAdding: .day, value: 1, to: now)) - result = try fsrs.next(card: card, now: now, rating: .easy) + result = try fsrs.schedule(card: card, at: now, rating: .easy) card = result.card #expect(card.state == .review) // Forget the card now = try #require(Calendar.current.date(byAdding: .day, value: 30, to: now)) - result = try fsrs.next(card: card, now: now, rating: .again) + result = try fsrs.schedule(card: card, at: now, rating: .again) card = result.card #expect(card.state == .relearning || card.state == .review) #expect(card.lapses >= 1) // Relearn successfully - result = try fsrs.next(card: card, now: now, rating: .good) + result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card // Verify recovery @@ -151,7 +151,7 @@ struct IntegrationTests { var totalDays = 0 while totalDays < 365 && card.reps < 20 { - let result = try fsrs.next(card: card, now: now, rating: .good) + let result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card let daysToAdd = max(1, card.scheduledDays) @@ -172,7 +172,7 @@ struct IntegrationTests { var cramCard = createCard() let cramDate = Date() for _ in 1...10 { - let result = try fsrs.next(card: cramCard, now: cramDate, rating: .good) + let result = try fsrs.schedule(card: cramCard, at: cramDate, rating: .good) cramCard = result.card } @@ -180,7 +180,7 @@ struct IntegrationTests { var spacedCard = createCard() var spacedDate = Date() for _ in 1...10 { - let result = try fsrs.next(card: spacedCard, now: spacedDate, rating: .good) + let result = try fsrs.schedule(card: spacedCard, at: spacedDate, rating: .good) spacedCard = result.card if spacedCard.scheduledDays > 0 { @@ -216,7 +216,7 @@ struct IntegrationTests { // Review all cards for i in 0..] = [] for card in cards { - let preview = try fsrs.repeat(card: card, now: now) + let preview = try fsrs.schedule(card: card, at: now) previews.append(preview) } @@ -260,7 +260,7 @@ struct IntegrationTests { let now = Date() // Perform review - let result = try fsrs.next(card: originalCard, now: now, rating: .hard) + let result = try fsrs.schedule(card: originalCard, at: now, rating: .hard) let updatedCard = result.card #expect(updatedCard.reps == 1) @@ -284,7 +284,7 @@ struct IntegrationTests { // Perform multiple reviews for _ in 0..<5 { - let result = try fsrs.next(card: card, now: now, rating: .good) + let result = try fsrs.schedule(card: card, at: now, rating: .good) logs.append(result.log) card = result.card cardStates.append(card) @@ -326,7 +326,7 @@ struct IntegrationTests { ) // Continue using the card - let result = try fsrs.next(card: importedCard, now: now, rating: .good) + let result = try fsrs.schedule(card: importedCard, at: now, rating: .good) #expect(result.card.reps == 6) #expect(result.card.stability >= importedCard.stability) @@ -350,7 +350,7 @@ struct IntegrationTests { ) // Reset the card - let result = fsrs.forget(card: matureCard, now: now, resetCount: true) + let result = fsrs.forget(card: matureCard, at: now, resetCount: true) #expect(result.card.state == .new) #expect(result.card.stability == 0) @@ -379,7 +379,7 @@ struct IntegrationTests { ) // Should still allow review - let result = try fsrs.next(card: card, now: now, rating: .good) + let result = try fsrs.schedule(card: card, at: now, rating: .good) #expect(result.card.reps == 4) } @@ -402,7 +402,7 @@ struct IntegrationTests { ) // Should handle very overdue cards - let result = try fsrs.next(card: card, now: now, rating: .again) + let result = try fsrs.schedule(card: card, at: now, rating: .again) #expect(result.card.lapses == 1) } @@ -413,8 +413,8 @@ struct IntegrationTests { let now = Date() // Simulate two different review paths - let result1 = try fsrs.next(card: originalCard, now: now, rating: .good) - let result2 = try fsrs.next(card: originalCard, now: now, rating: .easy) + let result1 = try fsrs.schedule(card: originalCard, at: now, rating: .good) + let result2 = try fsrs.schedule(card: originalCard, at: now, rating: .easy) // Both should be valid but different #expect(result1.card.reps == 1) @@ -431,11 +431,11 @@ struct IntegrationTests { // Create a reviewed card var card = createCard() - let result = try fsrs.next(card: card, now: baseDate, rating: .good) + let result = try fsrs.schedule(card: card, at: baseDate, rating: .good) card = result.card // Skip to review state - let result2 = try fsrs.next(card: card, now: baseDate, rating: .easy) + let result2 = try fsrs.schedule(card: card, at: baseDate, rating: .easy) card = result2.card var previousRetrievability = 1.0 @@ -444,7 +444,7 @@ struct IntegrationTests { for days in 1...30 { let checkDate = try #require( Calendar.current.date(byAdding: .day, value: days, to: baseDate)) - let retrievability = fsrs.getRetrievabilityValue(card: card, now: checkDate) + let retrievability = fsrs.retrievability(of: card, at: checkDate) // Should decrease over time #expect(retrievability <= previousRetrievability) @@ -462,17 +462,17 @@ struct IntegrationTests { let now = Date() // Review to get to review state - var result = try fsrs.next(card: card, now: now, rating: .good) + var result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card - result = try fsrs.next(card: card, now: now, rating: .easy) + result = try fsrs.schedule(card: card, at: now, rating: .easy) card = result.card guard card.state == .review else { return } // Check retrievability at scheduled due date let dueDate = card.due - let retrievabilityAtDue = fsrs.getRetrievabilityValue(card: card, now: dueDate) + let retrievabilityAtDue = fsrs.retrievability(of: card, at: dueDate) // Should be close to request retention (0.9) #expect(retrievabilityAtDue >= 0.8) @@ -494,7 +494,7 @@ struct IntegrationTests { // Multiple reviews for _ in 0..<5 { - let result = try fsrs.next(card: card, now: now, rating: .good) + let result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card // Verify custom properties @@ -518,7 +518,7 @@ struct IntegrationTests { // Perform many reviews for i in 1...100 { let rating: Rating = i % 4 == 0 ? .again : .good - let result = try fsrs.next(card: card, now: now, rating: rating) + let result = try fsrs.schedule(card: card, at: now, rating: rating) card = result.card now = try #require(Calendar.current.date(byAdding: .hour, value: 1, to: now)) @@ -538,7 +538,7 @@ struct IntegrationTests { // Review multiple times at the exact same time for _ in 0..<10 { - let result = try fsrs.next(card: card, now: now, rating: .good) + let result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card } diff --git a/Tests/FSRS/ParameterTests.swift b/Tests/FSRS/ParameterTests.swift index c147cf7..e414d88 100644 --- a/Tests/FSRS/ParameterTests.swift +++ b/Tests/FSRS/ParameterTests.swift @@ -219,7 +219,7 @@ struct ParameterTests { let fsrs: FSRS = fsrs(params: params) let card = TestCard(question: "Test", answer: "Test") - _ = try fsrs.repeat(card: card, now: Date()) + _ = try fsrs.schedule(card: card, at: Date()) // Learning steps should be reflected in the scheduling #expect(fsrs.parameters.learningSteps == steps) @@ -246,7 +246,7 @@ struct ParameterTests { let fsrs: FSRS = fsrs(params: params) let card = TestCard(question: "Test", answer: "Test") - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) // Should work even with empty steps #expect(result.card.reps == 1) @@ -262,8 +262,8 @@ struct ParameterTests { let card = TestCard(question: "Test", answer: "Test") let now = Date() - let result1 = try fsrs.next(card: card, now: now, rating: .good) - let result2 = try fsrs.next(card: card, now: now, rating: .good) + let result1 = try fsrs.schedule(card: card, at: now, rating: .good) + let result2 = try fsrs.schedule(card: card, at: now, rating: .good) // With fuzz disabled, intervals should be identical #expect(result1.card.scheduledDays == result2.card.scheduledDays) @@ -276,14 +276,14 @@ struct ParameterTests { var fsrs: FSRS = fsrs(params: PartialFSRSParameters(requestRetention: 0.9)) let card = TestCard(question: "Test", answer: "Test") - _ = try fsrs.next(card: card, now: Date(), rating: .good) + _ = try fsrs.schedule(card: card, at: Date(), rating: .good) // Update parameters var newParams = fsrs.parameters newParams.requestRetention = 0.7 fsrs.parameters = newParams - _ = try fsrs.next(card: card, now: Date(), rating: .good) + _ = try fsrs.schedule(card: card, at: Date(), rating: .good) // Different retention should affect intervals #expect(fsrs.parameters.requestRetention == 0.7) diff --git a/Tests/FSRS/StateTransitionTests.swift b/Tests/FSRS/StateTransitionTests.swift index 082eda1..ce47ac0 100644 --- a/Tests/FSRS/StateTransitionTests.swift +++ b/Tests/FSRS/StateTransitionTests.swift @@ -39,7 +39,7 @@ struct StateTransitionTests { let fsrs = createFSRS(enableShortTerm: enableShortTirm) let card = createCard(state: .new) - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.state == state) #expect(result.card.reps == 1) @@ -53,7 +53,7 @@ struct StateTransitionTests { let fsrs = createFSRS(enableShortTerm: true) let card = createCard(state: .new) - let result = try fsrs.next(card: card, now: Date(), rating: .hard) + let result = try fsrs.schedule(card: card, at: Date(), rating: .hard) #expect(result.card.state == .learning) #expect(result.card.reps == 1) @@ -64,7 +64,7 @@ struct StateTransitionTests { let fsrs = createFSRS(enableShortTerm: true) let card = createCard(state: .new) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.state == .learning) #expect(result.card.reps == 1) @@ -75,7 +75,7 @@ struct StateTransitionTests { let fsrs = createFSRS(enableShortTerm: false) let card = createCard(state: .new) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.state == .review) #expect(result.card.reps == 1) @@ -87,7 +87,7 @@ struct StateTransitionTests { let fsrs = createFSRS() let card = createCard(state: .new) - let result = try fsrs.next(card: card, now: Date(), rating: .easy) + let result = try fsrs.schedule(card: card, at: Date(), rating: .easy) #expect(result.card.state == .review) #expect(result.card.reps == 1) @@ -109,7 +109,7 @@ struct StateTransitionTests { reps: 1 ) - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.state == .learning) #expect(result.card.learningSteps == 0) @@ -128,7 +128,7 @@ struct StateTransitionTests { reps: 1 ) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) // Should progress in learning or graduate #expect(result.card.state == .review) @@ -150,7 +150,7 @@ struct StateTransitionTests { reps: 1 ) - let result = try fsrs.next(card: card, now: Date(), rating: .easy) + let result = try fsrs.schedule(card: card, at: Date(), rating: .easy) #expect(result.card.state == .review) #expect(result.card.scheduledDays > 0) @@ -163,7 +163,7 @@ struct StateTransitionTests { let fsrs = createFSRS(enableShortTerm: true) let card = createCard(state: .review) - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.state == .relearning) #expect(result.card.lapses == 1) @@ -174,7 +174,7 @@ struct StateTransitionTests { let fsrs = createFSRS() let card = createCard(state: .review) - let result = try fsrs.next(card: card, now: Date(), rating: .hard) + let result = try fsrs.schedule(card: card, at: Date(), rating: .hard) #expect(result.card.state == .review) #expect(result.card.reps == 4) @@ -186,7 +186,7 @@ struct StateTransitionTests { let fsrs = createFSRS() let card = createCard(state: .review) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.state == .review) #expect(result.card.reps == 4) @@ -199,7 +199,7 @@ struct StateTransitionTests { let fsrs = createFSRS() let card = createCard(state: .review) - let result = try fsrs.next(card: card, now: Date(), rating: .easy) + let result = try fsrs.schedule(card: card, at: Date(), rating: .easy) #expect(result.card.state == .review) #expect(result.card.reps == 4) @@ -222,7 +222,7 @@ struct StateTransitionTests { lapses: 1 ) - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) // State can be relearning or review depending on short-term configuration #expect(result.card.state == .relearning || result.card.state == .review) @@ -243,7 +243,7 @@ struct StateTransitionTests { lapses: 1 ) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.reps == 6) } @@ -262,7 +262,7 @@ struct StateTransitionTests { lapses: 1 ) - let result = try fsrs.next(card: card, now: Date(), rating: .easy) + let result = try fsrs.schedule(card: card, at: Date(), rating: .easy) #expect(result.card.state == .review) } @@ -282,7 +282,7 @@ struct StateTransitionTests { lapses: 3 ) - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.lapses == 4) } @@ -300,7 +300,7 @@ struct StateTransitionTests { lapses: 0 ) - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.lapses == 0) } @@ -318,7 +318,7 @@ struct StateTransitionTests { lapses: 5 ) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.lapses == 5) } @@ -331,7 +331,7 @@ struct StateTransitionTests { let card = createCard(state: .review) let initialStability = card.stability - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) #expect(result.card.stability >= initialStability) } @@ -341,7 +341,7 @@ struct StateTransitionTests { let card = createCard(state: .review) let initialStability = card.stability - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.stability < initialStability) } @@ -351,7 +351,7 @@ struct StateTransitionTests { let fsrs = createFSRS(enableShortTerm: true) let card = createCard(state: .review) - let recordLog = try fsrs.repeat(card: card, now: Date()) + let recordLog = try fsrs.schedule(card: card, at: Date()) // swiftlint:disable:next force_unwrapping let againStability = recordLog[.again]!.card.stability @@ -375,7 +375,7 @@ struct StateTransitionTests { let card = createCard(state: .review) let initialDifficulty = card.difficulty - let result = try fsrs.next(card: card, now: Date(), rating: .again) + let result = try fsrs.schedule(card: card, at: Date(), rating: .again) #expect(result.card.difficulty > initialDifficulty) } @@ -393,7 +393,7 @@ struct StateTransitionTests { ) let initialDifficulty = card.difficulty - let result = try fsrs.next(card: card, now: Date(), rating: .easy) + let result = try fsrs.schedule(card: card, at: Date(), rating: .easy) #expect(result.card.difficulty <= initialDifficulty) } @@ -412,7 +412,7 @@ struct StateTransitionTests { reps: 50 ) - let resultHard = try fsrs.next(card: hardCard, now: Date(), rating: .again) + let resultHard = try fsrs.schedule(card: hardCard, at: Date(), rating: .again) #expect(resultHard.card.difficulty >= 1.0) #expect(resultHard.card.difficulty <= 10.0) @@ -426,7 +426,7 @@ struct StateTransitionTests { reps: 50 ) - let resultEasy = try fsrs.next(card: easyCard, now: Date(), rating: .easy) + let resultEasy = try fsrs.schedule(card: easyCard, at: Date(), rating: .easy) #expect(resultEasy.card.difficulty >= 1.0) #expect(resultEasy.card.difficulty <= 10.0) } @@ -441,7 +441,7 @@ struct StateTransitionTests { var currentCard = card for _ in 0..<3 { - let result = try fsrs.next(card: currentCard, now: Date(), rating: .good) + let result = try fsrs.schedule(card: currentCard, at: Date(), rating: .good) let newScheduledDays = result.card.scheduledDays // Scheduled days should generally increase with successful reviews @@ -456,7 +456,7 @@ struct StateTransitionTests { let fsrs = createFSRS(enableShortTerm: true) let card = createCard(state: .new) - let result = try fsrs.next(card: card, now: Date(), rating: .good) + let result = try fsrs.schedule(card: card, at: Date(), rating: .good) if result.card.state == .learning { #expect(result.card.scheduledDays == 0) @@ -473,20 +473,20 @@ struct StateTransitionTests { // New → Learning #expect(card.state == .new) - var result = try fsrs.next(card: card, now: now, rating: .good) + var result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card #expect(card.state == .learning) #expect(card.reps == 1) // Progress through learning steps now = try #require(Calendar.current.date(byAdding: .minute, value: 10, to: now)) - result = try fsrs.next(card: card, now: now, rating: .good) + result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card // Eventually reach Review if card.state == .learning { now = try #require(Calendar.current.date(byAdding: .hour, value: 1, to: now)) - result = try fsrs.next(card: card, now: now, rating: .good) + result = try fsrs.schedule(card: card, at: now, rating: .good) card = result.card } @@ -505,14 +505,14 @@ struct StateTransitionTests { #expect(card.state == .review) #expect(card.lapses == 0) - var result = try fsrs.next(card: card, now: now, rating: .again) + var result = try fsrs.schedule(card: card, at: now, rating: .again) card = result.card #expect(card.state == .relearning) #expect(card.lapses == 1) // Relearning → Review (recovery) - result = try fsrs.next(card: card, now: now, rating: .easy) + result = try fsrs.schedule(card: card, at: now, rating: .easy) card = result.card #expect(card.state == .review) @@ -526,19 +526,19 @@ struct StateTransitionTests { var now = Date() // First failure - var result = try fsrs.next(card: card, now: now, rating: .again) + var result = try fsrs.schedule(card: card, at: now, rating: .again) card = result.card #expect(card.lapses == 1) // Graduate back to review - result = try fsrs.next(card: card, now: now, rating: .easy) + result = try fsrs.schedule(card: card, at: now, rating: .easy) card = result.card #expect(card.state == .review) #expect(card.lapses == 1) // Second failure now = try #require(Calendar.current.date(byAdding: .day, value: 1, to: now)) - result = try fsrs.next(card: card, now: now, rating: .again) + result = try fsrs.schedule(card: card, at: now, rating: .again) card = result.card #expect(card.lapses == 2) } From 56fd29b004f3a55933a06dccb521aee38d71e65b Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Sun, 22 Feb 2026 21:19:54 +0400 Subject: [PATCH 5/9] Extract reschedule orchestration from FSRS facade into RescheduleService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move filtering, sorting, empty card creation, handler application, and result assembly from FSRS.reschedule() into RescheduleService.reschedule(). FSRS.reschedule() is now a 3-line delegate. Rename inner reschedule loop to replayReviews() for clarity. FSRS.swift: 243 → 189 lines. --- Sources/FSRS/Core/FSRS.swift | 59 +------------ Sources/FSRS/Services/RescheduleService.swift | 88 +++++++++++++++---- 2 files changed, 75 insertions(+), 72 deletions(-) diff --git a/Sources/FSRS/Core/FSRS.swift b/Sources/FSRS/Core/FSRS.swift index 5c40d8d..e514824 100644 --- a/Sources/FSRS/Core/FSRS.swift +++ b/Sources/FSRS/Core/FSRS.swift @@ -30,7 +30,6 @@ public struct FSRS { /// - params: Partial FSRS parameters /// - randomProvider: Random provider (optional, uses system random if not provided) /// - logger: Optional logger for debugging and monitoring - /// - logger: Optional logger for debugging and monitoring /// - schedulerFactory: Optional scheduler factory (defaults to FSRSSchedulerFactory) public init( params: PartialFSRSParameters = PartialFSRSParameters(), @@ -183,61 +182,7 @@ public struct FSRS { reviews: [FSRSHistory], options: RescheduleOptions = RescheduleOptions() ) throws -> RescheduleResult { - logger?.debug("Rescheduling card with \(reviews.count) reviews") - - var filteredReviews = reviews - - // Sort reviews if needed - if let orderBy = options.reviewsOrderBy { - filteredReviews.sort(by: orderBy) - } - - // Skip manual reviews if requested - if options.skipManual { - filteredReviews = filteredReviews.filter { $0.rating != .manual } - } - - let rescheduleService = RescheduleService(fsrs: self, logger: logger) - - // Use firstCard or create empty from currentCard - var emptyCard = card - emptyCard.due = card.due - emptyCard.stability = 0 - emptyCard.difficulty = 0 - emptyCard.scheduledDays = 0 - emptyCard.learningSteps = 0 - emptyCard.reps = 0 - emptyCard.lapses = 0 - emptyCard.state = .new - emptyCard.lastReview = nil - - let collections = try rescheduleService.reschedule( - currentCard: options.firstCard ?? emptyCard, - reviews: filteredReviews - ) - - let nowDate = options.now ?? Date() - - let manualItem = try rescheduleService.calculateManualRecord( - currentCard: card, - now: nowDate, - recordLogItem: collections.last, - updateMemory: options.updateMemoryState - ) - - var resultCollections = collections - if let handler = options.recordLogHandler { - resultCollections = collections.map { handler($0) } - } - - var resultManualItem: RecordLogItem? = manualItem - if let handler = options.recordLogItemHandler, let manualItem = manualItem { - resultManualItem = handler(manualItem) - } - - return RescheduleResult( - collections: resultCollections, - rescheduleItem: resultManualItem - ) + let service = RescheduleService(fsrs: self, logger: logger) + return try service.reschedule(card: card, reviews: reviews, options: options) } } diff --git a/Sources/FSRS/Services/RescheduleService.swift b/Sources/FSRS/Services/RescheduleService.swift index 85432a9..8ea42c2 100644 --- a/Sources/FSRS/Services/RescheduleService.swift +++ b/Sources/FSRS/Services/RescheduleService.swift @@ -102,27 +102,85 @@ public struct RescheduleService { } } - /// Reschedule card based on review history + /// Reschedule card with full options, including filtering, sorting, and result assembly /// - Parameters: - /// - currentCard: Initial card state + /// - card: Current card state + /// - reviews: Review history + /// - options: Reschedule options + /// - Returns: Reschedule result + /// - Throws: FSRSError if any operation fails + public func reschedule( + card: Card, + reviews: [FSRSHistory], + options: RescheduleOptions = RescheduleOptions() + ) throws -> RescheduleResult { + logger?.debug("Rescheduling card with \(reviews.count) reviews") + + var filteredReviews = reviews + + if let orderBy = options.reviewsOrderBy { + filteredReviews.sort(by: orderBy) + } + + if options.skipManual { + filteredReviews = filteredReviews.filter { $0.rating != .manual } + } + + let startingCard: Card + if let firstCard = options.firstCard { + startingCard = firstCard + } else { + var emptyCard = card + emptyCard.due = card.due + emptyCard.stability = 0 + emptyCard.difficulty = 0 + emptyCard.scheduledDays = 0 + emptyCard.learningSteps = 0 + emptyCard.reps = 0 + emptyCard.lapses = 0 + emptyCard.state = .new + emptyCard.lastReview = nil + startingCard = emptyCard + } + + let collections = try replayReviews(startingCard: startingCard, reviews: filteredReviews) + + let nowDate = options.now ?? Date() + + let manualItem = try calculateManualRecord( + currentCard: card, + now: nowDate, + recordLogItem: collections.last, + updateMemory: options.updateMemoryState + ) + + var resultCollections = collections + if let handler = options.recordLogHandler { + resultCollections = collections.map { handler($0) } + } + + var resultManualItem: RecordLogItem? = manualItem + if let handler = options.recordLogItemHandler, let manualItem = manualItem { + resultManualItem = handler(manualItem) + } + + return RescheduleResult( + collections: resultCollections, + rescheduleItem: resultManualItem + ) + } + + /// Replay review history on a card + /// - Parameters: + /// - startingCard: Initial card state /// - reviews: Review history /// - Returns: Array of record log items /// - Throws: FSRSError if any operation fails - public func reschedule(currentCard: Card, reviews: [FSRSHistory]) throws -> [RecordLogItem] { - logger?.debug("Starting reschedule with \(reviews.count) reviews") + func replayReviews(startingCard: Card, reviews: [FSRSHistory]) throws -> [RecordLogItem] { + logger?.debug("Replaying \(reviews.count) reviews") var collections: [RecordLogItem] = [] - - // Create empty card from currentCard - var curCard = currentCard - curCard.stability = 0 - curCard.difficulty = 0 - curCard.scheduledDays = 0 - curCard.learningSteps = 0 - curCard.reps = 0 - curCard.lapses = 0 - curCard.state = .new - curCard.lastReview = nil + var curCard = startingCard for (index, review) in reviews.enumerated() { guard let reviewDateValue = review.review else { From 840c60e22aa4e623b7f8d41692f398209d439613 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Sun, 22 Feb 2026 21:25:05 +0400 Subject: [PATCH 6/9] Remove dead code and restructure directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete ScheduledInterval value object (unused type) - Delete invalidScheduledInterval error case (unreachable) - Delete Factory.swift; replace fsrs() free function with direct FSRS() init in all tests - Move ConsoleLogger to test helpers - Relocate Core/ contents: FSRSAlgorithm → Algorithm/, FSRS.swift → root, FSRSErrors/Constants/ParameterManagement → Models/ - Remove empty Core/ directory Final structure: Algorithm/, Scheduling/, Models/, Protocols/, Services/, Strategies/, Utilities/, plus FSRS.swift at root. --- .../AlgorithmConstants.swift | 0 .../{Core => Algorithm}/FSRSAlgorithm.swift | 0 Sources/FSRS/Core/Factory.swift | 15 ------- Sources/FSRS/{Core => }/FSRS.swift | 0 Sources/FSRS/{Core => Models}/Constants.swift | 0 .../FSRS/{Core => Models}/FSRSErrors.swift | 5 --- .../ParameterManagement.swift | 0 Sources/FSRS/Models/ValueObjects.swift | 44 ------------------- Tests/FSRS/FSRSAPITests.swift | 6 +-- Tests/FSRS/IntegrationTests.swift | 2 +- Tests/FSRS/Mocks/TestHelpers.swift | 8 ++++ Tests/FSRS/ParameterTests.swift | 24 +++++----- Tests/FSRS/StateTransitionTests.swift | 2 +- 13 files changed, 25 insertions(+), 81 deletions(-) rename Sources/FSRS/{Core => Algorithm}/AlgorithmConstants.swift (100%) rename Sources/FSRS/{Core => Algorithm}/FSRSAlgorithm.swift (100%) delete mode 100644 Sources/FSRS/Core/Factory.swift rename Sources/FSRS/{Core => }/FSRS.swift (100%) rename Sources/FSRS/{Core => Models}/Constants.swift (100%) rename Sources/FSRS/{Core => Models}/FSRSErrors.swift (94%) rename Sources/FSRS/{Core => Models}/ParameterManagement.swift (100%) create mode 100644 Tests/FSRS/Mocks/TestHelpers.swift diff --git a/Sources/FSRS/Core/AlgorithmConstants.swift b/Sources/FSRS/Algorithm/AlgorithmConstants.swift similarity index 100% rename from Sources/FSRS/Core/AlgorithmConstants.swift rename to Sources/FSRS/Algorithm/AlgorithmConstants.swift diff --git a/Sources/FSRS/Core/FSRSAlgorithm.swift b/Sources/FSRS/Algorithm/FSRSAlgorithm.swift similarity index 100% rename from Sources/FSRS/Core/FSRSAlgorithm.swift rename to Sources/FSRS/Algorithm/FSRSAlgorithm.swift diff --git a/Sources/FSRS/Core/Factory.swift b/Sources/FSRS/Core/Factory.swift deleted file mode 100644 index 5658f3d..0000000 --- a/Sources/FSRS/Core/Factory.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -struct ConsoleLogger: FSRSLogger { - func log(message: FSRSLogMessage) { - print(message.description) - } -} - -public func fsrs( - params: PartialFSRSParameters = PartialFSRSParameters(), - randomProvider: RandomProvider? = nil, - logger: (any FSRSLogger)? = nil -) -> FSRS { - FSRS(params: params, randomProvider: randomProvider, logger: logger) -} diff --git a/Sources/FSRS/Core/FSRS.swift b/Sources/FSRS/FSRS.swift similarity index 100% rename from Sources/FSRS/Core/FSRS.swift rename to Sources/FSRS/FSRS.swift diff --git a/Sources/FSRS/Core/Constants.swift b/Sources/FSRS/Models/Constants.swift similarity index 100% rename from Sources/FSRS/Core/Constants.swift rename to Sources/FSRS/Models/Constants.swift diff --git a/Sources/FSRS/Core/FSRSErrors.swift b/Sources/FSRS/Models/FSRSErrors.swift similarity index 94% rename from Sources/FSRS/Core/FSRSErrors.swift rename to Sources/FSRS/Models/FSRSErrors.swift index ec3ce70..b1045f5 100644 --- a/Sources/FSRS/Core/FSRSErrors.swift +++ b/Sources/FSRS/Models/FSRSErrors.swift @@ -9,8 +9,6 @@ public enum FSRSError: Error, Sendable, LocalizedError { case invalidDifficulty(String) case invalidRetrievability(String) case invalidElapsedDays(String) - case invalidScheduledInterval(String) - // MARK: - Rating/Grade Errors case invalidRating(String) case invalidGrade(String) @@ -41,9 +39,6 @@ public enum FSRSError: Error, Sendable, LocalizedError { return "Invalid retrievability: \(message)" case .invalidElapsedDays(let message): return "Invalid elapsed days: \(message)" - case .invalidScheduledInterval(let message): - return "Invalid scheduled interval: \(message)" - // Rating/Grade Errors case .invalidRating(let message): return "Invalid rating: \(message)" diff --git a/Sources/FSRS/Core/ParameterManagement.swift b/Sources/FSRS/Models/ParameterManagement.swift similarity index 100% rename from Sources/FSRS/Core/ParameterManagement.swift rename to Sources/FSRS/Models/ParameterManagement.swift diff --git a/Sources/FSRS/Models/ValueObjects.swift b/Sources/FSRS/Models/ValueObjects.swift index 73920f0..3d3c410 100644 --- a/Sources/FSRS/Models/ValueObjects.swift +++ b/Sources/FSRS/Models/ValueObjects.swift @@ -156,50 +156,6 @@ public struct ElapsedDays: Sendable, Equatable, Codable { } } -// MARK: - ScheduledInterval Value Object - -/// Represents a scheduled interval for card review -public struct ScheduledInterval: Sendable, Equatable, Codable { - public let days: Int - - public init(days: Int) throws { - guard days >= 0 else { - throw FSRSError.invalidParameter( - "ScheduledInterval must be non-negative, got \(days)" - ) - } - self.days = days - } - - /// Create from minutes - public init(minutes: Int) throws { - try self.init(days: minutes / MINUTES_PER_DAY) - } - - /// Create interval without validation (use carefully) - internal init(unchecked days: Int) { - self.days = days - } - - /// Zero interval (review immediately) - public static let immediate = ScheduledInterval(unchecked: 0) - - /// Convert to minutes - public var minutes: Int { - days * MINUTES_PER_DAY - } - - /// Check if this is an immediate review - public var isImmediate: Bool { - days == 0 - } - - /// Check if this is a short-term interval (less than 1 day) - public var isShortTerm: Bool { - days == 0 - } -} - // MARK: - MemoryState Value Object /// Represents the complete memory state of a card diff --git a/Tests/FSRS/FSRSAPITests.swift b/Tests/FSRS/FSRSAPITests.swift index 7beb217..8dcb3f8 100644 --- a/Tests/FSRS/FSRSAPITests.swift +++ b/Tests/FSRS/FSRSAPITests.swift @@ -14,7 +14,7 @@ struct FSRSAPITests { enableFuzz: enableFuzz, enableShortTerm: enableShortTerm ) - return fsrs(params: params, logger: ConsoleLogger()) + return FSRS(params: params, logger: ConsoleLogger()) } private func createCard() -> TestCard { @@ -502,7 +502,7 @@ struct FSRSAPITests { maximumInterval: 365, enableFuzz: false ) - let fsrs: FSRS = fsrs(params: customParams) + let fsrs = FSRS(params: customParams) #expect(fsrs.parameters.requestRetention == 0.85) #expect(fsrs.parameters.maximumInterval == 365) @@ -511,7 +511,7 @@ struct FSRSAPITests { @Test("FSRS uses default parameters when not specified") func testDefaultParameters() { - let fsrs: FSRS = fsrs() + let fsrs = FSRS() #expect(fsrs.parameters.requestRetention > 0) #expect(fsrs.parameters.maximumInterval > 0) diff --git a/Tests/FSRS/IntegrationTests.swift b/Tests/FSRS/IntegrationTests.swift index a07ea98..6da4986 100644 --- a/Tests/FSRS/IntegrationTests.swift +++ b/Tests/FSRS/IntegrationTests.swift @@ -14,7 +14,7 @@ struct IntegrationTests { maximumInterval: 36_500, enableFuzz: enableFuzz ) - return fsrs(params: params, logger: ConsoleLogger()) + return FSRS(params: params, logger: ConsoleLogger()) } private func createCard() -> TestCard { diff --git a/Tests/FSRS/Mocks/TestHelpers.swift b/Tests/FSRS/Mocks/TestHelpers.swift new file mode 100644 index 0000000..92abfa4 --- /dev/null +++ b/Tests/FSRS/Mocks/TestHelpers.swift @@ -0,0 +1,8 @@ +@testable import FSRS + +/// Simple console logger for test debugging +struct ConsoleLogger: FSRSLogger { + func log(message: FSRSLogMessage) { + print(message.description) + } +} diff --git a/Tests/FSRS/ParameterTests.swift b/Tests/FSRS/ParameterTests.swift index e414d88..e4f5581 100644 --- a/Tests/FSRS/ParameterTests.swift +++ b/Tests/FSRS/ParameterTests.swift @@ -144,7 +144,7 @@ struct ParameterTests { @Test("Request retention must be valid range") func testRequestRetentionRange() throws { - let fsrs: FSRS = fsrs(params: PartialFSRSParameters(requestRetention: 0.9)) + let fsrs = FSRS(params: PartialFSRSParameters(requestRetention: 0.9)) #expect(fsrs.parameters.requestRetention > 0) #expect(fsrs.parameters.requestRetention <= 1) @@ -152,14 +152,14 @@ struct ParameterTests { @Test("Maximum interval must be positive") func testMaximumIntervalPositive() { - let fsrs: FSRS = fsrs(params: PartialFSRSParameters(maximumInterval: 365)) + let fsrs = FSRS(params: PartialFSRSParameters(maximumInterval: 365)) #expect(fsrs.parameters.maximumInterval > 0) } @Test("Weight array has correct length") func testWeightArrayLength() { - let fsrs: FSRS = fsrs() + let fsrs = FSRS() #expect(fsrs.parameters.weights.count == 21) } @@ -216,7 +216,7 @@ struct ParameterTests { enableShortTerm: true, learningSteps: steps ) - let fsrs: FSRS = fsrs(params: params) + let fsrs = FSRS(params: params) let card = TestCard(question: "Test", answer: "Test") _ = try fsrs.schedule(card: card, at: Date()) @@ -232,7 +232,7 @@ struct ParameterTests { enableShortTerm: true, relearningSteps: steps ) - let fsrs: FSRS = fsrs(params: params) + let fsrs = FSRS(params: params) #expect(fsrs.parameters.relearningSteps == steps) } @@ -243,7 +243,7 @@ struct ParameterTests { enableShortTerm: true, learningSteps: [] ) - let fsrs: FSRS = fsrs(params: params) + let fsrs = FSRS(params: params) let card = TestCard(question: "Test", answer: "Test") let result = try fsrs.schedule(card: card, at: Date(), rating: .good) @@ -257,7 +257,7 @@ struct ParameterTests { @Test("Fuzz disabled produces consistent intervals") func testFuzzDisabledConsistency() throws { let params = PartialFSRSParameters(enableFuzz: false) - let fsrs: FSRS = fsrs(params: params) + let fsrs = FSRS(params: params) let card = TestCard(question: "Test", answer: "Test") let now = Date() @@ -273,7 +273,7 @@ struct ParameterTests { @Test("Update parameters affects future scheduling") func testUpdateParametersAffectsScheduling() throws { - var fsrs: FSRS = fsrs(params: PartialFSRSParameters(requestRetention: 0.9)) + var fsrs = FSRS(params: PartialFSRSParameters(requestRetention: 0.9)) let card = TestCard(question: "Test", answer: "Test") _ = try fsrs.schedule(card: card, at: Date(), rating: .good) @@ -294,7 +294,7 @@ struct ParameterTests { @Test("Minimum request retention") func testMinimumRequestRetention() { let params = PartialFSRSParameters(requestRetention: 0.01) - let fsrs: FSRS = fsrs(params: params) + let fsrs = FSRS(params: params) #expect(fsrs.parameters.requestRetention > 0) } @@ -302,7 +302,7 @@ struct ParameterTests { @Test("Maximum request retention") func testMaximumRequestRetention() { let params = PartialFSRSParameters(requestRetention: 1.0) - let fsrs: FSRS = fsrs(params: params) + let fsrs = FSRS(params: params) #expect(fsrs.parameters.requestRetention <= 1) } @@ -310,7 +310,7 @@ struct ParameterTests { @Test("Small maximum interval") func testSmallMaximumInterval() { let params = PartialFSRSParameters(maximumInterval: 7) - let fsrs: FSRS = fsrs(params: params) + let fsrs = FSRS(params: params) #expect(fsrs.parameters.maximumInterval == 7) } @@ -318,7 +318,7 @@ struct ParameterTests { @Test("Large maximum interval") func testLargeMaximumInterval() { let params = PartialFSRSParameters(maximumInterval: 100_000) - let fsrs: FSRS = fsrs(params: params) + let fsrs = FSRS(params: params) #expect(fsrs.parameters.maximumInterval == 100_000) } diff --git a/Tests/FSRS/StateTransitionTests.swift b/Tests/FSRS/StateTransitionTests.swift index ce47ac0..dfd55d0 100644 --- a/Tests/FSRS/StateTransitionTests.swift +++ b/Tests/FSRS/StateTransitionTests.swift @@ -12,7 +12,7 @@ struct StateTransitionTests { enableFuzz: false, enableShortTerm: enableShortTerm ) - return fsrs(params: params) + return FSRS(params: params) } private func createCard(state: State = .new) -> TestCard { From fc28f0f3309beed687a1e7694cccc709b1609435 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Sun, 22 Feb 2026 21:26:41 +0400 Subject: [PATCH 7/9] Add focused unit tests for MemoryStateCalculator and SchedulingPipeline 21 new tests covering: - MemoryStateCalculator: init stability/difficulty ordering, forgetting curve properties, new vs existing card paths, short-term vs long-term, decay factor computation - SchedulingPipeline: elapsed days calculation, reps increment, memory state computation, retrievability, constrained interval ordering, schedule-with-interval, build log Total: 146 tests across 6 suites, all passing. --- Tests/FSRS/MemoryStateCalculatorTests.swift | 207 ++++++++++++++++++++ Tests/FSRS/SchedulingPipelineTests.swift | 164 ++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 Tests/FSRS/MemoryStateCalculatorTests.swift create mode 100644 Tests/FSRS/SchedulingPipelineTests.swift diff --git a/Tests/FSRS/MemoryStateCalculatorTests.swift b/Tests/FSRS/MemoryStateCalculatorTests.swift new file mode 100644 index 0000000..adf7e0a --- /dev/null +++ b/Tests/FSRS/MemoryStateCalculatorTests.swift @@ -0,0 +1,207 @@ +import Foundation +import Testing +@testable import FSRS + +/// Focused unit tests for MemoryStateCalculator +@Suite("MemoryStateCalculator Tests") +struct MemoryStateCalculatorTests { + private let defaultParams = FSRSParametersGenerator.generate(from: PartialFSRSParameters()) + + private func makeCalculator(params: FSRSParameters? = nil) -> MemoryStateCalculator { + MemoryStateCalculator(parameters: params ?? defaultParams) + } + + // MARK: - Initial Stability + + @Test("Init stability returns positive value for each rating") + func testInitStabilityAllRatings() throws { + let calc = makeCalculator() + + for rating in [Rating.again, .hard, .good, .easy] { + let stability = try calc.initStability(for: rating) + #expect(stability.value > 0, "Stability for \(rating) should be positive") + } + } + + @Test("Init stability increases with better ratings") + func testInitStabilityOrdering() throws { + let calc = makeCalculator() + + let againS = try calc.initStability(for: .again) + let hardS = try calc.initStability(for: .hard) + let goodS = try calc.initStability(for: .good) + let easyS = try calc.initStability(for: .easy) + + #expect(againS.value < hardS.value) + #expect(hardS.value < goodS.value) + #expect(goodS.value < easyS.value) + } + + // MARK: - Initial Difficulty + + @Test("Init difficulty returns valid range for each rating") + func testInitDifficultyAllRatings() throws { + let calc = makeCalculator() + + for rating in [Rating.again, .hard, .good, .easy] { + let difficulty = try calc.initDifficulty(for: rating) + #expect(difficulty.value >= DIFFICULTY_RANGE_MIN) + #expect(difficulty.value <= DIFFICULTY_RANGE_MAX) + } + } + + @Test("Init difficulty decreases with better ratings") + func testInitDifficultyOrdering() throws { + let calc = makeCalculator() + + let againD = try calc.initDifficulty(for: .again) + let hardD = try calc.initDifficulty(for: .hard) + let goodD = try calc.initDifficulty(for: .good) + let easyD = try calc.initDifficulty(for: .easy) + + // Easier ratings should yield lower difficulty + #expect(againD.value >= hardD.value) + #expect(hardD.value >= goodD.value) + #expect(goodD.value >= easyD.value) + } + + // MARK: - Forgetting Curve + + @Test("Forgetting curve returns 1.0 at zero elapsed days") + func testForgettingCurveAtZero() throws { + let calc = makeCalculator() + let stability = try Stability(5.0) + let elapsed = ElapsedDays.zero + + let retrievability = try calc.forgettingCurve(elapsedDays: elapsed, stability: stability) + #expect(retrievability.value == 1.0) + } + + @Test("Forgetting curve decreases over time") + func testForgettingCurveDecreases() throws { + let calc = makeCalculator() + let stability = try Stability(10.0) + + let r1 = try calc.forgettingCurve(elapsedDays: ElapsedDays(1.0), stability: stability) + let r10 = try calc.forgettingCurve(elapsedDays: ElapsedDays(10.0), stability: stability) + let r30 = try calc.forgettingCurve(elapsedDays: ElapsedDays(30.0), stability: stability) + + #expect(r1.value > r10.value) + #expect(r10.value > r30.value) + } + + @Test("Forgetting curve stays in [0, 1] range") + func testForgettingCurveRange() throws { + let calc = makeCalculator() + let stability = try Stability(1.0) + + for days in [0.0, 1.0, 10.0, 100.0, 1000.0] { + let r = try calc.forgettingCurve(elapsedDays: ElapsedDays(days), stability: stability) + #expect(r.value >= 0.0) + #expect(r.value <= 1.0) + } + } + + // MARK: - Next Memory State (New Card) + + @Test("New card memory state initializes correctly") + func testNewCardMemoryState() throws { + let calc = makeCalculator() + let card = TestCard(question: "Q", answer: "A") + + let state = try calc.nextMemoryState( + currentCard: card, + elapsedDays: .zero, + rating: .good, + enableShortTerm: true + ) + + #expect(state.stability.value > 0) + #expect(state.difficulty.value >= DIFFICULTY_RANGE_MIN) + #expect(state.difficulty.value <= DIFFICULTY_RANGE_MAX) + } + + // MARK: - Next Memory State (Existing Card) + + @Test("Recall with Good rating increases stability") + func testRecallGoodIncreasesStability() throws { + let calc = makeCalculator() + let initialStability = 5.0 + let card = TestCard( + question: "Q", answer: "A", + stability: initialStability, + difficulty: 5.0, + reps: 1 + ) + + let state = try calc.nextMemoryState( + currentCard: card, + elapsedDays: try ElapsedDays(5.0), + rating: .good, + enableShortTerm: false + ) + + #expect(state.stability.value > initialStability) + } + + @Test("Forget (Again) rating decreases stability") + func testForgetDecreasesStability() throws { + let calc = makeCalculator() + let initialStability = 10.0 + let card = TestCard( + question: "Q", answer: "A", + stability: initialStability, + difficulty: 5.0, + reps: 1 + ) + + let state = try calc.nextMemoryState( + currentCard: card, + elapsedDays: try ElapsedDays(10.0), + rating: .again, + enableShortTerm: false + ) + + #expect(state.stability.value < initialStability) + } + + @Test("Short-term path used when elapsed days is zero and enableShortTerm is true") + func testShortTermPath() throws { + let calc = makeCalculator() + let card = TestCard( + question: "Q", answer: "A", + stability: 5.0, + difficulty: 5.0, + reps: 1 + ) + + // Short-term: elapsed = 0, enableShortTerm = true + let shortTermState = try calc.nextMemoryState( + currentCard: card, + elapsedDays: .zero, + rating: .good, + enableShortTerm: true + ) + + // Long-term: elapsed > 0, enableShortTerm = false + let longTermState = try calc.nextMemoryState( + currentCard: card, + elapsedDays: try ElapsedDays(5.0), + rating: .good, + enableShortTerm: false + ) + + // They should produce different stability values + #expect(shortTermState.stability.value != longTermState.stability.value) + } + + // MARK: - Decay Factor + + @Test("Compute decay factor returns valid values") + func testDecayFactor() { + let (decay, factor) = MemoryStateCalculator.computeDecayFactor(defaultParams.weights) + + #expect(decay < 0, "Decay should be negative") + #expect(factor > 0, "Factor should be positive") + } +} diff --git a/Tests/FSRS/SchedulingPipelineTests.swift b/Tests/FSRS/SchedulingPipelineTests.swift new file mode 100644 index 0000000..6ff9aee --- /dev/null +++ b/Tests/FSRS/SchedulingPipelineTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing +@testable import FSRS + +/// Focused unit tests for SchedulingPipeline +@Suite("SchedulingPipeline Tests") +struct SchedulingPipelineTests { + private func makeAlgorithm( + params: PartialFSRSParameters = PartialFSRSParameters(enableFuzz: false) + ) -> FSRSAlgorithm { + FSRSAlgorithm(params: params) + } + + private func makePipeline( + card: TestCard, + now: Date = Date(), + params: PartialFSRSParameters = PartialFSRSParameters(enableFuzz: false) + ) -> SchedulingPipeline { + SchedulingPipeline(card: card, now: now, algorithm: makeAlgorithm(params: params)) + } + + // MARK: - Initialization + + @Test("Pipeline initializes with correct elapsed days for new card") + func testNewCardElapsedDays() { + let card = TestCard(question: "Q", answer: "A") + let pipeline = makePipeline(card: card) + + #expect(pipeline.elapsedDaysValue.value == 0.0) + } + + @Test("Pipeline calculates elapsed days for review card") + func testReviewCardElapsedDays() { + let now = Date() + let fiveDaysAgo = Calendar.current.date(byAdding: .day, value: -5, to: now)! + + let card = TestCard( + question: "Q", answer: "A", + state: .review, + lastReview: fiveDaysAgo, + stability: 5.0, + difficulty: 5.0, + reps: 1 + ) + let pipeline = makePipeline(card: card, now: now) + + #expect(pipeline.elapsedDaysValue.value >= 4.0) + #expect(pipeline.elapsedDaysValue.value <= 6.0) + } + + @Test("Pipeline increments reps on current card") + func testRepsIncremented() { + let card = TestCard(question: "Q", answer: "A", reps: 3) + let pipeline = makePipeline(card: card) + + #expect(pipeline.currentCard.reps == 4) + #expect(pipeline.lastCard.reps == 3) + } + + @Test("Pipeline sets lastReview to now") + func testLastReviewSet() { + let now = Date() + let card = TestCard(question: "Q", answer: "A") + let pipeline = makePipeline(card: card, now: now) + + #expect(pipeline.currentCard.lastReview == now) + } + + // MARK: - Memory State Computation + + @Test("Compute memory state for new card returns valid state") + func testComputeMemoryStateNewCard() throws { + let card = TestCard(question: "Q", answer: "A") + let pipeline = makePipeline(card: card) + + let state = try pipeline.computeMemoryState( + elapsedDays: .zero, + rating: .good + ) + + #expect(state.stability.value > 0) + #expect(state.difficulty.value >= 1.0) + #expect(state.difficulty.value <= 10.0) + } + + // MARK: - Retrievability + + @Test("Compute retrievability for review card") + func testComputeRetrievability() throws { + let now = Date() + let threeDaysAgo = Calendar.current.date(byAdding: .day, value: -3, to: now)! + + let card = TestCard( + question: "Q", answer: "A", + state: .review, + lastReview: threeDaysAgo, + stability: 10.0, + difficulty: 5.0, + reps: 1 + ) + let pipeline = makePipeline(card: card, now: now) + + let r = try pipeline.computeRetrievability() + #expect(r.value > 0.0) + #expect(r.value < 1.0) + } + + // MARK: - Constrained Intervals + + @Test("Constrained review intervals maintain ordering: hard <= good <= easy") + func testConstrainedIntervalsOrdering() throws { + let now = Date() + let tenDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: now)! + + let card = TestCard( + question: "Q", answer: "A", + state: .review, + lastReview: tenDaysAgo, + stability: 10.0, + difficulty: 5.0, + scheduledDays: 10, + reps: 3 + ) + let pipeline = makePipeline(card: card, now: now) + let r = try pipeline.computeRetrievability() + let intervals = try pipeline.computeConstrainedReviewIntervals(retrievability: r) + + #expect(intervals.hard <= intervals.good) + #expect(intervals.good <= intervals.easy) + } + + // MARK: - Schedule With Interval + + @Test("Schedule with interval sets card state to review") + func testScheduleWithInterval() throws { + let card = TestCard(question: "Q", answer: "A") + var pipeline = makePipeline(card: card) + + let stability = try Stability(10.0) + pipeline.scheduleWithInterval(card: &pipeline.currentCard, stability: stability) + + #expect(pipeline.currentCard.state == .review) + #expect(pipeline.currentCard.scheduledDays > 0) + #expect(pipeline.currentCard.learningSteps == 0) + } + + // MARK: - Build Log + + @Test("Build log captures correct rating and state") + func testBuildLog() { + let card = TestCard( + question: "Q", answer: "A", + state: .review, + stability: 5.0, + difficulty: 5.0, + reps: 2 + ) + let pipeline = makePipeline(card: card) + let log = pipeline.buildLog(rating: .good) + + #expect(log.rating == .good) + #expect(log.state == .review) + } +} From 17e6144becc613936e5259e194074c616c2411ce Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Sun, 22 Feb 2026 21:57:10 +0400 Subject: [PATCH 8/9] Rename PartialFSRSParameters to FSRSConfiguration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the configuration struct and init parameter label across the entire codebase: - PartialFSRSParameters → FSRSConfiguration - FSRS(params:) → FSRS(configuration:) - FSRSAlgorithm(params:) → FSRSAlgorithm(configuration:) Kept as a top-level type rather than nested inside generic FSRS to avoid requiring FSRS.Configuration in non-generic contexts. --- README.md | 4 +- Sources/FSRS/Algorithm/FSRSAlgorithm.swift | 8 +-- Sources/FSRS/FSRS.swift | 10 ++-- Sources/FSRS/Models/ParameterManagement.swift | 12 ++-- .../FSRS/Protocols/ParameterProtocols.swift | 4 +- Tests/FSRS/FSRSAPITests.swift | 8 +-- Tests/FSRS/IntegrationTests.swift | 4 +- Tests/FSRS/MemoryStateCalculatorTests.swift | 2 +- Tests/FSRS/ParameterTests.swift | 58 +++++++++---------- Tests/FSRS/SchedulingPipelineTests.swift | 8 +-- Tests/FSRS/StateTransitionTests.swift | 4 +- 11 files changed, 61 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 72f211c..f3fbe54 100644 --- a/README.md +++ b/README.md @@ -124,13 +124,13 @@ print(retrievabilityValue) // 0.9 ### Custom Parameters ```swift -let params = PartialFSRSParameters( +let config = FSRSConfiguration( requestRetention: 0.9, maximumInterval: 36500, enableFuzz: true, enableShortTerm: true ) -let f = fsrs(params: params) +let f = FSRS(configuration: config) ``` ### Reschedule with History diff --git a/Sources/FSRS/Algorithm/FSRSAlgorithm.swift b/Sources/FSRS/Algorithm/FSRSAlgorithm.swift index c72ff64..2e85a2f 100644 --- a/Sources/FSRS/Algorithm/FSRSAlgorithm.swift +++ b/Sources/FSRS/Algorithm/FSRSAlgorithm.swift @@ -14,19 +14,19 @@ open class FSRSAlgorithm: FSRSAlgorithmProtocol { /// Optional logger for debugging and monitoring public var logger: (any FSRSLogger)? - /// Initialize FSRS algorithm with parameters + /// Initialize FSRS algorithm with configuration /// - Parameters: - /// - params: Partial FSRS parameters (missing values use defaults) + /// - configuration: FSRS configuration (missing values use defaults) /// - randomProvider: Random provider (optional, uses system random if not provided) /// - logger: Optional logger for debugging and monitoring public init( - params: PartialFSRSParameters = PartialFSRSParameters(), + configuration: FSRSConfiguration = FSRSConfiguration(), randomProvider: RandomProvider? = nil, logger: (any FSRSLogger)? = nil ) { self.randomProvider = randomProvider self.logger = logger - self.parameters = FSRSParametersGenerator.generate(from: params) + self.parameters = FSRSParametersGenerator.generate(from: configuration) do { self.intervalModifier = try FSRSAlgorithm.calculateIntervalModifier( requestRetention: parameters.requestRetention, diff --git a/Sources/FSRS/FSRS.swift b/Sources/FSRS/FSRS.swift index e514824..401a07b 100644 --- a/Sources/FSRS/FSRS.swift +++ b/Sources/FSRS/FSRS.swift @@ -25,23 +25,23 @@ public struct FSRS { // MARK: - Initialization - /// Initialize FSRS with parameters + /// Initialize FSRS with configuration /// - Parameters: - /// - params: Partial FSRS parameters + /// - configuration: FSRS configuration (missing values use defaults) /// - randomProvider: Random provider (optional, uses system random if not provided) /// - logger: Optional logger for debugging and monitoring /// - schedulerFactory: Optional scheduler factory (defaults to FSRSSchedulerFactory) public init( - params: PartialFSRSParameters = PartialFSRSParameters(), + configuration: FSRSConfiguration = FSRSConfiguration(), randomProvider: RandomProvider? = nil, logger: (any FSRSLogger)? = nil, schedulerFactory: (any SchedulerFactory)? = nil ) { - let finalParams = FSRSParametersGenerator.generate(from: params) + let finalParams = FSRSParametersGenerator.generate(from: configuration) self.useShortTerm = finalParams.enableShortTerm self.logger = logger self.algorithm = FSRSAlgorithm( - params: params, + configuration: configuration, randomProvider: randomProvider, logger: logger ) diff --git a/Sources/FSRS/Models/ParameterManagement.swift b/Sources/FSRS/Models/ParameterManagement.swift index 70069d3..c63aa9d 100644 --- a/Sources/FSRS/Models/ParameterManagement.swift +++ b/Sources/FSRS/Models/ParameterManagement.swift @@ -1,7 +1,7 @@ import Foundation -/// Partial FSRS parameters for initialization -public struct PartialFSRSParameters { +/// FSRS configuration with optional overrides (missing values use defaults) +public struct FSRSConfiguration { public var requestRetention: Double? public var maximumInterval: Int? public var weights: [Double]? @@ -33,11 +33,11 @@ public struct PartialFSRSParameters { public enum FSRSParametersGenerator { private static let generator: ParameterGenerator = DefaultParameterGenerator() - /// Generate FSRS parameters from partial parameters - /// - Parameter props: Partial parameters + /// Generate FSRS parameters from configuration + /// - Parameter configuration: FSRS configuration with optional overrides /// - Returns: Complete FSRS parameters - public static func generate(from props: PartialFSRSParameters) -> FSRSParameters { - generator.generate(from: props) + public static func generate(from configuration: FSRSConfiguration) -> FSRSParameters { + generator.generate(from: configuration) } private static let validator: ParameterValidator = DefaultParameterValidator() diff --git a/Sources/FSRS/Protocols/ParameterProtocols.swift b/Sources/FSRS/Protocols/ParameterProtocols.swift index da9608b..3baf740 100644 --- a/Sources/FSRS/Protocols/ParameterProtocols.swift +++ b/Sources/FSRS/Protocols/ParameterProtocols.swift @@ -21,7 +21,7 @@ public protocol ParameterGenerator { /// Generate complete parameters from partial parameters /// - Parameter partial: Partial parameters with optional values /// - Returns: Complete FSRS parameters with all defaults filled in - func generate(from partial: PartialFSRSParameters) -> FSRSParameters + func generate(from partial: FSRSConfiguration) -> FSRSParameters } /// Protocol for parameter migration @@ -164,7 +164,7 @@ public struct DefaultParameterGenerator: ParameterGenerator { self.migrator = migrator } - public func generate(from props: PartialFSRSParameters) -> FSRSParameters { + public func generate(from props: FSRSConfiguration) -> FSRSParameters { let learningSteps = props.learningSteps ?? defaultLearningSteps let relearningSteps = props.relearningSteps ?? defaultRelearningSteps let enableShortTerm = props.enableShortTerm ?? defaultEnableShortTerm diff --git a/Tests/FSRS/FSRSAPITests.swift b/Tests/FSRS/FSRSAPITests.swift index 8dcb3f8..e2ca780 100644 --- a/Tests/FSRS/FSRSAPITests.swift +++ b/Tests/FSRS/FSRSAPITests.swift @@ -8,13 +8,13 @@ struct FSRSAPITests { private func createFSRS(enableFuzz: Bool = false, enableShortTerm: Bool = true) -> FSRS< TestCard > { - let params = PartialFSRSParameters( + let params = FSRSConfiguration( requestRetention: 0.9, maximumInterval: 36_500, enableFuzz: enableFuzz, enableShortTerm: enableShortTerm ) - return FSRS(params: params, logger: ConsoleLogger()) + return FSRS(configuration: params, logger: ConsoleLogger()) } private func createCard() -> TestCard { @@ -497,12 +497,12 @@ struct FSRSAPITests { @Test("FSRS uses custom parameters") func testCustomParameters() { - let customParams = PartialFSRSParameters( + let customParams = FSRSConfiguration( requestRetention: 0.85, maximumInterval: 365, enableFuzz: false ) - let fsrs = FSRS(params: customParams) + let fsrs = FSRS(configuration: customParams) #expect(fsrs.parameters.requestRetention == 0.85) #expect(fsrs.parameters.maximumInterval == 365) diff --git a/Tests/FSRS/IntegrationTests.swift b/Tests/FSRS/IntegrationTests.swift index 6da4986..8e11a57 100644 --- a/Tests/FSRS/IntegrationTests.swift +++ b/Tests/FSRS/IntegrationTests.swift @@ -9,12 +9,12 @@ struct IntegrationTests { // MARK: - Helper Functions private func createFSRS(enableFuzz: Bool = false) -> FSRS { - let params = PartialFSRSParameters( + let params = FSRSConfiguration( requestRetention: 0.9, maximumInterval: 36_500, enableFuzz: enableFuzz ) - return FSRS(params: params, logger: ConsoleLogger()) + return FSRS(configuration: params, logger: ConsoleLogger()) } private func createCard() -> TestCard { diff --git a/Tests/FSRS/MemoryStateCalculatorTests.swift b/Tests/FSRS/MemoryStateCalculatorTests.swift index adf7e0a..28d5109 100644 --- a/Tests/FSRS/MemoryStateCalculatorTests.swift +++ b/Tests/FSRS/MemoryStateCalculatorTests.swift @@ -5,7 +5,7 @@ import Testing /// Focused unit tests for MemoryStateCalculator @Suite("MemoryStateCalculator Tests") struct MemoryStateCalculatorTests { - private let defaultParams = FSRSParametersGenerator.generate(from: PartialFSRSParameters()) + private let defaultParams = FSRSParametersGenerator.generate(from: FSRSConfiguration()) private func makeCalculator(params: FSRSParameters? = nil) -> MemoryStateCalculator { MemoryStateCalculator(parameters: params ?? defaultParams) diff --git a/Tests/FSRS/ParameterTests.swift b/Tests/FSRS/ParameterTests.swift index e4f5581..ed83584 100644 --- a/Tests/FSRS/ParameterTests.swift +++ b/Tests/FSRS/ParameterTests.swift @@ -9,7 +9,7 @@ struct ParameterTests { @Test("Generate parameters with all defaults") func testGenerateDefaultParameters() { - let params = PartialFSRSParameters() + let params = FSRSConfiguration() let generated = FSRSParametersGenerator.generate(from: params) #expect(generated.weights.count == 21) // FSRS-6 has 21 parameters @@ -19,7 +19,7 @@ struct ParameterTests { @Test("Generate parameters with custom request retention") func testCustomRequestRetention() { - let params = PartialFSRSParameters(requestRetention: 0.85) + let params = FSRSConfiguration(requestRetention: 0.85) let generated = FSRSParametersGenerator.generate(from: params) #expect(generated.requestRetention == 0.85) @@ -27,7 +27,7 @@ struct ParameterTests { @Test("Generate parameters with custom maximum interval") func testCustomMaximumInterval() { - let params = PartialFSRSParameters(maximumInterval: 180) + let params = FSRSConfiguration(maximumInterval: 180) let generated = FSRSParametersGenerator.generate(from: params) #expect(generated.maximumInterval == 180) @@ -36,7 +36,7 @@ struct ParameterTests { @Test("Generate parameters with custom weights") func testCustomWeights() { let customWeights = Array(repeating: 1.0, count: 21) - let params = PartialFSRSParameters(weights: customWeights) + let params = FSRSConfiguration(weights: customWeights) let generated = FSRSParametersGenerator.generate(from: params) // Custom weights are provided and should have 21 elements @@ -46,7 +46,7 @@ struct ParameterTests { @Test("Generate parameters with fuzz disabled") func testDisableFuzz() { - let params = PartialFSRSParameters(enableFuzz: false) + let params = FSRSConfiguration(enableFuzz: false) let generated = FSRSParametersGenerator.generate(from: params) #expect(generated.enableFuzz == false) @@ -54,7 +54,7 @@ struct ParameterTests { @Test("Generate parameters with short-term disabled") func testDisableShortTerm() { - let params = PartialFSRSParameters(enableShortTerm: false) + let params = FSRSConfiguration(enableShortTerm: false) let generated = FSRSParametersGenerator.generate(from: params) #expect(generated.enableShortTerm == false) @@ -63,7 +63,7 @@ struct ParameterTests { @Test("Generate parameters with custom learning steps") func testCustomLearningSteps() { let steps = [StepUnit(value: 1, unit: .minutes), StepUnit(value: 10, unit: .minutes)] - let params = PartialFSRSParameters(learningSteps: steps) + let params = FSRSConfiguration(learningSteps: steps) let generated = FSRSParametersGenerator.generate(from: params) #expect(generated.learningSteps == steps) @@ -72,7 +72,7 @@ struct ParameterTests { @Test("Generate parameters with custom relearning steps") func testCustomRelearningSteps() { let steps = [StepUnit(value: 5, unit: .minutes)] - let params = PartialFSRSParameters(relearningSteps: steps) + let params = FSRSConfiguration(relearningSteps: steps) let generated = FSRSParametersGenerator.generate(from: params) #expect(generated.relearningSteps == steps) @@ -144,7 +144,7 @@ struct ParameterTests { @Test("Request retention must be valid range") func testRequestRetentionRange() throws { - let fsrs = FSRS(params: PartialFSRSParameters(requestRetention: 0.9)) + let fsrs = FSRS(configuration: FSRSConfiguration(requestRetention: 0.9)) #expect(fsrs.parameters.requestRetention > 0) #expect(fsrs.parameters.requestRetention <= 1) @@ -152,7 +152,7 @@ struct ParameterTests { @Test("Maximum interval must be positive") func testMaximumIntervalPositive() { - let fsrs = FSRS(params: PartialFSRSParameters(maximumInterval: 365)) + let fsrs = FSRS(configuration: FSRSConfiguration(maximumInterval: 365)) #expect(fsrs.parameters.maximumInterval > 0) } @@ -212,11 +212,11 @@ struct ParameterTests { StepUnit(value: 1, unit: .minutes), StepUnit(value: 10, unit: .minutes) ] - let params = PartialFSRSParameters( + let params = FSRSConfiguration( enableShortTerm: true, learningSteps: steps ) - let fsrs = FSRS(params: params) + let fsrs = FSRS(configuration: params) let card = TestCard(question: "Test", answer: "Test") _ = try fsrs.schedule(card: card, at: Date()) @@ -228,22 +228,22 @@ struct ParameterTests { @Test("Relearning steps affect failed card scheduling") func testRelearningStepsAffectScheduling() throws { let steps = [StepUnit(value: 5, unit: .minutes)] - let params = PartialFSRSParameters( + let params = FSRSConfiguration( enableShortTerm: true, relearningSteps: steps ) - let fsrs = FSRS(params: params) + let fsrs = FSRS(configuration: params) #expect(fsrs.parameters.relearningSteps == steps) } @Test("Empty learning steps works correctly") func testEmptyLearningSteps() throws { - let params = PartialFSRSParameters( + let params = FSRSConfiguration( enableShortTerm: true, learningSteps: [] ) - let fsrs = FSRS(params: params) + let fsrs = FSRS(configuration: params) let card = TestCard(question: "Test", answer: "Test") let result = try fsrs.schedule(card: card, at: Date(), rating: .good) @@ -256,8 +256,8 @@ struct ParameterTests { @Test("Fuzz disabled produces consistent intervals") func testFuzzDisabledConsistency() throws { - let params = PartialFSRSParameters(enableFuzz: false) - let fsrs = FSRS(params: params) + let params = FSRSConfiguration(enableFuzz: false) + let fsrs = FSRS(configuration: params) let card = TestCard(question: "Test", answer: "Test") let now = Date() @@ -273,7 +273,7 @@ struct ParameterTests { @Test("Update parameters affects future scheduling") func testUpdateParametersAffectsScheduling() throws { - var fsrs = FSRS(params: PartialFSRSParameters(requestRetention: 0.9)) + var fsrs = FSRS(configuration: FSRSConfiguration(requestRetention: 0.9)) let card = TestCard(question: "Test", answer: "Test") _ = try fsrs.schedule(card: card, at: Date(), rating: .good) @@ -293,32 +293,32 @@ struct ParameterTests { @Test("Minimum request retention") func testMinimumRequestRetention() { - let params = PartialFSRSParameters(requestRetention: 0.01) - let fsrs = FSRS(params: params) + let params = FSRSConfiguration(requestRetention: 0.01) + let fsrs = FSRS(configuration: params) #expect(fsrs.parameters.requestRetention > 0) } @Test("Maximum request retention") func testMaximumRequestRetention() { - let params = PartialFSRSParameters(requestRetention: 1.0) - let fsrs = FSRS(params: params) + let params = FSRSConfiguration(requestRetention: 1.0) + let fsrs = FSRS(configuration: params) #expect(fsrs.parameters.requestRetention <= 1) } @Test("Small maximum interval") func testSmallMaximumInterval() { - let params = PartialFSRSParameters(maximumInterval: 7) - let fsrs = FSRS(params: params) + let params = FSRSConfiguration(maximumInterval: 7) + let fsrs = FSRS(configuration: params) #expect(fsrs.parameters.maximumInterval == 7) } @Test("Large maximum interval") func testLargeMaximumInterval() { - let params = PartialFSRSParameters(maximumInterval: 100_000) - let fsrs = FSRS(params: params) + let params = FSRSConfiguration(maximumInterval: 100_000) + let fsrs = FSRS(configuration: params) #expect(fsrs.parameters.maximumInterval == 100_000) } @@ -333,7 +333,7 @@ struct ParameterTests { StepUnit(value: 1, unit: .hours), StepUnit(value: 1, unit: .days) ] - let params = PartialFSRSParameters(learningSteps: steps) + let params = FSRSConfiguration(learningSteps: steps) let generated = FSRSParametersGenerator.generate(from: params) #expect(generated.learningSteps.count == 4) @@ -350,7 +350,7 @@ struct ParameterTests { StepUnit(value: 5, unit: .minutes), StepUnit(value: 10, unit: .minutes) ] - let params = PartialFSRSParameters(learningSteps: steps) + let params = FSRSConfiguration(learningSteps: steps) let generated = FSRSParametersGenerator.generate(from: params) for i in 0.. FSRSAlgorithm { - FSRSAlgorithm(params: params) + FSRSAlgorithm(configuration: configuration) } private func makePipeline( card: TestCard, now: Date = Date(), - params: PartialFSRSParameters = PartialFSRSParameters(enableFuzz: false) + configuration: FSRSConfiguration = FSRSConfiguration(enableFuzz: false) ) -> SchedulingPipeline { - SchedulingPipeline(card: card, now: now, algorithm: makeAlgorithm(params: params)) + SchedulingPipeline(card: card, now: now, algorithm: makeAlgorithm(configuration: configuration)) } // MARK: - Initialization diff --git a/Tests/FSRS/StateTransitionTests.swift b/Tests/FSRS/StateTransitionTests.swift index dfd55d0..e2bc09b 100644 --- a/Tests/FSRS/StateTransitionTests.swift +++ b/Tests/FSRS/StateTransitionTests.swift @@ -8,11 +8,11 @@ struct StateTransitionTests { // MARK: - Helper Functions private func createFSRS(enableShortTerm: Bool = false) -> FSRS { - let params = PartialFSRSParameters( + let params = FSRSConfiguration( enableFuzz: false, enableShortTerm: enableShortTerm ) - return FSRS(params: params) + return FSRS(configuration: params) } private func createCard(state: State = .new) -> TestCard { From 95e7c6f7fb04cd671d5e224733bb939498885c50 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Sun, 22 Feb 2026 22:23:24 +0400 Subject: [PATCH 9/9] Fix SwiftLint violations in new test and scheduler files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename short variable names (r, r1 → retrievability, ret1) - Replace force unwraps with nil-coalescing in date arithmetic - Fix multiline argument formatting (one arg per line) - Remove superfluous swiftlint disable commands in ShortTermScheduler --- .../FSRS/Scheduling/ShortTermScheduler.swift | 2 -- Tests/FSRS/MemoryStateCalculatorTests.swift | 28 +++++++++++-------- Tests/FSRS/SchedulingPipelineTests.swift | 28 +++++++++++-------- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/Sources/FSRS/Scheduling/ShortTermScheduler.swift b/Sources/FSRS/Scheduling/ShortTermScheduler.swift index e522623..43aea8b 100644 --- a/Sources/FSRS/Scheduling/ShortTermScheduler.swift +++ b/Sources/FSRS/Scheduling/ShortTermScheduler.swift @@ -61,7 +61,6 @@ public struct ShortTermScheduler: SchedulerProtocol { return RecordLogItem(card: nextCard, log: pipeline.buildLog(rating: rating)) } - // swiftlint:disable:next function_body_length public func learningState(rating: Rating) throws -> RecordLogItem { pipeline.logger?.debug("Short-term learning: rating=\(rating), currentState=\(pipeline.lastCard.state)") @@ -86,7 +85,6 @@ public struct ShortTermScheduler: SchedulerProtocol { return RecordLogItem(card: nextCard, log: pipeline.buildLog(rating: rating)) } - // swiftlint:disable:next function_body_length public func reviewState(rating: Rating) throws -> RecordLogItem { pipeline.logger?.debug("Short-term review: rating=\(rating)") diff --git a/Tests/FSRS/MemoryStateCalculatorTests.swift b/Tests/FSRS/MemoryStateCalculatorTests.swift index 28d5109..edaec10 100644 --- a/Tests/FSRS/MemoryStateCalculatorTests.swift +++ b/Tests/FSRS/MemoryStateCalculatorTests.swift @@ -82,12 +82,12 @@ struct MemoryStateCalculatorTests { let calc = makeCalculator() let stability = try Stability(10.0) - let r1 = try calc.forgettingCurve(elapsedDays: ElapsedDays(1.0), stability: stability) - let r10 = try calc.forgettingCurve(elapsedDays: ElapsedDays(10.0), stability: stability) - let r30 = try calc.forgettingCurve(elapsedDays: ElapsedDays(30.0), stability: stability) + let ret1 = try calc.forgettingCurve(elapsedDays: ElapsedDays(1.0), stability: stability) + let ret10 = try calc.forgettingCurve(elapsedDays: ElapsedDays(10.0), stability: stability) + let ret30 = try calc.forgettingCurve(elapsedDays: ElapsedDays(30.0), stability: stability) - #expect(r1.value > r10.value) - #expect(r10.value > r30.value) + #expect(ret1.value > ret10.value) + #expect(ret10.value > ret30.value) } @Test("Forgetting curve stays in [0, 1] range") @@ -96,9 +96,12 @@ struct MemoryStateCalculatorTests { let stability = try Stability(1.0) for days in [0.0, 1.0, 10.0, 100.0, 1000.0] { - let r = try calc.forgettingCurve(elapsedDays: ElapsedDays(days), stability: stability) - #expect(r.value >= 0.0) - #expect(r.value <= 1.0) + let retrievability = try calc.forgettingCurve( + elapsedDays: ElapsedDays(days), + stability: stability + ) + #expect(retrievability.value >= 0.0) + #expect(retrievability.value <= 1.0) } } @@ -128,7 +131,8 @@ struct MemoryStateCalculatorTests { let calc = makeCalculator() let initialStability = 5.0 let card = TestCard( - question: "Q", answer: "A", + question: "Q", + answer: "A", stability: initialStability, difficulty: 5.0, reps: 1 @@ -149,7 +153,8 @@ struct MemoryStateCalculatorTests { let calc = makeCalculator() let initialStability = 10.0 let card = TestCard( - question: "Q", answer: "A", + question: "Q", + answer: "A", stability: initialStability, difficulty: 5.0, reps: 1 @@ -169,7 +174,8 @@ struct MemoryStateCalculatorTests { func testShortTermPath() throws { let calc = makeCalculator() let card = TestCard( - question: "Q", answer: "A", + question: "Q", + answer: "A", stability: 5.0, difficulty: 5.0, reps: 1 diff --git a/Tests/FSRS/SchedulingPipelineTests.swift b/Tests/FSRS/SchedulingPipelineTests.swift index 29f5e9a..55e913f 100644 --- a/Tests/FSRS/SchedulingPipelineTests.swift +++ b/Tests/FSRS/SchedulingPipelineTests.swift @@ -32,10 +32,11 @@ struct SchedulingPipelineTests { @Test("Pipeline calculates elapsed days for review card") func testReviewCardElapsedDays() { let now = Date() - let fiveDaysAgo = Calendar.current.date(byAdding: .day, value: -5, to: now)! + let fiveDaysAgo = Calendar.current.date(byAdding: .day, value: -5, to: now) ?? now let card = TestCard( - question: "Q", answer: "A", + question: "Q", + answer: "A", state: .review, lastReview: fiveDaysAgo, stability: 5.0, @@ -88,10 +89,11 @@ struct SchedulingPipelineTests { @Test("Compute retrievability for review card") func testComputeRetrievability() throws { let now = Date() - let threeDaysAgo = Calendar.current.date(byAdding: .day, value: -3, to: now)! + let threeDaysAgo = Calendar.current.date(byAdding: .day, value: -3, to: now) ?? now let card = TestCard( - question: "Q", answer: "A", + question: "Q", + answer: "A", state: .review, lastReview: threeDaysAgo, stability: 10.0, @@ -100,9 +102,9 @@ struct SchedulingPipelineTests { ) let pipeline = makePipeline(card: card, now: now) - let r = try pipeline.computeRetrievability() - #expect(r.value > 0.0) - #expect(r.value < 1.0) + let retrievability = try pipeline.computeRetrievability() + #expect(retrievability.value > 0.0) + #expect(retrievability.value < 1.0) } // MARK: - Constrained Intervals @@ -110,10 +112,11 @@ struct SchedulingPipelineTests { @Test("Constrained review intervals maintain ordering: hard <= good <= easy") func testConstrainedIntervalsOrdering() throws { let now = Date() - let tenDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: now)! + let tenDaysAgo = Calendar.current.date(byAdding: .day, value: -10, to: now) ?? now let card = TestCard( - question: "Q", answer: "A", + question: "Q", + answer: "A", state: .review, lastReview: tenDaysAgo, stability: 10.0, @@ -122,8 +125,8 @@ struct SchedulingPipelineTests { reps: 3 ) let pipeline = makePipeline(card: card, now: now) - let r = try pipeline.computeRetrievability() - let intervals = try pipeline.computeConstrainedReviewIntervals(retrievability: r) + let retrievability = try pipeline.computeRetrievability() + let intervals = try pipeline.computeConstrainedReviewIntervals(retrievability: retrievability) #expect(intervals.hard <= intervals.good) #expect(intervals.good <= intervals.easy) @@ -149,7 +152,8 @@ struct SchedulingPipelineTests { @Test("Build log captures correct rating and state") func testBuildLog() { let card = TestCard( - question: "Q", answer: "A", + question: "Q", + answer: "A", state: .review, stability: 5.0, difficulty: 5.0,