From pain to primitive: how Spintax got plural agreement

A short engineering trace of how the {plural <count>: form|…} primitive went from a backlog conversation to shipped code in two engines (PHP plugin, TypeScript bundle) with shared semantics. Useful if you ever need to argue for a new primitive in a stable engine and want a reference for "decide first, build once, document the gap between intent and reality" as a workable pattern.

The trigger

Plural agreement sat in the engine backlog as "deferred — locked decisions, awaiting trigger" for months. The original criteria for promoting it from deferred to active were three editor signals:

  • A live template containing %N% {form1|form2|form3} (a random-pick bug, not a feature).
  • An editor question of the form "how do I do N + noun correctly".
  • Two or more inline workaround constructs in different templates that had started to diverge.

None of those fired. What fired instead was a fourth path: a real-data cost analysis on casino-platform, the multi-tenant content runtime that drives the bitcoincasinos network. The audit enumerated the workarounds that would be needed to surface counts in the rendered copy, and the cost added up to numbers that were not viable.

The numbers that fired the trigger

  • Engineer-in-the-loop dependency. Every new counter (new casino data column, literal in copy) would force an assembler change → worker deploy → template work loop. Editors couldn't autonomously add a number-bearing phrase. Organizational coupling, not cosmetic friction.
  • Literal numbers unaddressable. Phrases like "за 30 дней", "5 крипт" need plural agreement on values that don't exist in the casino data layer. Has-flag pre-computation can't help.
  • Multiplicative explosion on real entities. Six countable entities (Languages, Cryptos, Providers, Bonuses, FreeSpins, Days, Hours) × 3 has-flags × multiple counter sources × per-noun macros in every preset. Cost went from "more expensive" to "unmaintainable" once enumerated on real data.

The strict reading of the trigger criteria still said wait. The spirit reading — evidence the gap is real and quantified, not speculative — said this was the trigger. The deferral existed to avoid speculative work; the analysis was the inverse of speculative.

Lockdown before code

One thing went well: every consequential decision was made and recorded in the backlog before any line of code was written. The marker ({plural %N%|forms}), the locale model (per-template post meta with optional per-construct override), the V1 rule tables (RU 3-form + EN 2-form, no others), the AST node shape, the validator surface, the cross-engine contract — all locked in docs/backlog.md over a sequence of design-only conversations.

The discipline this enforces: when implementation starts, every "wait, what about…" question already has an answer. Implementation is mechanical.

The reality check

When implementation finally started, a survey of the casino-platform repo found something the backlog conversations hadn't captured: the TS implementation was already shipped. Two files, spintax-plurals.ts and spintax-plurals.test.ts, with ~70 passing tests, lenient mode for production rendering, and full integration into resolveForSite(). The casino-platform team had built it autonomously while the spintax-plugin backlog accumulated abstract design.

And the syntax differed:

DecisionBacklog specWorking TS
Marker{plural %N%|форма1|форма2|форма3} (pipe-only){plural <count>: форма1|форма2|форма3} (colon delimits)
Forms slot"Slots are full spintax expressions — nested synonyms valid"Rejects nested {}/[] — extract via #set first
Empty/undefined count"Last slot (other/many) plus warning""Empty string"
Locale configPost meta + per-construct override {plural:en %N%|…}Single lang param per render call, no per-construct

The backlog spec was the abstract design before code. The TS implementation was the working contract. Where they disagreed, the working contract won — and for good reasons. The colon is a cleaner discriminator (avoids the post-substitution {12|forms} hazard from a helper-var pattern). The forms-bracket constraint catches a real silent-corruption bug that the abstract spec never considered. The empty-to-empty contract aligns with the engine-wide unknown-var-renders-empty rule from earlier commits.

The reconciliation rule

When abstract design diverges from working code, the working code wins unless the divergence reveals an actual bug. The backlog was rewritten to match the TS contract — every locked decision updated, every "why this and not that" rationale rewritten with the post-implementation justification, every test count updated from "~27 fixtures" to the real ~70.

The lesson: specs are useful only when they're current. A frozen spec that disagrees with shipped code creates uncertainty, not clarity. Either keep them aligned or stop calling the doc a spec.

The PHP port

With the TS as the canonical contract, the PHP port is mechanical. Mirror the algorithm: same brace-aware scanner, same strict numeric parse, same arity check, same locale rule. Mirror the tests: 74 PHPUnit cases (one per TS test), some collapsed where PHP idioms naturally combine assertions. Mirror the runtime contract: lenient mode catches errors per-block and emits the verbatim construct with fullwidth braces — same codepoints (U+FF5B / U+FF5D), same pipeline-survival logic.

The pipeline insertion point in the WordPress plugin's Renderer.php is where resolveForSite() in casino-platform puts it: between the second conditional pass and enumeration resolution. So %var% substitution happens first (count slot becomes a literal integer), then the plural pass runs on the now-stable text.

Locale source differs by host. Casino-platform reads allVars.lang. The WordPress plugin reads a new template post meta _spintax_locale and falls back to the WordPress site locale (get_locale()). The Plurals class itself doesn't care — it normalises whatever it gets and looks up the rule.

Total port effort: ~280 lines of PHP across three new files (Plurals.php, PluralArityError.php, PluralFormError.php) plus surgical edits to Renderer.php and Validator.php, plus a 74-case PHPUnit file. PHPCS and the full 309-test suite pass. The plugin moved from 1.4.0 to 1.5.0.

What stayed out of scope

Just as important as what shipped is what didn't:

  • Other Slavic languages (Polish, Czech, Slovak, Slovenian, Bulgarian) — different bucket structures. Each requires its own per-language trigger.
  • Arabic 6-form, Welsh 6-form, Hebrew 4-form, Latvian 3-form, French (0/1 = singular) — different rules entirely.
  • Per-construct locale override ({plural:en %N%: …}) — was in the abstract spec but no real consumer surfaced. Deferred until needed.
  • Noun case declension (declining nouns by case, not just by number) — dictionary problem, not algorithmic.
  • Number formatting (NBSP separators, locale-aware decimal) — adjacent feature, separate ship.
  • Full CLDR coverage of ~200 locales — endless moving target. Ship per real demand.

The discipline: every "wouldn't it be nice if" was named explicitly and rejected with a reason. The backlog file documents each deferral with an explicit re-trigger criterion.

Lessons

  • Quantified speculation is not speculation. A real-data cost analysis can be a valid trigger even when the formal "saw it in production" criteria haven't fired. Capture the analysis in the trigger record so the deferral history stays honest.
  • Lock decisions before code, but reconcile after. Pre-implementation specs prevent endless re-litigation during implementation. Post-implementation reconciliation prevents the spec from becoming a fiction.
  • Working code beats abstract design. When the two disagree, default to the code. Update the spec. Investigate before overruling — the code may have learned something the spec didn't see.
  • Shared rule data, separate runtimes. Two engines (PHP, TS) maintain independent code paths but share the same plural-rules table. Future locales add one table entry, not two.
  • Mechanical ports compress when the contract is exact. The PHP port took an afternoon because every behavioural question already had a TS answer to mirror.

What this enables

The plural primitive is a foundation, not a feature. It lets editors author number + noun phrases autonomously across the platform. Casinos with different counts of cryptos, providers, languages, bonuses now read meaningfully different in their generated copy. Literal numbers in editorial text ("за 30 дней", "5 крипт") finally render correctly. SEO benefits from genuine catalogue differentiation; readers benefit from concrete numbers instead of "many".

For the editor's perspective on actually using it: see Plural agreement: {plural <count>: form|…}. For the pain side — why every existing spintax engine got this wrong — see Russian spintax: why your numbers always look wrong.