Skip to content

Commit 98bf699

Browse files
committed
fix: improve numeric correctness in arithmetic operations and add regression tests
1 parent 9450b68 commit 98bf699

9 files changed

Lines changed: 252 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
11
### [Unreleased]
22

3+
- **Numeric correctness fixes** — several arithmetic operations produced
4+
silently-wrong results:
5+
- Complex `pow` (machine precision) used `arg ** n` instead of `arg · n` in
6+
De Moivre's formula, so e.g. `` gave `−0.78 + 0.62i` instead of `−1`. A
7+
negative integer exponent on a complex base also dropped the imaginary part
8+
(`(1+i)⁻² → −0.5i` now).
9+
- Complex reciprocal/`inv` (machine and arbitrary precision) divided the
10+
conjugate by `|z|` instead of `|z|²` (`1/(2i) → −0.5i` now).
11+
- Exact `x^(1/n)` took the n-th root of the numerator (always 1) instead of
12+
the denominator, returning the base unchanged (`8^(1/3) → 2` now).
13+
- Exact `floor`/`ceil`/`round` routed integers and rationals through a float,
14+
losing digits beyond 2⁵³; they now compute exactly with bigints.
15+
- `BigDecimal.mod` gave a wrong remainder when the quotient exceeded the
16+
working precision (e.g. `1e60 mod 3`); the truncated quotient is now exact.
17+
- Skewness and kurtosis used incorrect central-moment formulas; a symmetric
18+
sample now has skewness 0 and `[1,2,3,4,5]` has (non-excess) kurtosis 1.7.
19+
320
- **Matrix (tensor) linear-algebra fixes** — operations were broken beyond the
421
hardcoded 2×2 cases:
522
- `Determinant` no longer throws for 4×4 and larger matrices, and now returns

src/big-decimal/big-decimal.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -629,9 +629,19 @@ export class BigDecimal {
629629
if (Number.isFinite(thisExp) && Number.isFinite(otherExp)) {
630630
if (other.significand === 0n) return BigDecimal.NAN; // x mod 0 → NaN
631631
if (this.significand === 0n) return fromRaw(0n, 0); // 0 mod x → 0
632-
return this.sub(this.div(other).trunc().mul(other)).toPrecision(
633-
BigDecimal.precision
634-
);
632+
633+
// Compute trunc(this / other) EXACTLY with bigints. Using the
634+
// precision-bounded `div` here would round the quotient before
635+
// truncating, producing a wrong remainder whenever |this / other|
636+
// exceeds the working precision (e.g. `1e60 mod 3`).
637+
// this / other = (s₁ · 10^(e₁−e₂)) / s₂
638+
const ediff = thisExp - otherExp;
639+
const num =
640+
ediff >= 0 ? this.significand * pow10(ediff) : this.significand;
641+
const den =
642+
ediff >= 0 ? other.significand : other.significand * pow10(-ediff);
643+
const q = num / den; // bigint division truncates toward zero
644+
return this.sub(fromRaw(q, 0).mul(other));
635645
}
636646

637647
// Slow path: NaN or Infinity

src/compute-engine/numeric-value/big-numeric-value.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -212,11 +212,9 @@ export class BigNumericValue extends NumericValue {
212212
if (this.isNegativeOne) return this;
213213
if (this.im === 0) return this.clone(this.decimal.inv());
214214

215-
const d = Math.hypot(this.re, this.im);
216-
const bigD = this.decimal
217-
.mul(this.decimal)
218-
.add(this.im * this.im)
219-
.sqrt();
215+
// 1/z = conj(z) / |z|² (not / |z|).
216+
const d = this.re * this.re + this.im * this.im;
217+
const bigD = this.decimal.mul(this.decimal).add(this.im * this.im);
220218
return this.clone({ re: this.decimal.div(bigD), im: -this.im / d });
221219
}
222220

src/compute-engine/numeric-value/exact-numeric-value.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -499,8 +499,10 @@ export class ExactNumericValue extends NumericValue {
499499
exponent = { re: exponent.re, im: exponent.im };
500500
} else {
501501
if (exponent instanceof ExactNumericValue) {
502+
// Exponent 1/n (numerator rational[0] === 1) ⇒ n-th root, where n is
503+
// the denominator rational[1] (not the numerator).
502504
if (exponent.radical === 1 && exponent.rational[0] == 1)
503-
return this.root(exponent.rational[0]);
505+
return this.root(Number(exponent.rational[1]));
504506
}
505507
exponent = exponent.re;
506508
}
@@ -709,21 +711,56 @@ export class ExactNumericValue extends NumericValue {
709711
return this.factory(this.bignumRe).exp();
710712
}
711713

714+
/**
715+
* Floor/ceil/round of a pure rational (`radical === 1`) computed exactly with
716+
* bigints. Routing through `this.re` (a float) would lose digits for
717+
* integers/rationals larger than 2^53.
718+
*/
719+
private _integerPart(mode: 'floor' | 'ceil' | 'round'): ExactNumericValue {
720+
let n = BigInt(this.rational[0]);
721+
let d = BigInt(this.rational[1]);
722+
if (d < 0n) {
723+
n = -n;
724+
d = -d;
725+
}
726+
let q: bigint;
727+
if (mode === 'round') {
728+
// Round half toward +∞ (matches JS `Math.round`): floor((2n + d) / (2d)).
729+
const m = 2n * n + d;
730+
const dd = 2n * d;
731+
q = m / dd;
732+
if (m % dd !== 0n && m < 0n) q -= 1n;
733+
} else {
734+
q = n / d; // bigint division truncates toward zero
735+
const r = n % d;
736+
if (r !== 0n) {
737+
if (mode === 'floor' && n < 0n) q -= 1n;
738+
if (mode === 'ceil' && n > 0n) q += 1n;
739+
}
740+
}
741+
return this.clone({ rational: [q, BigInt(1)], radical: 1 });
742+
}
743+
744+
// An exact value is an integer iff it has no radical part and a unit
745+
// denominator. (`this.type` returns `'finite_integer'`, never `'integer'`.)
712746
floor(): NumericValue {
713747
if (this.isNaN) return this.clone(NaN);
714-
if (this.type === 'integer') return this;
748+
if (this.radical === 1 && isInteger(this.rational)) return this;
749+
if (this.radical === 1) return this._integerPart('floor');
715750
return this.clone(Math.floor(this.re));
716751
}
717752

718753
ceil(): NumericValue {
719754
if (this.isNaN) return this.clone(NaN);
720-
if (this.type === 'integer') return this;
755+
if (this.radical === 1 && isInteger(this.rational)) return this;
756+
if (this.radical === 1) return this._integerPart('ceil');
721757
return this.clone(Math.ceil(this.re));
722758
}
723759

724760
round(): NumericValue {
725761
if (this.isNaN) return this.clone(NaN);
726-
if (this.type === 'integer') return this;
762+
if (this.radical === 1 && isInteger(this.rational)) return this;
763+
if (this.radical === 1) return this._integerPart('round');
727764
return this.clone(Math.round(this.re));
728765
}
729766

src/compute-engine/numeric-value/machine-numeric-value.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ export class MachineNumericValue extends NumericValue {
179179
if (this.isNegativeOne) return this;
180180
if (this.im === 0) return this.clone(1 / this.decimal);
181181

182-
const d = Math.hypot(this.re, this.im);
182+
// 1/z = conj(z) / |z|² (not / |z|).
183+
const d = this.re * this.re + this.im * this.im;
183184
return this.clone({ re: this.decimal / d, im: -this.im / d });
184185
}
185186

@@ -373,7 +374,11 @@ export class MachineNumericValue extends NumericValue {
373374
if (exponent < 0) return this.clone({ im: Infinity }); // Complex/unsigned infinity
374375
}
375376

376-
if (exponent < 0) return this.clone(1 / this.decimal ** -exponent);
377+
// Real base: 1/xⁿ. (Complex bases fall through to the De Moivre branch
378+
// below, which handles negative exponents too — using only `this.decimal`
379+
// here would drop the imaginary part.)
380+
if (exponent < 0 && this.im === 0)
381+
return this.clone(1 / this.decimal ** -exponent);
377382

378383
if (this.im === 0) return this.clone(this.decimal ** exponent);
379384

@@ -382,7 +387,9 @@ export class MachineNumericValue extends NumericValue {
382387
const modulus = Math.sqrt(a * a + b * b);
383388
const argument = Math.atan2(b, a);
384389
const newModulus = modulus ** exponent;
385-
const newArgument = argument ** exponent;
390+
// De Moivre: zⁿ = |z|ⁿ · (cos(n·arg) + i·sin(n·arg)). The new argument is
391+
// n·arg, not argⁿ.
392+
const newArgument = argument * exponent;
386393
return this.clone({
387394
re: newModulus * Math.cos(newArgument),
388395
im: newModulus * Math.sin(newArgument),

src/compute-engine/numerics/statistics.ts

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -118,57 +118,61 @@ export function bigPopulationStandardDeviation(
118118
export function kurtosis(values: Iterable<number>): number {
119119
let sum = 0;
120120
let sum2 = 0;
121+
let sum3 = 0;
121122
let sum4 = 0;
122123
let count = 0;
123124
for (const op of values) {
124125
const v = op;
125126
if (!Number.isFinite(v)) return NaN;
126127
sum += v;
127128
sum2 += v * v;
129+
sum3 += v * v * v;
128130
sum4 += v * v * v * v;
129131
count++;
130132
}
131133
if (count === 0) return NaN;
132-
const s2 = (sum2 - (sum * sum) / count) / (count - 1);
133-
return (
134+
const n = count;
135+
const m = sum / n;
136+
// Central moments: m2 = (1/n)Σ(x−m)², m4 = (1/n)Σ(x−m)⁴.
137+
const m2 = (sum2 - (sum * sum) / n) / n;
138+
const m4 =
134139
(sum4 -
135-
(4 * sum * sum2) / count +
136-
(6 * sum * sum * sum) / count / count -
137-
(3 * sum * sum * sum * sum) / count / count / count) /
138-
(s2 * s2)
139-
);
140+
4 * m * sum3 +
141+
6 * m * m * sum2 -
142+
4 * m * m * m * sum +
143+
n * m * m * m * m) /
144+
n;
145+
// Non-excess kurtosis β₂ = m4 / m2² (a normal distribution gives 3).
146+
return m4 / (m2 * m2);
140147
}
141148

142149
export function bigKurtosis(values: Iterable<BigDecimal>): BigDecimal {
143150
let sum = BigDecimal.ZERO;
144151
let sum2 = BigDecimal.ZERO;
152+
let sum3 = BigDecimal.ZERO;
145153
let sum4 = BigDecimal.ZERO;
146154
let count = 0;
147155
for (const op of values) {
148156
const v = op;
149157
if (!v.isFinite()) return BigDecimal.NAN;
150158
sum = sum.add(v);
151159
sum2 = sum2.add(v.mul(v));
160+
sum3 = sum3.add(v.mul(v).mul(v));
152161
sum4 = sum4.add(v.mul(v).mul(v).mul(v));
153162
count++;
154163
}
155164
if (count === 0) return BigDecimal.NAN;
156-
const bdCount = new BigDecimal(count);
157-
const s2 = sum2.sub(sum.mul(sum).div(bdCount)).div(new BigDecimal(count - 1));
158-
return sum4
159-
.sub(sum.mul(sum2).mul(new BigDecimal(4)).div(bdCount))
160-
.add(sum.mul(sum).mul(sum).mul(new BigDecimal(6)).div(bdCount).div(bdCount))
161-
.sub(
162-
sum
163-
.mul(sum)
164-
.mul(sum)
165-
.mul(sum)
166-
.mul(new BigDecimal(3))
167-
.div(bdCount)
168-
.div(bdCount)
169-
.div(bdCount)
170-
)
171-
.div(s2.mul(s2));
165+
const m = sum.div(count); // mean
166+
// Central moments: m2 = (1/n)Σ(x−m)², m4 = (1/n)Σ(x−m)⁴.
167+
const m2 = sum2.sub(sum.mul(sum).div(count)).div(count);
168+
const m4 = sum4
169+
.sub(m.mul(sum3).mul(4))
170+
.add(m.mul(m).mul(sum2).mul(6))
171+
.sub(m.mul(m).mul(m).mul(sum).mul(4))
172+
.add(m.mul(m).mul(m).mul(m).mul(count))
173+
.div(count);
174+
// Non-excess kurtosis β₂ = m4 / m2² (a normal distribution gives 3).
175+
return m4.div(m2.mul(m2));
172176
}
173177

174178
export function skewness(values: Iterable<number>): number {
@@ -185,9 +189,13 @@ export function skewness(values: Iterable<number>): number {
185189
count++;
186190
}
187191
if (count === 0) return NaN;
188-
const s2 = (sum2 - (sum * sum) / count) / (count - 1);
189-
const s3 = (sum3 - (sum2 * sum) / count) / (count - 1);
190-
return (s3 / Math.pow(s2, 3 / 2)) * Math.sqrt(count * 1);
192+
const n = count;
193+
const m = sum / n;
194+
// Central moments: m2 = (1/n)Σ(x−m)², m3 = (1/n)Σ(x−m)³.
195+
const m2 = (sum2 - (sum * sum) / n) / n;
196+
const m3 = (sum3 - 3 * m * sum2 + 3 * m * m * sum - n * m * m * m) / n;
197+
// Moment coefficient of skewness g₁ = m3 / m2^(3/2).
198+
return m3 / Math.pow(m2, 3 / 2);
191199
}
192200

193201
export function bigSkewness(values: Iterable<BigDecimal>): BigDecimal {
@@ -204,12 +212,16 @@ export function bigSkewness(values: Iterable<BigDecimal>): BigDecimal {
204212
count++;
205213
}
206214
if (count === 0) return BigDecimal.NAN;
207-
const bdCount = new BigDecimal(count);
208-
const s2 = sum2.sub(sum.mul(sum).div(bdCount)).div(new BigDecimal(count - 1));
209-
const s3 = sum3
210-
.sub(sum2.mul(sum).div(bdCount))
211-
.div(new BigDecimal(count - 1));
212-
return s3.div(s2.pow(new BigDecimal(1.5))).mul(bdCount.sqrt());
215+
const m = sum.div(count); // mean
216+
// Central moments: m2 = (1/n)Σ(x−m)², m3 = (1/n)Σ(x−m)³.
217+
const m2 = sum2.sub(sum.mul(sum).div(count)).div(count);
218+
const m3 = sum3
219+
.sub(m.mul(sum2).mul(3))
220+
.add(m.mul(m).mul(sum).mul(3))
221+
.sub(m.mul(m).mul(m).mul(count))
222+
.div(count);
223+
// Moment coefficient of skewness g₁ = m3 / m2^(3/2) = m3 / (m2·√m2).
224+
return m3.div(m2.mul(m2.sqrt()));
213225
}
214226

215227
export function mode(values: Iterable<number>): number {

test/big-decimal/big-decimal.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1592,6 +1592,17 @@ describe('mod()', () => {
15921592
expect(result.sub(new BigDecimal('1.5')).abs().lt(new BigDecimal('1e-30'))).toBe(true);
15931593
});
15941594

1595+
// REVIEW.md D5: when the quotient exceeds the working precision the old code
1596+
// rounded `this / other` before truncating, giving a wrong remainder. The
1597+
// quotient here (~3.3e59) far exceeds the precision of 50 digits.
1598+
test('1e60 mod 3 = 1 (quotient exceeds precision)', () => {
1599+
expect(new BigDecimal('1e60').mod(new BigDecimal('3')).eq(1)).toBe(true);
1600+
});
1601+
1602+
test('2e60 mod 3 = 2 (quotient exceeds precision)', () => {
1603+
expect(new BigDecimal('2e60').mod(new BigDecimal('3')).eq(2)).toBe(true);
1604+
});
1605+
15951606
test('-10 mod 3 = -1', () => {
15961607
const result = new BigDecimal('-10').mod(new BigDecimal('3'));
15971608
expect(result.sub(new BigDecimal('-1')).abs().lt(new BigDecimal('1e-30'))).toBe(true);

test/compute-engine/internals/numeric-value.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { engine as ce } from '../../utils';
2+
import { ComputeEngine } from '../../../src/compute-engine';
23

34
describe('constructor', () => {
45
it('should create from an integer', () => {
@@ -180,3 +181,74 @@ describe('power', () => {
180181
expect(b.toString()).toMatchInlineSnapshot(`192`);
181182
});
182183
});
184+
185+
// Regressions for the numeric-value bugs reported in REVIEW.md (D1–D4, D10).
186+
describe('Numeric-value correctness (REVIEW.md D1–D4)', () => {
187+
// `BigDecimal.precision` is a global static, so a machine-precision engine is
188+
// created in beforeAll (not at collection time) and the global precision is
189+
// restored in afterAll — otherwise it leaks into other tests.
190+
let machine: ComputeEngine;
191+
let savedPrecision: number;
192+
beforeAll(() => {
193+
savedPrecision = ce.precision;
194+
machine = new ComputeEngine({ precision: 'machine' });
195+
});
196+
afterAll(() => {
197+
ce.precision = savedPrecision;
198+
});
199+
200+
// D1: complex pow used De Moivre's `argument ** exponent` instead of
201+
// `argument * exponent` (machine precision).
202+
it('D1: machine complex pow (i^2 = -1)', () => {
203+
const r = machine._numericValue(machine.complex(0, 1)).pow(2);
204+
expect(r.re).toBeCloseTo(-1, 12);
205+
expect(r.im).toBeCloseTo(0, 12);
206+
});
207+
208+
// D10: negative exponent on a complex base dropped the imaginary part.
209+
it('D10: machine complex negative pow ((1+i)^-2 = -0.5i)', () => {
210+
const r = machine._numericValue(machine.complex(1, 1)).pow(-2);
211+
expect(r.re).toBeCloseTo(0, 12);
212+
expect(r.im).toBeCloseTo(-0.5, 12);
213+
});
214+
215+
// D2: complex inv divided the conjugate by |z| instead of |z|².
216+
it('D2: machine complex inv (1/(2i) = -0.5i)', () => {
217+
const r = machine._numericValue(machine.complex(0, 2)).inv();
218+
expect(r.re).toBeCloseTo(0, 12);
219+
expect(r.im).toBeCloseTo(-0.5, 12);
220+
});
221+
222+
it('D2: bignum complex inv (1/(2i) = -0.5i)', () => {
223+
const r = ce._numericValue(ce.complex(0, 2)).inv();
224+
expect(r.re).toBeCloseTo(0, 12);
225+
expect(r.im).toBeCloseTo(-0.5, 12);
226+
});
227+
228+
// D3: exact pow with a 1/n exponent took the n-th root of the numerator
229+
// (always 1) instead of the denominator, so it returned the base unchanged.
230+
it('D3: exact n-th root (8^(1/3) = 2, 27^(1/3) = 3)', () => {
231+
expect(ce._numericValue(8).pow(ce._numericValue([1, 3])).toString()).toEqual(
232+
'2'
233+
);
234+
expect(
235+
ce._numericValue(27).pow(ce._numericValue([1, 3])).toString()
236+
).toEqual('3');
237+
});
238+
239+
// D4: floor/ceil/round routed exact integers/rationals through a float,
240+
// losing digits beyond 2^53. They now compute exactly with bigints.
241+
it('D4: exact floor of a large integer keeps every digit', () => {
242+
const big = 123456789012345678901234567890n;
243+
expect(ce._numericValue(big).floor().toString()).toEqual(big.toString());
244+
});
245+
246+
it('D4: exact floor/ceil/round of rationals', () => {
247+
expect(ce._numericValue([7, 2]).floor().toString()).toEqual('3');
248+
expect(ce._numericValue([-7, 2]).floor().toString()).toEqual('-4');
249+
expect(ce._numericValue([7, 2]).ceil().toString()).toEqual('4');
250+
expect(ce._numericValue([-7, 2]).ceil().toString()).toEqual('-3');
251+
expect(ce._numericValue([5, 2]).round().toString()).toEqual('3');
252+
expect(ce._numericValue([-5, 2]).round().toString()).toEqual('-2');
253+
});
254+
});

0 commit comments

Comments
 (0)