diff --git a/lib/src/fsrs_base.dart b/lib/src/fsrs_base.dart index d9537b7..ec851c9 100644 --- a/lib/src/fsrs_base.dart +++ b/lib/src/fsrs_base.dart @@ -2,25 +2,52 @@ import 'dart:core'; import 'dart:math'; import './models.dart'; +/// TODO: document class FSRS { - late Parameters p; - late double decay; - late double factor; - - FSRS() { - p = Parameters(); - decay = -0.5; - factor = pow(0.9, 1 / decay) - 1; + FSRS({ + double? requestRetention, + int? maximumInterval, + List? weight, + }) : p = Parameters( + requestRetention: requestRetention, + maximumInterval: maximumInterval, + weight: weight, + ), + factor = pow(0.9, 1 / decay) - 1; + + final Parameters p; + static const double decay = -0.5; + final double factor; + + /// TODO: document + ({Card card, ReviewLog reviewLog}) reviewCard( + Card card, + Rating rating, + DateTime? now, + ) { + final date = now ?? DateTime.now(); + final schedulingCards = repeat(card, date); + + final reviewCard = schedulingCards[rating]!.card; + final reviewLog = schedulingCards[rating]!.reviewLog; + + return (card: reviewCard, reviewLog: reviewLog); } - Map repeat(Card card, DateTime now) { + /// TODO: document + Map repeat( + Card card, [ + DateTime? now, + ]) { + final date = now ?? DateTime.now(); + card = card.copyWith(); if (card.state == State.newState) { card.elapsedDays = 0; } else { - card.elapsedDays = now.difference(card.lastReview).inDays; + card.elapsedDays = date.difference(card.lastReview).inDays; } - card.lastReview = now; + card.lastReview = date; card.reps++; final s = SchedulingCards(card); @@ -30,27 +57,32 @@ class FSRS { case State.newState: _initDS(s); - s.again.due = now.add(Duration(minutes: 1)); - s.hard.due = now.add(Duration(minutes: 5)); - s.good.due = now.add(Duration(minutes: 10)); + s.again.due = date.add(Duration(minutes: 1)); + s.hard.due = date.add(Duration(minutes: 5)); + s.good.due = date.add(Duration(minutes: 10)); final easyInterval = _nextInterval(s.easy.stability); s.easy.scheduledDays = easyInterval; - s.easy.due = now.add(Duration(days: easyInterval)); + s.easy.due = date.add(Duration(days: easyInterval)); case State.learning: case State.relearning: + final interval = card.elapsedDays; + final lastD = card.difficulty; + final lastS = card.stability; + final retrievability = _forgettingCurve(interval, lastS); + _nextDS(s, lastD, lastS, retrievability, card.state); + final hardInterval = 0; final goodInterval = _nextInterval(s.good.stability); final easyInterval = max(_nextInterval(s.easy.stability), goodInterval + 1); - s.schedule(now, hardInterval.toDouble(), goodInterval.toDouble(), - easyInterval.toDouble()); + s.schedule(date, hardInterval, goodInterval, easyInterval); case State.review: final interval = card.elapsedDays; final lastD = card.difficulty; final lastS = card.stability; final retrievability = _forgettingCurve(interval, lastS); - _nextDS(s, lastD, lastS, retrievability); + _nextDS(s, lastD, lastS, retrievability, card.state); var hardInterval = _nextInterval(s.hard.stability); var goodInterval = _nextInterval(s.good.stability); @@ -58,82 +90,124 @@ class FSRS { goodInterval = max(goodInterval, hardInterval + 1); final easyInterval = max(_nextInterval(s.easy.stability), goodInterval + 1); - s.schedule(now, hardInterval.toDouble(), goodInterval.toDouble(), - easyInterval.toDouble()); + + s.schedule(date, hardInterval, goodInterval, easyInterval); } - return s.recordLog(card, now); + return s.recordLog(card, date); } + /// TODO: document void _initDS(SchedulingCards s) { - s.again.difficulty = _initDifficulty(Rating.again.val); + s.again.difficulty = _initDifficulty(Rating.again); s.again.stability = _initStability(Rating.again.val); - s.hard.difficulty = _initDifficulty(Rating.hard.val); + s.hard.difficulty = _initDifficulty(Rating.hard); s.hard.stability = _initStability(Rating.hard.val); - s.good.difficulty = _initDifficulty(Rating.good.val); + s.good.difficulty = _initDifficulty(Rating.good); s.good.stability = _initStability(Rating.good.val); - s.easy.difficulty = _initDifficulty(Rating.easy.val); + s.easy.difficulty = _initDifficulty(Rating.easy); s.easy.stability = _initStability(Rating.easy.val); } - double _initStability(int r) { - return max(p.w[r - 1], 0.1); - } + /// TODO: document + double _initStability(int r) => max(p.weight[r - 1], 0.1); - double _initDifficulty(int r) { - return min(max(p.w[4] - p.w[5] * (r - 3), 1), 10); - } + /// TODO: document + double _initDifficulty(Rating r) => + min(max(p.weight[4] - exp(p.weight[5] * (r.val - 1) + 1), 1), 10); - double _forgettingCurve(int elapsedDays, double stability) { - return pow(1 + factor * elapsedDays / stability, decay).toDouble(); - } + /// TODO: document + double _forgettingCurve(int elapsedDays, double stability) => + pow(1 + factor * elapsedDays / stability, decay).toDouble(); + /// TODO: document int _nextInterval(double s) { final newInterval = s / factor * (pow(p.requestRetention, 1 / decay) - 1); return min(max(newInterval.round(), 1), p.maximumInterval); } - double _nextDifficulty(double d, int r) { - final nextD = d - p.w[6] * (r - 3); - return min(max(_meanReversion(p.w[4], nextD), 1), 10); + /// TODO: document + double _nextDifficulty(double d, Rating r) { + final nextD = d - p.weight[6] * (r.val - 3); + return min(max(_meanReversion(_initDifficulty(Rating.easy), nextD), 1), 10); } - double _meanReversion(double init, double current) { - return p.w[7] * init + (1 - p.w[7]) * current; - } + /// TODO: document + double _shortTermStability(double stability, Rating rating) => + stability * exp(p.weight[17] * (rating.val - 3 + p.weight[18])); + /// TODO: document + double _meanReversion(double init, double current) => + p.weight[7] * init + (1 - p.weight[7]) * current; + + /// TODO: document double _nextRecallStability(double d, double s, double r, Rating rating) { - final hardPenalty = (rating == Rating.hard) ? p.w[15] : 1; - final easyBonus = (rating == Rating.easy) ? p.w[16] : 1; + final hardPenalty = rating == Rating.hard ? p.weight[15] : 1; + final easyBonus = rating == Rating.easy ? p.weight[16] : 1; return s * (1 + - exp(p.w[8]) * + exp(p.weight[8]) * (11 - d) * - pow(s, -p.w[9]) * - (exp((1 - r) * p.w[10]) - 1) * + pow(s, -p.weight[9]) * + (exp((1 - r) * p.weight[10]) - 1) * hardPenalty * easyBonus); } + /// TODO: document double _nextForgetStability(double d, double s, double r) { - return p.w[11] * - pow(d, -p.w[12]) * - (pow(s + 1, p.w[13]) - 1) * - exp((1 - r) * p.w[14]); + return p.weight[11] * + pow(d, -p.weight[12]) * + (pow(s + 1, p.weight[13]) - 1) * + exp((1 - r) * p.weight[14]); } + /// TODO: document void _nextDS( - SchedulingCards s, double lastD, double lastS, double retrievability) { - s.again.difficulty = _nextDifficulty(lastD, Rating.again.val); - s.again.stability = _nextForgetStability(lastD, lastS, retrievability); - s.hard.difficulty = _nextDifficulty(lastD, Rating.hard.val); - s.hard.stability = - _nextRecallStability(lastD, lastS, retrievability, Rating.hard); - s.good.difficulty = _nextDifficulty(lastD, Rating.good.val); - s.good.stability = - _nextRecallStability(lastD, lastS, retrievability, Rating.good); - s.easy.difficulty = _nextDifficulty(lastD, Rating.easy.val); - s.easy.stability = - _nextRecallStability(lastD, lastS, retrievability, Rating.easy); + SchedulingCards s, + double lastD, + double lastS, + double retrievability, + State state, + ) { + s.again.difficulty = _nextDifficulty(lastD, Rating.again); + s.hard.difficulty = _nextDifficulty(lastD, Rating.hard); + s.good.difficulty = _nextDifficulty(lastD, Rating.good); + s.easy.difficulty = _nextDifficulty(lastD, Rating.easy); + + switch (state) { + case State.learning: + case State.relearning: + s.again.stability = _shortTermStability(lastS, Rating.again); + s.hard.stability = _shortTermStability(lastS, Rating.hard); + s.good.stability = _shortTermStability(lastS, Rating.good); + s.easy.stability = _shortTermStability(lastS, Rating.easy); + case State.review: + s.again.stability = _nextForgetStability( + lastD, + lastS, + retrievability, + ); + s.hard.stability = _nextRecallStability( + lastD, + lastS, + retrievability, + Rating.hard, + ); + s.good.stability = _nextRecallStability( + lastD, + lastS, + retrievability, + Rating.good, + ); + s.easy.stability = _nextRecallStability( + lastD, + lastS, + retrievability, + Rating.easy, + ); + case State.newState: + return; + } } } diff --git a/lib/src/models.dart b/lib/src/models.dart index 803f339..46993f6 100644 --- a/lib/src/models.dart +++ b/lib/src/models.dart @@ -6,10 +6,18 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'models.freezed.dart'; part 'models.g.dart'; +/// TODO: document enum State { + /// TODO: document newState(0), + + /// TODO: document learning(1), + + /// TODO: document review(2), + + /// TODO: document relearning(3); const State(this.val); @@ -17,10 +25,18 @@ enum State { final int val; } +/// TODO: document enum Rating { + /// TODO: document again(1), + + /// TODO: document hard(2), + + /// TODO: document good(3), + + /// TODO: document easy(4); const Rating(this.val); @@ -28,15 +44,31 @@ enum Rating { final int val; } +/// TODO: document class ReviewLog { + ReviewLog( + this.rating, + this.scheduledDays, + this.elapsedDays, + this.review, + this.state, + ); + + /// TODO: document Rating rating; + + /// TODO: document int scheduledDays; + + /// TODO: document int elapsedDays; + + /// TODO: document DateTime review; + + /// TODO: document State state; - ReviewLog(this.rating, this.scheduledDays, this.elapsedDays, this.review, - this.state); @override String toString() { return jsonEncode({ @@ -87,6 +119,7 @@ class Card with _$Card { } } +/// TODO: document /// Store card and review log info class SchedulingInfo { late Card card; @@ -95,6 +128,7 @@ class SchedulingInfo { SchedulingInfo(this.card, this.reviewLog); } +/// TODO: document /// Calculate next review class SchedulingCards { late Card again; @@ -109,6 +143,7 @@ class SchedulingCards { easy = card.copyWith(); } + /// TODO: document void updateState(State state) { switch (state) { case State.newState: @@ -131,66 +166,107 @@ class SchedulingCards { } } + /// TODO: document void schedule( DateTime now, - double hardInterval, - double goodInterval, - double easyInterval, + int hardInterval, + int goodInterval, + int easyInterval, ) { again.scheduledDays = 0; - hard.scheduledDays = hardInterval.toInt(); - good.scheduledDays = goodInterval.toInt(); - easy.scheduledDays = easyInterval.toInt(); + hard.scheduledDays = hardInterval; + good.scheduledDays = goodInterval; + easy.scheduledDays = easyInterval; again.due = now.add(Duration(minutes: 5)); - hard.due = (hardInterval > 0) - ? now.add(Duration(days: hardInterval.toInt())) + hard.due = hardInterval > 0 + ? now.add(Duration(days: hardInterval)) : now.add(Duration(minutes: 10)); - good.due = now.add(Duration(days: goodInterval.toInt())); - easy.due = now.add(Duration(days: easyInterval.toInt())); + good.due = now.add(Duration(days: goodInterval)); + easy.due = now.add(Duration(days: easyInterval)); } - Map recordLog(Card card, DateTime now) { - return { - Rating.again: SchedulingInfo( + /// TODO: document + Map recordLog(Card card, DateTime now) => { + Rating.again: SchedulingInfo( again, - ReviewLog(Rating.again, again.scheduledDays, card.elapsedDays, now, - card.state)), - Rating.hard: SchedulingInfo( + ReviewLog( + Rating.again, + again.scheduledDays, + card.elapsedDays, + now, + card.state, + ), + ), + Rating.hard: SchedulingInfo( hard, - ReviewLog(Rating.hard, hard.scheduledDays, card.elapsedDays, now, - card.state)), - Rating.good: SchedulingInfo( + ReviewLog( + Rating.hard, + hard.scheduledDays, + card.elapsedDays, + now, + card.state, + ), + ), + Rating.good: SchedulingInfo( good, - ReviewLog(Rating.good, good.scheduledDays, card.elapsedDays, now, - card.state)), - Rating.easy: SchedulingInfo( + ReviewLog( + Rating.good, + good.scheduledDays, + card.elapsedDays, + now, + card.state, + ), + ), + Rating.easy: SchedulingInfo( easy, - ReviewLog(Rating.easy, easy.scheduledDays, card.elapsedDays, now, - card.state)), - }; - } + ReviewLog( + Rating.easy, + easy.scheduledDays, + card.elapsedDays, + now, + card.state, + ), + ), + }; } +/// TODO: document class Parameters { - double requestRetention = 0.9; - int maximumInterval = 36500; - List w = [ - 0.4, - 0.6, - 2.4, - 5.8, - 4.93, - 0.94, - 0.86, - 0.01, - 1.49, - 0.14, - 0.94, - 2.18, - 0.05, - 0.34, - 1.26, - 0.29, - 2.61 - ]; + Parameters({ + double? requestRetention, + int? maximumInterval, + List? w, + }) : requestRetention = requestRetention ?? 0.9, + maximumInterval = maximumInterval ?? 36500, + w = w ?? + const [ + 0.4072, + 1.1829, + 3.1262, + 15.4722, + 7.2102, + 0.5316, + 1.0651, + 0.0234, + 1.616, + 0.1544, + 1.0824, + 1.9813, + 0.0953, + 0.2975, + 2.2042, + 0.2407, + 2.9466, + 0.5034, + 0.6567, + ]; + + /// TODO: document + double requestRetention; + + /// TODO: document + int maximumInterval; + + /// TODO: document + List w; } diff --git a/lib/src/models.freezed.dart b/lib/src/models.freezed.dart index 64a529a..23f5bd6 100644 --- a/lib/src/models.freezed.dart +++ b/lib/src/models.freezed.dart @@ -100,8 +100,13 @@ mixin _$Card { required TResult orElse(), }) => throw _privateConstructorUsedError; + + /// Serializes this Card to a JSON map. Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) + + /// Create a copy of Card + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) $CardCopyWith get copyWith => throw _privateConstructorUsedError; } @@ -132,6 +137,8 @@ class _$CardCopyWithImpl<$Res, $Val extends Card> // ignore: unused_field final $Res Function($Val) _then; + /// Create a copy of Card + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -212,6 +219,8 @@ class __$$CardImplCopyWithImpl<$Res> __$$CardImplCopyWithImpl(_$CardImpl _value, $Res Function(_$CardImpl) _then) : super(_value, _then); + /// Create a copy of Card + /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({ @@ -313,7 +322,9 @@ class _$CardImpl extends _Card { return 'Card.def(due: $due, lastReview: $lastReview, stability: $stability, difficulty: $difficulty, elapsedDays: $elapsedDays, scheduledDays: $scheduledDays, reps: $reps, lapses: $lapses, state: $state)'; } - @JsonKey(ignore: true) + /// Create a copy of Card + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) @override @pragma('vm:prefer-inline') _$$CardImplCopyWith<_$CardImpl> get copyWith => @@ -456,8 +467,11 @@ abstract class _Card extends Card { @override State get state; set state(State value); + + /// Create a copy of Card + /// with the given fields replaced by the non-null parameter values. @override - @JsonKey(ignore: true) + @JsonKey(includeFromJson: false, includeToJson: false) _$$CardImplCopyWith<_$CardImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/pubspec.yaml b/pubspec.yaml index 2759d27..f605f78 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,7 +9,6 @@ environment: # Add regular dependencies here. dependencies: - collection: ^1.18.0 freezed_annotation: ^2.4.1 json_annotation: ^4.9.0 # path: ^1.8.0 diff --git a/test/fsrs_test.dart b/test/fsrs_test.dart index 2e45102..e96a22c 100644 --- a/test/fsrs_test.dart +++ b/test/fsrs_test.dart @@ -1,84 +1,245 @@ import 'package:fsrs/fsrs.dart'; import 'package:test/test.dart'; -import 'package:collection/collection.dart'; + +const testW = [ + 0.4197, + 1.1869, + 3.0412, + 15.2441, + 7.1434, + 0.6477, + 1.0007, + 0.0674, + 1.6597, + 0.1712, + 1.1178, + 2.0225, + 0.0904, + 0.3025, + 2.1214, + 0.2498, + 2.9466, + 0.4891, + 0.6468, +]; void main() { - group('A group of tests', () { - setUp(() { - // Additional setup goes here. + test('Review Card', () { + final f = FSRS(weight: testW); + var card = Card(); + var now = DateTime.utc(2022, 11, 29, 12, 30); + + const ratings = [ + Rating.good, + Rating.good, + Rating.good, + Rating.good, + Rating.good, + Rating.good, + Rating.again, + Rating.again, + Rating.good, + Rating.good, + Rating.good, + Rating.good, + Rating.good, + ]; + + List ivlHistory = []; + + for (final rating in ratings) { + final reviewCard = f.reviewCard(card, rating, now); + card = reviewCard.card; + final ivl = card.scheduledDays; + ivlHistory.add(ivl); + now = card.due; + } + + expect(ivlHistory, [ + 0, + 4, + 17, + 62, + 198, + 563, + 0, + 0, + 9, + 27, + 74, + 190, + 457, + ]); + }); + + test('Memo State', () { + final f = FSRS(weight: testW); + var card = Card(); + var now = DateTime.utc(2022, 11, 29, 12, 30); + + var schedulingCards = f.repeat(card, now); + const reviews = [ + (Rating.again, 0), + (Rating.good, 0), + (Rating.good, 1), + (Rating.good, 3), + (Rating.good, 8), + (Rating.good, 21), + ]; + + for (final (rating, ivl) in reviews) { + card = schedulingCards[rating]!.card; + now = now.add(Duration(days: ivl)); + schedulingCards = f.repeat(card, now); + } + + expect(schedulingCards[Rating.good]!.card.stability, 71.4554); + expect(schedulingCards[Rating.good]!.card.difficulty, 5.0976); + }); + + test('Default Arg', () { + final f = FSRS(); + + var card = Card(); + + final schedulingCards = f.repeat(card); + + final cardRating = Rating.good; + + card = schedulingCards[cardRating]!.card; + + final due = card.due; + + final timeDelta = due.difference(DateTime.now().toUtc()); + + expect(timeDelta.inSeconds, greaterThan(500)); + }); + + group("Custom Scheduler Args:", () { + const ratings = [ + Rating.good, + Rating.good, + Rating.good, + Rating.good, + Rating.good, + Rating.good, + Rating.again, + Rating.again, + Rating.good, + Rating.good, + Rating.good, + Rating.good, + Rating.good, + ]; + + test('IVL', () { + final f = FSRS( + requestRetention: 0.9, + maximumInterval: 36500, + weight: [ + 0.4197, + 1.1869, + 3.0412, + 15.2441, + 7.1434, + 0.6477, + 1.0007, + 0.0674, + 1.6597, + 0.1712, + 1.1178, + 2.0225, + 0.0904, + 0.3025, + 2.1214, + 0.2498, + 2.9466, + 0, + 0.6468, + ], + ); + + var card = Card(); + var now = DateTime(2022, 11, 29, 12, 30); + final List ivlHistory = []; + + for (final rating in ratings) { + final reviewCard = f.reviewCard(card, rating, now); + card = reviewCard.card; + final ivl = card.scheduledDays; + ivlHistory.add(ivl); + now = card.due; + } + + expect(ivlHistory, [0, 3, 13, 50, 163, 473, 0, 0, 12, 34, 91, 229, 541]); }); - test('First Test', () { - testRepeat(); + test('Verify parameters', () { + const requestRetention = 0.85; + const maximumInterval = 3650; + const w = [ + 0.1456, + 0.4186, + 1.1104, + 4.1315, + 5.2417, + 1.3098, + 0.8975, + 0.0000, + 1.5674, + 0.0567, + 0.9661, + 2.0275, + 0.1592, + 0.2446, + 1.5071, + 0.2272, + 2.8755, + 1.234, + 5.6789, + ]; + + final f = FSRS( + requestRetention: requestRetention, + maximumInterval: maximumInterval, + weight: w, + ); + + var card = Card(); + var now = DateTime.utc(2022, 11, 29, 12, 30); + final List ivlHistory = []; + + for (final rating in ratings) { + final reviewCard = f.reviewCard(card, rating, now); + card = reviewCard.card; + final ivl = card.scheduledDays; + ivlHistory.add(ivl); + now = card.due; + } + + expect(f.p.weight, w); + expect(f.p.requestRetention, requestRetention); + expect(f.p.maximumInterval, maximumInterval); }); }); -} -void printSchedulingCards(Map schedulingCards) { - print("again.card: ${schedulingCards[Rating.again]?.card}"); - print("again.reviewLog: ${schedulingCards[Rating.again]?.reviewLog}"); - print("hard.card: ${schedulingCards[Rating.hard]?.card}"); - print("hard.reviewLog: ${schedulingCards[Rating.hard]?.reviewLog}"); - print("good.card: ${schedulingCards[Rating.good]?.card}"); - print("good.reviewLog: ${schedulingCards[Rating.good]?.reviewLog}"); - print("easy.card: ${schedulingCards[Rating.easy]?.card}"); - print("easy.reviewLog: ${schedulingCards[Rating.easy]?.reviewLog}"); - print(""); -} + test('DateTime', () { + final f = FSRS(); + var card = Card(); + + expect(DateTime.now().compareTo(card.due), greaterThanOrEqualTo(0)); -void testRepeat() { - var f = FSRS(); - f.p.w = [ - 1.14, - 1.01, - 5.44, - 14.67, - 5.3024, - 1.5662, - 1.2503, - 0.0028, - 1.5489, - 0.1763, - 0.9953, - 2.7473, - 0.0179, - 0.3105, - 0.3976, - 0.0, - 2.0902 - ]; - var card = Card(); - var now = DateTime(2022, 11, 29, 12, 30, 0, 0); - var schedulingCards = f.repeat(card, now); - printSchedulingCards(schedulingCards); - - var ratings = [ - Rating.good, - Rating.good, - Rating.good, - Rating.good, - Rating.good, - Rating.good, - Rating.again, - Rating.again, - Rating.good, - Rating.good, - Rating.good, - Rating.good, - Rating.good, - ]; - var ivlHistory = []; - - for (var rating in ratings) { - card = schedulingCards[rating]?.card ?? Card(); - var ivl = card.scheduledDays; - ivlHistory.add(ivl); - now = card.due; - schedulingCards = f.repeat(card, now); - printSchedulingCards(schedulingCards); - } - - print(ivlHistory); - assert(ListEquality() - .equals(ivlHistory, [0, 5, 16, 43, 106, 236, 0, 0, 12, 25, 47, 85, 147])); + final schedulingCards = f.repeat(card, DateTime.now().toUtc()); + card = schedulingCards[Rating.good]!.card; + + expect(card.due.compareTo(card.lastReview), greaterThanOrEqualTo(0)); + }); + + test('Card Serialization', () { + // TODO + }); + + test('ReviewLog Serialization', () { + // TODO + }); }