От боли к примитиву: как Spintax получил согласование с числом
Краткий инженерный трейс того, как примитив {plural <count>: form|…} прошёл путь от обсуждения в backlog до shipped-кода в двух движках (PHP-плагин, TypeScript-бандл) с общей семантикой. Полезно, если когда-нибудь придётся аргументировать новый примитив в стабильном движке — есть референс для рабочего паттерна "решить сначала, построить один раз, документировать разрыв между намерением и реальностью".
Триггер
Согласование с числом сидело в backlog движка со статусом «deferred — решения залочены, ждём триггер» месяцами. Изначальные критерии для перевода из deferred в active были три сигнала от редактора:
- Live-шаблон с
%N% {form1|form2|form3}(random-pick bug, не feature). - Вопрос редактора в духе «как сделать N + существительное правильно».
- Два или больше inline-workaround-конструкции в разных шаблонах, начавших расходиться.
Ни один не сработал. Сработал четвёртый путь: реальный cost-анализ на casino-platform — multi-tenant контентном рантайме, который рендерит сеть bitcoincasinos. Аудит перечислил workaround'ы, которые понадобились бы для вывода чисел в рендеренной копи — и стоимость сложилась в числа, которые не работают.
Числа, запустившие триггер
- Engineer-in-the-loop dependency. Каждый новый counter (новая колонка данных казино, литерал в копи) заставлял бы делать ассемблер-изменения → деплой воркера → шаблонную работу. Редакторы не могли автономно добавить фразу с числом. Organizational coupling, не cosmetic friction.
- Литералы недоступны. Фразы вроде «за 30 дней», «5 крипт» требуют согласования по значениям, которых нет в casino data layer. Has-flag предзаготовка не помогает.
- Мультипликативный взрыв на реальных entities. Шесть считаемых сущностей (Languages, Cryptos, Providers, Bonuses, FreeSpins, Days, Hours) × 3 has-флага × несколько counter-источников × per-noun makros в каждом пресете. Стоимость пошла из «дороже» в «непригодно к поддержке», как только перечислили на реальных данных.
Строгое чтение критериев триггера всё ещё говорило «ждать». Чтение по духу — доказательство, что разрыв реален и quantified, а не speculative — говорило, что это и есть триггер. Deferral существовал для избегания speculative работы; анализ был обратным speculative.
Lockdown до кода
Что прошло хорошо: каждое значимое решение было принято и записано в backlog до того, как написана единая строка кода. Маркер ({plural %N%|forms}), модель локали (post meta шаблона + опциональный per-construct override), V1 rule-таблицы (RU 3-form + EN 2-form, других нет), форма AST-нода, поверхность валидатора, cross-engine контракт — всё залочено в docs/backlog.md через серию design-only обсуждений.
Дисциплина, которую это даёт: когда начинается имплементация, на каждый «погоди, а что насчёт…» уже есть ответ. Имплементация — механика.
Reality check
Когда имплементация наконец началась, обзор репозитория casino-platform нашёл то, чего backlog-обсуждения не зафиксировали: TS-имплементация уже была выпущена. Два файла, spintax-plurals.ts и spintax-plurals.test.ts, с ~70 проходящими тестами, lenient-режимом для production-рендера и полной интеграцией в resolveForSite(). Команда casino-platform построила это автономно, пока backlog spintax-плагина копил абстрактный дизайн.
И синтаксис отличался:
| Решение | Backlog-спек | Working TS |
|---|---|---|
| Маркер | {plural %N%|форма1|форма2|форма3} (только pipe) | {plural <count>: форма1|форма2|форма3} (двоеточие разделяет) |
| Слот форм | «Слоты — полные spintax-выражения — вложенные синонимы валидны» | Запрещает вложенные {}/[] — extract через #set сначала |
| Пустой/undefined count | «Последний слот (other/many) + warning» | «Пустая строка» |
| Конфигурация локали | Post meta + per-construct override {plural:en %N%|…} | Один lang-параметр на вызов рендера, без per-construct |
Backlog-спек был абстрактным дизайном до кода. TS-имплементация была работающим контрактом. Где они расходились, побеждал работающий контракт — и по веским причинам. Двоеточие — более чистый дискриминатор (избегает hazard'а {12|forms} после подстановки helper-переменной). Bracket-constraint в формах ловит реальный silent-corruption баг, который абстрактный спек не учёл. Контракт «empty → empty» соответствует engine-wide правилу «unknown-var-renders-empty» из ранних коммитов.
Правило реконциляции
Когда абстрактный дизайн расходится с working-кодом, побеждает работающий код, если расхождение не вскрывает реальный баг. Backlog был переписан, чтобы соответствовать TS-контракту — каждое залоченное решение обновлено, каждое «почему это, а не то» переписано с post-implementation обоснованием, счётчик тестов обновлён с «~27 фикстур» на реальные ~70.
Урок: спеки полезны только когда они актуальны. Замороженный спек, не совпадающий с shipped-кодом, создаёт неопределённость, а не ясность. Либо держите их синхронизированными, либо перестаньте называть документ спеком.
PHP-порт
С TS как каноническим контрактом PHP-порт — механика. Зеркалить алгоритм: тот же brace-aware scanner, тот же strict numeric parse, та же arity-проверка, то же правило локали. Зеркалить тесты: 74 PHPUnit-кейса (по одному на TS-тест), некоторые свёрнуты, где PHP-идиомы естественно объединяют assertion'ы. Зеркалить runtime-контракт: lenient-режим ловит ошибки поблочно и эмитит верботу с fullwidth-скобками — те же кодпоинты (U+FF5B / U+FF5D), та же логика выживания в пайплайне.
Точка вставки в пайплайн WordPress-плагина (Renderer.php) — там же, где resolveForSite() в casino-platform: между вторым conditional-проходом и раскрытием перечислений. Сначала идёт подстановка %var% (слот числа становится целым-литералом), потом плюральный проход работает на стабильном тексте.
Источник локали зависит от хоста. Casino-platform читает allVars.lang. WordPress-плагин читает новый post meta _spintax_locale и фолбэчится на site-локаль WP (get_locale()). Сам Plurals-класс не знает — он нормализует то, что получает, и ищет правило.
Общий объём порта: ~280 строк PHP на три новых файла (Plurals.php, PluralArityError.php, PluralFormError.php) плюс точечные правки Renderer.php и Validator.php, плюс PHPUnit-файл на 74 кейса. PHPCS и весь 309-тестовый набор зелёные. Плагин ушёл с 1.4.0 на 1.5.0.
Что осталось вне scope
Не менее важно, что не вошло:
- Другие славянские (польский, чешский, словацкий, словенский, болгарский) — другие структуры корзин. Каждый требует своего per-language триггера.
- Арабский 6-form, валлийский 6-form, иврит 4-form, латышский 3-form, французский (0/1 = singular) — совсем другие правила.
- Per-construct override локали (
{plural:en %N%: …}) — был в абстрактном спеке, но реального потребителя не возникло. Отложено до появления. - Склонение существительных по падежам (склонение по падежу, не только по числу) — словарная задача, не алгоритмическая.
- Форматирование чисел (NBSP-сепараторы, locale-aware decimal) — соседняя фича, отдельный shipping.
- Полное CLDR-покрытие ~200 локалей — бесконечно движущийся target. Доставлять по реальному запросу.
Дисциплина: каждое «было бы здорово, если бы» названо явно и отвергнуто с причиной. Backlog-файл документирует каждый deferral с явным re-trigger-критерием.
Уроки
- Quantified speculation — не speculation. Реальный cost-анализ может быть валидным триггером, даже когда формальные критерии «увидел в проде» не сработали. Зафиксируйте анализ в trigger-записи, чтобы deferral-история оставалась честной.
- Залочьте решения до кода, но реконсильте после. Pre-implementation спеки предотвращают бесконечное re-litigation во время имплементации. Post-implementation реконциляция предотвращает превращение спека в фикцию.
- Working-код побеждает абстрактный дизайн. Когда они расходятся, по умолчанию верьте коду. Обновляйте спек. Расследуйте до того, как override'ить — код мог узнать что-то, чего не увидел спек.
- Общая rule-data, отдельные runtime'ы. Два движка (PHP, TS) держат независимые code path, но делят одну plural-rules-таблицу. Будущие локали добавляют одну запись таблицы, не две.
- Механические порты сжимаются, когда контракт точный. PHP-порт занял один заход, потому что на каждый поведенческий вопрос уже был TS-ответ для зеркаливания.
Что это даёт
Плюральный примитив — фундамент, не feature. Он позволяет редакторам автономно создавать фразы число + существительное по всей платформе. Казино с разным количеством крипт, провайдеров, языков, бонусов теперь читаются осмысленно по-разному в сгенерированной копи. Литеральные числа в редакторском тексте («за 30 дней», «5 крипт») наконец рендерятся правильно. SEO выигрывает от настоящей дифференциации каталога; читатель — от конкретных чисел вместо «много».
За редакторским взглядом на использование — см. Согласование с числом: {plural <count>: form|…}. За болевой стороной — почему любой существующий spintax-движок ломал это — см. Русский spintax: почему ваши числа всегда выглядят криво.