段階公開を 5% で回していた壁紙アプリに、特定の言語の端末からだけクラッシュ報告が上がってきた夜がありました。直前に触ったのはアプリ内文言の差し替えだけで、コードには手を入れておりません。
訳文を上から順に読み返しました。どれも自然で、意味も合っています。読んでいる限り、どこにも異常が見当たりませんでした。
原因が見えたのは、訳文を「文章」として読むのをやめ、記号だけを縦に並べたときでした。原文の %1$@ が、訳文では %@ になっていたのです。
訳の良し悪しを見ている限り、書式指定子の壊れは一生見つかりません。 語彙の問題ではなく、構文の問題だからです。
落ちていたのは意味ではなく、引数の受け渡しでした
書式指定子は、実行時に値を差し込む穴です。iOS の String(format:) も Android の String.format も、この穴の個数と型を頼りに引数を割り当てます。
翻訳モデルは文を訳します。穴は文の一部として扱われるので、語順を組み替える過程で位置番号が落ちたり、プラットフォームの流儀に合わせて書き換えられたりします。悪意も不注意もなく、ただ「自然な訳文」を作った結果として起きます。
まず、引数が足りない/型が合わないときに何が起きるかを手元で確かめました。Python の % 書式で確認した実際の出力です。
"%s / %s" に1つだけ渡す: TypeError: not enough arguments for format string
"%d" に文字列を渡す: TypeError: %d format: a real number is required, not str
"%(n)s" に空の辞書: KeyError: 'n'
"%s" に2つ渡す: 成功 -> 'a' ← 例外にならない
最後の行が大事でした。穴が余ると落ち、穴が足りないと落ちません。 Android の String.format も同じ非対称で、供給されない引数を参照すると MissingFormatArgumentException、型が合わなければ IllegalFormatConversionException を投げますが、引数を使い残しても例外は出ません。
つまり「指定子が消えた」と「指定子が増えた」は、まったく別の事故です。前者は情報が黙って欠けるだけ、後者は端末が落ちます。同じ「不一致」として一律に扱うと、止めるべきものを止められず、止めなくてよいものでリリースが止まります。
壊れ方を並べると、六つに収まりました
手元で拾えた壊れ方を分類してみたところ、次の六つに収まりました。
壊れ方 原文 訳文 結果
位置番号の消失 %1$@ 件を %2$d 秒で%@ items in %d seconds順序が言語依存になる
流儀の取り違え %1$@%1$siOS 側で不正
指定子の欠落 %1$@ を %2$@ にMoved %1$@情報が消える
指定子の増加 %1$@ を保存Saved %1$@ to %2$@実行時に落ちる
字形の破損 %1$@%1$@ / % 1$@指定子として読まれない
エスケープの喪失 %1$d%%%1$d%裸の % が残る
字形の破損は、日本語話者が中間に入る運用だと特に出やすいと感じています。全角の % は目視でまず見分けがつかず、本番運用に出てから端末のクラッシュとして返ってくるのがいちばん痛い落とし穴でした。
回避の順番も、この分類のおかげではっきりしました。落ちる四つを機械で止め、落ちない二つは人に返します。
指定子だけを取り出す
検査の第一歩は、訳文から指定子だけを機械的に抜き出すことでした。iOS の %1$@、Android の %1$s、そして辞書形式の {count} を、ひとつの抽出器で扱います。
# fmtcheck.py — 書式指定子の抽出と重大度つきの照合
import re
from collections import Counter
PRINTF = re.compile(
r "% (?:(\d + ) \$ ) ? ([ -+ 0# ] * )(\d +| \* ) ? (?: \. (\d +| \* )) ? (?: hh | h | ll | l | q | z | t | L ) ? ([ @sdiufFeEgGxXoc% ]) "
)
BRACE = re.compile( r " \{ ([ ^ {} \s] + ) \} " ) # 訳者が中身ごと訳す事故を拾うため広めに取ります
BROKEN_GLYPH = re.compile( r " [ %$@ ] | % \s + \d + \s * \$ | % \s + [ @sd ] " )
LITERAL_PCT = re.compile( r "%%" )
NUMERIC = set ( "diufFeEgGxXo" )
def tokens (text):
"""(位置番号, 変換文字) の並びを返します。位置番号が無ければ None です。"""
out = []
for m in PRINTF .finditer(text):
if m.group( 5 ) == "%" :
continue # %% はリテラルで引数を消費しません
out.append(( int (m.group( 1 )) if m.group( 1 ) else None , m.group( 5 )))
for m in BRACE .finditer(text):
out.append((m.group( 1 ), " {} " ))
return out
def _kind (conv):
if conv == " {} " :
return "named"
return "number" if conv in NUMERIC else "text"
正規表現で気をつけた点が二つあります。
ひとつは %% を必ず読み飛ばすことです。リテラルのパーセント記号は引数を消費しないので、これを指定子として数えると原文と訳文が永遠に一致しません。
もうひとつは {...} の中身を広く取ったことです。最初は [A-Za-z_][A-Za-z0-9_]* に限定しておりました。ところが {count} を {件数} と訳された行が、その正規表現では「原文の count が消えた」としか見えず、「知らない名前が増えた」ほうを取り逃していました。増加は落ちる側の事故ですので、これは見逃してはいけない取り逃しでした。
照合の規則は、位置番号の有無で変えます
ここが実装の分かれ目でした。
位置番号(%1$@ の 1$)が原文にあるなら、訳文で語順が変わっても構いません。番号が引数を指し示すので、順序は自由です。見るべきは個数と型だけになります。
位置番号が無いなら、話が逆になります。%@ と %d は現れた順に引数を食べるので、並び順そのものが引数の割り当て です。順序が変わった時点で型が入れ替わり、落ちます。
def check (src, dst):
findings = []
s, d = tokens(src), tokens(dst)
# リテラルの %% は token に現れないため、別に数えます
ls, ld = len ( LITERAL_PCT .findall(src)), len ( LITERAL_PCT .findall(dst))
if ls != ld:
findings.append(( "crash" , "literal_percent" , (ls, ld)))
g = BROKEN_GLYPH .search(dst)
if g:
findings.append(( "crash" , "broken_glyph" , g.group( 0 )))
if any (a is not None for a, _ in s):
# 位置番号がある場合 — 順序は問わず、個数と型だけを見ます
if any (a is None for a, _ in d):
findings.append(( "crash" , "lost_argnum" , [t for t in d if t[ 0 ] is None ]))
cs, cd = Counter(s), Counter(d)
for tok in (cd - cs).elements():
# 同じ番号の使い回しは合法なので、増加でも review に落とします
findings.append(( "review" if tok in cs else "crash" , "extra" , tok))
for tok in (cs - cd).elements():
findings.append(( "loss" , "missing" , tok))
types_s = {a: c for a, c in s if a is not None }
types_d = {a: c for a, c in d if a is not None }
for a, c in types_d.items():
if a in types_s and _kind(types_s[a]) != _kind(c):
findings.append(( "crash" , "type_mismatch" , (a, types_s[a], c)))
else :
# 位置番号が無い場合 — 並び順が引数の割り当てそのものです
seq_s = [c for _, c in s]
seq_d = [c for _, c in d]
if len (seq_d) > len (seq_s):
findings.append(( "crash" , "too_many" , seq_d))
elif len (seq_d) < len (seq_s):
findings.append(( "loss" , "too_few" , seq_d))
elif seq_s != seq_d:
findings.append(( "crash" , "sequence_type_shift" , seq_d))
return findings
def worst (findings):
for level in ( "crash" , "loss" , "review" ):
if any (f[ 0 ] == level for f in findings):
return level
return "ok"
重大度を三段にしたのは、運用を止めないためでした。crash は配信を止めます。loss は取り込みつつ翻訳者に戻します。review は人の目に回すだけで、機械では止めません。
この場合は、止める側を狭く保つことのほうが大事でした。私は最初、不一致をすべて crash にしておりました。正しい訳まで弾かれてリリースが二日遅れ、そこでようやく段を分ける必要に気づいたのです。
集合ではなく多重集合(Counter)で比べている点も、手元で測って決めました。次は実際の出力です。
src='%1$@ を %2$@ に移動しました'
dst='Moved %2$@ to %1$@ and %1$@'
集合比較=一致 / 多重集合比較=不一致
集合で比べると、同じ番号が二度出ていることを見逃します。%1$@ の使い回し自体は合法ですので落ちはしませんが、原文に無い繰り返しが増えている以上、黙って通す状態にはしたくありませんでした。だから review という中間の段を置いたのです。
手元のデータで測った結果
十七通りの原文と訳文の組を用意して、実際に走らせました。次が実行結果そのままです。
id 重大度 最初の検出
ok_positional_swap ok —
ok_same ok —
ok_percent_literal ok —
ok_brace ok —
meaning_swap ok —
lost_argnum crash ('crash', 'lost_argnum', [(None, '@'), (None, 'd')])
platform_token_swap crash ('crash', 'extra', (1, 's'))
dropped_one loss ('loss', 'missing', (2, '@'))
added_one crash ('crash', 'extra', (2, '@'))
type_mismatch crash ('crash', 'extra', (1, '@'))
fullwidth crash ('crash', 'broken_glyph', '%')
space_injected crash ('crash', 'broken_glyph', '% 1$')
seq_reordered crash ('crash', 'sequence_type_shift', ['d', '@'])
brace_translated crash ('crash', 'extra', ('件数', '{}'))
brace_dropped loss ('loss', 'missing', ('count', '{}'))
percent_unescaped crash ('crash', 'literal_percent', (1, 0))
reuse_positional review ('review', 'extra', (1, '@'))
内訳: {'ok': 5, 'crash': 9, 'loss': 2, 'review': 1} / 全 17 件
percent_unescaped の行が、いちばん学びになりました。%% が % に変わる壊れ方は、指定子の照合だけでは拾えないことがあります。次は同じ原文に対して四つの訳文を通した実測です。
'Free %1$d%% left' tokens=[(1, 'd')] 判定=ok
'Free %1$d% left' tokens=[(1, 'd'), (None, 'e')] 判定=crash
'Free (%1$d%)' tokens=[(1, 'd')] 判定=ok ← 素通り
'Free %1$d%' tokens=[(1, 'd')] 判定=ok ← 素通り
裸の % の直後に e のような変換文字が来ると、たまたま指定子として読まれて検出されます。直後が ) や行末だと、何事もなかったように通ります。同じ壊れ方が、次の一文字によって見つかったり見つからなかったりしました。 ここに気づいてから、%% の個数を別に数える行を足しました。上の表で literal_percent が出ているのはその追加分です。
ゲートは CI ではなく、取り込み口に置きました
最初は CI に置こうと考えました。やめたのは、CI に着く頃には訳文がもうリポジトリに入っており、差し戻しの単位が「コミット」になってしまうからです。
翻訳結果を受け取った直後に検査すれば、差し戻しの単位は「行」で済みます。
# gate.py — 訳文ファイルを受け取った直後に走らせます
import re, sys
from fmtcheck import check, worst
STRINGS = re.compile( r ' ^\s * " ((?:[ ^ " \\ ] | \\ .) * ) " \s * = \s * " ((?:[ ^ " \\ ] | \\ .) * ) " \s * ;' , re.M)
def load_strings (path):
with open (path, encoding = "utf-8" ) as f:
return dict ( STRINGS .findall(f.read()))
def run (base_path, target_path):
base, target = load_strings(base_path), load_strings(target_path)
rows = []
for key, src in base.items():
dst = target.get(key)
if dst is None :
rows.append((key, "loss" , [( "loss" , "missing_key" , key)]))
continue
f = check(src, dst)
rows.append((key, worst(f), f))
return rows
if __name__ == "__main__" :
rows = run(sys.argv[ 1 ], sys.argv[ 2 ])
order = { "crash" : 0 , "loss" : 1 , "review" : 2 , "ok" : 3 }
rows.sort( key =lambda r: order[r[ 1 ]])
blocked = sum ( 1 for _, level, _ in rows if level == "crash" )
for key, level, f in rows:
print ( f "[ { level :<6 } ] { key } : { f if f else '—' } " )
print ( f " \n crash { blocked } 件 / 全 { len (rows) } 件" )
sys.exit( 1 if blocked else 0 )
四行だけのドイツ語ファイルに通した実際の出力です。
[crash ] storage: [('crash', 'broken_glyph', '%'), ('loss', 'missing', (1, 'd'))]
[crash ] retry: [('crash', 'sequence_type_shift', ['d', '@'])]
[loss ] move_done: [('loss', 'missing', (2, '@'))]
[ok ] add_done: —
crash 2 件 / 全 4 件
exit=1
add_done が ok になっている点を補足します。この行の訳文は Added %2$d wallpapers in %1$@ seconds で、原文とは指定子の順序が入れ替わっています。位置番号が付いているので、これは正しい訳です。順序の入れ替わりを一律に弾く実装だと、正しい訳がここで止まります。
伏字は効きますが、伏字そのものが壊れます
再翻訳のときは、指定子を伏字に置き換えてから渡すようにしました。%1$@ のままだと、訳文の一部として組み替えられてしまうからです。
# mask.py — 指定子を退避してから翻訳へ渡します
import re
from fmtcheck import PRINTF , BRACE
TOKEN = "§ {} §" # 訳されにくく、既存文と衝突しにくい記号で挟みます
BACK = re.compile( r "§ (\d + ) §" )
def mask (text):
slots, idx = [], 0
def take (m):
nonlocal idx
if m.group( 0 ) == " %% " :
return m.group( 0 )
slots.append(m.group( 0 )); idx += 1
return TOKEN .format(idx - 1 )
out = PRINTF .sub(take, text)
out = BRACE .sub(take, out)
return out, slots
def unmask (text, slots):
missing = []
def put (m):
i = int (m.group( 1 ))
if i >= len (slots):
missing.append(i); return m.group( 0 )
return slots[i]
restored = BACK .sub(put, text)
used = { int (m.group( 1 )) for m in BACK .finditer(text)}
return restored, sorted ( set ( range ( len (slots))) - used), missing
ここで期待していたのは「伏字なら壊れない」でした。実際に五通りの戻り方を通してみると、そうはなりませんでした。
伏字後 : §0§ 件の壁紙を §1§ 秒で追加しました(空き §2§%%)
退避 : ['%1$@', '%2$d', '%3$d']
そのまま戻る 未使用の退避 = [] / 未知の伏字 = []
順序が入れ替わる 未使用の退避 = [] / 未知の伏字 = []
伏字が1つ消える 未使用の退避 = [1] / 未知の伏字 = []
伏字が訳される 未使用の退避 = [0] / 未知の伏字 = []
伏字が増える 未使用の退避 = [] / 未知の伏字 = [3]
「伏字が訳される」は、§0§ が ⟪0⟫ のような別の記号に置き換わって返ってきた場合です。復元はできず、§ のまま探しても見つかりません。それでも検知はできました。退避した箱のうち、使われなかった番号を数えればよいのです。 伏字が消えたのか訳されたのかは区別できませんが、どちらも「その行は採用しない」で同じ扱いになるので、区別する必要がありませんでした。
伏字を使うようになってからも、unmask の戻り値を必ず見るようにしております。復元した文字列だけを受け取って先へ流すと、この検知が丸ごと無駄になります。
壊れた行だけを訳し直す
crash と判定された行だけを集め、伏字にして訳し直します。全体をもう一度投げ直さないのは、費用の話だけではありません。通っていた行が次の生成で壊れる可能性を、わざわざ作らないためです。
# repair.py — crash の行だけを、最大 2 周まで訳し直します
import json, os
from anthropic import Anthropic
from fmtcheck import check, worst
from mask import mask, unmask
client = Anthropic( api_key = os.environ[ "ANTHROPIC_API_KEY" ])
MODEL = "claude-sonnet-5"
SYSTEM = (
"You translate short UI strings. The input contains opaque markers like §0§. "
"Copy every marker verbatim, keep the same count, never translate or reformat them. "
"Return JSON only: an object mapping each key to the translated string."
)
def translate_batch (items, target_lang):
masked, slotmap = {}, {}
for k, src in items.items():
masked[k], slotmap[k] = mask(src)
msg = client.messages.create(
model = MODEL ,
max_tokens = 2000 ,
system = SYSTEM ,
messages = [{
"role" : "user" ,
"content" : f "Target language: { target_lang }\n "
f "Strings: \n{ json.dumps(masked, ensure_ascii = False , indent = 2 ) } " ,
}],
)
raw = json.loads(msg.content[ 0 ].text)
out = {}
for k, translated in raw.items():
restored, unused, unknown = unmask(translated, slotmap[k])
# 伏字が欠けた/増えた行は、訳文を見る前にここで落とします
out[k] = None if (unused or unknown) else restored
return out
def repair (base, target, lang, max_rounds = 2 ):
fixed, log = dict (target), []
for rnd in range ( 1 , max_rounds + 1 ):
broken = {k: base[k] for k in base
if worst(check(base[k], fixed.get(k, "" ))) == "crash" }
if not broken:
log.append((rnd, 0 , "打ち切り: crash なし" ))
break
got = translate_batch(broken, lang)
applied = 0
for k, v in got.items():
if v is None :
continue
if worst(check(base[k], v)) != "crash" : # 直っていなければ採用しません
fixed[k] = v
applied += 1
log.append((rnd, len (broken), f "採用 { applied } 件" ))
still = [k for k in base if worst(check(base[k], fixed.get(k, "" ))) == "crash" ]
return fixed, still, log
translate_batch を差し替えて、制御の流れだけを手元で確かめた出力です。一周目で二件、二周目で残りの一件が直る想定で走らせました。
round 1: 対象 3 件 -> 採用 2 件
round 2: 対象 1 件 -> 採用 1 件
残った crash: []
add_done [ok ] Added %1$@ wallpapers in %2$d seconds
storage [ok ] Uses %1$d%% of free space
retry [ok ] Tried %@ times, %d attempts
move [loss ] Moved %1$@
move が loss のまま残っている点が、この設計の意図どおりです。情報が落ちているだけの行を自動で書き換えると、訳の意図まで機械が決めることになります。落ちない壊れ方は人に返す、という線を引きました。
周回に上限を置いたのも、同じ理由です。二周して直らない行は、指示の書き方かもとの文の作りに原因があります。三周目を回しても、同じところを回るだけでした。
この検査で止められないもの
正直に書き残しておきたいことがあります。この仕組みで止められるのは、落ちる壊れ方だけです。
src='%1$@ から %2$@ へコピーしました'
dst='Copied from %2$@ to %1$@'
判定=ok
これは指定子の個数も型も一致しており、実行時にも落ちません。ただし引数の中身は入れ替わっているので、画面には「コピー先からコピー元へ」と表示されます。機械では通り、人が読めば分かる種類の誤りです。
だから重大度に review を残しました。全部を機械で決めようとせず、機械が確実に言えることだけを機械に言わせています。この線引きは、Claude API の構造化レスポンスを多層で検証する実装メモ で扱った考え方とも重なります。翻訳のパイプライン全体の組み方は多言語翻訳を本番運用する設計 に、画像側の多言語展開はスクショPSDを15言語へ展開した記録 にまとめてあります。
個人開発で多言語を回していると、翻訳の質を上げる話ばかりに目が向きます。私自身、用語集とスタイル指示を整えることに時間を使いすぎておりました。結果は芳しくありませんでした。用語集は語彙を揃えますが、構文は守ってくれないのです。
訳文の良し悪しは人が見て、指定子の同一性は機械が見ます。 この一行を先に決めてから、やっと夜に落ち着いて配信できるようになりました。
次の一歩
いま使っている翻訳出力を一つ選んで、原文と訳文の指定子を並べるだけの十行を書いてみていただければと思います。tokens() の部分だけで足ります。重大度も修復ループも、その並びを見てから足せば間に合います。
私も、最初はその十行から始めました。お読みいただきありがとうございました。