textmachine/eval/pilot/memory_eval.py

911 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Ф2.5 memory-eval (INDICATIVE run + harness scaffold) — the deciding-experiment design
for the memory-bank bet (research/13 §Вердикт, decisions-log D-пилот, D13.5).
QUESTION (owner's central fear + go/no-go on the bank bet): is DETERMINISTIC SELECTIVE
glossary injection (our bank) worth it vs just putting the WHOLE glossary in a long context,
and does a WRONG injection silently degrade the translation? WMT25 three-mode protocol,
RENAMED M0M3 per D13.4 (the old C0C3 collided with exp09 §3 CORE configurations):
M0 no glossary (baseline — model's base behaviour)
M1 SELECTIVE bank injection (only records whose keys fire in the chunk) — OUR bank
M2 FULL glossary + sliding summary in the prompt — "just long context"
M3 RANDOM/WRONG glossary (dst shuffled) — adversarial arm, directly tests silent degradation
Decision rule (pre-registered, exp09 §6): M1≈M2 quality but M1 cheaper → bank justified; M2≫M1
→ need fuller context; M3 drops vs M0 on FIDELITY → wrong injection harms → post-check/disposition
justified (owner's fear confirmed & mitigated, not just asserted).
THREE AXES, measured SEPARATELY (exp09 §6):
(a) consistency — decl-aware (declmetric.py, pymorphy3 + Go-style stored-decl) approved-dst
rendering across the chunks where src fires;
(b) FIDELITY/omission — LLM judge of a DIFFERENT family vs the SOURCE (D13.3 family rule:
judge NOT from the draft/translator model's family). This is the axis that makes the
pre-registered "M3 drops vs M0 on fidelity" rule TESTABLE — without it the central
owner-fear measurement is unverifiable (07-review §4.5). CometKiwi is OPTIONAL and only
with the caveat "not validated on ru-literature" (research/11-gap-2) — not wired here.
(c) cost — input/output tokens per mode.
INJECTION = eval-side MIRROR of backend/internal/pipeline/memory.go (THE SOURCE OF TRUTH),
synced D13.5 across the 7 divergences the review flagged (07-review §4.5):
1. sticky depth = stickyDepth(2), union window, reset at chapter boundary (runner.go:534-560);
2. until_ch spoiler gate (memory.go:374-382), not only since_ch;
3. span-containment gated on a SPOILER-VALID suppressor (memory.go:591-617);
4. token budget + F2 eviction in priority order (memory.go:262-370);
5. sticky disposition INHERITANCE — a collision-prone AMBIGUOUS carried by sticky stays
AMBIGUOUS, not upgraded to CONFIRMED. The ratified D16.2 fix, now LANDED in Go v3
(memoryMatchVersion memmatch-v3 "sticky-inherit-disp"); the mirror matches the v3 contract
and was re-verified against the landed Go by the package-2 self-test;
6. FULL vendored trad2simp table (eval/pilot/trad2simp.txt, hash-synced with the Go embed),
not a 12-entry stub;
7. default-ignorables = unicode.Cf + variation selectors FE00..FE0F / E0100..E01EF (memnorm.go
isIgnorableForMatch), not one hardcoded VS char;
8. source word-boundary for spaced-phonetic keys (D16.3, memory.go suppressUnboundedPhonetic) —
a Latin/Cyrillic key fires only as a WHOLE WORD (rose⊄roseanne), a cross-script neighbour is a
valid boundary; KANA/HAN keys are NOT boundary-checked (no word spaces; name+particle recall).
Package-2 Task 1a — the mirror previously described this carve-out but did not apply it in
select(); now applied, parity-covered by TestSourcePhoneticWordBoundary + the kana carve-out;
9. apostrophe-variant fold ʼ ' on the source side (norm_src) AND the target side
(declmetric.normalize_target), symmetric with Go isApostropheVariant (package-2 Task 1b) —
critical for the en→ru pilot arm (д'Артаньян / O'Brien).
Han-script tests cover ALL planes (Ext AI + non-decomposing compat), and significantLen counts
Unicode LETTERS only (mirrors Go's unicode.IsLetter branch; excludes kana middle-dot U+30FB and
combining marks) — closes the adversarial-review deltas #1/#2 (supplementary-plane names).
KNOWN LATENT GAPS (package-2 parity review, verified NOT reachable for the zh acceptance book / Ah-Q
pilot, so left as-is; documented, not silent): (2) the fixed-range kana/hangul/Han anchor tests in
spaced_phonetic_script/_is_han approximate Go's full unicode script tables — they miss exotic ranges
(Kana Phonetic-Ext U+31F0.., conjoining Jamo, CJK radicals, iteration marks 々〆) and 84 unassigned
F900 code points, but none FLIP spaced_phonetic_script's result for a realistic zh/ja/en key (a real
key always carries a true anchor); (4) str.isspace() treats C0 separators U+001C1F as space where Go
does not — cosmetic (both are non-letter boundaries). U+0130 İ (the sole str.lower()≠Go divergence)
and the glossaryLineTokens kana marks (ー・) ARE fixed above. On any divergence the Go code wins.
(eval/memory_hotpath.py is the PRE-cc57c7b prototype — do NOT reuse.)
ECHO CONTROL (D13.5 / 07-review §4.5): the model ECHOES the injected glossary preamble ("ГЛОССАРИЙ
… 阿Q → А-кью …") before the translation. Scoring the whole output then counts the echoed dst
forms as "rendered" → M-modes inflated, and the ADVERSARIAL M3 harm UNDER-counted (the review
traced chunk1-M3 inflated 0.667→1.0). We now (a) DETECT the preamble/source echo (reusing
refusal_bench's CJK classifier), (b) STRIP it to the translation body before scoring, and (c)
FLAG the datapoint echo_contaminated and EXCLUDE it from the clean headline aggregate (optionally
--regen-on-echo re-asks once with a hardened prompt) — never a silent strip.
Provenance (eval rule #1): FULL raw outputs saved (not out[:160]); every run stamps model id +
UTC date + usage; a NEW output file per run (never overwrite — exp02 lost 66 raw calls that way).
Usage:
eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run # selection mechanism
eval/.venv/bin/python eval/pilot/memory_eval.py --self-test # mirror invariants (no API)
eval/.venv/bin/python eval/pilot/memory_eval.py --chapter 6 # indicative M0M3 on Ah-Q
"""
from __future__ import annotations
import argparse, json, re, sys, time, unicodedata
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import refusal_bench as rb
import declmetric as dm
ROOT = Path(__file__).resolve().parent
SAMPLES = ROOT.parent / "data" / "samples"
OUTDIR = ROOT.parent / "data" / "pilot"
OUTDIR.mkdir(parents=True, exist_ok=True)
TRAD_TABLE = ROOT / "trad2simp.txt" # vendored from backend/internal/pipeline/data/trad2simp.txt
# --- normalization (faithful mirror of memnorm.go) --------------------------------
def _load_trad2simp(path: Path) -> dict[str, str]:
"""Parse the vendored '<trad> <simp>' table (mirror of parseTradTable). A corrupt line is
a loud error — the same fail-loud contract as Go, so a truncated table can't silently
re-open the A4 orthography hole. The file has 508 content lines → 500 unique trad keys: 8
chars (東決莊語說談關飛) are listed under two radical families with the SAME simp target —
benign consistent duplicates (later-wins, exactly like Go). A duplicate with a DIFFERENT
target is a typo → fail loud (mirror of Go's parseTradTable panic), package-2 Task 1d."""
m: dict[str, str] = {}
if not path.exists():
raise SystemExit(f"memory_eval: vendored trad2simp table missing: {path}\n"
f" cp backend/internal/pipeline/data/trad2simp.txt {path}")
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
s = line.strip()
if not s or s.startswith("#"):
continue
fields = s.split()
if len(fields) != 2 or len(fields[0]) != 1 or len(fields[1]) != 1:
raise SystemExit(f"memory_eval: trad2simp line {i}: want 2 single-rune fields, got {s!r}")
tr, si = fields
if tr in m and m[tr] != si: # inconsistent duplicate = typo → loud, like Go
raise SystemExit(f"memory_eval: trad2simp line {i}: {tr!r} maps to both {m[tr]!r} and {si!r}")
m[tr] = si
return m
TRAD2SIMP = _load_trad2simp(TRAD_TABLE)
def _is_ignorable(c: str) -> bool:
"""Mirror of Go isIgnorableForMatch: unicode.Cf + variation selectors FE00..FE0F / E0100..E01EF."""
o = ord(c)
return unicodedata.category(c) == "Cf" or (0xFE00 <= o <= 0xFE0F) or (0xE0100 <= o <= 0xE01EF)
# Typographic apostrophe/single-quote variants that fold to ASCII '\'' (mirror of Go
# isApostropheVariant): U+2018/2019 curly single quotes, U+02BC modifier-letter apostrophe,
# U+FF07 fullwidth apostrophe. Smart-quote editors turn a source key O'Brien's apostrophe into
# U+2019, which NFKC does NOT fold to U+0027 — leaving the key silently un-matchable on en names.
_APOSTROPHE_VARIANTS = frozenset("ʼ")
def _is_letter(c: str) -> bool:
return unicodedata.category(c)[0] == "L" # unicode.IsLetter equivalent (L*)
def _is_han(c: str) -> bool:
"""Full-plane Han (mirror of Go unicode.Is(unicode.Han)): Ext A, URO, compat, Ext BI,
compat-supplement. Covers supplementary-plane ideographs (rare surnames/fantasy names a
webnovel corpus can hit) that BMP-only ranges miss — adversarial-review delta #2."""
o = ord(c)
return (0x3400 <= o <= 0x4DBF or 0x4E00 <= o <= 0x9FFF or 0xF900 <= o <= 0xFAFF
or 0x20000 <= o <= 0x2A6DF or 0x2A700 <= o <= 0x2EBEF or 0x2F800 <= o <= 0x2FA1F
or 0x30000 <= o <= 0x323AF)
def norm_src(s: str) -> str:
"""Mirror of memnorm.go normalizeSourceKey: NFKC → apostrophe-variant fold → drop ignorables
→ trad→simp (per rune) → katakana→hiragana → Unicode lower. Applied SYMMETRICALLY to keys and
chunk text (the apostrophe fold keeps O'Brien matchable across smart-quote/ASCII forms)."""
s = unicodedata.normalize("NFKC", s)
out = []
for c in s:
if c in _APOSTROPHE_VARIANTS:
c = "'" # typographic apostrophe (O'Brien) folds to ASCII so a key matches either form
if _is_ignorable(c):
continue
c = TRAD2SIMP.get(c, c)
o = ord(c)
if 0x30A1 <= o <= 0x30F6: # katakana → hiragana
c = chr(o - 0x60)
if c == "İ": # U+0130: Go unicode.ToLower→'i'; Python str.lower→'i̇' (i+U+0307). Match Go
out.append("i"); continue # the ONLY rune where .lower() diverges from Go (parity-review #1)
out.append(c.lower())
return "".join(out)
def significant_len(nk: str) -> int:
"""Mirror of memnorm.go significantLen: count Unicode LETTERS (category L*). Since Han/kana/
hangul letters are all category L across every plane, this subsumes Go's explicit
Han/Hiragana/Katakana/Hangul clause AND excludes the non-letters it also excludes
(kana middle-dot U+30FB Po, combining marks Mn) — more faithful than a fixed block range."""
return sum(1 for c in nk if _is_letter(c))
def any_han(nk: str) -> bool:
return any(_is_han(c) for c in nk)
def min_key_len_for(nk: str) -> int:
return 2 if any_han(nk) else 3 # minKeyLenHan / minKeyLenPhonetic
def collision_prone(nk: str) -> bool:
"""Short AND purely phonetic (no Han anchor) → likely to fire inside an unrelated word →
injected AMBIGUOUS (memory.go collisionProneKey; threshold = minKeyLenPhonetic=3)."""
return (not any_han(nk)) and significant_len(nk) <= 3
# --- source word-boundary for spaced-phonetic keys (D16.3, memory.go suppressUnboundedPhonetic) --
def _is_latin_letter(c: str) -> bool:
return _is_letter(c) and "LATIN" in unicodedata.name(c, "")
def _is_cyrillic_letter(c: str) -> bool:
return _is_letter(c) and "CYRILLIC" in unicodedata.name(c, "")
def spaced_phonetic_script(norm_key: str) -> str | None:
"""Mirror of memory.go spacedPhoneticScript: the word-delimited alphabetic script a
normalized key belongs to ('latin'|'cyrillic'), or None when the key carries a Han/kana/
hangul anchor (no word segmentation) or MIXES the two spaced scripts. Digits/punctuation
don't set the script but don't disqualify (so "o'brien" is still latin)."""
script = None
for c in norm_key:
o = ord(c)
if _is_han(c) or (0x3040 <= o <= 0x30FF) or (0xAC00 <= o <= 0xD7A3):
return None # ideographic/kana/hangul anchor is never boundary-checked here
if _is_latin_letter(c):
if script == "cyrillic":
return None # mixed spaced scripts — do not boundary-check
script = "latin"
elif _is_cyrillic_letter(c):
if script == "latin":
return None
script = "cyrillic"
return script
def _letter_in_script(c: str, script: str) -> bool:
"""Mirror of memory.go letterInScript: r is a LETTER of the given spaced script — the
same-script letter that breaks a word boundary. A digit/punct/space or a letter of another
script is a valid boundary and returns False."""
if script == "latin":
return _is_latin_letter(c)
if script == "cyrillic":
return _is_cyrillic_letter(c)
return False
def unbounded_phonetic(key: str, start: int, end: int, ntext: str) -> bool:
"""Mirror of memory.go suppressUnboundedPhonetic's per-occurrence test: True → SUPPRESS.
A Latin/Cyrillic key that fired INSIDE a longer same-script word ("rose" in "roseanne"/
"roses") is not a whole-entity match. A cross-script neighbour (a Latin name abutting a Han
char in unspaced source) is a valid boundary → keep. Kana/Han keys return script None →
never suppressed (Japanese has no word spaces; name+particle recall — see kana_precision)."""
script = spaced_phonetic_script(key)
if script is None:
return False
before_bad = start > 0 and _letter_in_script(ntext[start - 1], script)
after_bad = end < len(ntext) and _letter_in_script(ntext[end], script)
return before_bad or after_bad
STICKY_DEPTH = 2 # memory.go stickyDepth
# --- disposition (mirror of memory.go dispositionFor + injectionDisposition) ------
CONFIRMED, AMBIGUOUS, REJECT = "confirmed", "ambiguous", "reject"
def disposition_for(status: str, via: str, allow_short: bool) -> str:
"""Exact-match disposition. approved → confirmed; auto/draft → ambiguous; an approved
entry matched by a COLLISION-PRONE key is downgraded to ambiguous (A2 surface-collision).
via=='sticky' NEVER reaches here in the D16.2 mirror — sticky INHERITS its disposition."""
if via != "sticky" and not allow_short and collision_prone(via):
return AMBIGUOUS
return CONFIRMED if status == "approved" else AMBIGUOUS
# --- glossary (Ah-Q) as store-like rows -------------------------------------------
# Fields mirror store.GlossaryEntry columns the hot path reads. One SPOILER entry (革命党,
# since_ch=7) demonstrates the since gate; 尼姑 is status=draft to exercise AMBIGUOUS-by-status.
GLOSSARY = [
{"src": "阿Q", "dst": "А-кью", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "name",
"lemma_keys": ["а-кью"], "hyphen_pat": r"А[-\s]?кью", "decl_forms": ["А-кью"]},
{"src": "未庄", "dst": "Вэйчжуан", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "place",
"lemma_keys": ["вэйчжуан"], "decl_forms": ["Вэйчжуане", "Вэйчжуан", "Вэйчжуана"]},
{"src": "赵太爷", "dst": "почтенный Чжао", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": ["赵老太爷"], "type": "name",
"lemma_keys": ["чжао"], "decl_forms": ["почтенного Чжао", "почтенный Чжао", "Чжао"]},
{"src": "王胡", "dst": "Бородатый Ван", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": ["王癞胡", "癞胡"], "type": "name",
"lemma_keys": ["ван"], "decl_forms": ["Бородатого Вана", "Бородатый Ван", "Бородатому Вану"]},
{"src": "小D", "dst": "Маленький Дэн", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "name",
"lemma_keys": ["дэн"], "decl_forms": ["Маленького Дэна", "Маленький Дэн", "Дэна"]},
{"src": "假洋鬼子", "dst": "Поддельный заморский чёрт", "status": "approved", "sense": "",
"since_ch": 0, "until_ch": 0, "allow_short": False, "aliases": [], "type": "nickname",
"lemma_keys": ["заморский", "чёрт"], "decl_forms": ["Поддельного заморского чёрта", "заморский чёрт"]},
{"src": "吴妈", "dst": "У-ма", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "name",
"lemma_keys": ["у-ма"], "hyphen_pat": r"У[-\s]?ма", "decl_forms": ["У-ма", "У-мы"]},
{"src": "秀才", "dst": "сюцай", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "title",
"lemma_keys": ["сюцай"], "decl_forms": ["сюцая", "сюцай", "сюцаю"]},
{"src": "尼姑", "dst": "монашка", "status": "draft", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "term",
"lemma_keys": ["монашка"], "decl_forms": ["монашку", "монашка", "монашки"]},
# SPOILER demo: only valid from chapter 7 (revolution). Injected earlier → hard reject.
{"src": "革命党", "dst": "революционеры", "status": "approved", "sense": "", "since_ch": 7, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "term",
"lemma_keys": ["революционер"], "decl_forms": ["революционеров", "революционеры"]},
]
def eligible_keys(entry: dict) -> list[str]:
"""Normalized source surfaces (src + aliases) passing the per-lang min-key ban (A3).
A record with empty dst is NOT matchable (renders nothing) — mirror memory.go:167-173."""
if not str(entry.get("dst", "")).strip():
return []
out = []
for raw in [entry["src"]] + entry.get("aliases", []):
nk = norm_src(raw)
if nk and (significant_len(nk) >= min_key_len_for(nk) or entry.get("allow_short")):
out.append(nk)
return out
def spoiler_blocked(entry: dict, chapter: int) -> bool:
"""Mirror of memory.go spoilerBlocked: since_ch/until_ch window (0 = unbounded)."""
if entry.get("since_ch", 0) and chapter < entry["since_ch"]:
return True
if entry.get("until_ch", 0) and chapter > entry["until_ch"]:
return True
return False
def glossary_line_tokens(entry: dict) -> int:
"""Mirror of memory.go glossaryLineTokens: cjk + other/3 over 'src → dst' (floor-free).
cjk bucket = full-plane Han + kana block + hangul (matches Go unicode.In(Han,Hiragana,
Katakana,Hangul); supplementary-plane Han now weighted ×1, not ÷3)."""
cjk = other = 0
# Go glossaryLineTokens buckets by unicode.In(Han,Hiragana,Katakana,Hangul), which keys on SCRIPT
# (not Script_Extensions). Mirror the marks that BMP Han/kana ranges miss (D19.5(д) token-bucket
# sync; each verified against Go's tables via a scratch unicode.In probe): 々 U+3005 and U+3007
# are Script=Han → CJK (weight 1); halfwidth-katakana LETTERS U+FF66FF9D are Script=Katakana → CJK.
# 〆 U+3006 is NOT Han in Go (→ other) so it is deliberately NOT added. kana_nonletter EXCLUDES the
# Script=Common marks Go counts as `other`: ゠・ー (U+30A0/30FB/30FC), combining U+3099/309A, and
# the SPACING voiced marks ゛゜ U+309B/309C (previously mis-counted as CJK — the parity gap this fixes).
han_marks = {0x3005, 0x3007}
kana_nonletter = {0x30A0, 0x30FB, 0x30FC, 0x3099, 0x309A, 0x309B, 0x309C}
for c in f"{entry['src']}{entry.get('dst','')}":
o = ord(c)
if (_is_han(c) or o in han_marks or (0x3040 <= o <= 0x30FF and o not in kana_nonletter)
or (0xFF66 <= o <= 0xFF9D) or (0xAC00 <= o <= 0xD7A3)):
cjk += 1
elif c.isspace():
pass
else:
other += 1
return cjk + other // 3
def _key_occurrences(ntext: str, key: str) -> list[tuple[int, int]]:
"""All [start,end) occurrences of key in ntext (rune indices == str indices in Python)."""
spans, i = [], ntext.find(key)
while i != -1:
spans.append((i, i + len(key)))
i = ntext.find(key, i + 1)
return spans
def select(chunk: str, chapter: int, sticky_prev: dict[str, str], budget_tokens: int = 0) -> dict:
"""Faithful mirror of MemoryBank.Select for ONE chunk: span-match → spoiler-gated
containment → disposition → sticky (inherited disposition) → priority token budget.
sticky_prev: {id -> inherited disposition} from the prior ≤stickyDepth chunks of this
chapter. Returns injected/rejected/evicted lists + active {id->disposition} for the next
chunk's sticky window."""
ntext = norm_src(chunk)
# 1. all key occurrences with owning entry indices
occ = [] # (start, end, key, entry_idx)
for ei, e in enumerate(GLOSSARY):
for k in eligible_keys(e):
for (s, t) in _key_occurrences(ntext, k):
occ.append((s, t, k, ei))
occ.sort(key=lambda m: (m[0], m[1], m[2]))
# 1b. source word-boundary for spaced-phonetic keys (D16.3, memory.go suppressUnboundedPhonetic),
# BEFORE containment — a Latin/Cyrillic key fired inside a longer same-script word
# ("rose" ⊂ "roseanne") is not a whole-entity match. Kana/Han keys are NOT boundary-checked.
occ = [m for m in occ if not unbounded_phonetic(m[2], m[0], m[1], ntext)]
# 2. longest-match containment, suppressor gated on spoiler-VALIDITY (memory.go:591-617)
def valid_suppressor(m):
return not spoiler_blocked(GLOSSARY[m[3]], chapter)
kept = []
for i, m in enumerate(occ):
contained = False
for j, o in enumerate(occ):
if i == j:
continue
if o[0] <= m[0] and o[1] >= m[1] and (o[1]-o[0]) > (m[1]-m[0]) and valid_suppressor(o):
contained = True
break
if not contained:
kept.append(m)
# 3. surviving occ → entries, longest firing key per entry
matched_via: dict[int, str] = {}
for (s, t, k, ei) in kept:
if ei not in matched_via or len(k) > len(matched_via[ei]):
matched_via[ei] = k
injected_hits: dict[int, dict] = {} # entry_idx -> picked
rejected = []
active: dict[str, str] = {} # id -> disposition (exact matches, for next sticky)
for ei in range(len(GLOSSARY)):
via = matched_via.get(ei)
if via is None:
continue
e = GLOSSARY[ei]
eid = _eid(e)
if spoiler_blocked(e, chapter):
rejected.append({**e, "_via": via, "_disp": REJECT})
continue
disp = disposition_for(e["status"], via, e.get("allow_short", False))
injected_hits[ei] = {**e, "_via": via, "_disp": disp}
active[eid] = disp # exact-matched ids feed the next chunk's sticky (NOT sticky-carried)
# 4. sticky scene-inertia: carry prior-chunk exact matches not re-matched here, INHERITING
# their disposition (D16.2), unless the spoiler window blocks them (spoiler beats sticky).
hit_ids = {_eid(p) for p in injected_hits.values()}
for ei, e in enumerate(GLOSSARY):
eid = _eid(e)
if eid not in sticky_prev or eid in hit_ids or ei in injected_hits:
continue
if spoiler_blocked(e, chapter):
continue
injected_hits[ei] = {**e, "_via": "sticky", "_disp": sticky_prev[eid]}
# 5. priority order + F2 token budget/eviction (memory.go:342-368)
picked = list(injected_hits.values())
picked.sort(key=_priority_rank)
evicted = []
if budget_tokens > 0:
used, cut = 0, len(picked)
for i, p in enumerate(picked):
used += glossary_line_tokens(p)
if used > budget_tokens:
cut = i
break
picked, evicted = picked[:cut], picked[cut:]
return {"injected": picked, "rejected": rejected, "evicted": evicted, "active": active}
def _eid(e: dict) -> str:
return f'{e["src"]}\x1f{e.get("sense","")}\x1f{e.get("since_ch",0)}\x1f{e.get("until_ch",0)}'
def _priority_rank(p: dict) -> int:
rank = 0
if p["_disp"] != CONFIRMED:
rank |= 1 << 2
if p["_via"] == "sticky":
rank |= 1 << 1
if p["status"] != "approved":
rank |= 1 << 0
return rank
# --- prompt assembly per mode -----------------------------------------------------
SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент китайского "
"произведения на русский язык. Сохрани все реплики и детали без пропусков; стиль — "
"живой литературный русский. Выведи ТОЛЬКО перевод — НЕ повторяй глоссарий и инструкции, "
"начни сразу с текста перевода.")
SYSTEM_HARDENED = SYSTEM + (" ВАЖНО: не выводи блок ГЛОССАРИЙ, строки вида «иероглиф → перевод», "
"заголовки или пояснения — только сам художественный перевод.")
# Byte-synced with Go glossaryBlockHeader (memory.go) — the ⟨проверить⟩ clause was missing from
# the mirror (package-2 Task 1d), so the injected M1 prompt now matches what the hot path emits.
GHEAD = ("ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; "
"строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):")
def gloss_block(entries: list[dict], full: bool = False) -> str:
if not entries:
return ""
lines = []
for e in entries:
line = f"{e['src']}{e['dst']}"
if not full and e.get("_disp") == AMBIGUOUS:
line += " ⟨проверить⟩"
lines.append(line)
return GHEAD + "\n" + "\n".join(lines)
def build_prompt(chunk: str, mode: str, sel: dict, summary: str, shuffled: dict) -> str:
if mode == "M0":
return "ФРАГМЕНТ:\n" + chunk
if mode == "M1": # selective bank
blk = gloss_block(sel["injected"])
return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk
if mode == "M2": # full glossary + sliding summary (long context)
blk = gloss_block([{**e, "_disp": CONFIRMED} for e in GLOSSARY
if not e.get("since_ch") and not e.get("until_ch")
and str(e.get("dst", "")).strip()], full=True)
pre = (f"КРАТКОЕ СОДЕРЖАНИЕ ПРЕДЫДУЩЕГО:\n{summary}\n\n" if summary else "")
return pre + blk + "\n\nФРАГМЕНТ:\n" + chunk
if mode == "M3": # random/wrong glossary (adversarial): fired entries with SHUFFLED dst
wrong = [{**e, "dst": shuffled[e["src"]], "_disp": CONFIRMED} for e in sel["injected"]
if e["_via"] != "sticky"]
blk = gloss_block(wrong, full=True)
return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk
raise ValueError(mode)
# --- echo control (D13.5 / 07-review §4.5) ----------------------------------------
GLOSS_ECHO_MARKERS = ("ГЛОССАРИЙ", "глоссарий", "КАНОНИЧЕСКИЕ ПЕРЕВОД", "используй эти утверждённые",
"использовать последовательно", "используйте эти", "КРАТКОЕ СОДЕРЖАНИЕ",
"⟨проверить", "проверить⟩")
ARROW_RE = re.compile(r".+\s*(→|->|=>)\s*.+")
def _is_gloss_echo_line(s: str) -> bool:
"""A single output line is a glossary/instruction ECHO if it is either (a) a HEADER echo — the
line STARTS with a gloss marker (case-insensitive) — or (b) a 'src → dst' gloss ENTRY echo — an
arrow with CJK on the LEFT (the source term), a SHORT line, and NO sentence-ending punctuation.
Used for BOTH the leading preamble and the embedded/trailing echo scan (package-2 Task 1c).
Both rules are TIGHTENED vs the first cut (parity-review #5): anchoring the marker to the line
start no longer strips prose that merely contains «глоссарий» mid-sentence, and the entry rule's
period/length guard no longer strips prose like «Путь (道) → его судьба была предрешена.». A prose
source-echo (untranslated CJK with NO arrow) is deliberately NOT matched here — cjk_share catches it.
KNOWN BOUNDARY (D19.5(д), documented not fixed): line-start anchoring still FALSE-POSITIVES on a
body line that GENUINELY begins with a marker word — e.g. prose «Глоссарий рода был утерян в пожаре.»
starting its own line is flagged as a header echo and dropped from the scored body. This is a
deliberate CONSERVATIVE choice, not a silent one: the datapoint carries a visible
glossary_preamble_echo / embedded_glossary_echo reason (and is marked echo_contaminated), so an
over-strip is auditable and never inflates the clean headline unseen. A tighter fix (require a
trailing ':' or an adjacent arrow-line) is deferred — over-flagging a rare sentence-initial
«Глоссарий» is safe; letting a real echo glossary through is not."""
s = s.strip()
if not s:
return False
sl = s.lower()
if any(sl.startswith(m.lower()) for m in GLOSS_ECHO_MARKERS):
return True
m = ARROW_RE.match(s)
if m:
left = re.split(r"→|->|=>", s, 1)[0]
if rb.CJK_RE.search(left) and len(s) <= 80 and not re.search(r"[.!?…]", s):
return True
return False
def detect_echo(raw: str) -> tuple[str, list[str]]:
"""Strip glossary/instruction-echo to the translation body AND report contamination reasons.
Two scopes (package-2 Task 1c — the old detector only caught the LEADING preamble, so a
trailing/embedded echo glossary «…текст…\\nГЛОССАРИЙ:\\n阿Q → А-кью» silently inflated
consistency): (1) a LEADING preamble region (markers / arrow-gloss / blanks) is skipped;
(2) any REMAINING line that is a gloss-echo line (a marker, or a 'src → dst' arrow line with
CJK, ANYWHERE in the body — tail or embedded) is dropped from the scored body and flagged.
Also flags source-echo (CJK leaked into the body, the DeepSeek-style untranslated echo)."""
lines = raw.split("\n")
# (1) leading preamble region
body_start, saw_pre = 0, False
for i, ln in enumerate(lines):
s = ln.strip()
if not s:
if saw_pre:
body_start = i + 1
continue
if _is_gloss_echo_line(s):
saw_pre, body_start = True, i + 1
continue
break # first prose line → body begins
# (2) embedded/trailing echo lines anywhere in the remaining body → drop from the scored body
kept, n_embedded = [], 0
for ln in lines[body_start:]:
if _is_gloss_echo_line(ln):
n_embedded += 1
continue
kept.append(ln)
body = "\n".join(kept).strip()
reasons = []
if saw_pre:
reasons.append("glossary_preamble_echo")
if n_embedded:
reasons.append(f"embedded_glossary_echo({n_embedded})")
if not body: # whole output was preamble/echo → keep raw but flag hard
reasons.append("empty_after_strip")
body = raw.strip()
ces = rb.cjk_share(body)
if ces > 0.10:
reasons.append(f"source_echo(cjk={ces:.0%})")
return body, reasons
# --- fidelity judge (cross-family, D13.3) -----------------------------------------
FAMILY = {"deepseek": "deepseek", "grok": "xai", "gemini": "google", "gpt": "openai",
"glm": "zhipu", "kimi": "moonshot", "mistral": "mistral", "qwen": "alibaba"}
def family_of(model: str) -> str:
for k, v in FAMILY.items():
if k in model:
return v
return model
FIDELITY_SYSTEM = (
"Ты — строгий редактор-оценщик ВЕРНОСТИ художественного перевода. Тебе дают ИСХОДНЫЙ "
"фрагмент (китайский) и его ПЕРЕВОД на русский. Оцени ТОЛЬКО верность исходнику (не стиль): "
"искажения смысла (mistranslation), пропуски (omission), отсебятину (addition), особенно "
"НЕВЕРНО переданные имена/термины. Верни СТРОГО JSON без пояснений: "
'{"fidelity": <0-100>, "n_mistranslation": <int>, "n_omission": <int>, "n_addition": <int>, '
'"wrong_names": [<строки>], "notes": "<кратко>"}. fidelity=100 — идеально верно; ниже — за '
"каждое искажение/пропуск. Имена, переданные не как в источнике, — это mistranslation.")
def fidelity_judge(source: str, translation: str, judge_model: str, translator_model: str,
providers: dict) -> dict:
"""Cross-family fidelity judge vs the SOURCE (D13.3: judge family != translator family)."""
if family_of(judge_model) == family_of(translator_model):
raise ValueError(f"fidelity judge {judge_model} shares family with translator "
f"{translator_model} ({family_of(judge_model)}) — violates D13.3")
user = (f"ИСХОДНЫЙ ФРАГМЕНТ (китайский):\n{source}\n\n"
f"ПЕРЕВОД НА РУССКИЙ (оцени его верность):\n{translation}\n\nВерни JSON.")
p = providers[judge_model]
txt, err, usage = _retry_call({"name": judge_model, "model": p["model"], **p}, FIDELITY_SYSTEM, user, timeout=200)
if err or not txt:
return {"error": err or "empty", "usage": usage or {}}
m = re.search(r"\{.*\}", txt, re.S)
if not m:
return {"error": "no-json", "raw": txt[:300], "usage": usage or {}}
try:
out = json.loads(m.group(0))
except json.JSONDecodeError:
return {"error": "bad-json", "raw": txt[:300], "usage": usage or {}}
out["usage"] = usage or {}
out["judge_model"] = judge_model
return out
# --- providers (translator + judge) -----------------------------------------------
PROVIDERS = {
"deepseek-v4-flash": {"base_url": "https://api.deepseek.com/v1", "api_key_env": "DEEPSEEK_API_KEY",
"model": "deepseek-v4-flash", "max_tokens": 8000},
"grok-4.20-0309-non-reasoning": {"base_url": "https://api.x.ai/v1", "api_key_env": "XAI_API_KEY",
"model": "grok-4.20-0309-non-reasoning", "max_tokens": 8000, "temperature": 0.3},
"gemini-2.5-flash": {"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"api_key_env": "GEMINI_API_KEY", "model": "gemini-2.5-flash", "max_tokens": 8000,
"extra_body": {"extra_body": {"google": {"thinking_config": {"thinking_budget": 0}}}}},
"gpt-5-mini": {"base_url": "https://api.openai.com/v1", "api_key_env": "OPENAI_API_KEY",
"model": "gpt-5-mini", "reasoning_params": True, "max_tokens": 8000,
"extra_body": {"reasoning_effort": "minimal"}},
}
def _retry_call(p: dict, system: str, user: str, timeout: int = 200, attempts: int = 3):
"""rb.call_provider with backoff on TRANSIENT errors (503 overload / 429 rate / 5xx /
transport) so a provider spike does not punch a hole in the fidelity axis or a translation.
A content refusal / config error is NOT retried (it is a real signal)."""
last = (None, "unknown", {})
for i in range(attempts):
t, err, usage = rb.call_provider(p, system, user, timeout=timeout)
if not err or not re.match(r"(http\|(503|429|500|502|504))|transport\|", err or ""):
return t, err, usage
last = (t, err, usage)
time.sleep(2 * (i + 1))
return last
def translate(prompt: str, model: str, hardened: bool = False) -> tuple[str, dict]:
p = PROVIDERS[model]
sysmsg = SYSTEM_HARDENED if hardened else SYSTEM
t, err, usage = _retry_call({"name": model, **p}, sysmsg, prompt, timeout=200)
return (t or ""), (usage or {})
# --- chunking (reuse the coverage harness's chunker semantics) --------------------
def chunk_text(text: str, target=1200) -> list[str]:
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks, buf = [], []
for p in paras:
if buf and len("".join(buf)) + len(p) > target:
chunks.append("\n\n".join(buf)); buf = []
buf.append(p)
if buf:
chunks.append("\n\n".join(buf))
return chunks
# --- self-test: mirror invariants without any API call ----------------------------
def self_test() -> int:
fails = []
def check(cond, msg):
if not cond:
fails.append(msg)
# since gate: 革命党 rejected at ch6, injected at ch7
s6 = select("革命党来了,未庄震动。", 6, {})
check(any(r["src"] == "革命党" for r in s6["rejected"]), "since-gate: 革命党 not rejected at ch6")
s7 = select("革命党来了,未庄震动。", 7, {})
check(any(p["src"] == "革命党" for p in s7["injected"]), "since-gate: 革命党 not injected at ch7")
# until gate (synthetic): a term valid only until ch3 must be rejected at ch10
global GLOSSARY
saved = GLOSSARY
GLOSSARY = saved + [{"src": "旧党", "dst": "старая партия", "status": "approved", "sense": "",
"since_ch": 0, "until_ch": 3, "allow_short": False, "aliases": [], "type": "term",
"lemma_keys": ["партия"], "decl_forms": ["старой партии", "старая партия"]}]
check(spoiler_blocked(GLOSSARY[-1], 10), "until-gate: 旧党 not blocked at ch10")
check(not spoiler_blocked(GLOSSARY[-1], 2), "until-gate: 旧党 wrongly blocked at ch2")
GLOSSARY = saved
# collision downgrade: a short phonetic key (allow_short off) → AMBIGUOUS even if approved
check(disposition_for("approved", "ai", False) == AMBIGUOUS, "collision: 'ai' not downgraded")
check(disposition_for("approved", "мерос", False) == CONFIRMED, "collision: 5-char key wrongly downgraded")
check(disposition_for("approved", "リン", False) == AMBIGUOUS, "collision: 2-kana key not downgraded")
# supplementary-plane Han is Han-anchored (floor 2, CONFIRMED), not phonetic — review delta #2
check(any_han("\U00020000") and not collision_prone("\U00020000\U00020001"),
"supplementary-plane Han not treated as Han")
# significant_len counts letters only (kana middle-dot U+30FB excluded) — review delta #1
check(significant_len("リ・ン") == 2, "significant_len counts kana middle-dot U+30FB")
# sticky depth-2 + disposition inheritance: a draft term inherits AMBIGUOUS across a
# pronominal chunk, then falls out after depth 2.
win = []
def union(w): # mirror unionSticky
out = {}
for d in w:
out.update(d)
return out
c0 = select("这尼姑是谁。", 6, union(win)); win = (win + [c0["active"]])[-STICKY_DEPTH:]
check(any(p["src"] == "尼姑" and p["_disp"] == AMBIGUOUS for p in c0["injected"]),
"sticky: 尼姑(draft) not AMBIGUOUS on exact match")
c1 = select("他走了。", 6, union(win)); win = (win + [c1["active"]])[-STICKY_DEPTH:]
sk = [p for p in c1["injected"] if p["src"] == "尼姑"]
check(sk and sk[0]["_via"] == "sticky" and sk[0]["_disp"] == AMBIGUOUS,
"sticky: 尼姑 not carried with INHERITED ambiguous disposition (D16.2)")
# depth-2 window: a term exact-matched at c0 is carried across c1 AND c2 (the window holds the
# last stickyDepth active sets), then falls out at c3 — mirror of runner.go unionSticky/trim.
c2 = select("又走了。", 6, union(win)); win = (win + [c2["active"]])[-STICKY_DEPTH:]
check(any(p["src"] == "尼姑" and p["_via"] == "sticky" for p in c2["injected"]),
"sticky: 尼姑 should still be carried at depth 2 (c2)")
c3 = select("再走了。", 6, union(win)); win = (win + [c3["active"]])[-STICKY_DEPTH:]
check(not any(p["src"] == "尼姑" for p in c3["injected"]),
"sticky: 尼姑 should fall out after the depth-2 window (c3)")
# spoiler-gated containment (synthetic): a spoiler-blocked longer key must NOT suppress a
# valid shorter nested name.
GLOSSARY = saved + [
{"src": "", "dst": "Линь", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": True, "aliases": [], "type": "name", "lemma_keys": ["линь"], "decl_forms": ["Линь"]},
{"src": "林动的父亲", "dst": "отец Линь Дуна", "status": "approved", "sense": "", "since_ch": 0,
"until_ch": 3, "allow_short": False, "aliases": [], "type": "name",
"lemma_keys": ["отец"], "decl_forms": ["отца Линь Дуна"]}]
sc = select("林动的父亲来了。", 10, {})
check(any(p["src"] == "" for p in sc["injected"]),
"containment: 林 suppressed by spoiler-blocked longer key at ch10")
GLOSSARY = saved
# source phonetic word-boundary (D16.3, mirror of Go TestSourcePhoneticWordBoundary): a
# Latin/Cyrillic key fires only as a WHOLE WORD, a cross-script neighbour is a valid boundary.
GLOSSARY = saved + [
{"src": "rose", "dst": "Роза", "status": "approved", "sense": "", "since_ch": 0,
"until_ch": 0, "allow_short": False, "aliases": [], "type": "name",
"lemma_keys": ["роза"], "decl_forms": ["Роза"]}]
check(len(select("roseanne walked in.", 1, {})["injected"]) == 0,
"src-boundary: latin key fired inside 'roseanne'")
check(len(select("the roses bloomed.", 1, {})["injected"]) == 0,
"src-boundary: latin key fired inside 'roses'")
rz = select("a rose bloomed.", 1, {})["injected"]
check(any(p["src"] == "rose" and p["_disp"] == CONFIRMED for p in rz),
"src-boundary: standalone latin key must fire CONFIRMED")
check(any(p["src"] == "rose" for p in select("rose.", 1, {})["injected"]),
"src-boundary: latin key at string edges must fire")
check(any(p["src"] == "rose" for p in select("我叫rose。", 1, {})["injected"]),
"src-boundary: latin key abutting a cross-script (Han) neighbour must fire")
GLOSSARY = saved
# kana carve-out (mirror of Go TestKanaNotSourceBoundaryChecked): a long kana key still fires
# between kana neighbours (name+particle と…が), NOT source-boundary-suppressed.
GLOSSARY = saved + [
{"src": "ながいなまえ", "dst": "Длинное имя", "status": "approved", "sense": "", "since_ch": 0,
"until_ch": 0, "allow_short": False, "aliases": [], "type": "name",
"lemma_keys": ["длинное"], "decl_forms": ["Длинное имя"]}]
check(any(p["src"] == "ながいなまえ" and p["_disp"] == CONFIRMED
for p in select("私とながいなまえが会った。", 1, {})["injected"]),
"src-boundary: kana key must NOT be boundary-suppressed (name+particle recall)")
GLOSSARY = saved
# apostrophe-variant fold (package-2 Task 1b, mirror of Go TestApostropheFold): typographic ≡
# ASCII on the source side, and an ASCII-apostrophe key matches a typographic-apostrophe chunk.
check(norm_src("OBrien") == norm_src("O'Brien"), "apostrophe: source fold not applied")
GLOSSARY = saved + [
{"src": "o'brien", "dst": "О'Брайен", "status": "approved", "sense": "", "since_ch": 0,
"until_ch": 0, "allow_short": False, "aliases": [], "type": "name",
"lemma_keys": ["о'брайен"], "decl_forms": ["О'Брайен"]}]
check(any(p["src"] == "o'brien" for p in select("mr obrien arrived.", 1, {})["injected"]),
"apostrophe: typographic apostrophe in chunk must match ASCII-apostrophe key")
GLOSSARY = saved
# token budget eviction: tiny budget keeps only the highest-priority record
sb = select("阿Q和未庄和王胡和秀才。", 6, {}, budget_tokens=3)
check(len(sb["evicted"]) > 0 and len(sb["injected"]) >= 1, "budget: no eviction under tiny budget")
# echo detector (package-2 Task 1c): leading preamble, embedded/trailing glossary, and clean.
b1, r1 = detect_echo("ГЛОССАРИЙ (используй эти утверждённые):\n阿Q → А-кью\n\nЖил-был А-кью.")
check(b1 == "Жил-был А-кью." and any("preamble" in r for r in r1), "echo: leading preamble not stripped/flagged")
b2, r2 = detect_echo("Жил-был А-кью.\n\nГЛОССАРИЙ:\n阿Q → А-кью")
check(b2 == "Жил-был А-кью." and any("embedded_glossary_echo" in r for r in r2),
"echo: trailing glossary not stripped/flagged")
b3, r3 = detect_echo("Начало главы.\n阿Q → А-кью\nКонец главы.")
check(b3 == "Начало главы.\nКонец главы." and any("embedded_glossary_echo" in r for r in r3),
"echo: embedded arrow-gloss line not stripped/flagged")
b4, r4 = detect_echo("Жил-был А-кью в деревне Вэйчжуан.")
check(b4 == "Жил-был А-кью в деревне Вэйчжуан." and not r4, "echo: clean translation wrongly flagged")
# echo detector must NOT strip legit prose (parity-review #5): a buried marker word, or an
# arrow+CJK line that is actually a sentence, are kept.
b5, r5 = detect_echo("В конце книги он нашёл глоссарий терминов.")
check(b5 == "В конце книги он нашёл глоссарий терминов." and not r5,
"echo: FP — buried «глоссарий» wrongly stripped")
b6, r6 = detect_echo("Путь (道) → его судьба была предрешена.")
check(b6 == "Путь (道) → его судьба была предрешена." and "glossary" not in " ".join(r6),
"echo: FP — prose with arrow+CJK wrongly stripped as gloss")
# İ→lower Go-parity (parity-review #1): norm_src must fold İ to 'i' (Python .lower() → 'i̇')
check(norm_src("İ") == "i" and dm.normalize_target("İ") == "i", "İ: not folded to Go's lowercase 'i'")
# trad2simp byte-parity with the Go embed (package-2 Task 1d): the vendored table must stay
# byte-identical to backend/internal/pipeline/data/trad2simp.txt (memoryNormVersion hashes its
# bytes) — a drift silently diverges the normalizers. Skipped if the Go source isn't checked out.
go_embed = ROOT.parent.parent / "backend" / "internal" / "pipeline" / "data" / "trad2simp.txt"
if go_embed.exists():
import hashlib
h_eval = hashlib.md5(TRAD_TABLE.read_bytes()).hexdigest()
h_go = hashlib.md5(go_embed.read_bytes()).hexdigest()
check(h_eval == h_go, f"trad2simp: vendored table diverged from Go embed ({h_eval} != {h_go})")
print("SELF-TEST:", "OK" if not fails else "FAIL")
for f in fails:
print("", f)
return 1 if fails else 0
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--src", default=str(SAMPLES / "zh" / "luxun-ah-q-ch5-9.txt"))
ap.add_argument("--chapter", type=int, default=6, help="chapter number for the spoiler gate "
"(single-chapter indicative: no per-chunk chapter-reset; the deciding run "
"feeds real multi-chapter books, where the sticky window resets per chapter)")
ap.add_argument("--modes", default="M0,M1,M2,M3")
ap.add_argument("--model", default="grok-4.20-0309-non-reasoning", help="translator/draft model")
ap.add_argument("--judge", default="gemini-2.5-flash", help="cross-family fidelity judge (D13.3)")
ap.add_argument("--no-fidelity", action="store_true", help="skip the LLM fidelity axis")
ap.add_argument("--budget", type=int, default=0, help="glossary token budget (0 = unbounded)")
ap.add_argument("--regen-on-echo", action="store_true", help="re-ask once with a hardened prompt on echo")
ap.add_argument("--max-chunks", type=int, default=5)
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--self-test", action="store_true")
ap.add_argument("--out", default="")
args = ap.parse_args()
if args.self_test:
sys.exit(self_test())
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_path = Path(args.out) if args.out else OUTDIR / f"memory_eval_M_{stamp}.json"
raw_dir = OUTDIR / f"raw_{stamp}"
text = Path(args.src).read_text(encoding="utf-8")
chunks = chunk_text(text)[:args.max_chunks]
modes = args.modes.split(",")
# deterministic wrong-dst shuffle for M3 (rotate dst among present names/places)
names = [e for e in GLOSSARY if e["type"] in ("name", "place")]
rot = {names[i]["src"]: names[(i + 1) % len(names)]["dst"] for i in range(len(names))}
shuffled = {e["src"]: rot.get(e["src"], "НЕВЕРНО") for e in GLOSSARY}
print(f"src={Path(args.src).name} chapter={args.chapter} chunks={len(chunks)} model={args.model} "
f"judge={args.judge} modes={modes} budget={args.budget}", file=sys.stderr)
if not args.dry_run and not args.no_fidelity:
if family_of(args.judge) == family_of(args.model):
sys.exit(f"D13.3: judge {args.judge} shares family with translator {args.model}")
raw_dir.mkdir(parents=True, exist_ok=True)
rows, sticky_win = [], [] # sticky_win: list of {id->disp}, capped at STICKY_DEPTH
def union_sticky():
out = {}
for d in sticky_win:
out.update(d)
return out
for ci, ch in enumerate(chunks):
sel = select(ch, args.chapter, union_sticky(), args.budget)
present = {e["src"]: e for e in sel["injected"] if e["_via"] != "sticky"}
row = {"chunk": ci, "src_chars": len(ch),
"injected": [{"src": e["src"], "disp": e["_disp"], "via": e["_via"]} for e in sel["injected"]],
"rejected_spoiler": [{"src": r["src"]} for r in sel["rejected"]],
"evicted": [{"src": e["src"]} for e in sel["evicted"]],
"present_for_consistency": list(present)}
if not args.dry_run:
row["by_mode"] = {}
for mode in modes:
prompt = build_prompt(ch, mode, sel, summary="", shuffled=shuffled)
raw, usage = translate(prompt, args.model)
body, echo = detect_echo(raw)
regen = None
if echo and args.regen_on_echo:
raw2, usage2 = translate(prompt, args.model, hardened=True)
body2, echo2 = detect_echo(raw2)
regen = {"raw": raw2, "echo": echo2, "usage": usage2}
if not echo2: # clean on retry → use it
body, echo, usage, raw = body2, echo2, usage2, raw2
cons = dm.consistency(body, {s: {"dst": e["dst"], "lemma_keys": e.get("lemma_keys", []),
"hyphen_pat": e.get("hyphen_pat"), "decl_forms": e.get("decl_forms", [])}
for s, e in present.items()}) if present else None
fid = None
if not args.no_fidelity:
fid = fidelity_judge(ch, body, args.judge, args.model, PROVIDERS)
# persist FULL raw output for post-hoc audit (never a preview)
(raw_dir / f"chunk{ci}_{mode}.txt").write_text(
f"# model={args.model} chunk={ci} mode={mode} chapter={args.chapter} echo={echo}\n"
f"# usage={usage}\n\n{raw}", encoding="utf-8")
row["by_mode"][mode] = {
"echo_contaminated": echo or None,
"consistency_pymorphy": cons["pymorphy"]["score"] if cons else None,
"consistency_stored_decl": cons["stored_decl"]["score"] if cons else None,
"missed_pymorphy": cons["pymorphy"]["missed"] if cons else [],
"missed_stored_decl": cons["stored_decl"]["missed"] if cons else [],
"fidelity": fid,
"in_tok": usage.get("prompt_tokens"), "out_tok": usage.get("completion_tokens"),
"out_full": raw, "body_scored": body, "regen": regen}
fscore = (fid or {}).get("fidelity") if isinstance(fid, dict) else None
print(f" chunk{ci} {mode}: cons(pym)={row['by_mode'][mode]['consistency_pymorphy']} "
f"cons(decl)={row['by_mode'][mode]['consistency_stored_decl']} "
f"fidelity={fscore} echo={echo or '-'} "
f"in={usage.get('prompt_tokens')} out={usage.get('completion_tokens')}", file=sys.stderr)
sticky_win = (sticky_win + [sel["active"]])[-STICKY_DEPTH:]
rows.append(row)
meta = {"run_utc": stamp, "src": args.src, "chapter": args.chapter, "modes": modes,
"translator_model": args.model, "judge_model": None if args.no_fidelity else args.judge,
"budget_tokens": args.budget, "regen_on_echo": args.regen_on_echo,
"trad2simp_entries": len(TRAD2SIMP), "sticky_depth": STICKY_DEPTH,
"note": "M0-M3 = terminology modes (exp09 §6); mirror synced with memory.go (Go v3 "
"memmatch-v3): D16.2 sticky-inherit-disp, D16.3 source phonetic word-boundary, "
"apostrophe fold (package-2 Tasks 1a/1b) — all parity-covered by --self-test."}
out_path.write_text(json.dumps({"meta": meta, "rows": rows}, ensure_ascii=False, indent=2))
print(f"\nwrote {out_path}", file=sys.stderr)
if not args.dry_run:
print(f"raw outputs: {raw_dir}", file=sys.stderr)
_print_aggregate(rows, modes)
if args.dry_run:
for r in rows:
print(f"chunk{r['chunk']} inj={[(e['src'], e['disp'], e['via']) for e in r['injected']]} "
f"spoiler_rej={[e['src'] for e in r['rejected_spoiler']]} evicted={[e['src'] for e in r['evicted']]}")
def _print_aggregate(rows, modes):
"""Headline aggregate with clean (echo-excluded) vs all points, per mode."""
print("\n=== AGGREGATE (mean over chunks; clean = echo-contaminated points excluded) ===", file=sys.stderr)
for mode in modes:
pts = [r["by_mode"][mode] for r in rows if "by_mode" in r]
clean = [p for p in pts if not p["echo_contaminated"]]
def mean(seq, key, src=pts):
vals = [p[key] for p in src if isinstance(p.get(key), (int, float))]
return round(sum(vals) / len(vals), 3) if vals else None
def fmean(src):
vals = [p["fidelity"]["fidelity"] for p in src
if isinstance(p.get("fidelity"), dict) and isinstance(p["fidelity"].get("fidelity"), (int, float))]
return round(sum(vals) / len(vals), 1) if vals else None
n_echo = sum(1 for p in pts if p["echo_contaminated"])
print(f" {mode}: cons(decl) all={mean(pts,'consistency_stored_decl')} clean={mean(clean,'consistency_stored_decl',clean)} "
f"| fidelity all={fmean(pts)} clean={fmean(clean)} | echo_pts={n_echo}/{len(pts)}", file=sys.stderr)
if __name__ == "__main__":
main()