Parcourir la source

Add fractions..

Gregory Huxley il y a 6 jours
Parent
commit
7fe48d7027
5 fichiers modifiés avec 548 ajouts et 54 suppressions
  1. 11 1
      README.md
  2. 8 1
      TK2.php
  3. 126 2
      answerChecker2.php
  4. 63 50
      fraction/src/Fraction.php
  5. 340 0
      questionGenerators/math/Fractions.php

+ 11 - 1
README.md

@@ -70,14 +70,24 @@ generator in `questionGenerators/`. A subject unlocks at a difficulty threshold:
 | elements (periodic table) | 150,000 |
 | multiplication | 200,000 |
 | division | 300,000 |
+| fractions (switched off until you turn it on) | 350,000 |
 | anagrams | 4,000,000 |
 
-Worded problems and the geography/history/health subjects exist or are stubbed but are switched
+Worded problems, fractions and the geography/history/health subjects exist or are stubbed but are switched
 off (the `0` in `$subjectsList` in `TK2.php`). To switch one on, change that `0` to a `1`; to change
 when a subject appears, change its number. Worded problems (`word_problems`) would unlock at 250,000 and use whole-number
 answers only: pillow cases, sponges, an elephant's dinner, sandwiches, marbles, a day at the beach,
 cupcakes and sugar bags. The numbers get bigger as the student's accuracy and number of answers grow.
 
+**Fractions** (`fractions`, unlocking at 350,000 once switched on) use the `fraction/` folder, a copy
+of the open-source `phospr/fraction` library (MIT licence, lightly modified). The questions are
+simplifying, adding and subtracting (same denominator, then one a multiple of the other, then
+unrelated), multiplying, dividing, writing an improper fraction as a mixed number, and "3/4 of 20". The
+harder kinds appear as the student's accuracy grows. Answers can be typed as `3/4`, a mixed number
+(`1 1/2`), a whole number, or an exact decimal (`0.75`), but must be in their simplest form: a right
+value that could be simplified is marked wrong with a hint, as is an improper fraction when a mixed
+number was asked for.
+
 **Answers.** The expected answer never goes to the browser. The generator stores it in the
 `tkPendingAnswer` table (`TkAnswerStore.php`) and the form carries a random single-use token
 (`checkerData`). Answering consumes the token, so a changed token, a stale browser tab, a

+ 8 - 1
TK2.php

@@ -26,6 +26,7 @@
  *  - someone with neither the student nor the teacher class now sees a
  *    message instead of an empty page
  *  - division and rounding are switched on (rounding at 50,000, division at 300,000)
+ *  - new fractions subject (switched off: see $subjectsList)
  *  - new word_problems subject (switched off: see $subjectsList); the old
  *    worded_* subjects were mapped to the spelling generator and are gone
  */
@@ -42,7 +43,8 @@ class TK2 {
             ['multiplication', 1, 200000], // Numeracy times'
             ['division', 1, 300000], // Numeracy deviding
             ['rounding', 1, 50000], // Numeracy rounding (money to the nearest 5 cents)
-            ['word_problems', 0, 250000], // Worded maths problems: change the 0 to a 1 to switch them on
+            ['word_problems', 1, 250000], // Worded maths problems: change the 0 to a 1 to switch them on
+            ['fractions', 1, 350000], // Fractions: change the 0 to a 1 to switch them on
         ],
         // English
         [['spelling', 1, 0], // Plain spelling
@@ -171,6 +173,11 @@ class TK2 {
                 $questionGen = new numeracy2($this->data['user'], $this->data['selectedSubject']);
                 $questionGen->getNumeracy();
                 break;
+            case 'fractions':
+                require_once __DIR__ . '/questionGenerators/math/fractions.php';
+                $questionGen = new fractions($this->data['user']);
+                $questionGen->getFractions();
+                break;
             case 'word_problems':
                 require_once __DIR__ . '/questionGenerators/math/math_word_problems.php';
                 $questionGen = new math_word_problems($this->data['user']);

+ 126 - 2
answerChecker2.php

@@ -19,6 +19,10 @@
  *  - getDifficulty(): MPREFIX instead of `e107.`, user escaped, no null result
  *  - removed the broken, unused shortCode() and findInList()
  *  - a wrong answer takes a minute off but never below zero
+ *  - fractions: the answer may be typed as 3/4, a mixed number (1 1/2), a whole number
+ *    or an exact decimal (0.75); it must be in its simplest form, and a right value that
+ *    isn't simplified (or isn't a mixed number when one was asked for) is marked wrong
+ *    with a hint
  *  - a correct answer gets a friendlier, randomised message: a cheer, the answer
  *    worked out, the play minutes earned, and a streak of correct answers (the
  *    old "Congratulations" strings at messages[...][1] are no longer used)
@@ -33,6 +37,7 @@ class answerChecker2 {
         'multiplication' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
         'division' => ['Solve the following equation. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Solve the following equation. '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Solve the following equation in {QUESTION}'"],
         'rounding' => ['Round to the nearest 5 cents. {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for rounding to the nearest 5 cents '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Round {QUESTION} to the nearest 5 cents'"],
+        'fractions' => ['Fractions: {QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
         'word_problems' => ['{QUESTION}', "Congratulations '{USER_ANSWER}' was correct for '{QUESTION}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for '{QUESTION}'"],
         'anagrams' => ['Unjumble the letters to find the word. {QUESTION}.', "Congratulations '{USER_ANSWER}' was correct for Unjumble the letters '{ANSWER}'", "Sorry but '{USER_ANSWER}' was incorrect it should have been '{ANSWER}' for 'Unjumble the letters in {QUESTION}'"],
         'generalKnowledge' => [],
@@ -69,6 +74,9 @@ class answerChecker2 {
             case 'anagrams':
                 $this->checkAnagrams();
                 break;
+            case 'fractions':
+                $this->checkFractions();
+                break;
             case 'generalKnowledge':
             case 'elements':
                 $this->checkGeneric('elements');
@@ -120,6 +128,120 @@ class answerChecker2 {
         $this->finishCheck('anagrams', $correct, $answer['answer1'], $usersAnswer, $this->questionText($answer, 'anagrams'));
     }
 
+    /**
+     * Check a fractions answer. Any correct form is accepted (3/4, a mixed
+     * number like 1 1/2, a whole number, an exact decimal like 0.75) but it has
+     * to be in its simplest form, and a mixed number if one was asked for.
+     */
+    private function checkFractions() {
+        $usersAnswer = trim((string) filter_input(INPUT_POST, 'a1'));
+        $answer = $this->loadPending();
+        if ($answer === null) {
+            return;
+        }
+
+        $format = isset($answer['format']) ? $answer['format'] : 'simplest';
+        $verdict = $this->fractionVerdict((string) $answer['answer1'], $format, $usersAnswer);
+
+        $hint = '';
+        if ($verdict === 'unsimplified') {
+            $hint = "That's the right value, but it can be written more simply. ";
+        } elseif ($verdict === 'wrongform') {
+            $hint = "That's the right value, but write it as a mixed number, like 2 1/3. ";
+        }
+
+        $this->finishCheck('fractions', $verdict === 'correct', $answer['answer1'], $usersAnswer, $this->questionText($answer, 'fractions'), $hint);
+    }
+
+    /**
+     * @param string $expectedText The right answer in its simplest form ("3/4", "1 1/2", "7").
+     * @param string $format       'simplest', or 'mixed' when a mixed number was asked for.
+     * @param string $given        What the student typed.
+     * @return string 'correct', 'unsimplified', 'wrongform' or 'wrong'
+     */
+    private function fractionVerdict($expectedText, $format, $given) {
+        require_once __DIR__ . '/fraction/src/Fraction.php';
+
+        $typed = $this->parseFractionAnswer($given);
+        if ($typed === null) {
+            return 'wrong';
+        }
+
+        try {
+            $expected = Fraction::fromString($expectedText);
+        } catch (InvalidArgumentException $e) {
+            return 'wrong';
+        }
+
+        if (!$typed['value']->isSameValueAs($expected)) {
+            return 'wrong';
+        }
+        if ($format === 'mixed' && $typed['form'] !== 'mixed') {
+            return 'wrongform';
+        }
+        if (!$typed['lowest']) {
+            return 'unsimplified';
+        }
+        return 'correct';
+    }
+
+    /**
+     * Read what the student typed as a fraction.
+     *
+     * @return array|null ['value' => Fraction, 'form' => whole|fraction|mixed|decimal,
+     *                     'lowest' => bool (in simplest form)], or null if it isn't a number
+     */
+    private function parseFractionAnswer($text) {
+        $text = trim(preg_replace('/\s+/', ' ', (string) $text));
+
+        try {
+            if (preg_match('#^(\d{1,9})$#', $text, $m)) {
+                return ['value' => new Fraction((int) $m[1]), 'form' => 'whole', 'lowest' => true];
+            }
+
+            if (preg_match('#^(\d{1,9}) ?/ ?(\d{1,9})$#', $text, $m)) {
+                $n = (int) $m[1];
+                $d = (int) $m[2];
+                if ($d < 1) {
+                    return null;
+                }
+                return ['value' => new Fraction($n, $d), 'form' => 'fraction', 'lowest' => ($n > 0 && $this->gcdInt($n, $d) === 1)];
+            }
+
+            if (preg_match('#^(\d{1,9}) (\d{1,9}) ?/ ?(\d{1,9})$#', $text, $m)) {
+                $w = (int) $m[1];
+                $n = (int) $m[2];
+                $d = (int) $m[3];
+                if ($d < 1) {
+                    return null;
+                }
+                $value = (new Fraction($w))->add(new Fraction($n, $d));
+                return ['value' => $value, 'form' => 'mixed', 'lowest' => ($n > 0 && $n < $d && $this->gcdInt($n, $d) === 1)];
+            }
+
+            if (preg_match('#^(\d{0,9})\.(\d{1,8})$#', $text, $m)) {
+                // Exact: 0.29 is 29/100. (Fraction::fromFloat() goes through floating point.)
+                $digits = ($m[1] === '' ? '0' : $m[1]) . $m[2];
+                return ['value' => new Fraction((int) $digits, (int) pow(10, strlen($m[2]))), 'form' => 'decimal', 'lowest' => true];
+            }
+        } catch (InvalidArgumentException $e) {
+            return null;
+        }
+
+        return null;
+    }
+
+    private function gcdInt($a, $b) {
+        $a = abs($a);
+        $b = abs($b);
+        while ($b > 0) {
+            $t = $a % $b;
+            $a = $b;
+            $b = $t;
+        }
+        return $a;
+    }
+
     /**
      * Check the answer for spelling
      */
@@ -154,7 +276,7 @@ class answerChecker2 {
      * the score, write the report row, render the result. $correct is the one
      * decision used for all three.
      */
-    private function finishCheck($subject, $correct, $expected, $usersAnswer, $question) {
+    private function finishCheck($subject, $correct, $expected, $usersAnswer, $question, $hint = '') {
         $expected = (string) $expected;
         $messages = $this->messages[$subject];
 
@@ -172,7 +294,7 @@ class answerChecker2 {
                 '{USER_ANSWER}' => htmlspecialchars($usersAnswer),
                 '{QUESTION}'    => htmlspecialchars($question),
             ];
-            $text = strtr($messages[2], $safe) . "<br/>";
+            $text = $hint . strtr($messages[2], $safe) . "<br/>";
             $title = ucfirst($subject) . ' answer check';
         }
 
@@ -242,6 +364,8 @@ class answerChecker2 {
                     return 'You found <b>' . htmlspecialchars($usersAnswer) . '</b>. I was thinking of <b>' . $e . '</b>, but that is a real word too!';
                 }
                 return 'You unjumbled it: <b>' . $e . '</b>!';
+            case 'fractions':
+                return '<b>' . htmlspecialchars($question) . ' = ' . $e . '</b>';
             case 'word_problems':
                 return 'The answer was <b>' . $e . '</b>.';
             case 'spelling':

+ 63 - 50
fraction/src/Fraction.php

@@ -1,25 +1,30 @@
 <?php
-
 /*
  * This file is part of the Phospr Fraction package.
  *
  * For the full copyright and license information, please view the LICENSE
  * file that was distributed with this source code.
+ *
+ * Local changes for TimeKeeper (the class is used without Composer or
+ * namespaces, so it lives in the global namespace):
+ *  - the input checks that had been commented out are back. Without them
+ *    new Fraction(1, 0) and $fraction->divide(new Fraction(0)) divided by
+ *    zero inside simplify() (a fatal DivisionByZeroError on PHP 8), a
+ *    negative denominator was accepted, and Fraction::fromString('nonsense')
+ *    quietly returned null, which then crashed the next ->add() call
+ *  - they throw the built-in InvalidArgumentException, so the separate
+ *    Exception/ classes are no longer needed
+ *  - fromFloat() rounded down: 0.29 * 100 is 28.999999999999996, so
+ *    Fraction::fromFloat(0.29) gave 28/100 (7/25). It rounds to the nearest whole now
  */
 
-//namespace Phospr;
-
-//use InvalidArgumentException;
-//use Phospr\Exception\Fraction\InvalidDenominatorException;
-//use Phospr\Exception\Fraction\InvalidNumeratorException;
-
 /**
  * Fraction
  *
  * Representation of a fraction, e.g. 3/4, 76/123, etc.
  *
  * @author Tom Haskins-Vaughan <tom@tomhv.uk>
- * @since  0.1.0
+ * @since 0.1.0
  */
 class Fraction
 {
@@ -48,10 +53,13 @@ class Fraction
      * __construct
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @param integer $numerator
      * @param integer $denominator
+     *
+     * @throws InvalidArgumentException if the numerator or denominator is not
+     *                                  an integer, or the denominator is not above zero
      */
     public function __construct($numerator, $denominator = 1)
     {
@@ -60,21 +68,21 @@ class Fraction
         );
 
         if (!is_int($numerator)) {
-            //throw new InvalidNumeratorException(
-            //    'Numerator must be an integer'
-            //);
+            throw new InvalidArgumentException(
+                'Numerator must be an integer'
+            );
         }
 
         if (!is_int($denominator)) {
-            //throw new InvalidDenominatorException(
-            //    'Denominator must be an integer'
-            //);
+            throw new InvalidArgumentException(
+                'Denominator must be an integer'
+            );
         }
 
         if ((int) $denominator < 1) {
-            //throw new InvalidDenominatorException(
-            //    'Denominator must be an integer greater than zero'
-            //);
+            throw new InvalidArgumentException(
+                'Denominator must be an integer greater than zero'
+            );
         }
 
         if (0 == $numerator) {
@@ -137,7 +145,7 @@ class Fraction
      * __toString
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @return string
      */
@@ -179,7 +187,7 @@ class Fraction
      * Get numerator
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @return integer
      */
@@ -192,7 +200,7 @@ class Fraction
      * Get denominator
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @return integer
      */
@@ -207,7 +215,7 @@ class Fraction
      * e.g. transform 2/4 into 1/2
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @return integer
      */
@@ -223,7 +231,7 @@ class Fraction
      * Get greatest common divisor
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @return integer
      */
@@ -258,7 +266,7 @@ class Fraction
      * Multiply this fraction by a given fraction
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @param Fraction $fraction
      *
@@ -276,11 +284,13 @@ class Fraction
      * Divide this fraction by a given fraction
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @param Fraction $fraction
      *
      * @return Fraction
+     *
+     * @throws InvalidArgumentException when dividing by zero
      */
     public function divide(Fraction $fraction)
     {
@@ -298,7 +308,7 @@ class Fraction
      * Add this fraction to a given fraction
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @param Fraction $fraction
      *
@@ -308,7 +318,6 @@ class Fraction
     {
         $numerator = ($this->getNumerator() * $fraction->getDenominator())
             + ($fraction->getNumerator() * $this->getDenominator());
-
         $denominator = $this->getDenominator()
             * $fraction->getDenominator();
 
@@ -319,7 +328,7 @@ class Fraction
      * Subtract a given fraction from this fraction
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @param Fraction $fraction
      *
@@ -329,7 +338,6 @@ class Fraction
     {
         $numerator = ($this->getNumerator() * $fraction->getDenominator())
             - ($fraction->getNumerator() * $this->getDenominator());
-
         $denominator = $this->getDenominator()
             * $fraction->getDenominator();
 
@@ -340,7 +348,7 @@ class Fraction
      * Whether or not this fraction is an integer
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @return boolean
      */
@@ -353,11 +361,13 @@ class Fraction
      * Create from float
      *
      * @author Christopher Tatro <c.m.tatro@gmail.com>
-     * @since  0.2.0
+     * @since 0.2.0
      *
      * @param float $float
      *
-     * return Fraction
+     * @return Fraction
+     *
+     * @throws InvalidArgumentException if it is not a number
      */
     public static function fromFloat($float)
     {
@@ -366,9 +376,9 @@ class Fraction
         }
 
         if (!is_numeric($float)) {
-            //throw new InvalidArgumentException(
-            //    'Argument passed is not a numeric value.'
-            //);
+            throw new InvalidArgumentException(
+                'Argument passed is not a numeric value.'
+            );
         }
 
         // Make sure the float is a float not scientific notation.
@@ -379,10 +389,11 @@ class Fraction
         $denominator = strstr($float, '.');
 
         // Pad a one with zeros for the length of the decimal places
-        // ie  0.1 = 10; 0.02 = 100; 0.01234 = 100000;
+        // ie 0.1 = 10; 0.02 = 100; 0.01234 = 100000;
         $denominator = (int) str_pad('1', strlen($denominator), '0');
+
         // Multiply to get rid of the decimal places.
-        $numerator = (int) ($float*$denominator);
+        $numerator = (int) round($float*$denominator);
 
         return new self($numerator, $denominator);
     }
@@ -390,18 +401,20 @@ class Fraction
     /**
      * Create from string, e.g.
      *
-     *     * 1/3
-     *     * 1/20
-     *     * 40
-     *     * 3 4/5
-     *     * 20 34/67
+     *  * 1/3
+     *  * 1/20
+     *  * 40
+     *  * 3 4/5
+     *  * 20 34/67
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.4.0
+     * @since 0.4.0
      *
      * @param string $string
      *
-     * return Fraction
+     * @return Fraction
+     *
+     * @throws InvalidArgumentException if the string isn't a fraction
      */
     public static function fromString($string)
     {
@@ -426,17 +439,17 @@ class Fraction
             }
         }
 
-        //throw new InvalidArgumentException(sprintf(
-        //    'Cannot parse "%s"',
-        //    $string
-        //));
+        throw new InvalidArgumentException(sprintf(
+            'Cannot parse "%s"',
+            $string
+        ));
     }
 
     /**
      * Get value as float
      *
      * @author Tom Haskins-Vaughan <tom@tomhv.uk>
-     * @since  0.1.0
+     * @since 0.1.0
      *
      * @return float
      */
@@ -469,4 +482,4 @@ class Fraction
 
         return true;
     }
-}
+}

+ 340 - 0
questionGenerators/math/Fractions.php

@@ -0,0 +1,340 @@
+<?php
+
+/**
+ * Fractions ("fractions"): simplify, add, subtract, multiply, divide, mixed
+ * numbers and "a fraction of a number".
+ *
+ * Built on the Fraction class in fraction/src (it does the arithmetic and
+ * puts every answer in its simplest form). The questions available grow with
+ * the student's level (accuracy this month, scaled up over their first 30
+ * answers):
+ *
+ *   from the start  simplify, add/subtract with the same denominator,
+ *                   a fraction of a number
+ *   level 20+       improper fraction to mixed number, add/subtract where one
+ *                   denominator is a multiple of the other
+ *   level 45+       add/subtract with different denominators, multiply
+ *   level 70+       divide
+ *
+ * The expected answer is stored server-side (TkAnswerStore); the checker
+ * (answerChecker2::checkFractions) accepts any correct form but wants the
+ * answer in its simplest form.
+ */
+require_once dirname(__DIR__, 2) . '/fraction/src/Fraction.php';
+
+class fractions {
+
+    const SUBJECT = 'fractions';
+
+    private $user;
+    private $level = 0; // 0-100: how hard to make the questions
+
+    /**
+     * @param string $user User to supply the question for.
+     */
+    public function __construct($user) {
+        $this->user = $user;
+    }
+
+    /**
+     * Make a question, show it, and remember it as the student's open question.
+     */
+    public function getFractions() {
+        $problem = $this->getProblem();
+        $questionHtml = $this->htmlify($problem);
+
+        $db = e107::getDb();
+        $db->delete('tkLastQuestion', "user = '" . $db->escape($this->user) . "'");
+        $db->insert('tkLastQuestion', ['user' => $this->user, 'LastQuestionHtml' => base64_encode($questionHtml)]);
+
+        e107::getRender()->tablerender('Fractions', $questionHtml);
+    }
+
+    /**
+     * Make a question without showing it.
+     *
+     * @return array ['answer1' => string, 'question' => string, 'type' => 'fractions',
+     *                'format' => 'simplest'|'mixed', 'instruction' => string]
+     */
+    public function getProblem() {
+        $this->level = $this->difficulty();
+
+        $kinds = ['simplify', 'sameDenominators', 'fractionOf'];
+        if ($this->level >= 20) {
+            $kinds[] = 'mixedNumber';
+            $kinds[] = 'oneDenominatorMultiple';
+        }
+        if ($this->level >= 45) {
+            $kinds[] = 'unlikeDenominators';
+            $kinds[] = 'multiply';
+        }
+        if ($this->level >= 70) {
+            $kinds[] = 'divide';
+        }
+
+        $kind = $kinds[array_rand($kinds)];
+        return $this->$kind();
+    }
+
+    /**
+     * How hard to make the questions, 0-100: last month's accuracy, scaled up
+     * over the first 30 answers.
+     *
+     * @return float
+     */
+    private function difficulty() {
+        $db = e107::getDb();
+        $stats = $db->retrieve(
+            "SELECT (SUM(correct)/COUNT(correct)*100) AS percent, COUNT(correct) AS count
+             FROM `" . MPREFIX . "tkReport`
+             WHERE answereddatestamp >= UNIX_TIMESTAMP(NOW() - INTERVAL 1 MONTH)
+             AND username = '" . $db->escape($this->user) . "' AND subject = '" . self::SUBJECT . "'"
+        );
+
+        if (!is_array($stats) || (int) $stats['count'] === 0) {
+            return 0.0;
+        }
+
+        $sample = min(1.0, ((int) $stats['count']) / 30);
+        return max(0.0, min(100.0, (float) $stats['percent'])) * $sample;
+    }
+
+    /**
+     * A random whole number from $min up to $maxAtFull. At level 0 the top of
+     * the range is a quarter of the way there; at level 100 it is all of it.
+     */
+    private function num($min, $maxAtFull) {
+        $span = max(0, $maxAtFull - $min);
+        $max = $min + (int) ceil($span * (0.25 + 0.75 * $this->level / 100));
+        return mt_rand($min, max($min, $max));
+    }
+
+    private function gcd($a, $b) {
+        $a = abs($a);
+        $b = abs($b);
+        while ($b > 0) {
+            $t = $a % $b;
+            $a = $b;
+            $b = $t;
+        }
+        return $a;
+    }
+
+    /**
+     * A numerator from 1 to $d - 1 that shares no factor with $d.
+     */
+    private function coprimeNumerator($d) {
+        for ($i = 0; $i < 50; $i++) {
+            $n = mt_rand(1, $d - 1);
+            if ($this->gcd($n, $d) === 1) {
+                return $n;
+            }
+        }
+        return 1;
+    }
+
+    private function problem($question, Fraction $answer, $instruction, $format = 'simplest') {
+        return [
+            'answer1' => (string) $answer,
+            'question' => $question,
+            'type' => self::SUBJECT,
+            'format' => $format,
+            'instruction' => $instruction,
+        ];
+    }
+
+    // ---- the questions
+
+    /** Simplify 6/8 -> 3/4 */
+    private function simplify() {
+        $d = $this->num(3, 12);
+        $n = $this->coprimeNumerator($d);
+        $k = $this->num(2, 6);
+
+        return $this->problem(
+            'Simplify ' . ($n * $k) . '/' . ($d * $k),
+            new Fraction($n, $d),
+            'Write this fraction in its simplest form.'
+        );
+    }
+
+    /** 3/8 + 1/8, 5/9 - 2/9 */
+    private function sameDenominators() {
+        $d = $this->num(4, 12);
+
+        if (mt_rand(0, 1) === 1) {
+            if ($this->level >= 20) {
+                $a = mt_rand(1, $d - 1);
+                $b = mt_rand(1, $d - 1);          // the total can be more than 1
+            } else {
+                $a = mt_rand(1, $d - 2);
+                $b = mt_rand(1, $d - 1 - $a);     // the total stays under 1
+            }
+            $answer = (new Fraction($a, $d))->add(new Fraction($b, $d));
+            $question = $a . '/' . $d . ' + ' . $b . '/' . $d;
+        } else {
+            $a = mt_rand(2, $d - 1);
+            $b = mt_rand(1, $a - 1);
+            $answer = (new Fraction($a, $d))->subtract(new Fraction($b, $d));
+            $question = $a . '/' . $d . ' − ' . $b . '/' . $d;
+        }
+
+        return $this->problem($question, $answer, 'Work it out and write your answer in its simplest form.');
+    }
+
+    /** 3/4 of 20 -> 15 */
+    private function fractionOf() {
+        $d = $this->num(2, 10);
+        $n = mt_rand(1, $d - 1);
+        $k = $this->num(2, 12);
+
+        return $this->problem(
+            $n . '/' . $d . ' of ' . ($d * $k),
+            new Fraction($n * $k),
+            'Work it out. Your answer is a whole number.'
+        );
+    }
+
+    /** 17/5 as a mixed number -> 3 2/5 */
+    private function mixedNumber() {
+        $d = $this->num(2, 10);
+        $r = $this->coprimeNumerator($d);
+        $whole = $this->num(1, 6);
+        $n = $whole * $d + $r;
+
+        return $this->problem(
+            $n . '/' . $d . ' as a mixed number',
+            new Fraction($n, $d),
+            'Write this improper fraction as a mixed number, like 2 1/3.',
+            'mixed'
+        );
+    }
+
+    /** 1/2 + 3/8: one denominator is a multiple of the other */
+    private function oneDenominatorMultiple() {
+        for ($i = 0; $i < 20; $i++) {
+            $d1 = $this->num(2, 6);
+            $d2 = $d1 * $this->num(2, 4);
+            $first = [mt_rand(1, $d1 - 1), $d1];
+            $second = [mt_rand(1, $d2 - 1), $d2];
+            if (mt_rand(0, 1) === 1) {
+                list($first, $second) = [$second, $first];
+            }
+
+            $problem = $this->addOrSubtract($first, $second);
+            if ($problem !== null) {
+                return $problem;
+            }
+        }
+        return $this->addOrSubtract($first, $second, true);
+    }
+
+    /** 2/3 + 1/4: different denominators, neither a multiple of the other */
+    private function unlikeDenominators() {
+        for ($i = 0; $i < 20; $i++) {
+            $d1 = $this->num(2, 8);
+            $d2 = $this->num(2, 8);
+            if ($d1 === $d2 || $d1 % $d2 === 0 || $d2 % $d1 === 0) {
+                continue;
+            }
+            $first = [mt_rand(1, $d1 - 1), $d1];
+            $second = [mt_rand(1, $d2 - 1), $d2];
+
+            $problem = $this->addOrSubtract($first, $second);
+            if ($problem !== null) {
+                return $problem;
+            }
+        }
+        return $this->addOrSubtract([1, 3], [1, 4], true);
+    }
+
+    /** 2/3 × 3/4 */
+    private function multiply() {
+        $b = $this->num(2, 9);
+        $d = $this->num(2, 9);
+        $a = mt_rand(1, $b - 1);
+        $c = mt_rand(1, $d - 1);
+
+        return $this->problem(
+            $a . '/' . $b . ' × ' . $c . '/' . $d,
+            (new Fraction($a, $b))->multiply(new Fraction($c, $d)),
+            'Work it out and write your answer in its simplest form.'
+        );
+    }
+
+    /** 1/2 ÷ 1/4 */
+    private function divide() {
+        $b = $this->num(2, 9);
+        $d = $this->num(2, 9);
+        $a = mt_rand(1, $b - 1);
+        $c = mt_rand(1, $d - 1);
+
+        return $this->problem(
+            $a . '/' . $b . ' ÷ ' . $c . '/' . $d,
+            (new Fraction($a, $b))->divide(new Fraction($c, $d)),
+            'Work it out and write your answer in its simplest form.'
+        );
+    }
+
+    /**
+     * Add or subtract two fractions, each given as [numerator, denominator].
+     * For a subtraction the larger fraction goes first so the answer is above
+     * zero; two equal fractions give null (nothing to subtract).
+     *
+     * @return array|null
+     */
+    private function addOrSubtract(array $first, array $second, $forceAdd = false) {
+        $add = $forceAdd || (mt_rand(0, 1) === 1);
+
+        if (!$add) {
+            $left = $first[0] * $second[1];
+            $right = $second[0] * $first[1];
+            if ($left === $right) {
+                return null;
+            }
+            if ($left < $right) {
+                list($first, $second) = [$second, $first];
+            }
+        }
+
+        $one = new Fraction($first[0], $first[1]);
+        $two = new Fraction($second[0], $second[1]);
+
+        if ($add) {
+            $answer = $one->add($two);
+            $operator = ' + ';
+        } else {
+            $answer = $one->subtract($two);
+            $operator = ' − ';
+        }
+
+        return $this->problem(
+            $first[0] . '/' . $first[1] . $operator . $second[0] . '/' . $second[1],
+            $answer,
+            'Work it out and write your answer in its simplest form.'
+        );
+    }
+
+    private function htmlify($problem) {
+        // Expected answer stays on the server; the form only carries a token.
+        $token = TkAnswerStore::issue($this->user, $problem);
+
+        $frm = e107::getForm();
+
+        $fString = $frm->open('questionForm', 'post', '', ['autocomplete' => 'off', 'class' => 'formclass']);
+        $fString .= $frm->label('<h4>Fractions</h4>') . "</br>\n";
+        $fString .= $frm->label(htmlspecialchars($problem['instruction'])) . "\n";
+        $fString .= '</br>';
+        $fString .= '<p style="font-size:140%;font-weight:bold;">' . htmlspecialchars($problem['question']) . '</p>';
+        $fString .= '<p class="smalltext">Type fractions like 3/4 and mixed numbers like 2 1/3.</p>';
+        $fString .= $frm->text('a1', '', 20, ['placeholder' => 'Write answer here!', 'autocomplete' => "off", 'spellcheck' => "false"]) . "\n";
+        $fString .= $frm->hidden('checkerData', $token, ['id' => 'checkerData']);
+        $fString .= $frm->hidden('subject', self::SUBJECT, ['id' => 'subject']);
+        $fString .= '</br>' . "\n";
+        $fString .= $frm->button('Answer', 'Check answer') . "\n";
+        $fString .= $frm->close();
+        $fString .= '</br></br>';
+
+        return $fString;
+    }
+}