Sixteen locales do not give you sixteen chances to notice a mistake. They give one set of grammatical assumptions sixteen places to land — and the assumptions are invisible precisely because they are true of the language the code was written in. A screen reader read a plural noun over a count of one in French and Italian. A plural selector we chose on purpose was silently rewritten into a category that means something different in Hindi. A test assertion passed on the exact outcome it existed to rule out, in seven languages, for sixty-three days. Three surfaces, one author, and the author is not carelessness.
The string that only breaks in the languages you cannot read
When a mission ends in
Iron Swarm, the star rating is spoken. It has to
be: the three star icons are excluded from the semantics tree, so one string is the only route
a screen-reader user has to that result. In English that string is
{stars} of 3 stars earned, and it carries no plural selector of any kind. It does
not need one. The noun stars is governed by the invariant total, the literal
3, and the variable count sits harmlessly in front of it. Zero stars, one star,
three stars — the sentence is grammatical at every value and nobody ever had to think
about it.
The French and Italian translations moved the noun. They read
{stars} étoiles sur 3 obtenues and
{stars} stelle su 3 ottenute, which puts the counted noun next to the count that
varies instead of the total that does not. At one star, that is a plural noun over a count of
one, in a language where the agreement is not optional. The commit that fixed it puts the reach
plainly: it fired on every one-star clear. This shipped. We are not going to attach a
number to it, because none exists — nothing in our records measures how many
screen-reader sessions ran in those locales, and we did not trace when the wrong agreement was
introduced, so there is no honest duration to quote either.
Hindi was a third defect nobody had named. Its version of the same key had a single plural-blind form, so it was wrong in the opposite direction — not the wrong plural, but no distinction at all. The French and Italian cases had surfaced incidentally, reported by translators working on a completely different key. Hindi came to light only because the dispatch was required to render all sixteen locales at zero, one, two and three stars and give a verdict on each. The template locale is the one place this class of problem is invisible, and it is also the locale everybody reads, which is why the only reliable way to find these is to make somebody look at all sixteen on purpose.
The fix was a sentence, not a selector
The instinct, once you have diagnosed a grammatical-agreement bug,
is to reach for ICU plural machinery. That is the wrong first move, and two of the three
locales here prove it. French went to {stars} sur 3 étoiles obtenues and
Italian to {stars} su 3 stelle ottenute. No selector, no categories, no generated
branching — the counted noun was simply moved back under the governance of the invariant
total, exactly where English had it all along. The sentence is now correct at zero, one, two
and three because there is nothing left for a count to agree with.
The strongest evidence that this is the better shape is that we did not invent it. Eleven of the other twelve non-English locales already used the same frame, which is why they were never affected in the first place. The translators who reached for a “n out of N” construction had, without being asked, produced a string that no plural rule can break. Only two had done the natural-sounding thing and put the noun on the variable.
Hindi is where the shortcut runs out. It has no equivalent “of N” frame that can carry the noun away from the count, so restructuring was not available and a selector was the only route. That is the honest shape of the rule: a plural selector is what you use when the language will not let you move the noun, and the first question is always whether you can move the noun. Three of the sixteen locales needed a fix on this string. Exactly one of them needed the machinery.
The selector we wrote is a request the generator answers in its own vocabulary
The Hindi fix used an explicit-value selector —
{stars, plural, =1{…} other{…}} — chosen on purpose, because
=1 reads like an instruction about the integer one rather than a bet on a category.
That is not what ships. Run flutter gen-l10n and the emitted Dart calls
intl.Intl.pluralLogic(stars, locale: localeName, other: …, one: …).
The literal =1 appears zero times in that locale’s generated file, against
fourteen one: arms. The two ARB spellings are indistinguishable in the output.
We can say that as an identity rather than as a count, which is
worth the extra sentence. For every locale we measured, the number of ARB keys carrying
=1{ plus the number carrying one{ equals the number of
one: arms in the generated Dart: Hindi 6 + 8 = 14, English 7 + 5 = 12, Thai
5 + 0 = 5. A bare “fourteen” would have been a claim about one file. The identity is
a claim about the mapping, which is the thing actually in question.
Now the part where the popular version of this story is
wrong, and it is wrong in the direction that matters. The received telling is that
CLDR routing replaces your selector wholesale, so =1 is ignored and the singular
arm becomes unreachable in languages with no one category. That is not what the
mechanism does. Intl.pluralLogic short-circuits on the exact integer
before it consults any CLDR rule — at howMany == 1 it returns the
one: arm unconditionally. So =1{X} other{Y} and
one{X} other{Y} behave identically at one in every single language, Thai and
Chinese and Japanese and Korean and Indonesian included. Nothing is dead code
there.
What the rewrite actually costs is one integer wide. Collapsing
=1 into one silently widens the singular arm to cover every
other value the locale’s one category admits. For Hindi that is
zero, and only zero. The whole defect surface is a zero-star clear taking the singular noun
— and the toolchain said nothing about it, because there was nothing to warn about:
gen-l10n exited zero and both spellings are legal input.
That behaviour is live today, in six Hindi keys, and it is not a
bug we are carrying. It was ruled and accepted with reasons we can still check: Hindi’s
one category deliberately includes zero, so a singular at zero is that
language’s own documented convention; a pre-existing sibling key already behaved
identically; and deviating would have made this one string inconsistent with every other Hindi
plural in the app. The only thing that was ever actually wrong was a sentence in a
changelog. The prior wave’s recorded rationale claimed =1 had been
chosen in order to escape the category that maps zero to the singular — which is
backwards, since =1 does not escape the category at all. It described the ARB as
though the ARB were the artifact. It was corrected in place twenty-eight minutes later.
And zero is not beyond reach, which is the last thing to say before
moving on. The same short-circuit that fires at one fires at zero: pass a zero:
argument and it is returned at zero in every locale, whatever that locale’s category rule
says. In an ARB, =0{…} produces exactly that arm. The toolchain is not the
obstacle here. Keep that in mind for the close, because it turns out to matter more than the
mechanism does.
Hindi is not an edge case. It is a rule set with four members
The reflex, on hearing that a language puts zero in its singular
category, is to file it as a Hindi quirk and move on. We checked, against CLDR’s own
machine-readable plural rules rather than against intuition, and the reflex is wrong twice
over. Among the sixteen locales this app ships, the group whose one category admits
zero is Hindi, French, Portuguese and Vietnamese. Four members, not one.
Vietnamese is the member nobody predicts, and it is not merely
similar to Hindi — it sits in the same pluralRules element, under the
identical condition i = 0 or n = 1. French has its own rule,
i = 0,1; Portuguese its own, i = 0..1. All four route a count of zero
to the singular arm. English is i = 1 and v = 0, with a single integer sample, so
zero falls to other; German shares that rule set exactly. The reason this
behaviour reads as exotic is that the reader’s own language is in the group that puts zero
where he expects it. There is no edge here. There is a distribution, and English sits
at one end of it.
Russian is a fourth kind of trap and belongs in none of those
lists. Its one condition is
v = 0 and i % 10 = 1 and i % 100 != 11, which puts zero in many and
pulls 21, 31 and 41 into the singular. Our Russian file happens not to be exposed
— it carries no explicit-value selectors at all, twelve one{ keys and zero
=1{ — but the counterfactual is instructive. The same collapse in Russian
would have widened the singular arm to twenty-one, thirty-one and forty-one, which is a far
louder bug than a singular at zero and a far easier one to notice. The quiet failure landed in
the one language whose own convention made it defensible.
One more line worth carrying, because it is the same error at a
smaller scale: pt_PT sits in the Italian rule set while pt
has a rule of its own. A measurement on one is not evidence about the other, inside what looks
like a single language. Whatever generalisation you are about to make across a locale set, the
rule table already disagrees with it somewhere.
In English, incorrect contains correct
That sentence is the whole of the next problem, and it is a fact
about words rather than about code. NeuralSpark, our other sixteen-locale app, announces the
outcome of an answer on a game screen using one of three strings. In English they are
correct, incorrect and partly correct. The second
contains the first as a substring — not as a control token that happens to overlap a real
one, but as a plain morphological consequence of building an antonym with a negative prefix. So
does the third.
The third one is deliberate. When we added the partial-credit
outcome, the ruling required each locale’s string to be built on the same positive root
that locale already uses for a fully correct answer, so that partial credit is framed as credit
rather than as failure. It worked: partly correct contains correct in
sixteen of sixteen locales, by construction. German teilweise richtig on
richtig. Korean 부분 정답 on
정답. Vietnamese đúng một phần on
đúng. Japanese 一部正解 on
正解. A good design decision, made once, that quietly established a
containment relation across the entire corpus.
The negation containment is the accidental one, and it holds in
exactly seven of the sixteen: English, Spanish, French, Japanese, Portuguese, Russian and Thai.
incorrecto contains correcto;
不正解 contains 正解. The other nine build the
antonym from a different word altogether — Italian is corretto
against errato, Chinese 正确 against
错误 — and are perfectly safe. There is no way to know which
group a language is in by looking at English, and no way to
know it by guessing: the two illustrations that came closest to killing this post were an
Italian and a Chinese example somebody had invented from morphological plausibility, both of
which turned out to exist nowhere in the corpus. Every foreign-language string in this post is a
value read out of an ARB file. Not one is an illustration.
The consequence for a test follows in one step. Write the assertion “the announcement contains the word for correct” and it is satisfied by the announcement for incorrect in those seven locales, and by the announcement for partly correct in all sixteen. It fails toward pass, on precisely the outcomes it exists to distinguish. That is not a broken instrument — it runs, it reports faithfully, and the answer it gives is correct for the question it asked. The question was only ever well-formed in English.
The dates make the point sharper than the mechanism does. The correct and incorrect pair landed on 2026-05-31. The partial-credit cell landed on 2026-08-02, sixty-three days later. The hazard was live for that entire window and nothing forced anybody to look at it, because two outcomes is a small enough set to reason about in your head. It took a third member arriving to make somebody enumerate the corpus. No test went red at any point, and none could have.
We are also not going to claim our suite is clean of this. It contains fifteen containment assertions over these strings, and the honest statement is a property rather than a count: every one of them asserts the hazard instead of relying on it. Each sits in a deliberately labelled starved arm, paired inside the same test body with an equality oracle that discriminates all three outcomes. That is the difference between a suite that has been measured and one that has merely been counted — and it is close to, but not the same as, the failure in a guard nobody has seen fail, where the guard’s enumeration basis was wrong. Here the basis was complete. The predicate was wrong.
A prefix test and a suffix test partition the corpus, and neither one is sound
The obvious repair is positional. Do not ask whether the
announcement contains the word for correct; ask whether it starts with it. In
English that discriminates cleanly, because partly correct does not start with
correct. So we measured it across all sixteen, and the result is the most elegant
thing in this whole investigation.
A prefix relation holds in exactly two locales: Thai and Vietnamese,
where the qualifier follows the root rather than preceding it —
ถูกต้องบางส่วน on ถูกต้อง, and
đúng một phần on đúng. A suffix
relation holds in exactly the complementary fourteen. There is no locale anywhere in the corpus
where the root sits in the middle. So the two positional tests partition the sixteen
languages between them: a prefix oracle fails toward pass in two of them, a suffix oracle fails
toward pass in the other fourteen, and each is perfectly sound over the part the other one
breaks. Only exact equality on the isolated outcome word is sound everywhere.
This is structural rather than unlucky, and the distinction is the reason the post exists. The qualifier has to sit on one side of the root or the other, and which side is a fact about the language’s word order. Any predicate built on position therefore cuts a translated corpus along a line that is completely invisible from the template locale. The same goes for containment: a negative prefix is an entirely ordinary way to build an antonym, in Latin and in Chinese and in Thai alike, so the overlap is a normal outcome rather than a coincidence.
Which is what separates this from the bug in the locale that breaks your layout, and the two are worth putting side by side. That one was a coverage gap: the assertion was right, it had simply never been run in German, and parameterising the loop over more locales fixed it outright. This one is a wrong premise. Running the same assertion over sixteen languages does not fix it — it makes it fail in more of them. Adding locales is the remedy for the first kind of mistake and no remedy at all for the second.
It is also not the argument we made in every zero has two explanations, and the difference is worth one clause. There the fault was in the instrument: a check read clean because the pipeline never ran, and a control would have caught it. Here a control would have passed. Enumerating from ground truth would have passed — the check genuinely did run over all sixteen locales. No amount of instrument hygiene reaches an assertion whose predicate is a grammatical fact about one language.
One boundary, so nobody reads more alarm into this than the record supports: the five prefix-shaped assertions that exist in our suite today are pinned to English by their own harness, so they pass and they are not failing. The unsoundness is latent. It would surface the day somebody parameterised that harness over locales, which is exactly the kind of improvement a person makes on a Tuesday without suspecting it changes what an assertion means.
The first fix was written in English too
Here is the part that makes the thesis more than a tidy framing. The rule we wrote to close the containment hazard — the durable, written-down one, the kind of artifact that outlives whoever wrote it — prescribed equality on the leading outcome token, or a prefix test paired with an inequality. It came with a stated rationale: the discriminator sits in first position.
That rationale is true of English. Measured against the corpus it is false in fourteen of sixteen languages, which is where the prefix numbers in the last section came from. The repair reproduced the defect it was written to prevent, one level up: an English morphological property generalised across sixteen grammars without anybody measuring it. And it was authored by the person who had the entire hazard laid out in front of him, in a document whose subject was that hazard.
This one never shipped. It was caught by measurement before it became the suite’s rule, and it was caught by the author of the rule. We are recording it as a near-miss rather than dressing it up, because the near-miss is the more useful artifact: it demonstrates that the failure is not a knowledge problem. You cannot reason your way out of the grammar you are reasoning in. Knowing about the trap, having just written a page about the trap, is not sufficient — the only thing that worked was running the relation over all sixteen files and reading the answer.
The answer was already shipping, one repo over
The hardest question in the whole set is whether you can special-case
zero at all, given that a category rule you do not control decides where zero lands. It has an
answer, it is unconditional, and it was one directory away for the entire investigation.
Intl.pluralLogic takes a zero: argument and the same short-circuit that
fires at one fires at zero, before any rule is consulted — so a zero: arm is
used at zero in every language, whatever the category says. =0{…} in an ARB
produces that arm. NeuralSpark ships two such keys across all sixteen of its locales today. The
other project’s files use none, and its entire generated tree contains not one.
Nobody looked, and the reason is the same shape as everything else here. The question announced itself as a CLDR question, so it got researched where CLDR lives — and CLDR is exactly where that answer is not. Meanwhile a working implementation was sitting in a sibling app, in the same workspace, written by the same crew, doing precisely the thing everyone was theorising about. When a feature already works somewhere you can read, the first diagnostic step is a diff against it, not a theory. That is the unflattering lesson and it is the one with the highest resale value.
To be exact about what that does and does not imply: the Hindi behaviour did not need it. Singular-at-zero was ruled correct for that language, deliberately, and there is no open plural defect sitting in that app waiting on this. The point is not a fix somebody missed — it is where an answer turned out to live.
And the last fact is the one that binds all of it together. Not a single test went red on anything in this post. Key-count parity, JSON validity, static analysis and per-locale rendering were green in both projects, throughout, and all four are satisfied by a selector that has been silently rewritten and by a containment relation between two perfectly correct strings. Every finding here came from a person opening an artifact — sixteen ARB files, a generated Dart file, a commit body — and reading it. The gates were not asleep. They were answering the questions they were given, and the questions were written in English.
A localization defect is rarely a translation error; it is usually a correct decision made in the wrong grammar. Sixteen locales do not give you sixteen chances to catch one — they give one set of assumptions sixteen places to land. Ours were English. They were invisible from where we were standing, and every one of them was found by measuring the corpus instead of reasoning about it.
Notes
This is a first-hand account of two of our own apps as their records
stood on 2026-08-03 — sixteen ARB files each, the Dart that
flutter gen-l10n emits from them, and the commits that fixed what is described here.
It carries no install, revenue or engagement figures, and deliberately no user-impact figure of
any kind: nothing in these records measures how many players heard an ungrammatical announcement,
or in which languages, or for how long. The strongest supportable statement about reach is the
commit’s own — it fired on every one-star clear — and we did not trace when the
French and Italian strings first acquired the wrong agreement, so no duration is claimed
anywhere above.
The dispositions differ per finding and the difference is the point rather than a hole in it. The spoken star rating shipped, in three locales. The plural rewrite shipped and was ruled correct. Everything on the test-oracle side was prevented — found while writing an assertion, before an unsound one landed — including the mis-prescribed repair, which never became a rule. None of it was found by a failing test, and no test run was performed for this write-up; the dispositions come from commit bodies and from reading source, not from an observed suite run.
Documented versus measured, because it bounds the middle section. The plural categories are documented, from CLDR, and anyone can check them. The mapping from an ARB explicit-value selector to a generated category arm is ours: we did not find official documentation stating it, and established it by measuring the generator’s output across sixteen locales in two projects at the toolchain version we ship. Treat it as observed behaviour of that version rather than as a documented contract.
Two boundaries on the framing. The star-rating defect is an accessibility defect in the plain sense that the string is the only spoken route to that information; nothing here was audited against any standard and we make no conformance claim. And NeuralSpark appears in this post only as the source of a sixteen-locale string corpus and a test suite — nothing above is a claim about what any of our games does for the person playing it, and we make none: no cognitive, memory, focus, learning or performance outcome is claimed or implied, anywhere, however hedged. Iron Swarm, like the rest of our games, is free and ad-supported with one optional Pro purchase that removes the ads. We are not claiming an ad-free product, because that would be false.
Sources
- Unicode
CLDR — Language Plural Rules chart (CLDR 47). The reader-facing form of every
category claim above: which languages have a
onecategory at all, and which integers each one admits. It is the human-readable rendering, and it agreed with the machine-readable source on every locale we quote. - Unicode
CLDR —
common/supplemental/plurals.xml. The authoritative source for the exact rule conditions and integer samples quoted above, including Hindi and Vietnamese sharing onepluralRuleselement andpt_PTsitting in a different set frompt. Read on 2026-08-03 from the repository’s movingmainbranch, so the conditions are as they stood on that date rather than as of a tagged release. - Dart
intlpackage. The explicit-number short-circuit that decides the singular arm at exactly one, and the zero arm at exactly zero, was read from the published source of version 0.20.2 in our own package cache. This bullet supports the mechanism; it does not support the ARB-to-generated-arm mapping, which is our measurement and not documented behaviour.
Keep reading: all posts on the WizusLabs Engineering blog.