Исполняемые спецификации на flang Время: версии правил, снапшоты и аудит задним числом
0%

Время: версии правил, снапшоты и аудит задним числом

Время: версии правил, снапшоты и аудит задним числом

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

Минимальный пример

Правило, которое предстоит изменить, — начисление бонусов:

модуль «Лояльность 2025»

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

тотальная функция «Базовое начисление»
  принимает покупка: «Покупка»
  возвращает число
  если покупка.«сумма» не меньше 1000 и притом покупка.«сумма» не больше 100000
    то (покупка.«сумма» умножить на 3) делить на 100
    иначе 0

тотальная функция «Надбавка постоянному»
  принимает покупка: «Покупка»
  возвращает число
  если покупка.«постоянный клиент» и притом покупка.«сумма» не больше 100000
    то (покупка.«сумма» умножить на 2) делить на 100
    иначе 0

тотальная функция «Потолок крупной покупки»
  принимает покупка: «Покупка»
  возвращает число
  обеспечивает «за крупную покупку ровно потолок» результат не больше 5000
  если покупка.«сумма» больше 100000
    то 5000
    иначе 0

тотальная функция «Начислить бонусы»
  принимает покупка: «Покупка»
  возвращает число
  обеспечивает «бонус ограничен» результат не больше 5000
  пример «мелкая покупка»
    дано покупка равно запись «Покупка» с «сумма» равным 500 и «постоянный клиент» равным нет
    ожидается 0
  пример «тысяча у обычного клиента»
    дано покупка равно запись «Покупка» с «сумма» равным 1000 и «постоянный клиент» равным нет
    ожидается 30
  пример «две тысячи у постоянного клиента»
    дано покупка равно запись «Покупка» с «сумма» равным 2000 и «постоянный клиент» равным да
    ожидается 100
  пример «покупка ровно на потолок бонуса»
    дано покупка равно запись «Покупка» с «сумма» равным 100000 и «постоянный клиент» равным да
    ожидается 5000
  пример «очень крупная покупка»
    дано покупка равно запись «Покупка» с «сумма» равным 200000 и «постоянный клиент» равным да
    ожидается 5000
  («Базовое начисление» от покупка) плюс («Надбавка постоянному» от покупка) плюс («Потолок крупной покупки» от покупка)

Эта модель лежит в static/spec/models/loyalty-tier.flang. С 1 марта 2026 года пороги пересмотрели: база — 5 процентов от 5000, надбавка постоянному клиенту — 1 процент, потолок — 6000 вместо 5000. Новая редакция лежит рядом, в loyalty-tier.v2.flang, с модулем «Лояльность 2026». Ни один файл не переписывается поверх другого.

Потолок в обеих редакциях записан числом, и обещание у «Потолка крупной покупки» ядро доказывает для всех входов. Обещание «бонус ограничен» у итоговой функции держится только на примерах — сумму процентов ядро не ограничивает, — поэтому flang check --proof отвечает кодом 3, а напечатанный код проверяет это обещание при каждом вызове.

Почему в модели нет «сегодня»

Это не упущение, а граница языка. Функция flang — чистая: её ответ зависит только от входа. Функции текущей даты в языке нет. Часы читает только план (flang io) — это действие с миром, как чтение файла, и у команды есть ключ --no-clock, который это действие отнимает.

Встроенного типа даты тоже нет. Поле «дата события»: дата компилятор принимает, но хранит строкой, и сравнение порядка на строках отвергается:

модуль «Лояльность по дате»

объект «Начисление»
  «дата события»: дата
  «сумма»: неотрицательное

тотальная функция «Начислить»
  принимает начисление: «Начисление»
  возвращает число
  если начисление.«дата события» не меньше "2026-03-01"
    то (начисление.«сумма» умножить на 10) делить на 100
    иначе 0
FLANG_TYPE в файле date-err.flang, строка 10, столбец 18: левый операнд сравнения «gte» имеет тип строка: сравнения порядка допустимы только для чисел
FLANG_TYPE в файле date-err.flang, строка 10, столбец 44: правый операнд сравнения «gte» имеет тип строка: сравнения порядка допустимы только для чисел

В стандартной библиотеке есть модуль «Date and time»: календарный день вариантом, «Дней от эпохи», разбор отметки времени. Но подключать его в модель правила дорого. Подключённый модуль проверяется вместе с моделью — со всеми 134 примерами самой библиотеки. На машине, где готовился курс, двоичный flang проверял такую модель 2 минуты 8 секунд, а в WebAssembly песочницы проверка упёрлась в предел шагов (FLANG_RECURSION_LIMIT).

Обойти это просто: пусть приложение подаёт день события числом, например 20260410. Сравнение чисел законно, и правило проверяется за доли секунды:

модуль «Лояльность с днём в числе»

объект «Начисление»
  «день события»: неотрицательное
  «сумма»: неотрицательное

тотальная функция «Начислить»
  принимает начисление: «Начисление»
  возвращает число
  пример «апрель»
    дано начисление равно запись «Начисление» с «день события» равным 20260410 и «сумма» равным 1000
    ожидается 100
  пример «февраль»
    дано начисление равно запись «Начисление» с «день события» равным 20260220 и «сумма» равным 1000
    ожидается 0
  если начисление.«день события» не меньше 20260301
    то (начисление.«сумма» умножить на 10) делить на 100
    иначе 0

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

Версия как файл

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

policies/loyalty/
  loyalty-tier.flang        // редакция 1, события до 2026-03-01
  loyalty-tier.v2.flang     // редакция 2, события с 2026-03-01
  editions.json             // таблица периодов

Файлов со временем становится много, и это нормально: редакция, которая когда-либо применялась к реальному событию, удаляется только вместе со сроком хранения соответствующих операций. Ветка git, тег релиза и каталог редакций — три разные вещи; смешивать их не нужно.

Отметку редакции полезно продублировать в имени модуля: «Лояльность 2025» против «Лояльность 2026». Имя модуля видно в отчёте компилятора, в flang ast и в напечатанном коде. Комментарий так не работает: компилятор комментарии выбрасывает, и // редакция 1.1 в начале файла не видно ни в одной функции, ни в разобранной программе.

Различить сами файлы помогает отпечаток. flang check --proof --record пишет запись доказательства, и в ней стоит отпечаток256 — sha256 исходника:

запись доказательства 1
исходник loyalty-tier.flang
строк 49
знаков 1815
отпечаток 354337829 725376771
ядро 2
утверждений 2
отпечаток256 beac695a764f4dac1da8b0a491547b367c6a30869d473eae05444b2c52632b69

У второй редакции он другой — 34f496a3…. Это тот же sha256, что печатает sha256sum, поэтому в журнал операций можно писать его, не запуская компилятор. Любая правка файла, даже комментария, меняет отпечаток.

Кто выбирает версию

Выбор редакции — обязанность приложения. Правило простое: ключом выбора служит дата события, а не дата расчёта. Полуинтервалы [since, until) исключают двойное покрытие границы; ISO-даты сравниваются лексикографически, поэтому хватает обычного <. Код ниже запускается из корня репозитория курса; каждая редакция печатается в JavaScript один раз (importModel из examples/spec/lib/flang.mjs):

import { importModel } from './examples/spec/lib/flang.mjs';

const editions = [
  { id: 'loyalty/2025', since: '2025-01-01', until: '2026-03-01', file: 'static/spec/models/loyalty-tier.flang' },
  { id: 'loyalty/2026', since: '2026-03-01', until: null, file: 'static/spec/models/loyalty-tier.v2.flang' },
];

const cache = new Map();

async function editionFor(eventDate) {
  const edition = editions.find((item) => item.since <= eventDate && (item.until === null || eventDate < item.until));
  if (edition === undefined) throw new Error(`нет редакции правил на дату ${eventDate}`);
  if (!cache.has(edition.id)) cache.set(edition.id, (await importModel(edition.file)).functions.get('Начислить бонусы'));
  return { ...edition, accrue: cache.get(edition.id) };
}

export async function accrue(purchase, eventDate) {
  const edition = await editionFor(eventDate);
  return { edition: edition.id, result: edition.accrue(purchase) };
}

Один и тот же снапшот { сумма: 2000, «постоянный клиент»: true } даёт 100 на дате 2026-02-20 и 20 на дате 2026-04-11. Дата вне всех периодов даёт не «ноль по умолчанию», а исключение «нет редакции правил на дату 1899-05-05» — пробел в таблице должен быть громким, иначе он превратится в тихое неверное начисление. Печать кешируется по редакции: компилятор на каждый запрос не нужен.

В продакшене модели лучше печатать при сборке (flang emit --target js) и класть рядом с сервисом, как в главе о генерации; importModel делает то же самое на лету.

Саму таблицу редакций держите там же, где остальная конфигурация домена, и версионируйте вместе с моделями: изменение периода — такое же изменение бизнес-правила, как изменение процента. Отдельный случай — ретроспективная правка, когда бизнес решает пересчитать уже закрытый период. Это не редактирование старого файла, а третья редакция с собственным периодом и явной процедурой пересчёта: старые операции остаются в журнале как были, новые пишутся рядом со ссылкой на основание.

Shadow-прогон перед переключением

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

const было = current(snapshot);
const стало = candidate(snapshot);
if (было !== стало) report.push({ snapshot, было, стало, дельта: стало - было });

Здесь current и candidate — функция «Начислить бонусы» двух напечатанных редакций. На шести снапшотах расходятся пять: 1000/обычный даёт 30 против 0, 2000/постоянный — 100 против 20, 10000/постоянный — 500 против 600, а покупки на потолке и выше (100000/постоянный и 200000/постоянный) — 5000 против 6000. Такой отчёт — рабочий артефакт для бизнеса, а не техническая деталь. В продакшене shadow-режим ставят рядом с боевым расчётом: новая редакция считает, но на результат не влияет, а расхождения пишутся в лог до дня переключения.

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

flang run-script: файла «.flangrc» нет ни в рабочем каталоге, ни выше до корня проекта

Поэтому shadow-прогон на своих данных остаётся вашим инструментом.

Снапшот, результат и запись решения

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

{
  "event_date": "2026-04-11",
  "edition": "loyalty/2026",
  "module": "Лояльность 2026",
  "source_sha256": "34f496a3c9d0e975e28660fb4607df997236895ac5bf34763f343db0db4994d1",
  "snapshot": { "сумма": 2000, "постоянный клиент": true },
  "result": 20
}

Снапшот — это те и только те поля, которые объявлены объектом модели. Ссылка на запись клиента не годится: запись изменится, а спор останется.

Через полгода эту запись можно перепроверить командой flang facts. Она вычисляет утверждение на фактах и отвечает вердиктом с шагами:

flang facts loyalty-tier.v2.flang --facts snapshot.json --claims '["«Начислить бонусы» от покупка равно 20"]' --pretty

Здесь snapshot.json — {"покупка": {"сумма": 2000, "постоянный клиент": true}}. Ответ — "holds": true и шаги: какая функция, на каких фактах, какое значение, какое сравнение. Та же команда на первой редакции выходит кодом 1 и называет расхождение:

«Начислить бонусы» от факта «покупка» = 100, а утверждение требует «равно 20». Исходные факты — «покупка»: сумма = 2000, постоянный клиент = да

Чего такая проверка не даёт. Вердикт flang facts — это вычисление, а не доказательство: он говорит, что эта редакция на этом снапшоте даёт 20, и не больше. Что редакция была применима к этой дате, остаётся утверждением приложения, и его нужно писать в журнал отдельным полем, вместе с отпечатком файла, по которому считали.

Ломающие изменения и CI

Ломающим считается изменение, после которого прежний вызывающий код перестаёт работать или получает другой смысл: переименование поля объекта, смена его типа, удаление поля, переименование или удаление функции, смена типа результата. Не ломают контракт: новые пороги и проценты, добавление необязательного поля (иногда является), новые примеры.

Опасная ловушка: flang test этого не ловит. Если переименовать «постоянный клиент» в «статус клиента» и заодно поправить примеры, все примеры останутся зелёными и команда выйдет кодом 0. Но вызов со старой полезной нагрузкой упадёт: прогонщик напечатанного модуля ответит FLANG_TYPE … не задано поле «статус клиента» записи «Покупка», а прямой вызов функции — FLANG_UNKNOWN_NAME: запись не содержит поле «статус клиента». Примеры проверяют поведение, а не контракт.

Контракт проверяется сравнением разобранной программы двух версий — достаточно проекции на типы и сигнатуры функций из flang ast:

import { flang } from './examples/spec/lib/flang.mjs';

const compiler = await flang();
const shape = async (file) => {
  const ast = await compiler.ast(file);
  return JSON.stringify({
    types: ast.types.map((type) => ({
      name: type.name,
      fields: (type.fields ?? []).map((field) => `${field.name}: ${JSON.stringify(field.type)}${field.optional ? '?' : ''}`).sort(),
    })),
    functions: ast.functions.map((fn) => ({
      name: fn.name,
      params: fn.params.map((param) => JSON.stringify(param.type)),
      returns: JSON.stringify(fn.returns),
    })),
  });
};

if ((await shape(previous)) !== (await shape(candidate))) process.exit(1);

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

Старые примеры не переносятся в новую редакцию и не «обновляются под новые цифры». Они остаются в старом файле и служат регрессионным набором: если flang test loyalty-tier.flang вдруг покраснел, значит, кто-то правил историю. Что происходит при настоящей регрессии, видно сразу: если убрать из первой редакции надбавку постоянному клиенту, падают два примера, и команда выходит кодом 1:

пример «две тысячи у постоянного клиента» функции «Начислить бонусы» не прошёл: значение не совпало с ожидаемым: ожидалось 100, получено 60
пример «покупка ровно на потолок бонуса» функции «Начислить бонусы» не прошёл: значение не совпало с ожидаемым: ожидалось 5000, получено 3000

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

Измените порог не меньше 5000 в «Базовом начислении» на не меньше 1000 и посмотрите, какие примеры покраснели. Это и есть shadow-прогон в миниатюре: набор примеров показывает границу, которую двигает новая редакция. Затем откройте первую редакцию — loyalty-tier.flang — и сравните, какие примеры у неё другие.

Типичные ошибки

  • Считать по правилам «на сегодня», а не по правилам на дату события.
  • Хранить только последнюю редакцию, полагаясь на git: развёрнутый сервис не умеет читать историю репозитория.
  • Записывать номер версии комментарием: компилятор его не видит.
  • Писать в журнал ссылку на клиента вместо снапшота полей.
  • Считать зелёный flang test доказательством совместимости.
  • Переписывать старые примеры под новые цифры, теряя регрессионный набор.
  • Оставлять дыру в таблице периодов и молча падать в редакцию по умолчанию.
  • Выдавать вердикт flang facts за доказательство того, что редакция применима к дате.

Чек-лист

  1. Каждая редакция — отдельный файл, старые не переписываются.
  2. Отметка редакции есть в имени файла и в имени модуля.
  3. Таблица периодов — данные приложения, интервалы полуоткрытые, без дыр.
  4. Выбор редакции идёт по дате события; отсутствие редакции — исключение.
  5. Перед переключением сделан shadow-прогон и посчитана дельта.
  6. В журнал пишутся снапшот, редакция, sha256 файла модели и результат.
  7. CI сравнивает типы и сигнатуры двух редакций и падает на ломающих изменениях.
  8. Старые примеры остаются зелёными на своей редакции.

Смежное: доказательства и сертификаты и generation и CI.

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

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

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

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

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