FTS — исполняемые спецификации Антипаттерны FTS: что не стоит выражать в спецификации
0%

Антипаттерны FTS: что не стоит выражать в спецификации

Антипаттерны FTS: что не стоит выражать в спецификации

Язык, который умеет меньше, ошибается реже. Но экономия работает только тогда, когда граница известна заранее: иначе первые две недели уходят на попытки записать то, чего в спецификации быть не может, и на вывод «инструмент сырой». Ошибки ниже — не следствие невнимательности. Это ровно те конструкции, которые пишет человек с пятнадцатью годами императивного кода за спиной: там условие означает ветвление, функция означает действие, а if / else if означает взаимное исключение. В FTS каждое из этих слов означает другое.

Как читать этот каталог

Каждый пункт разобран по схеме: какая задача стоит на самом деле, как её пытаются записать, что происходит, как выглядит рабочее разделение. Третий пункт важнее остальных. Часть попыток компилятор отклоняет сразу — это хороший исход. Часть компилируется, и модель, которая проходит проверку, но не значит того, что имел в виду автор, обходится дороже любой диагностики.

Сообщения получены на вендорной сборке компилятора из этого репозитория (static/js/vendor/fts/browser.js). Заведомо сломанные блоки помечены как фрагменты; блоки без пометки компилируются и проходят свои примеры.

Эффекты в спецификации

1. Действие в правиле утилиты

Задача. При крупной покупке отправить клиенту письмо с промокодом.

правило «Большая покупка»
  если сумма не меньше 10000
  то отправить письмо на поле почта

Что произойдёт. Компиляция падает:

FTS_UTILITY_RULE | в правиле ожидаются если, и или то

Сообщение выглядит странно — строка ведь начинается с то. Но грамматика знает ровно две формы действия: то добавить ... и то результат равен .... Всё прочее после то не разбирается, и правило считается недооформленным. То же получит то списать сумму со счёта.

Как правильно. Утилита отвечает на вопрос «сколько», а не «что сделать». Приложение вызывает её, смотрит на результат и само решает, слать ли письмо: отправка — это шаблон, ретрай, дедупликация и отчёт о доставке, ничто из этого компилятором не проверяется.

2. Действие, спрятанное в имя морфизма

Опаснее вариант, где эффект переезжает в морфизм, а то означает не действие, а имя типа-следствия.

категория «Уведомления»

  объект Заказ
    номер является строкой
    «оплачен» является состоянием «Оплачен»

  морфизм «Оплаченный заказ подтверждаем письмом»
    если «Оплачен»
    то «Письмо клиенту отправлено»

Что произойдёт. Ничего: модель валидна, диагностик нет. Ловушка в формулировке кодомена — он назван свершившимся фактом, и через месяц кто-нибудь прочитает сертификат как доказательство того, что письмо ушло. Доказано другое: если заказ оплачен, то по объявленному правилу состояние «письмо отправлено» считается достижимым. Отправку никто не проверял.

Как правильно. Кодомен формулируется как разрешение или обязанность: «Уведомление клиента требуется». Факт отправки возвращается из системы рассылки в данные и становится полем объекта, которое теорема уже проверит по контексту.

3. Ретраи, таймауты, очереди

Задача. После сбоя платежа повторить через пять секунд, потом через тридцать, дальше — не повторять.

правило «Повтор после сбоя»
  если «номер попытки» меньше 3
  то повторить через 5 секунд

Что произойдёт. FTS_UTILITY_RULE | в правиле ожидаются если, и или то. В языке нет ни ожидания, ни расписания, ни очереди.

Как правильно. Разделите «сколько ждать» и «кто ждёт». Политика повторов — чистая таблица, её стоит зафиксировать; исполнение задержки, идемпотентный ключ и дедупликация остаются в приложении.

категория «Повторы платежа»

  объект «Неудачная попытка»
    «номер попытки» является числом
    «ошибка окончательная» является признаком

  утилита «Задержка до следующей попытки»
    принимает «Неудачная попытка»
    возвращает число
    начинает с 0

    правило «Первый повтор»
      если «ошибка окончательная» равна нет
      и «номер попытки» равен 1
      то результат равен 5

    правило «Второй повтор»
      если «ошибка окончательная» равна нет
      и «номер попытки» равен 2
      то результат равен 30

    пример «Окончательная ошибка не повторяется»
      дано «номер попытки» равен 1
      дано «ошибка окончательная» равна да
      ожидается результат равен 0

    пример «Третья попытка не назначается»
      дано «номер попытки» равен 3
      дано «ошибка окончательная» равна нет
      ожидается результат равен 0

Ноль здесь означает «повтора нет» — тоже решение модели, а не умолчание приложения.

Внешний мир: время, случайность, сеть

4. Внешний источник как поле

Задача. Пересчитать тариф по текущему курсу валюты из внешнего API.

объект Отправление
  «курс валюты» является запросом к «API ЦБ»

Что произойдёт. FTS_NATURAL_NAME | лишний текст после имени: 'к «API ЦБ»'. Но уберите хвост — и начинается интересное:

категория «Тарифы»

  объект Отправление
    вес является числом
    «курс валюты» является запросом

Это компилируется, validate возвращает valid: true, поле получает тип запросом: любое незнакомое слово после является становится номинальным типом. Модель выглядит осмысленной и не значит ничего. Обнаружится это при попытке что-то с полем сделать:

FTS_UTILITY_COMPARE_TYPE | field 'курс валюты' is not numeric

Если поле в правиле вовсе не объявлено, диагностика честнее и раньше: FTS_UTILITY_FIELD | unknown utility input field 'курс валюты'.

Как правильно. Курс получает приложение — с таймаутом, кэшем и запасным значением. В модель он приходит полем «курс валюты» является числом.

5. «Сегодня» внутри правила

Задача. Начислить пени, если счёт просрочен относительно сегодняшнего дня.

правило «Просрочка»
  если «срок оплаты» меньше сегодня
  то добавить 1 процент от поля сумма

Что произойдёт. Строка разберётся, но validate вернёт FTS_UTILITY_COMPARE_TYPE | field 'срок оплаты' is not numeric. Слово сегодня ошибки не вызывает — оно молча становится строкой "сегодня". Замена на «текущая дата» даёт ту же диагностику; если сделать поле числовым, сообщение сменится на comparison for 'дней просрочки' uses incompatible values.

Дело не в датах. Дело в том, что «сегодня» — скрытый вход: модель перестаёт быть функцией, тот же счёт завтра даст другой ответ, а зелёный сегодня пример покраснеет через месяц.

Как правильно. Время вычисляет приложение и передаёт как данные. Просрочка, возраст, срок владения — числа на момент расчёта.

категория «Счета»

  объект Счёт
    сумма является деньгами
    «дней просрочки» является числом

  утилита «Начислить пени»
    принимает Счёт
    возвращает деньги
    начинает с 0

    правило «Просрочка более 30 дней»
      если «дней просрочки» больше 30
      то добавить 1 процент от поля сумма

    пример «Просрочка 45 дней»
      дано сумма равна 100000
      дано «дней просрочки» равно 45
      ожидается результат равен 1000

Побочный эффект приятный: «дней просрочки» видно в логах и в примерах, а спор о том, считается ли день оплаты, решается на уровне одного поля.

6. Случайность: A/B, сэмплирование, случайная скидка

Задача. Половине покупателей выдать скидку и сравнить конверсию.

правило «Половине покупателей»
  если случайное меньше 0.5
  то добавить 10 процентов от поля сумма

Что произойдёт. FTS_UTILITY_FIELD | unknown utility input field 'случайное'. Попытка сделать случайным само действие (то добавить случайное число процентов от поля сумма) отсекается раньше: FTS_NATURAL_NAME | лишний текст после имени: 'число процентов от поля сумма'.

Как правильно. Жребий бросает приложение — один раз, детерминированно по идентификатору клиента, с сохранением ветки. Модель получает ветку входом и остаётся воспроизводимой: по номеру заказа всегда можно пересчитать, какую скидку он был должен получить.

категория «Продажи»

  объект Покупка
    сумма является деньгами
    «ветка эксперимента» является строкой

  утилита «Скидка эксперимента»
    принимает Покупка
    возвращает деньги
    начинает с 0

    правило «Ветка B получает скидку»
      если «ветка эксперимента» равна «B»
      то добавить 10 процентов от поля сумма

    пример «Контрольная ветка»
      дано сумма равна 20000
      дано «ветка эксперимента» равна «A»
      ожидается результат равен 0

Логика правил

7. Правила как if / else

Главный антипаттерн каталога: он компилируется, проходит validate и молча считает не то.

Задача. Пять процентов скидки от тысячи рублей, десять — от десяти тысяч.

категория «Продажи»

  объект Покупка
    сумма является деньгами

  утилита «Рассчитать скидку»
    принимает Покупка
    возвращает деньги
    начинает с 0

    правило «Базовая скидка»
      если сумма не меньше 1000
      то добавить 5 процентов от поля сумма

    правило «Скидка за объём»
      если сумма не меньше 10000
      то добавить 10 процентов от поля сумма

Что произойдёт. Модель валидна. executeUtility при сумме 20000 возвращает 3000, а не 2000: выполняются все правила, чьи условия истинны, и пять процентов складываются с десятью. При сумме 5000 результат 250 — здесь ожидание и реальность совпадают, поэтому ошибка часто доживает до продакшена. Перестановка правил не помогает: добавить коммутативно, порядок влияет только на порядок слагаемых. «Иначе» в языке нет.

Как правильно. Взаимное исключение выражается условиями, а не порядком: каждая ветка получает явную нижнюю и верхнюю границу, закрытую примером.

категория «Продажи»

  объект Покупка
    сумма является деньгами

  утилита «Рассчитать скидку»
    принимает Покупка
    возвращает деньги
    начинает с 0

    правило «Средний чек»
      если сумма не меньше 1000
      и сумма меньше 10000
      то добавить 5 процентов от поля сумма

    правило «Крупный чек»
      если сумма не меньше 10000
      то добавить 10 процентов от поля сумма

    пример «Средний чек»
      дано сумма равна 5000
      ожидается результат равен 250

    пример «Граница десяти тысяч»
      дано сумма равна 10000
      ожидается результат равен 1000

Пара «не меньше / меньше» — единственное место, где видно, к какой ветке относится сама граница. В цепочке if / else if этот выбор не записан нигде.

8. Порядок правил и то результат равен

Действие то результат равен не складывает, а перезаписывает, и правила по-прежнему выполняются все подряд:

категория «Продажи»

  объект Покупка
    сумма является деньгами

  утилита «Ставка скидки»
    принимает Покупка
    возвращает число
    начинает с 0

    правило «Скидка за объём»
      если сумма не меньше 10000
      то результат равен 10

    правило «Базовая скидка»
      если сумма не меньше 1000
      то результат равен 5

При сумме 20000 результат равен 5. Привычка ставить частный случай выше общего работает наоборот: выигрывает последнее сработавшее правило. Лечение то же — непересекающиеся условия, после которых порядок перестаёт что-либо значить.

Попутно: сравнить поле с полем нельзя. если сумма больше поля лимит даёт FTS_NATURAL_NAME | лишний текст после имени: 'лимит', а вариант без слова «поля» тихо превращает лимит в строку и падает на FTS_UTILITY_COMPARE_TYPE | comparison for 'сумма' uses incompatible values. Разность считает приложение и передаёт полем.

9. Свойство как исправление результата

Задача. Ограничить суммарную скидку двадцатью процентами.

правило «Большая покупка»
  если сумма не меньше 10000
  то добавить 15 процентов от поля сумма

правило «Постоянный клиент»
  если «постоянный клиент» равен да
  то добавить 10 процентов от поля сумма

свойство «Скидка не больше 20 процентов»
  результат не больше 20 процентов от поля сумма

Что произойдёт. Модель валидна, но при сумме 20000 и постоянном клиенте выполнение падает:

FTS_UTILITY_PROPERTY | нарушено свойство «Скидка не больше 20 процентов»
                       утилиты «Рассчитать скидку»

Свойство — постусловие, а не clamp. Оно не обрежет 25 % до 20 %, оно уронит расчёт: в проде это отказ в обслуживании самому выгодному клиенту. Свойство с двумя строками отсекается ещё на разборе — свойство «...» должно содержать одно сравнение результата, один инвариант на одно имя.

Как правильно. Потолок закладывается в правила, а свойство остаётся страховкой, которая обязана никогда не срабатывать.

категория «Продажи»

  объект Покупка
    сумма является деньгами
    «постоянный клиент» является признаком

  утилита «Рассчитать скидку»
    принимает Покупка
    возвращает деньги
    начинает с 0

    правило «Крупная покупка постоянного клиента»
      если сумма не меньше 10000
      и сумма не больше 100000
      и «постоянный клиент» равен да
      то добавить 20 процентов от поля сумма

    правило «Крупная покупка нового клиента»
      если сумма не меньше 10000
      и сумма не больше 100000
      и «постоянный клиент» равен нет
      то добавить 15 процентов от поля сумма

    свойство «Скидка не превышает потолка»
      результат не больше 20000

    пример «Крупная покупка постоянного клиента»
      дано сумма равна 20000
      дано «постоянный клиент» равен да
      ожидается результат равен 4000

Родственный случай: процент от поля, которое может быть отрицательным. Соблазнительно записать потолок так же, как правило, — процентом от того же поля: результат не больше 20 процентов от поля сумма. На положительных суммах это выглядит безупречно. Но операнд «процент от поля» меняет знак вместе с полем: при сумма = −100 предел равен −20, а результат остаётся начальным нулём, потому что ни одно правило не сработало. 0 не больше −20 ложно — и утилита падает с FTS_UTILITY_PROPERTY на входе, которого правила даже не касались. Ровно эта ошибка два года жила в модели order-discount.fts из первой главы: примеры были зелёными, check молчал, а потолок в 20 % при максимуме правил в 15 % не проверял вообще ничего — его можно было ужесточить вдвое, и ни один пример не изменился бы. Нашла её не проверка примеров, а карта покрытия (ftsmap), которая перебирает области входа, а не точки. Поэтому в модели курса потолок теперь задан числом (результат не больше 15000), правила явно требуют сумма больше 0, а верхняя граница закрыта отдельным правилом — свойство стало одновременно достижимым и невозможным нарушить.

Та же правка прошла по остальным моделям курса: loyalty-tier.fts и loyalty-tier.v2.fts из главы о версионировании, pricing-rules.fts из главы про lodash, модель капстоуна и учебная модель из главы про утилиты. Иначе получилось бы неудобное: глава 18 объявляет приём антипаттерном, а глава 15 его показывает. Два исключения остались намеренно и названы на месте — модели переноса в главе про lodash и главе про миграцию: там предел обязан дословно повторять формулу легаси, иначе расхождения теста эквивалентности перестанут быть находками в старом коде.

Зеркальный случай: нижняя граница, совпадающая с «начинает с». Свойство результат не меньше 0 при начинает с 0 выглядит как страховка от отрицательного результата, но его равенство пределу достигается ровно там, где не сработало ни одно правило, — то есть предел не отделяет ни одного расчёта от другого. ftsmap называет это FTSMAP_PROPERTY_UNATTAINABLE: «предел недостижим на области, где правила меняют результат». Лечится это не редактированием свойства, а переносом границы в правила: в delivery-price.fts базовый тариф раньше жил в строке начинает с 300, а свойство результат не меньше 300 повторяло объявление; теперь базовый тариф вносят два взаимоисключающих правила (расстояние меньше 500 и расстояние не меньше 500), утилита начинает с нуля, и тот же предел стал достижимым — а заодно исчезли дыры в разбиении. Там, где нижняя граница и есть ноль (списание бонусов в pricing-rules.fts), свойство убрано совсем, а границу держит условие правила если «баланс бонусов» не меньше 0: инвариант, равный объявлению, лучше не писать, чем писать.

Примеры и доказательства

10. Модель без примеров

Модель, проходящая fts check без единого пример, — декларация о намерениях. validate вернёт valid: true; попытка выполнить тесты вернёт FTS_NO_UTILITY_EXAMPLES | утилиты не содержат примеров, а для документа вовсе без утилит — FTS_NO_UTILITIES | документ не содержит утилит.

Проверка типов говорит, что модель связна, и ничего не говорит о том, считает ли она правильно. Спецификацией текст становится тогда, когда в нём появляются числа, о которых договорился бизнес: минимум по примеру на ветку и по примеру на границу.

11. Пример, подогнанный под реализацию

Самый тихий способ обесценить конструкцию. Возьмите модель из пункта 7 и добавьте честное ожидание: бизнес обещал при 20000 скидку 2000.

{"valid": false, "total": 1, "passed": 0, "failed": 1,
 "results": [{"example": "Крупная покупка", "expected": 2000, "actual": 3000}]}

Дальше развилка. Правильный ход — чинить правила: расхождение и есть находка. Соблазнительный — исправить ожидается результат равен 3000: CI зелёный, коммит маленький. После этого пример перестаёт быть требованием и становится слепком поведения. Модель считает не то, что обещано клиенту, но теперь об этом не узнает никто.

Правило код-ревью: изменение строки ожидается в одном коммите с изменением правил считается изменением бизнес-договорённости и требует ссылки на источник — тикет, приказ, тариф. Без ссылки — возврат.

12. Теорема без данных, выданная за доказательство факта

Задача. Показать аудиту, что заказ ЗК-7781 действительно можно отгружать.

категория «Исполнение заказа»

  объект Заказ
    номер является строкой
    «готов к отгрузке» является состоянием «Готов к отгрузке»

  морфизм «Готовый заказ можно отгрузить»
    если «Готов к отгрузке»
    то «Отгрузить заказ разрешено»

  теорема «Заказ ЗК-7781 можно отгрузить»
    дано Заказ имеет «готов к отгрузке» равное да
    в данных заказы найти где номер равен «ЗК-7781»
    по морфизму «Готовый заказ можно отгрузить»
    следовательно «Отгрузить заказ разрешено»

Что произойдёт. Без данных prove выдаст вывод с Готовый заказ можно отгрузить ∘ π_готов к отгрузке. Ровно тот же вывод получится и с данными — визуально они неразличимы. Разница видна в сертификате: certify без контекста даёт status: "symbolic", и в assumptions записано symbolic witness Заказ.готов к отгрузке. Строгая проверка такой сертификат не принимает:

FTS_CERTIFICATE_SYMBOLIC | proof is symbolic; provide complete witness
                           context for strict verification

С контекстом статус становится verified, а подложные данные ловятся сразу: FTS_WITNESS_MISMATCH | witness does not match context at заказы[номер="ЗК-7781"].готов к отгрузке: expected true, got false.

Как правильно. Символьный вывод показывает, что рассуждение корректно. Утверждать факт о конкретном заказе можно только сертификатом со статусом verified и совпавшим context_digest. Аудитору идёт статус, а не картинка стрелок.

Границы модели

13. Гигантская категория

Задача. «Опишем компанию одной моделью, чтобы всё было в одном месте».

Первое, обо что споткнётся такая модель, — омонимы. Заказ в продажах и заказ в логистике различны, и одноимённое объявление отклоняется: FTS_DUPLICATE_STRUCTURE | duplicate structure 'Заказ'. Попытка слить их в один объект даёт FTS_DUPLICATE_FIELD | duplicate field 'Заказ.номер', как только у двух отделов разойдутся типы одного поля.

Дальше вступает менее очевидная механика: утилита принимает ровно один объект, а пример обязан задать все его поля. Объект из шести полей, из которых правило использует одно, даёт пять диагностик на каждый пример:

FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'номер'
FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'вес отправления'
FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'регион доставки'
FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'кредитный рейтинг'
FTS_UTILITY_EXAMPLE_FIELD | example 'Большая покупка' misses 'канал продаж'

Это не придирка, а измеритель связности: стоимость примера растёт линейно по размеру объекта, и god-object делает примеры непереносимыми задолго до того, как станет неудобно читать модель.

Как правильно. Категория — это bounded context: один язык, один владелец, одна причина для изменения. Объект утилиты содержит только то, что участвует в её решении, а пересечения оформляются отдельными объектами с собственными именами.

14. Строковые статусы вместо состояний и морфизмов

Задача. «У заказа есть статус, сравним его со строкой — зачем состояния».

объект Заказ
  статус является строкой

правило «Готовый заказ»
  если статус равен «готов к отрузке»
  то результат равен 1

Что произойдёт. Компилируется. Опечатка в «готов к отрузке» — обычный строковый литерал, сравнивать компилятору не с чем. Правило никогда не срабатывает; при живом примере это видно как expected 1, actual 0, без примера — никак.

Именованные состояния ведут себя иначе, хотя и не сразу. Такая же опечатка в морфизме тоже компилируется: домен Готов к отрузке — законный, но недостижимый тип. Как только появляется теорема, опирающаяся на настоящее состояние объекта, несостыковка становится ошибкой типов:

FTS_PROOF_TYPE_MISMATCH | морфизм «Готовый заказ можно отгрузить»
                          ожидает «Готов к отрузке»,
                          получено «Готов к отгрузке»

Как правильно. Всё, к чему привязаны переходы и разрешения, объявляется состоянием, переходы — морфизмами, и хотя бы один переход закрывается теоремой. Тогда опечатку ловит компилятор, а не оператор склада. Строка остаётся строкой там, где она действительно данные: номер, регион, ветка эксперимента.

Практика в песочнице

Добавьте четвёртое правило «Средний чек»: если сумма не меньше 1000, то добавить 7 процентов от поля сумма. Выполните расчёт для суммы 100000 и постоянного клиента. Сработают три правила из четырёх, накопится 22 % от суммы — 22 000 против потолка 15 000, — и вы получите FTS_UTILITY_PROPERTY | нарушено свойство «Скидка ограничена» — пункты 7 и 9 этого каталога в одном действии.

Добавьте в объект поле «курс валюты» является запросом и убедитесь, что check проходит. Затем используйте поле в условии правила и найдите в выводе FTS_UTILITY_COMPARE_TYPE.

Измените порог в любом правиле так, чтобы один пример перестал сходиться. Затем подгоните строку ожидается под новый результат и сформулируйте одним предложением, что модель перестала гарантировать.

Найдите раздел assumptions и определите, какие предпосылки компилятор проверил по данным, а какие принял как объявленный бизнес-закон.

Чек-лист код-ревью FTS-модели

  1. Нет ли в правилах глаголов действия — «отправить», «списать», «повторить»?
  2. Не назван ли кодомен морфизма свершившимся фактом вместо разрешения?
  3. Есть ли поля с незнакомым типом после является? Убедитесь, что это осознанное состояние, а не молча принятое запросом или списком.
  4. Нет ли в условиях сегодня, сейчас, случайное? Внешнее приходит полем.
  5. Могут ли два правила сработать одновременно? Тогда это сложение, а не выбор.
  6. Есть ли то результат равен с пересекающимися условиями? Побеждает последнее.
  7. Закрыта ли каждая граница примером — значение ровно на пороге, а не рядом?
  8. Не работает ли свойство ограничителем? Оно роняет расчёт, а не правит его. И достижим ли его предел хоть на одном входе — иначе это мёртвая строка. Не записан ли предел процентом от поля, которое может быть отрицательным? Не совпадает ли предел с начинает с — тогда свойство повторяет объявление, а не проверяет расчёт. Обе проверки делает ftsmap <модель>.fts --text.
  9. Есть ли примеры вообще и сходятся ли они? valid: true без них — декларация.
  10. Не менялась ли строка ожидается вместе с правилами без ссылки на решение бизнеса?
  11. Все ли поля объекта нужны утилите? Лишние оплачиваются в каждом примере.
  12. Нет ли в категории объектов из чужого контекста — по имени, по владельцу, по причине изменения?
  13. Опирается ли отчёт о доказанном факте на сертификат verified, а не на символьный вывод?

Кейсы каталога по этой теме

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше