From e4ca3a57fd1a8cf526e04ee10b6b736f18ac9696 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 5 Jul 2026 01:34:18 +0300 Subject: [PATCH] Add the adaptive-memory research verdict (doc 14) with four executable probes: flagship logprob access, in-context demos vs glossary, lemma post-check, and the deterministic lever curve --- docs/research/14-adaptive-memory.md | 16 ++- eval/adaptive_incontext.py | 170 +++++++++++++++++++++++++++ eval/adaptive_levers.py | 170 +++++++++++++++++++++++++++ eval/adaptive_probe.py | 176 ++++++++++++++++++++++++++++ eval/adaptive_reask.py | 86 ++++++++++++++ 5 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 eval/adaptive_incontext.py create mode 100644 eval/adaptive_levers.py create mode 100644 eval/adaptive_probe.py create mode 100644 eval/adaptive_reask.py diff --git a/docs/research/14-adaptive-memory.md b/docs/research/14-adaptive-memory.md index a5ba0f5..5b73c80 100644 --- a/docs/research/14-adaptive-memory.md +++ b/docs/research/14-adaptive-memory.md @@ -79,8 +79,10 @@ | D. глоссарий + дистрактор-демонстрации | **0.875** | near-miss не укусил на этой метрике/масштабе | | E. глоссарий с ОДНОЙ отравленной строкой (赵太爷→«Ли») | **0.833**, отравление принято **0/3** | сильная модель **сопротивлялась** отраве на известном имени | +**Поправка (диагностика `eval/adaptive_reask.py`):** строгий accept-регэксп **занизил** глоссарий. Персистентный промах 王胡 — **артефакт метрики, не провал adherence**: модель рендерила канон Рогова «Бородатого Вана» (склонённое), а `Ван\b` не ловит суффикс. Склонение-толерантный матч → **глоссарий = 8/8 = 1.000** (оба репа, ноль реальных провалов adherence). То есть **на этом чанке детерминированный глоссарий УЖЕ насыщает консистентность** — demos не бьют его **потому что бить нечего** (потолок). Это (а) усиливает вердикт §2, (б) — **живая инстанция несущего невалидированного риска research/13 §2**: post-check регэкспом в русской морфологии undercount'ит И даёт ложные флаги → **post-check обязан лемматизировать (pymorphy2/Natasha), а не регэкспить.** + Три индикативных вывода (малый N, hand-built accept-регэксп → риск само-подтверждения, одна книга/модель — **не** финальный eval): -1. **Детерминированный soft-глоссарий сильно работает на zh→ru** (0.458→0.875) — впервые индикативно подтверждено на нашем направлении (валидация банка это на zh→ru не мерила). Хорошая новость для ядра. +1. **Детерминированный soft-глоссарий сильно работает на zh→ru** (без памяти 0.458 → глоссарий **~1.0** после поправки на склонение) — впервые индикативно подтверждено на нашем направлении (валидация банка это на zh→ru не мерила). Хорошая новость для ядра. Оговорка: чанк «лёгкий» (глоссарий насыщается) → реальную ценность demos и re-ask надо мерить на **трудных** чанках (длинные/плотные, где adherence падает на 17–36%, 2310.05824). 2. **Демонстрации НЕ бьют глоссарий по консистентности** (0.833 vs 0.875) — ровно как предсказывает Li (выигрыш демо — 65% стиль, а не термин-матч, чего эта метрика не видит). Значит **выигрыш демонстраций, если он есть, — стилевой, а не консистентность/fidelity**, а БАР-(в) меряет именно консистентность/fidelity. 3. **Отравленную строку сильная модель отвергла** на ИЗВЕСТНОМ имени (阿Q-канон в претрейне) — мягкое свидетельство против слепого over-trust, но **НЕ** тест на НОВОМ термине без прайора (там over-trust реален — 2510.00829). @@ -174,6 +176,18 @@ --- +## §9. Максимизация коэффициента канон-консистентности — детерминированные рычаги (по запросу владельца) + +Раз гибрид отклонён — правильный ход не «обучать ранкер», а **добивать детерминированную сторону**. Замер `eval/adaptive_levers.py` (трудный чанк 阿Q, 11 сущностей, 1600 знаков, deepseek+grok) даёт кривую рычагов и порядок внедрения бэкенду: + +- **L1 — post-check ЛЕММАТИЗИРУЮЩИЙ (pymorphy3), не регэксп. Внедрять ПЕРВЫМ.** Наивный `\b`-регэксп на трудном чанке даёт **18–36% ложных флагов** (deepseek 2/11, grok 4/11 — склонённые формы, отрендеренные верно). pymorphy3 их снимает и **сворачивает OOV-транслит** (Вана→ван, Дэна→дэн, Вэйчжуане→вэйчжуан). Это количественная валидация несущего невалидированного риска research/13 §2 (регэксп-шум → редакторы не верят флагам → safety рушится). Дешевле всего, чинит больше всего. +- **L2 — RECALL матчера: нормализация (полная, тестируемая) + alias-граф + sticky.** Точный матч на trad/alias формах: recall **0.00 → +норм 0.50 → +alias 0.75**. Живой баг: неполная trad-карта (爺→爷) молча промахнула 赵太爷 → **A4 подтверждён: нормализацию покрыть и тестировать бит-в-бит** (OpenCC, не мини-карта). Крупнейший рычаг рекола (retrieval_bench recall 0.571 — 43% упоминаний не инъектится). +- **L3 — post-check + точечный re-ask** (verifier-gated correction, M2). На трудном чанке добивает реальные промахи adherence (deepseek 0.82→0.91). Не чинит гомограф/односимвольный ключ (钱) — и не должен. +- **L4 — таблица Палладия/Поливанова (B6): потолок ПРАВИЛЬНОСТИ.** L1–L3 поднимают «использует ли модель канон»; L4 — «правильный ли канон». Локаль zh 0.26, облако-топ 0.75 — только детерминированная таблица закрывает разрыв. +- **Побочно подтверждено:** 钱 (односимвольный ключ, гомограф Цянь/деньги) упорно промахивается даже после re-ask → **правило `min_key_len`/запрет одиночных ключей (A3) обязательно** — 钱 из глоссария выкинуть или заменить целой сущностью (钱府→«дом Цянь»). + +**Где ваш инстинкт «тюнить ранкер» легитимен (честно):** ТОЛЬКО во втором эшелоне (fuzzy/семантика, кандидаты для человека) — у bge-m3 нет разделяющего порога (retrieval_bench), и research/13 §5 оставил лазейку: другое представление / sparse-голова / маленький cross-encoder reranker может разделять лучше. Это Фаза-2, low-trust, коэффициент горячего пути (§9) не двигает. Инстинкт верен, цель — второй эшелон, не костяк. + ## Честные слабые места рекомендаций (по этим пунктам пишут решение) 1. **Ни одна цифра литературы не на zh/ja→ru — тотально.** Все 24 верификации: DelTA, Moslem, Li, Power-of-Noise, self-correction-скептики, kNN-MT, LoRA, ModernMT/Lilt — англо-центричны или ru только как источник. Ближайшее к нам — **WMT24 Discourse-Level Literary (2412.11732) с добавленным zh→ru document-level треком** и **DITING (2510.09116)** zh-en вебновелла — но это **бенчмарки-оценки**, не методы адаптации, и ja→ru литературной адаптации/бенчмарка **не существует вовсе** (2505.05423 держит ru только как источник). Любая рекомендация по ja→ru — по аналогии, не по данным. diff --git a/eval/adaptive_incontext.py b/eval/adaptive_incontext.py new file mode 100644 index 0000000..e2fe1e0 --- /dev/null +++ b/eval/adaptive_incontext.py @@ -0,0 +1,170 @@ +#!/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: B−A = does the deterministic soft-glossary baseline help; C−B = 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() diff --git a/eval/adaptive_levers.py b/eval/adaptive_levers.py new file mode 100644 index 0000000..ef3e7c8 --- /dev/null +++ b/eval/adaptive_levers.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Детерминированные рычаги канон-консистентности — кривая L1→L3 на ТРУДНОМ чанке. + +Follow-up к adaptive_incontext/adaptive_reask (по запросу владельца: «раз не идём в гибрид — +долбить детерминированную сторону; замерить, сколько добивает каждый рычаг»). Лёгкий чанк +насыщал глоссарий до 1.0 → рычаги не было видно. Здесь — ТРУДНЫЙ чанк (1600 знаков, ~11 +рекуррентных сущностей, плотно) + контролируемый recall-тест, где точный матч промахивается. + +Три рычага (все ДЕТЕРМИНИРОВАННЫЕ, 0 ML, 0 VRAM): + L1 post-check ЛЕММАТИЗИРУЮЩИЙ (pymorphy3), а не регэксп → убирает ложные флаги/undercount + (мой промах 王胡→«Бородатого Вана» на регэкспе `Ван\b`). Меряем false-flag rate. + L2 RECALL матчера: точный матч → +нормализация(trad→simp/NFKC) → +alias-граф → +sticky. + Детерминированный (без LLM): сколько «должных» записей вообще инъектится. + L3 post-check + точечный RE-ASK на РЕАЛЬНЫЕ промахи (verifier-gated correction) → прирост + adherence на трудном чанке. + +deepseek-v4-flash + grok-4.3. INDICATIVE (малый N). Usage: eval/.venv/bin/python eval/adaptive_levers.py +Output: stdout + eval/data/adaptive_levers.json +""" +from __future__ import annotations +import json, re, sys, unicodedata +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import refusal_bench as rb +import pymorphy3 + +ROOT = Path(__file__).resolve().parent +SRC = ROOT / "data" / "samples" / "zh" / "luxun-ah-q-ch5-9.txt" +OUT = ROOT / "data" / "adaptive_levers.json" +MORPH = pymorphy3.MorphAnalyzer() + +# Трудный чанк: 1600 знаков, ~11 рекуррентных сущностей (плотно → adherence проседает). +HARD = SRC.read_text(encoding="utf-8")[6450:6450 + 1600] + +# entity → (canon dst для глоссария, СТРОГИЙ регэксп [наивный post-check], лемма-ключ[и] для L1) +ENT = { + "阿Q": ("А-кью", r"А-кью\b", ["а-кью"]), # дефис → hyph-режим + "未庄": ("Вэйчжуан", r"Вэйчжуан\b", ["вэйчжуан"]), + "赵太爷": ("почтенный Чжао", r"Чжао\b", ["чжао"]), + "王胡": ("Бородатый Ван", r"Ван\b", ["ван"]), + "小D": ("Маленький Дэн", r"Дэн\b", ["дэн"]), + "假洋鬼子": ("Поддельный заморский чёрт", r"заморский чёрт\b", ["заморский", "чёрт"]), + "尼姑": ("монашка", r"монашка\b", ["монашка"]), + "吴妈": ("У-ма", r"У-ма\b", ["у-ма"]), + "邹七嫂": ("тётушка Цзоу Седьмая", r"Цзоу\b", ["цзоу"]), + "秀才": ("сюцай", r"сюцай\b", ["сюцай"]), + "钱": ("Цянь", r"Цянь\b", ["цянь"]), +} +HYPH = {"а-кью": r"А-?кью", "у-ма": r"У-?ма"} # дефисные translit — регэксп, не лемма + +SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент на русский язык. " + "Сохрани все реплики и детали без пропусков; стиль — живой литературный русский. Выведи ТОЛЬКО перевод.") +GLOSS = "ГЛОССАРИЙ (используй утверждённые переводы последовательно):\n" + \ + "\n".join(f"{z} → {v[0]}" for z, v in ENT.items()) +PRESENT = [z for z in ENT if z in HARD] + + +# ---------- матчеры post-check (L1) ---------- +def match_strict(out, z): + """Наивный регэксп-post-check (как в adaptive_incontext) — ломается на склонении.""" + return bool(re.search(ENT[z][1], out)) + +def lemmas_of(out): + return {MORPH.parse(w)[0].normal_form for w in re.findall(r"[А-Яа-яЁё]+", out)} + +def match_lemma(out, z, lem_cache): + """L1: лемматизирующий post-check. Для дефисных translit — регэксп-фолбэк.""" + keys = ENT[z][2] + if keys[0] in HYPH: + return bool(re.search(HYPH[keys[0]], out)) + return all(k in lem_cache for k in keys) + + +# ---------- матчер РЕКОЛА (L2), детерминированный ---------- +TRAD2SIMP = {"趙": "赵", "莊": "庄", "錢": "钱", "陳": "陈", "萬": "万", "舊": "旧", "臉": "脸"} +def normalize(s): + s = unicodedata.normalize("NFKC", s) + return "".join(TRAD2SIMP.get(c, c) for c in s) + +ALIASES = {"王胡": ["王癞胡", "癞胡"], "赵太爷": ["赵老太爷"]} + +def recall_config(chunks, keys_present, *, norm=False, alias=False, sticky=False): + """Сколько записей из keys_present инъектится по цепочке чанков при данной конфигурации.""" + active_prev, injected_ever = set(), set() + for ch in chunks: + text = normalize(ch) if norm else ch + hit = set() + for z in keys_present: + pats = [z] + (ALIASES.get(z, []) if alias else []) + pats = [normalize(p) if norm else p for p in pats] + if any(p in text for p in pats): + hit.add(z) + if sticky: + hit |= (active_prev & set(keys_present)) # scene-inertia + active_prev = hit + injected_ever |= hit + return injected_ever + + +def translate(model, base, key_env, user): + t, _, _ = rb.call_provider({"name": model, "base_url": base, "model": model, + "api_key_env": key_env, "max_tokens": 8000, + **({"temperature": 0} if "grok" in model else {})}, SYSTEM, user, timeout=200) + return t or "" + + +def main(): + res = {"present": PRESENT, "hard_chunk_len": len(HARD)} + print(f"ТРУДНЫЙ чанк: {len(HARD)} знаков, present entities ({len(PRESENT)}): {PRESENT}\n") + + # ===== L2: детерминированный RECALL-curve (без LLM) ===== + print("=== L2 · RECALL матчера (детерминированный, без LLM) ===") + # 3 чанка, где точный матч промахивается: trad-вариант, alias, местоименный (sticky) + recall_chunks = [ + "趙太爺同錢老爺在未莊很有威。王癞胡也常來。", # trad + alias 王癞胡(=王胡) + "他慢慢站起来,看了看四周。", # местоименный — 王胡 из sticky + ] + keys = ["赵太爷", "钱", "未庄", "王胡"] + configs = [("точный", {}), ("+норм", {"norm": True}), + ("+alias", {"norm": True, "alias": True}), + ("+sticky", {"norm": True, "alias": True, "sticky": True})] + l2 = {} + for name, kw in configs: + got = recall_config(recall_chunks, keys, **kw) + l2[name] = sorted(got) + print(f" {name:<9} recall {len(got)}/{len(keys)} = {len(got)/len(keys):.2f} инъектировано: {sorted(got)}") + res["L2_recall"] = l2 + + # ===== L1 + L3: adherence + re-ask на трудном чанке (LLM) ===== + print("\n=== L1 (лемма-post-check) + L3 (re-ask) на трудном чанке ===") + res["models"] = {} + for model, base, kenv in [("deepseek-v4-flash", "https://api.deepseek.com/v1", "DEEPSEEK_API_KEY"), + ("grok-4.3", "https://api.x.ai/v1", "XAI_API_KEY")]: + out = translate(model, base, kenv, GLOSS + "\n\nФРАГМЕНТ:\n" + HARD) + if not out: + print(f"\n[{model}] пустой ответ — пропуск"); continue + lem = lemmas_of(out) + strict = [z for z in PRESENT if match_strict(out, z)] + lemma = [z for z in PRESENT if match_lemma(out, z, lem)] + false_flags = [z for z in lemma if z not in strict] # L1 чинит именно их + real_miss = [z for z in PRESENT if z not in lemma] # настоящие провалы adherence + print(f"\n[{model}] строгий-регэксп {len(strict)}/{len(PRESENT)}={len(strict)/len(PRESENT):.2f}" + f" | L1 лемма-post-check {len(lemma)}/{len(PRESENT)}={len(lemma)/len(PRESENT):.2f}") + print(f" L1 убрал ЛОЖНЫХ флагов: {len(false_flags)} {false_flags}") + print(f" РЕАЛЬНЫЕ промахи adherence (по лемме): {real_miss}") + rec = {"strict": len(strict)/len(PRESENT), "lemma": len(lemma)/len(PRESENT), + "false_flags": false_flags, "real_miss": real_miss} + # L3: re-ask на реальные промахи + if real_miss: + terms = "; ".join(f"{z} = {ENT[z][0]}" for z in real_miss) + out2 = translate(model, base, kenv, GLOSS + "\n\nФРАГМЕНТ:\n" + HARD + + f"\n\nВАЖНО: обязательно используй утверждённые формы: {terms}. Перепиши перевод с ними.") + lem2 = lemmas_of(out2) + lemma2 = [z for z in PRESENT if match_lemma(out2, z, lem2)] + print(f" L3 re-ask по {real_miss}: adherence {len(lemma)/len(PRESENT):.2f} → {len(lemma2)/len(PRESENT):.2f}") + rec["after_reask"] = len(lemma2)/len(PRESENT) + rec["still_missing"] = [z for z in PRESENT if z not in lemma2] + else: + print(" L3 re-ask не нужен (реальных промахов нет)") + rec["after_reask"] = rec["lemma"] + res["models"][model] = rec + + OUT.write_text(json.dumps(res, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\nsaved → {OUT}") + print("\nЧитается: L2 — рекол точного матча растёт с норм/alias/sticky; L1 — сколько ложных\n" + "флагов убирает лемматизация (риск research/13 §2); L3 — сколько adherence добивает re-ask.") + + +if __name__ == "__main__": + main() diff --git a/eval/adaptive_probe.py b/eval/adaptive_probe.py new file mode 100644 index 0000000..e3874fa --- /dev/null +++ b/eval/adaptive_probe.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""Constraint probe for the ADAPTIVE-MEMORY research (docs/research/14-adaptive-memory.md). + +Question it answers empirically (mandate: «проверяй заблокированность методов пробой, +а не по памяти» — ADAPTIVE_MEMORY_RESEARCH_PROMPT §Жёсткий контур п.1): what LOGIT-LEVEL +control do our closed flagship providers actually expose in 2026? This bounds which +"learns-during-the-book" mechanisms are runnable on the flagship TRANSLATOR: + + logit-level lever needs from the API available on flagship? + ------------------------------------------------------------------------------------ + kNN-MT (Khandelwal 2021) DECODER HIDDEN STATES per step NO API exposes this + constrained/beam decoding beam access / token masking logit_bias at best + output-distribution reading per-token logprobs <- THIS we probe live + hard steering logit_bias honored <- THIS we probe live + +So even the BEST case (logprobs + logit_bias both honored) does NOT unblock kNN-MT: the +datastore key in kNN-MT is the decoder's context representation, which none of these APIs +return. This probe measures the ceiling (logprobs/logit_bias), and the ceiling is still +below what kNN-MT needs — a doubly-closed door. Local decoders (llama.cpp/vLLM) DO expose +logprobs/logits by design (n_probs / prompt_logprobs), so local kNN-MT is physically +possible; that half is asserted from the runtimes' documented APIs, not reprobed here (the +stand GPU is held by the polygon's resident model — do not evict, exp03/§coordination). + +Cheap by design: ~1 short call per provider. Reuses eval/.env + eval/providers.json quirks. +Usage: eval/.venv/bin/python eval/adaptive_probe.py +Output: stdout table + eval/data/adaptive_probe.json +""" +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +OUT = ROOT / "data" / "adaptive_probe.json" + + +def load_env() -> None: + for cand in (ROOT / ".env", Path("~/.textmachine.env").expanduser()): + if not cand.exists(): + continue + for line in cand.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + if v.strip(): + os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) + return + + +load_env() + +# The models we actually probe. Includes the REAL translator/editor stack models +# (deepseek-v4-flash draft, grok-4.3 editor, gemini-3.1-pro premium) plus a clean +# non-reasoning path per family to give logprobs its best chance to appear. +PROBES = [ + {"name": "deepseek-v4-flash", "base": "https://api.deepseek.com/v1", + "model": "deepseek-v4-flash", "key": "DEEPSEEK_API_KEY", "max": 200}, + {"name": "grok-non-reasoning", "base": "https://api.x.ai/v1", + "model": "grok-4.20-0309-non-reasoning", "key": "XAI_API_KEY", "max": 32}, + {"name": "grok-4.3(editor)", "base": "https://api.x.ai/v1", + "model": "grok-4.3", "key": "XAI_API_KEY", "max": 200}, + {"name": "glm-4.5-air", "base": "https://api.z.ai/api/paas/v4", + "model": "glm-4.5-air", "key": "ZAI_API_KEY", "max": 64, + "extra": {"thinking": {"type": "disabled"}}}, + {"name": "gemini-2.5-flash", "base": "https://generativelanguage.googleapis.com/v1beta/openai", + "model": "gemini-2.5-flash", "key": "GEMINI_API_KEY", "max": 64, + "extra": {"extra_body": {"google": {"thinking_config": {"thinking_budget": 0}}}}}, + {"name": "gemini-3.1-pro(premium)", "base": "https://generativelanguage.googleapis.com/v1beta/openai", + "model": "gemini-3.1-pro-preview", "key": "GEMINI_API_KEY", "max": 8000}, + {"name": "gpt-5-mini", "base": "https://api.openai.com/v1", + "model": "gpt-5-mini", "key": "OPENAI_API_KEY", "max": 8000, "reasoning": True}, +] + + +def one_call(p: dict, with_logprobs: bool, with_logit_bias: bool): + """Return dict describing what the provider did with logprobs / logit_bias.""" + key = os.environ.get(p["key"], "") + if not key: + return {"skip": f"no key {p['key']}"} + body = { + "model": p["model"], + "messages": [{"role": "user", "content": "Reply with exactly the single word: ok"}], + "max_tokens": p["max"], + } + if p.get("reasoning"): + body["max_completion_tokens"] = body.pop("max_tokens") + body["reasoning_effort"] = "minimal" + else: + body["temperature"] = 0.0 + body.update(p.get("extra", {})) + if with_logprobs: + body["logprobs"] = True + body["top_logprobs"] = 5 + if with_logit_bias: + # bias an arbitrary token id hard; we only check acceptance, not effect. + body["logit_bias"] = {"1734": -100} + req = urllib.request.Request( + p["base"].rstrip("/") + "/chat/completions", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}, + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + return {"http_error": e.code, "detail": e.read().decode(errors="replace")[:300].replace("\n", " ")} + except Exception as e: + return {"transport_error": f"{type(e).__name__}: {e}"} + ch = (data.get("choices") or [{}])[0] + lp = ch.get("logprobs") + # logprobs "present" = non-null object carrying per-token content entries + has_lp = bool(lp) and bool((lp or {}).get("content")) + sample = None + if has_lp: + first = lp["content"][0] + sample = {"token": first.get("token"), + "logprob": round(first.get("logprob", 0.0), 3), + "n_top": len(first.get("top_logprobs", []))} + return { + "http": 200, + "content": (ch.get("message", {}).get("content") or "")[:40], + "finish": ch.get("finish_reason"), + "logprobs_present": has_lp, + "logprobs_sample": sample, + "usage": {k: data.get("usage", {}).get(k) for k in ("prompt_tokens", "completion_tokens")}, + } + + +def main(): + results = [] + print(f"{'provider':<26} {'logprobs?':<11} {'logit_bias':<12} sample / note") + print("-" * 88) + for p in PROBES: + r_lp = one_call(p, with_logprobs=True, with_logit_bias=False) + r_lb = one_call(p, with_logprobs=False, with_logit_bias=True) + rec = {"provider": p["name"], "model": p["model"], "logprobs": r_lp, "logit_bias": r_lb} + results.append(rec) + + if "skip" in r_lp: + print(f"{p['name']:<26} {'SKIP':<11} {'':<12} {r_lp['skip']}") + continue + if r_lp.get("http") == 200: + lp_verdict = "YES" if r_lp["logprobs_present"] else "no(200)" + elif "http_error" in r_lp: + lp_verdict = f"HTTP{r_lp['http_error']}" + else: + lp_verdict = "ERR" + # logit_bias: 200 = accepted (not rejected); HTTP4xx = rejected + if r_lb.get("http") == 200: + lb_verdict = "accepted" + elif "http_error" in r_lb: + lb_verdict = f"HTTP{r_lb['http_error']}" + else: + lb_verdict = "ERR" + note = "" + if r_lp.get("logprobs_sample"): + s = r_lp["logprobs_sample"] + note = f"tok={s['token']!r} lp={s['logprob']} top{s['n_top']}" + elif "detail" in r_lp: + note = r_lp["detail"][:60] + elif r_lp.get("http") == 200 and not r_lp["logprobs_present"]: + note = f"finish={r_lp.get('finish')} content={r_lp.get('content')!r}" + print(f"{p['name']:<26} {lp_verdict:<11} {lb_verdict:<12} {note}") + + OUT.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\nsaved -> {OUT}") + print("\nReading: 'logprobs YES' = output distribution readable (still NOT decoder hidden\n" + "states → kNN-MT still blocked). 'logit_bias accepted' = crude token steering only.") + + +if __name__ == "__main__": + main() diff --git a/eval/adaptive_reask.py b/eval/adaptive_reask.py new file mode 100644 index 0000000..cd21c08 --- /dev/null +++ b/eval/adaptive_reask.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Диагностика + демонстрация ДЕТЕРМИНИРОВАННОГО рычага коэффициента канон-консистентности +(follow-up к adaptive_incontext.py по запросу владельца: «раз не идём в гибрид — долбить +детерминированную сторону»). + +Два вопроса: + 1. ДИАГНОЗ: промахи в arm B_glossary (напр. 王胡) — это РЕАЛЬНЫЙ провал adherence + (модель не использовала утв. форму) или АРТЕФАКТ моего accept-регэкспа (напр. `Ван\b` + не ловит склонённое «Вана/Вану»)? Печатаем реальный вывод + два матча (строгий \b vs + склонение-толерантный стем). + 2. РЫЧАГ: post-check находит промах → точечный re-ask («используй утв. формы X,Y») → + повторный перевод → коэффициент до/после. Это verifier-gated single-pass correction + (research/14 §6, M2) — тот самый детерминированный рычаг, а не дообучение. + +deepseek-v4-flash, тот же чанк 阿Q. INDICATIVE (малый N). Usage: eval/.venv/bin/python eval/adaptive_reask.py +""" +from __future__ import annotations +import json, re, sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import refusal_bench as rb + +ROOT = Path(__file__).resolve().parent +SRC = ROOT / "data" / "samples" / "zh" / "luxun-ah-q-ch5-9.txt" +CHUNK = SRC.read_text(encoding="utf-8")[6360:6360 + 650] + +# canonical dst + СТРОГИЙ (\b, как в adaptive_incontext) и СКЛОНЕНИЕ-ТОЛЕРАНТНЫЙ стем-матч +ENT = { + "阿Q": ("А-кью", r"А[-\s]?кью", r"А[-\s]?кью"), + "未庄": ("Вэйчжуан", r"Вэйчжуан", r"Вэйчжуан"), + "赵太爷": ("почтенный Чжао", r"Чжао", r"Чжао"), + "王胡": ("Бородатый Ван", r"Ван\b", r"Ван[аоуые]?\b|Бород"), + "小D": ("Маленький Дэн", r"Дэн", r"Дэн"), + "假洋鬼子": ("Поддельный заморский чёрт", r"заморск\w+ чёрт|поддельн", r"заморск|поддельн|чёрт"), + "吴妈": ("У-ма", r"У[-\s]?ма", r"У[-\s]?ма"), + "邹七嫂": ("тётушка Цзоу Седьмая", r"Цзоу", r"Цзоу"), +} +SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент на русский язык. " + "Сохрани все реплики и детали; стиль — живой литературный русский. Выведи ТОЛЬКО перевод.") +GLOSS = "ГЛОССАРИЙ (используй эти утверждённые переводы последовательно):\n" + \ + "\n".join(f"{z} → {v[0]}" for z, v in ENT.items()) +PRESENT = [z for z in ENT if z in CHUNK] + + +def score(out, strict): + idx = 1 if strict else 2 + return [z for z in PRESENT if re.search(ENT[z][idx], out)] + + +def translate(user): + t, err, _ = rb.call_provider({"name": "deepseek", "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-v4-flash", "api_key_env": "DEEPSEEK_API_KEY", + "max_tokens": 8000}, SYSTEM, user, timeout=180) + return t or "" + + +def main(): + print(f"present entities ({len(PRESENT)}): {PRESENT}\n") + for rep in range(2): + print(f"================= REP {rep+1} =================") + out = translate(GLOSS + "\n\nФРАГМЕНТ:\n" + CHUNK) + strict, tol = score(out, True), score(out, False) + print(f"[B_glossary] строгий-матч {len(strict)}/8={len(strict)/8:.3f} | склонение-толерантный {len(tol)}/8={len(tol)/8:.3f}") + print(f" промах(строгий): {[z for z in PRESENT if z not in strict]}") + print(f" промах(толерантный, = РЕАЛЬНЫЕ провалы adherence): {[z for z in PRESENT if z not in tol]}") + # показать фактический рендер спорных сущностей + for z in ["王胡", "假洋鬼子", "吴妈"]: + can = ENT[z][0] + frag = re.findall(r".{0,4}(?:Ван|Бород|заморск|поддельн|У-ма|У ма|Ху|Хо)\w*.{0,6}", out) + print(f" {z}→канон «{can}»: фрагменты вывода {frag[:3]}") + # РЫЧАГ: re-ask по реальным промахам + miss = [z for z in PRESENT if z not in tol] + if miss: + terms = "; ".join(f"{z} = {ENT[z][0]}" for z in miss) + reask = (GLOSS + "\n\nФРАГМЕНТ:\n" + CHUNK + + f"\n\nВАЖНО: в переводе ОБЯЗАТЕЛЬНО используй утверждённые формы: {terms}. Перепиши перевод, применив их.") + out2 = translate(reask) + tol2 = score(out2, False) + print(f" → post-check re-ask по {miss}: {len(tol)}/8 → {len(tol2)}/8 (толерантный)") + else: + print(" → промахов (толерантных) нет — re-ask не нужен") + print() + + +if __name__ == "__main__": + main()