Skip to content

Commit 84f5f2f

Browse files
committed
Test exact dtoa output and compact powers
1 parent 881a8ec commit 84f5f2f

4 files changed

Lines changed: 257 additions & 1 deletion

File tree

‎tests/dtoa/format.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import {
33
} from "~lib/util/dtoa";
44
import { dtoa as numberDtoa, dtoa_buffered as numberBuffered } from "util/number";
55

6+
// Called by the JS differential harness with exact input bit patterns.
7+
export function format64Bits(bits: u64, buffer: usize): u32 {
8+
return numberBuffered<f64>(buffer, reinterpret<f64>(bits));
9+
}
10+
11+
export function format32Bits(bits: u32, buffer: usize): u32 {
12+
return numberBuffered<f32>(buffer, reinterpret<f32>(bits));
13+
}
14+
615
toDigits32(1);
716
assert(gDigits == 1);
817
assert(gDigHi == 0x3130303030303030); // "00000001"

‎tests/dtoa/index.js‎

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,102 @@
11
import asc from "../../dist/asc.js";
2+
import { shortestFloat32, withAssemblyScriptDotZero } from "./oracle.js";
3+
import { verifyPowers } from "./powers.js";
4+
5+
verifyPowers();
6+
7+
const f64View = new DataView(new ArrayBuffer(8));
8+
const f32View = new DataView(new ArrayBuffer(4));
9+
const sign64 = 1n << 63n;
10+
const maxPositive64 = sign64 - 1n;
11+
let randomState = 0x12345678;
12+
13+
function random32() {
14+
randomState ^= randomState << 13;
15+
randomState ^= randomState >>> 17;
16+
randomState ^= randomState << 5;
17+
return randomState >>> 0;
18+
}
19+
20+
function bits64(value) {
21+
f64View.setFloat64(0, value);
22+
return f64View.getBigUint64(0);
23+
}
24+
25+
function bits32(value) {
26+
f32View.setFloat32(0, value);
27+
return f32View.getUint32(0);
28+
}
29+
30+
function makeCases64() {
31+
const patterns = new Set();
32+
const addSigned = positive => {
33+
if (positive < 0n || positive > maxPositive64) return;
34+
patterns.add(positive);
35+
patterns.add(positive | sign64);
36+
};
37+
for (let exponent = 0; exponent <= 2047; ++exponent) {
38+
const base = BigInt(exponent) << 52n;
39+
for (const significand of [0n, 1n, 15n, (1n << 52n) - 1n]) {
40+
addSigned(base | significand);
41+
}
42+
}
43+
for (let exponent = -323; exponent <= 308; ++exponent) {
44+
const base = bits64(Number(`1e${exponent}`));
45+
for (let delta = -2; delta <= 2; ++delta) addSigned(base + BigInt(delta));
46+
}
47+
for (let i = 0; i < 2048; ++i) {
48+
patterns.add((BigInt(random32()) << 32n) | BigInt(random32()));
49+
}
50+
return [...patterns].map(bits => {
51+
f64View.setBigUint64(0, bits);
52+
return [bits, withAssemblyScriptDotZero(f64View.getFloat64(0).toString())];
53+
});
54+
}
55+
56+
function makeCases32() {
57+
const patterns = new Set();
58+
const addSigned = positive => {
59+
if (positive < 0 || positive > 0x7fffffff) return;
60+
patterns.add(positive >>> 0);
61+
patterns.add((positive | 0x80000000) >>> 0);
62+
};
63+
for (let exponent = 0; exponent <= 255; ++exponent) {
64+
const base = exponent << 23;
65+
for (const significand of [0, 1, 15, 0x7fffff]) {
66+
addSigned(base | significand);
67+
}
68+
}
69+
for (let exponent = -45; exponent <= 38; ++exponent) {
70+
const base = bits32(10 ** exponent);
71+
for (let delta = -2; delta <= 2; ++delta) addSigned(base + delta);
72+
}
73+
for (let i = 0; i < 2048; ++i) patterns.add(random32());
74+
return [...patterns].map(bits => [bits, withAssemblyScriptDotZero(shortestFloat32(bits))]);
75+
}
76+
77+
const cases64 = makeCases64();
78+
const cases32 = makeCases32();
79+
80+
function compareExactOutput(exports, mode) {
81+
const { memory, format64Bits, format32Bits } = exports;
82+
const buffer = memory.grow(1) * 65536;
83+
for (const [bits, expected] of cases64) {
84+
const length = format64Bits(bits, buffer);
85+
if (length > 32) throw new Error(`${mode} f64 length ${length} exceeds buffer capacity`);
86+
const actual = String.fromCharCode(...new Uint16Array(memory.buffer, buffer, length));
87+
if (actual !== expected) {
88+
throw new Error(`${mode} f64 0x${bits.toString(16)}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`);
89+
}
90+
}
91+
for (const [bits, expected] of cases32) {
92+
const length = format32Bits(bits, buffer);
93+
if (length > 32) throw new Error(`${mode} f32 length ${length} exceeds buffer capacity`);
94+
const actual = String.fromCharCode(...new Uint16Array(memory.buffer, buffer, length));
95+
if (actual !== expected) {
96+
throw new Error(`${mode} f32 0x${bits.toString(16)}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`);
97+
}
98+
}
99+
}
2100

3101
for (const optimized of [false, true]) {
4102
for (const simd of [false, true]) {
@@ -31,6 +129,7 @@ for (const optimized of [false, true]) {
31129
},
32130
});
33131
instance.exports.start();
34-
console.log(`dtoa ${mode}: passed`);
132+
compareExactOutput(instance.exports, mode);
133+
console.log(`dtoa ${mode}: ${cases64.length} f64 and ${cases32.length} f32 exact outputs passed`);
35134
}
36135
}

‎tests/dtoa/oracle.js‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Independent f32 shortest-decimal oracle, adapted from xjb-as's BigInt
2+
// verifier. It works from the exact binary32 value, not a widened f64 string.
3+
const bits32 = new DataView(new ArrayBuffer(4));
4+
5+
export function float32FromBits(bits) {
6+
bits32.setUint32(0, bits >>> 0);
7+
return bits32.getFloat32(0);
8+
}
9+
10+
function formatDecimal(negative, digits, point) {
11+
const count = digits.length;
12+
let result;
13+
if (count <= point && point <= 21) {
14+
result = digits + "0".repeat(point - count);
15+
} else if (0 < point && point <= 21) {
16+
result = digits.slice(0, point) + "." + digits.slice(point);
17+
} else if (-6 < point && point <= 0) {
18+
result = "0." + "0".repeat(-point) + digits;
19+
} else {
20+
const exponent = point - 1;
21+
const suffix = "e" + (exponent >= 0 ? "+" : "-") + Math.abs(exponent);
22+
result = count === 1 ? digits + suffix : digits[0] + "." + digits.slice(1) + suffix;
23+
}
24+
return (negative ? "-" : "") + result;
25+
}
26+
27+
export function shortestFloat32(bits) {
28+
const value = float32FromBits(bits);
29+
if (Number.isNaN(value)) return "NaN";
30+
if (value === Infinity) return "Infinity";
31+
if (value === -Infinity) return "-Infinity";
32+
if (value === 0) return "0";
33+
34+
const negative = (bits >>> 31) !== 0;
35+
const exponentBits = (bits >>> 23) & 0xff;
36+
const mantissa = bits & 0x7fffff;
37+
const significand = BigInt(exponentBits ? mantissa | 0x800000 : mantissa);
38+
const binaryExponent = exponentBits ? exponentBits - 150 : -149;
39+
const decimalInteger = binaryExponent >= 0
40+
? significand << BigInt(binaryExponent)
41+
: significand * 5n ** BigInt(-binaryExponent);
42+
const decimalExponent = Math.min(binaryExponent, 0);
43+
const exactDigits = decimalInteger.toString();
44+
const magnitude = Math.abs(value);
45+
46+
// At each length, test the two decimals bracketing the exact value. Choose
47+
// the closest one that parses back to the binary32 value; ties go to even.
48+
for (let count = 1; count <= exactDigits.length; ++count) {
49+
const shift = exactDigits.length - count + decimalExponent;
50+
const floor = exactDigits.slice(0, count);
51+
const ceil = (BigInt(floor) + 1n).toString();
52+
const roundTrips = digits => Math.fround(Number(digits + "e" + shift)) === magnitude;
53+
const floorOk = roundTrips(floor);
54+
const ceilOk = roundTrips(ceil);
55+
if (!floorOk && !ceilOk) continue;
56+
57+
let chosen;
58+
if (floorOk && ceilOk) {
59+
const unit = 10n ** BigInt(exactDigits.length - count);
60+
const distance = 2n * (decimalInteger % unit);
61+
chosen = distance < unit ? floor : distance > unit ? ceil
62+
: (Number(floor[floor.length - 1]) & 1) === 0 ? floor : ceil;
63+
} else {
64+
chosen = floorOk ? floor : ceil;
65+
}
66+
const point = chosen.length + shift;
67+
return formatDecimal(negative, chosen.replace(/0+$/, ""), point);
68+
}
69+
throw new Error(`no shortest f32 decimal for 0x${bits.toString(16)}`);
70+
}
71+
72+
export function withAssemblyScriptDotZero(text) {
73+
return /^-?\d+$/.test(text) ? text + ".0" : text;
74+
}

‎tests/dtoa/powers.js‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { readFileSync } from "node:fs";
2+
3+
const source = readFileSync(new URL("../../std/assembly/util/dtoa.ts", import.meta.url), "utf8");
4+
const mask64 = (1n << 64n) - 1n;
5+
6+
function table(name) {
7+
const match = new RegExp(`\\b${name}\\s*=\\s*memory\\.data<[^>]+>\\(\\[([\\s\\S]*?)\\]\\)`).exec(source);
8+
if (!match) throw new Error(`missing ${name}`);
9+
return [...match[1].matchAll(/0x[0-9a-f]+/gi)].map(([hex]) => BigInt(hex));
10+
}
11+
12+
function exactPower(index) {
13+
const exponent = index - 293;
14+
const power = 10n ** BigInt(Math.abs(exponent));
15+
const bitCount = power.toString(2).length;
16+
return exponent >= 0
17+
? bitCount <= 128 ? power << BigInt(128 - bitCount) : power >> BigInt(bitCount - 128)
18+
: (1n << BigInt(127 + bitCount)) / power;
19+
}
20+
21+
export function verifyPowers() {
22+
const majorTable = table("POW10_MAJOR");
23+
const fixups = table("POW10_FIXUPS");
24+
const shared = table("POW10_SHARED");
25+
if (majorTable.length !== 46 || fixups.length !== 20 || shared.length !== 53) {
26+
throw new Error("power-table lengths changed; update the verifier with the layout");
27+
}
28+
29+
// Shared highs cover powers 10^27..10^-17. Their rounding direction is
30+
// important to the f32 path, even where the corresponding low is not stored.
31+
for (let t = 17; t <= 61; ++t) {
32+
const exact = exactPower(337 - t);
33+
const high = exact >> 64n;
34+
const low = exact & mask64;
35+
if (shared[t - 17] !== high + BigInt(low !== 0n)) {
36+
throw new Error(`shared high mismatch at t=${t}`);
37+
}
38+
if (t >= 45 && t <= 52 && shared[t] !== low) {
39+
throw new Error(`shared low mismatch at t=${t}`);
40+
}
41+
}
42+
43+
// Reproduce the compact reconstruction using BigInt multiplication, then
44+
// compare all 618 entries against exact powers of ten. The reference above
45+
// does not use any cached factors or correction bits.
46+
for (let index = 0; index < 618; ++index) {
47+
const j = index + 10;
48+
const major = (j * 293) >>> 13;
49+
if (major !== Math.floor(j / 28)) throw new Error(`quotient mismatch at ${j}`);
50+
const minor = j - major * 28;
51+
const factor = shared[27 - minor];
52+
const anchor = (majorTable[major * 2] << 64n) | majorTable[major * 2 + 1];
53+
const product = anchor * factor;
54+
const c0 = product & mask64;
55+
const c1 = (product >> 64n) & mask64;
56+
const c2 = product >> 128n;
57+
let high;
58+
let low;
59+
if (c2 >> 63n) {
60+
high = c2;
61+
low = c1;
62+
} else {
63+
high = (c2 << 1n) | (c1 >> 63n);
64+
low = ((c1 << 1n) | (c0 >> 63n)) & mask64;
65+
}
66+
const fixup = (fixups[index >> 5] >> BigInt(index & 31)) & 1n;
67+
low = (low - fixup) & mask64;
68+
const actual = (high << 64n) | low;
69+
const expected = exactPower(index);
70+
if (actual !== expected) {
71+
throw new Error(`power mismatch at ${index}: ${actual.toString(16)} != ${expected.toString(16)}`);
72+
}
73+
}
74+
}

0 commit comments

Comments
 (0)