JavaScript: Math, 110–114
Arithmetic, rounding, primes, factors, conversions and geometry.
Exercises 110 to 114 of 114
110 Continued fractions, and why 355/113 is famous
hardExpand Math.PI as a continued fraction, build its convergents, and show that no fraction with a denominator under a thousand approximates pi better than 355/113. Then run the machine backwards to recover 1/10 from the double 0.1, and find the spurious term that float error injects into the expansion of an exact value like 2.75.
Stuck? Three hints, smallest first
- 1. Take the whole part, subtract it, invert what is left, repeat. The whole parts are the terms.
- 2. Convergents come from the terms by a two-line recurrence:
p = a * p + pPrevand the same forq. - 3. A brute-force search over every denominator up to 1000, taking
p = Math.round(x * q), settles the "best" claim without any theory.
// Terms: whole part, subtract, invert, repeat.
function terms(x, count) {
const out = [];
let v = x;
for (let i = 0; i < count; i++) {
const a = Math.floor(v);
out.push(a);
const frac = v - a;
if (frac === 0) break;
v = 1 / frac;
}
return out;
}
// Convergents: p / q, each built from the two before it.
function convergents(list) {
let pPrev = 1;
let p = list[0];
let qPrev = 0;
let q = 1;
const out = [[p, q]];
for (let i = 1; i < list.length; i++) {
const a = list[i];
const pNext = a * p + pPrev;
const qNext = a * q + qPrev;
pPrev = p;
p = pNext;
qPrev = q;
q = qNext;
out.push([p, q]);
}
return out;
}
// The best fraction with denominator at most maxQ, found by looking at all of them.
function bestFraction(x, maxQ) {
let best = [1, 1];
let bestError = Infinity;
for (let q = 1; q <= maxQ; q++) {
const p = Math.round(x * q);
const error = Math.abs(p / q - x);
if (error < bestError) {
bestError = error;
best = [p, q];
}
}
return best;
}
// Backwards: grow convergents until one is within tol of x.
function toFraction(x, tol) {
const list = terms(x, 40);
const all = convergents(list);
for (const [p, q] of all) {
if (Math.abs(p / q - x) <= tol) return [p, q];
}
return all[all.length - 1];
}
const piTerms = terms(Math.PI, 12);
const piConvergents = convergents(piTerms).slice(0, 5);
const errors = piConvergents.map(([p, q]) => p / q - Math.PI);
const golden = convergents(terms((1 + Math.sqrt(5)) / 2, 10)).map(([p, q]) => p + '/' + q);
const root2 = terms(Math.SQRT2, 8);
const exactValue = terms(2.75, 4);
console.log('pi terms :', piTerms);
console.log('convergents :', piConvergents.map(([p, q]) => p + '/' + q));
console.log('errors :', errors);
console.log('best q <= 100 :', bestFraction(Math.PI, 100));
console.log('best q <= 1000:', bestFraction(Math.PI, 1000));
console.log('0.1 back :', toFraction(0.1, 1e-12), 'and 0.07 gives', toFraction(0.07, 1e-12));
console.log('the double 0.1:', 3602879701896397, '/ 2 ** 55');
console.log('golden :', golden);
console.log('root 2 terms :', root2);
console.log('2.75 terms :', exactValue, '<- the last one is not real');
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('pi expands as 3, 7, 15, 1, 292, then ones and twos', piTerms, [3, 7, 15, 1, 292, 1, 1, 1, 2, 1, 3, 1]);
test('whose convergents are the famous approximations of pi', piConvergents, [[3, 1], [22, 7], [333, 106], [355, 113], [103993, 33102]]);
test('22/7 is high by 0.00126 and 355/113 by 2.7e-7, alternating sides', errors.slice(1, 4), [0.0012644892673496777, -0.0000832196275291075, 2.667641894049666e-7]);
test('and the fifth is out by only 5.8e-10, which is nine correct digits of pi', [errors[4], Math.abs(errors[4]) < 6e-10], [-5.778906242426274e-10, true]);
test('no denominator up to 1000 beats 355/113, though 99 gives only 311/99', [bestFraction(Math.PI, 1000), bestFraction(Math.PI, 100)], [[355, 113], [311, 99]]);
test('run backwards, the double 0.1 gives back one tenth, and 0.07 seven hundredths', [toFraction(0.1, 1e-12), toFraction(0.07, 1e-12)], [[1, 10], [7, 100]]);
test('even though that double is really 3602879701896397 over 2 ** 55', 3602879701896397 / 2 ** 55 === 0.1, true);
test('the golden ratio is all ones, so its convergents are ratios of Fibonacci numbers', golden, ['1/1', '2/1', '3/2', '5/3', '8/5', '13/8', '21/13', '34/21', '55/34', '89/55']);
test('and the expansion of 2.75 is [2, 1, 3] plus a spurious 2 ** 50 from float error', [exactValue, exactValue[3] === 2 ** 50], [[2, 1, 3, 1125899906842624], true]);
A continued fraction is the answer to "what is the best fraction for this number", and the convergents of pi are the answers people have been quoting for two thousand years: 3, 22/7, 333/106, 355/113. The brute-force search is there to make the claim concrete — of all 1000 denominators, none beats 113, and 355/113 is right to seven significant figures. That is why it survived: three digits over three digits, and better than you have any right to expect. Notice the errors alternate sign, which is a property of the expansion, not luck.
Backwards, the same recurrence turns a double into the fraction a human meant. toFraction(0.1) returns [1, 10], and it has to, because the expansion asks 1 / (0.1 - 0) and 1 / 0.1 is exactly 10 in doubles even though 0.1 is not exactly a tenth — the real value is 3602879701896397 / 255, as the seventh assertion shows. This is the honest way to display a computed ratio as a fraction, and it is worth more than toFixed when the answer is meant to be rational.
The last assertion is the trap. 2.75 is exactly representable and its expansion is exactly [2, 1, 3], three terms. What comes back is [2, 1, 3, 1125899906842624], because at the third step the remaining fraction is 4.4e-16 instead of 0, and inverting that gives 250. Any expansion loop needs a tolerance on the remainder rather than frac === 0, or a cap on the term size — an enormous term means you have run out of precision, not that the number is interesting.
111 Matrix multiplication and the determinant
fairMultiply two 3 by 3 matrices, refusing shapes that do not fit, and compute determinants of any size by cofactor expansion. Check that det(AB) equals det(A) * det(B), that a matrix times its inverse is the identity, and then check both again with entries like 0.1. Finish by measuring a plot of land with the same 2 by 2 determinant.
Stuck? Three hints, smallest first
- 1. Entry
(i, j)of a product is rowiof the left dotted with columnjof the right, so the inner dimensions must match. - 2. For a determinant, expand along the first row: alternate signs, and recurse on the matrix with that row and column deleted.
- 3. A 2 by 2 determinant is the cross product of two vectors, which is twice the area of the triangle they span.
const shape = (m) => [m.length, m[0].length];
function multiply(a, b) {
const [rowsA, colsA] = shape(a);
const [rowsB, colsB] = shape(b);
if (colsA !== rowsB) {
throw new RangeError('cannot multiply ' + rowsA + 'x' + colsA + ' by ' + rowsB + 'x' + colsB);
}
const out = [];
for (let i = 0; i < rowsA; i++) {
const row = [];
for (let j = 0; j < colsB; j++) {
let total = 0;
for (let k = 0; k < colsA; k++) total += a[i][k] * b[k][j];
row.push(total);
}
out.push(row);
}
return out;
}
const identity = (n) =>
Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0)));
// Drop one row and one column.
const minor = (m, row, col) =>
m.filter((_, i) => i !== row).map((r) => r.filter((_, j) => j !== col));
function det(m) {
const n = m.length;
if (n === 1) return m[0][0];
if (n === 2) return m[0][0] * m[1][1] - m[0][1] * m[1][0];
let total = 0;
for (let j = 0; j < n; j++) total += (j % 2 ? -1 : 1) * m[0][j] * det(minor(m, 0, j));
return total;
}
function inverse2(m) {
const d = det(m);
if (d === 0) return null;
return [[m[1][1] / d, -m[0][1] / d], [-m[1][0] / d, m[0][0] / d]];
}
// The shoelace formula, written as a sum of 2 by 2 determinants.
function area(points) {
let total = 0;
for (let i = 0; i < points.length; i++) {
const p = points[i];
const q = points[(i + 1) % points.length];
total += det([[p[0], q[0]], [p[1], q[1]]]);
}
return total / 2;
}
const A = [[1, 2, 3], [4, 5, 6], [7, 8, 10]];
const B = [[2, 0, 1], [1, 3, 0], [0, 1, 4]];
const singular = [[1, 2, 3], [4, 5, 6], [7, 8, 9]];
const fourByFour = [[1, 0, 2, -1], [3, 0, 0, 5], [2, 1, 4, -3], [1, 0, 5, 0]];
const M = [[4, 7], [2, 6]];
const F = [[0.1, 0.2], [0.3, 0.4]];
const G = [[0.5, 0.6], [0.7, 0.8]];
const plot = [[0, 0], [40, 0], [40, 25], [15, 40], [0, 25]];
let mismatch = 'no error';
try { multiply(A, M); } catch (error) { mismatch = error.constructor.name + ': ' + error.message; }
console.log('A B :', multiply(A, B));
console.log('B A :', multiply(B, A), '<- a different matrix');
console.log('dets :', det(A), det(B), 'product', det(A) * det(B), 'det of AB', det(multiply(A, B)));
console.log('4 by 4 :', det(fourByFour), 'transpose of A', det([0, 1, 2].map((j) => A.map((r) => r[j]))));
console.log('singular :', det(singular), 'inverse of a singular 2 by 2', inverse2([[1, 2], [2, 4]]));
console.log('M times M^-1 :', multiply(M, inverse2(M)), '<- not quite the identity');
console.log('float dets :', det(multiply(F, G)), det(F) * det(G));
console.log('shape error :', mismatch);
console.log('plot :', area(plot), 'square metres, reversed', area(plot.slice().reverse()));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the product is row by column, and swapping the operands changes it', [multiply(A, B), multiply(B, A)], [[[4, 9, 13], [13, 21, 28], [22, 34, 47]], [[9, 12, 16], [13, 17, 21], [32, 37, 46]]]);
test('the identity leaves a matrix alone, and mismatched shapes throw', [multiply(A, identity(3)), mismatch], [A, 'RangeError: cannot multiply 3x3 by 2x2']);
test('det(AB) is det(A) times det(B), exactly, in whole numbers', [det(A), det(B), det(multiply(A, B)), det(A) * det(B)], [-3, 25, -75, -75]);
test('cofactor expansion handles 4 by 4, and a transpose has the same determinant', [det(fourByFour), det([0, 1, 2].map((j) => A.map((r) => r[j])))], [30, -3]);
test('a determinant of 0 means the rows are dependent, and there is no inverse', [det(singular), inverse2([[1, 2], [2, 4]])], [0, null]);
test('a matrix times its own inverse is NOT the identity in floating point', multiply(M, inverse2(M)), [[0.9999999999999998, 4.440892098500626e-16], [-2.220446049250313e-16, 1.0000000000000004]]);
test('and with 0.1 entries the same identity fails, by 1.46e-18', [det(multiply(F, G)), det(F) * det(G), det(multiply(F, G)) === det(F) * det(G)], [0.0003999999999999976, 0.00039999999999999904, false]);
test('the shoelace formula is a sum of 2 by 2 determinants: 1300 square metres', [area(plot), area(plot.slice().reverse())], [1300, -1300]);
test('and a 2 by 2 determinant is twice the triangle its two vectors span', det([[3, 0], [0, 4]]) / 2, 6);
Two things are worth taking from the shape of this code. Multiplication is three nested loops and a dimension check, and the check is not optional: colsA !== rowsB is the one error the algorithm can detect for you, and without it a mismatched multiply silently produces NaN entries from undefined reads. And the determinant is naturally recursive — expand along the first row, alternate signs, recurse on the minor — which is beautiful and costs n! multiplications. Fine at 4 by 4, hopeless at 20 by 20, where you would use LU decomposition instead.
Then the floating point. With whole-number entries every identity here holds exactly: det(AB) = det(A) det(B) lands on -75 both ways, a transpose has the same determinant, a singular matrix gives exactly 0. Swap in entries of 0.1 and the same identity misses by 1.46e-18, and M times its own inverse comes back with 0.9999999999999998 and 1.0000000000000004 on the diagonal and 4.4e-16 off it. So never test a matrix for invertibility with det(m) === 0 on measured data, and never assert an inverse round-trips exactly; compare against a tolerance scaled to the size of the entries.
The last two assertions connect this back to geometry. A 2 by 2 determinant is the cross product of two vectors and twice the area of the triangle they span, which means the shoelace formula from exercise 75 is literally a sum of determinants around the polygon — the 40 by 25 plot with a corner cut off comes to 1300 square metres, and listing the corners the other way round gives -1300. The sign is the winding direction, and it is the determinant telling you the orientation was flipped.
112 Quadratic roots, written so they survive
hardSolve a quadratic with the formula you were taught, then solve x² + 100000000x + 1 = 0 with it and check the roots by substituting them back. Write the stable version, compare residuals, and say what each version does when the discriminant is negative, when a is zero, and when b² overflows.
Stuck? Three hints, smallest first
- 1. The two roots come out of
(-b ± sqrt(d)) / (2a), and whenbis huge one of those is a big number minus a nearly equal big number. - 2. Compute the root that does not cancel, then get the other from the fact that the roots multiply to
c / a. - 3. Substituting a root back in and seeing how far from zero you land is the only honest test of a solver.
const residual = (a, b, c, x) => a * x * x + b * x + c;
// The formula as taught.
function naiveRoots(a, b, c) {
const d = b * b - 4 * a * c;
const root = Math.sqrt(d);
return [(-b + root) / (2 * a), (-b - root) / (2 * a)];
}
// The stable formula. Add the square root to b with b's own sign, so nothing
// cancels, then get the second root from the product of the roots, c / a.
function stableRoots(a, b, c) {
const d = b * b - 4 * a * c;
if (d < 0) return [];
const root = Math.sqrt(d);
const q = -(b + Math.sign(b || 1) * root) / 2;
return [q / a, q === 0 ? 0 : c / q];
}
// Divide through by the largest coefficient first, so b * b cannot overflow.
function scaledRoots(a, b, c) {
const s = Math.max(Math.abs(a), Math.abs(b), Math.abs(c));
return stableRoots(a / s, b / s, c / s);
}
const naiveBig = naiveRoots(1, 1e8, 1);
const stableBig = stableRoots(1, 1e8, 1);
const naiveResiduals = naiveBig.map((x) => residual(1, 1e8, 1, x));
const stableResiduals = stableBig.map((x) => residual(1, 1e8, 1, x));
// Vieta: the roots must add to -b / a and multiply to c / a.
const vietaNaive = naiveBig[0] * naiveBig[1] - 1;
const vietaStable = stableBig[0] * stableBig[1] - 1;
const overflowed = naiveRoots(1, 1e200, 1);
const stableOverflowed = stableRoots(1, 1e200, 1);
const rescued = scaledRoots(1, 1e200, 1);
console.log('x2 - 5x + 6 :', naiveRoots(1, -5, 6), stableRoots(1, -5, 6));
console.log('x2 - 2x + 1 :', naiveRoots(1, -2, 1), 'discriminant', 4 - 4);
console.log('x2 + x + 1 :', naiveRoots(1, 1, 1), 'against a list of', stableRoots(1, 1, 1).length, 'real roots');
console.log('b = 1e8, naive :', naiveBig, 'residuals', naiveResiduals);
console.log('b = 1e8, stable :', stableBig, 'residuals', stableResiduals);
console.log('b = 1e9, naive :', naiveRoots(1, 1e9, 1), '<- the small root is gone');
console.log('a = 0, naive :', naiveRoots(0, 2, -4), 'stable', stableRoots(0, 2, -4));
console.log('b = 1e200 :', overflowed, 'discriminant', 1e200 * 1e200);
console.log('stable, scaled :', stableOverflowed, rescued);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('with friendly numbers both versions agree, exactly', [naiveRoots(1, -5, 6), stableRoots(1, -5, 6)], [[3, 2], [3, 2]]);
test('a zero discriminant gives the same root twice', [naiveRoots(1, -2, 1), (-2) ** 2 - 4], [[1, 1], 0]);
test('a negative one gives two NaNs, where the stable version reports no real roots', [naiveRoots(1, 1, 1).map(Number.isNaN), stableRoots(1, 1, 1)], [[true, true], []]);
test('for b = 1e8 the taught formula loses a quarter of the small root', [naiveBig[0], stableBig[1]], [-7.450580596923828e-9, -1e-8]);
test('substituting back proves it: 0.25 against 1.1e-16', [naiveResiduals[0], stableResiduals[1]], [0.2549419403076172, 1.1102230246251565e-16]);
test('and the product of the roots should be exactly c / a, which is 1', [vietaNaive, vietaStable], [-0.2549419403076172, 0]);
test('one more decade and the small root collapses to exactly zero', naiveRoots(1, 1e9, 1)[0], 0);
test('with a = 0 the taught formula gives NaN and -Infinity, while the stable one finds x = 2', [Number.isNaN(naiveRoots(0, 2, -4)[0]), naiveRoots(0, 2, -4)[1] === -Infinity, stableRoots(0, 2, -4)[1]], [true, true, 2]);
test('and b = 1e200 overflows the discriminant, until you scale the coefficients first', [overflowed[0] === Infinity, Object.is(stableOverflowed[1], -0), rescued], [true, true, [-1e200, -1e-200]]);
Exercise 28 pointed here. x² + 100000000x + 1 = 0 has roots near -1e-8 and -1e8, and the formula everyone memorised returns -7.450580596923828e-9 for the small one — a quarter of the way wrong. Nothing is broken: -b + Math.sqrt(d) is 1e8 minus 99999999.99999998, two nearly equal numbers, and subtracting them throws away every significant digit they had in common. This is catastrophic cancellation, and it is the reason numerical analysis exists as a subject.
The fix costs one line. Compute q = -(b + sign(b) * sqrt(d)) / 2, which adds two same-signed numbers so nothing cancels, take q / a as the safe root, and get the other from the fact that the roots multiply to c / a. Substituting back is what proves it: the taught version leaves a residual of 0.25, the stable one 1.1e-16. Any solver you write should be checked this way, because a residual is the only test that does not need to know the answer in advance.
Then the edges, and they all pass in silence. A negative discriminant makes Math.sqrt return NaN, so the taught version hands back two NaNs that will propagate through everything downstream; returning an empty list says the same thing in a form the caller can test. a = 0 is not a quadratic at all, and dividing by 2 * a gives NaN and -Infinity — while the stable form, oddly, still finds the linear root 2, because c / q never touches a. And b = 1e200 overflows b * b to Infinity before the discriminant is even finished, which the stable version reports as a -0 root; dividing all three coefficients by the largest of them first brings the whole thing back into range and recovers -1e200 and -1e-200.
113 Integrating by the trapezium rule, and Simpson on top
hardIntegrate x² from 0 to 3 by the trapezium rule and show the error quartering as you double the strips. Add the midpoint and Simpson rules, and check that Simpson is exactly the combination of the other two. Then integrate 1/x from 1 to 2, compare against Math.log(2), and find where refining the mesh stops paying.
Stuck? Three hints, smallest first
- 1. The trapezium sum is
h * (f(a)/2 + f(a + h) + ... + f(b)/2), and it overestimates a curve that bends upwards. - 2. Simpson weights the interior points 4, 2, 4, 2, ..., which needs an even number of strips.
- 3. Simpson is
(trapezium + 2 * midpoint) / 3, and also(4 * T(h/2) - T(h)) / 3.
function trapezium(f, a, b, n) {
const h = (b - a) / n;
let total = (f(a) + f(b)) / 2;
for (let i = 1; i < n; i++) total += f(a + i * h);
return total * h;
}
function midpoint(f, a, b, n) {
const h = (b - a) / n;
let total = 0;
for (let i = 0; i < n; i++) total += f(a + (i + 0.5) * h);
return total * h;
}
function simpson(f, a, b, n) {
if (n % 2 !== 0) throw new RangeError('Simpson needs an even number of strips');
const h = (b - a) / n;
let total = f(a) + f(b);
for (let i = 1; i < n; i++) total += f(a + i * h) * (i % 2 ? 4 : 2);
return (total * h) / 3;
}
const square = (x) => x * x;
const cube = (x) => x ** 3;
const line = (x) => 2 * x + 1;
const reciprocal = (x) => 1 / x;
const spike = (x) => 1 / Math.sqrt(x); // infinite at x = 0
const trapErrors = [1, 2, 6, 12].map((n) => trapezium(square, 0, 3, n) - 9);
const T = trapezium(square, 0, 3, 6);
const Mid = midpoint(square, 0, 3, 6);
const t10 = trapezium(reciprocal, 1, 2, 10);
const t20 = trapezium(reciprocal, 1, 2, 20);
const s20 = simpson(reciprocal, 1, 2, 20);
const richardson = (4 * t20 - t10) / 3;
const halvingRatio = (t10 - Math.log(2)) / (t20 - Math.log(2));
// How many trapezium strips does it take to match Simpson's twenty?
let crossover = 0;
for (let n = 2; n <= 600 && crossover === 0; n++) {
if (Math.abs(trapezium(reciprocal, 1, 2, n) - Math.log(2)) <= Math.abs(s20 - Math.log(2))) crossover = n;
}
// Keep refining and watch the tenfold gain in accuracy decay.
const ratios = [];
let previous = 0;
for (const n of [100, 1000, 10000, 100000]) {
const error = Math.abs(trapezium(reciprocal, 1, 2, n) - Math.log(2));
if (previous !== 0) ratios.push(previous / error);
previous = error;
}
let oddStrips = 'no error';
try { simpson(square, 0, 3, 3); } catch (error) { oddStrips = error.constructor.name; }
console.log('x2 trapezium :', [1, 2, 6, 12].map((n) => trapezium(square, 0, 3, n)), 'exact 9');
console.log('errors :', trapErrors, '<- each a quarter of the last');
console.log('midpoint :', Mid, 'trapezium', T, '(T + 2M) / 3 =', (T + 2 * Mid) / 3);
console.log('Simpson x2, x3:', simpson(square, 0, 3, 2), simpson(cube, 0, 2, 2), 'exact 9 and 4');
console.log('a straight line:', trapezium(line, 0, 4, 1), trapezium(line, 0, 4, 7), trapezium(line, 0, 4, 8));
console.log('1/x, ln 2 :', t10, t20, s20, Math.log(2));
console.log('richardson :', richardson, 'against Simpson', s20, 'gap', richardson - s20);
console.log('halving ratio :', halvingRatio, 'refinement ratios', ratios);
console.log('crossover :', crossover, 'trapezium strips to match Simpson on 20');
console.log('spike :', trapezium(spike, 0, 1, 100), 'midpoint', midpoint(spike, 0, 1, 100), 'exact 2');
console.log('odd strips :', oddStrips);
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('one trapezium over x2 gives 13.5, and six give 9.125: always an overestimate', [trapezium(square, 0, 3, 1), T], [13.5, 9.125]);
test('doubling the strips quarters the error, exactly, for a quadratic', trapErrors, [4.5, 1.125, 0.125, 0.03125]);
test('the midpoint rule errs half as much, the other way, and Simpson is (T + 2M) / 3', [Mid - 9, (T + 2 * Mid) / 3], [-0.0625, 9]);
test('Simpson is exact for a quadratic and for a cubic, on two strips', [simpson(square, 0, 3, 2), simpson(cube, 0, 2, 2), oddStrips], [9, 4, 'RangeError']);
test('the trapezium rule is exact for a straight line, but the arithmetic is not always', [trapezium(line, 0, 4, 1), trapezium(line, 0, 4, 8), trapezium(line, 0, 4, 7)], [20, 20, 19.999999999999996]);
test('over 1/x the trapezium error falls by four when the strips double', [t10, t20, Math.abs(halvingRatio - 4) < 0.01], [0.693771403175428, 0.693303381792694, true]);
test('Simpson on 20 strips is worth 568 trapezium strips: 21 evaluations against 569', [crossover, Math.abs(s20 - Math.log(2)) < 2e-7], [568, true]);
test('Richardson on two trapezium sums IS Simpson, to within a single bit', [richardson, s20, richardson === s20, Math.abs(richardson - s20) < 2e-16], [0.693147374665116, 0.6931473746651161, false, true]);
test('but tenfold refinement stops buying a hundredfold accuracy at 100000 strips', [ratios.map((r) => Math.round(r * 1e4) / 1e4), ratios[2] > 100.1], [[99.9988, 100.0001, 100.1778], true]);
The trapezium rule is a sum with two half-weights at the ends, and its error is proportional to h², which the first two assertions show directly: 4.5, 1.125, 0.125, 0.03125 as the strips go 1, 2, 6, 12. Every one is an overestimate, because x² bends upwards and a chord sits above the curve. The midpoint rule makes the opposite error at half the size — -0.0625 against 0.125 — which is exactly why (T + 2M) / 3 lands on 9 with nothing left over. That combination is Simpson's rule, and the same rule written as (4 T(h/2) - T(h)) / 3 is Richardson extrapolation: two cheap estimates of a bad method make one estimate of a much better one.
Simpson is exact for cubics as well as quadratics, which is the free lunch that makes it the default. On 1/x over [1, 2] its 20-strip answer takes the trapezium rule 568 strips to equal — 21 function evaluations against 569, and the gap widens as you ask for more accuracy, because one method improves like h² and the other like h⁴. Note the assertion about Richardson: it produces 0.693147374665116 where simpson produces 0.6931473746651161. The same number mathematically, one bit apart in doubles, because the operations happen in a different order. Comparing two implementations of the same formula with === is a test that fails for no reason.
The last two assertions are the discipline. Refinement is not free and does not continue to pay: from n = 100 to n = 10000 each tenfold refinement gives back very close to the promised factor of 100, and at n = 100000 the ratio is 100.18 instead of 100, because a hundred thousand rounded additions have begun to contribute their own error. Push further and the error starts to grow. Use a better rule rather than a finer mesh. And the spike shows the other failure mode: 1 / Math.sqrt(x) has a finite integral over [0, 1], but the trapezium rule evaluates the endpoint and returns Infinity, where the midpoint rule never touches it and converges — slowly — to 2.
114 Check digits, and the errors each one cannot see
hardImplement three check-digit schemes: Luhn for card numbers, the 3-7-3 weighted sum Nigerian NUBAN account numbers use, and ISBN-10's mod 11. For each, exhaustively test every single-digit error and every swap of two adjacent digits, and report which errors each scheme cannot detect. Add a plain digit-sum check as the control.
Stuck? Three hints, smallest first
- 1. Luhn doubles every second digit from the right and subtracts 9 if the doubling passes 9; a valid number has a total divisible by 10.
- 2. A weighted scheme cannot see a swap of two digits whose weights are equal, because the weighted sum does not change.
- 3. Mod 11 with weights 10, 9, 8, ... 1 gives every position a distinct weight and a prime modulus, which is why it needs an X.
const digits = (s) => [...s].map(Number);
// Luhn: double every second digit counting from the right.
function luhnTotal(s) {
const d = digits(s);
let total = 0;
for (let i = 0; i < d.length; i++) {
const fromRight = d.length - 1 - i;
let v = d[i];
if (fromRight % 2 === 1) {
v *= 2;
if (v > 9) v -= 9;
}
total += v;
}
return total;
}
const luhnValid = (s) => luhnTotal(s) % 10 === 0;
const luhnDigit = (body) => (10 - (luhnTotal(body + '0') % 10)) % 10;
// NUBAN: weights 3, 7, 3 repeating over the bank code and the serial number.
const WEIGHTS = [3, 7, 3, 3, 7, 3, 3, 7, 3, 3, 7, 3];
function nubanDigit(bank, serial) {
const d = digits(bank + serial);
const total = d.reduce((sum, v, i) => sum + v * WEIGHTS[i], 0);
return (10 - (total % 10)) % 10;
}
// ISBN-10: weights 10 down to 1, modulus 11, and a check value of 10 prints as X.
function isbnDigit(body) {
const total = digits(body).reduce((sum, v, i) => sum + v * (10 - i), 0);
const check = (11 - (total % 11)) % 11;
return check === 10 ? 'X' : String(check);
}
const isbnValid = (s) =>
[...s].map((c) => (c === 'X' ? 10 : Number(c))).reduce((sum, v, i) => sum + v * (10 - i), 0) % 11 === 0;
// The control: add the digits, no weights at all.
const sumDigit = (body) => (10 - (digits(body).reduce((a, b) => a + b, 0) % 10)) % 10;
const sumValid = (s) => sumDigit(s.slice(0, -1)) === Number(s[s.length - 1]);
const card = '4111111111111111';
// Every single-digit change to the card: 16 positions, 9 wrong digits each.
let missedSingle = 0;
for (let i = 0; i < card.length; i++) {
for (let d = 0; d <= 9; d++) {
if (String(d) === card[i]) continue;
if (luhnValid(card.slice(0, i) + d + card.slice(i + 1))) missedSingle++;
}
}
// Every ordered pair of distinct digits, swapped in the same two positions.
function missedSwaps(check, valid) {
const missed = [];
for (let a = 0; a <= 9; a++) {
for (let b = 0; b <= 9; b++) {
if (a === b) continue;
const body = '0306' + a + b + '15';
const full = body + check(body);
const swapped = full.slice(0, 4) + b + a + full.slice(6);
if (valid(swapped)) missed.push(a + '' + b);
}
}
return missed;
}
const bank = '011';
const serial = '123456789';
const account = serial + nubanDigit(bank, serial);
// Which adjacent swaps inside the serial does NUBAN see? Only those across
// positions whose weights differ.
const nubanSwaps = [0, 1, 2, 3, 4, 5, 6, 7].map((i) => {
const swapped = serial.slice(0, i) + serial[i + 1] + serial[i] + serial.slice(i + 2);
return nubanDigit(bank, swapped) !== nubanDigit(bank, serial);
});
console.log('luhn 79927398713 :', luhnValid('79927398713'), 'total', luhnTotal('79927398713'));
console.log('luhn test card :', luhnValid(card), 'check digit for 411111111111111 is', luhnDigit('411111111111111'));
console.log('single-digit miss:', missedSingle, 'of', card.length * 9);
console.log('luhn swaps missed:', missedSwaps(luhnDigit, luhnValid));
console.log('isbn swaps missed:', missedSwaps(isbnDigit, isbnValid));
console.log('sum swaps missed :', missedSwaps(sumDigit, sumValid).length, 'of 90');
console.log('nuban account :', account, 'from bank', bank, 'and serial', serial);
console.log('nuban swaps seen :', nubanSwaps, 'weights', WEIGHTS.slice(3));
console.log('isbn :', isbnDigit('030640615'), isbnDigit('043942089'), isbnValid('097522980X'));
Output
9 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 9 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('two published Luhn examples check out, and the algorithm reproduces the digit', [luhnValid('79927398713'), luhnValid(card), luhnDigit('411111111111111')], [true, true, 1]);
test('Luhn catches all 144 single-digit errors in a 16-digit number', [missedSingle, card.length * 9], [0, 144]);
test('but of the 90 ordered pairs, it cannot see 0 swapped with 9', missedSwaps(luhnDigit, luhnValid), ['09', '90']);
test('mod 11 with distinct weights misses none of the 90', missedSwaps(isbnDigit, isbnValid), []);
test('while a plain digit sum misses every single one of them', missedSwaps(sumDigit, sumValid).length, 90);
test('ISBN-10 needs an X when the check value comes out as 10', [isbnDigit('030640615'), isbnDigit('043942089'), isbnValid('097522980X')], ['2', 'X', true]);
test('the NUBAN scheme turns a 9-digit serial into a 10-digit account number', [account, account.length], ['1234567895', 10]);
test('and its 3, 7, 3 weights are blind to a swap wherever two neighbours share a weight', nubanSwaps, [true, true, false, true, true, false, true, true]);
test('which is exactly where the weight list repeats itself', [0, 1, 2, 3, 4, 5, 6, 7].map((i) => WEIGHTS[i + 3] !== WEIGHTS[i + 4]), nubanSwaps);
A check digit is one number's worth of redundancy, and what it buys you depends entirely on the weights. The control at the bottom makes that concrete: a plain digit sum catches every single-digit error and none of the 90 adjacent swaps, because reordering the digits does not change their sum. Luhn's alternate doubling fixes that for 88 of the 90 — the two it misses are 09 and 90, since doubling 0 gives 0 and doubling 9 gives 18 which folds to 9, so the pair contributes 9 either way. That is the one famous hole in the scheme that guards most of the world's card numbers.
The NUBAN weights show the general rule. Any weighted sum is blind to a swap of two digits whose weights are equal, because the total is unchanged — and 3, 7, 3, 3, 7, 3 has adjacent equal weights wherever the pattern wraps. The last two assertions test exactly that: the positions NUBAN cannot see a swap in are precisely the positions where WEIGHTS[i] === WEIGHTS[i + 1], computed two independent ways and compared. It still catches every single-digit error, which is the common case, and a 10-digit account number that fails the check can be rejected before it reaches an interbank transfer.
ISBN-10 is the one with the cleanest mathematics: weights 10 down to 1 are all distinct, and the modulus 11 is prime, so every single-digit error and every transposition — adjacent or not — changes the total. The price is that the check value can come out as 10, which does not fit in a digit, hence the X. That is the trade: a prime modulus gives you complete transposition detection and an alphabet you did not want. When you design one of these, decide first which errors your users actually make.