Skip to content

feat(math): add commonly needed math and statistical functions - #7228

Open
hey-amanthakur wants to merge 18 commits into
denoland:mainfrom
hey-amanthakur:feat/math
Open

feat(math): add commonly needed math and statistical functions#7228
hey-amanthakur wants to merge 18 commits into
denoland:mainfrom
hey-amanthakur:feat/math

Conversation

@hey-amanthakur

Copy link
Copy Markdown

Expands the @std/math package from 3 to 18 functions, filling the most

Statistical functions:

  • sum(numbers): Sum of an array of numbers (0 for empty)
  • mean(numbers): Arithmetic mean (RangeError on empty)
  • median(numbers): Middle value (average of two for even length)
  • mode(numbers): Most frequent value(s), supports multimodal data
  • variance(numbers): Population variance (n >= 2 required)
  • stdDev(numbers): Population standard deviation (n >= 2 required)

Numeric utilities:

  • lerp(a, b, t): Linear interpolation: a + (b - a) * t
  • degToRad(degrees): Degrees to radians conversion
  • radToDeg(radians): Radians to degrees conversion
  • randomInt(min, max): Random integer in [min, max] inclusive
  • isEven(n): Parity check via n % 2 === 0
  • isOdd(n): Parity check via n % 2 !== 0

Number theory:

  • gcd(a, b): Greatest common divisor (Euclid's algorithm)
  • lcm(a, b): Least common multiple via |a*b|/gcd(a,b)
  • factorial(n): Non-negative integer factorial (0! = 1)

Calculates the sum of an array of numbers. Returns 0 for empty arrays.
Calculates the arithmetic mean of an array of numbers. Throws RangeError when input is empty.
Returns the middle value of a sorted numeric array. For even-length arrays, returns the average of the two middle values. Throws RangeError on empty input.
Returns an array of the most frequent value(s) from a set of numbers. Supports multimodal data by returning all values that appear the most. Throws RangeError on empty input.
Calculates the population variance of an array of numbers. Uses the formula sum((x - mean)^2) / n. Throws RangeError when fewer than 2 elements.
Calculates the population standard deviation of an array of numbers (square root of variance). Throws RangeError when fewer than 2 elements.
Performs linear interpolation between two values: a + (b - a) * t. When t=0 returns a, when t=1 returns b.
Converts degrees to radians using the formula degrees * (PI / 180).
Converts radians to degrees using the formula radians * (180 / PI).
Returns a random integer between min and max (both inclusive). Uses Math.floor(Math.random() * (max - min + 1)) + min. Throws RangeError if min/max are not integers or if min > max.
Calculates the least common multiple using the formula |a * b| / gcd(a, b). Returns 0 if either input is 0.
Checks if a number is even using n % 2 === 0.
Checks if a number is odd using n % 2 !== 0.
Calculates the factorial of a non-negative integer. Returns 1 for n=0 by convention. Throws RangeError for negative or non-integer inputs.
Adds 15 new function exports to the math package barrel (mod.ts) and subpath exports in deno.json:

- sum, mean, median, mode
- variance, stdDev
- lerp, degToRad, radToDeg
- randomInt, gcd, lcm
- isEven, isOdd, factorial
Calculates the greatest common divisor using Euclids algorithm. Handles negative inputs by taking absolute values.
@github-actions github-actions Bot added the math label Jul 10, 2026
@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.01%. Comparing base (ad7c87b) to head (113c665).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7228      +/-   ##
==========================================
+ Coverage   95.00%   95.01%   +0.01%     
==========================================
  Files         617      628      +11     
  Lines       51674    51775     +101     
  Branches     9326     9353      +27     
==========================================
+ Hits        49093    49195     +102     
  Misses       2038     2038              
+ Partials      543      542       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@babiabeo

Copy link
Copy Markdown
Contributor

Here's my feedback:

  • isEven/isOdd: Unnecessary. People prefer writing n % 2 === 0/n % 2 !== 0 directly over importing from the package.
  • randomInt: Redundant. We already have @std/random/randomIntegerBetween.
  • factorial: Low-utility. It's rarely used in real-world applications.

- isEven/isOdd: trivially expressed as n % 2 === 0 / n % 2 !== 0
- randomInt: duplicates @std/random/randomIntegerBetween
- factorial: low-utility for standard library
@hey-amanthakur hey-amanthakur changed the title feat(math): add 15 commonly needed math and statistical functions feat(math): add commonly needed math and statistical functions Jul 14, 2026
@hey-amanthakur

Copy link
Copy Markdown
Author

Hi @babiabeo,

Thank you for taking the time to review my PR. I've made the requested changes, could you please take another look when you have a chance?

I'm also happy to continue contributing to the project and would love to help further. Thanks again!

@bartlomieju

Copy link
Copy Markdown
Member

@tomas-zijdemans thoughts on this one?

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — and credit where it's due, a couple of things here are done better than most statistics submissions we see. variance uses the two-pass mean-subtraction form rather than E[X²] − E[X]², which avoids catastrophic cancellation, and median copies the input before sorting and uses a numeric comparator instead of the default lexicographic one. Those are exactly the two traps people usually fall into.

There are two bugs that need fixing regardless of anything else — gcd can hang the process, and lcm silently returns wrong answers for large inputs. Details inline, along with a definitional issue in variance/stdDev that I'd want settled before this lands.

One process note that isn't about the code: new APIs need a proposal issue and maintainer sign-off before implementation, and eleven new public symbols is a large set to agree on at once. Nothing here is wasted — but the API list itself needs a maintainer's yes before the implementation details matter. @std/math being 0.0.0 does at least mean the unstable_ prefix rules don't apply, so your file layout and mod.ts exports are correct as they stand.

Comment thread math/gcd.ts
export function gcd(a: number, b: number): number {
a = Math.abs(a);
b = Math.abs(b);
while (b !== 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This loop never terminates for non-finite or non-integer input. Infinity % 5 is NaN, and NaN !== 0 is true forever — so gcd(Infinity, 5) and gcd(NaN, 5) hang the process rather than throwing. I confirmed the hang locally.

A silent infinite loop is about the worst failure mode a small numeric utility can have, since there's no stack trace to point at the cause. Guarding the inputs up front fixes it:

if (!Number.isInteger(a) || !Number.isInteger(b)) {
  throw new RangeError(`"a" and "b" must be integers: received ${a} and ${b}`);
}

Worth tests for NaN, Infinity, and a fractional input.

Comment thread math/lcm.ts
*/
export function lcm(a: number, b: number): number {
if (a === 0 || b === 0) return 0;
return Math.abs(a * b) / gcd(a, b);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multiplying first overflows Number.MAX_SAFE_INTEGER even when the true result fits comfortably. lcm(123456789, 987654321) returns 13548070123626140; the exact answer is 13548070123626141. Wrong by one, with no error — the worst kind of numeric bug to debug downstream.

Dividing before multiplying keeps the intermediate small:

Suggested change
return Math.abs(a * b) / gcd(a, b);
return Math.abs(a / gcd(a, b) * b);

gcd(a, b) always divides a exactly, so this stays integral. Worth pinning with a test on a pair whose product exceeds 2^53.

Comment thread math/variance.ts
const avg = total / numbers.length;
let sumSqDiff = 0;
for (const n of numbers) sumSqDiff += (n - avg) ** 2;
return sumSqDiff / numbers.length;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a definitional mismatch here: dividing by numbers.length computes the population variance, but the guard above rejects n < 2, which is the precondition for the sample variance (n - 1). The population variance of a single-element array is legitimately 0, so as written the function refuses to answer a question it could answer correctly.

Also, only the PR description says "population" — the JSDoc doesn't, so a caller has no way to know which convention they're getting. That matters more than usual here, because R, numpy (ddof=1), and Excel's STDEV all default to the sample form, so most users will assume n - 1.

Two coherent options: keep population, drop the guard to n < 1, and say "population variance" in the JSDoc; or switch to n - 1, keep the guard, and say "sample variance". An options bag ({ population?: boolean }) covering both would be even better. Same applies to stdDev.

Comment thread math/std_dev.ts
const avg = total / numbers.length;
let sumSqDiff = 0;
for (const n of numbers) sumSqDiff += (n - avg) ** 2;
return Math.sqrt(sumSqDiff / numbers.length);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates variance()'s body verbatim — same mean pass, same sum-of-squares pass, differing only in the final Math.sqrt. Two copies of a numeric routine will drift, and any fix to one (such as the population/sample question above) has to be remembered twice.

Suggested change
return Math.sqrt(sumSqDiff / numbers.length);
return Math.sqrt(variance(numbers));

Comment thread math/sum.ts
*/
export function sum(numbers: readonly number[]): number {
let total = 0;
for (const n of numbers) total += n;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naive accumulation loses precision on inputs that span magnitudes: sum([1e16, 1, -1e16]) gives 0 rather than 1, and long arrays of small values drift steadily.

That may well be an acceptable tradeoff for a stdlib sum, but it should be a stated one rather than an accident — either document the precision characteristic in the JSDoc, or use Neumaier compensation, which is only a few lines and makes the function meaningfully better than what a user would write inline. The same applies to mean(), which sums the same way.

Either way, a test with mixed-magnitude inputs would pin whichever behaviour you choose.

Comment thread math/mode.ts
result.push(value);
}
}
return result;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two undocumented behaviours worth pinning down while the API is still new:

  1. For multimodal input the result order is Map insertion order — i.e. first appearance in the input. That's a reasonable choice, but it's currently an implementation detail that a caller could come to depend on. Either document it or sort the result.
  2. Map keys use SameValueZero, so -0 and 0 collapse into one entry, and the reported value is whichever appeared first. Edge case, but a surprising one for a statistics function.

A broader consistency point across the module: empty input behaves three different ways right now — sum([]) returns 0, mean/median/mode throw at n < 1, and variance/stdDev throw at n < 2. sum([]) === 0 is defensible (it's the identity), but the split between the other two groups is arbitrary from the caller's side. Worth stating one rule in the module doc and making each function follow it.

@tomas-zijdemans

Copy link
Copy Markdown
Contributor

@tomas-zijdemans thoughts on this one?

Wow, big changes to std. Getting it's own math/statistical module. Very cool!

@bartlomieju I agree with your feedback. One note on your review:

  • lcm.ts:24 the bug is real but the example doesn't demonstrate the fix. For the cited pair lcm(123456789, 987654321), the exact answer 13548070123626141 is odd and above 2^53, so it's unrepresentable as a double. Both the naive form and his divide-first suggestion return …140. No formula fixes that pair; only a doc note or a safe-integer guard on the result does. The divide-first form is still strictly better: it's exact whenever the true lcm is a safe integer, while the naive form isn't. A pair that actually demonstrates the difference: lcm(94998385, 94998005) → naive gives 94996390033914.98 (not even an integer), divide-first gives the exact 94996390033915. That's the pair worth pinning in a test.

Some findings:

  • lerp.ts:19 the JSDoc's endpoint claim is false in floating point. The doc says "1 returns b", but with a + (b - a) * t, lerp(1e30, 1, 1) returns 0, not 1 (verified). This is the known reason C++'s std::lerp special-cases t == 1. Either guarantee it (if (t === 1) return b;) or weaken the doc. Also worth stating that t outside [0, 1] extrapolates — currently undocumented.
  • Naming precedent (discussion, not blocker). @std/math is 0.0.0, so these names set the package's precedent. std elsewhere spells names out (randomIntegerBetween, not randomInt. The exact abbreviation babiabeo rejected on this very PR). degToRad/``radToDeg/stdDev vs degreesToRadians/radiansToDegrees/standardDeviation deserves a deliberate maintainer call.
  • nit: median.ts:22 . NaN input yields nondeterministic results. The comparator (a, b) => a - b returns NaN for NaN elements, making sort order implementation-defined. The result can be NaN or a silently wrong number depending on position. Once gcd gains input validation, it's worth deciding one NaN policy for the package.
  • nit: mod.ts:5 Module doc still says "Math functions such as modulo and clamp" — worth refreshing, and it's also the natural home for the empty-input policy bartlomieju asked for.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants