Материал FTS Antipatterns: What Should Not Be Expressed in the Specification
0%

FTS Antipatterns: What Should Not Be Expressed in the Specification

FTS Anti-Patterns: What Should Not Be Expressed in the Specification

A language that can do less makes fewer mistakes. But this economy works only when the boundary is known in advance: otherwise, the first two weeks are spent trying to write things that cannot exist in the specification, and concluding that “the tool is raw.” The errors below are not a result of carelessness. These are exactly the constructs written by someone with fifteen years of imperative code behind them: there, a condition means branching, a function means an action, and if / else if means mutual exclusion. In FTS, each of these words means something else.

How to read this catalog

Each item is analyzed according to the scheme: what the actual task is, how they try to record it, what happens, how the working division looks. The third item is more important than the others. Some attempts are rejected by the compiler immediately — this is a good outcome. Some compile, and the model that passes the check, but does not mean what the author intended, is more expensive than any diagnostics.

Messages received on the vendor compiler build from this repository (static/js/vendor/fts/browser.js). Obviously broken blocks are marked as fragments; blocks without a mark compile and pass their examples.

Effects in Specification

1. Action in utility rule

Task. Send a client an email with a promo code upon a large purchase.

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

What will happen. Compilation fails:

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

The message looks strange — the line starts with то. But grammar knows exactly two forms of action: то добавить ... and то результат равен .... Everything else after то is not parsed, and the rule is considered incomplete. The same will get то списать сумму со счёта.

How to do it right. The utility answers the question “how many,” not “what to do.” The application calls it, looks at the result, and decides on its own whether to send an email: sending is a template, retry, deduplication, and delivery report—none of this is checked by the compiler.

2. Action Hidden in the Morphism Name

More dangerous is the variant where the effect moves into the morphism, and то means not an action, but the name of a consequence-type.

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

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

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

What will happen. Nothing: the model is valid, no diagnostics. The trap is in the formulation of the codomain — it is named as an accomplished fact, and in a month someone will read the certificate as proof that the letter was sent. Another thing is proven: if the order is paid, then by the declared rule the state «letter sent» is considered reachable. No one checked the sending.

How to do it right. The codomain is formulated as a resolution or obligation: «Уведомление клиента требуется». The fact of sending is returned from the mailing system into the data and becomes a field of the object, which the theorem will already check by context.

3. Retries, timeouts, queues

Task. After a payment failure, retry after five seconds, then after thirty, then — do not retry.

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

What will happen. FTS_UTILITY_RULE | в правиле ожидаются если, и или то. The language has no waiting, no schedule, and no queue.

How correctly. Separate «how long to wait» and «who is waiting». Retry policy — a clean table, it should be fixed; delay execution, idempotent key and deduplication remain in the application.

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

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

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

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

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

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

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

Zero here means “no repetition” — also a model decision, not an application default.

The Outside World: Time, Randomness, Network

4. External Source as a Field

Task. Recalculate the tariff according to the current currency exchange rate from an external API.

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

What will happen. FTS_NATURAL_NAME | лишний текст после имени: 'к «API ЦБ»'. But remove the tail — and the interesting part begins:

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

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

This compiles, validate returns valid: true, the field gets type запросом: any unfamiliar word after является becomes a nominal type. The model looks meaningful and means nothing. This is discovered when trying to do something with the field:

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

If a field is not declared in the rule at all, the diagnostics is more honest and earlier: FTS_UTILITY_FIELD | unknown utility input field 'курс валюты'.

How to do it right. The course receives an application — with a timeout, a cache, and a fallback value. In the model, it arrives as the «курс валюты» является числом field.

5. «Today» inside the rule

Task. Calculate penalties if the invoice is overdue relative to today.

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

What will happen. The string will be parsed, but validate will return FTS_UTILITY_COMPARE_TYPE | field 'срок оплаты' is not numeric. The word сегодня does not cause an error — it silently becomes the string "сегодня". Replacing it with «текущая дата» gives the same diagnostics; if the field is made numeric, the message will change to comparison for 'дней просрочки' uses incompatible values.

It’s not about dates. It’s about the fact that “today” is a hidden input: the model ceases to be a total function, the same count will give a different answer tomorrow, and today’s green example will turn red in a month.

How to do it right. Time is calculated by the application and passed as data. Delinquency, age, ownership duration — numbers as of the calculation date.

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

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

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

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

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

A nice side effect: “days of delinquency” is visible in the logs and in the examples, and the dispute over whether a payment day is counted is resolved at the level of one field.

6. Randomness: A/B, sampling, random discount

Task. Give a discount to half of the customers and compare the conversion.

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

What will happen. FTS_UTILITY_FIELD | unknown utility input field 'случайное'. Attempt to make the action itself random (то добавить случайное число процентов от поля сумма) is cut off earlier: FTS_NATURAL_NAME | лишний текст после имени: 'число процентов от поля сумма'.

How to do it right. The application throws the dice — once, deterministically based on the client’s identifier, saving the branch. The model receives the branch as input and remains reproducible: by the order number, it is always possible to recalculate what discount it should have received.

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

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

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

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

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

Logic of Rules

7. Rules as if / else

The main catalog antipattern: it compiles, passes validate, and silently counts the wrong thing.

Task. Five percent discount from one thousand rubles, ten — from ten thousand.

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

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

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

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

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

What will happen. The model is valid. executeUtility at a total of 20000 returns 3000, not 2000: all rules whose conditions are true are executed, and five percent are added to ten. At a total of 5000 the result is 250 — here expectation and reality coincide, so the error often makes it to production. Reordering the rules does not help: добавить is commutative, the order affects only the order of the addends. “Otherwise” is not present in the language.

How to do it right. Mutual exclusion is expressed by conditions, not by order: each branch receives an explicit lower and upper bound, closed by an example.

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

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

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

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

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

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

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

The “not less / less” pair — the only place where it is visible which branch the boundary itself belongs to. In the if / else if chain this choice is not recorded anywhere.

8. Rule order and то результат равен

Action то результат равен does not accumulate but overwrites, and the rules are still executed one after another:

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

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

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

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

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

At a total of 20000 the result is 5. The habit of placing a special case above the general one works in the opposite way: the last rule that triggered wins. The treatment is the same — non-overlapping conditions, after which the order stops meaning anything.

Along the way: comparing a field to a field is not possible. если сумма больше поля лимит gives FTS_NATURAL_NAME | лишний текст после имени: 'лимит', and the variant without the word “field” silently converts лимит to a string and fails on FTS_UTILITY_COMPARE_TYPE | comparison for 'сумма' uses incompatible values. The difference calculates and passes as a field.

9. Property as a Result Correction

Task. Limit the total discount to twenty percent.

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

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

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

What will happen. The model is valid, but with a sum of 20000 and a regular customer execution fails:

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

Property — postcondition, not clamp. It won’t clip 25 % to 20 %, it will drop the calculation: in production this is a service denial to the most valuable customer. A property with two lines is cut off even during the walkthrough — свойство «...» должно содержать одно сравнение результата, one invariant per one name.

How to do it right. The ceiling is defined in the rules, and the property remains a safeguard that must never trigger.

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

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

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

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

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

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

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

Related case: percentage of a field that can be negative. It is tempting to record the ceiling in the same way as the rule — as a percentage of the same field: результат не больше 20 процентов от поля сумма. On positive amounts this looks flawless. But the “percentage of a field” operand changes sign together with the field: at сумма = −100 the limit equals −20, while the result remains the initial zero, because no rule triggered. 0 не больше −20 is false — and the utility crashes with FTS_UTILITY_PROPERTY as input, which the rules didn’t even touch. Exactly this error lived in the model order-discount.fts from the first chapter for two years: examples were green, check was silent, and the 20% ceiling at a maximum rule of 15% didn’t check anything at all — it could have been doubled, and no example would have changed. It was found not by example checking, but by the coverage map (ftsmap), which iterates over input regions, not points. Therefore, in the course model the ceiling is now defined as a number (результат не больше 15000), rules explicitly require сумма больше 0, and the upper bound is closed by a separate rule — the property became simultaneously achievable and impossible to violate.

The same correction was applied to the remaining course models: loyalty-tier.fts and loyalty-tier.v2.fts from the chapter on versioning, pricing-rules.fts from the chapter on lodash, the capstone model and the educational model from the utilities chapter. Otherwise it would be inconvenient: chapter 18 declares the practice an antipattern, while chapter 15 demonstrates it. Two exceptions were left intentionally and named in place — the transfer models in the chapter on lodash and the chapter on migration: there the limit must literally repeat the legacy formula, otherwise the discrepancies in the equivalence test will stop being discoveries in the old code.

Mirror case: lower bound coinciding with “starts with”. Property результат не меньше 0 at начинает с 0 looks like a safeguard against a negative result, but its equality to the limit is achieved exactly where none of the rules applied—that is, the limit does not separate any calculation from another. ftsmap calls this FTSMAP_PROPERTY_UNATTAINABLE: “the limit is unreachable in the area where rules change the result.” This is treated not by editing the property, but by moving the boundary into the rules: in delivery-price.fts the base tariff previously lived in line начинает с 300, and property результат не меньше 300 repeated the declaration; now the base tariff is covered by two mutually exclusive rules (расстояние меньше 500 and расстояние не меньше 500), the utility starts from zero, and the same limit became reachable—while at the same time the gaps in the partition disappeared. Where the lower bound is zero (bonus deductions in pricing-rules.fts), the property was removed entirely, and the boundary is held by the condition of rule если «баланс бонусов» не меньше 0: an invariant equal to the declaration is better not to write than to write.

Examples and Proofs

10. Model without examples

A model that passes fts check without a single пример is a public offer. validate will return valid: true; an attempt to run tests will return FTS_NO_UTILITY_EXAMPLES | утилиты не содержат примеров, and for a document without utilities — FTS_NO_UTILITIES | документ не содержит утилит.

Type checking says that the model is consistent, and says nothing about whether it computes correctly. A specification becomes text when it contains numbers agreed upon by the business: a minimum per example per branch and an example per boundary.

11. Example, fitted to the implementation

The quietest way to devalue a construct. Take the model from item 7 and add a fair expectation: the business promised a 2000 discount at 20000.

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

Ahead is a fork. The correct move is to fix the rules: the discrepancy is the discovery. The tempting one is to fix ожидается результат равен 3000: CI is green, the commit is small. After this, the example ceases to be a requirement and becomes a snapshot of behavior. The model calculates not what is promised to the client, but now no one will find out about it.

Code review rule: changing line ожидается in the same commit as changing the rules is considered a change to the business agreement and requires a reference to the source — a ticket, an order, a tariff. Without a reference — a refund.

12. A theorem without data, passed off as proof of a fact

Task. Show the audit that the order ZK-7781 can indeed be shipped.

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

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

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

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

What will happen. Without the data prove will output with Готовый заказ можно отгрузить ∘ π_готов к отгрузке. Exactly the same output will be obtained with the data — visually they are indistinguishable. The difference is visible in the certificate: certify without context gives status: "symbolic", and in assumptions it is recorded symbolic witness Заказ.готов к отгрузке. A strict check does not accept such a certificate:

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

With the context the status becomes verified, and the fake data is caught immediately: FTS_WITNESS_MISMATCH | witness does not match context at заказы[номер="ЗК-7781"].готов к отгрузке: expected true, got false.

How to do it right. Symbolic derivation shows that the reasoning is correct. To assert a fact about a specific order, a certificate with status verified and a matching context_digest is required. The auditor gets the status, not the arrows’ picture.

Model Boundaries

13. Giant Category

Task. “Describe a company with one model, so that everything is in one place.”

The first thing such a model will stumble upon is homonyms. An order in sales and an order in logistics are different, and a similarly named declaration is rejected: FTS_DUPLICATE_STRUCTURE | duplicate structure 'Заказ'. Attempting to merge them into one object results in FTS_DUPLICATE_FIELD | duplicate field 'Заказ.номер', as soon as the two departments diverge in the type of one field.

Further, a less obvious mechanics comes into play: the utility accepts exactly one object, and the example must specify all its fields. An object with six fields, of which the rule uses one, gives five diagnostics for each example:

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 'канал продаж'

This is not a quibble, but a coherence meter: the cost of an example grows linearly with the object size, and a god-object makes examples unportable long before it becomes uncomfortable to read the model.

How to do it right. A category is a bounded context: one language, one owner, one reason for change. A utility object contains only what is involved in its solution, while intersections are represented by separate objects with their own names.

14. String Statuses Instead of States and Morphisms

Task. «An order has a status, compare it with a string — why states».

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

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

What will happen. Compiles. A typo in «готов к отрузке» — an ordinary string literal, the compiler has nothing to compare it to. The rule never triggers; with a live example this is visible as expected 1, actual 0, without an example — not at all.

Named states behave differently, although not immediately. The same typo in the morphism also compiles: domain Готов к отрузке — a legal, but unreachable type. As soon as a theorem appears that relies on the actual state of the object, the inconsistency becomes a type error:

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

How to do it right. Everything to which transitions and permissions are bound is declared as a state, transitions — as morphisms, and at least one transition is closed by a theorem. Then a typo is caught by the compiler, not the warehouse operator. A string remains a string where it is really data: number, region, experiment branch.

Practice in the sandbox

Add the fourth rule “Average Check”: if the amount is no less than 1000, then add 7 percent of the amount field. Perform the calculation for the amount 100000 and a regular customer. Three out of the four rules will trigger, accumulating 22 percent of the amount — 22 000 against the ceiling of 15 000 — and you will receive FTS_UTILITY_PROPERTY | нарушено свойство «Скидка ограничена» — items 7 and 9 of this catalog in one action.

Add the field «курс валюты» является запросом to the object and ensure that check passes. Then use the field in the rule condition and find in the output FTS_UTILITY_COMPARE_TYPE.

Adjust the threshold in any rule so that one example stops matching. Then adjust the string ожидается to match the new result and formulate in one sentence what the model stops guaranteeing.

Find the section assumptions and determine which assumptions the compiler checked based on the data, and which it accepted as declared business laws.

Checklist for FTS-model code review

  1. Are there action verbs in the rules — “send”, “charge”, “repeat”?
  2. Is the codomain of the morphism named as a fait accompli instead of a resolution?
  3. Are there fields with unfamiliar types after является? Make sure this is an intentional state, not a silently accepted запросом or списком.
  4. Are there сегодня, сейчас, случайное in the conditions? The external comes as a field.
  5. Can two rules trigger simultaneously? Then it is addition, not a choice.
  6. Are there то результат равен with overlapping conditions? The last one wins.
  7. Is each boundary closed with an example — the value exactly at the threshold, not nearby?
  8. Does свойство not act as a limiter? It drops the calculation, not corrects it. And is its limit reachable on at least one input — otherwise it is a dead line. Is the limit not recorded as a percentage of a field that can be negative? Does the limit not coincide with начинает с — then the property repeats the declaration, not checks the calculation. Both checks are done by ftsmap <модель>.fts --text.
  9. Are there examples at all and do they converge? valid: true without them is a declaration.
  10. Has the line ожидается changed together with the rules without referring to a business decision?
  11. Are all object fields needed by the utility? Extra ones are paid for in each example.
  12. Are there objects from a foreign context in the category — by name, by owner, by reason for change?
  13. Does the report about a proven fact rely on a certificate verified, rather than symbolic derivation?

Cases in the catalog on this topic

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

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

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

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