英語版のページを開いたのは、読んでくださった方から一行だけの知らせが届いた朝でした。手順のところにコードが見当たりません、という内容です。
日本語版を開くと、コードブロックが 25 個並んでおりました。英語版には、ひとつもありませんでした。
本数は合っておりました。日本語 1,057 本に対して英語 1,057 本。私が push の前に毎回数えていたのは、この数字です。片方の言語だけが欠けると言語切り替えで 404 が出ますので、そこには門番を置いてありました。
けれど、その門番が見ていたのはファイルの数でした。揃っていたのはファイルの数だけで、中身の数ではありませんでした。
一本ずつ目視するには 1,057 ペアは多すぎます。そこで、翻訳しても増減しないはずのものだけを数え直しました。以下はその記録です。
数え直したのは、ファイルではなく中身です
数える対象は、言語が変わっても個数が変わらないはずのものに絞りました。コードブロック(フェンスで囲まれた塊)、H2 見出し、HTML テーブル、記事内リンク、そして frontmatter のいくつかのキーです。文章そのものは比べておりません。翻訳された文章は当然ちがう形をしているからです。
結果はこうなりました。
数えたもの ずれていたペア 全 1,057 ペアに占める割合
ファイルの有無(英語版の欠落) 0 0%
frontmatter の premium 1 0.1%
frontmatter の level 1 0.1%
frontmatter の tags の個数 12 1.1%
frontmatter の date 16 1.5%
HTML テーブルの個数 18 1.7%
記事内リンクの本数 30 2.8%
コードブロックの個数 148 14.0%
H2 見出しの個数 196 18.5%
コードをまったく含まないペアが 49 組ありますので、コードのある 1,008 ペアに限れば、コードブロックのずれは 14.7% になります。七本に一本です。
見て手が止まったのは、上の三行と下の二行の落差でした。私が門番を置いていた場所——premium と level——のずれは、1,057 ペア中わずか 2 件です。門番が働いていない本文側で、196 件ずれておりました。
検査は、通っている場所ばかりを見張ります。通っていない場所は、通っていないことにさえ気づけません。
英語版だけコードが消えていた四本
いちばん重い症状から拾いました。日本語版にコードブロックがあって、英語版がゼロになっているペアです。四本ありました。
日本語版のコードブロック 英語版 本文の文字数(日→英)
25 個 0 個 10,708 → 6,944
23 個 0 個 7,980 → 5,025
16 個 0 個 14,772 → 2,926
1 個 0 個 2,592 → 4,912
上の三本は、手順を追いに来た読者にとっては本文がまるごと役に立ちません。四本目は逆に英語版のほうが長く、それでもコードだけが落ちておりました。
もう一本、22 個から 3 個へ減っていたペアもありました。ゼロではないぶん、一覧では健全に見えます。こういう半端な欠けが、いちばん長く残ります。
文字数の比で見張ろうとして、うまくいきませんでした
最初に思いついたのは、いちばん安い方法でした。日本語版と英語版の文字数の比を取って、あまりに離れていたら止める、というものです。実装は数行で済みます。
ところが、実際に 1,057 ペアの比を並べてみると、前提から間違っておりました。
英語 ÷ 日本語(本文の文字数) 値
下位 5% 1.16
下位 25% 1.40
中央値 1.56
上位 25% 1.75
上位 5% 2.20
日本語は一文字あたりの情報量が多いので、同じ内容でも英語版は 1.5 倍前後の文字数になります。健全な状態の中心が 1.0 ではなく 1.56 だったのです。
ここに「1.0 を中心に上下 30%」という帯を張ると、帯に収まるのは 1,057 ペアのうち 134 本だけでした。残る 87% が警告になります。届いた翌週には、誰も見ない一覧になっていたはずです。
では中心を 1.56 に寄せれば済むのかというと、そうでもありませんでした。コードが 25 個から 0 個へ落ちた記事の比は 0.65 です。低いほうへ外れてはいますが、「英語版が短め」という理由だけで同じ位置に来るペアが他にもいくつもあります。欠落と省筆を、量の比だけでは区別できません。
量の比は、欠落の証拠になりません。 数えるべきは、翻訳を通しても増減しないものだけです。この線引きに辿り着くまで、私は帯の幅を三度いじって時間を溶かしました。
ずれの向きは、私の予想と逆でした
もうひとつ、事前の見立てが外れておりました。
私は「翻訳の途中で落ちる」と思い込んでおりました。英語版が痩せていく方向のずれを想像していたのです。実際に向きを数えると、こうなりました。
ずれの向き コードブロック H2 見出し
英語版のほうが少ない 52 ペア 74 ペア
英語版のほうが多い 96 ペア 122 ペア
多数派は「英語版のほうが増えている」でした。落ちたのではありません。いま思えば、痩せていく方向を疑った時点で、私は見る場所を半分に狭めてしまっていたのかもしれません。片方だけに手を入れた回数が積み重なって、二本が別の記事へ分岐していたのです。
心当たりはありました。日本語版を直したあと、英語版へ同じ手を入れ忘れます。逆に、英語で読み返していて説明が足りないと感じ、英語版にだけ節を足します。一回ごとは小さな判断です。それが 1,057 ペア分たまると、対訳のつもりだったものが二本の別々の記事になります。
片方だけを直す作業は、翻訳の劣化ではなく分岐を生みます。 劣化なら気づけますが、分岐は両方とも読めてしまうので気づけません。
検査は 40 行で足ります
数えるものが決まってしまえば、実装は短く済みます。リポジトリの直下に置いて走らせている全文です。修正はしません。見つけて並べるところまでにしております。
#!/usr/bin/env python3
"""日英ペアの構造パリティ検査。しきい値を超えたペアだけを標準出力へ並べます。"""
import os
import re
import sys
ROOT = "content/articles"
FENCE = "`" * 3 # コードフェンスの記号そのもの
FENCE_TOL = 3 # コードブロック個数の許容差
H2_TOL = 3 # H2 個数の許容差
def body_of (path):
text = open (path, encoding = "utf-8" ).read()
m = re.match( r " ^ --- \n . *? \n --- \n (. * )$ " , text, re.S)
return m.group( 1 ) if m else text
def shape (body):
# フェンスは開き閉じで 2 個ずつ数えられるので 2 で割ります
return {
"fence" : body.count( FENCE ) // 2 ,
"h2" : len (re.findall( r " ^ ## " , body, re.M)),
"table" : body.count( "<table" ),
}
def main ():
findings = []
for dirpath, _dirs, files in os.walk(os.path.join( ROOT , "ja" )):
for name in sorted (files):
if not name.endswith( ".mdx" ):
continue
ja = os.path.join(dirpath, name)
en = ja.replace( f " { ROOT } /ja/" , f " { ROOT } /en/" , 1 )
if not os.path.exists(en):
findings.append((ja, "英語版がありません" ))
continue
a, b = shape(body_of(ja)), shape(body_of(en))
if a[ "fence" ] > 0 and b[ "fence" ] == 0 :
findings.append((ja, f "英語版のコードがゼロです { a[ 'fence' ] } -> 0" ))
elif abs (a[ "fence" ] - b[ "fence" ]) >= FENCE_TOL :
findings.append((ja, f "コードブロック差 { a[ 'fence' ] } -> { b[ 'fence' ] } " ))
if abs (a[ "h2" ] - b[ "h2" ]) >= H2_TOL :
findings.append((ja, f "H2 差 { a[ 'h2' ] } -> { b[ 'h2' ] } " ))
for path, reason in findings:
print ( f " { path } : { reason } " )
print ( f "-- { len (findings) } 件" , file = sys.stderr)
return 1 if findings else 0
if __name__ == "__main__" :
sys.exit(main())
フェンス記号の出現数を 2 で割っているところだけ、少し説明を添えます。開きと閉じで 2 個ずつ出ますので、割らないと個数が倍になります。閉じ忘れがあると余りが 1 になり、切り捨てで個数が一致してしまいます。閉じ忘れそのものを止めたい場合は、出現数を 2 で割った余りを見る一行を足しておくと安心です。私は Markdown の整形側で閉じ忘れが弾かれるため、ここでは足しておりません。
<table> の個数は数えていますが、しきい値には使っておりません。表は片方の言語だけで足すことがあり、実害が出た例がまだ無いためです。数えるだけ数えておいて、判定には入れない——この使い分けは、警告を読み続けられる分量に保つうえで効いております。
しきい値を 3 にした理由
許容差をどこに置くかで、出てくる件数がまるで変わりました。
許容差 コードブロック H2 見出し
1 以上でずれと見なす 148 ペア 196 ペア
2 以上 79 ペア —
3 以上 50 ペア 51 ペア
5 以上 28 ペア —
差 1 で止めると 148 件と 196 件が並びます。この量は、読む前に閉じてしまう量です。差 3 にすると、コードと H2 の重複を除いて 80 ペアになりました。全体の 7.6% です。一日に数本ずつ潰していけば、月内に底が見えます。
手をつける順番は、症状の重さで三段に分けました。
英語版のコードがゼロになっているペア
四本ありました。読者にとっては本文が機能していない状態ですので、その日のうちに直します。差の大きさではなく、ゼロかどうかで別扱いにしております。
差が 3 以上あるペア
80 ペアです。ここは中身を見ないと判断がつきません。落ちているのか、片方に足したのかで対処が変わるためです。落ちている側へ足すか、増えている側の節を対訳へ戻すか、そのつど決めております。
差が 1〜2 のペア
残りです。表の追加や、英語版だけ節をひとつ分けたといった、実害の出にくいものが大半でした。ここは記事に触る用事ができたときの、ついでの仕事にしております。全部を揃えにいくと、直すこと自体が目的になってしまいます。
エージェントに渡すのは、指示ではなく数えられる条件です
Antigravity で「英語版も同じように直してください」と頼んだ時期がありました。結果は芳しくありませんでした。守られる日と守られない日があり、どちらだったのかは後から分かりません。
いまは頼み方を変えております。文章の対応づけはエージェントに任せ、揃っているかどうかの判定はスクリプトへ任せております。AGENTS.md には「日本語版を編集したら英語版の構造パリティ検査を走らせ、出力が空でなければ止めて報告すること」という一行を置いてあります。検査の実体は上のスクリプトで、エージェントはそれを走らせて結果を読むだけです。
Antigravity 2.11.0 以降は、プロジェクトのサブディレクトリに置いた設定ファイルからカスタムスキルやルールを見つけられるようになりました。私は言語ペアを持つリポジトリでだけこの規約が要りますので、ルールを全体の AGENTS.md に積み上げず、対象のディレクトリ側へ寄せております。規約が大きくなるほど、どのルールがいつ効いているのかを自分でも見失いがちです。
push の直前には、コミットに .mdx が含まれているときだけ走らせています。
#!/usr/bin/env bash
# .git/hooks/pre-push — MDX を触ったときだけ構造パリティを見ます
set -euo pipefail
if ! git diff --cached --name-only HEAD 2> /dev/null | grep -q '\.mdx$' ; then
# 直前のコミット群に MDX が無ければ黙って通します
if ! git diff --name-only origin/main...HEAD | grep -q '\.mdx$' ; then
exit 0
fi
fi
if ! python3 tools/pair_shape_check.py ; then
echo "構造パリティに差があります。上の一覧を見てから push してください。" >&2
exit 1
fi
自動で直させない、という線引きだけは動かしておりません。英語版に足りない節をエージェントに補わせると、日本語版に無い説明まで生えてきます。分岐を直すつもりで、新しい分岐を作ることになります。
翻訳させるのは文章で、数えるのは私です。 この線引きは、忙しい日ほど守るようにしております。frontmatter の値が知らないうちに書き換わる話はYAML frontmatter がエラーを出さずに値を書き換える条件を確かめました に、エージェントの検索範囲から静かに外れるファイルの話はGit が追跡しているのにエージェントが読めないファイルは、どこで外れているか に書き残してあります。どちらも、今回と同じ「通っているように見える」種類の落とし穴です。
次に数えるとしたら、どこからか
もし同じ形のリポジトリをお持ちでしたら、まず一行だけ走らせてみていただければと思います。しきい値も H2 も後回しで構いません。日本語版にコードがあって英語版がゼロのペアだけを出す一行です。
python3 - << 'PY'
import os, re
R = "content/articles"
F = "`" * 3
for d, _, fs in os.walk(f"{R}/ja"):
for f in fs:
if not f.endswith(".mdx"):
continue
ja = os.path.join(d, f)
en = ja.replace(f"{R}/ja/", f"{R}/en/", 1)
if not os.path.exists(en):
continue
cnt = lambda p: open(p, encoding="utf-8").read().count(F) // 2
if cnt(ja) > 0 and cnt(en) == 0:
print(ja, cnt(ja), "-> 0")
PY
私の場合、ここで出た四本が、いちばん読者に迷惑をかけていた四本でした。件数がゼロなら、その日は何もしないで構いません。ゼロでなければ、その一本を今日のうちに直せます。
最後まで読んでくださって、ありがとうございました。同じように二言語で書いていらっしゃる方の、数え直しの取っ掛かりになれば嬉しく思います。