От боли к примитиву: как 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: почему ваши числа всегда выглядят криво.