textmachine/eval/adaptive_incontext.py

170 lines
9.1 KiB
Python
Raw Permalink 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
"""INDICATIVE in-context experiment for the adaptive-memory verdict (docs/research/14).
Tests THE central BAR question of the "free win" (in-context demonstrations) strand:
does adding the book's own DEMONSTRATIONS (prior translated passages) beat the
deterministic baseline the bank ALREADY ships — soft-glossary injection — for keeping a
recurring entity's rendering canonical and consistent on a REAL zh→ru literary chunk;
and do the learning layer's failure modes actively HARM (Power-of-Noise near-miss
distractor, 2401.14887; over-trust of a poisoned entry, 2510.00829)?
Testbed: 魯迅《阿Q正传》ch5-9 (real PD zh) — a dense chunk where А-Q fantasizes the
revolution and names 8 recurring characters at once. Canonical dst = В. Рогов's published
translation (eval/data/samples/ru/luxun-ah-q-ru.txt); demonstrations are AUTHENTIC Rogov
sentences. Metric = the bank's OWN deterministic post-check (canonical stem present in
output), scored only over entities whose zh key is in the source chunk.
Arms:
A_bare no memory (raw model)
B_glossary soft-glossary "src → dst" block <- THE deterministic baseline
C_demos glossary + 3 authentic prior passages <- the learning layer (free win)
D_noise glossary + 3 IRRELEVANT prior passages <- Power-of-Noise near-miss arm
E_poison glossary with ONE wrong dst (赵太爷→"Ли") <- over-trust / silent-injection arm
Model: deepseek-v4-flash (the real draft model; thinking ON by default → avoids the zh
echo). N reps (temp 0.3) for stability. INDICATIVE: small N, hand-built accept-regexes
(self-confirmation risk), one model, one chunk. Not the Phase-2.5 in-house eval — a probe.
Usage: eval/.venv/bin/python eval/adaptive_incontext.py [--reps 3] [--provider deepseek]
Output: stdout table + eval/data/adaptive_incontext.json
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import refusal_bench as rb # reuse call_provider + providers quirks + env load
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "data" / "samples" / "zh" / "luxun-ah-q-ch5-9.txt"
OUT = ROOT / "data" / "adaptive_incontext.json"
# Dense multi-entity window (offset 6360, 650 chars): А-Q's revolution fantasy.
CHUNK_OFF, CHUNK_LEN = 6360, 650
# Recurring entities: zh key -> (canonical Rogov dst shown in glossary, accept-regex on
# the canonical STEM allowing Russian declension). Stem match = the bank's post-check (E1).
ENTITIES = {
"阿Q": ("А-кью", r"А[-\s]?кью"),
"未庄": ("Вэйчжуан (деревня)", r"Вэйчжуан"),
"赵太爷": ("почтенный Чжао", r"Чжао"),
"王胡": ("Бородатый Ван", r"Ван\b"),
"小D": ("Маленький Дэн", r"Дэн"),
"假洋鬼子": ("Поддельный заморский чёрт", r"заморск\w+ чёрт|поддельн"),
"吴妈": ("У-ма", r"У[-\s]?ма"),
"邹七嫂": ("тётушка Цзоу Седьмая", r"Цзоу"),
}
# Arm C: AUTHENTIC prior translated passages (Rogov), each carrying a recurring entity in
# natural inflected Russian — the "demonstration" the learning layer would retrieve.
DEMOS_RELEVANT = [
"Он знал, что вместо него нанимают Маленького Дэна, этого худого и слабосильного бедняка.",
"Сын почтенного Чжао добился, наконец, учёной степени сюцая, и об этом оповестили всю деревню.",
"Жители деревни Вэйчжуан требовали от него только подённой работы да насмехались над ним.",
]
# Arm D: IRRELEVANT prior passages (near-miss distractors) — plausible book prose, but
# about material NOT in this chunk. Tests "better nothing than garbage" on the real wire.
DEMOS_NOISE = [
"Стены храма Тудигун почернели от времени, и ветер свистел в щелях по ночам.",
"На реке скрипели вёсла лодочников, а торговцы раскладывали на пристани сушёную рыбу.",
"В тот год урожай риса выдался скудным, и цены на масло поднялись по всей округе.",
]
SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент художественного "
"произведения на русский язык. Это перевод существующего текста, предоставленного "
"правообладателем. Сохрани все реплики и детали без пропусков; стиль — живой "
"литературный русский. Выведи ТОЛЬКО перевод, без комментариев.")
def glossary_block(poison=False):
lines = []
for zh, (dst, _) in ENTITIES.items():
d = "господин Ли (важный чиновник)" if (poison and zh == "赵太爷") else dst
lines.append(f"{zh}{d}")
return "ГЛОССАРИЙ (используй эти утверждённые переводы имён/терминов последовательно):\n" + "\n".join(lines)
def demos_block(demos):
return ("РАНЕЕ ПЕРЕВЕДЁННЫЕ ФРАГМЕНТЫ ЭТОЙ КНИГИ (держи ту же терминологию и стиль):\n"
+ "\n".join(f"{d}" for d in demos))
def build_user(chunk, arm):
parts = []
if arm in ("B_glossary", "C_demos", "D_noise"):
parts.append(glossary_block())
if arm == "E_poison":
parts.append(glossary_block(poison=True))
if arm == "C_demos":
parts.append(demos_block(DEMOS_RELEVANT))
if arm == "D_noise":
parts.append(demos_block(DEMOS_NOISE))
parts.append("ФРАГМЕНТ ДЛЯ ПЕРЕВОДА:\n" + chunk)
return "\n\n".join(parts)
def cjk_ratio(s):
cjk = len(re.findall(r"[㐀-鿿]", s))
return round(cjk / max(1, len(s)), 3)
def score(output, present):
"""Bank post-check: fraction of source-present entities whose canonical stem is in output."""
matched = [zh for zh in present if re.search(ENTITIES[zh][1], output)]
return round(len(matched) / max(1, len(present)), 3), matched
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--reps", type=int, default=3)
ap.add_argument("--provider", default="deepseek")
ap.add_argument("--providers", default=str(ROOT / "providers.json"))
args = ap.parse_args()
prov = next(p for p in json.load(open(args.providers))["providers"] if p["name"] == args.provider)
chunk = SRC.read_text(encoding="utf-8")[CHUNK_OFF:CHUNK_OFF + CHUNK_LEN]
present = [zh for zh in ENTITIES if zh in chunk]
print(f"provider={prov['model']} chunk={len(chunk)}ch present entities ({len(present)}): {present}\n")
arms = ["A_bare", "B_glossary", "C_demos", "D_noise", "E_poison"]
results = {"provider": prov["model"], "present": present, "reps": args.reps, "arms": {}}
print(f"{'arm':<12} {'canon-consist':<14} {'cjk-echo':<9} {'poison-followed':<16} note")
print("-" * 78)
for arm in arms:
user = build_user(chunk, arm)
recs = []
for _ in range(args.reps):
text, err, usage = rb.call_provider(prov, SYSTEM, user, timeout=180)
if not text:
recs.append({"err": err}); continue
cons, matched = score(text, present)
rec = {"consist": cons, "matched": matched, "cjk": cjk_ratio(text),
"poison_followed": bool(re.search(r"господин Ли|\bЛи\b", text)) and not re.search(r"Чжао", text),
"len": len(text), "sample": text[:0]}
recs.append(rec)
ok = [r for r in recs if "err" not in r]
if not ok:
print(f"{arm:<12} {'ALL-FAILED':<14} {'':<9} {'':<16} {[r['err'] for r in recs][:1]}")
results["arms"][arm] = {"records": recs}; continue
mc = round(sum(r["consist"] for r in ok) / len(ok), 3)
me = round(sum(r["cjk"] for r in ok) / len(ok), 3)
pf = sum(r["poison_followed"] for r in ok)
echo = " ECHO!" if me > 0.15 else ""
results["arms"][arm] = {"mean_consist": mc, "mean_cjk": me, "poison_followed": pf,
"n_ok": len(ok), "records": recs}
print(f"{arm:<12} {mc:<14} {me:<9} {str(pf)+'/'+str(len(ok)):<16} n={len(ok)}{echo}")
OUT.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\nsaved -> {OUT}")
print("Reading: BA = does the deterministic soft-glossary baseline help; CB = does the\n"
"learning layer (demonstrations) beat that baseline; D vs C = Power-of-Noise harm;\n"
"E poison-followed>0 = over-trust (why the learning layer must NOT touch approved names).")
if __name__ == "__main__":
main()