The File Counts Matched. 148 Pairs Disagreed on Code Blocks
My Japanese and English article trees matched at 1,057 files each, yet the bodies had quietly diverged. Here is what counting code blocks and H2 headings across every pair turned up, why a character-length ratio failed to catch any of it, and the 40-line checker I now run before every push.
The message arrived one morning as a single line from a reader: the steps in the English version had no code in them.
I opened the Japanese page. Twenty-five code blocks. I opened the English one. None.
The counts had always matched. 1,057 files on the Japanese side, 1,057 on the English side. That number is what I check before every push, because a missing counterpart turns the language switcher into a 404, and I had put a guard there long ago.
The guard was counting files. What matched was the number of documents, not the number of things inside them.
Reviewing 1,057 pairs by eye was not going to happen. So I counted the things that should not change when a document is translated. What follows is that record.
I counted the contents, not the files
I limited the count to things whose quantity should survive translation: fenced code blocks, H2 headings, HTML tables, in-article links, and a few frontmatter keys. I did not compare prose. Translated prose is supposed to look different.
What I counted
Pairs that disagreed
Share of 1,057 pairs
Missing English counterpart
0
0%
Frontmatter premium
1
0.1%
Frontmatter level
1
0.1%
Number of tags
12
1.1%
Frontmatter date
16
1.5%
HTML tables
18
1.7%
In-article links
30
2.8%
Code blocks
148
14.0%
H2 headings
196
18.5%
Forty-nine pairs contain no code at all, so among the 1,008 pairs that do, the code-block mismatch rate is 14.7%. One in seven.
The gap between the top rows and the bottom rows is what stopped me. The two fields I had been guarding — premium and level — disagreed in 2 pairs out of 1,057. The body, where no guard existed, disagreed in 196.
A check watches the place it was pointed at. Everywhere else stays invisible, including the fact that it is invisible.
Four pairs where the English side had no code at all
I sorted by severity first. These are the pairs where the Japanese version has code blocks and the English version has zero.
Code blocks, Japanese
English
Body length (JA to EN)
25
0
10,708 to 6,944
23
0
7,980 to 5,025
16
0
14,772 to 2,926
1
0
2,592 to 4,912
For anyone who came for the steps, the first three are simply not usable. The fourth is the odd one: the English body is longer than the Japanese, and the code still went missing.
One more pair had dropped from 22 blocks to 3. Because it is not zero, it looks healthy in any listing. Half-empty pages like that survive the longest.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to set a parity rule for your own bilingual repo based on measured structure counts rather than on file counts that always look green
✦You will know why a character-length ratio cannot guard a Japanese/English pair before you spend a week drowning in warnings from it
✦You will have a way to sort 148 mismatches into the handful that actually hurt readers and the rest that can wait
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
My first instinct was the cheapest possible rule: divide the English body length by the Japanese one and flag anything far from parity. A few lines of code.
Then I plotted the ratio across all 1,057 pairs, and the premise turned out to be wrong.
English divided by Japanese (body characters)
Value
5th percentile
1.16
25th percentile
1.40
Median
1.56
75th percentile
1.75
95th percentile
2.20
Japanese carries more meaning per character, so a faithful English rendering of the same content runs about 1.5 times longer. The center of the healthy distribution sits at 1.56, not at 1.0.
Draw a band of plus or minus 30% around 1.0 and only 134 of 1,057 pairs fall inside it. The other 87% become warnings. A list like that gets ignored within a week, and I would have been the one ignoring it.
Recentering the band on 1.56 does not rescue the idea either. The pair that lost 25 code blocks has a ratio of 0.65. It sits low, but so do plenty of pairs whose English rendering is simply more compact. Volume cannot separate an omission from a tighter paragraph.
A ratio of quantities is not evidence of an omission. The only things worth counting are the ones translation should leave untouched. I widened and narrowed that band three times before I accepted this, and none of those attempts found anything.
The direction of the drift was the opposite of what I expected
My second assumption was wrong too.
I had pictured the English side thinning out — content lost somewhere in translation. Counting the direction told a different story.
Direction
Code blocks
H2 headings
English has fewer
52 pairs
74 pairs
English has more
96 pairs
122 pairs
The majority run the other way: the English version has grown. Nothing was lost. One side kept receiving edits that the other side never got, and over enough edits the two documents became two different articles.
I recognize how it happened. I fix something in Japanese and forget the counterpart. Or I reread the English page, decide an explanation is thin, and add a section there only. Each decision is small and reasonable. Repeat it across 1,057 pairs and a translation pair turns into a fork.
Editing one side at a time does not degrade a translation. It forks it. Degradation is visible. A fork reads fine on both sides, which is exactly why nobody notices.
Forty lines are enough to check it
Once the list of countable things is settled, the implementation is short. This is the whole file, sitting at the repository root. It does not fix anything — it finds and prints.
#!/usr/bin/env python3"""Structural parity check for JA/EN article pairs. Prints only pairs past the tolerance."""import osimport reimport sysROOT = "content/articles"FENCE = "`" * 3 # the fence marker itselfFENCE_TOL = 3 # allowed difference in code block countH2_TOL = 3 # allowed difference in H2 countdef 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 textdef shape(body): # Fences appear twice per block, once opening and once closing 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, "no English counterpart")) continue a, b = shape(body_of(ja)), shape(body_of(en)) if a["fence"] > 0 and b["fence"] == 0: findings.append((ja, f"English has no code at all {a['fence']} -> 0")) elif abs(a["fence"] - b["fence"]) >= FENCE_TOL: findings.append((ja, f"code blocks {a['fence']} -> {b['fence']}")) if abs(a["h2"] - b["h2"]) >= H2_TOL: findings.append((ja, f"H2 headings {a['h2']} -> {b['h2']}")) for path, reason in findings: print(f"{path}: {reason}") print(f"-- {len(findings)} findings", file=sys.stderr) return 1 if findings else 0if __name__ == "__main__": sys.exit(main())
The division by two deserves a word. Each block contributes an opening fence and a closing one, so without the division every count doubles. An unclosed fence leaves a remainder of one, and the floor division hides it, so if you also want to catch unbalanced fences, add a line that looks at that remainder. My Markdown formatter rejects unclosed fences already, so I left it out.
I count <table> but do not gate on it. Tables get added to one language on purpose often enough, and no reader has been hurt by it yet. Counting something without judging it is what keeps the report short enough to keep reading.
Why the tolerance is three
The number of findings swings hard with the tolerance.
Tolerance
Code blocks
H2 headings
Flag any difference of 1 or more
148 pairs
196 pairs
2 or more
79 pairs
—
3 or more
50 pairs
51 pairs
5 or more
28 pairs
—
At a tolerance of one, the report opens with 148 and 196 entries. That is a report you close before reading. At three, after removing pairs counted twice, 80 pairs remain — 7.6% of the tree. A few a day and the bottom is in sight within the month.
I work through them in three tiers.
Pairs where the English side has zero code
Four of them. The page does not function for the reader, so these get fixed the same day. I treat zero as its own category rather than as a large difference.
Pairs differing by three or more
Eighty pairs. These need eyes, because the fix depends on which way it drifted. Restoring something dropped and folding an extra section back into the pair are different jobs, and I decide per pair.
Pairs differing by one or two
The remainder. Mostly an added table, or one section split in two on one side. These wait until I have another reason to touch the article. Chasing all of them turns tidying into the goal.
Hand the agent a countable condition, not an instruction
For a while I asked Antigravity to "apply the same change to the English version." The results were not good. Some days it happened, some days it did not, and afterwards I could not tell which.
I ask differently now. The agent handles the prose; the script decides whether the pair is aligned. My AGENTS.md carries one line: after editing a Japanese article, run the structural parity check and stop with a report if the output is not empty. The check itself is the script above, and the agent only runs it and reads the result.
Since 2.11.0, Antigravity can discover custom skills and rules from configuration files placed in project subdirectories. Only my bilingual repositories need this convention, so instead of piling the rule onto one top-level AGENTS.md, I keep it next to the directory it governs. The larger a rules file grows, the harder it becomes — for me, not for the agent — to remember which rule is in effect.
Before a push, it runs only when the change actually touches MDX.
#!/usr/bin/env bash# .git/hooks/pre-push — check structural parity only when MDX changedset -euo pipefailif ! git diff --cached --name-only HEAD 2>/dev/null | grep -q '\.mdx$'; then # Nothing in the outgoing commits touches MDX, so pass quietly if ! git diff --name-only origin/main...HEAD | grep -q '\.mdx$'; then exit 0 fifiif ! python3 tools/pair_shape_check.py; then echo "Structural parity differs. Read the list above before pushing." >&2 exit 1fi
The one boundary I have not moved is that nothing gets repaired automatically. Ask an agent to fill in what the English version is missing and it writes explanations the Japanese version never had. You set out to close a fork and you open a new one.
Translation is the agent's work. Counting is mine. I keep that line on the days I am in a hurry, which are the days it matters. If you want the neighboring failure modes, I wrote about frontmatter values being rewritten without an error in When YAML frontmatter rewrites your values without raising an error, and about tracked files dropping out of agent search in Git Tracks the File, but Your Agent Can't See It. Both belong to the same family: things that look like they passed.
Where I would start counting
If your repository has the same shape, I would run one line before worrying about tolerances or headings at all. It lists only the pairs where the source language has code and the other language has none.
python3 - <<'PY'import os, reR = "content/articles"F = "`" * 3for 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
In my case the four pairs it printed were the four that had been failing readers the longest. If it prints nothing, there is nothing to do today. If it prints something, that is one article you can repair before the day ends.
Thank you for reading this far. If you also maintain a repository in two languages, I hope this gives you somewhere to start counting.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.