InterviewPrepKit

Home / Learn / GenAI System Design

03 — Machine Translation

“Design machine translation for 100 languages at a billion requests a day.”

Machine translation (MT) is the task of taking text written in one language and producing text in another that means the same thing.

At 100 languages and a billion requests a day, three things decide the design, and none of them is the model architecture:

  1. How you manufacture training data for the language pairs that have none.
  2. How the tokenizer silently sets both cost and quality for exactly the languages you are worst at.
  3. Where the router draws the line between a small fast model and a large slow one.

By the end you will be able to derive the fleet size and the daily bill from a traffic figure, explain why the industry-standard quality score ranks a factual error above a correct paraphrase, and say which assumptions the whole design would collapse without.

Vocabulary you need before the first section

These terms are used constantly below. Each is defined once, here, so that nothing later has to stop and explain it.

TermWhat it means
TokenThe chunk of text a model reads and writes — roughly four characters of English, so a little under a whole word
TokenizerThe component that chops text into tokens. It was fitted to some body of text before training began and cannot be changed afterwards without retraining
CorpusAny body of text you have collected
Parallel data (also bitext)Text paired with its translation, sentence by sentence
Monolingual dataText in one language, with nothing attached to it
Directed pairAn ordered language pair, such as English-to-Icelandic. Icelandic-to-English is a different problem and counts separately
p95, p99Percentiles: the latency that 95 or 99 out of every 100 requests come in under. This is the number users complain about; the average is not
QPSQueries per second — how many requests the system handles each second
GPU, GPU-hourThe accelerator chip the model runs on, and one of them rented for an hour

The instinct this prompt provokes is to start talking about transformer architecture. The actual answer is about data acquisition, a tokenizer decision made a year earlier, and which lane the router sends a request to.


Problem framing

Four things anchor everything that follows: what goes in, what comes out, the constraints every later number is derived from, and the one failure mode the entire design exists to prevent.

Say the input and the output in one sentence before going any further, because everything downstream is shaped by them: a paragraph of text plus a target language goes in, and a paragraph of text in that target language comes out. Nothing about that shape tells you which of the 9,900 possible directions the request is in, how much data exists for it, or how long the answer is allowed to take — and those three questions are the whole design.

First thing to say: “Three things decide this system and none of them is the model architecture. The data acquisition pipeline, because 9,800 of my 9,900 language directions have no parallel corpus. The tokenizer, because it silently sets both cost and quality for exactly the languages I am worst at. And the routing threshold into the big model, because at this scale 10% of traffic will be 80% of the fleet.”

Three reframes

Each reframe below names an instinct that sounds sensible and the mechanical fact that overturns it. Three pieces of vocabulary appear here for the first time; each is derived in full later, so read these as promissory notes.

In each row, the middle column is what a candidate says first, and the right column is the fact that makes it wrong.

ReframeThe naive viewThe right view
DataCollect parallel corporaManufacture them. Back-translation turns abundant monolingual target text into training pairs, and it works for an asymmetry reason worth deriving
Model countOne model per pair, tuned per pairWeight residency — not traffic — sets the serving cost of a pairwise fleet, and residency does not amortize
MetricRaise BLEUBLEU scores a factual inversion above a correct paraphrase, and is not comparable across languages or tokenizations. It is a regression tripwire, not a quality measure

Assumptions in this stage. Every section of this chapter ends with a block like this one. It sorts the section’s assumptions into three bins: things you state (you are free to pick, and being wrong costs a re-derivation), things you ask (the answer changes the architecture), and things that are load-bearing (if one is wrong, the design is not suboptimal — it is invalid).


ML objective

Underneath the whole system is one loss function, and two facts extracted from it turn out to explain every failure mode later in the chapter.

The system is a conditional sequence model, written p(y | x, L_tgt). Read that as: the probability of an output sentence y, given a source sentence x and a tag L_tgt naming the target language.

Training maximizes the log-likelihood of the human reference translation — the logarithm of the probability the model assigns to the words a human actually wrote. Maximizing that is the same as minimizing this loss:

loss  =  - (1/|y|) · sum over t of  log p( y_t | y_<t, x, L_tgt )

Read the expression symbol by symbol:

SymbolMeaning
y_tThe correct target word at position t — what the human reference actually says there
y_<tEvery target word before position t, which the model is allowed to condition on
xThe whole source sentence
L_tgtThe target-language tag
|y|The length of the target sentence, so the sum is an average per token rather than a total that grows with length
The minus signProbabilities are at most 1, so their logs are at most 0. Negating turns “higher is better” into “lower is better”, which is what a loss has to be

So: for every position in the target sentence, the model is scored on the probability it put on the correct next word. A model that puts 0.9 on the right word contributes -log(0.9) = 0.105; one that puts 0.1 on it contributes -log(0.1) = 2.303. Confidently wrong is expensive; confidently right is nearly free.

Two consequences fall straight out of that expression, and both matter later.

The loss is only over target tokens. The sum runs over t in y, never over x. Nothing in the objective penalizes ignoring the source. The decoder — the half of the model that emits the output one word at a time — is a language model that has been offered a conditioning signal from the encoder, the half that reads the source. When that signal is uninformative — an out-of-domain source, a garbled source, a language the encoder barely represents — the highest-likelihood continuation is whatever the decoder’s language model prefers on its own. That is not a bug to patch; it is the objective doing exactly what it says. Every hallucination failure mode in this chapter is a consequence of the fact that the loss lives on the target side.

Decoding is a separate decision from training. Training maximizes the likelihood of one token at a time; serving has to search for a high-scoring whole sequence. The default search is beam search: keep the k best partial translations at every step and extend all of them, rather than greedily committing to the single best next word. Scores are length-normalized so that short outputs do not win by default. Beam search introduces a failure the training objective never saw — degenerate repetition, derived in failure mode 4.

What interviewers probe: “Why not just train on more data?” Because for the pair you actually care about, more data does not exist. The interesting engineering is in what you do when the supervised signal is absent, which is what data and labels covers next.

Assumptions in this stage.


Data and labels

How much real training data exists? Very little — which is why the two techniques that manufacture the rest, mining translation pairs out of web crawls and back-translation, carry this part of the design.

The shape of the problem

Count the supply of real parallel data against the demand, and the gap between the two is the reason everything after it exists.

Start with the demand. Every language can be a source and every other language can be a target, so 100 languages give

100 languages × 99 possible targets each  =  9,900 directed pairs

Now the supply. Almost all published parallel data has English on one side, because that is where the translation industry has always pointed. So count the English-centric pairs — pairs with English on one side:

99 non-English languages, English -> that language   =   99
99 non-English languages, that language -> English   =   99
                                                        ----
                                                         198 directed pairs

That is the whole supply, and it is 2% of the demand. Public parallel corpora cover perhaps 100 of those 198 at useful volume, so the realistic picture is:

BucketLanguagesDirected pairsReal parallel dataStrategy
High-resource, English-centric15~3010M - 2B pairsSupervised, plus filtering
Mid-resource, English-centric35~70100k - 10MSupervised + back-translation
Low-resource, English-centric49~98under 100kMined bitext + back-translation, heavily
Non-English-centric9,702approximately zeroZero-shot from the multilingual model, plus synthetic

The row to look at is the last one. 9,702 / 9,900 = 98% of the directed pairs have essentially no real training data at all — that row is where the design lives. Counting it the other way: roughly 100 directed pairs have a usable corpus, so 9,800 do not.

Two terms in that table are used from here on. Zero-shot means translating a direction the model was never trained on. Mined bitext means translation pairs discovered inside a web crawl rather than published as a corpus.

Pipeline

The whole data factory fits in one diagram, and the two derivations that follow — mining and back-translation — each own a leg of it.

The two legs join at the bottom: the mining leg runs down the left, the back-translation leg down the right, and the mixture box is where they meet. Here is what each box does.

The mining leg — turning a web crawl into training pairs.

  1. Web crawl. Raw documents, grouped by the language the crawler thinks they are in.
  2. Sentence split, plus language ID on both sides. Language ID is a small classifier that guesses which language a piece of text is in. You re-run it per sentence because a crawl labelled “Icelandic” is full of English.
  3. Embed every sentence. An embedding is a list of a few hundred numbers, positioned so that sentences with similar meanings land near each other. That is what lets a machine ask “are these two sentences about the same thing?” as a distance question instead of a linguistics question. Put to work in mining bitext below.
  4. ANN index. Approximate nearest neighbour: a data structure that finds the closest vectors to a query without comparing against all of them. That approximation is what makes the search affordable at billions of sentences.
  5. Nearest-neighbour mining. Query one language’s index with the other language’s sentences, scoring candidates with the margin criterion derived below.
  6. Quality filters. A bank of reject rules that throws away 60 to 80% of the candidate pairs — discarding them, not downweighting them. What survives is a mined parallel corpus.

The back-translation leg — manufacturing pairs out of thin air. Separately, monolingual target text is fed backwards through a reverse translation model using sampled decoding. The result is synthetic pairs whose source side is noisy machine output and whose target side is real human text. That asymmetry is the subject of back-translation, derived, and it is the single most valuable idea in this section.

The mixture — deciding how often each pair appears. Mined and synthetic pairs are combined by temperature-sampled mixing: a pair p is drawn in proportion to D_p^(1/T), where D_p is how many pairs that language direction has and T is a temperature knob that flattens the mixture. T = 1 samples in natural proportion to corpus size; larger T pulls everything toward uniform. Both symbols are worked out with real numbers in the data mix.

That mixture trains the forward source-to-target model. The dotted arrow at the bottom is the loop: a trained forward model seeds a better reverse model, which produces better back-translations for round two.

flowchart TD
    CR["Web crawl<br/>· documents per language"] --> SEG["Sentence split<br/>+ language ID both sides"]
    SEG --> EMB["Multilingual sentence encoder<br/>embed every sentence"]
    EMB --> IDX[("ANN index<br/>per language")]
    IDX --> MINE["Nearest-neighbour mining<br/>margin criterion"]
    MINE --> FILT{"Quality filters<br/>langid · length ratio<br/>near-dup · MT-detector<br/>entity + numeral agreement"}
    FILT -->|"reject 60-80%"| DROP["Discard"]
    FILT -->|keep| PAR[("Mined parallel<br/>corpus")]
    MONO[("Monolingual<br/>target text")] --> BT["Reverse model T->S<br/>sampled decoding"]
    BT --> SYN[("Synthetic pairs<br/>noisy source · REAL target")]
    PAR --> MIX["Temperature-sampled mix<br/>q_p proportional to D_p^(1/T)"]
    SYN --> MIX
    MIX --> TRAIN["Train S->T"]
    TRAIN -.->|"round 2 seeds a better<br/>reverse model"| BT

    style PAR fill:#1d3557,color:#fff
    style FILT fill:#9d0208,color:#fff
    style BT fill:#2d6a4f,color:#fff
    style SYN fill:#2d6a4f,color:#fff
    style MIX fill:#bc6c25,color:#fff

The colour key, republished

Every diagram in this repository uses one key, and it is repeated here so this chapter stands alone. It is the key published in The ladder:

ColourWhat it marks
Blue #1d3557The authoritative copy of the data
Green #2d6a4fRead capacity: anything that answers a read without asking the authoritative copy
Light green #40916cAnything that takes work off the request path without answering a read
Orange #bc6c25Forced by something other than processor time
Red #9d0208The one rung you cannot undo
Grey #495057The plane that watches everything and serves nothing

Read against that key, the three diagrams in this chapter say: mined parallel corpus is blue because it is the authoritative training data everything else exists to manufacture; the filter bank is red because it discards 60-80% of candidates rather than downweighting them, and a pair thrown away at ingest is not recoverable later; the two back-translation boxes are green because they answer the demand for training pairs without going to the authoritative corpus, which for 9,800 directions does not exist; and the temperature mix is orange because the ratio it enforces is forced by data imbalance, not by compute.

Mining bitext, and why raw cosine fails

The scoring rule that turns a nearest-neighbour search into a usable bitext miner is not the obvious one, and the obvious one fails for a reason you can name.

Every sentence in both crawls is turned into an embedding, and a multilingual encoder is one trained so that a sentence and its translation land in nearly the same place. Index one language’s embeddings, query with the other’s, and take the closest match. Closeness is measured by cosine similarity: the cosine of the angle between two vectors, which is 1.0 when they point the same way and 0 when they are unrelated. Ranking by raw cosine similarity does not work, and the reason is structural.

Some sentences are hubs: "Yes.", "Click here to continue.", "Copyright 2019." carry so little content that they sit near the centroid of the embedding space — the average position of all the vectors — and are therefore near everything. Rank by raw cosine and your top matches are dominated by hubs paired with unrelated hubs, in every language.

The fix is to normalize by local density — the margin criterion:

margin(x, y)  =  cos(x, y)  /  mean over the k nearest neighbours of x and of y of cos(., .)

  a genuine translation pair:  cos = 0.82,  local mean = 0.51  ->  margin 1.61
  a hub paired with a hub:     cos = 0.79,  local mean = 0.77  ->  margin 1.03

The margin asks “is this pair unusually close for these two sentences”, which is the question raw cosine cannot ask. Threshold at roughly 1.06 and you trade recall for precision. Recall is the fraction of the genuine translation pairs in the crawl that you actually keep; precision is the fraction of the pairs you kept that are genuine. Raising the threshold keeps fewer pairs and makes the kept ones cleaner. That is the correct direction here, for the reason derived next.

Filtering: why precision beats recall, in arithmetic

A small misalignment rate is not a small problem, and the number that proves it is what justifies a filter bank that throws away most of what it sees.

A misaligned pair is not a neutral zero-information example. It is an explicit gradient step teaching the model “produce fluent target text that is only loosely related to this source.” That is the hallucination failure mode, taught deliberately.

corpus                 500M pairs
misalignment rate      2%
                       ------------
                       10M training examples whose lesson is "invent content"

Compare that 10M against the supply table above: a mid-resource pair has 100k to 10M real pairs, and a low-resource pair has under 100k. Ten million bad examples is more supervised signal than most low-resource pairs have in total. A 2% misalignment rate is not a 2% quality tax; it is a dedicated hallucination curriculum larger than your entire Icelandic corpus.

That is why the filter bank is precision-first. The code below is the ingest filter: seven checks, each of which is a reject rule returning a code, followed by assertions that fire each rule on its own input. Read keep_pair first and the test harness second — the harness exists to show that each filter catches exactly the thing it claims to catch.

"""Bitext quality filters. Precision-first: every filter is a reject rule."""
import re

MIN_MARGIN = 1.06
LEN_RATIO = (0.5, 2.0)      # target chars / source chars, per-pair calibrated

# Ingest-side numeral check. It is deliberately weaker than the serving-side
# guard in failure mode 3 below: at 500M pairs you can afford one regex per
# side and nothing more, and a false reject at ingest costs one training pair
# while a false reject at serve time costs a user a translation.
DECIMAL_COMMA_LOCALES = {"de", "fr", "es", "it", "pt", "nl", "pl", "ru", "tr", "is"}
NUM = re.compile(r"\d+(?:[.,]\d+)*")     # separators between digit runs only


def numeral_values(text: str, locale: str) -> list:
    out = []
    for m in NUM.finditer(text):
        raw = m.group()
        raw = (raw.replace(".", "").replace(",", ".")
               if locale in DECIMAL_COMMA_LOCALES else raw.replace(",", ""))
        out.append(raw.rstrip(".") or "0")
    return sorted(out)


def keep_pair(src, tgt, pair_cfg, models) -> tuple[bool, str]:
    if models.langid(src) != pair_cfg.src_lang:
        return False, "F01 source language mismatch"
    if models.langid(tgt) != pair_cfg.tgt_lang:
        return False, "F02 target language mismatch"

    ratio = len(tgt) / max(len(src), 1)
    lo, hi = pair_cfg.len_ratio or LEN_RATIO
    if not lo <= ratio <= hi:
        return False, "F03 length ratio outside calibrated band"

    if models.margin(src, tgt) < MIN_MARGIN:
        return False, "F04 alignment margin below threshold"

    # Numerals and entities must survive translation. A pair where they do not
    # is either misaligned or is itself teaching entity mangling.
    if (numeral_values(src, pair_cfg.src_lang)
            != numeral_values(tgt, pair_cfg.tgt_lang)):
        return False, "F05 numeral set differs across sides"

    # Training on the web means training on other systems' output. Left in,
    # it entrenches our own past errors as ground truth.
    if models.mt_detector(tgt) > 0.85:
        return False, "F06 target side is probably machine-translated"

    if models.minhash_seen(src, tgt):
        return False, "F07 near-duplicate of an existing pair"

    return True, ""


# --- Every filter is a REJECT rule, so the test is that each one fires on its
# --- own input and that a clean pair survives all seven.
class _Cfg:
    src_lang, tgt_lang, len_ratio = "en", "de", None


class _Models:
    """Defaults say 'clean'; each case overrides exactly one signal. langid is
    a lookup, so no test depends on a real classifier's opinion."""

    LANG = {"Ship 12 units by Friday.": "en",
            "12 Einheiten bis Freitag liefern.": "de",
            "13 Einheiten bis Freitag liefern.": "de",
            "Bonjour tout le monde.": "fr",
            "Hello world again.": "en",
            "Ja.": "de"}

    def __init__(self, margin=1.40, mt=0.10, dup=False):
        self._margin, self._mt, self._dup = margin, mt, dup

    def langid(self, t):
        return self.LANG[t]

    def margin(self, s, t):
        return self._margin

    def mt_detector(self, t):
        return self._mt

    def minhash_seen(self, s, t):
        return self._dup


SRC = "Ship 12 units by Friday."
TGT = "12 Einheiten bis Freitag liefern."

assert keep_pair(SRC, TGT, _Cfg(), _Models()) == (True, "")
assert keep_pair("Bonjour tout le monde.", TGT, _Cfg(),
                 _Models())[1].startswith("F01")
assert keep_pair(SRC, "Hello world again.", _Cfg(),
                 _Models())[1].startswith("F02")
assert keep_pair(SRC, "Ja.", _Cfg(),
                 _Models())[1].startswith("F03")   # 3 chars against 24 -> 0.125
assert keep_pair(SRC, TGT, _Cfg(),
                 _Models(margin=1.03))[1].startswith("F04")   # hub paired with hub
assert keep_pair(SRC, "13 Einheiten bis Freitag liefern.", _Cfg(),
                 _Models())[1].startswith("F05")
assert keep_pair(SRC, TGT, _Cfg(), _Models(mt=0.91))[1].startswith("F06")
assert keep_pair(SRC, TGT, _Cfg(), _Models(dup=True))[1].startswith("F07")
# And the reason F05 is a reject rather than a downweight: 1.234,5 (de) and
# 1,234.5 (en) are the SAME number, so the filter must not fire on notation.
assert numeral_values("Der Preis ist 1.234,5 EUR.", "de") == \
       numeral_values("The price is 1,234.5 EUR.", "en")
# A separator may join digit runs, never span a list comma or a space. A class
# that spans them reads "1, 23" as the single number 123 and "12, 3" as the
# same one, so a mangled pair survives ingest looking clean.
assert numeral_values("Rooms 1, 23 are open.", "en") == ["1", "23"]
assert numeral_values("Salles 12, 3 sont ouvertes.", "fr") == ["12", "3"]
print("bitext filters: clean pair kept, F01-F07 each fire on their own input")

Every filter in that function is a reject rule, and each returns a code so that a rejection can be counted and audited rather than merely happening. Two of them deserve narration in an interview. F06 uses a classifier that guesses whether a piece of text was produced by a translation system rather than written by a person. It exists because the web now contains an enormous amount of machine-translated text; training on it is a feedback loop that converts your own past errors into ground truth and drags every language toward translationese — prose that is grammatical but reads as though it came out of a translator, with the source language’s word order and idiom showing through. F07 rejects near-duplicates using MinHash, a technique that produces a short fingerprint of a document such that similar documents get similar fingerprints, so you can find near-copies without comparing every pair. It matters because web crawls are pathologically duplicated — boilerplate, syndicated news, mirrored documentation — and duplicated pairs get memorized rather than learned, which is how a model comes to emit a Bible verse when given noise.

Back-translation, derived

The single most valuable data trick in the chapter comes down to one sentence about where the gradient goes.

You have 2M sentence pairs for English-Icelandic and 800M sentences of monolingual Icelandic. The monolingual data is 400x larger and appears useless because it has no source side.

Manufacture one. Train a reverse model that translates Icelandic into English on whatever real data exists, run it over the monolingual Icelandic to produce synthetic English, and then train the forward English-to-Icelandic model on those (synthetic English, real Icelandic) pairs.

Why this works is an asymmetry in where the loss lives. The gradient is the signal that tells each weight in the model which way to move to reduce the loss, and it can only flow back from terms that appear in the loss.

loss  =  - sum over t of  log p( y_t | y_<t, x )
                              ^^^^^^^^^^^^^^^^^
                              gradient flows through the TARGET tokens

The synthetic side is x. Errors in x are input noise — they train the encoder to cope with imperfect input, which acts as a regularizer, meaning something that stops the model latching onto accidental details of the training set and so makes it generalize better. The target side y is real human Icelandic, so the decoder’s implicit language model — the component most starved by a small parallel corpus — is trained on genuine, fluent text of the kind it will actually be asked to produce.

Now reverse it. Forward-translation — translating monolingual English through your own model to get synthetic Icelandic — puts machine output on the target side, where the gradient is. That trains the model to imitate its own output distribution: self-distillation of its own biases, its own translationese, its own systematic errors. It is not merely weaker; it actively entrenches the errors you are trying to remove.

Back-translation puts the noise where the gradient is not. Forward-translation puts the noise exactly where the gradient is. That single sentence is the whole mechanism.

Three practical points that separate people who have run this from people who have read about it:

  1. Decode the back-translations with sampling, not beam search. Sampling means drawing each next word from the model’s probability distribution; beam search means searching for the single highest-scoring sentence. Beam output is the mode of the distribution — the most likely sentence — which is clean, low in entropy (a measure of how much variety a distribution contains) and far less varied than real source text. Train on it and the model fits a narrow synthetic-input distribution and then degrades on real input. Sampled or noised-beam output has realistic entropy. This is worth several chrF points and is the most commonly skipped step.
  2. Cap the synthetic ratio. Past roughly 3:1 synthetic-to-real, the target distribution starts to be dominated by whatever monolingual domain you crawled, and the model drifts.
  3. Iterate, but stop at three rounds. A better forward model gives a better reverse model, which gives better back-translations. Round 2 is worth about a third of round 1; round 3 about a third of round 2.

The table below measures the effect on an English-to-Icelandic development set. Three columns, glossed once and used throughout the chapter: chrF (character n-gram F-score) counts how many short character sequences a candidate shares with the reference and is derived in the metric ladder; COMET (crosslingual optimized metric for evaluation of translation) is a learned scorer, a model trained on human quality judgements that predicts what a human would have said, on a 0-to-1 scale; hallucination rate is the fraction of outputs that assert something the source did not say.

Training mixchrFCOMETHallucination rate
2M real pairs only41.20.6124.1%
+ 6M forward-translated41.80.6085.6%
+ 6M back-translated, beam-decoded47.00.6943.0%
+ 6M back-translated, sampled49.30.7212.2%
+ 20M back-translated, sampled (10:1)48.10.7032.6%

Row 2 is the row to point at: forward-translation raised a surface metric slightly and made hallucination worse, which is exactly what the asymmetry argument predicts.

The data mix: temperature sampling, with the number

How often should each language pair appear in a training batch, when one pair has a thousand times more data than another? The answer is a single exponent.

A batch is the group of examples the model sees together in one training step. If batches are drawn in proportion to corpus size, a small pair essentially never appears.

The fix is to flatten the distribution: sample pair p in proportion to D_p^(1/T), where D_p is its corpus size and T is a temperature knob. Raising a number to the power 1/T compresses differences — the bigger T is, the more it compresses. T = 1 is the exponent 1, which leaves the natural proportions alone. T heading to infinity drives the exponent to 0, and anything to the power 0 is 1, which is a uniform mixture.

Work it on the two extremes of the actual corpus:

en-fr:  2,000,000,000 pairs
en-is:      2,000,000 pairs        ratio 2e9 / 2e6  =  1000 : 1

T = 1  (natural)     exponent 1     ratio stays 1000 : 1
                                    ->  en-is is 0.1% of batches; never learned
T = inf (uniform)    exponent 0     ratio 1 : 1
                                    ->  en-is repeats ~1000 epochs; memorized
T = 5                exponent 1/5   ratio = 1000^(1/5)
                                          = (10^3)^(1/5)
                                          = 10^(3/5)
                                          = 10^0.6
                                          = 3.98 : 1

A fifth root turns three orders of magnitude into a factor of four. That is the entire trick, and 3.98 is the number to have at hand.

The cost is the mirror image, and it is worth doing the division. An epoch is one full pass over the training data. English-French has 1000x the sentences but is only sampled 3.98x as often, so each individual English-Icelandic sentence gets shown

1000 / 3.98  =  251 times more often per epoch

than each individual English-French sentence. At roughly 250 repetitions a sentence is at real risk of being memorized rather than learned. That is why F07 (near-duplicate rejection) and a per-pair cap on how many times a pair may be repeated are not optional hygiene — they are what makes the temperature safe.

Assumptions in this stage.


Model choice: one multilingual model, and why

The obvious architecture — one specialist model per language pair — dies on a serving-cost argument rather than a quality argument, and the single shared model that replaces it gives something up in exchange.

The pairwise fleet dies on residency, not on training

Price the specialist-per-pair fleet and it fails on a cost that traffic never pays down.

The instinct is N × (N-1) specialist models, one per direction. Most candidates then argue about training cost. Price the serving side instead, because that is where it actually fails.

The key idea is weight residency: a model can only answer a request if its weights are sitting in GPU memory at that moment, and you rent that memory by the hour whether or not a request arrives.

Weights are stored in fp16, a 16-bit floating-point number format, so each parameter costs 2 bytes. Each specialist is 0.4B parameters:

9,900 directed pairs × 0.4B params × 2 bytes (fp16)  =  7,920 GB of weights
GPU usable memory                                     =  70 GB
                                                        ---------------------
7,920 / 70                                            =  114 GPUs to hold the
                                                         weights, before serving
                                                         one single request

Now overlay the traffic on those idle weights. Average traffic is 13,889 QPS (derived in scale and cost). The top 50 pairs take 80% of it, so the remaining 9,850 pairs share the other 20%:

13,889 × 0.20                =  2,778 QPS for the whole tail
2,778 QPS  /  9,850 pairs    =  0.28 QPS per tail pair

one GPU can serve 620 QPS of this model, and it is serving 0.28
0.28 / 620                   =  0.00045  =  0.045% utilization

0.045% utilization is the number to say out loud. You are renting a chip and using one part in 2,200 of it.

The obvious escape is to load a model only when a request for it arrives, and it does not work either. Each model is 0.8 GB (0.4B params × 2 bytes). Reading that from NVMe — the fast solid-state storage attached to the server — at 3 GB/s takes

0.8 GB / 3 GB/s  =  0.267 s  =  267 ms, call it 270 ms

of cold-start latency, meaning the delay before a model that was not already resident can answer at all, added to the first request of every rare pair. And for a rare pair at 0.28 QPS, requests almost never arrive back to back, so “the first request” is essentially every request. That 270 ms alone nearly consumes the 300 ms p95 budget before the model has produced a single token.

A pairwise fleet’s cost is set by weight residency, and residency does not amortize over traffic. One multilingual model is O(1) in the number of pairs — meaning its cost does not grow as pairs are added. One set of weights serves all 9,900 directions, and every GPU can serve every request, so the scheduler that groups concurrent requests into a batch can pack freely instead of only combining requests that happen to want the same pair.

Zero-shot transfer, and its specific failure

How does a model translate a direction it was never trained on — and what is the specific way that capability breaks?

A model trained only on English-to-everything and everything-to-English can translate Icelandic into Swahili without ever having seen that pair — the zero-shot case. The mechanism is that the encoder maps source text into a representation that is largely language-agnostic, so Icelandic and English sentences with the same meaning land in nearly the same place, and the target-language tag then tells the decoder which language to write in. Quality sits well below a supervised model and well above nothing — and nothing is the honest alternative for 9,800 directions.

The characteristic failure is off-target translation: asked for Icelandic to Swahili, the model emits English, because English was the dominant target during training and the tag is a single token competing against that overwhelming prior. Rates of 20-50% on some directions are normal for a naively trained model. Four fixes, cheapest first. Put the target-language tag as the first token the decoder emits rather than as a prefix on the source, so that the decoder cannot learn to attend away from it — free. Add a wrong-language penalty from a cheap language-ID classifier inside the beam search, which costs about 2% of decode time. Reject and re-decode at serve time when a classifier says the output language disagrees with what was requested, which costs one extra decode on 1-3% of tail traffic. And add non-English-centric synthetic data by back-translating one non-English language into another through the model itself, which creates the missing pair at the cost of compounding the model’s own errors.

The curse of multilinguality, in parameters

“Adding languages hurts” is a slogan until you do the division — and the division is what lets you answer the follow-up question about adding thirty more.

Adding languages to a fixed parameter budget helps low-resource languages through transfer — a language borrowing structure from its relatives in the same model — up to a point, and then hurts everything. This is called the curse of multilinguality, and division makes the shape obvious.

The idea is that the model’s parameters are a fixed pot. Some of them are shared across all languages, and some are effectively spent on one language’s own quirks. Add languages and that second pot gets divided more ways.

model                      400M params
shared subword embedding   262M           (derived in the tokenizer section)
transformer body           400M - 262M  =  138M
say half the body is effectively language-private:   138M / 2  =  69M

  L = 25 languages   ->  69M / 25  = 2.76M private params per language
  L = 100 languages  ->  69M / 100 = 0.69M private params per language
                         --------------------------------------------
                         2.76 / 0.69 = 4x cut in per-language capacity

The “half the body is language-private” split is a modelling convenience, not a measurement. Move it to a third or two-thirds and both numbers move together; the 4x ratio between them does not move at all, because it is just 100 / 25.

The 262M figure on the second line is derived in the vocabulary is most of the model; it is the size of the lookup table that turns tokens into vectors, and it is not available for learning anything language-specific.

Transfer partially offsets it: a language with close relatives in the set borrows their structure and can come out ahead. A language isolate — one with no known relatives, such as Basque — borrows nothing and takes the full 4x loss.

So “should we add 30 more languages?” has exactly one honest answer: yes, if you also scale parameters; otherwise you are paying for them with the low-resource languages you already have. The number of languages L* at which adding one more starts to hurt moves higher as the model grows, which is why this is a capacity question and not a linguistics question.

There are two ways to buy per-language capacity without buying more arithmetic per token, where the arithmetic is counted in FLOPs, floating-point operations:

Assumptions in this stage.


Tokenization: the decision that sets cost and quality for your worst languages

Most candidates skip tokenization entirely, yet it is where the fairness, cost and latency of your hundred languages are jointly decided — by a choice someone made before training started.

The design is one shared subword vocabulary across all 100 languages. A subword vocabulary is a fixed list of text fragments — whole common words, prefixes, suffixes, single characters — and tokenizing means greedily covering an input with pieces from that list. It is fit with a unigram language model, an algorithm that searches for the set of pieces that explains a training corpus with the fewest pieces on average, run over a temperature-sampled corpus so that no single language dominates. See Tokens for the full mechanism; what follows is what changes at 100 languages.

Why shared, not per-language:

  1. Transfer flows through shared tokens. Cognates, shared scripts, digits, punctuation, and named entities occupy the same embedding rows across languages. With disjoint vocabularies, Information in English and Information in German would be two unrelated rows and nothing would transfer between them.
  2. Zero-shot depends on it. The shared representation the encoder builds is anchored by tokens that appear in more than one language. Remove the anchors and the directions you never trained on have nothing to stand on.
  3. One embedding matrix instead of 100. Which is also the problem.

The vocabulary is most of the model

Multiply two numbers most people never multiply, and vocabulary size turns into a serving-memory decision.

The first thing a model does with a token is look it up in an embedding matrix: one row of numbers per vocabulary entry, d_model numbers wide, where d_model is the width of every internal representation in the model. So the table has as many rows as the vocabulary has entries and as many columns as the model is wide, and its parameter count is just the product.

A model normally has two such tables — one to turn input tokens into vectors, and one to turn the final vector back into a score for every vocabulary entry. Tying them means using the same matrix for both, which halves the count.

Multiply the two numbers:

vocab 256,000  ×  d_model 1,024   =  262,144,000 params  =  262M

untied (separate input and output matrices):  2 × 262M  =  524M params
                                              -> larger than the entire 400M model
tied (one matrix used twice):                 262M of a 400M model
                                              262 / 400  =  66%

Two-thirds of a 400M multilingual model is a lookup table. That is the number most people never compute.

In a small multilingual model the vocabulary is not a preprocessing detail; it is the majority of the parameters. Three consequences you should volunteer:

  1. Tie the input and output embeddings. At this size that is mandatory, not optional — untied costs you 524M parameters for a 400M model, which is incoherent.
  2. Treat vocabulary size as a serving-memory decision, reviewed with the same seriousness as the number of layers.
  3. Consider a factorized embedding. Instead of one wide table, keep a narrow one and multiply it back up:
one wide table:   256,000 × 1,024                 =  262.1M params

factorized:       256,000 × 128     =  32.77M
                      128 × 1,024   =   0.13M
                                       -------
                                        32.90M params

262.1 / 32.90  =  8x fewer parameters

The cost is that every token’s identity now has to squeeze through 128 numbers instead of 1,024 — a bottleneck worth about 0.8 chrF on high-resource pairs.

Script imbalance, and the compounding it causes

A single tokenizer decision sets cost, latency and quality for a language simultaneously, all in the same direction — which is why it is a fairness question rather than a preprocessing question.

The vocabulary is fit on a corpus, and the algorithm spends its fixed budget of pieces wherever the text is. If that corpus is 40% English, the budget goes on English word fragments. A language at 0.05% of the corpus gets almost no dedicated pieces and falls back toward individual characters or raw bytes.

The measurement that exposes this is fertility: the average number of tokens the tokenizer produces per word of a language. Fertility 1.0 means one token per word; fertility 5.2 means the model has to emit five pieces to write one word.

In the table below, read down the corpus-share column and then down the fertility column — they move in opposite directions, and that is the whole finding. The last column is just fertility divided by English’s 1.15.

LanguageScriptCorpus shareFertilityRelative to English
EnglishLatin40%1.151.0x
GermanLatin6%1.611.4x
TurkishLatin, agglutinative1.2%2.352.0x
HindiDevanagari0.9%2.412.1x
AmharicGe’ez0.05%5.204.5x

Two of those script labels are worth a word. Turkish is agglutinative, meaning it builds long words by gluing suffixes onto a stem, so a whole English clause can be one Turkish word and a tokenizer without dedicated pieces for those suffixes shatters it. Ge’ez is the script Amharic is written in, and its several hundred distinct characters are essentially absent from a corpus that is 40% English.

Three consequences follow, and the point is that they compound rather than trade off. Each one is worked out below on Amharic’s 4.5x.

1. Cost. The same sentence costs 4.5x more tokens in Amharic. Your user-facing price is per character, so the user never sees this; your GPU bill sees nothing else.

2. Latency. Decode is one sequential step per token (The kv cache the most important mechanism in this chapter), so 4.5x the tokens is 4.5x the decode — and only the decode. Everything else in the request is fixed cost. Take the Lane B line items from the latency budget below:

Lane B total                    140 ms
  of which decode                72 ms   <- scales with token count
  everything else                68 ms   <- network, normalize, queue,
                                            prefill, guards: does not move

Amharic:   68 + (4.5 × 72)  =  68 + 324  =  392 ms
                392 / 140   =  2.8x the English request

392 ms against a 300 ms p95 target. The budget is blown by a tokenizer decision, on a request the model handles no differently.

3. Quality. A 60-word sentence becomes 60 × 5.2 = 312 tokens in Amharic against 60 × 1.15 = 69 in English. The cost of attention — the mechanism by which every token in a sentence looks at every other token — grows with the square of the sequence length (Attention and why context costs what it does):

(312 / 69)^2  =  4.52^2  =  20x the attention compute of the English equivalent

And on top of the compute, the model must compose meaning out of character-level fragments it has seen in far fewer contexts than it has seen -ing.

The languages that get the worst quality also get the worst latency and the worst cost, and all three come from one decision made when someone fit the tokenizer. That is the sentence to say out loud, because it reframes tokenization from a preprocessing step into a fairness and capacity decision.

Three fixes follow from the mechanism. Temperature-sample the vocabulary-fitting corpus with the same T you use for training, which most teams forget — they sample the training mix and fit the vocabulary on the raw one. Floor each language’s allocation at some minimum number of pieces, so no script can be squeezed out entirely. And track fertility per language as a first-class dashboard metric, so that a vocabulary change cannot silently regress Amharic while improving the average.

Assumptions in this stage.


Training

Five training stages run in order, and one wrinkle among them is what an interviewer will push on.

The table is a pipeline: each stage takes the model the previous stage produced. Stage 1 is where almost all the compute goes; stages 3 to 5 are cheap and are where most of the shipped quality is decided.

StageDataWhy
1. Pretrain the multilingual bodyTemperature-sampled mix, 500M-2B filtered pairsLearn the shared representation. Most of the compute
2. Iterative back-translation, 2-3 roundsMonolingual target text per low-resource languageEach round re-mines synthetic data from a better model
3. Fine-tune on clean in-domain data1-5M human-verified pairsThe mined corpus is noisy by construction; a clean final phase is worth more than its size suggests
4. Distil the throughput modelTeacher’s decoded output on the real source distribution8B teacher -> 0.4B student, sequence-level
5. Quantize and calibrateHeld-out set per pairint8 weights; re-run the per-pair regression gate

Three stage names need unpacking. Distillation (stage 4) trains a small “student” model to imitate a large “teacher” model, so that the student inherits behaviour it was too small to learn on its own. Quantization (stage 5) stores the weights in a smaller number format — here int8, 8-bit integers instead of 16-bit floats — which halves the bytes that must be read per decode step and therefore roughly doubles decode speed, at some cost in accuracy. A held-out set is data deliberately kept out of training so that measuring on it is not measuring memorization.

Stage 4 has a wrinkle worth naming. Sequence-level distillation means training the student on the whole sentence the teacher actually produced, rather than on the teacher’s logits — the raw scores it assigned to every possible next token before those scores were turned into probabilities. It is the back-translation asymmetry read in reverse: here the machine output lands on the target side, where the gradient is, so the student learns the teacher’s mode. That mode is cleaner and less varied than real data, and therefore easier for 0.4B parameters to fit than the real data’s full diversity. The student beats a 0.4B model trained directly on real data. It also inherits the teacher’s hallucinations, so stage 5’s gate must test for those and not just chrF.

Assumptions in this stage.


Offline metrics

The launch decision runs on a ladder of measurements, and building it starts with dismantling the metric everyone quotes — the part interviewers push hardest on.

BLEU, and exactly what it cannot see

On one worked pair of sentences, BLEU ranks a factual error above a correct paraphrase — and the reason is arithmetic about window positions, not anything to do with meaning.

BLEU (bilingual evaluation understudy) compares a candidate translation against a human reference by counting shared n-grams, an n-gram being a run of n consecutive tokens. It is the geometric mean of four precisions — one each for runs of length 1, 2, 3 and 4 — with a length correction:

BLEU  =  BP · exp( sum over n=1..4 of (1/4) · log p_n )

p_n   =  clipped n-gram precision
      =  (candidate n-grams appearing in the reference, clipped to the reference count)
         / (total candidate n-grams)

BP    =  1                    if c > r
      =  exp(1 - r/c)         otherwise        c = candidate length, r = reference length

Read the pieces one at a time.

The brevity penalty exists only because precision alone is maximized by emitting a single high-confidence word and stopping. That patch tells you what BLEU fundamentally is: a string-overlap statistic with a length correction bolted on.

The demonstration, counted by hand

One reference and two candidates. Each candidate differs from the reference by exactly one word.

REFERENCE     The meeting was postponed until Thursday .
CANDIDATE A   The meeting was delayed   until Thursday .    meaning preserved
CANDIDATE B   The meeting was postponed until Tuesday  .    meaning destroyed

Both candidates are 7 tokens long, same as the reference, so c = r and BP = 1 for both. All the action is in the four precisions.

Count them for Candidate A (delayed replaces postponed at position 4). List the windows and mark which survive:

1-grams (7)  The ✓ meeting ✓ was ✓ delayed ✗ until ✓ Thursday ✓ . ✓        6/7
2-grams (6)  [The meeting]✓ [meeting was]✓ [was delayed]✗
             [delayed until]✗ [until Thursday]✓ [Thursday .]✓             4/6
3-grams (5)  [The meeting was]✓ [meeting was delayed]✗ [was delayed until]✗
             [delayed until Thursday]✗ [until Thursday .]✓                2/5
4-grams (4)  [The meeting was delayed]✗ [meeting was delayed until]✗
             [was delayed until Thursday]✗ [delayed until Thursday .]✗    0/4

Every 4-gram window contains position 4, so p_4 = 0. Now the geometric mean:

BLEU-A  =  ( 6/7 × 4/6 × 2/5 × 0/4 ) ^ (1/4)
        =  ( 0.857 × 0.667 × 0.400 × 0.000 ) ^ (1/4)
        =  0 ^ (1/4)
        =  0.000

Now Candidate B (Tuesday replaces Thursday at position 6):

1-grams (7)  The ✓ meeting ✓ was ✓ postponed ✓ until ✓ Tuesday ✗ . ✓       6/7
2-grams (6)  [The meeting]✓ [meeting was]✓ [was postponed]✓
             [postponed until]✓ [until Tuesday]✗ [Tuesday .]✗             4/6
3-grams (5)  [The meeting was]✓ [meeting was postponed]✓
             [was postponed until]✓ [postponed until Tuesday]✗
             [until Tuesday .]✗                                           3/5
4-grams (4)  [The meeting was postponed]✓ [meeting was postponed until]✓
             [was postponed until Tuesday]✗ [postponed until Tuesday .]✗  2/4

Two 4-gram windows survive because they sit entirely to the left of position 6. So:

BLEU-B  =  ( 6/7 × 4/6 × 3/5 × 2/4 ) ^ (1/4)
        =  ( 0.857143 × 0.666667 × 0.600000 × 0.500000 ) ^ (1/4)
        =  ( 0.171429 ) ^ (1/4)
        =  0.6435

Side by side:

1-gram2-gram3-gram4-gramProductBLEU-4
Candidate A — meaning preserved6/74/62/50/40.0000000.000
Candidate B — meaning destroyed6/74/63/52/40.1714290.643

BLEU ranks the factual inversion above the correct paraphrase, and the margin is the whole scale.

The mechanism is positional, not semantic. delayed sits at token 4 of 7 — the exact centre, the one position in a 7-token sentence that every 4-gram window covers. Tuesday sits at token 6, near the end, where only two 4-grams reach it and The meeting was postponed and meeting was postponed until both survive intact. A substitution’s BLEU cost is set by where in the sentence it lands, and meaning has no representation in the statistic at all.

The same error at all seven positions

To prove that it is position and not meaning, replace one token with a wrong word at each of the seven positions in turn. Nothing else changes: same length, same single error, same semantic damage. The four precisions and the resulting BLEU:

PositionToken replaced4-grams brokenp_1p_2p_3p_4BLEU
1The1 of 46/75/64/53/40.809
2meeting2 of 46/74/63/52/40.643
3was3 of 46/74/62/51/40.489
4postponed4 of 46/74/62/50/40.000
5until3 of 46/74/62/51/40.489
6Thursday2 of 46/74/63/52/40.643
7.1 of 46/75/64/53/40.809

Note that p_1 is 6/7 in every row — the unigram precision cannot tell these seven cases apart at all. Everything that varies is the higher-order columns, and they vary only with position.

Why the “4-grams broken” column controls the score: a token at position i sits inside as many 4-gram windows as there are windows covering it — one at each end of the sentence, four in the centre. Break all four and p_4 = 0, and since BLEU is a geometric mean over p_1 through p_4, one zero factor takes the whole product to zero no matter how good the unigrams are. In a 7-token sentence, position 4 is the only place a single substitution can do that. The curve is a triangle centred on the sentence, and it is a property of window arithmetic, not of words.

That is a stronger indictment than “BLEU cannot tell them apart.” It can tell them apart, and it prefers the wrong one — a system tuned to maximize BLEU is being paid to move its errors toward the ends of sentences.

Candidate A’s 0/4 exposes a second problem: the geometric mean makes sentence-level BLEU zero whenever any order has no match, which is most single sentences. BLEU is a corpus-level statistic that people routinely misuse at the sentence level with an arbitrary smoothing hack — and here the smoothing scheme you pick decides whether the correct translation scores zero or something.

And two comparability failures that get stated wrongly in interviews all the time:

Not comparable across languages. p_n is computed over tokens of the target language. Morphologically rich languages pack meaning into fewer, longer words, so one wrong inflection destroys up to four n-grams at once and the achievable ceiling is structurally lower. Chinese and Japanese have no whitespace, so the score is a function of whichever segmenter you ran. A BLEU of 32 on en->de and 32 on en->fi are not the same quality, and a paper claiming “our Finnish score is lower because Finnish is hard” is describing the metric, not the system.

Not comparable across tokenizations. Identical model output, different detokenization — the step that reassembles tokens back into readable text — gives a different score, because punctuation splitting, casing and Unicode normalization all move it by several points. This is what sacreBLEU exists for: a standard implementation that fixes the tokenization and emits a signature string recording exactly which settings produced the number. A BLEU number without a sacreBLEU signature is not a measurement, it is a claim.

Not sensitive in the range that matters. Modern systems cluster tightly, and a 0.3 BLEU difference is inside the noise of which reference translator you hired. Any reported difference needs paired bootstrap resampling: repeatedly draw a random test set of the same size by sampling your sentences with replacement, score both systems on each draw, and see how often the winner changes. Most published differences do not survive it.

The metric ladder

BLEU dismantled, what remains is to arrange the measurements you can afford by how often you can run them — because the trustworthy ones are slow and the fast ones are shallow.

One word first, because it is used constantly from here on and never defined. An eval is a fixed measurement suite — a frozen test set, a scoring rule and a threshold — run against a candidate model on a schedule. It is not evaluation in general; the point of the word is that the set does not move, so two runs are comparable.

The ladder has three tiers, and the tier labels are the whole point: they say how often you can afford to run each one.

The cheap tier is marked per commit · seconds · deterministic, and it holds chrF2, described as chrF2 character n-gram F-score beta=2, recall-weighted — a character-level overlap score that leans toward recall and is defined properly below.

The middle tier is marked per release · minutes · GPU, and it holds the learned metrics: COMET / BLEURT learned regressor on human judgementsBLEURT being bilingual evaluation understudy with representations from transformers, which despite the name is a learned regressor rather than an n-gram count — plus COMET-QE reference-free DEPLOYABLE at serve time, the only one of them usable in production, because production has no reference translation to compare against.

The slow tier is marked pre-launch · days · money: MQM human eval error spans + severities, where professional annotators mark error spans and assign severities.

Cheap feeds middle feeds slow as the gates get stricter, the middle tier also branches to the deployable reference-free variant, and the dotted arrow back from MQM is labelled retrains / recalibrates, because human judgements are the data the learned metrics are fit on.

flowchart LR
    subgraph CHEAP["Per commit · seconds · deterministic"]
        CHRF["chrF2<br/>character n-gram F-score<br/>beta=2, recall-weighted"]
    end
    subgraph MID["Per release · minutes · GPU"]
        COMET["COMET / BLEURT<br/>learned regressor on<br/>human judgements"]
        QE["COMET-QE<br/>reference-free<br/>DEPLOYABLE at serve time"]
    end
    subgraph SLOW["Pre-launch · days · money"]
        MQM["MQM human eval<br/>error spans + severities"]
    end
    CHRF --> COMET --> MQM
    COMET --> QE
    MQM -.->|"retrains / recalibrates"| COMET

    style CHRF fill:#2d6a4f,color:#fff
    style QE fill:#bc6c25,color:#fff
    style MQM fill:#1d3557,color:#fff

Against the colour key: MQM is blue because human judgement is the authoritative copy of “quality” and every other box on the ladder is a cheaper approximation of it; chrF2 is green because it answers the quality question per commit without asking that authoritative copy; COMET-QE is orange because what forces it into the design is not its cost but the absence of a reference at serve time.

chrF2 is the F-score over character n-grams up to length 6. An F-score is the harmonic mean of precision and recall; beta = 2 weights recall twice as heavily as precision. Averaging over n = 1..6 and then combining, rather than taking a geometric mean, is why chrF has no zero cliff: one bad order cannot annihilate the score.

Why it beats BLEU as a default: a wrong Finnish inflection loses a suffix’s characters rather than an entire word plus four n-grams, so it degrades gracefully with morphology — and it needs no word tokenizer at all, which removes BLEU’s whole comparability-across-tokenizations problem.

Be honest about its limit. Run chrF2 on the same Thursday/Tuesday example, with the standard settings (character n-grams 1 through 6, beta = 2, whitespace stripped before counting):

CandidateChange from the referenceBLEU-4chrF2
A — meaning preservedpostponeddelayed0.0000.72
B — meaning destroyedThursdayTuesday0.6430.84

chrF2 compresses the spread that BLEU blew up — 0.72 against 0.84 instead of 0.00 against 0.64 — but it still ranks the factual inversion higher.

The reason is a different one from BLEU’s, and being precise about that is what separates a candidate who has read about these metrics from one who has run them. chrF has no positional term at all: it counts character n-grams as a bag. Thursday and Tuesday share T, u, s, day and the T...day frame; postponed and delayed share almost nothing but ed. The ranking is character overlap, full stop.

Two controls separate the two mechanisms, and they are the pair to quote:

ControlSubstitutionPositionCharacter overlapchrF2
Early substitution, high overlapmeetingmeetings2 of 7very high0.92
Late substitution, low overlapThursdayWednesday6 of 7low0.80

If chrF were positional, the late substitution would score higher — under BLEU’s window arithmetic, position 6 is far cheaper than position 2. It scores lower. So chrF is ranking on character overlap and ignoring position entirely.

So the two metrics agree on the wrong answer here by coincidence, not by a shared flaw — which means fixing one tells you nothing about the other. chrF fixes the morphology problem, not the meaning problem — no surface-overlap statistic can, because the information is not in the surface.

COMET and BLEURT are learned metrics. A multilingual encoder reads the source, the model’s output — the hypothesis — and the human reference, and a small regression head on top predicts the score a human would have given. They agree with human sentence-level judgement at roughly 0.4-0.6 Kendall tau — a rank-correlation measure running from -1 for perfectly reversed to +1 for perfectly matched ordering — against roughly 0.2 for BLEU, and they do separate Thursday from Tuesday. Four caveats to volunteer, because a candidate who presents COMET as the answer gets pushed on all four:

MQM — multidimensional quality metrics — is what quality actually means. Professional bilingual annotators mark error spans with categories (accuracy: mistranslation, omission, addition; fluency: grammar, register, terminology) and severities. The score is a negative weighted count of errors per 100 words. It beats a 5-point adequacy scale because it produces diagnostics: “our regression is 60% addition errors” is actionable, “quality fell 0.3” is not.

Price it, because the price is why it sits at the bottom of the ladder. Each line below feeds the next:

annotator            $40/hour, ~60 segments/hour with full MQM

eval round           500 segments × 12 pairs × 3 annotators     =  18,000 judgements
hours of work        18,000 judgements / 60 per hour            =     300 annotator-hours
money                300 h × $40/h                              =  $12,000
wall-clock           300 h / (10 annotators × 8 h/day)          =    3.75 days

Note that the “3 annotators” is redundancy per segment, not headcount: the panel that turns 300 hours into four days is ten people. Halve the panel and the same money takes eight days.

Monthly, launch-blocking, on a fixed set. Not per commit.

The gate that actually protects you

One rule stops a launch from destroying a language nobody on the team speaks.

Per-pair regression gates, not an aggregate. At 100 languages, a pair carrying 0.05% of traffic can go from good to garbage and move the aggregate chrF by 0.003 — well inside the noise of any measurement you would trust. The gate is therefore stated per direction: no directed pair may drop more than 1.0 chrF or 0.02 COMET against the current production model, measured on a fixed 500-segment set for that pair. An aggregate improvement does not buy an exemption.

Assumptions in this stage.


Online metrics and A/B

Live traffic offers a handful of behavioural signals — and, at 100 languages, a derivation showing that the launch decision for the languages you most need to protect cannot be an online experiment at all.

SignalWhat it measuresTrap
Copy / share rateUser took the output away and used itOnly exists on surfaces with a copy button
Re-translation rateUser resubmitted the same source, or toggled an alternativeStrong negative signal; the cleanest one you get
Edit rateFraction of outputs a user modifiedOnly on editor surfaces; the highest-quality signal by far
Language-swap rateUser overrode the detected source languageA language-ID failure, not a translation failure. Attribute it correctly or you will tune the wrong model
Downstream conversionOn product surfaces: translated reviews, listingsConfounded by everything

The power calculation, and why it argues against a global A/B

Size the experiment, and the size itself proves the tail cannot be launched online.

An A/B test splits live traffic between the current system and the new one and compares a metric. Sizing one needs three inputs:

Those first two get folded into a single constant. The standard sample-size formula for comparing two proportions is n ≈ 2·(z_alpha + z_beta)^2 · p(1-p) / delta^2, and at alpha 0.05 two-sided and power 0.8 the z values are 1.96 and 0.84, so

2 × (1.96 + 0.84)^2  =  2 × 7.84  =  15.7,  round to 16

That is where the 16 comes from. It is not magic; it is alpha 0.05 and power 0.8 baked into one number.

Now put in real values. Say you want to detect a 0.5% relative change in the copy rate, against a baseline copy rate of p = 8%:

delta      =  0.005 × 0.08          =  0.0004 absolute
             (0.5% OF 8%, not 0.5 percentage points — this is the step
              people get wrong, and it is a factor of 12.5)

n per arm  ≈  16 · p(1-p) / delta^2
           =  16 · 0.08 · 0.92 / (0.0004)^2
           =  1.1776 / 1.6e-7
           =  7,360,000  =  7.36M requests per arm

How long does 7.36M take to collect? At 1.2B requests/day, a 1% experiment allocation gives

1.2e9 × 0.01  =  12M requests/day, split two ways  =  6M per arm per day
7.36M / 6M    =  1.2 days for the global number

1.2 days sounds fine, and it is the trap.

The trap is that the effect is not uniform across pairs. It is concentrated in a handful of them. So you have to slice by pair — and each slice needs its own 7.36M, which a pair at 0.05% of traffic will never accumulate:

tail pair volume in the experiment
  =  1.2e9 requests/day × 0.0005 (share) × 0.01 (allocation)
  =  6,000 requests/day

7.36M / 6,000  =  1,227 days  =  3.4 years

You would need 3.4 years to power an A/B on a tail pair. That is not a tuning problem; it is a structural fact about the traffic distribution.

So the launch decision cannot be an online A/B for the languages you most need to protect. It is:

A quality launch decision at 100 languages is made offline and confirmed online, not the other way round.

Assumptions in this stage.


Serving architecture

Now the design becomes a request path, priced line by line — and the pricing is why two lanes are mandatory rather than an optimization.

The diagram below is one request’s journey, top to bottom. Here is the same journey in steps, with the numbers that matter attached to each box.

  1. Request arrives carrying text and a target language.
  2. Normalize, sentence split, language ID if undeclared. That last step is a small classifier guessing the source language when the caller did not say which it is.
  3. Exact cache lookup. The key is the normalized source plus the language pair plus the model version plus the formality setting. About 30% of requests hit here and return without touching a model at all.
  4. Router (on a miss). It decides using three inputs: the pair’s resource tier, the input length, and whether this is document mode.
  5. Lane B — the distilled lane. Roughly 60% of all traffic: short interactive requests on high-resource pairs. A 0.4B model at batch 64, beam 4, 620 requests per second per GPU.
  6. Lane C — the MoE lane. Roughly 10% of all traffic: low-resource pairs, long inputs, document mode. An 8B mixture-of-experts model at batch 32 with document context, 23 requests per second per GPU. Note the 27x throughput gap against Lane B — that gap is the whole cost story in scale and cost.
  7. Harness guards. Both lanes feed the same checks: numerals and named entities survived, length ratio is sane, nothing is repeating, output language is the one requested, and the reference-free quality score clears its floor.
  8. Two repair paths. A numeral mismatch triggers a re-decode with the source numerals forced into the output. A quality score below floor escalates the request to Lane C, or returns the untranslated source with a low-confidence flag.
  9. Write cache, then respond. Both repair paths rejoin here, so nothing skips the cache write.
flowchart TD
    REQ(["Request · text + target lang"]) --> NORM["Normalize · sentence split<br/>language ID if undeclared"]
    NORM --> CK{"Exact cache<br/>key = norm_src + pair<br/>+ model_version + formality"}
    CK -->|"hit · 30%"| OUT(["Response"])
    CK -->|miss| ROUTE{"Router<br/>pair tier · length<br/>· document mode"}
    ROUTE -->|"60% · high-resource<br/>short · interactive"| S["Lane B · 0.4B distilled<br/>batch 64 · beam 4<br/>620 req/s per GPU"]
    ROUTE -->|"10% · low-resource<br/>long · document mode"| L["Lane C · 8B MoE<br/>batch 32 · doc context<br/>23 req/s per GPU"]
    S --> GUARD{"Harness guards<br/>numeral + entity set<br/>length ratio · repetition<br/>output language · COMET-QE"}
    L --> GUARD
    GUARD -->|pass| WR["Write cache"]
    GUARD -->|"numeral mismatch"| CD["Re-decode with<br/>source numerals forced"]
    GUARD -->|"QE below floor"| ESC["Escalate to Lane C<br/>or return source with<br/>low-confidence flag"]
    CD --> WR
    ESC --> WR
    WR --> OUT

    style CK fill:#2d6a4f,color:#fff
    style ROUTE fill:#bc6c25,color:#fff
    style L fill:#9d0208,color:#fff
    style GUARD fill:#1d3557,color:#fff

Against the colour key: the exact cache is green because it answers a read without asking a model at all; the router is orange because what forces its threshold is memory bandwidth and weight residency rather than arithmetic; Lane C is red because routing into it is the one decision in this path you cannot take back — a 960 ms decode is committed the moment the request enters, and no downstream box makes it faster; the guard box is blue because it, not the model, is the authority on what ships.

The latency budget

Decompose the 300 ms budget into lines that each have a different fix, and the decomposition proves the big model cannot sit on the interactive path.

The interactive target is p95 under 300 ms, because the surface re-translates as the user types.

The table below breaks one request into stages and prices each one in both lanes. Compare the two columns row by row: every line is identical or near-identical except one. Then look at the totals.

StageLane BLane C
Network + TLS40 ms40 ms
Normalize, segment, language ID, cache lookup5 ms5 ms
Batch queue wait10 ms25 ms
Encoder prefill8 ms40 ms
Decode (60 output tokens)72 ms960 ms
Guards, deterministic (numeral/entity/length/repetition)3 ms3 ms
COMET-QE guard (96% of traffic reaching a model)2 ms2 ms
Total p50140 ms1,075 ms

Three of those lines need naming. TLS is transport layer security, the handshake that establishes the encrypted connection before any request bytes move; it is bundled with network time because on a cold connection it is a round trip you cannot skip. Prefill is the one parallel pass in which the model reads the entire source sentence; decode is the sequential crawl that produces the translation one token at a time, each token needing its own pass over the model.

The guard line is split in two on purpose, because they are different components with different bills. The deterministic checks are free arithmetic on strings. COMET-QE is a model, it runs on 96% of the traffic that reaches a model at all, and it therefore needs a row in the fleet table — which it gets, and which turns out to cost more than the chapter’s top-ranked cost lever saves.

Decode dominates both lanes: 72 of 140 ms, and 960 of 1,075 ms. The reason is derived in The kv cache the most important mechanism in this chapter, and it is worth restating in one line here.

Decode is strictly sequential — one pass over the model per output token — and it is memory-bandwidth-bound, meaning the chip spends its time waiting for the model’s weights to arrive from memory rather than doing arithmetic. So the floor on one decode step is:

time per step  =  bytes of weight that must be read  /  memory bandwidth
total decode   =  time per step × number of output tokens

The accelerator assumed here has 2.0 TB/s of memory bandwidth, which is 2,000 GB/s. Substituting for each lane, with 60 output tokens:

Lane B   0.4B params × 2 bytes (fp16)   =  0.8 GB of weights per step
         0.8 GB / 2,000 GB/s            =  0.0004 s  =  0.4 ms  <- the floor
         × 3 overhead                   =  1.2 ms per step
         × 60 output tokens             =  72 ms

Lane C   8B params × 2 bytes (fp16)     =  16 GB of weights per step
         16 GB / 2,000 GB/s             =  0.008 s   =  8 ms    <- the floor
         × 2 overhead                   =  16 ms per step
         × 60 output tokens             =  960 ms

The overhead multipliers (×3 and ×2) cover attention, the KV cache reads, and kernel launch costs that the pure weight-read floor ignores. The small model pays a larger multiplier because its fixed per-step overheads are a bigger fraction of a shorter step.

The ratio that falls out — 960 / 72 = 13x — is entirely a consequence of the model being 20x bigger and the overheads being different. Nothing about batching or scheduling appears in it.

Lane C cannot be on the interactive path, and no amount of batching fixes it — batching raises throughput and does nothing for the latency of a single sequence. So Lane C serves document mode (paste, upload, file) where a second is acceptable, plus low-resource pairs where the alternative is a bad translation rather than a fast one. On surfaces where text may visibly change, a “fast then refine” pattern shows Lane B immediately and swaps in Lane C when it lands.

Caching common phrases

An exact-match cache is worth 30% of traffic here, and the obvious upgrade to it is the one thing you must not build.

Translation requests are severely Zipfian, meaning a small number of inputs account for a very large share of volume while everything else is a long thin tail: greetings, interface strings, product names, and the same news headline pasted by 40,000 people in one hour. Exact-match lookup on a normalized source gets a 30% hit rate at 3 ms and zero GPU cost.

key  =  hash( nfkc_casefold(src) , src_lang , tgt_lang , model_version , formality )

nfkc_casefold is Unicode’s standard normalization plus lowercasing, so that visually identical strings written with different code points collide as they should. Every component of that key is load-bearing. model_version in particular: a cached translation outlives the bug that produced it, and a cache with no version in the key is a permanent record of every mistake you have ever shipped.

Do not build a semantic cache — one that serves a stored answer when a new query merely embeds close to an old one. A near-match source differing only in a number or a name gets the wrong translation returned with full confidence, and that is the failure this whole design exists to prevent. Exact match or nothing.

Document context versus batching

Document context is a modelling improvement that is unambiguously good for quality — and its real cost is a serving cost that lands on the languages that can least afford it.

Sentence-level translation loses real information: pronoun antecedents (which noun a “he” or “it” refers back to), formality — the German choice between the familiar du and the formal Sie, which English source text does not mark at all — terminology consistency across a document, gender agreement with an entity named three sentences earlier, and ellipsis, where a sentence leaves out a word that the previous sentence supplied.

Passing the four previous source sentences as context is worth roughly 2-4 chrF on a contrastive test set — one built so that each item has a correct and an incorrect variant differing only in the phenomenon being tested, here the discourse-sensitive cases — and roughly 0.2 chrF on an aggregate test set, because only 5-10% of sentences are discourse-sensitive at all. If you build document context and measure it with aggregate chrF you will conclude it did not work. You need a contrastive suite that isolates the phenomenon.

The costs are where the design decision lives:

  1. Input grows 60 -> 300 tokens. Encoder attention is quadratic, so that term goes up ~25x; it is a small share of the total, so this is the cheap part.
  2. Sequence lengths become ragged, and a batch pads to its longest member. With uniform 60-token inputs, padding waste is about 10%. With document context of 0-400 tokens depending on position in the document, waste exceeds 50% unless you bucket by length. Bucketing means the scheduler cannot take the next k requests off the queue — it must wait for enough same-bucket requests, which adds queueing latency precisely when the queue is thin, which is precisely the low-traffic language pairs. The cost of context lands hardest on the languages with the least traffic, again.
  3. Live typing has no next sentence, and the preceding sentences change as the user edits — which invalidates the cache key on every keystroke.

The resolution is to use document context only in document mode. On the interactive path, carry a per-session formality and glossary tag — one extra token in the prefix, with no effect on sequence length or on batching — which recovers the du/Sie choice and the terminology consistency for approximately nothing.

Assumptions in this stage.


Scale and cost

Converting the traffic figure into a GPU count and a daily bill, then ranking the levers that change it, produces the chapter’s most counter-intuitive result.

The inputs

Every number in this section is derived from the eight inputs below. State them out loud before you start dividing, because an interviewer who disagrees with one of them wants to say so before you have spent four minutes on arithmetic.

InputValueWhat it is
Traffic1.2B requests/dayGiven in the prompt
Request shape60 source tokens, 60 target tokensAverage, from the framing section
Peak factor2.5Peak traffic is 2.5x the daily average
Accelerator memory80 GB, of which 70 GB usableAn A100-class part. The runtime takes the rest
Memory bandwidth2.0 TB/s of HBMHigh-bandwidth memory, the memory stacked next to the chip. This is what sets decode speed
Arithmetic throughput150 TFLOP/s effectiveA TFLOP is a trillion floating-point operations, so this is trillions of arithmetic operations per second
Price$2.00/GPU-hour fully loadedWhich is $2.00 × 24 = $48 per GPU-day — the number every cost line below multiplies by
Utilization derate0.7You assume you can actually use only 70% of theoretical throughput once queueing and ragged batches have taken their cut

One cross-chapter warning: this is a different part from the H100-class accelerator used in 2b latency is dominated by decode and decode is sequential and Why decoder only and not an encoder decoder the load bearing argument, which has 3.3 TB/s and costs $2.50/hour. Carry the step-time formula across chapters, not the constants.

From traffic to QPS

1.2e9 requests/day  /  86,400 seconds/day  =  13,889 QPS average
13,889  ×  2.5 peak factor                 =  34,722 QPS peak

Everything from here is sized against the peak number, because a fleet that only handles the average is a fleet that is down at lunchtime.

Per-GPU throughput for each of the three components

All three figures are worth deriving rather than asserting, because an interviewer will ask where they came from. Note that the two decode lanes and the guard are computed differently, and the difference is the point.

The two decode lanes are bandwidth-bound, so throughput is batch size divided by that lane’s decode time from the latency budget, then derated:

Lane B    batch 64  /  0.072 s   =  889 req/s raw
          889 × 0.7 derate       =  622 req/s per GPU,  call it 620

Lane C    batch 32  /  0.960 s   =  33.3 req/s raw
          33.3 × 0.7 derate      =  23 req/s per GPU

The QE guard is not a decode at all. It reads the source and the hypothesis in one encoder forward pass and emits a single score, so it is bounded by arithmetic rather than by bandwidth, and it is derived from FLOPs. The rule of thumb is that a forward pass costs about 2 FLOPs per parameter per token:

work per request   2 × 0.55e9 params × 120 tokens (60 src + 60 hyp)
                                       =  132e9 FLOP  =  132 GFLOP

chip throughput    150 TFLOP/s × 0.7 derate
                                       =  105 TFLOP/s effective

requests per GPU   105e12 / 132e9      =  795 req/s per GPU

The fleet table

Each row below takes the peak 34,722 QPS, multiplies by that component’s share of traffic, divides by its per-GPU throughput, and then multiplies by 1.5 for regional redundancy plus one spare replica. The last column is the one you commit to.

LaneSharePeak QPSThroughput/GPUGPUs at peak× 1.5 (region + N+1)
Cache hit30%10,41700
Lane B · 0.4B60%20,833620 req/s33.651
Lane C · 8B10%3,47223 req/s151227
QE guard · 0.55B encoder96% of misses23,333795 req/s29.444
322 GPUs

Two rows deserve their arithmetic spelled out.

Lane C   34,722 × 0.10  =  3,472 QPS
         3,472 / 23     =  151 GPUs  ->  × 1.5  =  227

QE guard  misses reaching a model = 34,722 × 0.70  =  24,306 QPS
          of those, 96% pass the deterministic checks and pay for QE:
          24,306 × 0.96  =  23,333 QPS
          23,333 / 795   =  29.4 GPUs  ->  × 1.5  =  44

The QE row is the one most versions of this answer leave out, and it is not small. The serving diagram puts COMET-QE inside the guard box, and the hallucination guard says it is paid for on the 96% of traffic that passes the deterministic checks. A model that runs on nearly every request needs a row in the fleet table like any other.

The bill

Multiply GPUs by $48 per GPU-day:

decode fleet     51 (Lane B) + 227 (Lane C)  =  278 GPUs
                 278 × 24 h × $2.00          =  $13,344/day
QE guard          44 × 24 h × $2.00          =  $ 2,112/day
                                                -----------
total            322 GPUs                       $15,456/day

per year         $15,456 × 365               =  $5.64M/year
per million req  $15,456 / 1,200 M requests  =  $12.88 per million requests

Now the ratio the whole optimization roadmap hangs on. Lane C is 10% of traffic, and:

227 Lane C GPUs / 278 decode GPUs  =  0.817  =  82% of the decode fleet

10% of traffic is 82% of the decode fleet. That points somewhere non-obvious, which the next subsection prices.

Which lever, in dollars

Five ways to make the system cheaper, priced against each other — and the ranking is the opposite of the order people propose them in.

Before the table: the $/day column is a change against the 278-GPU decode baseline of $13,344/day, not a total fleet cost. That is why every row but the last is negative. Every row is fleet change × $48 per GPU-day, so you can check each one in your head.

LeverMechanismFleet change$/day
Move 2 points of traffic from Lane C to Lane B694 peak QPS: -45 GPUs on C, +2 on B-43-$2,064
Raise cache hit rate 30% -> 40%3,472 peak QPS leaves Lane B-8-$384
Quantize Lane B to int8Halves bytes read per decode step, so 620 -> 1,240 req/s-25-$1,200
Shorten Lane C beam from 4 to 2Halves sequences per batch, roughly 1.6x throughput-85-$4,080
Serve everything on Lane C24,306 peak QPS / 23 × 1.5+1,307+$62,736

Work the top row so the rest are checkable:

2 points of peak traffic  =  34,722 × 0.02  =  694 QPS

leaves Lane C:   694 / 23   =  30.2 GPUs  ->  × 1.5  =  45 fewer
arrives Lane B:  694 / 620  =   1.1 GPUs  ->  × 1.5  =   2 more
net                                                     -43 GPUs
                                          -43 × $48  =  -$2,064/day

The last row is the one that gets misquoted. +$62,736 is the addition of 1,307 GPUs to the 278-GPU decode fleet, so serving everything on Lane C is 278 + 1,307 = 1,585 decode GPUs at 1,585 × $48 = $76,080/day — that row added to the $13,344 decode baseline, not to the $15,456 total.

Now the comparison that matters:

router lever   -$2,064/day  for 2 points of traffic moved
cache lever      -$384/day  for 10 points of hit rate

headline ratio        $2,064 / $384        =  5.4x
per point of traffic  $1,032 / $38.40      =  27x

The routing threshold is 5.4x the lever the cache is — and 27x per point of traffic moved. The cache is the one everybody proposes first. Say this plainly: caching is a latency feature that also saves a little money; the router is the cost architecture.

And then say the uncomfortable thing, because it is the strongest line available here. Put the QE guard’s own cost next to the best lever:

QE guard, total cost               $2,112/day
top-ranked lever in the table      $2,064/day saved

The component the chapter nearly forgot to size outranks the lever the chapter tells you to optimize. And the cheapest way to act on it is not a routing change at all. Halve the guard model:

0.55B encoder -> 0.28B encoder    throughput roughly doubles
44 GPUs -> 23 GPUs                21 GPUs × $48  =  $1,008/day

That $1,008/day would slot fourth in the five-row lever table — behind the beam cut, the router, and int8 quantization, but 2.6x the cache lever everybody proposes first ($1,008 / $384). It is bought with a weaker quality signal on exactly the tail pairs that need it most. Size your guards before you rank your levers.

Two honest caveats to volunteer:

Sanity check against price

Compare the serving cost to the list price of the same service, and the ratio explains where the money in a translation business actually goes.

Commercial machine translation lists at around $20 per million characters. An average request is 60 tokens, and a token is roughly 4 characters, so about 240 characters per request:

1M requests × 240 chars       =  240M characters
240M / 1M × $20               =  $4,800 of list price

our marginal serving cost     =  $12.88   (from the fleet arithmetic above)

$4,800 / $12.88               =  373x

The gap is not margin on the serving call. It is data acquisition, mining compute, training runs, the MQM budget, and the 96% of language directions that will never carry enough traffic to pay for themselves. The price of a translation API is set by the long tail, not by the request in front of you — and a candidate who says that has understood the business shape of the system.

Assumptions in this stage.


Failure modes

Four failures are worth deriving in full, and six more fit in a table. Each gets a concrete trace, the mechanism that produces it, and the specific control that catches it — because “we would add guardrails” is not an answer.

1. Hallucinated content — the dangerous one

First, the failure the whole design exists to prevent: a fluent, confident sentence that says something the source did not.

Two traces below. The first is the subtle form — a single dropped negation. Look at which of the three checks catches it, because only one does.

SOURCE  (de)   "Der Termin wurde nicht verschoben."
HYP     (en)   "The appointment has been postponed."
                                     ^^^^^^^^^^^^^ negation dropped

chrF vs reference          0.71   -> passes any surface-metric gate
COMET-QE (reference-free)  0.42   -> below the 0.60 floor  -> CAUGHT
cross-attention entropy    normal -> not caught by the attention guard

The German says the appointment was not postponed. The English says it was. nicht is one token, and dropping it inverts the meaning while leaving the sentence perfectly fluent — which is why chrF, a surface-overlap score, still gives it 0.71.

Now the pure form, under domain shift. Here the output has nothing to do with the input at all, and the cheap checks catch it easily:

SOURCE  (is)   "th th th th"                         (noise, or an unsupported script)
HYP     (en)   "The Lord bless you and keep you."

cross-attention mass on source tokens   0.09   -> diffuse  -> CAUGHT
length ratio                            8.0    -> outside [0.5, 2.0]  -> CAUGHT

Cross-attention in those traces is the mechanism by which each output token looks back at the source tokens, and the mass is how much of that looking was aimed at real source words rather than at padding or the end-of-sentence marker. Diffuse mass means the output was not really conditioned on the input.

Mechanism, and it is the objective from the second section. The loss lives on the target side, so nothing trains the model to depend on the source. When the encoder provides no usable signal — noise, an unsupported script, a domain the model never saw — the decoder’s language model takes over and emits the mode of its training distribution. For low-resource languages that distribution is dominated by religious text, because Bible and Watchtower translations are the only large parallel corpora that exist for many languages. The hallucination is not random; it is the training corpus’s most probable sentence, which is why it is so fluent.

The design runs three guards in order of cost:

CodeCheckCostWhat it catches
H01Length ratio outside the per-pair bandFree — string arithmeticOutput wildly longer or shorter than the input
H02Cross-attention mass on real source tokens below 0.25Free — the decoder already computed itOutput not conditioned on the input at all
H03Reference-free COMET-QE below a per-pair floorA model forward passEverything subtle, including the dropped negation

Be precise about what the ordering buys, because the obvious claim — “the expensive one is only paid for when the free ones have passed” — reads as a saving, and the chapter’s own number says it is not much of one. The ordering saves the QE call on about 4% of traffic. (That is where the “96% of traffic reaching a model” line in the fleet table comes from.)

Its real purpose is different: a deterministic hit is auditable and a model score is not. When H01 or H02 fires, you can point at the length ratio or the attention mass in the trace and a reviewer can check it. “The quality model said 0.41” is a number with no argument attached. Cheapest-first is an explainability order that happens to shave 4% off the guard’s bill.

The code below implements exactly that ordering. Read hallucination_guards first; the assertions underneath replay the two traces above and count how often the QE model is called, because “the model is only paid for when the free checks pass” is a claim about call counts and should be tested as one.

def hallucination_guards(src, hyp, attn, qe_model, pair_cfg):
    """Ordered cheapest-first. Deterministic checks before any model call."""
    flags = []

    ratio = len(hyp) / max(len(src), 1)
    lo, hi = pair_cfg.len_ratio
    if not lo <= ratio <= hi:
        flags.append(("H01 length ratio", ratio))

    # Cross-attention mass the decoder placed on real source tokens (not EOS
    # or padding). Diffuse mass means the output is not conditioned on input.
    src_mass = attn.mean_mass_on_source_tokens()
    if src_mass < 0.25:
        flags.append(("H02 output not conditioned on source", src_mass))

    if flags:                       # deterministic hit: no need to pay for QE
        return flags

    score = qe_model.score(src, hyp)          # reference-free COMET-QE
    if score < pair_cfg.qe_floor:             # per-pair, calibrated on MQM
        flags.append(("H03 QE below per-pair floor", score))
    return flags


# --- the two traces above, plus the claim the ordering actually makes --------
class _Cfg2:
    len_ratio, qe_floor = (0.5, 2.0), 0.60


class _Attn:
    def __init__(self, mass):
        self._m = mass

    def mean_mass_on_source_tokens(self):
        return self._m


class _QE:
    """Counts its own calls, because 'the model is only paid for when the free
    checks pass' is a claim about how often it is called."""

    def __init__(self, score):
        self.score_value, self.calls = score, 0

    def score(self, src, hyp):
        self.calls += 1
        return self.score_value


# Dropped negation: normal length, normal attention, caught only by QE.
_qe = _QE(0.42)
_f = hallucination_guards("Der Termin wurde nicht verschoben.",
                          "The appointment has been postponed.",
                          _Attn(0.55), _qe, _Cfg2())
assert [c for c, _ in _f] == ["H03 QE below per-pair floor"], _f
assert _qe.calls == 1                     # deterministic checks passed -> QE paid

# Pure hallucination under domain shift: length ratio AND attention both fire.
_qe = _QE(0.42)
_f = hallucination_guards("th th th th", "The Lord bless you and keep you.",
                          _Attn(0.09), _qe, _Cfg2())
assert [c for c, _ in _f] == ["H01 length ratio",
                              "H02 output not conditioned on source"], _f
assert _qe.calls == 0, "a deterministic hit must short-circuit the model call"

# A clean translation pays for QE and passes it.
_qe = _QE(0.81)
assert hallucination_guards("Der Termin wurde verschoben.",
                            "The appointment has been postponed.",
                            _Attn(0.61), _qe, _Cfg2()) == []
assert _qe.calls == 1
print("hallucination guards: dropped negation caught by QE only, "
      "pure hallucination caught deterministically without paying for QE")

Note what those assertions pin down. The dropped negation has a normal length ratio and normal attention mass, so the only layer that catches it is the model — which is why the ordering cannot be sold as a saving. The pure hallucination fires two deterministic checks and the QE model is never called, which is the 4% the ordering does save. And the per-pair floor is calibrated against MQM rather than guessed.

2. Gender bias

Some failures cannot be fixed in the model at all, because the information the model would need is genuinely not in its input.

SOURCE  (tr)   "O bir doktor. O bir hemsire."       ('o' is genderless in Turkish)
HYP     (en)   "He is a doctor. She is a nurse."

Mechanism: the target language forces a distinction the source does not encode. The model must emit something, and it resolves the ambiguity with the training corpus’s prior over occupation-gender co-occurrence. This is not a decoding bug and it is not fixable by “debiasing the model,” because the information genuinely is not in the source.

So it is a product decision. Detect ambiguity — score the hypothesis with the pronoun swapped; if the two scores differ by less than a threshold, the source did not determine it — and emit both alternatives with a note. The failure is not that the model guessed; it is that the interface presented a guess as a translation.

3. Named-entity and numeral mangling

Next, the failure that is cheapest to catch and most embarrassing to ship — and the fix is code rather than a model.

SOURCE  (en)   "Contact Dr. Nguyen at 555-0142, room 3B, by March 4."
HYP     (fr)   "Contactez le Dr Nguyen au 555-0124, salle 3B, avant le 3 mars."
                                          ^^^^^^^^                    ^^^^^^^
                                          digits transposed           wrong date

Mechanism: numerals fragment into low-information subword pieces (Tokens) and the decoder’s language model has no preference between 0142 and 0124. Both are equally plausible continuations. There is no signal in the objective that makes one win.

This is a harness control — code that runs on the output before it ships — not a prompt trick and not a training fix.

Three things about it are load-bearing, and all three are things a one-line regular expression cannot do:

  1. It compares anchors, not just a sorted multiset of values. {555-0142, 555-0143} on both sides is a match as a multiset even when Alice’s number has been handed to Bob.
  2. It splits on non-digits rather than letting a character class span separators. A greedy class turns Rooms 1, 23 into the single number 123, and Salles 12, 3 into 123 as well, so a mangled pair compares equal.
  3. It parses dates in each locale’s own order. 3/4 is March 4 in en-US and 3 April in fr, so reading both sides the same way makes a real error invisible.

The code below is the guard. Read the three module-level constants first (the locale sets and the number-word table), then numerals(), then numeral_guard(), which runs the three checks in order and returns a code plus the repair it wants. The assertions at the bottom are the four inputs a naive version got wrong.

import re

# Locales that write 1.234,5 where English writes 1,234.5. Defined here rather
# than imported: a guard whose constants live in someone else's module raises
# NameError the first time it meets a German price.
DECIMAL_COMMA_LOCALES = {"de", "fr", "es", "it", "pt", "nl", "pl", "ru", "tr", "is"}

# Locales that write a bare numeric date day-first. This is why `3/4` is not
# one date: en-US reads March 4, everything below reads 3 April.
DAY_FIRST_LOCALES = DECIMAL_COMMA_LOCALES | {"en-GB"}

# Numbers a correct translation may spell out instead of leaving as digits.
# Without this the guard fires on every "3 rooms" -> "trois salles", and the
# stated repair -- force-copy the source digits -- would push digits into a
# translation that was already right.
NUMBER_WORDS = {
    "en": {"zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
           "five": "5", "six": "6", "seven": "7", "eight": "8", "nine": "9",
           "ten": "10", "eleven": "11", "twelve": "12"},
    "fr": {"zéro": "0", "un": "1", "une": "1", "deux": "2", "trois": "3",
           "quatre": "4", "cinq": "5", "six": "6", "sept": "7", "huit": "8",
           "neuf": "9", "dix": "10", "onze": "11", "douze": "12"},
    "de": {"null": "0", "eins": "1", "ein": "1", "eine": "1", "zwei": "2",
           "drei": "3", "vier": "4", "fünf": "5", "sechs": "6", "sieben": "7",
           "acht": "8", "neun": "9", "zehn": "10", "elf": "11", "zwölf": "12"},
}

# A separator may appear only BETWEEN digit runs. The one-line version this
# replaced, `\d[\d.,\s]*\d`, spanned whitespace and swallowed "1, 23" into the
# single token "123".
NUM_PAT = r"\d+(?:[.,]\d+)*"
WORD_PAT = r"[^\W\d_]+"
TOKEN = re.compile(f"{NUM_PAT}|{WORD_PAT}", re.UNICODE)
# The separator is a named piece so that the source of this block never
# contains the character sequence "](", which markdown link checkers read
# as a link.
SEP = r"[/.\-]"
DATE = re.compile(r"\b(\d{1,2})" + SEP + r"(\d{1,2})(?:" + SEP + r"(\d{2,4}))?\b")


def _canon(raw: str, locale: str) -> str:
    """1.234,5 (de) and 1,234.5 (en) are the same number."""
    raw = raw.replace(".", "").replace(",", ".") \
        if locale in DECIMAL_COMMA_LOCALES else raw.replace(",", "")
    return raw.rstrip(".") or "0"


def dates(text: str, locale: str) -> list[tuple]:
    """Numeric dates as (year, month, day), read in the locale's own order."""
    out = []
    for m in DATE.finditer(text):
        a, b, y = int(m.group(1)), int(m.group(2)), m.group(3)
        day, month = (a, b) if locale in DAY_FIRST_LOCALES else (b, a)
        if 1 <= month <= 12 and 1 <= day <= 31:
            out.append((int(y) if y else None, month, day))
    return sorted(out, key=lambda t: (t[0] is None, t))


def numerals(text: str, locale: str) -> list[tuple[str, str]]:
    """(anchor, value) pairs in reading order. The anchor is the nearest
    preceding capitalised word — a cheap stand-in for the entity the number
    belongs to, and the part of a sentence most likely to survive translation
    unchanged, which is what makes it comparable across the two sides."""
    text = DATE.sub(" ", text)                  # dates are checked separately
    spelled = NUMBER_WORDS.get(locale.split("-")[0], {})
    out, anchor = [], ""
    for m in TOKEN.finditer(text):
        tok = m.group()
        if tok[0].isdigit():
            out.append((anchor, _canon(tok, locale)))
        elif tok.lower() in spelled:
            out.append((anchor, spelled[tok.lower()]))
        elif tok[:1].isupper() and m.start() > 0:   # skip the sentence-initial word
            anchor = tok
    return out


def numeral_guard(src, hyp, src_locale, tgt_locale):
    """Three checks, none of them a bare set comparison, all of them before the
    response ships. Returns None when the hypothesis is clean."""
    s, h = numerals(src, src_locale), numerals(hyp, tgt_locale)

    # 1. Multiset of VALUES. A digit invented, dropped or mistyped shows here.
    if sorted(v for _, v in s) != sorted(v for _, v in h):
        return {"code": "N01 numeral value set differs",
                "source": [v for _, v in s], "hypothesis": [v for _, v in h],
                "action": "re-decode with source numerals force-copied"}

    # 2. Anchored comparison. Values can match as a multiset while being
    # re-assigned across entities — Alice's number handed to Bob. Only anchors
    # appearing verbatim on BOTH sides are comparable across languages.
    for a in sorted({a for a, _ in s if a} & {a for a, _ in h if a}):
        if sorted(v for k, v in s if k == a) != sorted(v for k, v in h if k == a):
            return {"code": "N02 numerals re-assigned across entities",
                    "entity": a,
                    "source": sorted(v for k, v in s if k == a),
                    "hypothesis": sorted(v for k, v in h if k == a),
                    "action": "re-decode with the entity-number binding constrained"}

    # 3. Dates, each side read in its own order. `3/4` is not one day.
    ds, dh = dates(src, src_locale), dates(hyp, tgt_locale)
    if ds != dh:
        return {"code": "N03 date differs once locale order is applied",
                "source": ds, "hypothesis": dh,
                "action": "re-decode with the date rendered unambiguously"}
    return None


# --- the four inputs the sorted()-multiset version shipped wrong ------------
assert numeral_guard("Call Alice at 555-0142 and Bob at 555-0143.",
                     "Appelez Alice au 555-0143 et Bob au 555-0142.",
                     "en", "fr") is not None          # numbers swapped: was PASS
assert numeral_guard("Rooms 1, 23 are open.",
                     "Salles 12, 3 sont ouvertes.",
                     "en", "fr") is not None          # regex glued "1, 23": was PASS
assert numeral_guard("Deliver on 3/4/2024.",
                     "Livraison le 3/4/2024.",
                     "en", "fr") is not None          # 4 Mar vs 3 Apr: was PASS
assert numeral_guard("There are 3 rooms.",
                     "Il y a trois salles.",
                     "en", "fr") is None              # spelled out: was a false N01

# --- and it still passes correct translations, and still catches the chapter's
# own trace from the top of this subsection
assert numeral_guard("Deliver on 3/4/2024.", "Livraison le 4/3/2024.",
                     "en", "fr") is None
assert numeral_guard("Call Alice at 555-0142 and Bob at 555-0143.",
                     "Appelez Alice au 555-0142 et Bob au 555-0143.",
                     "en", "fr") is None
assert numeral_guard("Der Preis ist 1.234,5 EUR.", "The price is 1,234.5 EUR.",
                     "de", "en") is None
assert numeral_guard(
    "Contact Dr. Nguyen at 555-0142, room 3B, by March 4.",
    "Contactez le Dr Nguyen au 555-0124, salle 3B, avant le 3 mars.",
    "en", "fr")["code"].startswith("N01")
print("numeral guard: 8/8 assertions passed")

Each code carries its own repair, and they are not the same repair. On N01 — a value invented, dropped or mistyped — re-decode with the source numerals forced into the output by constrained decoding: at each step, restrict the model’s choice of next token to those that keep the output valid, so a wrong digit is not unlikely but unreachable. On N02 the values are all present and merely attached to the wrong entity, so force-copying them changes nothing; the constraint has to be on the binding, which is the harder decode. On N03 the digits are identical on both sides and only the reading differs, so force-copying them actively preserves the bug — the repair is to render the date unambiguously (4 March 2024) rather than to copy 3/4.

The spelled-out case is why the naive version of this guard is worse than no guard. "There are 3 rooms." translated as "Il y a trois salles." is correct, and a guard that compares digit sets fires on it and then applies N01’s repair — forcing the digit 3 into a translation that had already spelled the number out properly. A guard whose false positive is repaired by corrupting a correct output is a guard that ships damage. That is the specific reason the number-word table is in the code and not in a backlog ticket.

4. Degenerate repetition

One failure the training objective never saw, because it is created by the search procedure used at serving time rather than by the model.

SOURCE  (km)   [a 120-token legal sentence with no clear clause boundaries]
HYP     (en)   "...the party shall be liable for the party shall be liable
                for the party shall be liable for the party shall be lia"
                                                    ^ truncated at max length

Mechanism: beam search’s length-normalized score can make a loop locally optimal. Once "the party shall be liable for" is in the prefix, the highest-probability continuation given that identical context is the same sequence again — each repetition raises the conditional probability of the next repetition. It is the same self-reinforcing dynamic as an agent loop (Why loops self reinforce), and it fires on the same trigger: an input the model has no good response to.

Three guards, all needed. N-gram blocking during decode bans any four-token run that this hypothesis has already emitted, which breaks the loop mechanically. A coverage penalty added to the beam score punishes candidates that left source tokens with no attention on them, which is what a looping output does. And the length-ratio check from the hallucination guard catches whatever the first two miss.

5. The rest

The remaining six failures fit in one table, each with the signature you would see in a trace and the control that catches it.

FailureTrace signatureGuard
Wrong source language detectedUser overrode the language selectorConfidence threshold on language ID; on low confidence, translate under the top-2 and pick by output QE
Code-switched inputTwo languages in one sentenceSegment-level language ID rather than request-level; do not force a single label
Copy-through (source emitted verbatim)Character overlap src/hyp above 0.9 on a pair where that is abnormalPer-pair overlap threshold; re-decode with the copy path penalized
Off-target languageOutput language classifier disagrees with the requested tagBan wrong-language tokens and re-decode; count as a launch-blocking guardrail metric
Stale cache after a model shipAnswers reflect the previous checkpointmodel_version in the cache key; the cache drains itself on deploy
MT-contaminated training dataRising translationese in eval; errors that mirror a competitor’sF06 MT-detector at ingest; monitor the crawl’s MT fraction over time

Assumptions in this stage.


Alternatives considered and rejected

Each design below was considered, and each is paired with the number that killed it — because rejecting on a number rather than a preference is what the round is actually scoring. Every entry in the third column is either a figure derived earlier in the chapter or a mechanism you can state in one sentence.

AlternativeWhy it is temptingWhy rejected
N × (N-1) pairwise modelsEach model specialized; no capacity dilution7,920 GB of weights needs 114 GPUs resident before serving anything, and 9,850 pairs would sit at 0.045% utilization. Cost is set by residency, which does not amortize
Pivot everything through EnglishOnly needs 2N models; all data is English-centric anywayTwo lossy legs compose: at 0.87 adequacy per leg you land near 0.76. Worse, the pivot destroys information rather than adding noise — English has no T-V distinction, so ja->en->de must guess formality with zero source signal, and Turkish evidentiality cannot survive a pivot through a language that does not mark it
A general 70B LLM for all translationGenuinely better on document-level, context, and instructions20-50x the cost and 10x the latency. Correct use is as a teacher for distillation and as an offline data generator, not on the interactive path
Per-language adapters on a shared trunkRecovers per-language capacity cheaply; small parameter deltaBatching requires every request in a batch to share weights. Per-language adapters mean a batch can contain only one language, which destroys batch efficiency exactly for the low-traffic languages the adapters exist to help. The fix is the problem
Mixture of experts in the throughput laneMore capacity at constant FLOPsServing cost in Lane B is set by weight residency across many GPUs; MoE’s parameter count is the wrong shape for it. Keep MoE in Lane C where residency is already paid
Semantic cache on final translationsTranslation requests repeat constantlyA near-match differing by one numeral returns confidently wrong output. Exact match on a normalized, version-keyed source, or nothing
Document context on every requestBetter pronouns, formality, terminologyRagged sequence lengths push padding waste past 50% and force length bucketing, whose queueing cost lands on the low-traffic pairs. Document mode only; a formality tag on the interactive path
Aggregate chrF as the launch gateOne number, easy to automateA pair at 0.05% of traffic can go to garbage and move aggregate chrF by 0.003. Per-pair gates or no gate
Optimize decoding against COMET (MBR)Directly maximizes the metric you reportReward hacking against a learned metric. Outputs score high and read strangely. Never let the decoding target and the reporting metric be the same model
Human review of low-confidence outputZero risk of shipping a hallucination2% of 1.2B requests is 24M reviews/day. The QE gate is the affordable version of the same idea

Two linguistic terms in the pivot row need unpacking, because they are what makes that rejection stronger than an arithmetic one. The T-V distinction is a grammatical split between familiar and formal second-person address — French tu against vous, German du against Sie — which English does not have. Evidentiality is a grammatical marker, obligatory in Turkish and many other languages, indicating how the speaker came by the information: witnessed directly, or heard from someone else. Neither survives a trip through English, because English has no slot to put them in. That is the difference between a pivot and a noisy channel: a noisy channel corrupts information, and a pivot deletes categories of it outright. The arithmetic — 0.87 adequacy per leg composing to 0.76 — understates the damage, because it treats the loss as uniform when it is concentrated exactly on the features the source language marked and the pivot language does not.


Interviewer pushback

Eleven questions this design actually gets asked, what each one is testing, and an answer that survives the follow-up. Almost every one of them is an attack on an assumption rather than on a fact.

“Why does back-translation work at all? You’re training on machine output.” Testing: whether you know where the gradient is. Because the machine output is on the input side, and the loss only touches target tokens. Synthetic source is input noise, which regularizes the encoder; the target is real human text, which trains the decoder’s language model — the component a small parallel corpus starves. Forward-translation puts machine output on the target side, where the gradient is, so the model learns to imitate its own errors. Measured on our en->is set, forward-translation raised chrF by 0.6 and raised the hallucination rate from 4.1% to 5.6%. And decode the back-translations with sampling, not beam — beam output is the low-entropy mode and the model overfits to it.

“One model for 100 languages, or 100 models?” Testing: whether you cost the serving side or only the training side. One, and the argument is residency rather than quality. 9,900 pairwise models is 7,920 GB of weights, 114 GPUs to hold before serving a request, and 9,850 of those pairs at 0.28 QPS each — 0.045% utilization on hardware that does not get cheaper when idle. A multilingual model is O(1) in pair count and every GPU can serve every request, so the scheduler packs freely. Then quality follows: zero-shot covers the 9,800 directions that have no data at all. The cost is capacity dilution, which is real and is why I would size the parameter budget by language count rather than by dataset size.

“So add another 30 languages?” Testing: whether you understood the curse or just named it. Only with more parameters. My 400M model has 262M in the embedding table, leaving 138M of body; if half of that is effectively language-private, going from 25 to 100 languages already cut per-language private capacity from 2.76M to 0.69M params. Adding 30 more on a fixed budget pays for them out of the low-resource languages I already have. Languages with close relatives in the set partly offset it via transfer; an isolate takes the full loss. The scalable answer is MoE — total parameters grow, active parameters per token do not — but that costs memory residency, so it belongs in the big lane only.

“Your BLEU went up 1.2 points. Ship it?” Testing: whether you take a metric at face value. It is a trap. No, and BLEU alone would not be enough even if it went up 12. Take “the meeting was postponed until Thursday” as the reference. The correct paraphrase — delayed for postponed — scores 0.000, because the substitution lands at token 4 of 7 — dead centre, inside all four 4-gram windows — so p_4 = 0 and the geometric mean collapses. The factual inversion — Tuesday for Thursday — scores 0.643, because token 6 sits near the end where only two 4-grams reach it and the leading ones survive. Both are one token away; BLEU prefers the wrong one, and the thing deciding the score is where the error landed. chrF2 compresses the gap to 0.72 against 0.84 but still ranks them the same way round. It is also not comparable across languages: p_n is computed over target-language tokens, so a morphologically rich language loses up to four n-grams to a single wrong inflection and has a structurally lower ceiling, while Chinese and Japanese scores are a property of your segmenter. And it is not comparable across tokenizations, so I would want a sacreBLEU signature before believing the 1.2 at all. What actually gates the launch is the per-pair table — no directed pair may drop more than 1.0 chrF or 0.02 COMET — plus the guardrails: hallucination flag rate, wrong-target-language rate, empty output.

“How would you catch a fluent translation that says the opposite of the source?” Testing: whether you have a serving-time control or only an offline metric. Three layers, cheapest first, and only the third involves a model. Length ratio against a per-pair calibrated band. Cross-attention mass on real source tokens — below 0.25 means the output is not conditioned on the input, which is the pure hallucination signature and it is free because the decoder already computed it. Then reference-free COMET-QE with a per-pair floor calibrated against MQM, which is the layer that catches the dropped negation, since that one has normal length and normal attention. Surface metrics catch none of these; chrF on the dropped-negation example is 0.71.

“What is your actual cost driver?” Testing: whether you have done the arithmetic. The routing threshold. 322 GPUs at $15,456/day, of which 278 are the two decode lanes and 44 are the QE guard; Lane C is 10% of traffic and 82% of that decode fleet, because an 8B model does 23 requests per second per GPU against Lane B’s 620. So moving 2 points of traffic from C to B is worth $2,064/day and raising the cache hit rate 10 points is worth $384 — a 5.4x difference off five times fewer points of traffic, so 27x per point moved, and the cache is what everyone proposes first. The honest caveat is that moving traffic out of C is a quality decision. You need a calibrated predictor of “the small model suffices,” and the QE model I already run for hallucination is exactly that predictor.

“Why not use an LLM for translation? They’re better.” Testing: whether you can say yes and still reject it. They are better, particularly on document-level context, register, and instruction-following, and I would use one — as a teacher. Sequence-level distillation from an 8B or larger teacher into a 0.4B student beats training the student on the real data, because the real data’s diversity exceeds what 0.4B can represent. But on the serving path it is 20-50x the cost and 10x the latency against a 300 ms budget, and the student inherits the teacher’s hallucinations, so stage 5’s regression gate has to test for those specifically rather than just chrF.

“Document-level translation is strictly better. Why isn’t it the default?” Testing: whether you see the systems consequence of a modeling choice. Because it breaks batching. Uniform 60-token inputs pad to about 10% waste; document context of 0-400 tokens pushes that past 50% unless you bucket by length, and bucketing means the scheduler waits for enough same-bucket requests instead of taking the next k off the queue. That queueing cost lands hardest where the queue is thinnest, which is the low-traffic pairs — the same ones the tokenizer already treats worst. Also, on a live-typing surface there is no next sentence and the preceding ones change on every keystroke, invalidating the cache. So: document context in document mode, and a one-token formality/glossary tag on the interactive path, which recovers the du/Sie and terminology wins without touching sequence length.

“Your Amharic quality is bad. What do you do first?” Testing: whether you look at the tokenizer. Measure fertility before touching the model. Amharic sits at 5.2 tokens per word against English’s 1.15, because the vocabulary was fit on a corpus that is 40% English and Amharic is 0.05% of it. That single number explains three symptoms at once: 4.5x the cost, 4.5x the decode steps — which is 4.5x the 72 ms of decode in a 140 ms budget, so ~392 ms end to end against a 300 ms p95 — and a model composing meaning from character fragments it has seen in far fewer contexts. The fixes are temperature-sampling the vocabulary-fitting corpus with the same T as the training mix, which most teams forget, and flooring each language’s piece allocation. Only after that would I add data — because more data through a bad tokenizer buys less than it should.

“You have no parallel data for Icelandic to Swahili. What ships?” Testing: whether you can start. Zero-shot from the multilingual model, with the target-language tag as the first decoder token rather than a source prefix so it cannot be attended away, plus a wrong-language penalty in the beam and an output-language classifier that triggers a re-decode. Expect 20-50% off-target rate before those guards and low single digits after. Then improve it with synthetic data: back-translate Swahili monolingual text into Icelandic through the model itself, which is noisy but creates a pair that did not exist. And gate the whole thing on a side-by-side preference panel with bilingual raters at 200 segments, because an online A/B on that pair would need 3.4 years to reach power.

“How do you know a launch didn’t break a language nobody on your team speaks?” Testing: whether your evaluation scales to the tail. It cannot be the aggregate metric and it cannot be an online A/B — both are structurally blind to a pair carrying 0.05% of traffic. It is a fixed 500-segment set per directed pair with per-pair chrF and COMET thresholds as a hard blocker, plus guardrail metrics that fire per pair: wrong-target-language rate, hallucination flag rate from the serving QE model, empty-output rate, and p99 latency. Then MQM monthly on a rotating subset of 12 pairs, which is 300 annotator-hours — $12,000, and about four days on a ten-person panel — so it is a launch gate rather than a CI gate. The design principle is that the tail is protected offline and confirmed online, never the reverse.


The assumption ledger

Every assumption the chapter has leaned on, collected in one place, so you can state the design’s foundations in twenty seconds and say what replaces the design when each one fails.

The bins are the same three used at the end of every section above, and the same three used in Assumptions are the design:

The rows are sorted load-bearing first. The last column is the one to rehearse: it is what you say when an interviewer knocks an assumption out.

AssumptionBinWhat it holds upWhat replaces the design if it is false
p95 under 300 ms on the interactive surfaceLoad-bearingThe two-lane split, the 0.4B distilled model, and every routing decisionAt a 2 s budget there is one lane, the 8B model serves everything, and the router — the largest cost lever in the chapter — stops existing
A fluent falsehood is the expensive failure, not a clumsy sentenceLoad-bearingThe whole serving guard layer and the ban on semantic cachingIf near-misses were cheap, ship one model with no guards, semantically cache, and spend the money on language coverage instead
Back-translation’s noise lands on the input side, where no gradient flowsLoad-bearingEvery low-resource pair, and the 41.2 -> 49.3 chrF jumpIf synthetic sources degraded the model the way synthetic targets do, there is no affordable path to 9,800 directions — support far fewer languages properly
Zero-shot transfer is usable on non-English-centric pairsLoad-bearing98% of the directed pairs the product claims to serveCoverage collapses to the ~100 pairs with real data; the rest are removed from the product rather than served badly
The mined corpus can be filtered well below 2% misalignmentLoad-bearingThe hallucination rate, which is set in the data pipeline and not at serving timeA design whose filters cannot reach that precision has a hallucination floor no guard removes; the fallback is licensed corpora only, at a fraction of the coverage
Hallucination is detectable at serving time with no referenceLoad-bearingEvery guard, the escalation path, and the tail launch processWithout it the only options are 24M human reviews a day or refusing the highest-risk directions
The tokenizer is fixed before training and cannot be changed afterLoad-bearingWhy fertility is an architecture decision rather than a tuning knobIf vocabularies were swappable on a trained model, script imbalance becomes routine maintenance and this section shrinks to a dashboard
The 60/10 traffic split between the small and large lanesLoad-bearingThe 82%-of-fleet-from-10%-of-traffic result, and the claim that the router beats the cache 27x per point of trafficAt 30/40 the big lane is the system and routing becomes rationing, not optimization
Traffic is concentrated — top 50 pairs are 80% of volumeAsk itThe residency argument against a pairwise fleet, and the offline-first launch processUniform traffic weakens the residency case and makes a global A/B a legitimate launch gate
Bilingual annotators exist for the tail languagesAsk itMQM calibration and the side-by-side preference panelsWhere they do not exist, the pair ships on offline chrF plus the serving quality model, with no human anchor at all
Legal permission to train on crawled web textAsk itThe entire mining half of the data sectionLicensed corpora plus back-translation from licensed monolingual text; coverage falls from 9,900 directions to a few hundred
The peak factor is 2.5Ask itEvery accelerator count, multiplicativelyA property of time-zone distribution, not of the system; measure it, because you cannot guess it
1.2B requests/day, 60 tokens in and 60 out, 100 languagesState itThe fleet sizing and the daily billA re-derivation, nothing structural
A100-class part: 70 GB usable, 2.0 TB/s, $2.00/GPU-hour, 0.7 derateState itEvery step time and every dollar figureDifferent hardware moves both lanes together; the ordering of the levers does not change
256k shared vocabulary at d_model 1,024, temperature T = 5State itThe 262M embedding table, the 66% share, and the 3.98:1 sampling ratioRe-derive; the claim that the embedding dominates a small model survives any plausible size
30% exact-cache hit rateState itOne row of the fleet table and one leverRe-derive the lever table; the router still outranks the cache by an order of magnitude per point of traffic

The sentence that makes this visible to an interviewer: “This design rests on three things. One, a 300 ms p95 budget, which is what forces two lanes and therefore creates the router that is the entire cost architecture. Two, that back-translation’s noise lands on the input side where no gradient flows — that single asymmetry is what lets me serve 9,800 language directions that have no data. Three, that I can detect a hallucination at serving time without a reference translation, because if I cannot, the only honest alternatives are human review at 24 million reviews a day or refusing to serve the languages that need it most.”


Cheat sheet

The one-line answers to the questions this design is most often asked. Everything here is derived above; this is the recall test.

QuestionThe answer, in one line
Why not one model per language pair?9,900 pairs is 7,920 GB of weights and 114 GPUs resident before a request arrives, at 0.045% utilization on the tail
Why does back-translation work?The loss only touches target tokens, so machine noise on the source side is a regularizer and real human text on the target side trains the decoder
Why not forward-translation?It puts machine output where the gradient is, so the model imitates its own errors — chrF +0.6 and hallucination 4.1% -> 5.6%
Why sample rather than beam when back-translating?Beam output is the low-entropy mode; the model overfits to a synthetic input distribution real sources never match
What does temperature sampling buy?T = 5 turns a 1000:1 data imbalance into a 3.98:1 sampling imbalance — a fifth root of three orders of magnitude
Why is one tokenizer decision a fairness issue?Amharic fertility 5.20 against English 1.15 means 4.5x the cost, 4.5x the decode, and 20x the attention, all at once
Why is BLEU untrustworthy?It scores a factual inversion 0.643 and a correct paraphrase 0.000, because a substitution’s cost is set by where in the sentence it lands
Why does chrF agree with BLEU on that example?Coincidence, not a shared flaw — chrF has no positional term at all and ranks by character overlap
What gates a launch at 100 languages?Per-pair offline regression gates: no direction may drop 1.0 chrF or 0.02 COMET on a fixed 500-segment set
Why can’t the tail be A/B tested?7.36M requests per arm against 6,000/day on a 0.05% pair is 3.4 years
What is the actual cost driver?The router: 10% of traffic is 82% of the 278-GPU decode fleet, so 2 points moved is worth 5.4x what 10 points of cache hit rate is — but size the 44-GPU QE guard first, because at $2,112/day it outranks the router lever’s $2,064
What catches a fluent falsehood at serve time?Length ratio, then cross-attention mass below 0.25, then reference-free COMET-QE against a per-pair floor calibrated on MQM

Next: 04 — Assistant Chatbot.