wordeck-native/Sources/Core/Domain/FSRSScheduler.swift
Till JS fbd758d96e fix(fsrs): 19-Gewichte (FSRS-5) migrieren statt w[20]-Crash
FSRS.init: 19-Element-w wird auf 21 erweitert (FSRS-6-Decay-Defaults),
unerwartete Längen fallen sicher auf defaultW zurück. Verhindert
Index-Crash, falls ein Deck FSRS-5-Settings mit 19 Gewichten liefert.
Test mit 19-Gewichte-Settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:50:05 +02:00

363 lines
14 KiB
Swift

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))
}
}