├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Card.php ├── CardQueue.php ├── Repeater.php └── SM2.php └── tests ├── CardTest.php └── MemorizeTest.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 5.4 5 | - 5.5 6 | - hhvm 7 | 8 | before_script: 9 | - composer self-update 10 | - composer install 11 | 12 | script: 13 | - phpunit -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Shahin Zarrabi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Memorize 2 | 3 | [![Build Status](https://travis-ci.org/wiwofone/memorize.svg?branch=master)](https://travis-ci.org/wiwofone/memorize) 4 | 5 | *A PHP implementation of the SM-2 algorithm.* 6 | 7 | ## About 8 | This is a library created by me to try out popular tools such as Travis, Packagist and Composer, as well as setting up a library structure and enhancing my understanding of test driven development. While being a test project for me, the library is still completely usable and could be used by any database of questions and answers to implement spaced repetition in an application. 9 | 10 | ## Features 11 | * Calculate the interval in which to repeat an item after the n:th repetition based on an E-factor. 12 | * Calculate an E-factor for an item based on the old factor (or no factor) and a response quality. 13 | 14 | ## Installation 15 | Memorize is installed through [Composer](http://getcomposer.org/doc/00-intro.md). Add the following to your `composer.json` file. 16 | 17 | ```js 18 | { 19 | "require": { 20 | "wiwofone/memorize": "1.*" 21 | } 22 | } 23 | ``` 24 | 25 | ## The algorithm 26 | SM is a family of algorithms made popular by the SuperMemo software package. The Memorize library implements the complete [SM-2 algorithm](http://www.supermemo.com/english/ol/sm2.htm) in PHP. The `SM2` class handles calculating repetition intervals and E-factors. The `Card` class handles flash cards and how many times they have been virtually repeated. Finally, the `Repeater` class handles actual repetition of a `CardQueue`, deciding which cards to repeat first and if they have been repeated successfully or not. 27 | 28 | ## Testing 29 | Run PHPUnit with `$ phpunit` in the root directory. 30 | 31 | ## Author 32 | * Shahin Zarrabi - shahin@wiwo.se - [@wiwofone](http://twitter.com/wiwofone) - http://www.wiwo.se -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wiwofone/memorize", 3 | "description": "A PHP implementation of the SM-2 algorithm", 4 | "type": "Library", 5 | "license": "MIT", 6 | "keywords": ["SM-2", "supermemo", "spaced repetition"], 7 | "homepage": "https://github.com/wiwofone/memorize", 8 | "authors": [ 9 | { 10 | "name": "Shahin Zarrabi", 11 | "email": "shahin@wiwo.se", 12 | "homepage": "http://wiwo.se" 13 | } 14 | ], 15 | "require": { 16 | "php": ">=5.4.0" 17 | }, 18 | "require-dev": { 19 | "phpunit/phpunit": "3.7.*" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Memorize\\": "src" 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 15 | 16 | 17 | 18 | tests 19 | 20 | 21 | 22 | 23 | 24 | src/ 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Card.php: -------------------------------------------------------------------------------- 1 | question = $question; 44 | $this->answer = $answer; 45 | $this->numberOfRepeats = $numberOfRepeats; 46 | $this->factor = $factor; 47 | $this->nextTime = $nextTime; 48 | } 49 | 50 | /** 51 | * Set flash card question 52 | * @param string $question 53 | * 54 | * @return \Memorize\Card 55 | */ 56 | public function setQuestion($question) 57 | { 58 | $this->question = $question; 59 | return $this; 60 | } 61 | 62 | /** 63 | * Get flash card question 64 | * 65 | * @return string 66 | */ 67 | public function getQuestion() 68 | { 69 | return $this->question; 70 | } 71 | 72 | /** 73 | * Set flash card answer 74 | * @param string $answer 75 | * 76 | * @return \Memorize\Card 77 | */ 78 | public function setAnswer($answer) 79 | { 80 | $this->answer = $answer; 81 | return $this; 82 | } 83 | 84 | /** 85 | * Get flash card answer 86 | * @return string 87 | */ 88 | public function getAnswer() 89 | { 90 | return $this->answer; 91 | } 92 | 93 | /** 94 | * Set number of repetitions 95 | * @param int $numberOfRepeats 96 | * 97 | * @return \Memorize\Card 98 | */ 99 | public function setNumberOfRepeats($numberOfRepeats) 100 | { 101 | $this->numberOfRepeats = $numberOfRepeats; 102 | return $this; 103 | } 104 | 105 | /** 106 | * Get number of repetitions 107 | * @return int 108 | */ 109 | public function getNumberOfRepeats() 110 | { 111 | return $this->numberOfRepeats; 112 | } 113 | 114 | /** 115 | * Set E-factor 116 | * @param float $factor 117 | * 118 | * 119 | * @return \Memorize\Card 120 | */ 121 | public function setFactor($factor) 122 | { 123 | $this->factor = $factor; 124 | return $this; 125 | } 126 | 127 | /** 128 | * Get E-factor 129 | * @return float 130 | */ 131 | public function getFactor() 132 | { 133 | return $this->factor; 134 | } 135 | 136 | /** 137 | * Set next repetition occurrence (UNIX timestamp) 138 | * @param int $nextTime 139 | * 140 | * @return \Memorize\Card 141 | */ 142 | public function setNextTime($nextTime) 143 | { 144 | $this->nextTime = $nextTime; 145 | return $this; 146 | } 147 | 148 | /** 149 | * Get next repetition occurrence (UNIX timestamp) 150 | * @return int 151 | */ 152 | public function getNextTime() 153 | { 154 | return $this->nextTime; 155 | } 156 | 157 | /** 158 | * Repeat the card 159 | * 160 | * This method takes an instance of SM2 and a quality factor to update 161 | * the flash card accordingly after a repetition. 162 | * 163 | * @param \Memorize\SM2 $SM2 An instance of an SM2 object 164 | * @param int $quality The quality of the answer 165 | */ 166 | public function repeat($SM2, $quality) 167 | { 168 | if ($quality >= 3) { 169 | $this->numberOfRepeats++; 170 | } else { 171 | $this->numberOfRepeats = 1; 172 | } 173 | 174 | $newFactor = $SM2->calcNewFactor($this->factor, $quality); 175 | $this->factor = $newFactor; 176 | 177 | $interval = $SM2->calcInterval($this->numberOfRepeats, $newFactor); 178 | $this->nextTime = time() + $interval*24*60*60; 179 | } 180 | 181 | /** 182 | * Encode the card in JSON 183 | * 184 | * @return String The JSON encoded Card object 185 | */ 186 | public function jsonSerialize() 187 | { 188 | return [ 189 | 'question' => $this->question, 190 | 'answer' => $this->answer, 191 | 'numberOfRepeats' => $this->numberOfRepeats, 192 | 'factor' => $this->factor, 193 | 'nextTime' => $this->nextTime 194 | ]; 195 | } 196 | 197 | /** 198 | * toJson method for semantic purposes. 199 | * 200 | * @return String JSON encoded Card object 201 | */ 202 | public function toJson() 203 | { 204 | return json_encode($this); 205 | } 206 | 207 | /** 208 | * Create Card from JSON encoded string. 209 | * 210 | * @param String $json 211 | */ 212 | public static function fromJson($json) 213 | { 214 | $card = json_decode($json, true); 215 | return new Card($card['question'],$card['answer'],$card['numberOfRepeats'], 216 | $card['factor'],$card['nextTime']); 217 | } 218 | 219 | /** 220 | * Convert the Card object to an array. 221 | * 222 | * @return Array Card object as array. 223 | */ 224 | public function toArray() 225 | { 226 | return array( 227 | 'question' => $this->question, 228 | 'answer' => $this->answer, 229 | 'numberOfRepeats' => $this->numberOfRepeats, 230 | 'factor' => $this->factor, 231 | 'nextTime' => $this->nextTime 232 | ); 233 | } 234 | 235 | /** 236 | * Create card from array. 237 | * 238 | * @param Array $card 239 | */ 240 | public static function fromArray(array $card) 241 | { 242 | return new Card($card['question'],$card['answer'],$card['numberOfRepeats'], 243 | $card['factor'],$card['nextTime']); 244 | } 245 | 246 | } -------------------------------------------------------------------------------- /src/CardQueue.php: -------------------------------------------------------------------------------- 1 | getNextTime()); 45 | } else { 46 | throw new \InvalidArgumentException('CardQueue only accepts objects of the type Card'); 47 | } 48 | } 49 | 50 | } -------------------------------------------------------------------------------- /src/Repeater.php: -------------------------------------------------------------------------------- 1 | queue = $queue; 28 | } 29 | 30 | /** 31 | * Remove the current Card from the queue. 32 | */ 33 | private function removeCard() 34 | { 35 | return $this->queue->extract(); 36 | } 37 | 38 | /** 39 | * Insert a Card object in the queue. 40 | * 41 | * @param Card $card 42 | */ 43 | private function reinsertCard(Card $card) 44 | { 45 | $this->queue->insert($card); 46 | } 47 | 48 | /** 49 | * Get the current Card in the queue. 50 | */ 51 | public function getCard() 52 | { 53 | return $this->queue->current(); 54 | } 55 | 56 | /** 57 | * Register an answer on a card and handle the card accordingly. 58 | * 59 | * @param SM2 $SM2 An instance of SM2 60 | * @param int $quality The quality of the answer 61 | */ 62 | public function answer($SM2, $quality) 63 | { 64 | /* Get the current card and call its repeat method */ 65 | $this->getCard()->repeat($SM2, $quality); 66 | 67 | /* Remove the card from the queue */ 68 | $card = $this->removeCard(); 69 | 70 | /* If the quality was below 4, or the card is set to be repeated again 71 | * in less than a day, reinsert it to the queue. */ 72 | if ($quality < 4 || $card->getNextTime() - time() < 60*60*24) { 73 | $this->reinsertCard($card); 74 | } 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /src/SM2.php: -------------------------------------------------------------------------------- 1 | 5 || $quality < 0) { 65 | throw new \RangeException('Quality must be between 0 and 5'); 66 | } 67 | 68 | $newFactor = $oldFactor+(0.1-(5-$quality)*(0.08+(5-$quality)*0.02)); 69 | 70 | return $newFactor > 1.3 ? ($newFactor < 2.5 ? $newFactor : 2.5) : 1.3; 71 | } 72 | 73 | } -------------------------------------------------------------------------------- /tests/CardTest.php: -------------------------------------------------------------------------------- 1 | repeat($memorize, rand(3,5)); 26 | $card->repeat($memorize, rand(3,5)); 27 | $card->repeat($memorize, rand(3,5)); 28 | $this->assertEquals(3, $card->getNumberOfRepeats()); 29 | } 30 | 31 | /** 32 | * A repetition with a factor lower than 3 should reset the number of 33 | * repetitions, regardless of previous performance. 34 | */ 35 | public function testBadRepeat() 36 | { 37 | $card = new Card(); 38 | $memorize = new SM2(); 39 | $card->repeat($memorize, rand(3,5)); 40 | $card->repeat($memorize, rand(3,5)); 41 | $card->repeat($memorize, rand(0,2)); 42 | $this->assertEquals(1, $card->getNumberOfRepeats()); 43 | } 44 | 45 | /** 46 | * Create a card from JSON and make sure it's correct. 47 | */ 48 | public function testFromJson() 49 | { 50 | $json = json_encode(array( 51 | 'question' => 'This is the question', 52 | 'answer' => 'This is the answer', 53 | 'numberOfRepeats' => '5', 54 | 'factor' => '2.4', 55 | 'nextTime' => '123' 56 | )); 57 | 58 | $card = Card::fromJson($json); 59 | 60 | $this->assertEquals('This is the question', $card->getQuestion()); 61 | $this->assertEquals('This is the answer', $card->getAnswer()); 62 | $this->assertEquals(5, $card->getNumberOfRepeats()); 63 | $this->assertEquals(2.4, $card->getFactor()); 64 | $this->assertEquals(123, $card->getNextTime()); 65 | } 66 | 67 | /** 68 | * Create a card, encode it in JSON, and then create a new Card from the same JSON. 69 | */ 70 | public function testJsonSwitcharoo() 71 | { 72 | $card = new Card('What is your name?', 'Peter'); 73 | $json = $card->toJson(); 74 | $newCard = Card::fromJson($json); 75 | $this->assertEquals('What is your name?', $newCard->getQuestion()); 76 | } 77 | 78 | /** 79 | * Create a card from an array and make sure it's correct. 80 | */ 81 | public function testFromArray() 82 | { 83 | $array = array( 84 | 'question' => 'This is the question', 85 | 'answer' => 'This is the answer', 86 | 'numberOfRepeats' => '5', 87 | 'factor' => '2.4', 88 | 'nextTime' => '123' 89 | ); 90 | 91 | $card = Card::fromArray($array); 92 | 93 | $this->assertEquals('This is the question', $card->getQuestion()); 94 | $this->assertEquals('This is the answer', $card->getAnswer()); 95 | $this->assertEquals(5, $card->getNumberOfRepeats()); 96 | $this->assertEquals(2.4, $card->getFactor()); 97 | $this->assertEquals(123, $card->getNextTime()); 98 | } 99 | 100 | /** 101 | * Create a card, convert it to an array, and then create a new Card from the array. 102 | */ 103 | public function testArraySwitcharoo() 104 | { 105 | $card = new Card('What is your name?', 'Peter'); 106 | $array = $card->toArray(); 107 | $newCard = Card::fromArray($array); 108 | $this->assertEquals('What is your name?', $newCard->getQuestion()); 109 | } 110 | } -------------------------------------------------------------------------------- /tests/MemorizeTest.php: -------------------------------------------------------------------------------- 1 | calcInterval(0); 26 | } 27 | 28 | /** 29 | * 1 repetition should always give interval = 1, 2 should give 6 and a test 30 | * case on 3 with E-factor 2.5 should arithmetically give 15. 31 | */ 32 | public function testIntervalCases() 33 | { 34 | $mem = new SM2(); 35 | $this->assertEquals(1, $mem->calcInterval(1, rand())); 36 | $this->assertEquals(6, $mem->calcInterval(2, rand())); 37 | $this->assertEquals(15, $mem->calcInterval(3,2.5)); 38 | } 39 | 40 | /** 41 | * calcInterval should always return a float. 42 | */ 43 | public function testIntervalIsFloat() 44 | { 45 | $mem = new SM2(); 46 | $this->assertInternalType('float', $mem->calcInterval()); 47 | } 48 | 49 | /** 50 | * Quality should not be able to be higher than 6. 51 | * 52 | * @expectedException RangeException 53 | * @expectedExceptionMessage Quality must be between 0 and 5 54 | */ 55 | public function testQualityTooHigh() 56 | { 57 | $mem = new SM2(); 58 | $mem->calcNewFactor(2.5,6); 59 | } 60 | 61 | /** 62 | * Quality should not be able to be lower than 0. 63 | * 64 | * @expectedException RangeException 65 | * @expectedExceptionMessage Quality must be between 0 and 5 66 | */ 67 | public function testQualityTooLow() 68 | { 69 | $mem = new SM2(); 70 | $mem->calcNewFactor(2.5,-1); 71 | } 72 | 73 | /** 74 | * A factor of 1.4 with response quality 1 should generate a factor of 0.86. 75 | * However, 1.3 should always be the minimum factor. 76 | */ 77 | public function testFactorMinimum() 78 | { 79 | $mem = new SM2(); 80 | $this->assertEquals(1.3, $mem->calcNewFactor(1.4,1)); 81 | } 82 | 83 | /** 84 | * A factor of 2.5 with response quality 5 should generate a factor of 2.6. 85 | * However, 2.5 should always be the maximum factor. 86 | */ 87 | public function testFactorMaximum() 88 | { 89 | $mem = new SM2(); 90 | $this->assertEquals(2.5, $mem->calcNewFactor(2.5,5)); 91 | } 92 | 93 | /** 94 | * A response of quality 4 should not change a factor between 1.3 and 2.5. 95 | */ 96 | public function testQuality4() { 97 | $mem = new SM2(); 98 | $oldFactor = rand(13,25)/10; 99 | $this->assertEquals($oldFactor,$mem->calcNewFactor($oldFactor,4)); 100 | } 101 | 102 | /** 103 | * calcNewFactor should always return a float. 104 | */ 105 | public function testFactorIsFloat() { 106 | $mem = new SM2(); 107 | $this->assertInternalType('float', $mem->calcNewFactor()); 108 | } 109 | } --------------------------------------------------------------------------------