kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
285 lines (260 loc) • 13.6 kB
text/typescript
/**
* # fair/detmath — vendored, bit-deterministic `exp` / `log` for the certified-grade path
*
* ## Why this file exists
* A certified Grade must be **byte-identical across every runtime we grade on** — V8 on Node,
* V8 on Cloudflare Workers, and JSC on Bun. JavaScript's `+ − × ÷` and `Math.sqrt` are pinned
* by IEEE-754 (correctly-rounded binary64) and reproduce bit-for-bit everywhere. The *library*
* transcendentals `Math.exp` and `Math.log` are **not** pinned — the ECMAScript spec explicitly
* allows implementation-defined results, so V8 and JSC can (and do) return answers that differ
* in the last ulp. On the certified-grade path that is a cross-runtime float divergence: two
* engines could stamp two different grades on the same tape.
*
* This module removes that risk by **vendoring a fixed, portable double-precision `exp`/`log`**
* (the SunPro / fdlibm `__ieee754_exp` and `__ieee754_log`, the same code musl and the BSDs
* ship) reimplemented in terms of *only* the pinned IEEE-754 primitives. Because every operation
* inside is a correctly-rounded binary64 op, the result is the same bits on every conforming
* engine — which the checked-in cross-engine golden bit-vectors (`tests/detmath.golden.test.ts`)
* assert.
*
* These are drop-in replacements for `Math.exp` / `Math.log` on the pricing + fill-hazard path
* ({@link ../fair/black76.ts}, {@link ../fill/model.ts}). They are pure (no I/O, no clock, no
* RNG — RUNTIME §0); the shared scratch buffer is single-threaded mutable state used only to read
* a double's raw words, never observable output.
*
* ## Provenance
* Ported from FreeBSD/SunPro `msun` `e_exp.c` and `e_log.c` (fdlibm). Original notice:
* ```
* Copyright (C) 1993 by Sun Microsystems, Inc. All rights reserved.
* Developed at SunPro, a Sun Microsystems, Inc. business. Permission to use, copy, modify, and
* distribute this software is freely granted, provided that this notice is preserved.
* ```
* The algorithm, constants and comments are Sun's; only the surface syntax is TypeScript.
*/
// ─────────────────────────────────────────────────────────────────────────────
// Raw double ⇄ 32-bit word access (the fdlibm GET/SET_HIGH_WORD primitives)
//
// A single 8-byte scratch buffer aliased as a float64 and two uint32s lets us read/write the
// high and low 32-bit halves of a double. `HI`/`LO` are resolved once from the platform's
// endianness so the port is correct on both LE and (hypothetical) BE hosts — the words are what
// matters, never their byte order.
// ─────────────────────────────────────────────────────────────────────────────
const _buf = new ArrayBuffer(8);
const _f64 = new Float64Array(_buf);
const _u32 = new Uint32Array(_buf);
// 1.0 === 0x3FF0000000000000: the word holding 0x3ff00000 is the HIGH word.
_f64[0] = 1.0;
const HI = _u32[1] === 0x3ff00000 ? 1 : 0;
const LO = 1 - HI;
/** High 32 bits of `x` as an unsigned word (fdlibm `GET_HIGH_WORD`). */
function getHighWord(x: number): number {
_f64[0] = x;
return _u32[HI]! >>> 0;
}
/** Low 32 bits of `x` as an unsigned word (fdlibm `GET_LOW_WORD`). */
function getLowWord(x: number): number {
_f64[0] = x;
return _u32[LO]! >>> 0;
}
/** Return `x` with its high 32 bits replaced by `hw` (fdlibm `SET_HIGH_WORD`). */
function withHighWord(x: number, hw: number): number {
_f64[0] = x;
_u32[HI] = hw >>> 0;
return _f64[0]!;
}
/** Compose a double from a high and low word (fdlibm `INSERT_WORDS`). */
function fromWords(hw: number, lw: number): number {
_u32[HI] = hw >>> 0;
_u32[LO] = lw >>> 0;
return _f64[0]!;
}
/**
* The one NaN this module is allowed to *return*: quiet NaN with a clear sign bit, built from its
* bits rather than obtained from the hardware.
*
* fdlibm mints NaN by evaluating an invalid operation — `(x-x)/0`, `0/0`, `Inf-Inf` — and lets the
* FPU hand back its default NaN. That is fine in C, where only NaN-*ness* is promised, and it is
* **wrong here**: IEEE-754 does not specify the default NaN's sign bit, and the architectures we
* grade on disagree. arm64 delivers `7ff8000000000000`; x86_64 delivers `fff8000000000000`. Both
* are quiet NaNs, both satisfy `Number.isNaN`, and both are invisible to `===` — but this module's
* whole contract is that its output is the *same bits* everywhere (the golden compares hex, not
* values), so a sign bit that tracks the CPU is a cross-engine divergence on the certified-grade
* path. It is the exact defect the vendoring exists to prevent, one level below the ulps.
*
* The JS `NaN` literal is no fix: its bit pattern is implementation-defined too (ECMAScript lets
* an engine pick any NaN, observable precisely through the typed-array reads above), so `return NaN`
* would trade one unpinned pattern for another. Only writing the words settles it.
*
* `7ff8000000000000` — the sign bit clear — is chosen because it is the pattern already recorded in
* `tests/golden/detmath.golden.json`, and it is the canonical qNaN in the IEEE-754 sense.
*
* Do not "simplify" this away. Know which half CI defends: an arithmetic mint IS caught — the golden
* goes red on x86_64. `return NaN` is NOT — every engine we currently test happens to materialize the
* literal as `7ff8000000000000`, so CI would stay green. That is luck, not a guarantee: the paragraph
* above is the reason, and it holds by spec reasoning and review, not by the golden. Writing the words
* is the only form that does not rest on an engine choice we cannot pin.
*/
const CANONICAL_NAN = fromWords(0x7ff80000, 0x00000000);
// ─────────────────────────────────────────────────────────────────────────────
// exp — SunPro/fdlibm `__ieee754_exp`
//
// exp(x) is computed by argument reduction x = k·ln2 + r (|r| <= 0.5·ln2), a degree-5 minimax
// remez polynomial for expm1 on r, then reassembly exp(x) = 2^k · exp(r). Accurate to < 1 ulp.
// ─────────────────────────────────────────────────────────────────────────────
const one = 1.0;
const halF = [0.5, -0.5];
const o_threshold = 7.09782712893383973096e2; // 0x40862E42 0xFEFA39EF — overflow above this
const u_threshold = -7.45133219101941108420e2; // 0xC0874910 0xD52D3051 — underflow below this
const ln2HI = [6.93147180369123816490e-1, -6.93147180369123816490e-1]; // 0x3FE62E42 0xFEE00000
const ln2LO = [1.90821492927058770002e-10, -1.90821492927058770002e-10]; // 0x3DEA39EF 0x35793C76
const invln2 = 1.44269504088896341351; // 0x3FF71547 0x652B82FE
const P1 = 1.66666666666666019037e-1; // 0x3FC55555 0x5555553E
const P2 = -2.77777777770155933842e-3; // 0xBF66C16C 0x16BEBD93
const P3 = 6.61375632143793436117e-5; // 0x3F11566A 0xAF25DE2C
const P4 = -1.65339022054652515390e-6; // 0xBEBBBD41 0xC5D26BF1
const P5 = 4.13813679705723846039e-8; // 0x3E663769 0x72BEA4D0
const huge = 1.0e300;
const twom1000 = 9.33263618503218878990e-302; // 2**-1000 = 0x01700000 0x00000000
/**
* Bit-deterministic `Math.exp`. Same value on every IEEE-754 engine (< 1 ulp of the true exp),
* used everywhere `Math.exp` sat on the certified-grade path. Edge cases match the library:
* `exp(NaN)=NaN`, `exp(+Inf)=+Inf`, `exp(-Inf)=0`, overflow → `+Inf`, underflow → `0`.
*/
export function dexp(x: number): number {
let hi = 0.0;
let lo = 0.0;
let k = 0;
let hx = getHighWord(x);
const xsb = (hx >>> 31) & 1; // sign bit of x
hx &= 0x7fffffff; // high word of |x|
// Filter out non-finite / overflow / underflow arguments.
if (hx >= 0x40862e42) {
// |x| >= 709.78...
if (hx >= 0x7ff00000) {
const lx = getLowWord(x);
if (((hx & 0xfffff) | lx) !== 0) return x + x; // NaN
return xsb === 0 ? x : 0.0; // exp(+-inf) = {inf, 0}
}
if (x > o_threshold) return huge * huge; // overflow → +Inf
if (x < u_threshold) return twom1000 * twom1000; // underflow → 0
}
// Argument reduction: x = k·ln2 + (hi - lo).
if (hx > 0x3fd62e42) {
// |x| > 0.5·ln2
if (hx < 0x3ff0a2b2) {
// |x| < 1.5·ln2
hi = x - ln2HI[xsb]!;
lo = ln2LO[xsb]!;
k = 1 - xsb - xsb;
} else {
k = (invln2 * x + halF[xsb]!) | 0; // truncate toward zero (C int cast)
const t = k;
hi = x - t * ln2HI[0]!; // t·ln2HI is exact here
lo = t * ln2LO[0]!;
}
x = hi - lo;
} else if (hx < 0x3e300000) {
// |x| < 2**-28: exp(x) ≈ 1 + x
if (huge + x > one) return one + x;
} else {
k = 0;
}
// x is now in the primary range [-0.5·ln2, 0.5·ln2].
const t = x * x;
const c = x - t * (P1 + t * (P2 + t * (P3 + t * (P4 + t * P5))));
let y: number;
if (k === 0) return one - ((x * c) / (c - 2.0) - x);
y = one - ((lo - (x * c) / (2.0 - c)) - hi);
// Reassemble exp(x) = 2^k · y by adding k to y's exponent word.
if (k >= -1021) {
const hy = getHighWord(y);
return withHighWord(y, (hy + (k << 20)) >>> 0);
}
const hy = getHighWord(y);
return withHighWord(y, (hy + ((k + 1000) << 20)) >>> 0) * twom1000;
}
// ─────────────────────────────────────────────────────────────────────────────
// log — SunPro/fdlibm `__ieee754_log`
//
// log(x): reduce x = 2^k · (1 + f) with sqrt(2)/2 < 1+f < sqrt(2), then log(1+f) via the odd
// series in s = f/(2+f). Accurate to < 1 ulp.
// ─────────────────────────────────────────────────────────────────────────────
const ln2_hi = 6.93147180369123816490e-1; // 0x3FE62E42 0xFEE00000
const ln2_lo = 1.90821492927058770002e-10; // 0x3DEA39EF 0x35793C76
const two54 = 1.80143985094819840000e16; // 0x43500000 0x00000000
const Lg1 = 6.666666666666735130e-1; // 0x3FE55555 0x55555593
const Lg2 = 3.999999999940941908e-1; // 0x3FD99999 0x9997FA04
const Lg3 = 2.857142874366239149e-1; // 0x3FD24924 0x94229359
const Lg4 = 2.222219843214978396e-1; // 0x3FCC71C5 0x1D8E78B0
const Lg5 = 1.818357216161805012e-1; // 0x3FC74664 0x96CB03DE
const Lg6 = 1.531383769920937332e-1; // 0x3FC39A09 0xD078C69F
const Lg7 = 1.479819860511658591e-1; // 0x3FC2F112 0xDF3E5244
const zero = 0.0;
/**
* Bit-deterministic `Math.log` (natural log). Same value on every IEEE-754 engine (< 1 ulp),
* used everywhere `Math.log` sat on the certified-grade path. Edge cases match the library:
* `log(+-0)=-Inf`, `log(x<0)=NaN`, `log(NaN)=NaN`, `log(+Inf)=+Inf`.
*/
export function dlog(x: number): number {
let hx = getHighWord(x) | 0; // signed high word
const lx = getLowWord(x);
let k = 0;
if (hx < 0x00100000) {
// x < 2**-1022 (subnormal, zero, or negative)
if (((hx & 0x7fffffff) | lx) === 0) return -two54 / zero; // log(+-0) = -Inf
// log(-#) = NaN. fdlibm writes `(x-x)/zero` here; we return the constructed qNaN instead, because
// the hardware's default NaN has an architecture-defined sign bit. See {@link CANONICAL_NAN}.
if (hx < 0) return CANONICAL_NAN;
k -= 54;
x *= two54; // scale subnormal up
hx = getHighWord(x) | 0;
}
if (hx >= 0x7ff00000) return x + x; // +Inf or NaN
k += (hx >> 20) - 1023;
hx &= 0x000fffff;
const i = (hx + 0x95f64) & 0x100000;
x = withHighWord(x, (hx | (i ^ 0x3ff00000)) >>> 0); // normalize x or x/2 into [sqrt(1/2), sqrt(2))
k += i >> 20;
const f = x - 1.0;
if ((0x000fffff & (2 + hx)) < 3) {
// -2**-20 <= f < 2**-20: f is tiny, use the short series.
if (f === zero) {
if (k === 0) return zero;
const dk0 = k;
return dk0 * ln2_hi + dk0 * ln2_lo;
}
const R0 = f * f * (0.5 - 0.33333333333333333 * f);
if (k === 0) return f - R0;
const dk1 = k;
return dk1 * ln2_hi - (R0 - dk1 * ln2_lo - f);
}
const s = f / (2.0 + f);
const dk = k;
const z = s * s;
let i2 = hx - 0x6147a;
const w = z * z;
const j = 0x6b851 - hx;
const t1 = w * (Lg2 + w * (Lg4 + w * Lg6));
const t2 = z * (Lg1 + w * (Lg3 + w * (Lg5 + w * Lg7)));
i2 |= j;
const R = t2 + t1;
if (i2 > 0) {
const hfsq = 0.5 * f * f;
if (k === 0) return f - (hfsq - s * (hfsq + R));
return dk * ln2_hi - (hfsq - (s * (hfsq + R) + dk * ln2_lo) - f);
}
if (k === 0) return f - s * (f - R);
return dk * ln2_hi - (s * (f - R) - dk * ln2_lo - f);
}
/** Raw IEEE-754 bits of a double as a stable 16-hex-digit string (big-endian, high word first).
* The canonical form the cross-engine golden bit-vectors are recorded and compared in — two
* engines agree iff these strings are equal. */
export function f64ToHex(x: number): string {
const hi = getHighWord(x).toString(16).padStart(8, "0");
const lo = getLowWord(x).toString(16).padStart(8, "0");
return hi + lo;
}
/** Inverse of {@link f64ToHex}: reconstruct the double a golden hex string encodes. */
export function hexToF64(hex: string): number {
return fromWords(parseInt(hex.slice(0, 8), 16), parseInt(hex.slice(8, 16), 16));
}