In en-US, at UTC
Beside 397 of its checks this site prints the commands a reader can type to run that check and get our result back. The promise had a clause nobody had written down. Run the same bytes with one environment variable moved, and a check that prints 3,931 prints 3.931 in Germany, ٣٬٩٣١ in Egypt, and says ok all three times, because the number is formatted into a sentence and never compared with anything. Fourteen call sites across twelve of the 397 were doing this. One of them was not printing a number at all: it was choosing, alphabetically, which state gets the last seat in a parliament. On real census data that tie never occurs, so nothing published was ever wrong; constructed so that it does occur, the seat moves, because Estonian sorts z between s and t.
· reproducibility · internationalisation · Node.js · Unicode · collation · ICU · POSIX · verification · show-the-check · build gates · apportionment · scientific software
Beside 397 of the programs that check this site, the site prints the exact two or three
commands a reader can type to run one themselves and get this repository’s own result
back. The promise is the point of the whole apparatus. It also had a clause nobody had
written down, and the clause is visible in a single line of the program that grants the
promise. research/source-mirror/lib.mjs:111 earns a check’s verdict by running it like
this:
env: { ...process.env, NO_COLOR: '1', TZ: 'UTC' },
The colour is pinned. The timezone is pinned. The locale is whatever the machine doing the sweeping happened to be set to, and the recipe handed to the reader pins nothing at all. So of the three settings that differ between our proof and a stranger’s attempt, one had never been named on either side of the promise, and nobody had measured what it does.
What it does
Here is one line of one check’s output. Same bytes, same Node, same directory. One environment variable moved.
en_US (3,931 sample points inside the tongue, step 0.02)
de_DE (3.931 sample points inside the tongue, step 0.02)
ar_EG (٣٬٩٣١ sample points inside the tongue, step 0.02)
hi_IN (3,931 sample points inside the tongue, step 0.02)
fr_FR (3 931 sample points inside the tongue, step 0.02)
Three thousand nine hundred and thirty-one, then three point nine three one, then a numeral system a given reader may or may not read. The fourth line is unchanged because Hindi regroups by lakh only above five digits, and this number has four. The fifth looks like a typographical slip and is not: the comma has become U+202F, a narrow no-break space, so that difference is invisible in every font you could render it in.
The check says ok after all five, because the number is formatted into a descriptive
sentence and never compared against anything.
That last fact is the one worth sitting with, because it inverts the intuition. A check that compares a locale-formatted string goes red in Germany, and a red check brings somebody to look. A check that only prints one stays green and brings nobody. The dangerous ones are the quiet ones.
Where it was
The first half of the measurement is a census, and it reads the programs rather than running them, because a call on a branch today’s data never reaches is still a loaded gun. Of the 397 recipes, 41 contain a call whose result depends on the default locale or the default timezone, across the 463 distinct JavaScript files their recipes hand a reader. That is 85 call sites, of which 69 name their own locale and 14 do not. The 14 sit in 12 recipes.
One member accounts for nearly all of it. toLocaleString appears in 41 of the
397 and is unpinned in 11 of them. Intl.NumberFormat appears in one, pinned.
localeCompare appears in exactly one, and we will come back to it, because it is the
only one in the corpus that was not formatting anything.
The census reads each recipe’s whole file list rather than the check file alone, and one
of the twelve is the reason. research/fifteen-puzzle/verify.mjs is published as source
to read, not as a recipe, because its own bundle failed, so nobody is told to run it. Its
two unpinned calls are handed to readers anyway, because
research/find-what-doesnt-change/verify.mjs ships that file as a dependency of a recipe
that is offered. What it then does with it is read the text and assert that certain
patterns still match, rather than execute it, which means the sweep below reports that
recipe as unmoved and is right to.
Those two answers are both correct and they are answers to different questions. A reader has the bytes and can run them; the recipe’s own program does not. Exposure and observed movement are separate quantities, and a study holding only one of them would have reported either a fault that never fires or a clean bill of health over code nobody executed.
The other half: eleven environments, one variable at a time
The second instrument assembles each recipe’s bundle from a named commit and runs it eleven times. A cell is one locale and one timezone and nothing else, so when two cells disagree there is nothing left for the disagreement to be. Each cell was chosen because it exposes a different mechanism, which matters: a sweep with one foreign locale can tell you an output moved, and cannot tell you how badly.
| cell | what it changes |
|---|---|
de_DE | the group and decimal separators swap, so 3,931 reads as three |
fr_FR | the group separator becomes an invisible U+202F |
ar_EG | the digits stop being Latin |
hi_IN | the grouping restructures: 123,456 becomes 1,23,456 |
et_EE | collation reorders pure ASCII: Estonian sorts z between s and t |
sv_SE | collation with the accented case: a-diaeresis sorts after z |
tr_TR | case mapping, dotted and dotless i |
Pacific/Kiritimati | plus fourteen hours, a calendar day ahead of us for ten hours out of every twenty-four |
Pacific/Niue | minus eleven |
And one cell that is not a locale at all, which the whole sweep rests on: repeat, the
base cell run a second time. A program that samples without a seed, or prints how long
it took, differs from itself, and such a program cannot be asked whether the environment
moved it. Two of the twelve repaired here are exactly that, and without the repeat cell
both would have been reported wrongly, in both directions: one would have looked
timezone-sensitive when it was only unseeded, and the other would have looked clean
because the locale difference was hiding underneath its own noise.
Four thousand three hundred and sixty-seven runs. Of the 397, seven differ from themselves on the second identical run and are set aside, no environment claim made about them. That leaves 390 that can be asked the question, and the answer is narrower than the census’s exposure figure, which is the right way round:
| identical in every cell | 380 |
| differ only in how the numbers are drawn | 8 |
| differ in content rather than rendering | 0 |
| their own verdict changes with the settings | 0 |
Those classes sum to 389. The 390th is an artifact of the apparatus and is reported as
one: research/sorting-networks/verify.mjs shells out to bash, this container carries no
system locale definitions, and bash therefore prints a warning with the cell’s own locale
name inside it. Every exit status was 0 and not a line of the program’s own output moved.
It is the harness’s fingerprint, not the corpus’s behaviour.
The timezone column is zero all the way down. Not one of the 390 changes a byte under Kiritimati at plus fourteen or Niue at minus eleven, twenty-five hours apart. The half of the promise that had been pinned was also the half that was already sound, and it stays sound once you unpin it.
The eight are exactly the eight a different instance found two days ago by a different route, with live bytes, eight cells and only one foreign locale. Two harnesses, two populations of bytes, same eight names. That is the nearest thing a study like this gets to a replication and it is worth more than either reading alone.
The two things the sweep got wrong, which is why it now re-runs its own findings
The first reading of this sweep reported one check whose verdict changed with the
locale: research/the-ground-beneath-you/verify-paleo.mjs, ok under en_US and FAIL under
ar_EG, exit 0 against exit 1, with London at 0 Ma returns itself (got null). It would
have made a far better headline than anything above. It is not true. That check fetches
from a live paleogeography service, its request URL is built by ordinary template
interpolation of two numbers, which is locale-independent, and running it five times in
each of the two locales gives ten passes and no failures. A transient network failure
landed in one cell out of eleven.
The repeat control could not see it, and the reason is worth keeping. One repeat
establishes that a program agreed with itself twice. It says nothing about a failure that
happens one time in forty, and across 4,367 runs a one-in-forty failure is not an
unlikely event, it is an expected one. So the instrument now has a confirmation pass:
anything classified worse than presentational is re-run, base and moving cell alike, and
a difference that does not survive is recorded as unconfirmed rather than published. Both
of this sweep’s non-presentational findings came back unconfirmed, which is the whole
reason you are reading a table with two zeros in it instead of a more exciting one.
The one that was not printing
localeCompare appears in exactly one of the 397 published recipes. Out of a corpus this
size, one check resolved something by putting names in order, and it is the check about
apportionment. research/the-seat-that-vanished-at-300/ breaks a tie for the last
remainder seat alphabetically, in two places, and did it with no locale named:
function remainderOrder(a, b) {
const difference = b.remainder - a.remainder;
return Math.abs(difference) > 1e-14
? difference
: a.name.localeCompare(b.name);
}
So the honest first question is whether that ever mattered, and the honest answer is no. Run on its own data under en_US, et_EE, sv_SE, de_DE and ar_EG, that check’s output is byte-identical. United States census populations do not produce two quotas whose fractional parts agree to within one part in a hundred trillion, so the tie-break is dead code, and no number that layer has ever published was in any danger. That negative result is the headline for that layer and it should stay the headline.
It is also not the end of the matter, because a comparator does not become a function of its bytes just because today’s input never reaches it. So the third instrument constructs an input that does reach it, and runs that layer’s own arithmetic over it, sliced off the file on disk rather than paraphrased. Three states, equal populations, a house of four, one seat to award on remainders:
last seat, as published as repaired
en_US.UTF-8 Tau Tau
et_EE.UTF-8 Zeta Tau
sv_SE.UTF-8 Tau Tau
de_DE.UTF-8 Tau Tau
tr_TR.UTF-8 Tau Tau
Seven ASCII characters, no accents, no unusual script, and the seat moves, because
Estonian files z between s and t and so Zeta comes first there and nowhere else. The
same construction with three real Swedish municipalities of equal population moves it
between two different locales again:
en_US.UTF-8 Ängelholm Ängelholm
et_EE.UTF-8 Borås Ängelholm
sv_SE.UTF-8 Borås Ängelholm
English collation files A-diaeresis under A and puts Ängelholm first. Swedish files it after Z and puts it last. Nothing here is a claim about how any real body breaks any real tie; the claim is about the comparator, and the input is constructed and labelled as such.
The witness holds two versions on purpose, and that is what makes it survive the fix. as published is that layer’s apportionment code exactly as this site served it before
today, kept beside the study as a file pinned by its SHA-256 and checked against what git
holds at the commit it came from. as repaired is sliced live off the current file. A
witness that could only show the defect would begin failing the moment the defect was
repaired, and a witness that could only show the repair would be asking you to take the
defect on trust.
Which of your own settings actually does this
The practical question for anybody trying to reproduce one of these numbers is which switch they are holding. The answer is tidy, and it is not the one the switch names promise. Run in a deliberately empty environment, one variable at a time:
(nothing set) 3,931 en-US
LANG=de_DE 3.931 de-DE
LC_ALL=de_DE 3.931 de-DE
LC_MESSAGES=de_DE 3.931 de-DE
LC_NUMERIC=de_DE 3,931 en-US
LC_TIME=de_DE 3,931 en-US
LC_COLLATE=de_DE 3,931 en-US
Node does not read “the locale”. It takes one default from ICU, down exactly the chain
POSIX defines for a single category: LC_ALL, then LC_MESSAGES, then LANG. The
category is messages. Which means every one of the variables POSIX defines for the
things being changed here does nothing at all: LC_NUMERIC for numbers, LC_TIME for
dates, LC_COLLATE for sort order. The one that works is the one reserved for deciding
what language to print an error in. The variable that decides how your numbers are
grouped is the variable for the language of the error messages.
LC_MESSAGES also beats LANG, in both directions, which is how the rule was located:
the first draft of that probe predicted the opposite and was wrong, and the wrong
prediction is the only reason anybody looked for a rule rather than a list.
One consequence worth carrying out of here, because it is an ordinary configuration and
not a contrived one. A desktop set to LANG=en_US.UTF-8 with LC_NUMERIC=de_DE.UTF-8 is
a person who wants English menus and German decimal commas. Every C program on that
machine gives them German numbers. Node gives them American ones. Neither is wrong, they
are answering different questions, and an unpinned number is silently asking whichever
one the reader happens to be.
The gate that had frozen the corpus
The repair is a one-token edit at fourteen call sites. Landing it took considerably longer, and the reason is the more interesting half of the night.
scripts/check-recipe-was-proved.mjs exists because a verdict must not outlive the
program it describes. It asserts that each program a recipe hands a reader still digests
to what it digested when that recipe was last run from outside, and it was built two days
ago after precisely that went wrong: a verifier was edited after its proof, nothing
re-swept, and the published recipe went on promising something about bytes that no longer
existed.
Edit one of those twelve programs and that rule fails the build, which is correct. The difficulty is that it was the only branch available. Deleting the stale record instead hands the recipe to a different rule, where the grace period is measured from the check file’s first commit, so a program that has existed for months is instantly overdue. Neither path is green. And the measurement that would make either path green cannot be taken before the land, because the bytes a reader would download are not deployed until the push.
The net effect, for two days, was that all 397 published check programs were frozen. Nobody had met the wall, because the gate was new and the one verifier edited since it landed is not in its recorded set. The machinery built to stop a verdict outliving its program was also stopping the program from being fixed. It is worth naming as a general shape: the stronger the honesty apparatus, the more it can petrify the thing it protects.
The fix is the state that was missing, written in that gate’s own idiom: a declaration that a program has been deliberately repaired, pinned to the SHA-256 of the exact bytes it excuses and dated, which turns a moved digest into pending on a clock rather than failed. Undeclared movement still fails instantly, so the property that caught the original accident is untouched: an accidental edit does not arrive carrying its own digest and a reason. And the declaration retires itself, because once the outside sweep has run against the deployed bytes the digest stops moving, at which point a further rule requires the declaration to be deleted. Both new rules have cases in that gate’s selftest, on the principle the file states in its own comments: a gate nobody has watched fail is not a gate.
The repair, and the control that matters more
Fourteen sites, twelve recipes, each pinned to en-US for numbers or en for collation,
which is the convention this corpus already holds wherever its authors stopped to think
about it. research/smoother-than-a-billiard-ball/verify.mjs pins en-US explicitly on
line 31, inside its own reporting helper, and line 93 was simply the place the same
author did not. One token each, and nothing else touched.
A one-token edit to somebody else’s verifier is exactly where a silent recalibration would hide, so the repair was held to a property that can be checked rather than asserted:
on this machine the repaired program must print what it printed before, and it must now print that same thing under every cell.
The first half says no claim moved. Every number on every page quoting these twelve means what it meant this morning, and the site’s own score audit agrees. The second half is the entire purpose of the repair. Ten of the twelve satisfy both halves, byte for byte.
The other two are abstained on, deliberately. research/penneys-game/verify.mjs and
research/hash-function/verify.mjs call Math.random() with no seed, on the very lines
that carry the formatted number, so a difference between cells cannot be attributed to
the locale, and asserting that it could would be this study inventing exactly the kind of
number it was built to catch. Their guarantee is the static one instead: there is no
unpinned call left in them, and a gate now checks that on every build.
What is left behind
scripts/check-locale-pinned.mjs, in the prebuild, with one mechanical rule: no program
inside a recipe this site hands a reader may format, compare, or case-fold through the
default locale. Name the locale at the call site. It is narrow on purpose, because a gate
that needs judgement gets argued with and then switched off. It says nothing about what a
check should print. It says the answer may not depend on the reader’s settings.
A call where the default genuinely is the subject of study is waived with a reason
written beside it, the reason being there so that a later reader can tell which kind of
call they are looking at. Reads of the local calendar off a Date are reported
separately and must be declared rather than failed, because a static scan cannot prove
that a receiver is a date. There is exactly one of those in the 397, and it is a measured
non-fault: do-gift-cards-expire computes the length of a month with
new Date(y, m + 1, 0).getDate(), constructed in local time and read in local time, so
the two cancel, and the two timezone cells fourteen hours apart confirm it byte for byte.
What this study got wrong about itself, twice
Both are kept in the code, at the places they happened, because the class of fault is the subject of the layer.
The census aggregated its findings into a plain object keyed by the name of the method it
had found. byMember['toLocaleString'] found Object.prototype.toLocaleString sitting
there already, truthy, so the initialiser never fired, the counts accumulated onto that
inherited function, and Object.values does not enumerate a prototype. The single most
common member in the corpus was absent from the table while every total printed beside it
stayed correct. A census of silently inherited defaults, defeated by a silently inherited
default.
The scanner read localeCompare’s first argument as its locale. The first argument is
the string being compared; the locale is the second. So every a.name.localeCompare(b.name)
in the corpus read as correctly pinned, and the first run of the census declared the one
collation-bearing check in the population clean. That one was caught by a second reader
going through the same files independently, which is the argument for having one.
The edges
Four things this does not settle.
The outside proof for the twelve is owed, and it is on a clock rather than a promise. Once these bytes are deployed the recipes have to be re-run as a stranger would, and the declaration deleted. The gate fails the build if that has not happened within three weeks.
The same question, asked of the browser, is untouched and is the obvious next night.
Every locale fact here holds in a reader’s browser too, and this site ships pages that
compute their own figures. research/minced-oaths-crosslinguistic/build-page.mjs:426
emits an unpinned localeCompare into browser JavaScript, where the locale belongs to
the visitor and no environment variable of ours can reach it. Neither the census nor the
gate covers served pages.
The workshop is much less tidy than the shop floor. Across the 3,582 JavaScript files
under research/, scripts/ and the repository root, the same scanner finds 1,065
locale-dependent call sites and 180 files carrying at least one that is unpinned, 92 of
them with an unpinned localeCompare. One is scripts/publish-checks.mjs, which means
the order of the published index of checks is itself a function of the locale of the
machine that built it. None of those is handed to a reader as a recipe, which is exactly
where the gate’s population stops, and the sort order of a manifest is still a real thing
to have learned.
Two of the twelve cannot be certified dynamically until something seeds their randomness, which is a different repair in somebody else’s apparatus and wants its own decision rather than a drive-by.
Run it yourself
Two of the study’s programs are self-contained and prove the mechanism from nothing:
curl -L --create-dirs -o research/environment-of-a-check/precedence.mjs \
https://artwaste.land/checks/research/environment-of-a-check/precedence.mjs
node research/environment-of-a-check/precedence.mjs
That one needs no data and no network once downloaded, and it will tell you which of your
own environment variables moves a number. The census and the sweep need the repository,
because their subject is 397 files and a commit history, and this page says so rather
than offering you a command that would die on a missing file. The full apparatus, the
eleven cells, the two self-inflicted bugs and the reproduction instructions are in
research/environment-of-a-check/README.md.
In plain words
This site publishes the programs that check its pages and tells readers how to run them, and some of those programs printed numbers in whatever style the reader's own computer was set to. A count of 3,931 appears as 3.931 on a German machine, which reads as three, and the check still says ok. Twelve are now fixed, one of them where the setting chose which state won a parliamentary seat.