Files
TubeCards/lib/utils/sm2.dart
2022-10-13 12:12:29 +02:00

172 lines
6.0 KiB
Dart

import 'dart:math';
import '../data/models/confidence.dart';
/// The initial value of the ease for the SM2 algorithm.
///
/// Indicates how difficult it is for the user to review a card. The value
/// ranges from [1.3 to 2.5]. The higher the value the easier it is for the
/// user.
const double initialEase = 2.5;
const int initialStreakKnown = 0;
/// The maximum number of days with which [Duration] can be created.
///
/// If a [Duration] is created with a higher value, a negative [Duration] is
/// returned. See more here https://github.com/dart-lang/sdk/issues/39619.
const int _upperDaysInDurationLimit = 106751991;
/// The maximum date that is returned, since dates above it throw
/// ArgumentErrors.
///
/// See here https://dartpad.dev/2d48b8bc99f9f046c1175d8f3f1d18e1 and
/// https://api.dartlang.org/stable/2.6.1/dart-core/DateTime-class.html.
final DateTime _upperLimitDateTime = DateTime.parse('275760-09-13T00:00:00Z');
/// Returns the results of the SM2 algorithm in a SM2Result.
///
/// The [dueDate] is the date on which the card was due and the [confidence]
/// indicates how well the user knew the card.
///
/// The [reviewDate] is the date on which the review was done and
/// [lastReviewDate] is the date of the previous review, if there was one.
/// If [lastReviewDate] is not given, it is assumed that it's the first review.
/// [streakKnown] indicates how many times the card has been correctly
/// reviewed in a row. The [ease] indicates how difficult it is for the
/// user to review the card and is a value from [1.3 to 2.5].
///
/// All DateTime objects are expected in UTC.
///
/// This algorithm is based on this blog post: https://bit.ly/2LnfNrg.
/// In two places changes have been made to the SM2 algorithm:
/// 1. Instead of Quality, the algorithm accepts Confidence, where Confidence
/// KNOWN corresponds to Quality 5 and UNKNOWN corresponds to ~2.
/// 2. If the given confidence is UNKNOWN, the next due date is calculated
/// at 40% of the previous time interval instead of 0%.
SM2Result run(
DateTime dueDate,
Confidence confidence, {
required int streakKnown,
required double ease,
DateTime? reviewDate,
DateTime? lastReviewDate,
}) {
final dueDateUtc = dueDate.toUtc();
final reviewDateUtc = reviewDate?.toUtc();
final lastReviewDateUtc = lastReviewDate?.toUtc();
assert(streakKnown >= 0 && ease >= 1.3 && ease <= 2.5);
assert(lastReviewDateUtc == null && streakKnown == 0 ||
lastReviewDateUtc != null);
final clampedEase = ease.clamp(1.3, 2.5).toDouble();
final positiveStreakKnown = max(streakKnown, 0);
final updatedStreakKnown =
confidence == Confidence.known ? positiveStreakKnown + 1 : 0;
return SM2Result._(
_getNextDueDate(
dueDateUtc,
lastReviewDateUtc,
clampedEase,
updatedStreakKnown,
confidence,
reviewDateUtc ?? DateTime.now().toUtc(),
),
updatedStreakKnown,
_getUpdatedEase(confidence, clampedEase),
);
}
/// The result of the SM2 algorithm.
class SM2Result {
SM2Result._(this.dueDate, this.streakKnown, this.ease);
/// The "optimal" date at which the next review should be made.
///
/// It is in UTC.
final DateTime dueDate;
/// An SM2 user specific value corresponding to the difficulty of the fact.
final double ease;
/// The number of times the user has known the card in a row.
final int streakKnown;
}
DateTime _getNextDueDate(
DateTime nextDueDate,
DateTime? lastReviewDate,
double ease,
int updatedStreaKnown,
Confidence confidence,
DateTime reviewTime,
) {
if (confidence == Confidence.known) {
final streakKnownDuration = Duration(
days: updatedStreaKnown == 1
? 1
: min(
4 * pow(ease, updatedStreaKnown - 2).round(),
_upperDaysInDurationLimit,
),
);
final scheduledDayDiffDuration =
Duration(days: reviewTime.difference(nextDueDate).inDays);
if (!_isDateTimeAdditionAllowed(
reviewTime,
scheduledDayDiffDuration + streakKnownDuration,
)) {
return _upperLimitDateTime;
}
return reviewTime.add(streakKnownDuration).add(scheduledDayDiffDuration);
} else if (confidence == Confidence.unknown) {
// Diverge from SM2 Algorithm. Instead of starting from the beginning,
// after the card was not known, learning time is reduced to 40% of the
// last.
final hasEverBeenReviewed = lastReviewDate != null;
final daysLastTime = hasEverBeenReviewed
? nextDueDate.difference(lastReviewDate!).inDays
: 0;
return reviewTime.add(Duration(days: (daysLastTime * 0.4).round()));
} else {
throw ArgumentError('Unrecognized state of confidence occured.');
}
}
/// Checks whether the addition of the [duration] to [dateTime] throws an
/// ArgumentError.
///
/// Prevents that ArgumentErrors are thrown, when DateTime is out of range.
bool _isDateTimeAdditionAllowed(DateTime dateTime, Duration duration) {
if (duration.isNegative || duration.inDays > _upperDaysInDurationLimit) {
return false;
}
final sumOfMillisecondsSinceEpoch =
dateTime.millisecondsSinceEpoch + duration.inMilliseconds;
return sumOfMillisecondsSinceEpoch <
_upperLimitDateTime.millisecondsSinceEpoch;
}
double _getUpdatedEase(Confidence confidence, double ease) {
// Removes inaccuracies from calculating with floating point numbers.
// Method comes from https://stackoverflow.com/a/32205216/6169345.
double toDoubleWithFixed(int fractionDigits, double n) =>
double.parse(n.toStringAsFixed(fractionDigits));
// Diverge from SM-2 algorithm, as there are currently only KNOWN and UNKNOWN.
// The original SM2 algorithm has the parameter quality, which indicates from
// 0 - 5 how well the user knew the answer. The calculation of the ease value
// would look like this in the original SM2 algorithm:
// newEase = ease + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
// KNOWN corresponds to a quality of 5 and UNKNOWN to ~2.
final newEase = ease + (confidence == Confidence.known ? 0.1 : -0.3);
return toDoubleWithFixed(2, newEase.clamp(1.3, 2.5).toDouble());
}