Зачем нужен Lean
Это уже случалось
Amazon Prime Day, июль 2019: всё по $94.48
Официальные товары Amazon, включая объектив Canon за $13 000, продавались за $94.48. Один из покупателей предположил, что система должна была взять 94.48% цены после скидки, а вместо этого поставила $94.48 как итоговую сумму. Классическая путаница процента и абсолютного значения — ровно то, что ловится типами и доказательством. Многие такие заказы Amazon при этом выполнил.
techspot.comAmazon UK и RepricerExpress, декабрь 2014: всё по одному пенни
Софт автоматического репрайсинга должен был держать цены чуть ниже конкурентов, но в пятницу с 19:00 до 20:00 из-за ошибки сотни товаров ушли за бесценок. Одна продавщица отдала 675 товаров обычной ценой £5–100 по пенни и потеряла £20 000. Отдельная боль: уже отправленные заказы Amazon отменить не смог — склады FBA отгружали товар, не глядя на цену. Инвариант «цена не ниже нижней границы» должен проверяться на границе системы, а не доверяться внешнему репрайсеру.
iol.co.za
Тест — это высказывание про один случай. «Эта пара акций в порядке» проверяется и проходит. Акций становится больше, пар между ними — квадратично больше. Тестов столько не пишет никто.
Ничего при этом не падает. Заказ проходит обычным путём и в мониторинге выглядит успешной продажей. Находят это в конце месяца, когда кто-нибудь смотрит маржу по SKU. Или раньше, если сотрудник наткнётся на сочетание случайно.
Lean позволяет высказать другое: «никакой набор акций не опустит цену ниже закупочной». Это утверждение про все случаи сразу, и у него нет состояния «тесты зелёные» — оно либо доказано, либо опровергнуто конкретной парой.
Компилятор ловит и ещё одно. Если в какой-то момент мы захотим добавить новый тип скидки — скажем, кешбэк, — файл перестанет собираться. Размер скидки считает функция, которая разбирает типы акций по случаям; для нового случая ветки в ней нет, и Lean называет пропущенный случай по имени. Пока ветку не дописали, то есть не решили, сколько кешбэк снимает с цены, сборки не будет. Само правило при этом в безопасности: доказательство не заглядывает внутрь механик и гарантирует нижнюю границу суммы в чеке. Последующие выплаты кешбэка оно не учитывает.
Где ещё складываются правила
Правила доставки, тарифные планы, уровни лояльности, схемы комиссий. Везде, где правила складываются, а за сумму никто не отвечает.
Где проходит граница
Прайсинг прекрасно живёт без пруф-ассистента. Разговор о том, какого рода высказывание тебе нужно. «Эта пара в порядке» — тест, и их всегда будет мало. «Никакой набор акций не опустит цену ниже закупочной» — то, что бизнес просил с самого начала.
Здесь доказывается только нижняя граница суммы в чеке. Кешбэк после покупки, доставка и другие расходы в модель не входят: прибыльность продажи из этой теоремы не следует. Если прайс изначально ниже закупочной, эта модель поднимет итог до закупочной даже без акций — это выбранное правило, а не свойство Lean.
Файл Pricing.lean из этого разбора собирается целиком: Lean 4.34, ноль ошибок.
Числа в выводе — то, что напечатал компилятор.
Шаг 1 из 16
У товара две цены
Одна — за сколько продаём, вторая — за сколько купили. Вторая обычно живёт в другой системе: в закупках, в 1С, в выгрузке от поставщика. Движок цен про неё не знает. В коде храним копейки: 5000 — это 50.00, 3200 — это 32.00.
Шаг 2 из 16
Механики заводит маркетинг
Процент и фиксированная сумма. Обе заводятся через админку, минуя деплой и ревью: разработчик об этом просто не узнаёт.
Шаг 3 из 16
Размер одной скидки
cutберёт цену и одну акцию и возвращает размер скидки. Процент считается от той цены, что передали. Деньги храним в копейках, поэтому при делении размер скидки округляется вниз до целой копейки. Фиксированная сумма на цену не смотрит вовсе.Шаг 4 из 16
Как они складываются
stackидёт по списку и на каждом шаге передаётcutуже сниженную цену. После первой скидки 25% остаётся 37.50; следующая акция будет считаться от этой суммы. Про закупочную здесь не сказано ни слова.Шаг 5 из 16
Одна акция
Категорийная акция и купон дают по 25%. Каждая по отдельности снижает цену с 50.00 до 37.50 — выше закупки 32.00. Проверяем именно те две акции, которые дальше применим вместе: оба теста проходят.
def categorySale : Promo := .percent 25 def coupon : Promo := .percent 25 -- каждая из этих двух акций по отдельности оставляет цену выше закупочной example : stack sneakers.price [categorySale] = 3750 := by decide example : stack sneakers.price [coupon] = 3750 := by decideВывод
Pricing.lean: no errors
Шаг 6 из 16
А если применить две сразу?
Категорийную акцию завёл один маркетолог в мае. Купон — другой, в июне. По отдельности обе безопасны. А вместе? Акций 30, пар между ними 435, и эту пару никто не проверил. После первой акции осталось 37.50 при закупке 32.00. Как думаешь, останемся выше закупочной после купона?
Получилось 28.13 — ниже закупочной на 3.87. Вторые 25% считаются от 37.50: скидка 9.375 округляется вниз до 9.37. Именно сочетание двух безопасных по отдельности акций нарушило правило.
−25% купонШаг 7 из 16
Обрезать по закупочной
Клиент теперь платит 32.00. Но отчёт по-прежнему считает скидку через старый движок, мимо чека: заплачено 3200, в отчёте скидка 2187, фактически выдано 1800. Причина расхождения — два разных расчёта, а не сам
max. Если отчёт берёт скидку из итоговой суммы чека, обрезка решает и эту проблему.-- обрезаем итог по закупочной def clamped (i : Item) (ps : List Promo) : Nat := max i.cost (stack i.price ps) -- отчёт берёт число из движка акций, мимо чека def reported (i : Item) (ps : List Promo) : Nat := i.price - stack i.price ps #eval clamped sneakers promos #eval reported sneakers promos #eval sneakers.price - clamped sneakers promosВывод
3200 2187 1800
Шаг 8 из 16
Сформулировать правило
Теорема — это утверждение, которое компилятор обязан проверить. Сначала его надо записать. Вот то, чего хочет бизнес: для любого товара и любого набора акций итог не ниже закупочной. Значок
∀читается «для всех» — он и отличает это от теста, который говорит про один случай. Пока это только формулировка: не доказана и не опровергнута.Шаг 9 из 16
Допустить, что оно верно
Пара акций уже уронила цену до 28.13, но пока это наблюдение: компилятор о нём не знает и при следующей правке движка о нём не напомнит. Чтобы знал, отрицание правила записывают теоремой и доказывают.
¬Pв Lean — это сокращение дляP → False, значит надо из допущения вывести противоречие. Этим занятintro— он забирает посылку и даёт ей имя. Теперьrule— это предположение «правило выполняется», а доказать осталосьFalse.Шаг 10 из 16
Применить к одному товару
ruleговорит про все товары и все наборы акций сразу. Применяем его к одному — к этим кроссовкам и к той самой паре. Получаемbad: 3200 ≤ 2813. Числа здесь заведомо не сходятся, и так и задумано —badдержится на допущении, сделанном шагом раньше, и рухнет вместе с ним.Шаг 11 из 16
Получить противоречие
Разберём строку по частям.
bad— это «3200 ≤ 2813», полученное из допущения.(by decide)доказывает обратное: Lean честно считает то же неравенство и получает «ложь».absurdпринимает утверждение вместе с его отрицанием и выдаёт что угодно — из противоречия следует всё; здесь от него нуженFalse.exactотдаёт этотFalseкак доказательство цели, и на этом теорема закрыта. Значит, допущение неверно: правило не выполняется. Контрпример остаётся в файле, и компилятор пересчитывает его при каждой сборке.-- «неправда» — и это тоже надо доказать theorem stack_breaks_the_rule : ¬ never_below_cost := by -- intro забирает посылку и даёт ей имя rule: «правило верно» intro rule -- have применяет rule к этим кроссовкам: bad — это 3200 ≤ 2813 have bad := rule sneakers promos -- bad — это 3200 ≤ 2813, а (by decide) считает и доказывает, что оно ложно -- absurd из утверждения и его отрицания даёт False, exact отдаёт его цели exact absurd bad (by decide)-- «неправда» — и это тоже надо доказать theorem stack_breaks_the_rule : ¬ never_below_cost := by -- intro забирает посылку и даёт ей имя rule: «правило верно» intro rule -- have применяет rule к этим кроссовкам: bad — это 3200 ≤ 2813 have bad := rule sneakers promos -- bad — это 3200 ≤ 2813, а (by decide) считает и доказывает, что оно ложно -- absurd из утверждения и его отрицания даёт False, exact отдаёт его цели exact absurd bad (by decide)-- «неправда» — и это тоже надо доказать theorem stack_breaks_the_rule : ¬ never_below_cost := by -- intro забирает посылку и даёт ей имя rule: «правило верно» intro rule -- have применяет rule к этим кроссовкам: bad — это 3200 ≤ 2813 have bad := rule sneakers promos -- bad — это 3200 ≤ 2813, а (by decide) считает и доказывает, что оно ложно -- absurd из утверждения и его отрицания даёт False, exact отдаёт его цели exact absurd bad (by decide)Вывод
Pricing.lean: no errors
Шаг 12 из 16
Запас, из которого платят скидки
Вся сумма, которой акции могут распоряжаться, — это маржа: разница между прайсом и закупочной. На этих кроссовках 18.00. Раньше акции считались от цены и о закупочной не знали вовсе; теперь у них появился общий кошелёк.
Шаг 13 из 16
Из пустого кошелька не берут
spendберёт меньшую из двух сумм: сколько акция просит и сколько осталось в запасе.takenвычитается и из цены, и из запаса. Поэтому следующий процент, как и раньше, считается от уже сниженной цены. Первая акция берёт 12.50, вторая просит 9.37, но получает только оставшиеся 5.50.Шаг 14 из 16
Доказательство в одну строку
Итог — закупочная плюс остаток запаса. Остаток имеет тип
Natи не бывает отрицательным.Nat.le_add_rightдоказывает, что число не больше суммы себя с неотрицательным числом. Одна акция даёт 37.50; пара и три жадные акции — 32.00. Гарантия относится к сумме в чеке, а не ко всем расходам магазина.-- весь запас скидок — это маржа def margin (i : Item) : Nat := i.price - i.cost -- берём сколько просим или сколько осталось def spend (price : Nat) : Nat → List Promo → Nat | budget, [] => budget | budget, p :: ps => let taken := min (cut price p) budget spend (price - taken) (budget - taken) ps -- итог: закупочная плюс остаток запаса def checkout (i : Item) (ps : List Promo) : Nat := i.cost + spend i.price (margin i) ps theorem checkout_never_below_cost (i : Item) (ps : List Promo) : i.cost ≤ checkout i ps := Nat.le_add_right _ _ #eval checkout sneakers [categorySale] #eval checkout sneakers promos #eval checkout sneakers [.percent 50, .fixed 9000, .percent 90]Вывод
3750 3200 3200
-- весь запас скидок — это маржа def margin (i : Item) : Nat := i.price - i.cost -- берём сколько просим или сколько осталось def spend (price : Nat) : Nat → List Promo → Nat | budget, [] => budget | budget, p :: ps => let taken := min (cut price p) budget spend (price - taken) (budget - taken) ps -- итог: закупочная плюс остаток запаса def checkout (i : Item) (ps : List Promo) : Nat := i.cost + spend i.price (margin i) ps theorem checkout_never_below_cost (i : Item) (ps : List Promo) : i.cost ≤ checkout i ps := Nat.le_add_right _ _ #eval checkout sneakers [categorySale] #eval checkout sneakers promos #eval checkout sneakers [.percent 50, .fixed 9000, .percent 90]Вывод
3750 3200 3200
-- весь запас скидок — это маржа def margin (i : Item) : Nat := i.price - i.cost -- берём сколько просим или сколько осталось def spend (price : Nat) : Nat → List Promo → Nat | budget, [] => budget | budget, p :: ps => let taken := min (cut price p) budget spend (price - taken) (budget - taken) ps -- итог: закупочная плюс остаток запаса def checkout (i : Item) (ps : List Promo) : Nat := i.cost + spend i.price (margin i) ps theorem checkout_never_below_cost (i : Item) (ps : List Promo) : i.cost ≤ checkout i ps := Nat.le_add_right _ _ #eval checkout sneakers [categorySale] #eval checkout sneakers promos #eval checkout sneakers [.percent 50, .fixed 9000, .percent 90]Вывод
3750 3200 3200
Шаг 15 из 16
Следующая механика
Маркетинг просит кешбэк. Файл перестаёт собираться, пока кто-нибудь не скажет, сколько эта механика снимает с цены. Доказательство при этом переписывать не нужно: оно не заглядывает внутрь механик.
Шаг 16 из 16
Объявить долю
Кешбэк выплачивают после покупки, поэтому сумму в чеке он не меняет:
cutвозвращает 0. Файл снова собирается, а доказательство осталось прежним. Оно гарантирует только то, что сумма в чеке не ниже закупочной. Выплата кешбэка может уменьшить доход ниже закупочной; для её учёта нужны другая модель и отдельное правило.inductive Promo where | percent (p : Nat) | fixed (cents : Nat) | cashback (p : Nat) def cut (price : Nat) : Promo → Nat | .percent p => price * p / 100 | .fixed c => c -- кешбэк возвращают после покупки — с цены он не снимает ничего | .cashback _ => 0 -- доказательство осталось прежним, его даже не пришлось открывать theorem checkout_never_below_cost (i : Item) (ps : List Promo) : i.cost ≤ checkout i ps := Nat.le_add_right _ _Вывод
Pricing.lean: no errors
Листать шаги можно стрелками ← и →.
Проверь себя
Почему в этом примере после обрезки расходится отчёт?
Клиент платит 32.00, реальная скидка — 18.00. Отчёт берёт старый расчёт и показывает 21.87. Сам max не мешает корректной отчётности: скидку для отчёта нужно считать по той же итоговой сумме, что и в чеке.
Почему бюджетная версия не может пробить закупочную?
Итог складывается из закупочной и того, что осталось от запаса. Остаток — натуральное число, отрицательным он не бывает, поэтому итог не опускается ниже закупочной. Отдельная проверка после этого не нужна.
Отвечено 0 из 2