Repository navigation
fix(translation): stop shipping blank strings for terms the protector covers whole - #45
Merged
Merged
Conversation
… covers whole
quality_report_20260810_093056 over a real ru→uk starfield_ru.STRINGS.xml run
reports 344 errors. 343 are EMPTY_TRANSLATION, and they come out of one chain in
which every step is locally correct.
TermProtector matches a term and replaces it with [[TK_…]]. When the match covers
the whole string the model is handed a bare token and can only hand it back, so
restore_text reproduces the source byte for byte — by construction. The final
guard in _translate_single then sees a verbatim copy carrying ы/э/ё/ъ, reads it
as "the model never translated this", and returns None. The row is never filled,
and a re-run repeats every step identically. Measured against the shipped file:
296 of the 343 blank rows.
The trigger was star_system_name, which was
[Cyrillic]+(\s+[Cyrillic]+)*\s+[IVXLCDM]+(?:[-–—]\w+)?
— an unbounded run of Cyrillic words followed by anything spelled out of I V X L
C D M. A lone C or D qualifies, so it claimed «Портативный CD-плеер» (a CD
player), «Шкура ходока четырёхногих карпов C» and «Активировать вспомогательное
питание блока D», and it swallowed the verb of every quest objective
(«Отправиться на Арктур II»). 6,051 rows — 12 % of the file — reached the model
as a bare token. The pattern now takes one capitalised Cyrillic word (internal
hyphens kept, for «Аль-Баттани»), a well-formed Roman numeral rather than a
letter set, and an optional single-letter body suffix; a–f are the only suffix
letters the shipped file uses. tests/test_term_protector_obfuscated_codes.py had
already recorded a symptom of this as an accepted limitation — star_system_name
claiming "код C" so obf_code lost the trailing "$1000" — and that test now pins
the fixed behaviour instead.
star_system_name also sat among STRUCTURAL_PATTERNS, beside <Alias=…> and %s,
where nothing can switch it off. A designation is a proper noun, so it belongs in
SOFT_CATEGORIES under "Protect named entities": with that off, «Брэдбери I» now
reaches the model to be transliterated as «Бредбері I». Whole-file tokenisation
drops 7,748 → 1,697 rows, and 0 of the 104 distinct broken strings stay fully
covered.
With protection on, a fully covered string still has nothing to ask the model,
so _translate_single restores and returns before the network call, and both echo
guards consult _is_protected_verbatim so an intended passthrough is not read as a
failure. _is_untranslated_echo keeps its full authority everywhere else: it
exists because an echoed primary used to be fanned out to every dedup follower
and replayed from cache.
The remaining blanks came from _clean_translation. Its mid-length shrink floor
was not gated on closely_related the way its two immediate neighbours are, so a
correct and legitimately shorter East-Slavic translation was discarded as
garbage: «Сопротивление» → «Опір» (13 → 4) and «Стрельбище» → «Тир» (10 → 3)
both cleaned to "". East-Slavic pairs now get the relaxed floor the surrounding
code already applies to them; the text_len == 1 check still catches an empty
reply either way.
None of that would have broken the game had the export not written the row out
as finished. An SST file is a patch dictionary, so <Dest></Dest> does not read as
"not translated yet" — it reads as "translate this to the empty string", and the
game draws nothing where the planet name should be. Two things in this repo
already had the right policy and were not consulted: parse_sst_xml skips entries
with no <Dest>, and apply_changes_to_file falls back to the original text, so the
same project saved as .strings was fine and saved as .xml was not. write_sst_xml
now omits untranslated rows and returns the count written, which also makes
write→parse lossless, and export_to_xml reports what actually landed in the file
rather than the row count.
The lone MISSING_TAG (string 45533) was a phantom. _COMPILED_PATTERNS compiles
every entry with IGNORECASE, so the printf conversion class also accepted U and
".MG%UYOL;{M&:AF" — Starfield's deliberately-scrambled terminal text — registered
as a missing %u. In C the case is the conversion: %U is not one, and %x and %X
print differently. printf_var is now matched and counted case-sensitively; every
other pattern keeps IGNORECASE, where <BR> and </FONT> are real spellings. Three
phantom matches over the shipped file (%U ×2, %O ×1).
Re-exporting the same project now yields 0 entries with a blank <Dest>, down from
343, and all 343 previously-blank rows either reach the model with real text or
return verbatim without a call. tests/ 1083 passed, including 56 new ones across
test_star_system_protection.py, test_protected_passthrough.py,
test_sst_export_untranslated.py and test_printf_tag_case.py.
UI_OVERFLOW is untouched: it is a visual finding and was out of scope here.
Co-authored-by: BuildBot <build@local>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this fixes
quality_report_20260810_093056, over a real ru→ukstarfield_ru.STRINGS.xmlrun, reports 344 errors — 343 of them
EMPTY_TRANSLATION(severityerror:"Will break in game"). Those rows exported as
<Dest></Dest>, which the gamedraws as nothing at all.
They come out of one chain in which every step is locally correct:
TermProtectormatches a term and replaces it with[[TK_…]]. When the matchcovers the whole string, the model is handed a bare token.
restore_textputs the term back, so the result is byte-identical to thesource by construction.
_is_untranslated_echosees a verbatim copy carryingы/э/ё/ъ, reads it as"the model never translated this", and
_translate_singlereturnsNone.Neither subsystem is wrong on its own; they had no way to tell each other the
verbatim-ness was intended. 296 of the 343 blank rows.
The trigger
star_system_namewas[Cyrillic]+(\s+[Cyrillic]+)*\s+[IVXLCDM]+(?:[-–—]\w+)?—an unbounded run of Cyrillic words followed by anything spelled out of
I V X L C D M. A loneCorDqualifies, so it claimed:Портативный CD-плеерШкура ходока четырёхногих карпов CАктивировать вспомогательное питание блока DОтправиться на Арктур II6,051 rows — 12 % of the file — reached the model as a bare
[[TK_…]]token.Changes
gui/term_protector.pyАль-Баттани), a well-formed Roman numeral rather than a letter set, and an optional single-letter body suffix (a–fare the only ones the shipped file uses).gui/term_protector.pystar_system_namemoved fromSTRUCTURAL_PATTERNSintoSOFT_CATEGORIES. A designation is a proper noun, so "Protect named entities" must govern it — with it off,Брэдбери Ireaches the model to be transliterated asБредбері I.gui/ollama_worker.py_protection_covers_everything/_is_protected_verbatim. A fully covered string restores and returns before the network call, and both echo guards exempt an intended passthrough.gui/ollama_worker.py_clean_translation's mid-length shrink floor gated onclosely_related, as its two immediate neighbours already were.bethesda_strings/xml_handler.pywrite_sst_xmlomits untranslated rows and returns the count written.gui/main_window.pygui/quality_checker.pyprintf_varmatched and counted case-sensitively.Why the export change is the part that broke the game
An SST file is a patch dictionary: an entry claims a finished translation
for its sID.
<Dest></Dest>does not read as "not translated yet" — it reads as"translate this to the empty string".
Two things in this repo already had the right policy and were not consulted:
parse_sst_xmlskips entries with no<Dest>.apply_changes_to_file(the binary.stringspath) falls back to theoriginal text.
So the same project saved as
.stringswas fine and saved as.xmlwas not.Omitting the row restores symmetry with the reader and makes write→parse
lossless.
The second blanking path
_clean_translation's garbage detection discarded correct, legitimately shorterEast-Slavic output — «Сопротивление» → «Опір» (13 → 4) and «Стрельбище» → «Тир»
(10 → 3) both cleaned to
"". Thetext_len == 1check still catches agenuinely empty reply.
The lone
MISSING_TAGwas a phantom_COMPILED_PATTERNScompiled every entry withIGNORECASE, so the printfconversion class also accepted
U, and.MG%UYOL;{M&:AF— Starfield'sdeliberately-scrambled terminal text — registered as a missing
%u. In C thecase is the conversion:
%Uis not one, and%x/%Xprint differently. Everyother pattern keeps
IGNORECASE, where<BR>and</FONT>are real spellings.3 phantom matches over the shipped file (
%U×2,%O×1).Measured
<Dest>All 343 previously-blank rows now either reach the model with real text or return
verbatim without a call.
Tests
1083 passed, including 56 new across
test_star_system_protection.py,test_protected_passthrough.py,test_sst_export_untranslated.pyandtest_printf_tag_case.py.tests/test_term_protector_obfuscated_codes.pyhad already recorded a symptom ofthis over-match as an accepted limitation —
star_system_nameclaimingкод Cso
obf_codelost the trailing$1000. It failed by succeeding; the test nowpins the fixed behaviour.
Out of scope
UI_OVERFLOW(107 warnings) is a visual finding and was deliberately excluded.One thing worth flagging separately: string 45533's translation is genuinely
truncated (826 → 230 chars, 15 sentences → 4, ending in a stray
[). ItsMISSING_TAGwas the phantom tag fixed here, but the content loss is real andthe checker reports it only at info severity, because
SUSPICIOUSLY_SHORTfires below 0.20× and this is 0.28×. Not addressed here — it affects every row in
the file and was not in the critical tier.
🤖 Generated with Claude Code