Skip to content
markpaper

src/format/decimal.ts

v0.3.0 · 7.7 KB

Download file
// 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;
}
All files