mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
String to number: Number(str), unary +, and parseFloat now convert short decimal spellings with Clinger's exact fast path (scr_decimal_scan in scr_number.c): significant digits m <= 2^53 and a decimal exponent within +-22 (plus the folded range up to 22 + 15) need one correctly rounded multiply or divide, bit-identical to strtod. ToNumber validates and accumulates in the same pass; everything else still goes through the copied span and strtod. Whitespace trimming in Number(), parseInt, and parseFloat skips the UTF-8 decode for ASCII non-whitespace. toFixed: the exact-binary-value digit generation moves from scr_lib.c into scr_number.c (scr_f64_to_fixed). For f <= 22, mantissa * 5^f fits a 64x64->128 multiply, so the spec's n is one shift plus the dropped-half bit whenever it fits 64 bits. Digits render two at a time instead of through snprintf. The 16-limb bignum remains the fallback. Number to string (H44): a non-integral x below 2^46 that is the double nearest to m/100 prints as m/100 directly instead of running Ryu. Below 2^46 ulp(x) < 0.01, so m/100 is the unique shortest round-tripping decimal. The correctly rounded division (double)m / 100 == x is the proof. This covers JSON.stringify, templates, String(), join, and console output. Tests: Node oracles for every path. number-cases.txt gains money-like values, the 2^46 cutoff, and one-ulp neighbors. tonumber-cases.txt gains fast-path boundaries plus 20k seeded short decimals. The new parsefloat-cases.txt pins scr_parse_float, and the new fixed-cases.txt/test_fixed.c pin toFixed, including the 2^64 fast-path limit and f up to 100. Fuzz modes cover all four paths. Existing case lines are unchanged. Corpus 3170 pins the paths end-to-end against Node. Linux x64 sandbox, A/B against perf/stack/06-h23-layout-noise, --layouts=4 --runs=16 (run 3 of 3; geomean -6.0% / -6.7% / -6.3%): | workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node | | --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: | | json-records | 236.2 | 204.2 | -13.5% | -19.8% .. +2.1% | neutral | +0.0% | 488.62 | 0.42x | | regex-logs | 176.2 | 168.8 | -4.2% | -10.1% .. +0.3% | neutral | +0.0% | 281.23 | 0.60x | | ast-interp | 280.0 | 273.1 | -2.5% | -5.6% .. +1.4% | neutral | +2.8% | 273.05 | 1.00x | | records-sort | 952.4 | 939.4 | -1.4% | -5.0% .. +5.6% | neutral | +0.0% | 289.87 | 3.24x | | template-render | 78.9 | 77.3 | -2.1% | -6.2% .. +1.2% | neutral | +0.0% | 246.71 | 0.31x | | functional-pipeline | 338.7 | 348.8 | +3.0% | -6.5% .. +24.0% | neutral | +0.1% | 339.84 | 1.03x | | async-pipeline | 207.3 | 207.8 | +0.2% | -5.3% .. +3.8% | neutral | +0.0% | 105.57 | 1.97x | | word-graph | 302.0 | 300.1 | -0.6% | -5.1% .. +12.9% | neutral | +3.0% | 600.48 | 0.50x | | alloc-trees | 336.3 | 332.4 | -1.1% | -6.2% .. +2.6% | neutral | +0.1% | 144.73 | 2.30x | | numeric-kernels | 49.7 | 49.8 | +0.1% | -3.4% .. +6.5% | neutral | +4.0% | 143.73 | 0.35x | | validate-errors | 179.1 | 182.9 | +2.1% | -5.8% .. +9.1% | neutral | +0.0% | 727.78 | 0.25x | | csv-numbers | 349.0 | 233.8 | -33.0% | -37.5% .. -26.5% | faster | +0.1% | 541.77 | 0.43x | | log-summary | 171.6 | 135.7 | -20.9% | -23.3% .. -17.7% | faster | +1.8% | 313.2 | 0.43x | | inventory-report | 411.9 | 384.7 | -6.6% | -18.1% .. +5.5% | neutral | +2.7% | 521.27 | 0.74x | Size: +64..+136 bytes per executable. The +1.8..+4.0% rows are one 4 KiB page crossing (two for inventory-report).
182 lines
5.1 KiB
JavaScript
182 lines
5.1 KiB
JavaScript
/* oxlint-disable no-loss-of-precision -- Oracle inputs intentionally exercise numeric literal rounding. */
|
||
// Generates number-formatting test cases with Node as the oracle.
|
||
// Each line: <16 hex digits of the double's bit pattern>\t<String(value)>
|
||
//
|
||
// node gen-number-cases.mjs cases # 50k deterministic cases → stdout
|
||
// node gen-number-cases.mjs fuzz <count> # <count> random cases → stdout
|
||
import { argv, stdout } from "node:process";
|
||
|
||
const f64 = new Float64Array(1);
|
||
const u64 = new BigUint64Array(f64.buffer);
|
||
|
||
function line(x) {
|
||
f64[0] = x;
|
||
return `${u64[0].toString(16).padStart(16, "0")}\t${String(x)}\n`;
|
||
}
|
||
|
||
function* curated() {
|
||
yield* [
|
||
0,
|
||
-0,
|
||
1,
|
||
-1,
|
||
0.5,
|
||
0.1,
|
||
0.2,
|
||
0.3,
|
||
0.1 + 0.2,
|
||
2 / 3,
|
||
1 / 3,
|
||
NaN,
|
||
Infinity,
|
||
-Infinity,
|
||
Number.MAX_VALUE,
|
||
Number.MIN_VALUE,
|
||
-Number.MAX_VALUE,
|
||
-Number.MIN_VALUE,
|
||
Number.MAX_SAFE_INTEGER,
|
||
Number.MAX_SAFE_INTEGER + 1,
|
||
Number.MAX_SAFE_INTEGER + 2,
|
||
Number.EPSILON,
|
||
2.2250738585072014e-308, // smallest normal
|
||
9007199254740993,
|
||
123456789012345678,
|
||
1.7976931348623155e308,
|
||
5e-324,
|
||
1e21,
|
||
1e-7,
|
||
1e-6,
|
||
999999999999999999999,
|
||
1000000000000000000000,
|
||
0.000001,
|
||
0.0000001,
|
||
123.456,
|
||
3.141592653589793,
|
||
2.718281828459045,
|
||
// %e/strtod tie-breaking nasties
|
||
5e-1,
|
||
5e1,
|
||
35,
|
||
8.5,
|
||
1.5e300,
|
||
6.34e-8,
|
||
4.35e-9,
|
||
];
|
||
// decimal-exponent boundary sweep: d × 10^e around the notation switches
|
||
for (let e = -10; e <= 25; e++) {
|
||
for (const d of [1, 2, 5, 9, 1.5, 9.99]) yield d * 10 ** e;
|
||
}
|
||
// powers of 2 and their neighbors across the full range
|
||
for (let e = -1074; e <= 1023; e += 7) {
|
||
const x = 2 ** e;
|
||
yield* [x, -x, x * (1 + Number.EPSILON), x * (1 - Number.EPSILON / 2)];
|
||
}
|
||
// integers near digit-length transitions
|
||
for (let d = 1; d <= 21; d++) {
|
||
const x = 10 ** d;
|
||
yield* [x - 1, x, x + 1];
|
||
}
|
||
// Money-like values (the two-decimal fast path): m / 100 across every
|
||
// magnitude, around the 2^46 cutoff, their neighbors one ulp away, and
|
||
// spellings whose x * 100 is close to but not exactly integral.
|
||
yield* [0.01, 0.05, 0.1, 0.5, 0.99, 1.01, 1.1, 12.34, 99.99, 100.01, 1.005, 1.015, 0.285];
|
||
yield* [0.1 + 0.2, 0.7 + 0.1, 1.1 * 1.1, 4.35 * 100, 0.07 * 3, 19.99 * 3, 1 / 100, 3 / 100];
|
||
const cutoff = 2 ** 46;
|
||
for (const base of [cutoff, cutoff / 2, cutoff * 2, 2 ** 43, 2 ** 53 / 100]) {
|
||
for (let k = -3; k <= 3; k++) {
|
||
for (const c of [0.01, 0.1, 0.25, 0.5, 0.75, 0.99]) {
|
||
const x = Math.floor(base) + k + c;
|
||
yield* [x, -x];
|
||
}
|
||
}
|
||
}
|
||
const next = makeRng(0x6d6f6e6579n);
|
||
const int = (n) => Number(next() % BigInt(n));
|
||
for (let i = 0; i < 4000; i++) {
|
||
const digits = 1 + int(17);
|
||
const m = int(10 ** Math.min(digits, 15)) * 10 ** Math.max(0, digits - 15) + int(100);
|
||
const x = m / 100;
|
||
yield x;
|
||
if (i % 4 === 0) yield -x;
|
||
if (i % 8 === 0) {
|
||
// one ulp either side: no longer the double nearest to m / 100
|
||
f64[0] = x;
|
||
u64[0] += 1n;
|
||
yield f64[0];
|
||
f64[0] = x;
|
||
if (u64[0] > 0n) u64[0] -= 1n;
|
||
yield f64[0];
|
||
}
|
||
if (i % 16 === 0) yield m / 10;
|
||
if (i % 16 === 8) yield m / 1000;
|
||
}
|
||
}
|
||
|
||
// xorshift128 — deterministic so the committed file is reproducible
|
||
function makeRng(seed) {
|
||
let s0 = seed ^ 0x9e3779b97f4a7c15n,
|
||
s1 = 0x2545f4914f6cdd1dn;
|
||
return () => {
|
||
let x = s0;
|
||
const y = s1;
|
||
s0 = y;
|
||
x ^= (x << 23n) & 0xffffffffffffffffn;
|
||
s1 = x ^ y ^ (x >> 17n) ^ (y >> 26n);
|
||
return (s1 + y) & 0xffffffffffffffffn;
|
||
};
|
||
}
|
||
|
||
function* random(count, seed) {
|
||
const next = makeRng(seed);
|
||
let emitted = 0;
|
||
while (emitted < count) {
|
||
u64[0] = next();
|
||
yield f64[0];
|
||
emitted++;
|
||
}
|
||
}
|
||
|
||
// Money-like doubles for the two-decimal fast path: m / 100 (and m / 10,
|
||
// m / 1000) at every magnitude up to 1e17, products of such values, and
|
||
// neighbors one ulp away. Fuzz-only, so the committed file is unchanged.
|
||
function* moneyLike(count, seed) {
|
||
const next = makeRng(seed);
|
||
const int = (n) => Number(next() % BigInt(n));
|
||
for (let emitted = 0; emitted < count; emitted++) {
|
||
const m = int(10 ** (1 + int(15))) * 10 ** int(3) + int(100);
|
||
let x = m / [100, 100, 100, 10, 1000][int(5)];
|
||
const shape = int(8);
|
||
if (shape === 0) x *= int(50);
|
||
else if (shape === 1) x = (x * (1000 - int(300))) / 1000;
|
||
else if (shape === 2) {
|
||
f64[0] = x;
|
||
u64[0] += int(2) ? 1n : -1n;
|
||
x = f64[0];
|
||
}
|
||
yield int(4) === 0 ? -x : x;
|
||
}
|
||
}
|
||
|
||
const mode = argv[2] ?? "cases";
|
||
let chunk = "";
|
||
function emit(x) {
|
||
chunk += line(x);
|
||
if (chunk.length > 1 << 16) {
|
||
stdout.write(chunk);
|
||
chunk = "";
|
||
}
|
||
}
|
||
|
||
if (mode === "cases") {
|
||
for (const x of curated()) emit(x);
|
||
for (const x of random(50_000, 0x7357cafen)) emit(x);
|
||
} else if (mode === "fuzz") {
|
||
const count = Number(argv[3] ?? 1_000_000);
|
||
const seed = BigInt(Date.now());
|
||
for (const x of random(count >> 1, seed)) emit(x);
|
||
for (const x of moneyLike(count - (count >> 1), seed + 1n)) emit(x);
|
||
} else {
|
||
throw new Error(`unknown mode: ${mode}`);
|
||
}
|
||
stdout.write(chunk);
|