#!/usr/bin/env python3 """Round-trip drift analysis for #000056 (the pattern, deterministic). For each bench pair: en --[opus-mt-en-es]--> es --[opus-mt-es-en]--> en' The es→en leg is the SANDWICH'S ACTUAL edge-IN. If en' preserves the content nouns of en, the sandwich feeds FTS5 the right terms and grounding tracks the English baseline. If a content noun is lost (Hamlet→"village", "New London"→"new London"), retrieval can't find the article no matter how good Hermes is — the failure is upstream of grounding, in named-entity-preserving MT. Buckets by content-token recall of en' vs en (stopword-stripped): CLEAN >= 0.80 COLLAPSE < 0.40 DRIFT otherwise No Hermes, no network — offline + reproducible. """ from __future__ import annotations import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from arborist.qa.mt import OpusMTTranslator # noqa: E402 _W = re.compile(r"[A-Za-z][A-Za-z0-9]*") _STOP = set("the a an is are was were be of to in on at for with by from as " "and or what who where when why how which this that who whom did " "do does has have had can could would will who're were name named " "between play wrote write written who's".split()) def toks(s: str) -> set[str]: return {w.lower() for w in _W.findall(s) if w.lower() not in _STOP and len(w) > 1} def main() -> int: pairs = json.loads((ROOT / "bench" / "qa_questions_es_map.json").read_text()) tr = OpusMTTranslator() rows = [] for p in pairs: en, es = p["en"], p["es"] back = tr.translate(es, "es", "en") # the sandwich's edge-IN a, b = toks(en), toks(back) recall = round(len(a & b) / len(a), 2) if a else 1.0 bucket = "CLEAN" if recall >= 0.80 else "COLLAPSE" if recall < 0.40 else "DRIFT" lost = sorted(a - b) rows.append({"en": en, "es": es, "back": back, "recall": recall, "bucket": bucket, "lost_tokens": lost}) out = ROOT / "bench" / "qa_results" / "es_roundtrip.json" out.write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n") from collections import Counter c = Counter(r["bucket"] for r in rows) n = len(rows) print(f"n={n} CLEAN={c['CLEAN']} ({c['CLEAN']/n:.0%}) " f"DRIFT={c['DRIFT']} ({c['DRIFT']/n:.0%}) " f"COLLAPSE={c['COLLAPSE']} ({c['COLLAPSE']/n:.0%})") print("\n-- COLLAPSE (named entity / key noun lost on round-trip) --") for r in rows: if r["bucket"] == "COLLAPSE": print(f" en : {r['en']}\n back: {r['back']} lost={r['lost_tokens']}") print("\n-- a few CLEAN --") for r in [x for x in rows if x["bucket"] == "CLEAN"][:6]: print(f" {r['en']} ==~ {r['back']}") print(f"\nwrote {out}") return 0 if __name__ == "__main__": raise SystemExit(main())