Skip to content

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

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

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

Conversation

@hey-amanthakur

@hey-amanthakur hey-amanthakur commented Jul 10, 2026

Copy link
Copy Markdown

Expands the @std/math package from 3 to 11 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

Number theory:

  • gcd(a, b): Greatest common divisor (Euclid's algorithm)
  • lcm(a, b): Least common multiple via |a*b|/gcd(a,b)

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.04%. Comparing base (ad7c87b) to head (a11d44e).
⚠️ Report is 34 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7228      +/-   ##
==========================================
+ Coverage   95.00%   95.04%   +0.04%     
==========================================
  Files         617      628      +11     
  Lines       51674    51756      +82     
  Branches     9326     9395      +69     
==========================================
+ Hits        49093    49193     +100     
+ Misses       2038     2021      -17     
+ 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
Comment thread math/lcm.ts Outdated
Comment thread math/variance.ts Outdated
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 Outdated
Comment thread math/sum.ts Outdated
*/
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.

@hey-amanthakur

Copy link
Copy Markdown
Author

Thanks to you both — the two-pass mean-subtraction and numeric-comparator points are appreciated, and @tomas-zijdemans, good catch on the lcm example; you're right that the cited pair is unrepresentable either way. I've pinned your pair instead.

On the process note: agreed, I'll open a proposal issue for the API list and hold off until we have maintainer sign-off before landing anything.

Changes addressing the review:

  • gcd: now throws RangeError unless both inputs are integers, so NaN/Infinity/fractional input can't hang the loop. Tests added for all three.
  • lcm: switched to divide-before-multiply (a / gcd(a, b) * b), which is exact whenever the true lcm is a safe integer. It also validates integer input. Test pinned on lcm(94998385, 94998005) === 94996390033915 (naive form gives 94996390033914.98).
  • variance / stdDev: took the options-bag route. Both now accept { population?: boolean } — default is sample (n - 1, matching numpy/Excel/R expectations, with the n >= 2 guard), { population: true } computes population (n, single element allowed, variance 0). JSDoc states the convention explicitly, and stdDev delegates to variance() so the math lives in one place. Empty input throws.
  • sum / mean: sum uses Neumaier compensated summation (sum([1e16, 1, -1e16]) now returns 1) and mean reuses it. Tests pinned the mixed-magnitude case.
  • lerp: t === 0 and t === 1 now return a and b exactly, matching C++ std::lerp — lerp(1e30, 1, 1) returns 1. Extrapolation for t outside [0, 1] is documented.
  • mode: JSDoc now documents that multimodal results are returned in order of first appearance, and that 0/-0 collapse via SameValueZero (tests added).
  • median: NaN input now returns NaN deterministically instead of relying on sort's implementation-defined comparator.

One policy decision (per @bartlomieju's empty-input question): stated in the mod.ts module doc — sum([]) returns 0 (the identity); mean/median/mode throw on empty; variance/stdDev throw on fewer than 2 elements for sample (1 for population). Non-integer input throws in gcd/lcm; elsewhere non-finite input propagates as NaN.

Open for maintainer call: naming (stdDev/degToRad/radToDeg). I've kept the current names for now and am happy to switch to the spelled-out forms if that's the precedent you want set for @std/math.

@tomas-zijdemans

Copy link
Copy Markdown
Contributor

Great work 💪 I'm looking forward to seeing this cross the finish line.

One new bug slipped in with the Neumaier sum:

  • sum.ts returns NaN for any input containing Infinity. sum([Infinity, 1]) gives NaN where naive summation correctly gives Infinity, because the correction term computes Infinity - Infinity. This is Neumaier's known caveat, and it contradicts the non-finite policy this PR writes into mod.ts ("results may be NaN or Infinity"). It leaks into mean too. One-line fix: return Number.isFinite(total) ? total + correction : total; plus a test pinning sum([Infinity, 1]) to Infinity.

Minor stuff:

  • nit: median.ts throws on empty input but the JSDoc has no @throws tag. Worth stating the deterministic-NaN behavior there too, since it is now a feature.
  • nit: variance.ts computes its internal mean with naive summation, so "mean reuses it" is not true yet. Calling sum() there would make it true and keep the summation policy in one place.
  • nit: the PR body needs an update

One style observation for @bartlomieju: the error messages are identifier-first (`numbers` must contain at least one element), matching clamp and round_to, while CONTRIBUTING.md asks for action-first ("Cannot compute the mean of an empty array"). With eleven new functions in a 0.0.0 package, whichever form lands here becomes the package precedent. Same bucket as the stdDev/degToRad naming question, which I agree is yours to call.

…) in variance

- Guard Neumaier correction against Infinity-Infinity NaN in sum()
- Add @throws and non-finite behavior docs to median JSDoc
- Replace naive summation loop in variance() with sum() call
- Add tests for sum() with Infinity inputs
@hey-amanthakur

Copy link
Copy Markdown
Author

done @tomas-zijdemans thanks for guiding me.

@hey-amanthakur

Copy link
Copy Markdown
Author

@tomas-zijdemans do it require something else i can add those changes too..

@tomas-zijdemans

Copy link
Copy Markdown
Contributor

@hey-amanthakur: One thing left, the PR description.

It still says 18 functions and lists randomInt, isEven, isOdd and factorial, all removed now. std squashes on merge, so that text becomes the commit message.

The naming and the error message style are for the maintainer to decide

Code looks good. Just a little patience ☺️

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