Рефлекция после 6 лет с момента решения интересной задачи
Потеря миллионов рублей на округлениях. Зачем я создал свой язык формул

Введение
Больше пяти лет назад на одном проекте я разбирался почему фин. приложение неправильно считает деньги. В результате исследования обнаружилось, что используется три-четыре математические библиотеки. Иногда несколько из них участвовали в одном расчёте
Каждая библиотека приносила собственный API и числовой тип. Чтобы передать результат дальше, значения преобразовывали из одного формата в другой. Преобразования между типами и промежуточные вычисления в Number давали погрешность. Округление превращало её в ошибку. При большом количестве транзакций вывод новых фичей оборачивался подтверждёнными убытками на миллионы рублей за одну ночь
В разработке продукта участвовало больше десяти команд, у каждой - десятки сервисов. Серверная часть расчётов ещё не была готова, а клиентская уже работала. При неполной аналитике и сжатых сроках требовалось решение, которое можно внедрять постепенно и при этом снижать bugrate по дефектам в этой части
Из этой задачи вырос Nemathode - небольшой язык математических выражений и вычислитель, за которым можно было спрятать разные библиотеки. То есть пишешь всегда в одном стиле, а под капотом можно подключать N библиотек. Примерно за пару недель было создано решение и за квартал проведена полная миграция всех вычислений на него. Позже я попытался развить его как отдельный продукт, а в сентябре 2026 года решил остановиться
Начало. Арифметика с пересадками
Точные фрагменты тех багов я уже не помню. Примеры ниже - реконструкция: числа и связки библиотек подобраны для объяснения. Это не код из того приложения и не расчёт размера его убытков
Песочница с живым кодом лежит здесь
Округляем вверх. Плюс копейка
Допустим, две составляющие комиссии равны 1,10 и 2,20 рубля, а итог по правилам этой операции округляется вверх до копейки. Разработчик складывает обычные JavaScript-числа и передаёт результат в Decimal.js, чтобы дальше считать точно
import Decimal from 'decimal.js';
const sum = 1.1 + 2.2;
console.log('Дробный хвост. Результат суммирования в Number', sum); // 3.3000000000000003
console.log(
'Плюс копейка. Сумма Number и последующие операции в Decimal',
new Decimal(sum)
.times(100)
.ceil()
.div(100)
.toFixed(2)
); // "3.31"
В десятичной арифметике сумма равна 3,30. Но сложение уже произошло в JavaScript Number, и в Decimal попало значение чуть больше 3,30. После умножения на сто оно оказалось чуть больше 330. Округление вверх честно превратило его в 331 копейку
Decimal.js не может восстановить исходное намерение по переданному результату. Для него это число, которое нужно обработать по заданным правилам. Метода guessWhatIMeant() в API, к сожалению, нет
Если сохранить исходные десятичные значения и выполнить сложение внутри Decimal, получится другой результат
console.log(
'Корректные вычисления на Decimal',
new Decimal('1.10')
.plus('2.20')
.times(100)
.ceil()
.div(100)
.toFixed(2)
); // "3.30"
Округляем вниз. Минус копейка
Обратный эффект легко получить при округлении вниз. Пусть значения находятся в BigNumber.js, но для вычитания их временно превращают в обычные числа, а округление поручают Decimal.js
import BigNumber from 'bignumber.js';
const paid = new BigNumber('19.90');
const charged = new BigNumber('19.80');
const difference = paid.toNumber() - charged.toNumber();
console.log('Дробный хвост', difference); // 0.09999999999999787
console.log(
'Минус копейка. Разность Number и последующие операции в Decimal',
new Decimal(difference)
.times(100)
.floor()
.div(100)
.toFixed(2)
); // "0.09"
Математически разница равна десяти копейкам. В промежуточном Number-вычислении она стала немного меньше. Округление вниз оставило девять
В этом примере вычитание можно завершить в BigNumber, а результат передать другой библиотеке десятичной строкой
console.log(
'Корректные вычисления на Decimal с BigNumber',
new Decimal(paid.minus(charged).toString())
.times(100)
.floor()
.div(100)
.toFixed(2)
); // "0.10"
Здесь важно место возникновения ошибки. Сам по себе вызов new Decimal(0.1) не обязан создавать длинный десятичный хвост: он даёт 0.1. В наших примерах проблема возникла при промежуточном сложении или вычитании в Number. Последующее преобразование в точный тип уже не могло отменить сделанное. В документации Decimal.js тоже показан этот эффект и рекомендована передача строк, когда есть риск потери точности
If using values with more than a few digits, it is recommended to pass strings rather than numbers to avoid a potential loss of precision
Поэтому наличие точной библиотеки где-нибудь в цепочке мало что говорит о точности всей цепочки. Нужно понимать, в каком типе выполняется каждая операция и где происходит округление. Маленькое отклонение особенно заметно рядом с границей ceil или floor: там оно превращается в целую копейку
Решение. N правильных обёрток
На первый взгляд задача сводилась к выбору одной библиотеки или общему интерфейсу поверх неё. Мы провели опрос, и он обнаружил ещё одну часть проблемы
Сторонники разных библиотек по-разному представляли даже обёртку. Одним нужны были цепочки вызовов. Другим - отдельные функции (математический lodash). Кто-то хотел передавать внутренние объекты чисел, кто-то предпочитал обычный Number. В предложениях снова проступали привычные API
Мы спрашивали об общем будущем, а получали довольно точные описания привычного настоящего
Это не делало общую обёртку технически невозможной. Но показывало, что её форма сама стала предметом спора. Можно было выбрать один подход и убеждать остальных. Я захотел пойти другим путём - предложить интерфейс, который не выглядел бы победой одной библиотеки над другими
Я стал искать запись, максимально близкую к математическому выражению
Решение. Путь к собственному DSL
К тому времени я уже изучал, как устроены языки программирования и в какой-то из вечеров я наткнулся на этот пост на StackOverflow. И в этой задаче мне пригодилась возможность отделить запись выражения от действий, которые нужно выполнить. Так появился маленький язык - DSL
Словосочетание "собственный язык программирования" приятно действует на разработчика. К счастью для проекта, мои амбиции уместились в обычный массив
Так вот, в Nemathode выражение выглядело так
nemathode.evaluate([2, '+', 3, '*', 4]); // 14
nemathode.evaluate([[2, '+', 3], '*', 4]); // 20
Числа остаются числами. Операторы записываются строками. Вложенный массив играет роль скобок
В первом примере умножение выполняется раньше сложения. Во втором сначала считается вложенная сумма. Наконец, программа вызывает реализацию каждой операции и получает результат
Как работает Nemathode
Принцип работы такого интерпретатора можно объяснить на двух строчках:
- сначала программа распознаёт элементы: число, оператор, вложенное выражение
- затем определяет порядок вычислений
То есть на вход поступает нечто такое
[1, '-', 1]
Затем программа разбирает выражение на токены и делает из него сл список. Внутри проекта эта промежуточная структура называется AST, хотя точнее описывать её как список типизированных токенов с вложенными списками
[
{ type: OperandType.Number, value: 1 },
{ type: OperandType.BinaryOperator, value: '-' },
{ type: OperandType.Number, value: 1 },
]
И затем происодят вычисления. Приоритеты операций учитывает вычислитель. Это видно в исходном коде Nemathode
Сам знак + при этом не был навсегда привязан к сложению JavaScript-чисел. Конфигурация задавала приоритет и реализацию оператора, функции, константы и преобразования типов. Выражение сохранялось, а реализацию можно было заменить. То есть любому оператору можно было задать любое нужное поведение
Схема показывает основные роли. Вложенные выражения вычисляются рекурсивно; вызовы операций и преобразования могут повторяться по ходу расчёта. Конфигурация позволяет менять реализации за общей записью (синтаксисом), а точность зависит от того, как эти реализации и преобразования устроены
Какую работу за меня сделал JavaScript
Запись массивом позволила сократить работу: мне не понадобился отдельный лексер, который разбирал бы текст вроде "2 + 3 * 4" на элементы. JavaScript уже создавал массив с числами и строками, а мой код разбирал его содержимое. JavaScript умел читать массивы задолго до моего проекта
Разницу удобно показать рядом с привычной схемой интерпретируемого текстового языка
Это один из распространённых путей исполнения: через дерево синтаксиса и интерпретатор. В компилируемом языке после разбора может работать компилятор; здесь детали этого пути опущены
А тут (в Nemathode) массив ещё не является готовым AST: Nemathode всё равно должен распознать операторы, функции и вложенные выражения. Сэкономить удалось на отдельном разборе текста, а не на необходимости понимать выражение
TypeScript должен был помогать проверять допустимую форму записи ещё во время разработки. Конечно, типизация выражения и точность его результата - отдельные вопросы: правильная последовательность элементов не исправит потерю точности внутри операции
Один синтаксис, всё меньше библиотек
Для миграции это давало выгодную последовательность действий
- сначала мы переносили участки на общий синтаксис, оставляя возможность использовать существующие библиотеки за ним. Разработчику не требовалось одновременно переписать формулу и немедленно отказаться от привычной реализации всех операций. Команде понравились и новый синтаксис, и идея такой инкапсуляции. Появился общий способ записывать расчёты, который не копировал API чьей-то любимой библиотеки
- дальше математические реализации можно было унифицировать за этим интерфейсом. Постепенно библиотеки свели к одной. После перехода на общий формат замена внутренней реализации уже не требовала снова переписывать сами выражения
Именно здесь важно не приписать синтаксису лишних заслуг. Массив с операторами не лечит арифметику. Он создаёт точку управления всей арифметикой приложения
В показанных выше примерах исправление вполне конкретно: убрать промежуточное вычисление в Number, выполнить операции в выбранном десятичном типе и округлить в предусмотренной точке. Если между библиотеками всё ещё нужно передать значение, сохранить его точное представление. Сам переход на Decimal после потери точности ничего не исправляет
Общий слой позволял собрать такие решения в реализации операций, вместо того чтобы оставлять преобразования на усмотрение каждого участка прикладного кода. В этом и состояла инженерная польза миграции: сначала появилась общая запись, затем - возможность последовательно привести к одному порядку то, что за ней происходило
На создание решения и миграцию ушло около квартала. Само решение было покрыто юнит-тестами, все известные ошибки в расчётах мы исправили. Для исходной задачи это был успешный результат
Попытка превратить инструмент в продукт
После такого естественно подумать, что инструмент пригодится кому-то ещё. Я так и подумал. Внутри команды всё заработало - казалось, внешнему миру оставалось только об этом сообщить
У Nemathode была привлекательная следующая идея - выражение уже представлено данными. Его можно сохранить, передать по сети, версионировать и исполнить в другом месте. Значит, можно попробовать описывать формулу один раз, отправлять её на клиент для предварительного расчёта и использовать на сервере для окончательного
Здесь масштаб задачи начал меняться
Для миграции удобно разрешить подменять математические реализации. Для одинаковых вычислений в разных системах нужно определить, что каждая реализация обязана делать. Сколько цифр сохранять, когда округлять, какой режим округления применять, что делать с делением и как представлять результат
Одинаковая запись формулы этих правил не задаёт
Готовые решения и другие неудобные открытия
Когда я вернулся к вопросу развития проекта в августе 2026 года, пришлось посмотреть и на существующие решения. Обмен выражениями и логикой между системами уже имел собственные инструменты
- JsonLogic прямо предлагает хранить правила в JSON и передавать их между клиентом и сервером
- math.js предоставляет разбор математических выражений, дерево выражения и его исполнение, а также поддерживает BigNumber
- GoRules ZEN / JDM использует JSON-модели решений; вокруг них есть движок и инструменты редактирования
- DMN / FEEL описывает модели решений, таблицы правил и язык выражений для них
Это не было внезапным открытием существования математических движков. Ещё в статье о Nemathode от 26 мая 2021 года я упоминал math.js среди источников вдохновения. В 2026-м изменился вопрос, с которым я смотрел на эти инструменты: какую полезную возможность я собираюсь добавить и почему ради неё кому-то стоит принять мой формат?
Свой README очень убедителен, когда читаешь его сам. С чужой документацией этот эффект почему-то ослабевает
Более серьёзная задача всё ещё оставалась. Можно захотеть, чтобы клиент и сервер воспроизводимо считали одну финансовую формулу, а при расхождении система объясняла его причину. Но тогда понадобятся строгие правила арифметики, версии формул, проверки совместимости исполнителей и механизм сверки. Сервер должен самостоятельно повторять расчёт: результат клиента не становится доверенным только потому, что тот использовал известную формулу
Ни один пункт в списке альтернатив сам по себе не доказывает, что весь такой продукт уже существует в готовом виде. Зато список показывает, сколько его частей можно получить из существующих решений. Начинать собственный движок заново пришлось бы обосновывать конкретными требованиями, которые они не закрывают
Были и более простые варианты. Например, запрашивать предварительный расчёт у сервера. Или вынести общую реализацию в пакет, если клиент и сервер работают в одной языковой среде (я пока вижу, что это большая редкость). Собственный переносимый язык имеет цену, и потребность в нём нужно проверять отдельно от удовольствия его написать
Архивация проекта как инженерное решение
При повторном разборе обнаружились и ограничения самого Nemathode. Формат усложнял валидацию по мере роста выражений. Проверки TypeScript покрывали лишь часть возможных проблем. Особенно неприятным был путь обработки вложенных результатов: при определённой конфигурации выходное преобразование могло вернуть значение в Number, после чего оно снова попадало во внутренний числовой тип. Та самая граница, на которой можно потерять точность
Ключевой вывод который я сделал исходил из известного афоризма про то, что в программировании есть только 2 проблемы - инвалидация кэша и именования. Мой опыт показывает лишь одну проблему и она звучит так "если бы люди умели лучше договариваться мы бы имели намного меньше проблем"
12 сентября 2026 года я принял решение архивировать проект. Для этого решения отдельный DSL мне не понадобился