Add IEEE-754 Binary64 conversion library

This commit is contained in:
atsiv sivat 2026-01-31 15:53:51 +00:00
parent d8c9f22d97
commit 9706fbb10b

353
src/core/lib/IEEEBinary.mjs Normal file
View file

@ -0,0 +1,353 @@
/**
* IEEEBinary functions.
*
* Convert between:
* - exact decimal strings IEEE 754 double-precision (binary64) bits
* using BigInt rational arithmetic and correct Round-to-Nearest-Even (RNE).
*
* @author atsiv1 [atsiv1@proton.me]
* @copyright Crown Copyright 2016
* @license Apache-2.0
*/
import Utils from "../Utils.mjs";
// IEEE 754 double-precision constants
const EXP_BITS = 11n;
const MANT_BITS = 52n;
const PRECISION = MANT_BITS + 1n;
const BIAS = (1n << (EXP_BITS - 1n)) - 1n;
const MAX_EXP = (1n << EXP_BITS) - 1n;
const EXP_BITS_NUM = Number(EXP_BITS);
const MANT_BITS_NUM = Number(MANT_BITS);
/**
* Compute 10^exp as BigInt without using ** on BigInt, to avoid
* environments that try to coerce BigInt to Number.
*
* @param {bigint} exp Non-negative BigInt exponent.
* @returns {bigint}
*/
function pow10BigInt(exp) {
if (exp < 0n) {
throw new RangeError("pow10BigInt expects non-negative exponent");
}
let e = exp;
let result = 1n;
let base = 10n;
// fast exponentiation by squaring
while (e > 0n) {
if ((e & 1n) === 1n) {
result *= base;
}
base *= base;
e >>= 1n;
}
return result;
}
/**
* Converts an exact rational number (num / den) into its decimal string representation.
*
* The result is returned as a string. If the decimal is repeating, the repeating
* part will be enclosed in parentheses (e.g., "0.(3)").
*
* @param {bigint} num - The numerator of the fraction.
* @param {bigint} den - The denominator of the fraction.
* @returns {string} The decimal representation of the fraction, including repeating decimals in parentheses.
*
* @example
* // returns "0.5"
* fractionToDecimal(1n, 2n);
*
* // returns "0.(3)"
* fractionToDecimal(1n, 3n);
*
* // returns "1.25"
* fractionToDecimal(5n, 4n);
*
* @note For many binary64 values, the repeating cycle can be long, which is
* mathematically correct but may produce large strings.
*/
function fractionToDecimal(num, den) {
const intPart = num / den;
let rem = num % den;
if (rem === 0n) {
return intPart.toString();
}
const seen = new Map();
const digits = [];
let pos = 0;
while (rem !== 0n) {
if (seen.has(rem)) {
const p = seen.get(rem);
digits.splice(p, 0, "(");
digits.push(")");
break;
}
seen.set(rem, pos++);
rem *= 10n;
digits.push((rem / den).toString());
rem %= den;
}
return intPart.toString() + "." + digits.join("");
}
/**
* Converts a 64-bit IEEE-754 binary64 representation into its exact
* decimal string.
*
* Note: While binary64 can only represent a subset of all real numbers,
* every representable value has a finite decimal expansion. This function
* uses BigInt arithmetic to retrieve the exact value without the
* precision loss typically encountered in standard floating-point arithmetic.
*
* @param {string} binary64String
* @returns {string}
*
* @example
* // returns "10"
* FromIEEE754Float64("0 10000000010 0100000000000000000000000000000000000000000000000000");
*
* // returns "7.29999999999999982236431605997495353221893310546875"
* FromIEEE754Float64("0 10000000001 1101001100110011001100110011001100110011001100110011);
*/
export function FromIEEE754Float64(binary64String) {
const bin = binary64String.trim().replace(/\s+/g, "");
if (bin.length !== 64) {
throw new Error("Input must be 64 bits.");
}
const signBit = bin[0];
const expBits = bin.slice(1, 12);
const mantBits = bin.slice(12);
const sign = signBit === "1" ? "-" : "";
const exp = parseInt(expBits, 2);
const mant = BigInt("0b" + mantBits);
// Special values
if (exp === Number(MAX_EXP)) {
if (mant === 0n) {
return sign + "Infinity";
}
return "NaN";
}
let e;
let m;
if (exp === 0) {
// Subnormal: exponent is 1 - BIAS, no implicit leading 1
e = 1n - BIAS - MANT_BITS;
m = mant;
} else {
// Normal: exponent is exp - BIAS, with implicit leading 1
e = BigInt(exp) - BIAS - MANT_BITS;
m = mant | (1n << MANT_BITS);
}
// If exponent is non-negative, result is an integer
if (e >= 0n) {
return sign + (m << e).toString();
}
// Otherwise, it's a fraction m / 2^(-e)
const den = 1n << (-e);
return sign + fractionToDecimal(m, den);
}
/**
*
* Converts a decimal number into its IEEE-754 double-precision
* (binary64) bit representation.
*
* @param {string|number|BigInt} input
* @returns {string}
*
* @example
* // returns "0 10000000000 0000000000000000000000000000000000000000000000000000"
* ToIEEE754Float64("2");
*
* // returns "0 10000000011 0111010011001100110011001100110011001100110011001101"
* ToIEEE754Float64("23.300000000000000710542735760100185871124267578125");
*/
export function ToIEEE754Float64(input) {
input = String(input).trim();
const expOnes = "1".repeat(EXP_BITS_NUM);
const mantZeros = "0".repeat(MANT_BITS_NUM);
// Special values: NaN / ±Infinity
if (/^(NaN)$/i.test(input)) {
return `0 ${expOnes} 1${"0".repeat(MANT_BITS_NUM - 1)}`;
}
if (/^[+-]?inf(inity)?$/i.test(input)) {
const s = input.startsWith("-") ? "1" : "0";
return `${s} ${expOnes} ${mantZeros}`;
}
let sign = 0n;
if (input.startsWith("-")) {
sign = 1n;
input = input.slice(1);
} else if (input.startsWith("+")) {
input = input.slice(1);
}
let sci = 0n;
const sciMatch = input.match(/^(.*)e([+-]?\d+)$/i);
if (sciMatch) {
input = sciMatch[1];
sci = BigInt(sciMatch[2]);
}
let [intStr, fracStr] = input.split(".");
intStr = (intStr || "0").replace(/_/g, "");
fracStr = (fracStr || "").replace(/_/g, "");
let N = BigInt(intStr);
let D = 1n;
if (fracStr.length > 0) {
const pow10 = pow10BigInt(BigInt(fracStr.length));
N = N * pow10 + BigInt(fracStr);
D = pow10;
}
// Apply scientific exponent
if (sci > 0n) {
N *= pow10BigInt(sci);
} else if (sci < 0n) {
D *= pow10BigInt(-sci);
}
// Zero (preserve sign, including -0)
if (N === 0n) {
const expZero = "0".repeat(EXP_BITS_NUM);
return `${sign} ${expZero} ${mantZeros}`;
}
// Reduce the fraction
const g = Utils.gcdBigInt(N, D);
N /= g;
D /= g;
// Compute binary exponent approximation (with fix)
const eN = BigInt(N.toString(2).length - 1);
const eD = BigInt(D.toString(2).length - 1);
let e2 = eN - eD;
const GRS = 3n; // Guard, Round, Sticky bits
const totalBits = PRECISION + GRS;
// Approximate bit-length of normalized N/D: e2 + 1
const currentBitLength = eN - eD + 1n;
// Shift so we get 'totalBits' bits of precision in the quotient
let shift = totalBits - currentBitLength;
let num, den;
if (shift >= 0n) {
num = N << shift;
den = D;
} else {
num = N;
den = D << (-shift);
}
let full = num / den;
let rem = num % den;
// Normalisation Fix
if (full < (1n << (totalBits - 1n))) {
e2 -= 1n;
shift += 1n;
if (shift >= 0n) {
num <<= 1n;
} else {
den >>= 1n;
}
full = num / den;
rem = num % den;
}
const roundMask = (1n << GRS) - 1n;
const roundBits = full & roundMask;
let mant = full >> GRS;
// Extract G, R, S bits
const G = (roundBits >> 2n) & 1n;
const R = (roundBits >> 1n) & 1n;
const S = ((roundBits & 1n) === 1n || rem !== 0n) ? 1n : 0n;
let roundUp = false;
if (G === 1n) {
if (R === 1n || S === 1n) {
// strictly > 0.5
roundUp = true;
} else if ((mant & 1n) === 1n) {
// exactly 0.5 → round to even
roundUp = true;
}
}
if (roundUp) mant++;
// Renormalize if mantissa overflowed
if (mant >= (1n << PRECISION)) {
mant >>= 1n;
e2 += 1n;
}
// Overflow → Infinity
if (e2 + BIAS >= MAX_EXP) {
return `${sign} ${expOnes} ${mantZeros}`;
}
// Normal number (exponent >= 1)
if (e2 + BIAS >= 1n) {
const expValue = e2 + BIAS;
const expBits = expValue.toString(2).padStart(EXP_BITS_NUM, "0");
// Remove the implicit leading 1: mantissa stores only the 52 explicit bits
const mantBits = (mant & ((1n << MANT_BITS) - 1n))
.toString(2)
.padStart(MANT_BITS_NUM, "0");
return `${sign} ${expBits} ${mantBits}`;
}
// Subnormal numbers (exponent field = 0)
const minNormalExponentValue = 1n - BIAS;
const subShift = minNormalExponentValue - e2;
let subMant = mant >> subShift;
// Subnormal mantissa uses all 52 explicit bits
subMant &= (1n << MANT_BITS) - 1n;
const expZero = "0".repeat(EXP_BITS_NUM);
if (subMant === 0n) {
return `${sign} ${expZero} ${mantZeros}`;
}
const subMantBits = subMant.toString(2).padStart(MANT_BITS_NUM, "0");
return `${sign} ${expZero} ${subMantBits}`;
}