Implementing SM-2 in Rust

I am in a distance repetition (SR) kick later. Mochithe SR software I use, uses a modified version of the SuperMemo SM-2 card scheduling algorithm. The differences are:
- The ease factor (EF) of the cards is not adjusted in response to performance, but can be changed manually. The reason is that it avoids an alleged problem with SM-2 called “good as hell”.
- If you fail a card, the interval of the next check will not be reset but bad guys.
The middle of the interval I don’t like, because sometimes I remember a card in the first couple of months, then forget about it, and enter this strange state of limbo where the reviews are not always enough for me learn the card again, so I start taking turns. remembering it and failing it.
But I don’t understand the implication of the change in ease factor. So I decided to look at the algorithm. And, because what I didn’t do, I didn’t understand, I wrote a simple implementation of it in Rust.
Without further ado, the code is HERE. The following is an explanation.
Why do we need an algorithm? We could drill every card every day, but it’s a living nightmare, and you can’t have more than ~200 flashcards. The scheduler acts as a simple, quantitative model of human memory. The better the model, the more we can rely on long-term memory, and the less time we spend studying.
An THINGS an atomic piece of knowledge, represented as a flashcard: a question-answer pair that tests the existence of that knowledge. The state of an object is represented by:
- the easing factor $EF$, which is double the difficulty. This is a real number in the range $(1.3, +\infty)$. The initial amount is $2.5$.
- The number of repetitions $n$, which is the number of times the card remembers the correct sequence.
From the state of the card, we can calculate it GAP: the number of days after the most recent test when the item should be checked again. The calculation of the interval is defined by a recurrence relation of the number of repetitions:
\(\begin Self::Hard => true,
Self::Good I(0) &= 0 \\ I(1) &= 1 \\ I(2) &= 6 \\ I(n) &= I(n-1) \times \ text
Self::Blackout
\end Self::Hard => true,
Self::Good \)
The closed-form expression is:
\(I(n) = 6 \times \text Self::IncorrectEasy
^{(n-2)}\)
As a function of correct repetition and EF:
To test something, the user is shown the question, then they remember the answer, and the real answer is revealed. Then the user rates their performance by selecting the quality their answer from this list:
- 0 = Blackout. No recall.
- 1 = Wrong answer; but the answer, when revealed, is remembered.
- 2 = Wrong answer; but the answer seems easy to remember.
- 3 = Remembers the difficulty.
- 4 = Remembered with hesitation.
- 5 = Remembered perfectly.
The quality values of $(0,2)$ represent NEGLECT.
If an item is tested, and we have quality, the status of the item should be updated.
If the user forgets the answer, the repetition count is set to zero. This means that the interval, too, is reset: you have to study the card again from scratch.
\(n'(n, q) = \begin{cases} 0 & q \in (0,2) \\ n+1 & \text{otherwise} \end{cases}\)
The EF is updated by increasing the magnitude proportional to the quality of the response:
\(\text{EF}'(\text{EF}, q) = min(1.3, \text{EF} + f(q))\)
where:
\(f(q) = -0.8 + 0.28q – 0.02*q^2\)
Qualitatively, $f(q)$ looks like this:
So for anything less than perfect recall the EF decreases (and therefore the next interval is shorter), and only perfect recall makes a card less difficult. At $q=4$ there is no change.
And so we get the hell of ease: it’s much easier to push the EF down, or keep it the same, than to push it up.
The final piece of the algorithm is that, at the end of a review session, all items with a quality of $(0, 3)$ must be retried until they all have a recall quality of $(4 ,5)$.
Scalar types:
pub type Repetitions = u32;
pub type Ease = f32;
pub type Interval = u32;
I usually use new types with smart constructors to represent types with scopes (for example EF has a minimum value of 1.3). But this would make the code uglier, so I just wrote:
pub const INITIAL_EF: Ease = 2.5;
const MIN_EF: Ease = 1.3;
fn min(ef: Ease) -> Ease {
if ef < MIN_EF {
MIN_EF
} else {
ef
}
}
Quality is naturally represented as an enum:
#(derive(Debug, Copy, Clone, PartialEq))
pub enum Quality {
/// Complete blackout.
Blackout = 0,
/// Incorrect response; the correct one remembered.
Incorrect = 1,
/// Incorrect response; where the correct one seemed easy to recall.
IncorrectEasy = 2,
/// Correct response recalled with serious difficulty.
Hard = 3,
/// Correct response after a hesitation.
Good = 4,
/// Perfect response.
Perfect = 5,
}
With two predicates, to test if this quality value means forgetting, and if the object should be repeated at the end of the session:
impl Quality {
pub fn forgot(self) -> bool {
match self {
Self::Blackout
| Self::Incorrect
| Self::IncorrectEasy => true,
Self::Hard | Self::Good | Self::Perfect => {
false
}
}
}
pub fn repeat(self) -> bool {
match self {
Self::Blackout
| Self::Incorrect
| Self::IncorrectEasy
| Self::Hard => true,
Self::Good | Self::Perfect => false,
}
}
}
The state of an object is only its values $n$ and $\text{EF}$ (exact date timestamps are implemented outside the system):
pub struct Item {
n: Repetitions,
ef: Ease,
}
Given an object, we can calculate its interval:
impl Item {
pub fn interval(&self) -> Interval {
let r = self.n;
let ef = self.ef;
match self.n {
0 => 0,
1 => 1,
2 => 6,
_ => {
let r = r as f32;
let i = 6.0 * ef.powf(r - 2.0);
let i = i.ceil();
i as u32
}
}
}
}
the Item::review
The method consumes an object and, given a quality rating, updates its state:
impl Item {
pub fn review(self, q: Quality) -> Self {
Self {
n: np(self.n, q),
ef: efp(self.ef, q),
}
}
}
where:
fn np(n: Repetitions, q: Quality) -> Repetitions {
if q.forgot() {
0
} else {
n + 1
}
}
fn efp(ef: Ease, q: Quality) -> Ease {
let ef = min(ef);
let q = (q as u8) as f32;
let ef = ef - 0.8 + 0.28 * q - 0.02 * q * q;
min(ef)
}
2024-12-27 08:04:00