`truncated` compared character counts against a flat 0.5 for every language. A
logographic script carries a morpheme per character, so a complete Chinese
rendering of an English passage runs at roughly a third of its length and the
predicate read it as a dropped clause.
Measured against the deployed LibreTranslate, re-translating 15 refused sources
per language: zh 15 of 15 refused, ja 11 of 15, ko 10 of 15, while 44 of those
45 ended in terminal punctuation, so they were complete sentences. The ratios
clustered between 0.28 and 0.55; a real truncation scatters and stops
mid-sentence. 0.25 sits below every complete rendering measured and still
catches a passage cut to a quarter.
The language set is measured, not guessed: zh, ko and ja account for 5553 of
the 6135 truncation refusals, 90.5%. The next language down is at 116, small
enough to be genuine truncation, so it keeps the flat ratio.
`collapsed` already reasons this way for the same reason, its docstring naming
Chinese explicitly; `truncated` never got the same treatment. `reason` already
carried the language for `untranslated` and now passes it on.
Effect on the catalogs, from the run rather than a test: zh 4153 truncation
refusals to 20, ko 816 to 0, ja 584 to 0, and 5590 entries that had been stuck
are now translated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A server that gives up on a passage returns a word: four distinct sources
of forty characters and more all came back as `Name`, and the catalogs kept
it. Every predicate passed, because a label carries no protected span, no
name, no markup and no stutter, and `truncated` does not look below 120
characters, which is where these sources end.
`collapsed` covers that band. Its ratio is a third of the truncation one so
a language that genuinely says the same in fewer characters survives:
Chinese renders a 58-character source in 17, a fifth of it, while the label
that prompted the rule is a fifteenth. It joins `reason`, so the client
drops such an answer and the release gate reports one that got in anyway.
This does not close the whole gap the collapse check polices. An answer
that is short but not a fragment, `E-poçt ünvanı` for several distinct
sources, sits between this ratio and a legitimate Chinese rendering; no
length rule separates them, because what is wrong with it is that another
entry already has it. That needs the writer to carry per-catalog state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bare mark told an operator that an entry is unanswerable without saying
what came back for it, so the verdict could not be judged and the only way
to see the criterion fire was to run the translator again. The rejection now
travels with its text: `unmask` returns a `Rejected` carrying the restored
translation and the criterion it broke, and the entry records both as
comments beside the mark.
# translation-refused: libretranslate structure
# nocheck: url
# translation-rejected: Siehe [den Leitfaden]](https://gone.invalid/a)
msgid "See [the guide](https://gone.invalid/a) first."
msgstr ""
The quote is a comment and never a msgstr, because it is exactly what the
consumers must not use.
`harms` keeps being the predicate both ends of the pipeline ask, but it is
now the boolean face of `reason`, which names the criterion instead of
folding every check into one `or` chain. The token failures `unknown-token`,
`lost-token` and `empty` join the criteria the catalog can report.
Two details are load-bearing. The `nocheck` sits on a line of its own above
the quote: babel wraps a long comment, and appended behind the text the
marker landed one line below the URL it has to cover, where the block-above
resolver no longer sees it. And the cleanup drops a `nocheck` only where a
quote follows it, because Sphinx carries a source comment into the catalog
as a user comment and removing one would let the URL probe loose on the
msgid.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ruff format reflows long calls and normalises quotes, isort reorders the
imports of the documentation lint package, SIM117 folds nested `with`
statements into one parenthesised block, TC003 moves typing-only imports
under `if TYPE_CHECKING`, the JavaScript linter replaces a `var`, and the
YAML formatter rewraps five role files. None of it changes behaviour.
One entry does more than that: the formatter had reflowed two calls in
test_builder.py so their `# nocheck: cache-read` markers landed on the
closing parenthesis, where they suppress nothing and the cache-read lint
would have reported two real violations. Both calls are hoisted into a
variable so the marker sits on the reading line again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The server answers a failed call with its input, sometimes lightly
reworded and sometimes with a word repeated. Both shapes pass every
existing criterion: the protected spans survive, the markup count
survives, it is no echo, and truncated() only watches the short side.
stutters() reports a word the translation repeats three times in a row
and the source does not. untranslated() reports a non-Latin target whose
prose is at least 90% ASCII words, which needs the catalog language, so
harms() and unmask() now take it and client.py passes its target through.
Measured over 580079 translated entries: 3650 stutters, 611 still in
Latin script, 2 both, 4259 newly reported. None of the source messages
repeats a word three times itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client seeds every entry with its own source and replaces only the ones that carry words, so when the server answers with its input the seed stays, gets the libretranslate marker, and is neither pending nor damaged. 542 entries across 439 msgids were plain English while counting as translated, and nothing would ever retry them.
harms() now rejects an identity mapping once the source carries 30 letters outside its protected spans. prose() is has_words' inner half, extracted so the threshold measures what the translator actually had to translate; has_words keeps its signature and behaviour.
The floor is measured, and two earlier shapes were wrong: a raw length of 30 condemned '', and counting prose characters condemned backtick lists of role names whose 75 characters were commas. Counting letters leaves 439 msgids, every one of them translated by at least one other catalog, led by ar with 327 against de with 25 - if identity were legitimate it would cluster in the languages closest to English, not the furthest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The client seeds every entry with its own source and only replaces the ones that carry words, so a word-free msgid keeps the source verbatim. That was harmless until 9679fa4221: tighten and resegment judge a translation against a rule rather than against its source, so an identity mapping became condemnable as soon as the source itself broke the rule. The gate then reported a source defect as translation damage and prune could not help, because the next run seeds the same source again.
Return False when the translation equals its source. A source that breaks a rule is what the source-side lints are for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_i18n_catalogs.py:125 and :141 report a translation whose emphasis carries a space or whose sentence boundary was swallowed, and the first one's failure message says to run prune and translate again. prune clears through damaged(), which did not know either criterion, so those entries survived every prune and the advice was impossible to follow.
Add both to harms(). The writer does not get stricter: unmask() applies tighten and resegment as repairs before it asks harms(), so a freshly written entry satisfies them by construction, and a test pins that. Measured over every checked-in catalog, 581220 translated entries: 599 condemned before, 599 after, 0 newly condemned.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
unmask() accepted a translation as soon as protected_spans matched, while damaged() also rejected it for a dropped name, a halved body or changed markup. Three of the four criteria lived only in the gate, so every run wrote entries the gate then condemned, make i18n-prune emptied them and the next run wrote them back. It never converged: ko carries 331 such entries, 3.7 percent of what it has translated, against 0.1 percent or less for Latin scripts.
Introduce harms(source, translation) in placeholders.py as the single source of truth and call it from both ends. truncated() and structure() move there with it, since translate.py already imports placeholders and the other direction would be a cycle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2aa7325eca put role names and product titles into the spans mask() produces, and
unmask hands those same spans to resegment. That was safe for every span the
protection had before, because each ends in a delimiter, so a letter right
behind it can only be the text a lost boundary was taken from. A bare name ends
in a word character, and Bengali writes `Baserowo` where the source says
`Baserow`, so the substring search found the name inside the longer word and put
the punctuation in the middle of it:
resegment('Baserowo-r shathe', ('Baserow',), 'Adapter to Baserow: ...')
-> 'Baserow: o-r shathe'
Names are skipped. The repair still fires for the spans it was written for, and
the 85 entries the boundary lint reported across et, fi, eu, cs, ja and bn drop
to none.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured over the entries no rule reported, a machine translation loses the
sentence's full stop in 12417 cases, the capital of its opening word in 2139,
and opens a run of spaces around a protected span in 610. None of them touches
the meaning, so the answer is worth keeping and worth repairing rather than
discarding.
The full stop goes missing when a closing bracket moves in front of a protected
span and takes the punctuation behind it along, which leaves the sentence
running into the next one. resegment already puts back a boundary that went
missing mid-text; terminate does the same at the end.
collapse leaves a run the source carries itself alone, because an aligned block
in a code sample means its spacing.
Truncation cannot be repaired, only reported, and joins the damaged set with a
floor of 120 characters: `Situation` becomes `Lage` and halves its length while
saying the same thing, so a shorter entry proves nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>