import Foundation // Pure-Swift-Port von ts-fsrs v5.3.2 (FSRS-6.0), byte-paritätisch zur // Web-Berechnung in @wordeck/domain. Spec + Herkunft: ../../FSRS_PORT.md. // Parität abgesichert über Tests/UnitTests/Fixtures/fsrs_golden.json // (echte ts-fsrs-Outputs). Fuzz ist absichtlich nicht portiert — wordeck // nutzt `enable_fuzz: false` (Default). enum FSRSRating: Int { case again = 1, hard = 2, good = 3, easy = 4 } enum FSRSState: Int { case new = 0, learning = 1, review = 2, relearning = 3 /// Wire-Name wie in @wordeck/domain (`STATE_FROM_FSRS`). var wireName: String { switch self { case .new: "new" case .learning: "learning" case .review: "review" case .relearning: "relearning" } } } /// Arbeits-Karte — 1:1 die Felder einer ts-fsrs `Card`. struct FSRSCard: Equatable { var due: Date var stability: Double var difficulty: Double var elapsedDays: Int var scheduledDays: Int var learningSteps: Int var reps: Int var lapses: Int var state: FSRSState var lastReview: Date? /// `createEmptyCard(now)` — neue Karte. static func empty(now: Date) -> FSRSCard { FSRSCard( due: now, stability: 0, difficulty: 0, elapsedDays: 0, scheduledDays: 0, learningSteps: 0, reps: 0, lapses: 0, state: .new, lastReview: nil ) } } /// Per-Deck-FSRS-Settings (Overrides). `w` muss 21 Werte haben (FSRS-6); /// 19er-Arrays (FSRS-5) müssten vorher migriert werden (siehe FSRS_PORT.md). struct FSRSSettings { var requestRetention: Double = 0.9 var maximumInterval: Int = 36500 var w: [Double] = FSRS.defaultW var enableShortTerm: Bool = true var learningSteps: [String] = ["1m", "10m"] var relearningSteps: [String] = ["10m"] } /// FSRS-6-Scheduler. `grade(card:rating:now:)` ist der einzige Eingang. struct FSRS { static let defaultW: [Double] = [ 0.212, 1.2931, 2.3065, 8.2956, 6.4133, 0.8334, 3.0194, 0.001, 1.8722, 0.1666, 0.796, 1.4835, 0.0614, 0.2629, 1.6483, 0.6014, 1.8729, 0.5425, 0.0912, 0.0658, 0.1542 ] private static let sMin = 0.001 private static let sMax = 36500.0 let s: FSRSSettings private let decay: Double private let factor: Double private let intervalModifier: Double init(_ settings: FSRSSettings = FSRSSettings()) { var resolved = settings // FSRS-5-Settings haben 19 Gewichte → auf FSRS-6 (21) migrieren, // sonst crasht `w[20]`. Bei jeder anderen unerwarteten Länge sicher // auf die Defaults zurückfallen. if resolved.w.count == 19 { resolved.w.append(contentsOf: [0.0658, 0.1542]) } if resolved.w.count != 21 { resolved.w = Self.defaultW } s = resolved decay = -resolved.w[20] factor = Self.roundTo(exp((1.0 / decay) * log(0.9)) - 1, 8) intervalModifier = Self.roundTo((pow(resolved.requestRetention, 1.0 / decay) - 1) / factor, 8) } // MARK: - Public API /// Wendet ein Rating an und liefert die nächste Karte (entspricht /// `scheduler.next(card, now, rating)` in ts-fsrs). func grade(card: FSRSCard, rating: FSRSRating, now: Date) -> FSRSCard { // init() aus AbstractScheduler. let last = card var current = card let interval: Int = (current.state != .new && current.lastReview != nil) ? Self.dateDiffInDays(current.lastReview!, now) : 0 current.lastReview = now current.elapsedDays = interval current.reps += 1 switch last.state { case .new: return newState(current: current, now: now, elapsed: interval, grade: rating, toState: .learning) case .learning, .relearning: return newState(current: current, now: now, elapsed: interval, grade: rating, toState: last.state) case .review: return reviewState(current: current, now: now, grade: rating) } } // MARK: - Scheduler-Pfade private func newState( current: FSRSCard, now: Date, elapsed: Int, grade: FSRSRating, toState: FSRSState ) -> FSRSCard { var next = nextDS(current: current, t: Double(elapsed), grade: grade, r: nil) applyLearningSteps(&next, current: current, now: now, elapsed: elapsed, grade: grade, toState: toState) return next } private func reviewState(current: FSRSCard, now: Date, grade: FSRSRating) -> FSRSCard { let interval = current.elapsedDays let r = forgettingCurve(t: Double(interval), s: current.stability) if grade == .again { var nextAgain = nextDS(current: current, t: Double(interval), grade: .again, r: r) applyLearningSteps( &nextAgain, current: current, now: now, elapsed: interval, grade: .again, toState: .relearning ) nextAgain.lapses += 1 return nextAgain } // Hard/Good/Easy: gemeinsam berechnen (Intervall-Interdependenz). var nextHard = nextDS(current: current, t: Double(interval), grade: .hard, r: r) var nextGood = nextDS(current: current, t: Double(interval), grade: .good, r: r) var nextEasy = nextDS(current: current, t: Double(interval), grade: .easy, r: r) reviewNextInterval(&nextHard, &nextGood, &nextEasy, now: now, elapsed: interval) nextHard.state = .review nextHard.learningSteps = 0 nextGood.state = .review nextGood.learningSteps = 0 nextEasy.state = .review nextEasy.learningSteps = 0 switch grade { case .hard: return nextHard case .good: return nextGood case .easy: return nextEasy case .again: return nextHard // unerreichbar } } /// `next_ds`: neue d/s aus `next_state`, Rest aus `current` übernommen. private func nextDS(current: FSRSCard, t: Double, grade: FSRSRating, r: Double?) -> FSRSCard { let (d, st) = nextState(d: current.difficulty, s: current.stability, t: t, g: grade, r: r) var card = current card.difficulty = d card.stability = st return card } /// `next_interval` für Review (Hard/Good/Easy) inkl. Interdependenz. private func reviewNextInterval( _ nextHard: inout FSRSCard, _ nextGood: inout FSRSCard, _ nextEasy: inout FSRSCard, now: Date, elapsed _: Int ) { var hard = nextInterval(stability: nextHard.stability) let goodRaw = nextInterval(stability: nextGood.stability) hard = min(hard, goodRaw) let good = max(goodRaw, hard + 1) let easy = max(nextInterval(stability: nextEasy.stability), good + 1) nextHard.scheduledDays = hard nextHard.due = Self.dateScheduler(now, hard, isDay: true) nextGood.scheduledDays = good nextGood.due = Self.dateScheduler(now, good, isDay: true) nextEasy.scheduledDays = easy nextEasy.due = Self.dateScheduler(now, easy, isDay: true) } // swiftlint:disable:next function_parameter_count private func applyLearningSteps( _ nextCard: inout FSRSCard, current: FSRSCard, now: Date, elapsed _: Int, grade: FSRSRating, toState: FSRSState ) { let (scheduledMinutes, nextSteps) = learningInfo( state: current.state, curStep: current.learningSteps, grade: grade ) if scheduledMinutes > 0, scheduledMinutes < 1440 { nextCard.learningSteps = nextSteps nextCard.scheduledDays = 0 nextCard.state = toState nextCard.due = Self.dateScheduler(now, Int(Self.jsRound(Double(scheduledMinutes))), isDay: false) } else if scheduledMinutes >= 1440 { nextCard.state = .review nextCard.learningSteps = nextSteps nextCard.due = Self.dateScheduler(now, Int(Self.jsRound(Double(scheduledMinutes))), isDay: false) nextCard.scheduledDays = Int((Double(scheduledMinutes) / 1440).rounded(.down)) } else { nextCard.state = .review nextCard.learningSteps = 0 let interval = nextInterval(stability: nextCard.stability) nextCard.scheduledDays = interval nextCard.due = Self.dateScheduler(now, interval, isDay: true) } } /// `BasicLearningStepsStrategy` → (scheduled_minutes, next_step) für `grade`. private func learningInfo(state: FSRSState, curStep: Int, grade: FSRSRating) -> (Int, Int) { let steps = (state == .relearning || state == .review) ? s.relearningSteps : s.learningSteps let count = steps.count if count == 0 || curStep >= count { return (0, 0) } let firstMin = Self.stepMinutes(steps[0]) if state == .review { // Nur Again ist gesetzt. guard grade == .again else { return (0, 0) } return (Self.stepMinutes(steps[max(0, curStep)]), 0) } switch grade { case .again: return (firstMin, 0) case .hard: let hard: Int = count == 1 ? Int(Self.jsRound(Double(firstMin) * 1.5)) : Int(Self.jsRound(Double(firstMin + Self.stepMinutes(steps[1])) / 2.0)) return (hard, curStep) case .good: let nextIdx = curStep + 1 if nextIdx >= 0, nextIdx < count { let m = Self.stepMinutes(steps[nextIdx]) if m != 0 { return (Int(Self.jsRound(Double(m))), nextIdx) } } return (0, 0) // kein nächster Step → graduieren case .easy: return (0, 0) // Easy nie in der Strategie → graduieren via next_interval } } // MARK: - FSRSAlgorithm (DSR-Memory) private func nextInterval(stability: Double) -> Int { let ivl = Int(Self.jsRound(stability * intervalModifier)) return min(max(1, ivl), s.maximumInterval) } private func forgettingCurve(t: Double, s stability: Double) -> Double { Self.roundTo(pow(1 + factor * t / stability, decay), 8) } private func initStability(_ g: FSRSRating) -> Double { max(s.w[g.rawValue - 1], 0.1) } private func initDifficulty(_ g: FSRSRating) -> Double { Self.roundTo(s.w[4] - exp(Double(g.rawValue - 1) * s.w[5]) + 1, 8) } private func nextDifficulty(_ d: Double, _ g: FSRSRating) -> Double { let deltaD = -s.w[6] * Double(g.rawValue - 3) let damped = Self.roundTo(deltaD * (10 - d) / 9, 8) let nextD = d + damped let easyInit = initDifficulty(.easy) let reverted = Self.roundTo(s.w[7] * easyInit + (1 - s.w[7]) * nextD, 8) return Self.clamp(reverted, 1, 10) } private func nextRecallStability(_ d: Double, _ st: Double, _ r: Double, _ g: FSRSRating) -> Double { let hardPenalty = g == .hard ? s.w[15] : 1 let easyBound = g == .easy ? s.w[16] : 1 let value = st * (1 + exp(s.w[8]) * (11 - d) * pow(st, -s.w[9]) * (exp((1 - r) * s.w[10]) - 1) * hardPenalty * easyBound) return Self.roundTo(Self.clamp(value, Self.sMin, Self.sMax), 8) } private func nextForgetStability(_ d: Double, _ st: Double, _ r: Double) -> Double { let value = s.w[11] * pow(d, -s.w[12]) * (pow(st + 1, s.w[13]) - 1) * exp((1 - r) * s.w[14]) return Self.roundTo(Self.clamp(value, Self.sMin, Self.sMax), 8) } private func nextShortTermStability(_ st: Double, _ g: FSRSRating) -> Double { let sinc = pow(st, -s.w[19]) * exp(s.w[17] * (Double(g.rawValue) - 3 + s.w[18])) let masked = g.rawValue >= FSRSRating.hard.rawValue ? max(sinc, 1) : sinc return Self.roundTo(Self.clamp(st * masked, Self.sMin, Self.sMax), 8) } /// `next_state` — d/s-Update. `r` wird aus der Forgetting-Curve berechnet, /// falls nicht übergeben. private func nextState(d: Double, s st: Double, t: Double, g: FSRSRating, r: Double?) -> (Double, Double) { if d == 0, st == 0 { return (Self.clamp(initDifficulty(g), 1, 10), initStability(g)) } let rr = r ?? forgettingCurve(t: t, s: st) let newS: Double if t == 0, s.enableShortTerm { newS = nextShortTermStability(st, g) } else if g == .again { let sAfterFail = nextForgetStability(d, st, rr) let w17 = s.enableShortTerm ? s.w[17] : 0 let w18 = s.enableShortTerm ? s.w[18] : 0 let sMinFail = st / exp(w17 * w18) newS = Self.clamp(Self.roundTo(sMinFail, 8), Self.sMin, sAfterFail) } else { newS = nextRecallStability(d, st, rr, g) } return (nextDifficulty(d, g), newS) } // MARK: - Helfer (JS-Parität) /// JS `Math.round` = `floor(x + 0.5)` (rundet .5 Richtung +∞, nicht weg von 0). static func jsRound(_ x: Double) -> Double { (x + 0.5).rounded(.down) } static func roundTo(_ num: Double, _ decimals: Int) -> Double { let f = pow(10.0, Double(decimals)) return jsRound(num * f) / f } static func clamp(_ value: Double, _ lo: Double, _ hi: Double) -> Double { min(max(value, lo), hi) } static func stepMinutes(_ step: String) -> Int { let unit = step.suffix(1) let value = Int(step.dropLast()) ?? 0 switch unit { case "m": return value case "h": return value * 60 case "d": return value * 1440 default: return value } } static func dateScheduler(_ now: Date, _ t: Int, isDay: Bool) -> Date { let ms = isDay ? Double(t) * 86_400_000 : Double(t) * 60000 return Date(timeIntervalSince1970: now.timeIntervalSince1970 + ms / 1000) } /// Kalender-Tage-Differenz an UTC-Mitternacht (wie ts-fsrs). static func dateDiffInDays(_ last: Date, _ cur: Date) -> Int { var cal = Calendar(identifier: .gregorian) cal.timeZone = TimeZone(identifier: "UTC")! let l = cal.startOfDay(for: last) let c = cal.startOfDay(for: cur) return Int(((c.timeIntervalSince1970 - l.timeIntervalSince1970) / 86400).rounded(.down)) } }