src/format/decimal.ts
v0.3.0 · 7.7 KB
// Private exact decimal arithmetic for the format module.
//
// Prices and sizes are never rounded with binary floats here: every input is
// converted to an exact decimal (`n / 10^s` with a BigInt `n`) and all
// rounding, multiplication and comparison happen on integers. This module is
// intentionally not exported from the package.
/** Error thrown by the format module for inputs that cannot be formatted. */
export class HlFormatError extends Error {
override readonly name = 'HlFormatError';
}
/** Exact decimal: value = n / 10^s, with s >= 0. */
export interface Dec {
readonly n: bigint;
readonly s: number;
}
const DEC_RE = /^([+-])?(?:(\d+)(?:\.(\d*))?|\.(\d+))(?:[eE]([+-]?\d+))?$/;
const MAX_EXPONENT = 400;
const POW10: bigint[] = [];
/** 10^k as a bigint (k >= 0), memoised. */
export function pow10(k: number): bigint {
if (!Number.isInteger(k) || k < 0) throw new RangeError(`pow10: bad exponent ${k}`);
let v = POW10[k];
if (v === undefined) {
v = 10n ** BigInt(k);
if (k < 1024) POW10[k] = v;
}
return v;
}
export const ZERO: Dec = { n: 0n, s: 0 };
/**
* Parses a number or decimal string into an exact decimal.
*
* Numbers go through `String(n)`, which yields the shortest decimal that
* round-trips to the same double (`0.1 + 0.2` -> `"0.30000000000000004"`,
* `1e-7` -> `"1e-7"`), so a literal like `0.29` is read as exactly `0.29`.
*/
export function parseDec(value: number | string, field = 'value'): Dec {
let text: string;
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new HlFormatError(`Invalid ${field}: ${String(value)} is not finite`);
text = String(value);
} else if (typeof value === 'string') {
text = value.trim();
} else {
throw new HlFormatError(`Invalid ${field}: expected number or string, got ${typeof value}`);
}
const m = DEC_RE.exec(text);
if (!m) throw new HlFormatError(`Invalid ${field}: ${JSON.stringify(value)}`);
const [, sign, intDigits, fracDigits, onlyFrac, expText] = m;
const intPart = intDigits ?? '0';
const fracPart = fracDigits ?? onlyFrac ?? '';
const exp = expText === undefined ? 0 : Number(expText);
if (!Number.isSafeInteger(exp) || Math.abs(exp) > MAX_EXPONENT) {
throw new HlFormatError(`Invalid ${field}: exponent out of range in ${JSON.stringify(value)}`);
}
let n = BigInt(intPart + fracPart);
let s = fracPart.length - exp;
if (s < 0) {
n *= pow10(-s);
s = 0;
}
if (sign === '-') n = -n;
return strip({ n, s });
}
/** Removes trailing zeros from the fraction (canonical scale). */
export function strip(d: Dec): Dec {
if (d.n === 0n) return ZERO;
let { n, s } = d;
while (s > 0 && n % 10n === 0n) {
n /= 10n;
s -= 1;
}
return { n, s };
}
/** Canonical plain string: no exponent, no trailing zeros, no leading zeros, no "-0". */
export function toPlain(d: Dec): string {
const { n, s } = strip(d);
if (n === 0n) return '0';
const neg = n < 0n;
const abs = neg ? -n : n;
let body: string;
if (s === 0) {
body = abs.toString();
} else {
const unit = pow10(s);
const frac = (abs % unit).toString().padStart(s, '0');
body = `${(abs / unit).toString()}.${frac}`;
}
return neg ? `-${body}` : body;
}
export function toNumber(d: Dec): number {
return Number(toPlain(d));
}
function align(a: Dec, b: Dec): [bigint, bigint, number] {
if (a.s === b.s) return [a.n, b.n, a.s];
if (a.s > b.s) return [a.n, b.n * pow10(a.s - b.s), a.s];
return [a.n * pow10(b.s - a.s), b.n, b.s];
}
export function cmp(a: Dec, b: Dec): -1 | 0 | 1 {
const [x, y] = align(a, b);
return x < y ? -1 : x > y ? 1 : 0;
}
export function add(a: Dec, b: Dec): Dec {
const [x, y, s] = align(a, b);
return strip({ n: x + y, s });
}
export function sub(a: Dec, b: Dec): Dec {
const [x, y, s] = align(a, b);
return strip({ n: x - y, s });
}
export function mul(a: Dec, b: Dec): Dec {
return strip({ n: a.n * b.n, s: a.s + b.s });
}
export function abs(a: Dec): Dec {
return a.n < 0n ? { n: -a.n, s: a.s } : a;
}
export function isZero(a: Dec): boolean {
return a.n === 0n;
}
export function isPositive(a: Dec): boolean {
return a.n > 0n;
}
export function isInteger(a: Dec): boolean {
return strip(a).s === 0;
}
/** 10^e as an exact decimal (e may be negative). */
export function powTen(e: number): Dec {
return e >= 0 ? { n: pow10(e), s: 0 } : { n: 1n, s: -e };
}
/** floor(log10(|d|)) for d != 0, computed exactly from the digits. */
export function magnitude(d: Dec): number {
if (d.n === 0n) throw new RangeError('magnitude of zero');
const digits = (d.n < 0n ? -d.n : d.n).toString().length;
return digits - 1 - d.s;
}
/** Number of digits after the decimal point in canonical form. */
export function decimalPlaces(d: Dec): number {
return strip(d).s;
}
/** Number of significant digits in canonical form (trailing integer zeros are not significant). */
export function significantDigits(d: Dec): number {
let n = strip(d).n;
if (n === 0n) return 0;
if (n < 0n) n = -n;
while (n % 10n === 0n) n /= 10n;
return n.toString().length;
}
export type Rounding = 'floor' | 'ceil' | 'round';
/** Extra fixed-point digits used to apply the rounding epsilon exactly. */
const EPS_SCALE = 15;
const EPS_UNIT = pow10(EPS_SCALE);
/** Absolute epsilon of 1e-9 step, expressed in 1e-15 step units. */
const EPS_ABS = pow10(EPS_SCALE - 9);
/**
* Upper bound of the widened epsilon: 1e-3 step. Without it the relative term
* reaches a whole step at 1e15 steps, so `floor` returned a value one lot ABOVE
* the input and `ceil` one lot BELOW it (an under-sized "full" close leaves dust).
*/
const EPS_CAP = pow10(EPS_SCALE - 3);
/**
* Rounds the non-negative ratio `num / den` (expressed in step units) to an
* integer step count.
*
* The epsilon is the one from the knowledge base (`Math.floor(sz * f + 1e-9)`):
* `0.29 * 100 = 28.999999999999996` must floor to 29, not 28. It is widened to
* a relative 1e-15 for very large counts, where a double's own spacing exceeds
* 1e-9 of a step and the absolute epsilon would silently vanish, and capped at
* 1e-3 step so rounding can never move a value by a whole step. Beyond ~1e12
* steps a JS number cannot carry sub-1e-3-step precision anyway: pass strings. Planner and
* executor must share this single function so they never disagree by a lot.
*/
export function roundRatio(num: bigint, den: bigint, mode: Rounding): bigint {
if (den <= 0n || num < 0n) throw new RangeError('roundRatio expects num >= 0 and den > 0');
const scaled = num * EPS_UNIT;
const lo = scaled / den;
const hi = lo * den === scaled ? lo : lo + 1n;
const rel = lo / EPS_UNIT;
const widened = rel > EPS_ABS ? rel : EPS_ABS;
const eps = widened < EPS_CAP ? widened : EPS_CAP;
switch (mode) {
case 'floor':
return (lo + eps) / EPS_UNIT;
case 'ceil': {
const x = hi - eps;
return x <= 0n ? 0n : (x + EPS_UNIT - 1n) / EPS_UNIT;
}
case 'round':
return (lo + EPS_UNIT / 2n + eps) / EPS_UNIT;
}
}
/** Rounds a non-negative decimal to a multiple of 10^e. */
export function roundToExp(d: Dec, e: number, mode: Rounding): Dec {
if (d.n < 0n) throw new RangeError('roundToExp expects a non-negative value');
// d / 10^e = n / 10^(s + e)
const p = d.s + e;
const [num, den] = p <= 0 ? [d.n * pow10(-p), 1n] : [d.n, pow10(p)];
const count = roundRatio(num, den, mode);
return strip(e >= 0 ? { n: count * pow10(e), s: 0 } : { n: count, s: -e });
}
/** ceil(a / b) for a >= 0, b > 0, exactly (no epsilon). */
export function ceilDivDec(a: Dec, b: Dec): bigint {
// a / b = (a.n / 10^a.s) / (b.n / 10^b.s) = a.n * 10^b.s / (b.n * 10^a.s)
const num = a.n * pow10(b.s);
const den = b.n * pow10(a.s);
if (den <= 0n || num < 0n) throw new RangeError('ceilDivDec expects a >= 0 and b > 0');
return (num + den - 1n) / den;
}