Hamis ID > Cikkek > The Bug That Made Every Italian Twenty-One

Ez a cikk még nincs lefordítva erre: Magyar — az eredetit olvasod ezen a nyelven: English. Elérhető még:Deutsch, English, Українська

The Bug That Made Every Italian Twenty-One

Look up any Italian given name and the age that came back was between 19 and 25. Not usually. Always. Median 21, minimum 19, maximum 25, across all 3,189 names in the Italian cohort data — Giuseppe, Giovanni, Mario, Maria, every one of them.

Giuseppe is not a young man's name. It is the archetypal name of the Italian grandfather generation. The system reported him as twenty-one with complete confidence, and it did so for a reason that has nothing to do with Italian demography and everything to do with a single arithmetic assumption: that a zero in the data means there were none.

Sometimes it does. Sometimes it means the source does not reach this far. Those are two entirely different statements, they are written identically, and confusing them is a distinct class of bug that no structural validator can see.

What the model is supposed to do

Given names carry age information. That is not a subtlety, it is the strongest signal in the whole dataset: naming fashions turn over on a generational cycle, and a name tells you the decade someone was probably born in far more reliably than it tells you anything else.

So a name-to-age model is a genuinely good idea, and when the underlying data is complete it works. Measured on 10,000 draws per name against an age window of 19 to 57:

LocaleNameAge 19–2526–3536–4546–57Median
no_NOBjørn1.6%4.3%8.6%85.5%57
no_NOEmma60.3%29.4%6.2%4.1%24
en_USRobert3.1%6.2%11.0%79.7%57
en_USEmily28.4%37.1%21.6%12.9%31

That is exactly right, and it is right because Norway and the United States publish given-name counts by year going back far enough to fill every decade the model uses. The model holds seven decade columns per name, from the 1940s to the 2000s, weights the decades by their counts, picks one, picks a year inside it, and converts to an age.

What the data actually looked like

Coverage is not uniform across countries, because national statistics offices did not start publishing name counts at the same time. Measured directly from the files — a decade counts as covered if any name in the locale has a non-zero value in that column — most of the locales that carry cohort data cover all seven decades.

Two do not. The Austrian series begins in 1984, so its four oldest decade columns are empty across every row in the file; the Italian series covers 1999 onward, so six of its seven are. Those columns hold zeros — not because nobody was born in Italy in the 1960s, but because the published counts do not go back that far.

The bug is one line of arithmetic

The selection is a standard weighted pick over the seven decades:

total = sum(decades)
r     = random(1, total)
acc   = 0
for i in 0..6:
    acc = acc + decades[i]
    if r <= acc:
        chosen = decade[i]
        break

There is nothing wrong with this code. It is the correct implementation of "weight the decades by their counts". The problem is entirely in what the counts mean.

When only the last column is non-zero, the accumulator stays at zero through six iterations. The condition r <= acc cannot be satisfied until the loop reaches the one column with mass. It is not that the seventh decade is very likely; it is that the other six are unreachable, structurally, for every possible value of r. The outcome is 100% and it is 100% by construction, not by chance.

Re-simulating both variants over 10,000 draws each, independently of the shipped implementation:

LocaleNameCovered decadesVersion19–2526–3536–4546–57MedianRange
it_ITGiuseppe1before100.0%0.0%0.0%0.0%2119–25
it_ITGiuseppe1after30.3%22.2%21.6%25.9%3419–57
it_ITGiulia1before100.0%0.0%0.0%0.0%2119–25
it_ITGiulia1after29.2%21.6%22.8%26.4%3519–57
de_ATJohann3before12.4%31.1%56.4%0.0%3719–45
de_ATJohann3after15.4%28.6%39.0%17.0%3719–57
de_ATEmma3before92.7%7.1%0.2%0.0%2119–45
de_ATEmma3after50.5%17.6%14.6%17.2%2519–57
no_NOBjørn7before1.6%4.3%8.6%85.5%5719–57
no_NOBjørn7after1.6%4.3%8.6%85.5%5719–57

Two things are worth reading off that table.

The Austrian column is the same bug at lower amplitude. Nobody named Johann, Lukas, Maria or Emma could be older than 45 — a hard ceiling, produced by the same mechanism, sitting in the data since the Austrian rows were built. It looked plausible enough that nobody queried it. It is only when the Italian case degenerated to 100% that the shape became visible, and then the Austrian case was obviously the same thing.

The defect scales inversely with the quality of the source. Seven decades covered: correct. Three covered: no card older than 45. One covered: everyone is 21. The less the source knows, the more confidently the model asserts. That is precisely backwards, and it is the signature of missing data being read as evidence.

Zero has two meanings and the file cannot tell you which

The general form of this is worth naming, because it appears far outside name data.

A zero in a count column can mean:

  1. An observed zero. We looked, and there were none. This is information, and weighting by it is correct.
  2. An unobserved cell. We did not look here, or the instrument does not reach. This is the absence of information, and weighting by it asserts something the data never said.

Statisticians distinguish a structural zero (a cell that cannot logically be non-zero) from a sampling zero (a cell that could be non-zero but was not observed), and the whole apparatus of missing-data methods exists because treating the second like the first biases everything downstream. Our case is the second kind, in its purest form: the cells are not merely unobserved, they are systematically unobserved, all at one end of the range, for every row at once.

The reason this survives review is that most tabular formats have no way to express the difference. A tab-separated file of integers has no NULL. Neither does a fixed-width binary column, a protobuf uint32, or a NumPy integer array. The moment you serialise "we don't know" into an integer column, it becomes zero, and zero is a perfectly good number that arithmetic will happily consume.

You can see the same collision everywhere once you look for it:

DomainZero that means "none"Zero that means "no data"
Retail analyticsThis store sold none todayThis store had not opened yet
Sensor telemetryThe tank is emptyThe sensor was offline
Web analyticsNo visits from this countryThe tracker was blocked in this country
EpidemiologyNo cases in this districtThis district did not report
Financial reportingThis segment had no revenueThis segment was not broken out in that year's filing
Name frequencyNobody was given this nameThe registry does not publish that far back

In every row, an aggregate computed over the left-hand meaning is correct, and the identical aggregate over the right-hand meaning is confidently wrong in a specific direction: it concentrates the result onto whatever was observed.

Why no validator caught it

The cohort files pass every structural check there is. Correct column count, correct sort order, UTF-8 without a byte-order mark, no duplicate rows, sums within range, every value a non-negative integer. There is nothing malformed about a row of zeros — a row of zeros is exactly what an honest count file contains for a rare name in a decade where it was rare.

The defect exists only in the distribution of the output, and only when compared against an expectation that lives in somebody's head rather than in a file. "Nobody older than 45" is not a property of the input; it is a property of what the model does with the input, and you can only see it by running the model and looking at what comes out.

That is the same shape as a check that reports success without reading its input — the failure is invisible because the artefact is well-formed and the process ran to completion. We have written about that pattern at length in The Check That Goes Blind Exactly Where You Need It; this is its data-side twin. The lesson transfers directly: validate the output distribution, not the input schema. A schema check tells you the file is readable. It cannot tell you the file is being read to mean what you think.

There is one check that would have caught it immediately, and it costs about five lines:

for each column c in the reference table:
    if sum(c over all rows) == 0:
        warn("column c is empty across the entire dataset")

A zero for a single row is normal. A zero for an entire column, across every row, is never a fact about the world — it is a fact about your source. That distinction is the whole fix.

The distinction that makes the fix hard

The naive repair — "treat zeros as unknown" — destroys the thing the model exists for.

In a locale with full coverage, individual zeros are real and load-bearing. Chloe in Britain has four non-empty decades out of seven; the three empty ones are genuine, because essentially nobody in Britain was named Chloe in the 1940s. That zero is exactly the signal that makes the model say "Chloe is young", and softening it would flatten every name in every complete locale back towards uniform.

So the two zeros are distinguished not by their value, not by their row, but by their scope:

  • A zero in one row, in a column that other rows populate → real. The name was rare that decade.
  • A zero in one row, in a column that is empty for every row → not a measurement. The source stops there.

The second condition can only be evaluated at the level of the whole dataset. No amount of inspection of a single row can tell you which kind of zero you are looking at, which is why the check has to be a column-wide aggregate and why it does not fall out of any row-level validation.

The fix, in general form

Compute how many of the columns the source actually covers, then split the probability mass between "what the data knows" and "what it does not", in proportion to that coverage:

The rule needs one number the row itself cannot supply: how many of the decade columns the source covers at all, counted across the whole locale rather than the row. Split the probability mass in proportion to that coverage. The covered columns keep their share of it; the rest goes into a single unknown bucket standing for the decades the source cannot see. Draw once against the two together. Land in the unknown bucket and the answer is exactly the one the system already gives for a locale with no cohort data at all — a uniform age across the whole window. Land in the data and it is the ordinary weighted pick over the decades, untouched.

Four properties make this the version we kept:

It is exact in integers. The split is whole columns against whole columns, so the covered mass and the unknown mass always add back to the same total whatever the name's counts are. No rounding, no floats, no "just put a 1 in the empty cells".

It degenerates to the old behaviour at full coverage. When every column is covered, the unknown bucket is empty and the arithmetic is not merely equivalent, it is literally the same expression. We verified that on 640,000 comparisons against a verbatim copy of the previous implementation: eight complete locales, zero divergences. This matters more than it sounds — a fix for two locales that quietly perturbs the other eight is not a fix.

It falls back to the honest baseline. The unknown bucket does not invent a distribution; it produces exactly the behaviour the system already has for a locale with no cohort data at all. The fallback is not a new model, it is the absence of one, which is the correct thing to say about years the source cannot see.

It scales with what is known. The expected outputs follow from the coverage ratio alone. Italy covers one decade of seven, so six sevenths of the mass lands in the uniform bucket and one seventh in the data; against an age window of 19–57 and a covered decade that can only produce ages 19 to 25, that predicts 29.7% of cards in the 19–25 band, and the measured value is 29.1–30.3%. The same arithmetic predicts 26.4% in the 46–57 band against a measured 25.9–26.6%, and for Austria — three decades of seven — 17.6% against a measured 17.0–17.5%.

Three alternatives, and why they lose

Write 1 into every empty cell. The obvious one-liner. It does nothing: the real cohort masses are hundreds of thousands, so a 1 among them is not a small correction, it is no correction. Worth stating because it is the first thing everybody suggests.

Spread the missing mass across the empty decades. This is the intuitively "correct" imputation and it was measured to be actively harmful. Italy's empty decades are the 1940s to the 1980s, and against an age window of 19–57 almost all of those birth years fall outside the window. After clamping, roughly 40% of cards would have come out at exactly the maximum age. One conspicuous artefact traded for another. The uniform-age bucket has no such edge behaviour by construction.

Read the list of empty decades from the validator's configuration. Tempting, because the validator already carries a curated list of "known-empty, here is why". But that file is a build-time artefact and does not ship, and duplicating a config into the runtime means somebody has to keep two copies in step forever. Counting the coverage from the data itself means the data describes itself and there is nothing to synchronise.

That last choice has an honest cost, and it should be written down rather than discovered: because the runtime now infers coverage from the data, a future pipeline bug that silently drops an entire decade will be smoothed over at runtime instead of showing up as broken output. The responsibility for catching that has not disappeared — it sits with the build-time validator, which still warns on every unexplained empty column. Moving a failure from one place to another is fine. Losing track of which place is not.

What the fix does not do

It does not create information. This is the part worth being blunt about.

Before the change, all eight test names in the Italian locale produced byte-identical sequences of ages. With one covered decade, the cohort data cannot distinguish names by age at all — there is no signal to recover. The choice was never between signal and noise. It was between "every Italian is 21" and "a plausible spread with no age signal in it", and the second is better only because the first is conspicuously false.

The genuinely useful signal in a single-decade cohort is not age at all — it is gender. A name that appears overwhelmingly in the female column is female regardless of which decade the counts came from, and that inference is fully preserved. Before the cohort data existed, gender for a searched name was a coin flip, which is a far more visible error than an implausible age: "Giulia, male" jumps off the page in a way that "Giuseppe, 23" does not.

So the honest accounting is: gender inference gained everything, age inference gained nothing but stopped lying, and the eight locales with complete data changed by not one bit.

The checklist

  1. For every column in a reference table, check whether it is empty across the entire dataset. A row-level zero is data. A column-level zero is a fact about your source.
  2. Never let "unobserved" serialise into the same value as "observed zero" without recording the difference somewhere. Integer columns have no NULL; if the format cannot carry it, the coverage map has to live beside the file.
  3. Test the invariant that a poorer source produces a wider output, not a narrower one. If your model gets more confident when you feed it less data, missing data is being read as evidence. This is a one-line property test and it catches the whole class.
  4. Validate the distribution of the output, not the schema of the input. Every structural check passed on files that produced a degenerate answer for 3,189 names.
  5. Look for degenerate ranges. Minimum equals maximum, or a hard ceiling that matches no real-world boundary, is the visible fingerprint. "No card is ever older than 45" was on screen for as long as the Austrian data existed.
  6. Distinguish scope when deciding whether a zero is real. The same value is a measurement in one locale and an artefact in another; only the column-wide aggregate can tell you which.
  7. Prefer falling back to the honest baseline over imputing a shape. Spreading the mass across the empty cells looked principled and put 40% of results on a single boundary value.
  8. Prove the fix is inert where it should be inert. Two locales needed changing and eight did not; "eight unchanged" was established by direct comparison against the old implementation, not assumed.
  9. When the runtime starts inferring something the pipeline used to guarantee, write down who now catches the pipeline bug. Smoothing is a feature for bad sources and a mask for bad builds.

The bug had a single-line cause and a visible, absurd symptom, and it still survived because everything around it was well formed. The files were valid, the loop was correct, the tests were green, and the answer was that every Italian in the database was twenty-one years old.

For the companion defect — where a formally weighted list turned out to be half uniform, and every aggregate metric agreed it was fine — see Half of Your Weighted List Is Not Weighted. For what the age model does when the data behind it is complete, see Names by Generation. For checks that report success without ever looking, see The Check That Goes Blind Exactly Where You Need It. For the underlying data and its gaps, see Migration Layers in Birth Cohorts.


Data as of 2026-07-21

All figures were measured on 21 July 2026. The decade coverage was computed directly from the cohort files on disk. The before/after age distributions were re-derived for this article by an independent reimplementation of both the old and the new selection rules, run over the same data at a fixed seed, rather than quoted from the change record; they reproduce it exactly.

Sources and notes:

  • Coverage — a decade column counts as covered if its sum over all rows in the locale is greater than zero, taking both gender files together. Eight locales cover 7 of 7; de_AT covers 3 of 7 (1981, 1991, 2001 populated; 1941, 1951, 1961, 1971 empty); it_IT covers 1 of 7 (2001 only). Coverage does not differ between the male and female files in any locale.
  • Italy — 3,189 rows in the cohort data, 1,667 male and 1,522 female, totalling 2,425,502 and 2,283,897 counts respectively, all in the 2001–2010 column. The underlying series is ISTAT's given-name counts, which begin in 1999.
  • Austria — 17,434 rows, 8,159 male and 9,275 female. Decade sums, male: 0, 0, 0, 0, 296,553, 388,347, 346,755. Female: 0, 0, 0, 0, 281,455, 368,831, 329,587. The national series begins in 1984, which is why the first four columns are empty.
  • Age measurements — 10,000 draws per name, fixed seed, age window 19–57, base year 2026. The four bands are 19–25, 26–35, 36–45, 46–57. Italian names before the fix: 100.0% in 19–25, median 21, range 19–25, for every name tested. After: 29.1–30.3% in 19–25, medians 34–35, range 19–57. Austrian names before: 0.0% in 46–57 for every name, maximum age 45. After: 17.0–17.5% in 46–57, maximum 57.
  • Complete locales are unchangedno_NO and en_US produce identical band shares and identical medians under both rules in the re-simulation above. In the shipped implementation this was established more strongly, by running a verbatim copy of the previous code against the new one on 400 names per locale × 4 age windows × 40 seeds = 64,000 comparisons per locale, 640,000 in total, with zero divergences in the eight complete locales.
  • The 40% figure — the measured consequence of the rejected alternative in which the missing mass is spread across the empty decade columns rather than into a single uniform bucket. Italy's empty decades are 1941–1990; against a 19–57 window in base year 2026 those birth years correspond to ages 36 and above, most of them beyond 57, so clamping piles them onto the upper bound.
  • Predicted versus measured — the expected share of a band is the uncovered fraction of the decades times the band's share of the age window, plus the covered fraction times the share the data itself produces. For Italy this gives 29.7% for 19–25 and 26.4% for 46–57 against measurements of 29.1–30.3% and 25.9–26.6%. For Austria it gives 17.6% for 46–57 against 17.0–17.5%.
  • The gender claim — before cohort data existed for a locale, the gender attached to a searched name was drawn at random. With single-decade cohort data it is inferred from which file carries the greater mass, which is correct for all 3,189 Italian names regardless of the age degeneracy. This is stated as a design property, not as a measured accuracy figure.
  • Structural versus sampling zeros — the terminology is standard in categorical data analysis and is used here for its precision, not as a claim that our case is a textbook contingency table. Our zeros are systematically unobserved cells, which is the harder variant, since the missingness is perfectly correlated with the variable of interest.

← Cikkek