👀 SaMathGen is NPM library that generates mathematical expressions for educational purposes. https://samathgen.js.org/
  • Rust 59.3%
  • JavaScript 38.3%
  • HTML 2.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Azer 901a620a83 Add max, to keep every number in a task within 20 or any other bound
With answers from 10 to 20, the default, the numbers in a task run well
past them. Over 20 000 seeds, a number past 100 is in 10% of tasks of
two numbers and 34% of tasks of eight, up to 9720, and the README's own
task for a young class, adding and taking away without negatives, has
a number past 20 in 57% of tasks of three and up to 124 at six. A class
working within twenty had no way to ask for that.

max: 20 keeps every number in the task, every step of working it out
left to right and every quiz option between -20 and 20, or 0 and 20
with negatives: false. It is a whole number from 10 to 10000, 10000
being the bound every task already kept to. An answer has to be within
it, and without one the answer is drawn from half of max up to max when
max is below 20.

What cannot be built follows the same rules with max in place of
10000, and cargo test checks them for every answer under max 10, 13, 20
and 100, across all fifteen operator sets, negatives on and off and
lengths up to 32. A small max can grow a tree into a corner that no leaf
can be split out of: with / alone under max 10, -1 becomes (-8)/8 about
one time in two. Such a tree is grown again, up to 64 times. An answer
that cannot be split at all is still refused at once, and so is +
alone without negatives and an answer below the length, the one other
case that cannot be built, so a refusal costs what it did.

Without max nothing changes: 100 000 seeded tasks come out as before.
Quiz options now stay within 10000 as well, which only changes them for
answers within 4 of either end. The .wasm grows by 520 bytes.
2026-10-13 21:49:06 +04:00
.forgejo/workflows Rewrite the generator in Rust, as a tree that cannot report a wrong answer 2026-10-07 03:05:29 +04:00
.github/workflows Run the CI gate on GitHub Actions too, for the mirror 2026-10-11 16:51:29 +04:00
bench Add mathGenBatch, many tasks for one crossing into WebAssembly 2026-10-11 01:06:39 +04:00
js Add max, to keep every number in a task within 20 or any other bound 2026-10-13 21:49:06 +04:00
scripts Rewrite the generator in Rust, as a tree that cannot report a wrong answer 2026-10-07 03:05:29 +04:00
src Add max, to keep every number in a task within 20 or any other bound 2026-10-13 21:49:06 +04:00
test Add max, to keep every number in a task within 20 or any other bound 2026-10-13 21:49:06 +04:00
.gitattributes Rewrite the generator in Rust, as a tree that cannot report a wrong answer 2026-10-07 03:05:29 +04:00
.gitignore Rewrite the generator in Rust, as a tree that cannot report a wrong answer 2026-10-07 03:05:29 +04:00
Cargo.lock Rewrite the generator in Rust, as a tree that cannot report a wrong answer 2026-10-07 03:05:29 +04:00
Cargo.toml Rewrite the generator in Rust, as a tree that cannot report a wrong answer 2026-10-07 03:05:29 +04:00
LICENSE Initial commit 2021-12-22 15:21:27 +04:00
package-lock.json Rewrite the generator in Rust, as a tree that cannot report a wrong answer 2026-10-07 03:05:29 +04:00
package.json Hand each task to JavaScript as integers instead of JSON 2026-10-11 00:53:44 +04:00
README.md Add max, to keep every number in a task within 20 or any other bound 2026-10-13 21:49:06 +04:00

SaMathGen

NPM library that generates math expressions for education purposes.
You can report bug or request feature 👀

Table Of Contents

About The Library

Generating various math expressions independently, for example, for primary school educators — is a routine job.

This library can help you automate this process; it is enough to write the required arguments like expression's size, optionally brackets and other cool features.

The generator is written in Rust and ships as WebAssembly, so the same build runs in Node and in the browser. It has no runtime dependencies at all, in either language.

With 💚 by Azer Sadykhzadeh

Installation

npm install samathgen

The published package carries the compiled WebAssembly, so installing it needs no Rust toolchain.

Usage

Node

import { mathGen } from 'samathgen'
// or: const { mathGen } = require('samathgen')

const task = mathGen(4, { quizMode: true })

console.log(task.task.join(''))   // 36/6+2*5
console.log(task.answer)          // 16
console.log(task.quizOptions)     // [ 16, 18, 19, 15 ]

Browser

A WebAssembly module larger than 4 kB cannot be compiled synchronously on a browser's main thread, so the browser entry point asks you to await init() once:

import { init, mathGen } from 'samathgen'

await init()
mathGen(4, { quizMode: true })

Until init() has finished, mathGen and mathGenBatch throw an error that says so. If loading fails, as when the .wasm cannot be fetched, init() rejects and can be called again. The Node entry point exports init() too, resolving at once, so code written this way also runs under Node, in tests or on a server.

test/browser.html is a page that does exactly this and then checks 20 000 generated tasks; serve the repository over HTTP and open it.

API

mathGen(length, more?)

length is how many numbers the task contains, clamped to 2..=32; one that is not a number at all is a TypeError.

Option Default Meaning
answer a random integer in 10..=20, or from half of max up to max when that is below 20 The value the task must evaluate to, a whole number from -max to max. 0 is honoured.
brackets false Ask for a task that groups part of itself in brackets, as in (2+3)*4. The brackets around a negative number, as in 7-(-5), do not count, and a task of two numbers has no room for a group.
quizMode false Fill quizOptions with the answer and three near misses, each within 5 of it.
operators ['+', '-', '*', '/'] The operators the task may use, for example ['+', '-'] for a class that has not met multiplication yet.
negatives true Set to false to keep every number, every quiz option and every step of working the task out left to right at 0 or above.
max 10000 How far from zero any number may be: every number in the task, every step of working it out left to right and every quiz option. A whole number from 10 to 10000, so max: 20 is a task within twenty.
seed drawn from Math.random Pin the generator: the same seed always gives the same task. A whole number from 0 to Number.MAX_SAFE_INTEGER.

An option that is given but cannot be honoured throws instead of being quietly replaced: an answer, max or seed outside the range above or with a fraction is a RangeError, and a length, answer, max or seed that is not a number is a TypeError. null and undefined mean the default.

A few combinations cannot be built at all, and throw a RangeError rather than return a shorter task:

  • an answer of 0 without + or - (zero is not a product or a quotient of anything but zero), or without - once negatives is false;
  • an answer past half of max either side with / alone, since its numerator would pass max;
  • with negatives: false, a negative answer, + alone with an answer smaller than length (every term is at least 1), and an answer of max itself with neither + nor *.

With * alone a prime answer can only become 1 * p, so expect plenty of ones. By default about one task in three of length 3 contains a negative number, and one in two of length 5.

// adding and taking away within twenty, nothing below zero
mathGen(3, { operators: ['+', '-'], negatives: false, max: 20 })

Without a max, numbers run well past the answer: with answers from 10 to 20, a number past 100 turns up in 10% of tasks of two numbers and 34% of tasks of eight, and adding and taking away without negatives passes 20 in 57% of tasks of three. With max: 20 none of them does.

Returns:

Field Type Meaning
task Array<number | '+' | '-' | '*' | '/' | '(' | ')'> The task, token by token.
answer number What the task evaluates to.
quizOptions number[] Four options including the answer, shuffled. The answer is as likely to be the smallest of them as the largest or either between, so their sizes give it away no more than their order does; with negatives: false an answer below 3 has fewer options below it, and one above max - 3 has fewer above it. Empty unless quizMode.

task.join('') always evaluates to answer. That is the contract, and it is checked over 200 000 generated tasks per entry point on every build.

Note that brackets can appear even with brackets: false, wherever operator precedence requires them. The alternative would be a task that does not evaluate to its answer. They are also kept where leaving them out would take a step of working the task out past max either side: 2*(5600/10) rather than 2*5600/10, which passes through 11200.

mathGenBatch(count, length, more?)

Builds count tasks in one call, for a worksheet or a whole quiz. It takes the same options as mathGen and returns an array of the same results, about twice as fast per task (see On speed).

Task i is exactly what mathGen(length, { ...more, seed: seed + i }) would give, so one seed reproduces the whole batch, and the third task of a worksheet can be regenerated on its own. Without a seed one is drawn, as for mathGen.

count must be a whole number from 0 to 10000; anything else is a RangeError. Past that a batch stops being small, and a count taken from a request should not get to decide how much memory a server asks for.

import { mathGenBatch } from 'samathgen'

const worksheet = mathGenBatch(20, 4, { seed: 2024, quizMode: true })

TypeScript declarations ship with the package.

How it works

A task is built as a tree. It starts as a single leaf holding the answer, and each step replaces some leaf holding v with a node that evaluates to exactly v — a + b, a - b, a * b for a real divisor a of v, or a / b with a = v * b. Every division introduced is exact. The root therefore keeps its value whatever shape the tree takes, and the text is produced afterwards with brackets placed by precedence.

Versions up to 0.1.4 worked the other way round: they spliced a sub-expression in place of an operand and printed the result flat, with a genValidOperator function trying to forbid the operator combinations that would break. Around 6% of generated tasks did not evaluate to the answer they reported (more for longer tasks), which is also why the old test suite — twenty random cases — failed on roughly two runs in three.

On speed

A call used to hand its result to JavaScript as a JSON string, and that cost more than building the task: formatting the string in Rust, decoding it on the other side and running JSON.parse over it. The result now crosses as a short array of integers that the wrapper turns into the same object. Building the tree no longer allocates on every split either. A given seed produces exactly the same task as before both changes.

Lengths cycling through 2..8 with quiz options on, Node 24. Every version runs in turn in one process and is timed by the CPU time it used, best of 11 runs of 400 000 tasks:

Version per task with brackets
0.1.3, TypeScript 1.01 µs 1.79 µs
1.0, result as JSON 2.31 µs 2.54 µs
1.0, result as integers 1.09 µs 1.21 µs
1.0, no allocation per split 0.90 µs 1.05 µs

Each call still crosses into WebAssembly and back once, and copies its result out. mathGenBatch does that once for the whole batch, measured the same way without brackets:

Tasks per call per task
1, mathGen 0.98 µs
10 0.58 µs
100 0.55 µs
1000 0.51 µs

The WebAssembly shrank with it, by 4.6 kB (26 545 to 21 889 bytes at the time, 12 359 to 10 420 gzipped), because nothing formats text any more.

npm run bench prints the figures for your machine.

Building from source

Needs a Rust toolchain with the wasm32-unknown-unknown target, plus wasm-pack.

rustup target add wasm32-unknown-unknown
npm run build          # writes dist/node and dist/web
npm test               # cargo test, then the JavaScript contract suite

cargo test alone exercises the generator natively, with an expression parser written independently of the renderer so the two have to agree. The contract suite then runs against both entry points, loading the browser one under Node from the built bytes, and checks that the two give the same task for a seed.

Contributing

Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated.

  • If you have suggestions, feel free to open an issue to discuss it, or directly create a pull request.
  • Please make sure you check your spelling and grammar.
  • Create individual PR for each suggestion.

Creating A Pull Request

  1. Fork the Project
  2. Create your Feature Branch (git checkout -b feature/AmazingFeature)
  3. Commit your Changes (git commit -m 'Add some AmazingFeature')
  4. Push to the Branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

License

Distributed under the GNU General Public License v3.0. See LICENSE for more information.