Skip to content

fix(translation): stop shipping blank strings for terms the protector covers whole - #45

Merged
0xra0 merged 1 commit into
mainfrom
fix/empty-translations-protected-passthrough
Aug 25, 2026
Merged

0xra0 merged 1 commit into
mainfrom
fix/empty-translations-protected-passthrough

Conversation

@0xra0

@0xra0 0xra0 commented Aug 25, 2026

Copy link
Copy Markdown
Owner

What this fixes

quality_report_20260810_093056, over a real ru→uk starfield_ru.STRINGS.xml
run, reports 344 errors — 343 of them EMPTY_TRANSLATION (severity error:
"Will break in game"). Those rows exported as <Dest></Dest>, which the game
draws as nothing at all.

They come out of one chain in which every step is locally correct:

  1. TermProtector matches a term and replaces it with [[TK_…]]. When the match
    covers the whole string, the model is handed a bare token.
  2. The model returns the token untouched — all it was given.
  3. restore_text puts the term back, so the result is byte-identical to the
    source by construction.
  4. _is_untranslated_echo sees a verbatim copy carrying ы/э/ё/ъ, reads it as
    "the model never translated this", and _translate_single returns None.
  5. The row is never filled, and a re-run repeats every step identically.

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_name 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:

string what it actually is
Портативный CD-плеер a portable CD player
Шкура ходока четырёхногих карпов C an item name
Активировать вспомогательное питание блока D a quest objective
Отправиться на Арктур II a quest objective — the verb was swallowed too

6,051 rows — 12 % of the file — reached the model as a bare [[TK_…]] token.

Changes

File Change
gui/term_protector.py Pattern requires 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 ones the shipped file uses).
gui/term_protector.py star_system_name moved from STRUCTURAL_PATTERNS into SOFT_CATEGORIES. A designation is a proper noun, so "Protect named entities" must govern it — with it off, Брэдбери I reaches the model to be transliterated as Бредбері I.
gui/ollama_worker.py New _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 on closely_related, as its two immediate neighbours already were.
bethesda_strings/xml_handler.py write_sst_xml omits untranslated rows and returns the count written.
gui/main_window.py Export reports what actually landed in the file, plus how many rows were left out.
gui/quality_checker.py printf_var matched 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_xml skips entries with no <Dest>.
  • apply_changes_to_file (the binary .strings path) falls back to the
    original text
    .

So the same project saved as .strings was fine and saved as .xml was 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 shorter
East-Slavic output — «Сопротивление» → «Опір» (13 → 4) and «Стрельбище» → «Тир»
(10 → 3) both cleaned to "". The text_len == 1 check still catches a
genuinely empty reply.

The lone MISSING_TAG was a phantom

_COMPILED_PATTERNS compiled 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/%X print differently. Every
other pattern keeps IGNORECASE, where <BR> and </FONT> are real spellings.
3 phantom matches over the shipped file (%U ×2, %O ×1).

Measured

before after
whole-file tokenisation (named entities off) 7,748 rows 1,697
of the 104 distinct broken strings, still fully covered 88 0
re-export of the same project: entries with a blank <Dest> 343 0

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.py and
test_printf_tag_case.py.

tests/test_term_protector_obfuscated_codes.py had already recorded a symptom of
this over-match as an accepted limitation — star_system_name claiming код C
so obf_code lost the trailing $1000. It failed by succeeding; the test now
pins 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 [). Its
MISSING_TAG was the phantom tag fixed here, but the content loss is real and
the checker reports it only at info severity, because SUSPICIOUSLY_SHORT
fires 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

… 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>
@0xra0
0xra0 merged commit 97aa07e into main Aug 25, 2026
4 of 6 checks passed
@0xra0
0xra0 deleted the fix/empty-translations-protected-passthrough branch August 25, 2026 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant