Add translation check gate and herdr batch workflow docs

check-translation.py: 11-check deterministic gate (line/paragraph/heading
parity, emphasis preservation, CJK and Chinese-punctuation leakage, bold
leakage, 万/亿-aware digit fidelity, terminology vs term map, bilingual
freshness). Built and calibrated on the 9-book review batch (2026-08-07).

AGENTS.md: add Workflow C (batch translate/review with herdr) — pane setup,
the three canonical prompts (translate/review/apply), and gotchas learned on
the 9-book run.

readme.md: document the gate in common tasks and point to Workflow C.
This commit is contained in:
iacore
2026-08-08 12:53:48 +08:00
parent da0ee57834
commit 6144d63b68
3 changed files with 490 additions and 1 deletions
+411
View File
@@ -0,0 +1,411 @@
#!/usr/bin/env python3
"""Deterministic translation checks for the MPI project (TDD-style gate).
Checks the mechanical invariants of a Chinese->English djot translation.
Semantic quality (fluency, register, tone) is NOT checked here — that is the
LLM review layer. This script is the hard gate: it must go green before a
translation is delivered, and it stays in toolkit/scripts/ as a regression
suite so later edits cannot silently break parity.
Severity:
FAIL — hard invariant broken; gate is red.
WARN — possible drift worth a reviewer's eye; does not fail the gate.
PASS — clean.
Checks:
1. line-count parity — source.dj and target.dj must have equal lines
2. paragraph parity — equal number of blank-separated blocks
3. heading parity — equal # of lines starting with each heading level
4. emphasis preservation — every *...* in a source line survives on the
same-index target line (D5). Target may ADD
italics (titles, Sanskrit) — that is fine.
5. comment parity — equal {% ... %} blocks (D5)
6. CJK leakage — no Chinese characters in target (whitelistable)
7. Chinese punctuation — no strictly-Chinese punctuation in target
(,。、;:?!《》【】()). Em dash, curly
quotes, middot are legal English — not flagged.
8. bold leakage — no Markdown ** in target (D5)
9. digit fidelity — every arabic number in source appears in target
10. terminology — source terms from a term map must appear in target
with an allowed English rendering (A3 / terms DB).
FAIL: term present in source, none of its
renderings found in target. WARN: renderings
found but fewer times than the source term.
11. bilingual freshness — bilingual.dj, if present, equals a regeneration
from source+target
Usage:
check-translation.py <book_dir>
Uses <book_dir>/source.dj and <book_dir>/target.dj; auto-detects
bilingual.dj and term-map.md in the same directory.
check-translation.py <source.dj> <target.dj> [--bilingual FILE]
Explicit files.
Options:
--term-map FILE Term map (default: <book_dir>/term-map.md if present).
Accepted formats:
- markdown table rows: | 菩提心 | bodhicitta |
- plain lines: CN<TAB>EN or CN|EN1|EN2
Multiple Chinese terms separated by "/" or "" share
one English side; English renderings separated by "/"
are alternatives, any of which satisfies the check.
--allow-cjk LIST Comma-separated CJK strings permitted in target
(e.g. quoted book titles like 《心经》).
--json Emit machine-readable JSON results.
Exit code: 0 when no check FAILs (WARNs allowed), 1 otherwise.
"""
import argparse
import json
import re
import sys
from pathlib import Path
CJK_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]")
# Strictly-Chinese punctuation only. Em dash (—), curly quotes (“” ‘’),
# middot (·), and ellipsis are legitimate in English prose.
CN_PUNCT_RE = re.compile(r"[,。、;:?!《》【】()]")
EMPHASIS_RE = re.compile(r"\*[^*\n]+\*")
COMMENT_RE = re.compile(r"\{%[\s\S]*?%\}")
HEADING_RE = re.compile(r"^(#{1,6})(?:\s|$)")
BOLD_RE = re.compile(r"\*\*")
DIGIT_RE = re.compile(r"\d+")
PASS, WARN, FAIL = "PASS", "WARN", "FAIL"
def read_lines(path):
return Path(path).read_text(encoding="utf-8").splitlines()
def count_paras(lines):
"""Blank-separated blocks; leading/trailing blanks ignored."""
count = 0
in_block = False
for line in lines:
if line.strip():
if not in_block:
count += 1
in_block = True
else:
in_block = False
return count
def heading_counts(lines):
counts = {i: 0 for i in range(1, 7)}
for line in lines:
m = HEADING_RE.match(line)
if m:
counts[len(m.group(1))] += 1
return counts
def term_map_from_markdown(text):
"""Parse a term map into {chinese_term: [english_renderings]}."""
terms = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "\t" in line:
cn, _, en = line.partition("\t")
terms[cn.strip()] = [r.strip() for r in en.split("/") if r.strip()]
continue
if "|" not in line:
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if len(cells) < 2:
continue
cn_cell, en_cell = cells[0], cells[1]
if not cn_cell or not en_cell or set(cn_cell) <= {"-", " "}:
continue
for cn in (c.strip() for c in re.split(r"[/、]", cn_cell) if c.strip()):
terms[cn] = [r.strip() for r in en_cell.split("/") if r.strip()]
return terms
def check_line_count(src, tgt):
ok = len(src) == len(tgt)
return (PASS if ok else FAIL,
f"source={len(src)} target={len(tgt)}", [])
def check_paras(src, tgt):
s, t = count_paras(src), count_paras(tgt)
return (PASS if s == t else FAIL,
f"source={s} target={t}", [])
def check_headings(src, tgt):
s, t = heading_counts(src), heading_counts(tgt)
diffs = [f"H{i}: {s[i]} vs {t[i]}" for i in range(1, 7) if s[i] != t[i]]
return (PASS if not diffs else FAIL,
"; ".join(diffs) if diffs else "all levels match", diffs)
def check_emphasis(src, tgt):
"""D5 preservation: source emphasis must survive on the same line.
Target may add emphasis for titles/Sanskrit, so counts need not match.
"""
missing = []
for i, (s, t) in enumerate(zip(src, tgt), 1):
if EMPHASIS_RE.search(s) and not EMPHASIS_RE.search(t):
missing.append((i, s, t))
return (PASS if not missing else FAIL,
"all source emphasis preserved"
if not missing else
f"{len(missing)} source line(s) lost emphasis: "
+ ", ".join(f"S{i}" for i, _, _ in missing[:10]),
missing)
def check_comments(src, tgt):
s, t = len(COMMENT_RE.findall("\n".join(src))), len(COMMENT_RE.findall("\n".join(tgt)))
return (PASS if s == t else FAIL,
f"source={s} target={t}", [])
ANCHOR_RE = re.compile(r"\{#[^{}]*\}|\(\s*#?[^{}\n]*\)")
LINK_TGT_RE = re.compile(r"\[\d+\]\(\s*#")
def strip_structural(text):
"""Remove djot anchors {#...}, link destinations (...), and image paths —
structural markup that may legitimately contain Chinese."""
return ANCHOR_RE.sub("", text)
def check_cjk(tgt, allow=()):
bad = []
for i, line in enumerate(tgt, 1):
stripped = strip_structural(line)
for token in allow:
stripped = stripped.replace(token, "")
if CJK_RE.search(stripped):
bad.append((i, line))
return (PASS if not bad else FAIL,
"clean" if not bad else f"{len(bad)} line(s) contain CJK outside anchors/links: "
+ ", ".join(f"L{i}" for i, _ in bad[:10]),
bad)
def check_cn_punct(tgt):
bad = []
for i, line in enumerate(tgt, 1):
if CN_PUNCT_RE.search(line):
bad.append((i, line))
return (PASS if not bad else FAIL,
"clean" if not bad else f"{len(bad)} line(s) contain Chinese punctuation: "
+ ", ".join(f"L{i}" for i, _ in bad[:10]),
bad)
def check_bold(tgt):
bad = []
for i, line in enumerate(tgt, 1):
if BOLD_RE.search(line):
bad.append((i, line))
return (PASS if not bad else FAIL,
"clean" if not bad else f"{len(bad)} line(s) contain ** : "
+ ", ".join(f"L{i}" for i, _ in bad[:10]),
bad)
_ONES = ["", "one", "two", "three", "four", "five", "six", "seven", "eight",
"nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
"sixteen", "seventeen", "eighteen", "nineteen"]
_TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
"eighty", "ninety"]
def number_to_words(n):
if n < 20:
return _ONES[n]
if n < 100:
return (_TENS[n // 10] + ("-" + _ONES[n % 10] if n % 10 else ""))
if n < 1000:
return _ONES[n // 100] + " hundred" + (
(" " + number_to_words(n % 100)) if n % 100 else "")
if n < 1000000:
return number_to_words(n // 1000) + " thousand" + (
(" " + number_to_words(n % 1000)) if n % 1000 else "")
return number_to_words(n // 1000000) + " million" + (
(" " + number_to_words(n % 1000000)) if n % 1000000 else "")
def accepted_number_spellings(n, unit):
"""All English spellings that legitimately render source number n.
unit: "" (plain), "" (×10^4), or "亿" (×10^8). The target may keep the
digits ("1,200"), spell them ("twelve hundred"), or scale the unit
("13 million" for 1300万, "18 billion" for 180亿).
"""
cands = {str(n), number_to_words(n)}
if 100 <= n < 10000 and n % 100 == 0: # "twelve hundred"
cands.add(f"{n // 100} hundred")
cands.add(number_to_words(n // 100) + " hundred")
value = n * (10 ** 4 if unit == "" else 10 ** 8 if unit == "亿" else 1)
if value != n:
cands.add(str(value))
cands.add(number_to_words(value))
for divisor, suffix in ((10 ** 9, "billion"), (10 ** 6, "million"),
(10 ** 3, "thousand")):
if value % divisor == 0 and value // divisor > 0:
cands.add(f"{value // divisor} {suffix}")
cands.add(number_to_words(value // divisor) + " " + suffix)
return cands
def source_content_nums(src_text):
"""(number, unit) pairs from the source, excluding TOC page numbers
([N](#...)) that the project convention intentionally drops."""
stripped = LINK_TGT_RE.sub("", src_text)
out = []
for m in re.finditer(r"\d+(?:\s*(?:多\s*)?[万亿])?", stripped):
tok = m.group(0)
unit = tok[-1] if tok[-1] in "万亿" else ""
out.append((int(re.sub(r"\D", "", tok)), unit))
return out
def check_digits(src, tgt):
src_nums = source_content_nums("\n".join(src))
tgt_text = " ".join(tgt).lower().replace(",", "")
missing = []
for n, unit in src_nums:
if any(s.lower() in tgt_text
for s in accepted_number_spellings(n, unit)):
continue
missing.append(str(n) + unit)
return (PASS if not missing else FAIL,
"all present" if not missing else f"missing in target: {', '.join(missing)}",
missing)
def check_terminology(src, tgt, terms):
"""A3: source term present -> some allowed rendering present in target.
FAIL when no rendering is found at all; WARN when found but under-counted
(inflections, line wraps, or a genuine drift the reviewer should verify).
"""
if not terms:
return PASS, "no term map provided; skipped", []
src_text = "\n".join(src)
tgt_text = " ".join(tgt).lower()
fails, warns = [], []
checked = 0
for cn, renderings in sorted(terms.items()):
n = src_text.count(cn)
if n == 0:
continue
checked += 1
hits = sum(tgt_text.count(r.lower()) for r in renderings)
if hits == 0:
fails.append(f"{cn} ({n}× in source) — none of {renderings} found in target")
elif hits < n:
warns.append(f"{cn} ({n}× in source, {hits}× rendered) — verify")
if fails:
status, detail = FAIL, f"{checked} term(s) checked; " + "; ".join(fails)
elif warns:
status, detail = WARN, f"{checked} term(s) checked; " + "; ".join(warns)
else:
status, detail = PASS, f"{checked} term(s) checked; all consistent"
return status, detail, fails + warns
def check_bilingual(src, tgt, bilingual_path):
if bilingual_path is None or not Path(bilingual_path).exists():
return PASS, "no bilingual.dj present; skipped", []
actual = Path(bilingual_path).read_text(encoding="utf-8").splitlines()
expected = []
for s, t in zip(src, tgt):
if s == "":
expected.append("")
else:
expected.extend([s, t, ""])
if expected and expected[-1] != "":
expected.append("")
if actual == expected:
return PASS, f"{len(actual)} lines match a regeneration", []
return FAIL, f"stale: {Path(bilingual_path)} differs from source+target regeneration", []
def run_checks(src, tgt, bilingual=None, term_map=None, allow_cjk=()):
return [
("line-count parity", *check_line_count(src, tgt)),
("paragraph parity", *check_paras(src, tgt)),
("heading parity", *check_headings(src, tgt)),
("emphasis preservation", *check_emphasis(src, tgt)),
("comment parity", *check_comments(src, tgt)),
("CJK leakage", *check_cjk(tgt, allow_cjk)),
("Chinese punctuation", *check_cn_punct(tgt)),
("bold leakage", *check_bold(tgt)),
("digit fidelity", *check_digits(src, tgt)),
("terminology", *check_terminology(src, tgt, term_map)),
("bilingual freshness", *check_bilingual(src, tgt, bilingual)),
]
def main():
ap = argparse.ArgumentParser(description="Deterministic translation checks")
ap.add_argument("paths", nargs="+", help="book dir, or source.dj target.dj")
ap.add_argument("--bilingual", default=None, help="bilingual.dj to verify")
ap.add_argument("--term-map", default=None, help="term map file")
ap.add_argument("--allow-cjk", default="", help="comma-separated CJK whitelist")
ap.add_argument("--json", action="store_true")
args = ap.parse_args()
if len(args.paths) == 1 and Path(args.paths[0]).is_dir():
d = Path(args.paths[0])
src, tgt = d / "source.dj", d / "target.dj"
bilingual = args.bilingual or d / "bilingual.dj"
term_map = args.term_map or d / "term-map.md"
label = str(d)
elif len(args.paths) == 2:
src, tgt = Path(args.paths[0]), Path(args.paths[1])
bilingual = Path(args.bilingual) if args.bilingual else None
term_map = Path(args.term_map) if args.term_map else None
label = f"{src} -> {tgt}"
else:
ap.error("pass a book directory, or source.dj target.dj")
if not src.exists() or not tgt.exists():
ap.error(f"missing source or target: {src} / {tgt}")
src_lines = read_lines(src)
tgt_lines = read_lines(tgt)
allow = [t for t in args.allow_cjk.split(",") if t.strip()]
terms = {}
if term_map and Path(term_map).exists():
terms = term_map_from_markdown(Path(term_map).read_text(encoding="utf-8"))
checks = run_checks(src_lines, tgt_lines, bilingual, terms, allow)
failed = [name for name, status, *_ in checks if status == FAIL]
warned = [name for name, status, *_ in checks if status == WARN]
if args.json:
print(json.dumps({
"target": label,
"passed": [c[0] for c in checks if c[1] == PASS],
"warned": warned,
"failed": failed,
"details": {c[0]: {"status": c[1], "detail": c[2]} for c in checks},
}, ensure_ascii=False, indent=2))
else:
print(f"check-translation.py — {label}\n")
for name, status, detail, *_ in checks:
print(f"[{status:4}] {name}: {detail}")
summary = "ALL CHECKS PASSED"
if warned:
summary = f"PASSED with warnings: {', '.join(warned)}"
if failed:
summary = f"FAILED: {', '.join(failed)}"
print(f"\n{summary}")
sys.exit(0 if not failed else 1)
if __name__ == "__main__":
main()