├── .github └── workflows │ └── test.yaml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── coverage.sh ├── ef.gnuplot ├── ef.png ├── interval.gnuplot ├── interval.png ├── rustfmt.toml ├── src └── lib.rs └── tests └── test.rs /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: push 3 | 4 | jobs: 5 | lint: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: checkout 9 | uses: actions/checkout@v4 10 | 11 | - name: cargo fmt 12 | run: cargo fmt --all -- --check 13 | 14 | - name: cargo clippy 15 | run: cargo clippy --all-targets -- -D warnings 16 | 17 | test: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: fetch deps 24 | run: cargo fetch 25 | 26 | - name: install cargo-llvm-cov 27 | uses: taiki-e/install-action@cargo-llvm-cov 28 | 29 | - name: build tests 30 | run: cargo test --no-run 31 | 32 | - name: run tests 33 | run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info 34 | 35 | - name: upload to codecov 36 | uses: codecov/codecov-action@v5 37 | with: 38 | token: ${{ secrets.CODECOV_TOKEN }} 39 | files: lcov.info 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "sm2" 7 | version = "0.1.0" 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sm2" 3 | version = "0.1.0" 4 | authors = ["Fernando Borretti "] 5 | edition = "2021" 6 | license = "MIT" 7 | 8 | [dependencies] 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Fernando Borretti 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: ef.png interval.png 2 | 3 | ef.png: ef.gnuplot 4 | gnuplot ef.gnuplot 5 | 6 | interval.png: interval.gnuplot 7 | gnuplot interval.gnuplot 8 | 9 | clean: 10 | rm -f ef.png 11 | rm -f interval.png 12 | 13 | .PHONY: all clean 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sm2 2 | 3 | [![Test](https://github.com/eudoxia0/sm2/actions/workflows/test.yaml/badge.svg?branch=master)](https://github.com/eudoxia0/sm2/actions/workflows/test.yaml) 4 | [![codecov](https://codecov.io/gh/eudoxia0/sm2/branch/master/graph/badge.svg?token=0U0ZFEY774)](https://codecov.io/gh/eudoxia0/sm2) 5 | 6 | An implementation of the [SM-2][sm2] algorithm from [SuperMemo][sm]. For my own elucidation. 7 | 8 | [sm2]: https://super-memory.com/english/ol/sm2.htm 9 | [sm]: https://en.wikipedia.org/wiki/SuperMemo 10 | 11 | Plot of the change in EF as a function of response quality: 12 | 13 | ![](ef.png) 14 | 15 | Interval as a function of correct repetitions, for different EF values: 16 | 17 | ![](interval.png) 18 | -------------------------------------------------------------------------------- /coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cargo llvm-cov --html 4 | -------------------------------------------------------------------------------- /ef.gnuplot: -------------------------------------------------------------------------------- 1 | set terminal pngcairo enhanced color size 800,600 2 | set output 'ef.png' 3 | set title 'f(q)' 4 | set xlabel 'q' 5 | set ylabel 'f(q)' 6 | set grid 7 | set xrange [0:5] 8 | f(q) = (0.1-(5-q)*(0.08+(5-q)*0.02)) 9 | plot f(x) notitle with lines lw 2 10 | -------------------------------------------------------------------------------- /ef.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudoxia0/sm2/8af7b69bf557343885d75d49499b6b596f022d2a/ef.png -------------------------------------------------------------------------------- /interval.gnuplot: -------------------------------------------------------------------------------- 1 | set terminal pngcairo enhanced color size 800,600 2 | set output 'interval.png' 3 | set title 'i(n)' 4 | set xlabel 'n' 5 | set ylabel 'i(n)' 6 | set grid 7 | set xrange [0:5] 8 | set key left top 9 | 10 | i(x,ef) = 6.0*ef**(x-2.0) 11 | plot i(x, 1.3) title "EF=1.3" with lines lw 2, \ 12 | i(x, 1.5) title "EF=1.5" with lines lw 2, \ 13 | i(x, 1.7) title "EF=1.7" with lines lw 2, \ 14 | i(x, 2.0) title "EF=2.0" with lines lw 2 15 | -------------------------------------------------------------------------------- /interval.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eudoxia0/sm2/8af7b69bf557343885d75d49499b6b596f022d2a/interval.png -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 60 2 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | /// The number of times in a row an item was recalled correctly. 2 | pub type Repetitions = u32; 3 | 4 | /// The Ease Factor (EF) of an item. 5 | pub type Ease = f32; 6 | 7 | /// The initial EF of an item. 8 | pub const INITIAL_EF: Ease = 2.5; 9 | 10 | /// The minimum EF value. 11 | const MIN_EF: Ease = 1.3; 12 | 13 | /// If the given EF is below the minimum, return the minimum. 14 | fn min(ef: Ease) -> Ease { 15 | if ef < MIN_EF { 16 | MIN_EF 17 | } else { 18 | ef 19 | } 20 | } 21 | 22 | /// The number of days until the next review. 23 | pub type Interval = u32; 24 | 25 | /// Recall quality. 26 | #[derive(Debug, Copy, Clone, PartialEq)] 27 | pub enum Quality { 28 | /// Complete blackout. 29 | Blackout = 0, 30 | /// Incorrect response; the correct one remembered. 31 | Incorrect = 1, 32 | /// Incorrect response; where the correct one seemed easy to recall. 33 | IncorrectEasy = 2, 34 | /// Correct response recalled with serious difficulty. 35 | Hard = 3, 36 | /// Correct response after a hesitation. 37 | Good = 4, 38 | /// Perfect response. 39 | Perfect = 5, 40 | } 41 | 42 | impl Quality { 43 | /// True for quality levels representing failure. 44 | pub fn forgot(self) -> bool { 45 | match self { 46 | Self::Blackout 47 | | Self::Incorrect 48 | | Self::IncorrectEasy => true, 49 | Self::Hard | Self::Good | Self::Perfect => { 50 | false 51 | } 52 | } 53 | } 54 | 55 | /// Should the item be repeated at the end of the session? 56 | pub fn repeat(self) -> bool { 57 | match self { 58 | Self::Blackout 59 | | Self::Incorrect 60 | | Self::IncorrectEasy 61 | | Self::Hard => true, 62 | Self::Good | Self::Perfect => false, 63 | } 64 | } 65 | } 66 | 67 | /// An item of knowledge. 68 | #[derive(Debug, Copy, Clone)] 69 | pub struct Item { 70 | n: Repetitions, 71 | ef: Ease, 72 | } 73 | 74 | impl Item { 75 | /// Construct an item from a repetition count and an EF. 76 | pub fn new(n: Repetitions, ef: Ease) -> Self { 77 | Self { n, ef } 78 | } 79 | 80 | /// The item's number of repetitions. 81 | pub fn n(&self) -> Repetitions { 82 | self.n 83 | } 84 | 85 | /// The item's easiness factor. 86 | pub fn ef(&self) -> Ease { 87 | self.ef 88 | } 89 | 90 | #[must_use = "Item::review returns a new Item"] 91 | pub fn review(self, q: Quality) -> Self { 92 | Self { 93 | n: np(self.n, q), 94 | ef: efp(self.ef, q), 95 | } 96 | } 97 | 98 | /// The interval when the item will be reviewed next. 99 | pub fn interval(&self) -> Interval { 100 | let r = self.n; 101 | let ef = self.ef; 102 | match self.n { 103 | 0 => 0, 104 | 1 => 1, 105 | 2 => 6, 106 | _ => { 107 | let r = r as f32; 108 | let i = 6.0 * ef.powf(r - 2.0); 109 | let i = i.round(); 110 | i as u32 111 | } 112 | } 113 | } 114 | } 115 | 116 | impl Default for Item { 117 | fn default() -> Self { 118 | Self { 119 | n: 0, 120 | ef: INITIAL_EF, 121 | } 122 | } 123 | } 124 | 125 | /// Update the repetitions after a review. 126 | fn np(rep: Repetitions, q: Quality) -> Repetitions { 127 | if q.forgot() { 128 | 0 129 | } else { 130 | rep + 1 131 | } 132 | } 133 | 134 | /// Update EF after a review. 135 | fn efp(ef: Ease, q: Quality) -> Ease { 136 | let ef = min(ef); 137 | let q = (q as u8) as f32; 138 | let ef = ef - 0.8 + 0.28 * q - 0.02 * q * q; 139 | min(ef) 140 | } 141 | -------------------------------------------------------------------------------- /tests/test.rs: -------------------------------------------------------------------------------- 1 | use sm2::Item; 2 | use sm2::Quality; 3 | 4 | /// Quality maps to expected natural number values. 5 | #[test] 6 | fn quality_values() { 7 | assert_eq!(Quality::Blackout as u8, 0); 8 | assert_eq!(Quality::Incorrect as u8, 1); 9 | assert_eq!(Quality::IncorrectEasy as u8, 2); 10 | assert_eq!(Quality::Hard as u8, 3); 11 | assert_eq!(Quality::Good as u8, 4); 12 | assert_eq!(Quality::Perfect as u8, 5); 13 | } 14 | 15 | /// `Quality::forgot` works as expected. 16 | #[test] 17 | fn test_forgot() { 18 | assert!(Quality::Blackout.forgot()); 19 | assert!(Quality::Incorrect.forgot()); 20 | assert!(Quality::IncorrectEasy.forgot()); 21 | assert!(!Quality::Hard.forgot()); 22 | assert!(!Quality::Good.forgot()); 23 | assert!(!Quality::Perfect.forgot()); 24 | } 25 | 26 | /// `Quality::repeat` works as expected. 27 | #[test] 28 | fn test_repeat() { 29 | assert!(Quality::Blackout.repeat()); 30 | assert!(Quality::Incorrect.repeat()); 31 | assert!(Quality::IncorrectEasy.repeat()); 32 | assert!(Quality::Hard.repeat()); 33 | assert!(!Quality::Good.repeat()); 34 | assert!(!Quality::Perfect.repeat()); 35 | } 36 | 37 | /// A default `Item` has zero repetitions and the initial EF. 38 | #[test] 39 | fn test_default() { 40 | let item = Item::default(); 41 | assert_eq!(item.n(), 0); 42 | assert_eq!(item.ef(), sm2::INITIAL_EF); 43 | } 44 | 45 | /// Item constructor and accessors work. 46 | #[test] 47 | fn test_constructor_and_accessors() { 48 | let item = Item::new(0, 3.0); 49 | assert_eq!(item.n(), 0); 50 | assert_eq!(item.ef(), 3.0); 51 | } 52 | 53 | /// Test the `Item::interval` method. 54 | #[test] 55 | fn test_interval() { 56 | let ef = 2.5; 57 | let cases = [ 58 | (Item::new(0, ef), 0), 59 | (Item::new(1, ef), 1), 60 | (Item::new(2, ef), 6), 61 | (Item::new(3, ef), 15), 62 | (Item::new(4, ef), 38), 63 | (Item::new(5, ef), 94), 64 | ]; 65 | for (item, expected) in cases { 66 | assert_eq!(item.interval(), expected); 67 | } 68 | } 69 | 70 | /// Forgetting an item sets the repetitions to zero. 71 | #[test] 72 | fn test_forgetting() { 73 | let qs = [ 74 | Quality::Blackout, 75 | Quality::Incorrect, 76 | Quality::IncorrectEasy, 77 | ]; 78 | for q in qs { 79 | let item = Item::new(3, 2.5); 80 | let item = item.review(q); 81 | assert_eq!(item.n(), 0); 82 | } 83 | } 84 | 85 | /// EF is never below the minimum after a review. 86 | #[test] 87 | fn test_min_ef() { 88 | let qs = [ 89 | Quality::Blackout, 90 | Quality::Incorrect, 91 | Quality::IncorrectEasy, 92 | Quality::Hard, 93 | Quality::Good, 94 | Quality::Perfect, 95 | ]; 96 | for q1 in qs { 97 | for q2 in qs { 98 | for q3 in qs { 99 | for q4 in qs { 100 | for q5 in qs { 101 | let mut item = Item::default(); 102 | let qv = [q1, q2, q3, q4, q5]; 103 | for q in qv { 104 | item = item.review(q); 105 | assert!(item.ef() >= 1.3); 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | 114 | fn close_enough(a: f32, b: f32) -> bool { 115 | (a - b).abs() < 0.01 116 | } 117 | 118 | /// Test how EF evolves. 119 | /// 120 | /// These values were calculated manually in Python: 121 | /// 122 | /// ```python 123 | /// >>> ef = lambda f, q: f-0.8+0.28*q-0.02*q*q 124 | /// >>> ef(2.5, 3) 125 | /// 2.36 126 | /// >>> ef(2.36, 4) 127 | /// 2.36 128 | /// >>> ef(2.36, 5) 129 | /// 2.46 130 | /// >>> ef(2.46, 5) 131 | /// 2.56 132 | /// ``` 133 | #[test] 134 | fn test_ef() { 135 | let item = Item::default(); 136 | let item = item.review(Quality::Hard); 137 | assert!(close_enough(item.ef(), 2.36)); 138 | let item = item.review(Quality::Good); 139 | assert!(close_enough(item.ef(), 2.36)); 140 | let item = item.review(Quality::Perfect); 141 | assert!(close_enough(item.ef(), 2.46)); 142 | let item = item.review(Quality::Perfect); 143 | assert!(close_enough(item.ef(), 2.56)); 144 | } 145 | --------------------------------------------------------------------------------