feat(math): add commonly needed math and statistical functions - #7228
feat(math): add commonly needed math and statistical functions#7228hey-amanthakur wants to merge 18 commits into
Conversation
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.
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
|
Here's my feedback:
|
- isEven/isOdd: trivially expressed as n % 2 === 0 / n % 2 !== 0 - randomInt: duplicates @std/random/randomIntegerBetween - factorial: low-utility for standard library
|
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! |
|
@tomas-zijdemans thoughts on this one? |
bartlomieju
left a comment
There was a problem hiding this comment.
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.
| export function gcd(a: number, b: number): number { | ||
| a = Math.abs(a); | ||
| b = Math.abs(b); | ||
| while (b !== 0) { |
There was a problem hiding this comment.
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.
| */ | ||
| export function lcm(a: number, b: number): number { | ||
| if (a === 0 || b === 0) return 0; | ||
| return Math.abs(a * b) / gcd(a, b); |
There was a problem hiding this comment.
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:
| 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.
| const avg = total / numbers.length; | ||
| let sumSqDiff = 0; | ||
| for (const n of numbers) sumSqDiff += (n - avg) ** 2; | ||
| return sumSqDiff / numbers.length; |
There was a problem hiding this comment.
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.
| const avg = total / numbers.length; | ||
| let sumSqDiff = 0; | ||
| for (const n of numbers) sumSqDiff += (n - avg) ** 2; | ||
| return Math.sqrt(sumSqDiff / numbers.length); |
There was a problem hiding this comment.
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.
| return Math.sqrt(sumSqDiff / numbers.length); | |
| return Math.sqrt(variance(numbers)); |
| */ | ||
| export function sum(numbers: readonly number[]): number { | ||
| let total = 0; | ||
| for (const n of numbers) total += n; |
There was a problem hiding this comment.
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.
| result.push(value); | ||
| } | ||
| } | ||
| return result; |
There was a problem hiding this comment.
Two undocumented behaviours worth pinning down while the API is still new:
- For multimodal input the result order is
Mapinsertion 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. Mapkeys use SameValueZero, so-0and0collapse 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.
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:
Some findings:
|
Expands the @std/math package from 3 to 18 functions, filling the most
Statistical functions:
Numeric utilities:
Number theory: