From 2f5f3aae905f24a2c4972c6b5c09500b56af97f0 Mon Sep 17 00:00:00 2001 From: alexxxnikolskiy Date: Tue, 28 Jul 2026 19:19:33 +0300 Subject: [PATCH] =?UTF-8?q?perf(canonical-json):=20=D0=B1=D1=8B=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=8B=D0=B9=20=D0=BF=D1=83=D1=82=D1=8C=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D1=87=D0=B8=D1=81=D0=B5=D0=BB,=20=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=20=D1=82=D0=BE=D1=87=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D1=81=D1=82=D0=B0=D0=B2=D0=B8=D0=BC=D1=8B=D1=85=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=88=D0=BA=D0=B0=D0=BB=D0=B5=20(E1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `decimal.js` строит значение из `String(n)` — из кратчайшего представления, которое round-trip'ится обратно в то же число. Значит, когда это представление и так короче восьми знаков после запятой и записано без экспоненты, весь круг «строка → Decimal → toDecimalPlaces → toFixed → строка» возвращает РОВНО исходную строку: округлять нечего, переводить в фиксированную нотацию нечего. Такие числа — большинство артефакта: индексы баров, метки времени, размеры, цены с биржевым тиком. Условие намеренно консервативно: есть `e` — медленный путь (1e-7 обязано стать 0.0000001); дробная часть длиннее шкалы — медленный путь (есть что округлять по HALF_EVEN). `-0` отдельным случаем не нужен: `String(-0)` в JS и так `"0"`. Значений не двигает, и это проверено, а не заявлено: тест держит рядом дословную копию прежней реализации как эталон и сверяет обе ветки на кромках (границы шкалы, экспоненты с обеих сторон, MAX_SAFE_INTEGER, MIN_VALUE, EPSILON) и на 20 000 случайных величин двенадцати порядков. Гейты: pnpm typecheck → 0; pnpm test → 116 passed / 0 failed; gate:determinism и gate:tapes чистые. Парный замер сериализатора на 40 000 строк артефакта: 4463 → 1868 мс (×2.39). --- src/determinism/canonical-json.ts | 26 +++++++++- test/canonical-json-fast-path.test.ts | 69 +++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 test/canonical-json-fast-path.test.ts diff --git a/src/determinism/canonical-json.ts b/src/determinism/canonical-json.ts index 2416a3b..5091257 100644 --- a/src/determinism/canonical-json.ts +++ b/src/determinism/canonical-json.ts @@ -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 diff --git a/test/canonical-json-fast-path.test.ts b/test/canonical-json-fast-path.test.ts new file mode 100644 index 0000000..230e7bf --- /dev/null +++ b/test/canonical-json-fast-path.test.ts @@ -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/); + }); +});