Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/determinism/canonical-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,35 @@ Decimal.set({ rounding: Decimal.ROUND_HALF_EVEN });
/** Fixed quantization scale for numeric fields (decimal places). */
const SCALE = 8;

/** Quantize a number to its canonical string: 8 places, `-0 → 0`, fixed (non-exponential). */
/**
* Quantize a number to its canonical string: 8 places, `-0 → 0`, fixed (non-exponential).
*
* E1 — БЫСТРЫЙ ПУТЬ ДЛЯ ЧИСЕЛ, УЖЕ ТОЧНО ПРЕДСТАВИМЫХ НА ШКАЛЕ.
*
* `decimal.js` строит значение из `String(n)` — из кратчайшего представления, которое
* round-trip'ится обратно в то же самое число. Значит, когда это представление и так короче
* восьми знаков после запятой и записано без экспоненты, весь круг «строка → Decimal →
* toDecimalPlaces → toFixed → строка» возвращает РОВНО исходную строку: округлять нечего,
* переводить в фиксированную нотацию нечего. Такие числа — большинство артефакта: индексы баров,
* метки времени, размеры, цены с биржевым тиком.
*
* Условие быстрого пути ровно это и проверяет, и оно намеренно консервативно:
* - есть `e` ⇒ медленный путь (`1e-7` обязано стать `0.0000001`, `1e21` — развернуться);
* - дробная часть длиннее `SCALE` ⇒ медленный путь (есть что округлять по HALF_EVEN).
* `-0` отдельным случаем не нужен: `String(-0)` в JS и так `"0"`.
*
* Значений это не двигает — обе ветки обязаны давать одну строку, и это проверяется тестом
* (`canonical-json-fast-path.test.ts`) на кромках и на выборке случайных величин, а не рассуждением.
*/
function quantizeToString(n: number): string {
if (!Number.isFinite(n)) {
throw new Error(`canonical-json: non-finite number not allowed (got ${n})`);
}
const s = String(n);
if (!s.includes('e')) {
const dot = s.indexOf('.');
if (dot < 0 || s.length - dot - 1 <= SCALE) return s;
}
let d = new Decimal(n).toDecimalPlaces(SCALE, Decimal.ROUND_HALF_EVEN);
if (d.isZero()) d = new Decimal(0); // normalize `-0 → 0`
return d.toFixed(); // fixed notation, no trailing zeros, no exponent
Expand Down
69 changes: 69 additions & 0 deletions test/canonical-json-fast-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { Decimal } from 'decimal.js';
import { canonicalJson, quantize } from '../src/determinism/canonical-json.js';

// E1. Быстрый путь в `quantizeToString` обязан давать ТУ ЖЕ строку, что и путь через decimal.js —
// иначе это не оптимизация, а тихий сдвиг значений. Медленный путь воспроизведён здесь дословно
// (эталон), и обе ветки сравниваются на кромках и на выборке.
const SCALE = 8;

Decimal.set({ rounding: Decimal.ROUND_HALF_EVEN });

/** Эталон — прежняя реализация, слово в слово. */
function reference(n: number): string {
let d = new Decimal(n).toDecimalPlaces(SCALE, Decimal.ROUND_HALF_EVEN);
if (d.isZero()) d = new Decimal(0);
return d.toFixed();
}

/** Канонизация одного числа через публичный вход (сериализатор квантует каждое число). */
function actual(n: number): string {
return canonicalJson(n).slice(0, -1);
}

describe('canonical-json — быстрый путь тождествен медленному', () => {
const edges = [
0, -0, 1, -1, 0.5, -0.5,
0.1, 0.2, 0.3, 1 / 3, 2 / 3,
// ровно на границе шкалы и на знак за ней
0.12345678, -0.12345678, 0.123456785, 0.123456784999, 1.000000005, 2.000000015,
// экспоненциальные представления с обеих сторон
1e-7, 1e-8, 1e-9, 1e-21, 1e20, 1e21, 1.5e22, -1e-9, -1e21,
// крупные целые и «биржевые» величины
27000.5, 27000.123456, 1_700_000_000_000, 9_007_199_254_740_991, -9_007_199_254_740_991,
Number.MIN_VALUE, Number.EPSILON, Number.MAX_SAFE_INTEGER + 2,
];

it('совпадает на кромках', () => {
for (const n of edges) {
expect(actual(n), `n=${n}`).toBe(reference(n));
}
});

it('совпадает на выборке случайных величин разного порядка', () => {
// Детерминированный генератор — выборка воспроизводима при падении.
let seed = 0x9e3779b9;
const rnd = () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
for (let i = 0; i < 20_000; i += 1) {
const mag = 10 ** (Math.floor(rnd() * 24) - 12);
const n = (rnd() - 0.5) * mag;
expect(actual(n), `n=${n}`).toBe(reference(n));
}
});

it('`quantize` остаётся тем же числом', () => {
for (const n of edges) {
expect(quantize(n), `n=${n}`).toBe(Number(reference(n)));
}
});

it('non-finite по-прежнему запрещены', () => {
expect(() => canonicalJson(Number.NaN)).toThrow(/non-finite/);
expect(() => canonicalJson(Number.POSITIVE_INFINITY)).toThrow(/non-finite/);
});
});
Loading