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>
363 lines
14 KiB
Swift
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))
|
|
}
|
|
}
|