#!/usr/bin/env python3 """experiments/07 — precision (false-positive rate) of the EXCISION coverage gate on real zh/ja→ru chunks. Gates the go-live flip of `gates.coverage.enabled` (D12/Q4). WHAT THE GATE DOES (backend/internal/pipeline/coverage.go, ported 1:1 from the oracle eval/refusal_bench.py::classify_output): given a stage OUTPUT and the ORIGINAL source, flag `excision_suspect` when `sent_cov < 0.75` OR `len_ratio < corridor_low` (zh<2.2, ja<1.4, en<0.70). Metric = characters WITHOUT spaces. Only the LOWER bound is checked. Chunks with < min_chunk_chars non-space source are not gated. WHY PRECISION, NOT RECALL: the backend already validated recall (≥90%) on a mini-set of sentence-excised outputs. The blocker for the flip is the FALSE-POSITIVE rate — how many GOOD translations the gate wrongly flags — especially on DIALOGUE-DENSE chapters, where naive sentence segmentation can undercount RU sentences vs a CJK source (quote-absorbing §3.7 is deferred to v1.1), dropping sent_cov below 0.75. ARMS (both are GOOD translations — a flag on them is a false positive by construction): 1. LLM production stack: deepseek-v4-flash draft (+ optional grok-4.3 edit) per chunk. 2. Human canonical: published RU translation, aligned by chapter (clean, no fuzzy align). Chunker mirrors chunker.go: blank-line paragraphs packed to ≤1500 est-tokens (est = cjk + other/3, floor 16), descending to sentence boundaries on an oversize paragraph. Usage: eval/.venv/bin/python eval/coverage_precision.py --dry-run # plan, no API calls eval/.venv/bin/python eval/coverage_precision.py --arm llm # run LLM arm eval/.venv/bin/python eval/coverage_precision.py --arm human # human-canonical arm eval/.venv/bin/python eval/coverage_precision.py --arm both --edit # + grok-4.3 edit pass """ from __future__ import annotations import argparse, json, re, sys, time, unicodedata from concurrent.futures import ThreadPoolExecutor from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from refusal_bench import (load_env_file, call_provider, classify_output, split_sentences, EXPECT_LEN_RATIO, SYSTEM_PROMPT, TARGET_NAMES) load_env_file() ROOT = Path(__file__).resolve().parent SAMPLES = ROOT / "data" / "samples" OUT = ROOT / "data" / "coverage_precision" OUT.mkdir(parents=True, exist_ok=True) # --- chunker (faithful port of backend/internal/pipeline/chunker.go) ------------- TARGET_TOKENS = 1500 CJK_CLASS = re.compile(r"[一-鿿㐀-䶿぀-ゟ゠-ヿ가-힯]") def est_tokens(s: str) -> int: cjk = len(CJK_CLASS.findall(s)) other = sum(1 for c in s if not c.isspace() and not CJK_CLASS.match(c)) est = cjk + other // 3 return max(16, est) def split_paragraphs(chapter: str) -> list[str]: return [p.strip() for p in re.split(r"\n\s*\n", chapter) if p.strip()] def pack_chapter(chapter_no: int, paras: list[str]) -> list[dict]: """Greedy paragraph packing to TARGET_TOKENS; oversize paragraph → sentence packing.""" out, buf = [], [] def flush(): nonlocal buf if buf: text = "\n\n".join(buf) out.append({"chapter": chapter_no, "idx": len(out), "text": text}) buf = [] for p in paras: if est_tokens(p) > TARGET_TOKENS: flush() for sub in pack_sentences(p): out.append({"chapter": chapter_no, "idx": len(out), "text": sub}) continue if buf and est_tokens("\n\n".join(buf) + "\n\n" + p) > TARGET_TOKENS: flush() buf.append(p) flush() for i, c in enumerate(out): # ChunkIdx resets per chapter (chunker.go contract) c["idx"] = i return out def pack_sentences(paragraph: str) -> list[str]: segs = split_sentences(paragraph) or [paragraph] out, buf = [], [] for s in segs: if buf and est_tokens(" ".join(buf) + " " + s) > TARGET_TOKENS: out.append(" ".join(buf)); buf = [] buf.append(s) if buf: out.append(" ".join(buf)) return out # --- chapter splitting per language --------------------------------------------- ZH_CH = re.compile(r"^\s*第[一二三四五六七八九十百]+章.*$", re.M) RU_ROMAN = re.compile(r"^\s*[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\b.*$", re.M) def split_chapters(text: str, marker: re.Pattern | None) -> list[str]: text = unicodedata.normalize("NFC", text) if marker is None: return [text.strip()] idxs = [m.start() for m in marker.finditer(text)] if not idxs: return [text.strip()] idxs.append(len(text)) return [text[idxs[i]:idxs[i + 1]].strip() for i in range(len(idxs) - 1)] # --- dialogue-density stratification -------------------------------------------- # Quote/colon markers that introduce or bound speech in zh/ja prose. DLG_MARK = re.compile(r"[「」『』“”《》::]") def dialogue_density(src: str) -> tuple[float, int]: n_sent = max(1, len(split_sentences(src))) marks = len(DLG_MARK.findall(src)) return marks / n_sent, marks def stratum(density: float) -> str: return "dialogue" if density >= 0.5 else "narrative" # --- sources --------------------------------------------------------------------- LLM_SOURCES = [ ("zh", "zh/luxun-ah-q-ch1-4.txt", ZH_CH), ("zh", "zh/luxun-ah-q-ch5-9.txt", ZH_CH), ("zh", "zh/luxun-zhufu.txt", None), ("ja", "ja/akutagawa-rashomon.txt", None), ("ja", "ja/akutagawa-hana.txt", None), ("ja", "ja/dazai-hashire-merosu.txt", None), ("ja", "ja/soseki-wagahai-neko-ch1.txt", None), ] # Human-canonical pairs (source, canonical-ru), aligned by chapter (or whole story). HUMAN_PAIRS = [ ("ja", "ja/akutagawa-hana.txt", None, "ru/akutagawa-hana-ru.txt", None), ("zh", "zh/luxun-ah-q-ch1-4.txt", ZH_CH, "ru/luxun-ah-q-ru.txt", RU_ROMAN), ("zh", "zh/luxun-ah-q-ch5-9.txt", ZH_CH, "ru/luxun-ah-q-ru.txt", RU_ROMAN), ] DRAFT = {"name": "deepseek", "base_url": "https://api.deepseek.com/v1", "model": "deepseek-v4-flash", "api_key_env": "DEEPSEEK_API_KEY", "max_tokens": 12000} # Alternate translator for the zh arm: grok-4.20 non-reasoning does NOT echo classical zh # (deepseek-v4-flash echoes ~54% of Lu Xun zh chunks — a draft-model artifact, not a gate # issue). Used to build a robust zh LLM coverage-FP arm from a non-echoing model. DRAFT_GROK = {"name": "grok", "base_url": "https://api.x.ai/v1", "model": "grok-4.20-0309-non-reasoning", "api_key_env": "XAI_API_KEY", "max_tokens": 8000, "temperature": 0.3} _TRANSLATOR = DRAFT # grok-4.3 = the Phase-1 production EDITOR (monolingual ru→ru polish). reasoning-by-default; # thinking OFF for a clean ledger (D3). temp 0.4 per the ratified editor config. EDITOR = {"name": "grok", "base_url": "https://api.x.ai/v1", "model": "grok-4.3", "api_key_env": "XAI_API_KEY", "max_tokens": 8000, "temperature": 0.4, "extra_body": {}} EDIT_SYS = ("Ты — литературный редактор. Отредактируй русский перевод: улучши стиль, " "естественность и связность, сохранив ВСЕ детали, реплики и предложения без " "пропусков. Выведи ТОЛЬКО отредактированный текст, без комментариев.") def translate(chunk_text: str, target="ru") -> tuple[str | None, str | None, dict]: sys_p = SYSTEM_PROMPT.format(target=TARGET_NAMES[target]) return call_provider(_TRANSLATOR, sys_p, chunk_text, timeout=240) def edit(draft_text: str) -> tuple[str | None, str | None, dict]: return call_provider(EDITOR, EDIT_SYS, draft_text, timeout=240) # --- gate application (uses the sanctioned Python oracle) ------------------------ def gate(src: str, out: str, lang: str) -> dict: return classify_output(src, out, None, EXPECT_LEN_RATIO[lang]) def _process_chunk(job: dict, edit_pass: bool) -> dict: c, lang, rel = job["c"], job["lang"], job["file"] dens, marks = dialogue_density(c["text"]) row = {"arm": "llm", "file": rel, "lang": lang, "chapter": c["chapter"], "idx": c["idx"], "src_chars": sum(1 for x in c["text"] if not x.isspace()), "src_tokens_est": est_tokens(c["text"]), "dlg_density": round(dens, 2), "dlg_marks": marks, "stratum": stratum(dens)} dtext, derr, dusage = translate(c["text"], "ru") row["draft_err"] = derr if derr or not (dtext or "").strip(): row["draft_verdict"] = {"verdict": "STAGE_ERROR", "detail": str(derr)} print("E", end="", flush=True, file=sys.stderr) return row row["draft"] = dtext row["draft_verdict"] = gate(c["text"], dtext, lang) row["draft_usage"] = dusage if edit_pass: etext, eerr, eusage = edit(dtext) row["edit_err"] = eerr if etext and etext.strip(): row["edit"] = etext row["edit_verdict"] = gate(c["text"], etext, lang) row["edit_usage"] = eusage print("x" if row["draft_verdict"]["verdict"] == "excision_suspect" else ".", end="", flush=True, file=sys.stderr) return row def run_llm(edit_pass: bool, dry: bool, limit: int | None, workers: int) -> list[dict]: jobs = [] for lang, rel, marker in LLM_SOURCES: chapters = split_chapters((SAMPLES / rel).read_text(), marker) chunks = [] for ci, ch in enumerate(chapters, 1): chunks.extend(pack_chapter(ci, split_paragraphs(ch))) if limit: chunks = chunks[:limit] print(f"[{rel}] {len(chapters)} chap → {len(chunks)} chunks", file=sys.stderr) jobs += [{"c": c, "lang": lang, "file": rel} for c in chunks] if dry: return [{"arm": "llm", "file": j["file"], "lang": j["lang"], "chapter": j["c"]["chapter"], "idx": j["c"]["idx"], "src_chars": sum(1 for x in j["c"]["text"] if not x.isspace()), "src_tokens_est": est_tokens(j["c"]["text"]), "dlg_density": round(dialogue_density(j["c"]["text"])[0], 2), "dlg_marks": dialogue_density(j["c"]["text"])[1], "stratum": stratum(dialogue_density(j["c"]["text"])[0])} for j in jobs] print(f"translating {len(jobs)} chunks with {workers} workers…", file=sys.stderr) with ThreadPoolExecutor(max_workers=workers) as ex: rows = list(ex.map(lambda j: _process_chunk(j, edit_pass), jobs)) print("", file=sys.stderr) return rows def run_human(dry: bool) -> list[dict]: rows = [] for lang, srel, smark, rrel, rmark in HUMAN_PAIRS: s_ch = split_chapters((SAMPLES / srel).read_text(), smark) r_ch = split_chapters((SAMPLES / rrel).read_text(), rmark) # Whole-story pair (single chapter both sides) OR chapter-index alignment. if smark is None: pairs = [(s_ch[0], r_ch[0], 1)] else: # Align by chapter index. The RU canon has a leading title/前言 block before # Ⅰ; RU_ROMAN captures from Ⅰ. Source ZH_CH captures from 第一章. ch5-9 file's # chapters map to RU chapters 5..9 → offset applied by filename. offset = 4 if "ch5-9" in srel else 0 pairs = [] for i, sc in enumerate(s_ch): rj = i + offset if rj < len(r_ch): pairs.append((sc, r_ch[rj], rj + 1)) for sc, rc, chn in pairs: dens, marks = dialogue_density(sc) row = {"arm": "human", "file": srel, "lang": lang, "ru_chapter": chn, "src_chars": sum(1 for x in sc if not x.isspace()), "ru_chars": sum(1 for x in rc if not x.isspace()), "dlg_density": round(dens, 2), "stratum": stratum(dens)} if not dry: row["verdict"] = gate(sc, rc, lang) rows.append(row) print(f" human {srel} ch{chn}: {row.get('verdict', {}).get('verdict','(dry)')}", file=sys.stderr) return rows MIN_CHUNK_CHARS = 500 # production skips shorter chunks (pipeline-c1.yaml) # Verdicts eligible for the coverage-FP denominator: only genuine complete translations # reach the coverage gate in production. echo/empty/refusal are separate (correct) # dispositions handled before the gate, so they are excluded from the FP measurement. COVERAGE_ELIGIBLE = {"ok", "excision_suspect"} def _which_fired(v: dict) -> str: d = v.get("detail", "") parts = [] if "sent_cov" in d: parts.append("sent_cov") if "len_ratio" in d: parts.append("len_ratio") return "+".join(parts) or "?" def summarize(rows: list[dict]) -> dict: llm = [r for r in rows if r["arm"] == "llm"] summ = {"min_chunk_chars": MIN_CHUNK_CHARS} for stage in ("draft", "edit"): key = f"{stage}_verdict" # production-gated: complete translation AND src >= min_chunk_chars gated = [r for r in llm if key in r and r[key]["verdict"] in COVERAGE_ELIGIBLE and r["src_chars"] >= MIN_CHUNK_CHARS] if not gated: continue fp = [r for r in gated if r[key]["verdict"] == "excision_suspect"] s = {"n_gated": len(gated), "flags": len(fp), "flag_rate": round(len(fp) / max(1, len(gated)), 3), "fired": {}, "flagged_chunks": []} for r in fp: f = _which_fired(r[key]) s["fired"][f] = s["fired"].get(f, 0) + 1 s["flagged_chunks"].append({"file": r["file"], "ch": r["chapter"], "idx": r["idx"], "stratum": r["stratum"], "detail": r[key]["detail"], "sent_cov": r[key].get("sent_cov"), "len_ratio": r[key].get("len_ratio")}) for st in ("narrative", "dialogue"): sub = [r for r in gated if r["stratum"] == st] subfp = [r for r in sub if r[key]["verdict"] == "excision_suspect"] s[st] = {"n": len(sub), "flags": len(subfp), "flag_rate": round(len(subfp) / max(1, len(sub)), 3)} # per-chapter flag counts → informs the "N flags per chapter" threshold perch = {} for r in gated: k = f"{r['file']}::ch{r['chapter']}" perch.setdefault(k, {"chunks": 0, "flags": 0}) perch[k]["chunks"] += 1 perch[k]["flags"] += int(r[key]["verdict"] == "excision_suspect") s["per_chapter_max_flags"] = max((v["flags"] for v in perch.values()), default=0) s["per_chapter"] = perch # sent_cov / len_ratio distribution over gated OK chunks (for threshold tuning) covs = sorted(round(r[key]["sent_cov"], 3) for r in gated if r[key].get("sent_cov") is not None) lrs = sorted(round(r[key]["len_ratio"], 3) for r in gated if r[key].get("len_ratio") is not None) if covs: s["sent_cov_min_observed"] = covs[0] s["sent_cov_p05"] = covs[max(0, int(0.05 * len(covs)) - 1)] if lrs: s["len_ratio_min_observed"] = lrs[0] summ[stage] = s # sub-min chunks excluded from prod gating (reported for transparency) submin = [r for r in llm if "draft_verdict" in r and r["src_chars"] < MIN_CHUNK_CHARS] summ["submin_excluded"] = len(submin) stage_err = [r for r in llm if r.get("draft_verdict", {}).get("verdict") not in COVERAGE_ELIGIBLE and "draft_verdict" in r] summ["non_coverage_dispositions"] = [ {"file": r["file"], "ch": r["chapter"], "idx": r["idx"], "verdict": r["draft_verdict"]["verdict"]} for r in stage_err] # human arm: ANY flag on a published complete translation is a clean false positive human = [r for r in rows if r["arm"] == "human" and "verdict" in r] hfp = [r for r in human if r["verdict"]["verdict"] == "excision_suspect"] summ["human"] = {"n": len(human), "flags": len(hfp), "flag_rate": round(len(hfp) / max(1, len(human)), 3), "flagged": [{"file": r["file"], "ru_ch": r.get("ru_chapter"), "stratum": r["stratum"], "detail": r["verdict"]["detail"], "sent_cov": r["verdict"].get("sent_cov"), "len_ratio": r["verdict"].get("len_ratio")} for r in hfp], "sent_cov_all": sorted(round(r["verdict"].get("sent_cov", 9), 3) for r in human), "len_ratio_all": sorted(round(r["verdict"].get("len_ratio", 9), 3) for r in human)} return summ def main(): ap = argparse.ArgumentParser() ap.add_argument("--arm", choices=["llm", "human", "both"], default="both") ap.add_argument("--edit", action="store_true", help="also run grok-4.3 editor pass") ap.add_argument("--dry-run", action="store_true") ap.add_argument("--limit", type=int, help="max chunks per file (LLM arm)") ap.add_argument("--workers", type=int, default=5) ap.add_argument("--translator", choices=["deepseek", "grok"], default="deepseek", help="draft model for the LLM arm (grok = non-echoing zh translator)") ap.add_argument("--langs", help="comma-separated source langs to include (e.g. zh)") ap.add_argument("--out", default=str(OUT / "results.json")) args = ap.parse_args() global _TRANSLATOR, LLM_SOURCES _TRANSLATOR = DRAFT_GROK if args.translator == "grok" else DRAFT if args.langs: keep = set(args.langs.split(",")) LLM_SOURCES = [s for s in LLM_SOURCES if s[0] in keep] rows = [] if args.arm in ("llm", "both"): rows += run_llm(args.edit, args.dry_run, args.limit, args.workers) if args.arm in ("human", "both"): rows += run_human(args.dry_run) summ = summarize(rows) Path(args.out).write_text(json.dumps({"summary": summ, "rows": rows}, ensure_ascii=False, indent=2)) print("\n=== SUMMARY ===") print(json.dumps(summ, ensure_ascii=False, indent=2)) print(f"\nwrote {args.out}") if __name__ == "__main__": main()